17-安全
JWT签名中的非对称加密具体是如何防篡改的?
原始问法:
- JWT签名中的非对称加密具体是如何防篡改的?
来源题目:
SRC-17-156-514
面试先答
JWT(JSON Web Token)使用非对称加密防篡改的核心机制是"私钥签名、公钥验签"。JWT由三部分组成:Header(头部)、Payload(载荷)、Signature(签名)。签名过程是将 Header 和 Payload 经 Base64Url 编码后拼接,使用私钥对拼接串进行 RSA 或 ECDSA 加密运算,生成签名值。验证时,服务端用公钥对签名进行解密,将解密结果与原始编码串的哈希值比对,若一致则证明报文未被篡改且来源可信。非对称加密的优势在于:公钥可以分发给任何验证方而不泄露签名能力,私钥仅保留在签发方,确保了签名的唯一性和不可否认性。
核心结论
- 非对称加密防篡改的本质是"私钥签名 + 公钥验签"的数学不对称性
- JWT 的签名覆盖 Header 和 Payload 两部分的编码结果,任何篡改都会导致验签失败
- 公钥可安全分发,实现了分布式系统中多节点独立验证而无需共享密钥
1. 是什么
JWT 签名中的非对称加密,指使用一对密钥(公钥/私钥)来保证令牌完整性的技术方案。常见算法包括 RS256(RSA + SHA-256)、ES256(ECDSA + SHA-256)、PS256(RSA-PSS + SHA-256)。
JWT 三部分结构:
- Header:声明类型(JWT)和签名算法(如 RS256),Base64Url 编码
- Payload:承载用户标识、权限、过期时间等声明(Claims),Base64Url 编码
- Signature:对
Base64(Header) + "." + Base64(Payload)的加密签名结果
2. 为什么需要它
对称加密(如 HS256)使用同一密钥进行签名和验证,在分布式场景下存在两个核心问题:
- 密钥共享风险:所有微服务节点都需持有同一密钥,任何节点泄露则全系统崩溃
- 密钥分发困难:新增服务节点需要安全地传递密钥,增加了运维复杂度
非对称加密解决了上述矛盾:公钥可随意分发用于验签,私钥仅在鉴权中心保存用于签发,实现了"谁签发、谁负责,谁验证、无需密钥"的信任模型。
3. 底层原理与完整流程
签名流程(签发方):
- 将 Header JSON(如
{"alg":"RS256","typ":"JWT"})进行 Base64Url 编码 - 将 Payload JSON(如
{"sub":"user123","exp":1700000000})进行 Base64Url 编码 - 拼接两部分:
signing_input = base64(header) + "." + base64(payload) - 使用 SHA-256 对 signing_input 计算哈希值
- 使用私钥对哈希值进行 RSA/ECDSA 加密运算,生成 signature
- 拼接最终 JWT:
base64(header) + "." + base64(payload) + "." + signature
验签流程(验证方):
- 按
.分割 JWT 为三部分 - 对前两部分重新拼接得到 signing_input
- 使用相同哈希算法计算 signing_input 的哈希值
- 使用公钥对 signature 进行解密,得到原始哈希值
- 比较两个哈希值,一致则通过,不一致则拒绝
威胁模型与攻击路径:
| 攻击类型 | 防护边界 | 防御机制 |
|---|---|---|
| Payload 篡改(改权限、改过期) | 签名覆盖完整性 | 验签时哈希比对失败 |
| Header 篡改(改算法为 none) | 服务端硬编码算法 | 不接受 alg:none,固定验签算法 |
| 重放攻击 | 时效性+一次性令牌 | 设置 exp 过期、使用 jti 唯一标识 |
| 私钥泄露 | 密钥管理 | 使用 HSM(硬件安全模块)存储密钥 |
4. 怎么使用
// Maven 依赖:jjwt
// <dependency>
// <groupId>io.jsonwebtoken</groupId>
// <artifactId>jjwt-api</artifactId>
// <version>0.12.5</version>
// </dependency>
public class Jwt asymmetricExample {
// 加载密钥对(实际生产应从密钥管理系统获取)
public static KeyPair loadKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
return kpg.generateKeyPair();
}
// 使用私钥签发 JWT
public static String generateJwt(KeyPair keyPair) {
return Jwts.builder()
.setHeaderParam("alg", "RS256")
.setSubject("user-123")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600_000))
.claim("role", "ADMIN")
.signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256)
.compact();
}
// 使用公钥验证 JWT
public static Claims validateJwt(String jwt, PublicKey publicKey) {
return Jwts.parserBuilder()
.setSigningKey(publicKey)
.requireAudience(null)
.build()
.parseClaimsJws(jwt)
.getBody();
}
}
5. 适用场景
- 微服务架构:多个网关和服务节点需要独立验证令牌,不想共享密钥
- 第三方授权(OAuth2):授权服务器签发令牌,资源服务器独立验证
- 分布式 SSO:多个系统需要统一认证但不信任彼此的密钥
- JWT 令牌刷新:Access Token 由网关验证,Refresh Token 由鉴权中心验证
6. 不适用场景与替代方案
- 性能极致要求:非对称加密比对称慢 10-100 倍,高频验签场景可考虑 HS256 + 密钥分发服务
- 单体内系统:单体应用无分布式需求,HS256 足够且更简单
- 需要撤销能力:JWT 本身难以实现令牌撤销,需配合黑名单机制(见后续题目)
7. 优缺点与技术取舍
| 维度 | 非对称(RS256/ES256) | 对称(HS256) |
|---|---|---|
| 安全性 | 高(私钥隔离) | 中(密钥共享风险) |
| 性能 | 较低(10-100x 慢) | 高 |
| 扩展性 | 好(公钥可分发) | 差(密钥需同步) |
| 实现复杂度 | 较高 | 低 |
| 适用规模 | 分布式/多系统 | 单体/少量节点 |
8. 常见问题及解决方案
Q1:如何防止算法混淆攻击(Algorithm Confusion Attack)?
攻击场景:攻击者修改 Header 中 alg 为 none,删除签名部分,服务端若未强制指定算法则直接信任。
解决方案:
// 强制指定验签算法,不接受 Header 中的 alg 声明
Jwts.parserBuilder()
.setSigningKey(publicKey)
.require("alg", "RS256") // 硬编码要求 RS256
.build();
Q2:RS256 和 ES256 如何选择?
ES256 基于椭圆曲线密码学,在同等安全强度下密钥更短(256-bit EC ≈ 3072-bit RSA),签名更小,性能在移动端更优。RS256 兼容性更好,生态更成熟。
Q3:密钥轮换如何实现?
签发方定期生成新密钥对,旧公钥保留一段时间用于验证旧令牌。通过 kid(Key ID)Header 标识,验证方根据 kid 选择对应公钥。
9. 版本差异与实现边界
- Java JWT 库(jjwt):0.12.x 版本 API 变更较大,
parseClaimsJws替代了旧的parseClaimsJwt - Spring Security OAuth2 Resource Server:从 5.2.x 开始支持 JWT 非对称验签,通过
spring-security-oauth2-resource-server模块配置 - JDK 8 内置
java.security.Signature支持 RSA/ECDSA 签名;JDK 17 新增 EdDSA(Ed25519)支持 - 不同语言的 JWT 库对非对称算法支持程度不同,需注意跨平台兼容性
10. 常见追问
- 追问1:如果我修改了 Payload 中的
role字段为ADMIN,验签能发现吗?——能,因为签名覆盖了 Payload 的哈希值,任何单 bit 篡改都会导致哈希不匹配。 - 追问2:非对称加密能否防重放?——不能直接防重放。非对称加密只保证完整性和来源可信度,重放防护需配合
exp(过期时间)、nbf(生效时间)、jti(唯一标识+黑名单)等机制。 - 追问3:ES256 比 RS256 快多少?——在服务端验证场景,ES256 验签比 RS256 快约 2-5 倍;但 RS256 签名生成比 ES256 慢。实际影响取决于签发/验证的比例。
- 追问4:为什么不用普通加密(DES、AES)而要用签名?——加密是可逆的,需要密钥解密密文;签名是不可逆的,验证方只需公钥验证完整性,不需要(也不能)还原原文。
11. 易错点
❌ 错误:JWT 使用非对称加密对 Payload 内容进行了加密
✅ 正确:JWT 的 Payload 是 Base64Url 编码(编码不是加密),任何人都可以解码查看。非对称加密仅作用于签名部分,Payload 本身是明文的。敏感信息不应放入 JWT Payload。
❌ 错误:非对称加密比对称加密更安全,应始终使用
✅ 正确:非对称加密在密钥分发场景下更安全,但计算开销大。单体内对称加密足够安全且性能更好。
❌ 错误:验证方需要持有私钥才能验签
✅ 正确:验证方只需要公钥。私钥仅由签发方(认证中心)持有。
一句话总结
JWT 通过"私钥对 Header+Payload 的哈希进行非对称签名,公钥验签比对哈希一致性"的机制,在分布式系统中实现了无需共享密钥的完整性校验和来源认证。
JWT令牌被窃取后该如何处理?了解重放攻击吗?
原始问法:
- JWT令牌被窃取后该如何处理?了解重放攻击吗?
来源题目:
SRC-17-156-515
面试先答
JWT 令牌被窃取后,传统无状态架构下的应对手段有限,但可以通过"黑名单机制 + 令牌短期有效 + 刷新令牌分离 + 绑定客户端特征"的组合策略来降低风险。重放攻击是指攻击者窃取合法令牌后重复使用以获取服务的行为。应对重放攻击的核心思路是:让令牌"用一次就失效"或"仅在特定时间窗口/特定上下文中有效"。实际生产中通常将 Access Token 有效期设为 5-15 分钟,配合 Redis 黑名单实现强制失效,同时引入 Refresh Token 做令牌续期,形成多层防护体系。
核心结论
- JWT 天生无状态,被窃取后无法"撤销",需借助外部存储实现黑名单
- 重放攻击的核心防御是"时间窗口限制 + 一次性使用 + 上下文绑定"
- 最佳实践是短有效期 Access Token + 长有效期 Refresh Token + Redis 黑名单的组合策略
1. 是什么
令牌窃取场景:攻击者通过 XSS、网络嗅探、中间人攻击、日志泄露等方式获取用户的 JWT 令牌。
重放攻击(Replay Attack):攻击者使用窃取的合法令牌,在有效期内重复向服务端发起请求,冒充受害者身份。
威胁模型:
- 攻击入口:浏览器(XSS 窃取 Cookie/Header)、网络传输(HTTPS 降级)、服务端日志(令牌明文打印)
- 攻击目标:任意使用该令牌的微服务接口
- 攻击后果:数据泄露、权限滥用、资产损失
2. 为什么需要它
JWT 的核心设计哲学是"无状态"——服务端不存储令牌信息,每次请求仅靠验签判断合法性。这带来了扩展性优势,但也导致了一个致命缺陷:令牌一旦签发,在过期前无法被撤销。如果令牌被窃取,攻击者可以在有效期内无限次使用,服务端无法区分"合法持有人"和"窃取者"。
因此,必须引入额外机制来弥补 JWT 无状态的不足。
3. 底层原理与完整流程
防护路径一:Redis 黑名单(主动失效)
用户登出/令牌被窃取
↓
前端调用登出接口,将令牌 jti(JWT ID)发送到认证中心
↓
认证中心将 jti 存入 Redis SET,设置 TTL = 令牌剩余有效期
↓
各微服务网关在验签前先查 Redis 黑名单
↓
如果 jti 在黑名单中,拒绝请求
防护路径二:短期 Access Token + Refresh Token(被动缩减窗口)
登录成功 → 返回 Access Token(10min)+ Refresh Token(7d)
↓
Access Token 用于日常请求,过期后自动失效
↓
Refresh Token 用于换取新 Access Token(需校验用户凭据)
↓
即使 Access Token 被窃取,攻击窗口仅限 10 分钟
↓
Refresh Token 存储在 HttpOnly Cookie 中,前端 JS 无法读取
防护路径三:令牌绑定客户端特征(上下文绑定)
签发 JWT 时,将客户端指纹(IP+UA 哈希)写入 Payload 的 jti 或自定义 claim
↓
每次验证时比对当前请求的客户端指纹与令牌绑定的指纹
↓
如果不一致,拒绝请求(攻击者窃取令牌后从其他设备使用会失败)
纵深防御体系:
| 防御层 | 机制 | 作用 |
|---|---|---|
| 网络层 | HTTPS/TLS 1.3 | 防止传输中被嗅探 |
| 应用层 | HttpOnly Cookie 存储 Refresh Token | 防止 XSS 窃取 |
| 令牌层 | 短有效期 + jti 黑名单 | 限制攻击窗口 |
| 验证层 | 网关统一校验黑名单 | 集中防护 |
| 审计层 | 异常使用检测(异地登录、频率异常) | 事后发现 |
4. 怎么使用
Redis 黑名单实现:
// 1. 登出时将令牌加入黑名单
public void logout(String jwt) {
String jti = extractJti(jwt);
long remainingTtl = extractRemainingTtl(jwt);
if (remainingTtl > 0) {
redisTemplate.opsForSet().add("jwt:blacklist", jti);
redisTemplate.expire("jwt:blacklist", remainingTtl, TimeUnit.SECONDS);
}
}
// 2. 网关验签时检查黑名单
public Jws<Claims> validateWithBlacklist(String jwt) {
Jws<Claims> claims = jwtUtil.parse(jwt);
String jti = claims.getBody().getId();
if (redisTemplate.hasKey("jti:" + jti)) {
throw new JwtException("Token已被吊销");
}
return claims;
}
// 3. 黑名单存储优化:使用 SET 按过期时间分散存储
// 避免一个大 Key 导致 Redis 性能问题
public void addToBlacklist(String jti, long expireAt) {
long now = System.currentTimeMillis();
long ttl = expireAt - now;
if (ttl > 0) {
String key = "jwt:bl:" + jti;
redisTemplate.opsForValue().set(key, "1", ttl, TimeUnit.MILLISECONDS);
}
}
Spring Cloud Gateway 中集成黑名单校验:
@Component
public class JwtBlacklistFilter implements GlobalFilter, Ordered {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = extractToken(exchange.getRequest());
if (token != null) {
try {
Jws<Claims> claims = jwtUtil.parse(token);
String jti = claims.getBody().getId();
if (redisTemplate.hasKey("jwt:bl:" + jti)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
} catch (Exception e) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -100;
}
}
5. 适用场景
- 必须实现强制下线功能:运营需要强制用户退出(如密码泄露)
- 安全敏感系统:金融、支付、后台管理系统对令牌生命周期要求严格
- 多终端登录管理:允许用户查看已登录设备并远程下线
- 合规要求:某些行业监管要求令牌可撤销
6. 不适用场景与替代方案
- 极致性能要求:每次请求查 Redis 增加约 1-3ms 延迟。可考虑用 Redis 本地缓存(Caffeine)做二级缓存
- 高并发黑名单写入:海量登出场景下 Redis 写入成为瓶颈。可引入 Kafka 异步处理黑名单
- 替代方案:Session + Redis:如果强制下线是核心需求,传统 Session 方案天然支持,只是失去了 JWT 无状态的扩展性
7. 优缺点与技术取舍
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯 JWT(无黑名单) | 零依赖、高性能 | 无法撤销令牌 |
| JWT + Redis 黑名单 | 可撤销、分布式 | 引入外部依赖、增加延迟 |
| 短期 Token + 刷新机制 | 攻击窗口小 | 需处理并发刷新、体验管理 |
| 客户端特征绑定 | 防重放能力强 | 客户端特征不稳定(NAT、CDN) |
技术取舍原则:根据业务安全等级选择。普通互联网产品可用"短期 Token + 刷新"简化方案;金融级系统必须叠加 Redis 黑名单 + 客户端绑定。
8. 常见问题及解决方案
Q1:黑名单查询成为性能瓶颈怎么办?
方案:
- 使用 Redis Pipeline 批量查询
- 网关层引入本地缓存(Caffeine)缓存热点 jti
- 按令牌签发时间分片存储,查询时只查对应分片
- 使用布隆过滤器(Bloom Filter)做预筛选,但有误判率
Q2:Refresh Token 被窃取了怎么办?
Refresh Token 应存储在 HttpOnly + Secure + SameSite Cookie 中,前端 JS 无法读取。即使被窃取,还应:
- 绑定客户端指纹(IP+UA)
- 设置 Refresh Token 旋转(每次使用后颁发新的,旧的立即失效)
- 记录异常刷新行为并告警
Q3:如何防止令牌被用于 API 批量扫描?
- 网关层对单 IP/单令牌增加请求频率限制(Rate Limiting)
- 对敏感接口增加二次验证(短信验证码、图形验证码)
- 异常行为检测(如令牌突然请求大量不同接口)
9. 版本差异与实现边界
- Spring Security:从 5.2.x 开始提供
oauth2-resource-server模块,内置 JWT 验证和黑名单扩展点 - Redis 客户端:Lettuce(Spring Boot 2.x 默认)连接池配置需合理设置
- 分布式场景:多网关实例共享 Redis 集群,确保黑名单一致性
- JDK 安全:JDK 8u275+ 对 RSA 密钥长度限制为 ≥ 2048 位
10. 常见追问
- 追问1:为什么不在 JWT Payload 里加一个
valid字段,置为 false 就代表失效?——因为 Payload 是明文且不可变(签发后无法修改),服务端无法修改已签发的 JWT 内容,必须靠外部存储记录失效状态。 - 追问2:如果 Redis 挂了,黑名单机制失效怎么办?——网关应设计降级策略:Redis 不可用时默认放行(可用性优先)或默认拒绝(安全性优先),根据业务需求选择。同时 Redis 应配置高可用集群(哨兵或 Cluster 模式)。
- 追问3:jti(JWT ID)用什么生成?——UUID、雪花算法或随机字符串均可。要求全局唯一且不可预测。
- 追问4:黑名单的 TTL 应该设多长?——与令牌剩余有效期一致。如果令牌已过期,黑名单条目自动清理,避免 Redis 内存浪费。
11. 易错点
❌ 错误:JWT 被窃取后可以通过修改 Payload 中的过期时间来延长有效期
✅ 正确:JWT 的过期时间由签发时的私钥签名保护,任何修改都会导致验签失败。攻击者无法修改已签发 JWT 的任何部分。
❌ 错误:Refresh Token 应该存储在 localStorage 中方便使用
✅ 正确:localStorage 可被 XSS 攻击读取。Refresh Token 应存储在 HttpOnly Cookie 中,前端 JS 无法访问。
❌ 错误:只要 JWT 有签名就绝对安全
✅ 正确:JWT 签名只保证完整性和来源可信度,不保证传输安全(需 HTTPS)、不防重放(需时间窗口)、不防窃取(需黑名单/绑定)。
一句话总结
JWT 被窃取后的防护核心是"用 Redis 黑名单实现主动撤销、用短期令牌缩短攻击窗口、用 Refresh Token 隔离敏感操作"的三层组合策略,配合 HTTPS 和客户端特征绑定形成纵深防御。
JWT是无状态的,为什么不使用Session?
原始问法:
- JWT是无状态的,为什么不使用Session?
来源题目:
SRC-17-156-516
面试先答
JWT 无状态并非"不用 Session",而是"不用服务端存储会话状态"。传统 Session 在分布式环境下面临 Session 共享(粘性 Session、Session 集中存储)的挑战,而 JWT 将状态编码到令牌本身,服务端无需存储即可完成鉴权。选择 JWT 的核心理由是:水平扩展能力(任意节点可独立鉴权)、无中心依赖(不依赖 Session 存储服务)、跨服务通信便利(令牌即凭据)。但 JWT 并非银弹,在需要强制下线、细粒度权限动态变更的场景下,Session 模型反而更直接。实际生产常出现"JWT + Redis 会话状态"的混合模式。
核心结论
- JWT 的无状态性是其在分布式系统中取代 Session 的核心优势,但代价是丧失了即时撤销和动态变更的能力
- Session 适合需要服务端主动控制会话生命周期的场景,JWT 适合需要大规模水平扩展的场景
- 实际工程中两者常混合使用:JWT 做身份认证,Redis 做会话状态管理
1. 是什么
Session 认证模型:
客户端 ←→ 服务端:Session ID(Cookie)
服务端存储:Session 数据(用户信息、权限、状态)
鉴权方式:根据 Session ID 查找服务端存储的会话数据
JWT 无状态模型:
客户端 ←→ 服务端:JWT 令牌(Header.Payload.Signature)
服务端存储:无(或仅存储黑名单/刷新令牌)
鉴权方式:验证签名 + 解析 Payload 获取用户信息
关键术语:
- 有状态:服务端需要为每个会话存储数据(内存、Redis、数据库)
- 无状态:服务端不需要为会话存储数据,所有必要信息自包含在客户端提交的令牌中
2. 为什么需要它
Session 在分布式场景下的三大痛点:
| 问题 | 具体表现 | 解决方案 | 代价 |
|---|---|---|---|
| Session 同步 | 多节点间 Session 不一致 | Session 粘滞(Nginx ip_hash) | 负载不均、故障影响 |
| Session 共享 | 新节点无历史 Session | Session 集中存储(Redis) | 引入外部依赖、序列化开销 |
| Session 广播 | 登出/权限变更需通知所有节点 | Session 广播或版本号机制 | 实现复杂、延迟高 |
JWT 解决思路:将"服务端存储 + 查找"的模式,转变为"客户端携带 + 服务端验签"的模式。每个节点独立完成鉴权,无需通信,天然支持水平扩展。
3. 底层原理与完整流程
Session 认证流程:
1. 用户登录 → 服务端创建 Session 对象,分配 Session ID
2. 服务端将 Session ID 通过 Set-Cookie 返回给客户端
3. 客户端后续请求自动携带 Cookie(Session ID)
4. 服务端根据 Session ID 查找 Session 数据 → 验证用户身份和权限
5. 登出时,服务端销毁 Session 数据
JWT 无状态认证流程:
1. 用户登录 → 服务端验证凭据,签发 JWT(包含用户ID、权限、过期时间)
2. 客户端保存 JWT(localStorage 或 Cookie)
3. 后续请求在 Header 中携带 JWT:Authorization: Bearer <token>
4. 服务端验证 JWT 签名 + 检查过期时间 → 直接获取用户信息
5. 无需服务端存储,任意节点可独立完成鉴权
对比分析:
| 维度 | Session | JWT |
|---|---|---|
| 存储位置 | 服务端内存/Redis | 客户端(LocalStorage/Cookie) |
| 扩展性 | 需共享 Session | 天然支持水平扩展 |
| 可撤销性 | 直接删除 Session | 需额外黑名单机制 |
| 传输开销 | 小(仅 Session ID) | 大(完整用户信息) |
| 跨域支持 | 需配置 Cookie Domain | Header 方式天然支持 |
| 安全性 | Session ID 泄露风险 | Payload 明文风险 |
4. 怎么使用
Session 实现(Spring Boot):
@Configuration
@EnableWebSecurity
public class SessionConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
return http.build();
}
// 分布式 Session 存储:使用 Spring Session + Redis
// pom.xml 添加:spring-session-data-redis
// 配置文件:spring.session.store-type=redis
}
JWT 实现(Spring Boot + JJWT):
@Component
public class JwtTokenProvider {
@Value("${jwt.private-key}")
private PrivateKey privateKey;
@Value("${jwt.public-key}")
private PublicKey publicKey;
public String generateToken(Long userId, String roles) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("roles", roles)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 600_000))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
混合方案(推荐生产实践):
// JWT 负责身份认证,Redis 负责会话状态
@Service
public class HybridAuthService {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private RedisTemplate<String, UserSession> redisTemplate;
// 登录:签发 JWT + 初始化 Redis 会话
public AuthResponse login(LoginRequest request) {
User user = userRepository.findByUsername(request.getUsername());
// ... 密码验证
String jwt = jwtTokenProvider.generateToken(user.getId(), user.getRoles());
UserSession session = new UserSession(user.getId(), user.getRoles(), new ArrayList<>());
redisTemplate.opsForValue().set("session:" + user.getId(), session, 30, TimeUnit.MINUTES);
return new AuthResponse(jwt, session);
}
// 鉴权:JWT 验签 + Redis 会话检查
public UserSession authenticate(String jwt) {
Claims claims = jwtTokenProvider.parseToken(jwt);
Long userId = Long.parseLong(claims.getSubject());
UserSession session = redisTemplate.opsForValue().get("session:" + userId);
if (session == null) {
throw new AuthenticationException("会话已过期");
}
return session;
}
// 强制下线:清除 Redis 会话
public void forceLogout(Long userId) {
redisTemplate.delete("session:" + userId);
}
}
5. 适用场景
适合 JWT 的场景:
- 微服务/SOA 架构,需要多服务独立鉴权
- 云原生/Kubernetes 环境,服务频繁扩缩容
- 第三方 OAuth2 授权,需要跨系统身份传递
- 移动端 App + 后端 API,不依赖 Cookie
适合 Session 的场景:
- 单体应用或少量服务节点
- 需要频繁变更权限(如管理员实时调整用户权限)
- 需要强制下线功能且要求即时生效
- 对传输体积敏感的场景
6. 不适用场景与替代方案
不适合 JWT 的场景:
- 权限变更需要即时生效:JWT Payload 中的权限在过期前无法修改
- 需要服务端主动控制会话:JWT 无状态特性使得强制下线实现复杂
- 令牌体积敏感:JWT 通常 500-2000 字节,比 Session ID 大 10-40 倍
替代方案:
- 短期 JWT + 频繁刷新 + Redis 黑名单(混合模式)
- Reference Token(JWT 只存 ID,服务端存储完整信息)
- 继续使用 Session + Redis 集中存储
7. 优缺点与技术取舍
| 维度 | JWT 优点 | JWT 缺点 | Session 优点 | Session 缺点 |
|---|---|---|---|---|
| 扩展性 | 天然支持水平扩展 | — | — | 需 Session 共享 |
| 可撤销 | — | 需额外机制 | 直接删除 | — |
| 传输开销 | — | 令牌体积大 | 仅 Session ID | — |
| 跨服务 | 天然支持 | — | — | 需 Session 传递 |
| 实现复杂度 | 中等 | — | 简单 | — |
| 安全性 | 签名防篡改 | Payload 明文 | 隔离数据 | Session 泄露风险 |
| 性能 | 验签开销 | — | 数据库/Redis 查找 | — |
8. 常见问题及解决方案
Q1:JWT 中应放哪些信息?
原则:非敏感、变化少、必须的信息。
- ✅ userId、username、roles(用于鉴权)
- ✅ exp、iat(用于时效控制)
- ❌ 密码、手机号、身份证号(敏感信息)
- ❌ 频繁变更的权限列表(如需变更,用 Redis 存储)
Q2:JWT Token 存哪里?
- 推荐方案:AccessToken 存 localStorage(方便 JS 使用),Refresh Token 存 HttpOnly Cookie
- 安全提示:localStorage 可被 XSS 读取,需配合 CSP 和输入校验
- 备选方案:全部存 HttpOnly Cookie,配合 CSRF Token
Q3:混合方案中 Redis 挂了怎么办?
降级策略:
- Redis 不可用时降级为纯 JWT 验签模式(不查会话状态)
- 此时丧失强制下线和动态权限能力,但核心鉴权可用
- 使用 Spring Cloud Sentinel 或 Resilience4j 实现自动降级
9. 版本差异与实现边界
- Java 8:
javax.servlet.http.HttpSession是 Servlet 规范的一部分 - Spring Security 6(Spring Boot 3):默认推荐 JWT 无状态认证,Session 创建策略默认为
STATELESS - JDK 17:
java.security包中签名算法性能优化 - Servlet 6.0(Spring Boot 3):Session 相关 API 无重大变更
- 云原生环境:Kubernetes 中 Session 存储通常使用 Redis Sentinel 或 Redis Cluster
10. 常见追问
- 追问1:JWT 比 Session 快还是慢?——单次鉴训 JWT 略慢(需验签),但在大规模分布式环境中,Session 需要的 Redis 查询/同步开销可能更大。在 10 万 QPS 以内的场景,两者性能差异可忽略。
- 追问2:如果所有服务都用 JWT,某个服务想让某个用户"临时"失效权限怎么办?——方案一:在 Redis 中存储权限版本号,JWT Payload 中带版本号,验证时比对版本号;方案二:使用 Reference Token,服务端存储完整会话数据。
- 追问3:Session 和 JWT 能否结合使用?——可以。常见混合模式:网关层用 Session 做登录态管理,微服务间用 JWT 做服务间认证。或如上文的 JWT+Redis 会话状态模式。
- 追问4:OAuth2 的 Access Token 是 JWT 吗?——不一定。OAuth2 规范允许 Access Token 是不透明字符串(Reference Token)或 JWT(JWT Access Token)。现代实现(如 Keycloak、Auth0)通常使用 JWT 格式。
11. 易错点
❌ 错误:JWT 完全不需要服务端存储
✅ 正确:纯 JWT 确实不需要,但生产环境几乎都会配合 Redis 存储黑名单、会话状态或刷新令牌。说"不需要存储"是简化说法。
❌ 错误:JWT 可以完全替代 Session
✅ 正确:它们是不同场景的选择。需要服务端主动控制会话生命周期时,Session 模型更合适。混合使用是更常见的工程实践。
❌ 错误:JWT 的无状态意味着它比 Session 更先进
✅ 正确:JWT 和 Session 是两种认证模型,各有适用场景。选择应基于业务需求(是否需要强制下线、权限变更频率、系统规模),而非技术偏好。
一句话总结
JWT 以"自包含令牌 + 无服务端状态"的设计,牺牲了即时撤销和动态变更能力,换取了分布式环境下的无限水平扩展能力,是大规模微服务架构的首选认证方案。
为什么选择JWT+Filter实现用户登录认证,而非拦截器?有哪些考量?
原始问法:
- 为什么选择JWT+Filter实现用户登录认证,而非拦截器?有哪些考量?
来源题目:
SRC-17-156-517
面试先答
选择 JWT + Filter 而非拦截器(Interceptor)的核心理由是 Filter 在 Spring 体系中的执行位置更早、更通用。Filter 基于 Servlet 规范,在 DispatcherServlet 之前执行,可作用于所有请求(包括静态资源和非 Spring MVC 请求),且不依赖 Spring 容器初始化完成。拦截器基于 Spring MVC,仅在 Controller 请求生效,无法拦截静态资源,且必须等待 Spring 上下文就绪。对于 JWT 认证这种需要覆盖全链路的横切关注点,Filter 是更合适的选择。此外,Filter 可与 Spring Cloud Gateway、Jakarta EE 等非 Spring MVC 环境无缝集成。
核心结论
- Filter 基于 Servlet 规范,执行时机早于拦截器,覆盖范围更广
- 拦截器基于 Spring MVC,仅对 Controller 请求生效,且依赖 Spring 上下文
- JWT 认证通常需要覆盖所有 HTTP 请求(含静态资源、健康检查等),Filter 更匹配这一需求
1. 是什么
Filter(过滤器):Servlet 规范定义的组件,在请求到达 Servlet/JSP 之前或响应返回客户端之前执行。基于责任链模式,支持在 web.xml 或 @WebFilter 中注册。
Interceptor(拦截器):Spring MVC 定义的组件,在 Controller 方法调用前后、视图渲染前后执行。实现 HandlerInterceptor 接口,通过 WebMvcConfigurer 注册。
执行顺序:
客户端请求
→ Filter.doFilter() ← Servlet 规范层(更外层)
→ DispatcherServlet
→ Interceptor.preHandle() ← Spring MVC 层
→ Controller 方法
→ Interceptor.postHandle()
→ 视图渲染
→ Interceptor.afterCompletion()
→ Filter.doFilter()(响应过滤)
→ 客户端响应
2. 为什么需要它
JWT 认证的需求特性:
- 覆盖所有 URL 路径(包括静态资源、WebSocket、API 文档)
- 支持异步请求和跨域预检(CORS)
- 不依赖 Spring MVC 是否可用
- 可在网关层(Spring Cloud Gateway)复用
拦截器的局限性:
- 仅对 Controller 请求生效,静态资源请求不经过 DispatcherServlet
- 在非 Spring MVC 项目(如纯 Servlet、Spring Cloud Gateway WebFlux)中无法使用
- 拦截器在 Spring 上下文初始化完成后才能注册,启动期间的请求可能被遗漏
- 无法处理 CORS 预检请求(OPTIONS 方法)
Filter 的优势:
- 所有 HTTP 请求都会经过 Filter 链,无遗漏
- Servlet 容器启动时即可初始化,不依赖 Spring 上下文
- 可在 Spring Boot、传统 WAR、微服务网关等任何 Servlet 环境中使用
- 支持异步请求(
AsyncContext)
3. 底层原理与完整流程
Filter 认证流程:
1. 客户端发送请求(携带 JWT 或 Cookie)
2. Servlet 容器调用 Filter 链
3. JwtAuthenticationFilter.doFilter():
a. 检查请求路径是否在白名单(登录接口、公开资源)
b. 提取 JWT(从 Header Authorization 或 Cookie)
c. 验证 JWT 签名和有效期
d. 检查 Redis 黑名单(如已实现)
e. 将用户信息存入 SecurityContextHolder 或请求属性
f. 传递到下一个 Filter 或 Servlet
4. Controller 正常执行业务逻辑
5. 响应返回前可进行 Token 续期
拦截器认证流程的局限:
1. 客户端发送请求
2. DispatcherServlet 接收
3. HandlerMapping 匹配 Controller
4. Interceptor.preHandle() 执行(仅此时可拦截)
5. 如果请求的是静态资源,DispatcherServlet 直接处理,不会进入拦截器
6. 如果是 OPTIONS 预检请求,Spring MVC 默认处理,不经过拦截器(需特殊配置)
4. 怎么使用
JWT 认证 Filter 实现:
@Component
@Order(1)
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserDetailsService userDetailsService;
private static final Set<String> WHITELIST = Set.of(
"/api/auth/login",
"/api/auth/register",
"/public/**",
"/actuator/**",
"/swagger-ui/**",
"/v3/api-docs/**"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestPath = request.getRequestURI();
if (isWhitelisted(requestPath)) {
filterChain.doFilter(request, response);
return;
}
String token = extractToken(request);
if (token == null || !jwtTokenProvider.validateToken(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"Unauthorized\",\"message\":\"Invalid or missing token\"}");
return;
}
Claims claims = jwtTokenProvider.parseToken(token);
String username = claims.getSubject();
List<String> roles = claims.get("roles", List.class);
request.setAttribute("currentUser", username);
request.setAttribute("currentRoles", roles);
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
String cookieToken = WebUtils.getCookie(request, "JWT_TOKEN")
.map(Cookie::getValue).orElse(null);
return cookieToken;
}
private boolean isWhitelisted(String path) {
return WHITELIST.stream().anyMatch(path::startsWith);
}
}
使用拦截器实现同样功能(对比示例):
// 拦截器只能拦截 Controller 请求,无法覆盖静态资源
public class JwtAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 如果 handler 不是 Controller 方法(如静态资源),此方法根本不会被调用
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 只能处理 Controller 级别的认证
String token = extractToken(request);
// ... 验证逻辑
return true;
}
}
// 注册拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtAuthInterceptor jwtAuthInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtAuthInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/**", "/public/**");
// 无法覆盖 /static/**, /webjars/** 等静态资源
}
}
Filter 与 Spring Security 整合(推荐):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/public/**", "/actuator/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
5. 适用场景
- 全链路认证:需要覆盖所有 URL 路径(API、静态资源、WebSocket)
- 跨框架复用:同一认证逻辑需在 Spring MVC、WebFlux、网关中复用
- 高性能要求:Filter 比拦截器少一次 DispatcherServlet 分发
- CORS 处理:需要在认证前处理跨域预检请求
- 文件上传/下载:大文件传输需要在 Filter 层控制访问
6. 不适用场景与替代方案
- 只需要 Controller 级别的细粒度控制:拦截器配合
@PreAuthorize更灵活 - 需要 AOP 式方法级权限控制:
@PreAuthorize("hasRole('ADMIN')")在方法上注解更直观 - 简单的单体应用:拦截器配置更简洁,Filter 显得"过重"
替代方案:
- Spring Security 过滤器链(本质上是一组 Filter 的集合,功能最强大)
- AOP 切面(
@Aspect+@Around),在方法级别做权限控制
7. 优缺点与技术取舍
| 维度 | Filter | Interceptor |
|---|---|---|
| 规范基础 | Servlet 规范(JSR 369) | Spring MVC 框架 |
| 执行时机 | DispatcherServlet 之前 | Controller 方法之前 |
| 覆盖范围 | 所有 HTTP 请求 | 仅 Controller 请求 |
| 依赖 | 仅 Servlet API | Spring MVC 上下文 |
| 灵活性 | 通用但配置较繁琐 | Spring 友好,注解驱动 |
| 与 Spring Security 集成 | 原生支持 | 需额外适配 |
| 性能 | 略高(少一次分发) | 略低(经过 DispatcherServlet) |
选择决策树:
是否需要覆盖所有请求?(含静态资源)
├── 是 → 使用 Filter 或 Spring Security
└── 否 → 是否需要方法级权限控制?
├── 是 → 使用 Spring AOP + @PreAuthorize
└── 否 → 使用 Interceptor
8. 常见问题及解决方案
Q1:Filter 和 Interceptor 能否同时使用?
可以。典型组合是 Filter 做 JWT 基础认证(快速失败),Interceptor 做业务级别的权限校验。注意执行顺序:Filter 先于 Interceptor。
Q2:Filter 中如何获取 Spring Bean?
通过 @Component 注解让 Spring 管理 Filter,或通过 WebApplicationContextUtils 手动获取:
// 方式一:Spring 管理(推荐)
@Component
public class JwtFilter extends OncePerRequestFilter { ... }
// 方式二:手动获取
public class JwtFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
ServletContext servletContext = filterConfig.getServletContext();
WebApplicationContext ctx = WebApplicationContextUtils
.getRequiredWebApplicationContext(servletContext);
this.jwtTokenProvider = ctx.getBean(JwtTokenProvider.class);
}
}
Q3:为什么用 OncePerRequestFilter 而不是直接实现 Filter?
OncePerRequestFilter 是 Spring 提供的抽象类,确保 Filter 在一次请求中只执行一次。在请求转发(forward)或包含(include)场景下,避免同一 Filter 被重复执行。
9. 版本差异与实现边界
- Servlet 3.0+:支持异步 Filter(
AsyncContext),可在 Filter 中进行异步处理 - Spring Boot 2.x:
OncePerRequestFilter默认为异步友好 - Spring Boot 3.x(Jakarta EE 9+):
jakarta.servlet替代javax.servlet - Spring Cloud Gateway:基于 WebFlux,使用
GatewayFilter和GlobalFilter(非 Servlet Filter),但设计思想相同 - Spring Security 6:推荐通过
SecurityFilterChain配置过滤器链,而非手动注册
10. 常见追问
- 追问1:Filter 能否阻止请求到达 DispatcherServlet?——可以。在
doFilter中不调用filterChain.doFilter()即可中断请求链,直接返回响应。 - 追问2:多个 Filter 的执行顺序如何控制?——通过
@Order注解或FilterRegistrationBean.setOrder()方法。数值越小越先执行。 - 追问3:拦截器和 Filter 在 Spring 中哪个更早执行?——Filter 先执行(Servlet 规范层),然后是 DispatcherServlet,然后是 Interceptor(Spring MVC 层)。
- 追问4:能不能用 Filter 实现方法级权限控制?——理论上可以(通过反射获取 HandlerMethod),但不推荐。Filter 设计用于 URL 级别的粗粒度过滤,方法级权限应由 AOP 或 Spring Security 的
@PreAuthorize处理。
11. 易错点
❌ 错误:Filter 能拦截所有请求,包括转发和包含的请求
✅ 正确:
OncePerRequestFilter确保一次请求只执行一次。如果需要对转发请求也执行,应使用普通Filter并在 web.xml 中配置dispatcher为FORWARD。❌ 错误:Interceptor 是线程安全的(因为 Spring Bean 默认单例)
✅ 正确:Interceptor 是单例的,对每个请求复用。如果有状态需要存储,应使用请求级别的对象(如
request.setAttribute()),而非实例变量。❌ 错误:JWT 认证必须用 Filter
✅ 正确:技术上拦截器也可以实现,但 Filter 更合适。最终选择应根据项目架构和安全需求决定。
一句话总结
Filter 基于 Servlet 规范在请求处理链最外层工作,覆盖范围和通用性均优于拦截器,是实现 JWT 全链路认证的最优选择。
JWT存在哪些安全风险,如何在分布式网关层做防护?
原始问法:
- JWT存在哪些安全风险,如何在分布式网关层做防护?
来源题目:
SRC-17-156-518
面试先答
JWT 的主要安全风险包括:算法混淆攻击(将 RS256 改为 none)、令牌泄露(Payload 明文 + 传输层未加密)、重放攻击(令牌被窃取后重复使用)、密钥泄露(对称密钥共享或非对称私钥泄露)、跨站请求伪造(Cookie 方式存储时)。在分布式网关层的防护策略是"统一入口、集中校验、纵深防御":网关作为所有请求的唯一入口,强制 HTTPS、硬编码验签算法、检查黑名单、绑定客户端特征、实施频率限制,将安全逻辑从业务服务中剥离,形成统一的安全屏障。
核心结论
- JWT 的安全风险可分为令牌本身风险(算法、密钥、有效期)和传输/存储风险(泄露、重放、CSRF)
- 网关层是实施统一防护的最佳位置,避免每个微服务重复实现安全逻辑
- 纵深防御:传输层(HTTPS)+ 令牌层(签名+黑名单)+ 应用层(频率限制+异常检测)
1. 是什么
JWT 安全风险清单:
| 风险类型 | 威胁描述 | 攻击路径 |
|---|---|---|
| 算法混淆 | 攻击者将 alg 改为 none,绕过签名验证 | 修改 JWT Header,删除 Signature 部分 |
| 弱密钥攻击 | 对称加密密钥过短或可预测 | 暴力破解 HS256 密钥 |
| 重放攻击 | 窃取令牌后在有效期内重复使用 | 网络嗅探、XSS 窃取令牌 |
| Payload 泄露 | 敏感信息明文存储在 Payload | 解码 JWT Header/Payload |
| 密钥泄露 | 签名私钥/对称密钥被窃取 | 服务器入侵、内部人员泄露 |
| CSRF 攻击 | 利用浏览器自动携带 Cookie 发送请求 | 构造恶意页面诱导用户操作 |
| 令牌劫持 | 中间人攻击窃取令牌 | 未使用 HTTPS 传输 |
2. 为什么需要它
分布式系统中,每个微服务独立实现安全防护会导致:
- 安全策略不一致(有的服务查黑名单,有的不查)
- 维护成本高(安全升级需每个服务逐一修改)
- 容易遗漏(某些服务忘记加安全校验)
- 难以统一审计(安全事件分散在各服务日志中)
网关层集中防护的优势:
- 统一入口,所有请求先经过安全检查
- 策略集中管理,变更一次全局生效
- 能力复用(无需每个服务重复实现)
- 可观测性好(安全事件集中记录)
3. 底层原理与完整流程
网关层防护架构:
客户端
│
▼
[HTTPS/TLS 终端] ← 传输层加密,防止中间人
│
▼
[CORS 预检处理] ← 处理跨域,限制来源
│
▼
[CSRF 防护] ← 检查 Origin/Referer,验证 CSRF Token
│
▼
[JWT 提取与验签] ← 硬编码算法,拒绝 alg:none
│
▼
[黑名单检查] ← Redis 分布式黑名单
│
▼
[客户端特征校验] ← IP+UA 哈希比对
│
▼
[频率限制] ← 令牌/IP 级别的请求频率控制
│
▼
[权限校验] ← 基于角色/资源的访问控制
│
▼
[路由转发] → 后端微服务
算法混淆攻击防护流程:
网关接收 JWT
│
▼
解析 Header 部分
│
▼
检查 alg 字段:
├── 如果是 none → 直接拒绝
├── 如果非白名单算法 → 直接拒绝
└── 如果在白名单 → 使用对应公钥/密钥验签
│
▼
验签成功 → 继续后续检查
验签失败 → 拒绝请求
4. 怎么使用
Spring Cloud Gateway 安全过滤器链配置:
@Configuration
public class GatewaySecurityConfig {
@Bean
public JwtAuthGlobalFilter jwtAuthGlobalFilter() {
return new JwtAuthGlobalFilter();
}
@Bean
public RateLimitGlobalFilter rateLimitGlobalFilter() {
return new RateLimitGlobalFilter();
}
}
@Component
public class JwtAuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private JwtTokenValidator tokenValidator;
@Autowired
private BlacklistService blacklistService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (isPublicPath(path)) {
return chain.filter(exchange);
}
String token = extractToken(exchange.getRequest());
if (token == null) {
return unauthorized(exchange, "Missing token");
}
try {
JwtClaims claims = tokenValidator.validate(token);
String jti = claims.getJti();
if (blacklistService.isBlacklisted(jti)) {
return unauthorized(exchange, "Token revoked");
}
String clientFingerprint = extractFingerprint(exchange.getRequest());
if (!claims.getFingerprint().equals(clientFingerprint)) {
return unauthorized(exchange, "Client mismatch");
}
exchange.getAttributes().put("userId", claims.getUserId());
exchange.getAttributes().put("roles", claims.getRoles());
return chain.filter(exchange);
} catch (JwtValidationException e) {
return unauthorized(exchange, e.getMessage());
}
}
@Override
public int getOrder() {
return -100;
}
}
密钥安全管理:
# application.yml - 密钥配置(不要硬编码在代码中)
jwt:
# 公钥可配置化,方便轮换
public-key: ${JWT_PUBLIC_KEY:classpath:keys/public.pem}
# 私钥仅在认证中心保存,网关只配公钥
# 网关不需要私钥,只做验签
algorithms: RS256, ES256 # 白名单算法
reject-alg-none: true # 强制拒绝 none 算法
频率限制配置:
@Configuration
public class RateLimitConfig {
@Bean
public KeyResolver ipKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
@Bean
public KeyResolver userKeyResolver() {
return exchange -> {
String userId = (String) exchange.getAttributes().get("userId");
return Mono.justOrEmpty(userId);
};
}
}
@Component
public class RateLimitGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String clientIp = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
String token = extractToken(exchange.getRequest());
// IP 级别的频率限制(无令牌时)
String ipKey = "ratelimit:ip:" + clientIp;
Long ipCount = redisTemplate.opsForValue().increment(ipKey);
redisTemplate.expire(ipKey, 60, TimeUnit.SECONDS);
if (ipCount > 100) {
return tooManyRequests(exchange);
}
// 用户级别的频率限制(有令牌时)
if (token != null) {
String userKey = "ratelimit:user:" + token;
Long userCount = redisTemplate.opsForValue().increment(userKey);
redisTemplate.expire(userKey, 60, TimeUnit.SECONDS);
if (userCount > 1000) {
return tooManyRequests(exchange);
}
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -90;
}
}
5. 适用场景
- 所有需要认证的 API 网关:微服务架构、云原生应用
- 多租户系统:不同租户有不同安全策略
- 高安全要求系统:金融、支付、医疗等
- 对外 API 服务:开放平台、第三方集成
6. 不适用场景与替代方案
- 单体简单应用:Spring Security + 内存 Session 足够
- 需要复杂业务逻辑:网关层只做基础校验,业务权限由下游服务处理
- 实时性要求极高:网关增加验签延迟(约 1-5ms)
7. 优缺点与技术取舍
| 防护层 | 实现成本 | 安全提升 | 性能影响 |
|---|---|---|---|
| HTTPS/TLS | 中(证书管理) | 高(防窃听) | 低(TLS 握手 ~5ms) |
| 硬编码验签算法 | 低 | 高(防算法混淆) | 无 |
| Redis 黑名单 | 中 | 中(可撤销) | 中(~1-3ms 查询) |
| 客户端绑定 | 低 | 中(防重放) | 低(哈希计算) |
| 频率限制 | 中 | 中(防暴力) | 低(Redis INCR) |
| 异常检测 | 高 | 高(主动防御) | 中(异步分析) |
8. 常见问题及解决方案
Q1:如何防止 JWT 被用于 Open Redirect 攻击?
在登录回调中验证 redirect_uri 白名单,不允许任意 URL 跳转。JWT 本身不直接导致 Open Redirect,但 OAuth2 流程中常见此风险。
Q2:网关层验签性能如何优化?
- 使用本地缓存(Caffeine)缓存最近验证过的 JWT 结果
- 使用 RS256 而非 ES256(验证更快)
- 批量验证(如果有批量请求场景)
- 公钥预加载到内存,避免每次读取文件
Q3:如何检测异常使用行为?
- 记录令牌使用日志(时间、IP、接口)
- 检测异常模式:同一令牌短时间大量请求、异地登录、频率突增
- 设置告警规则,触发人工审核或自动冻结
- 使用 Spring Boot Actuator + Prometheus + Grafana 监控
9. 版本差异与实现边界
- Spring Cloud Gateway 3.x:支持
GlobalFilter和GatewayFilter两种扩展点 - Redis 7:客户端缓存优化,
INCR命令性能提升 - TLS 1.3:握手简化,性能提升约 30%
- Java 17:
java.security包性能优化,RS256 验签更快
10. 常见追问
- 追问1:为什么不直接在每个微服务里验签,而要网关集中验签?——可以在每个服务验签,但会导致安全策略不一致、维护成本高。网关集中验签是更优的工程实践,尤其是策略变更时只需改网关。
- 追问2:如果网关被绕过,直接访问后端服务怎么办?——后端服务应独立校验 JWT(纵深防御),网关只是第一道防线。网络层应配置防火墙,禁止外部直接访问后端服务端口。
- 追问3:Payload 中能否存用户密码?——绝对不能。Payload 是 Base64Url 编码,不是加密,任何人都可以解码查看。敏感信息永远不要放在 JWT Payload 中。
- 追问4:如何处理密钥轮换期间的令牌验证?——签发新密钥时,保留旧公钥用于验证旧令牌。通过
kid(Key ID)Header 标识,网关根据kid选择对应公钥。
11. 易错点
❌ 错误:JWT 使用了非对称加密,所以 Payload 是加密的
✅ 正确:Payload 是 Base64Url 编码的明文,非对称加密仅用于签名部分。
❌ 错误:只要用了 HTTPS,JWT 就绝对安全
✅ 正确:HTTPS 保护传输过程,但不防止令牌被窃取后的重放攻击、也不防止 Payload 中的敏感信息泄露。
❌ 错误:网关层校验后,后端服务就不需要再校验了
✅ 正确:后端服务仍需做基础验签(纵深防御),但可以信任网关已完成的黑名单检查。
一句话总结
JWT 安全防护的核心是"网关统一入口 + 硬编码算法白名单 + 分布式黑名单 + 客户端特征绑定 + 频率限制"的纵深防御体系,将安全逻辑收敛到网关层,实现集中化、可观测的安全管控。
JWT无状态架构下,如何实现分布式会话的强制下线、拉黑功能?
原始问法:
- JWT无状态架构下,如何实现分布式会话的强制下线、拉黑功能?
来源题目:
SRC-17-156-519
面试先答
JWT 无状态架构下实现强制下线和拉黑的核心思路是"通过外部存储记录令牌失效状态,在鉴权环节检查并拒绝"。具体方案是:为每个 JWT 分配唯一 ID(jti),在 Redis 中维护"已失效 jti"的集合,设置 TTL 与令牌有效期一致。强制下线时将当前有效令牌的 jti 加入黑名单;拉黑用户时将该用户的所有活跃 jti 都加入黑名单。各微服务在验证 JWT 时统一查询 Redis 黑名单,若命中则拒绝。对于超大规模场景,可引入 Kafka 异步处理黑名单事件,配合 Redis 本地缓存降低查询压力。
核心结论
- JWT 无状态特性决定了必须引入外部存储(Redis)来记录令牌失效状态
- 强制下线作用于单个令牌,拉黑作用于整个用户(所有活跃令牌)
- 高性能实现需要 Redis 集群 + 本地缓存 + 异步通知的组合策略
1. 是什么
强制下线:用户主动登出或管理员强制将用户踢下线,使其当前持有的 JWT 立即失效。
拉黑:管理员或系统安全策略将某个用户标记为不可用,该用户的所有 JWT 立即失效,无法再获取新令牌。
威胁模型:
- 强制下线场景:用户修改密码后旧令牌应立即失效、用户在公共设备忘记登出
- 拉黑场景:检测到异常行为(暴力破解、异地登录)、账号被盗、风控触发
2. 为什么需要它
纯 JWT 无状态模型的局限:
- 签发方在令牌过期前无法撤销已签发的令牌
- 如果令牌被窃取,攻击者可以在有效期内无限使用
- 用户权限变更(如被降权、被禁用)在令牌过期前不会生效
因此,必须引入"令牌吊销列表"(Token Revocation List,TRL)机制。
3. 底层原理与完整流程
强制下线流程:
1. 客户端请求登出:POST /api/auth/logout
2. 服务端提取当前 JWT 的 jti 和剩余有效期
3. 将 jti 存入 Redis 黑名单:SET jti:bl:{jti} 1 EX {remaining_ttl}
4. 客户端删除本地存储的 JWT
5. 后续请求携带该 JWT 时,网关/服务查 Redis 命中黑名单 → 拒绝
拉黑用户流程:
1. 管理员操作:POST /api/admin/users/{userId}/ban
2. 服务端查询该用户所有活跃会话的 jti 列表
3. 将所有 jti 批量加入 Redis 黑名单
4. 同时在用户表中标记 status=BANNED
5. 该用户后续登录时验证用户状态 → 拒绝
6. 已签发的 JWT 命中黑名单 → 拒绝
存储优化策略:
| 策略 | 说明 | 目的 |
|---|---|---|
| 按过期时间分片 | 按令牌过期时间分桶存储 | 方便批量过期清理 |
| 布隆过滤器 | 先查布隆过滤器再查 Redis | 降低 Redis 查询压力 |
| 本地缓存 | 网关用 Caffeine 缓存热点 jti | 减少 Redis 网络请求 |
| 异步写入 | 登出事件经 Kafka 异步处理 | 削峰填谷 |
| 批量操作 | Pipeline/MULTI 批量写 Redis | 降低 RTT 开销 |
4. 怎么使用
完整实现方案:
@Service
public class TokenRevocationService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
private static final String BL_PREFIX = "jti:bl:";
private static final String USER_ACTIVE_PREFIX = "user:active-jti:";
// 强制下线:单个令牌
public void revokeToken(String jti, long expireAtMs) {
long ttlSeconds = (expireAtMs - System.currentTimeMillis()) / 1000;
if (ttlSeconds > 0) {
redisTemplate.opsForValue()
.set(BL_PREFIX + jti, "1", ttlSeconds, TimeUnit.SECONDS);
}
}
// 记录活跃令牌(登录时调用)
public void trackActiveToken(String userId, String jti, long expireAtMs) {
long ttlSeconds = (expireAtMs - System.currentTimeMillis()) / 1000;
if (ttlSeconds > 0) {
String key = USER_ACTIVE_PREFIX + userId;
redisTemplate.opsForSet().add(key, jti);
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
}
}
// 拉黑用户:所有令牌
public void banUser(String userId) {
Set<String> activeJtis = redisTemplate.opsForSet()
.members(USER_ACTIVE_PREFIX + userId);
if (activeJtis != null && !activeJtis.isEmpty()) {
Map<String, String> batchOps = new HashMap<>();
for (String jti : activeJtis) {
batchOps.put(BL_PREFIX + jti, "1");
}
// 使用 Pipeline 批量写入
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Map.Entry<String, String> entry : batchOps.entrySet()) {
connection.stringSet()
.set(entry.getKey().getBytes(), entry.getValue().getBytes());
}
return null;
});
}
// 标记用户状态
jdbcTemplate.update("UPDATE users SET status = 'BANNED' WHERE id = ?", userId);
}
// 检查令牌是否被吊销
public boolean isRevoked(String jti) {
return Boolean.TRUE.equals(redisTemplate.hasKey(BL_PREFIX + jti));
}
}
网关过滤器中集成:
@Component
public class RevocationCheckFilter extends OncePerRequestFilter {
@Autowired
private TokenRevocationService revocationService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(token)
.getBody();
String jti = claims.getId();
if (revocationService.isRevoked(jti)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Token revoked\"}");
return;
}
} catch (ExpiredJwtException e) {
// 已过期,直接拒绝
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Token expired\"}");
return;
}
}
filterChain.doFilter(request, response);
}
}
高可用优化:布隆过滤器 + Redis:
@Component
public class BloomFilterRevocationChecker {
private BloomFilter<String> bloomFilter;
@PostConstruct
public void init() {
this.bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), 1000000, 0.01);
// 启动时从 Redis 加载已吊销的 jti 到布隆过滤器
loadExistingRevocations();
}
public boolean isRevoked(String jti) {
if (bloomFilter.mightContain(jti)) {
// 布隆过滤器命中,再查 Redis 确认(降低误判)
return Boolean.TRUE.equals(redisTemplate.hasKey("jti:bl:" + jti));
}
return false;
}
public void addRevocation(String jti) {
bloomFilter.put(jti);
redisTemplate.opsForValue().set("jti:bl:" + jti, "1", ttl, TimeUnit.SECONDS);
}
}
5. 适用场景
- 需要强制下线功能的系统:几乎所有生产系统都需要
- 安全敏感行业:金融、支付、政府系统必须支持
- 多终端登录管理:允许用户查看已登录设备并远程下线
- 账号安全事件响应:密码泄露后强制所有设备重新登录
6. 不适用场景与替代方案
- 极致性能要求(超过 50K QPS):Redis 查询成为瓶颈。可考虑 Reference Token 模式(服务端存储完整会话)
- 无 Redis 环境:可用数据库或文件存储,但性能较差
- 简单的 Demo 项目:纯 JWT 即可,无需黑名单
替代方案:
- 短期令牌 + 刷新机制:缩短令牌有效期,减少黑名单需求
- Reference Token:JWT 只含 ID,完整信息存服务端(退化为有状态)
- 版本号机制:用户权限变更时递增版本号,JWT 中包含版本号,验证时比对
7. 优缺点与技术取舍
| 维度 | JWT + Redis 黑名单 | 纯 JWT | Session + Redis |
|---|---|---|---|
| 可撤销性 | 支持 | 不支持 | 支持 |
| 扩展性 | 好 | 最好 | 中 |
| 性能 | 中(Redis 查询) | 最好(无外部依赖) | 中 |
| 实现复杂度 | 中 | 低 | 低 |
| 存储开销 | 中(黑名单 TTL) | 无 | 高(全量会话) |
8. 常见问题及解决方案
Q1:黑名单 Redis 挂了怎么办?
降级策略:
- 方案 A:放行所有请求(可用性优先),但丧失撤销能力
- 方案 B:拒绝所有请求(安全性优先),服务不可用
- 方案 C:使用本地缓存(Caffeine)存储最近的吊销记录作为降级
推荐方案 C,结合 Sentinel 熔断:
@Configuration
public class RevocationDegradationConfig {
@Bean
public Cache<String, Boolean> revocationCache() {
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
}
}
Q2:黑名单内存占用如何控制?
- 每个 jti 条目仅存储 "1"(1 字节),加上 Redis key 开销约 50 字节
- 100 万活跃用户 × 平均 2 个令牌 ≈ 200 万条目 ≈ 100MB
- 设置 TTL 自动过期,令牌过期后黑名单条目自动清理
- 使用 Redis Cluster 分片存储,横向扩展
Q3:如何确保黑名单的实时性?
- Redis 单节点操作约 0.1-0.5ms,延迟极低
- 从"吊销操作"到"网关拒绝"的延迟通常 < 5ms
- 对实时性要求极高的场景,可在网关本地维护热点黑名单
9. 版本差异与实现边界
- Redis 6.0+:支持客户端缓存(Client-side Caching),可大幅降低网络请求
- Redisson:提供分布式 RBloomFilter 实现
- Spring Boot 3.x:
StringRedisTemplate自动配置优化 - 云原生环境:Redis 托管服务(如 AWS ElastiCache、Azure Cache for Redis)高可用
10. 常见追问
- 追问1:为什么不在 JWT 中加一个
banned字段?——JWT 是不可变的,签发后无法修改 Payload。banned状态只能在服务端存储。 - 追问2:如果用户同时在 5 个设备登录,拉黑时如何确保 5 个都失效?——每个登录生成独立 jti,拉黑时遍历用户所有活跃 jti 并批量加入黑名单。
- 追问3:jti 用什么生成?——UUID、雪花 ID、随机字符串都可以。要求全局唯一且不可预测。推荐使用
UUID.randomUUID().toString()。 - 追问4:能否用 JWT 的
jti+ Redis SET NX 实现一次性令牌?——可以。将 jti 存入 Redis 并设置 TTL,使用后通过GETSET原子操作标记为已使用。
11. 易错点
❌ 错误:JWT 天生不支持撤销,所以没法做强制下线
✅ 正确:JWT 本身确实不支持,但可以通过 Redis 黑名单等外部机制实现。这是工程上的折中方案。
❌ 错误:黑名单只需要存 jti 字符串即可
✅ 正确:还需要设置 TTL 与令牌剩余有效期一致,避免 Redis 内存泄漏。
❌ 错误:吊销操作需要同步通知所有服务
✅ 正确:所有服务共享同一个 Redis,写入即全局可见。无需通知。
一句话总结
JWT 无状态架构下的强制下线和拉黑,本质是用 Redis 维护"令牌吊销列表",通过 jti 唯一标识令牌、TTL 自动清理、集中查询实现分布式环境下的即时撤销能力。
分布式会话场景下,如何实现JWT的细粒度权限刷新?
原始问法:
- 分布式会话场景下,如何实现JWT的细粒度权限刷新?
来源题目:
SRC-17-156-520
面试先答
JWT 的细粒度权限刷新核心矛盾在于:JWT 签发后 Payload 不可变,但权限可能随时变更(如管理员调整角色、用户权限升级)。解决方案是"版本号机制 + 按需刷新":在 JWT Payload 中增加 version 字段,Redis 中存储该用户的当前权限版本号。每次鉴权时比对版本号,不一致则拒绝并要求刷新令牌。同时,将细粒度权限(菜单级、按钮级)存储在 Redis 而非 JWT 中,JWT 仅携带粗粒度角色信息。权限变更时只需更新 Redis 中的权限数据和版本号,下次鉴权自动生效。
核心结论
- JWT 中只放粗粒度角色(Role),细粒度权限(Permission)存 Redis
- 版本号机制实现权限变更的即时生效
- 刷新策略:版本不匹配时自动刷新令牌,避免用户感知
1. 是什么
细粒度权限:精确到菜单、按钮、接口级别的权限控制。例如"用户管理页面的编辑按钮"、"订单接口的删除操作"。
权限刷新场景:
- 管理员给用户分配新角色
- 用户权限被回收(降权)
- 权限规则变更(新增/修改权限点)
- 租户权限调整(多租户场景)
核心矛盾:JWT Payload 中的权限在签发后无法修改,直到令牌过期。但权限变更需要即时生效。
2. 为什么需要它
如果将所有权限信息放入 JWT Payload:
- 权限变更时,已签发的 JWT 中仍是旧权限
- 需要强制所有用户重新登录(体验差)
- 频繁变更的权限不适合放入不可变的 JWT
将权限拆分到 Redis 的优势:
- 权限变更即时生效
- JWT 体积保持稳定
- 支持更丰富的权限模型(动态权限、数据权限)
- 权限数据可独立缓存和优化
3. 底层原理与完整流程
权限分层架构:
JWT Payload(粗粒度,不可变):
- userId: 123
- roles: ["ROLE_ADMIN", "ROLE_USER"]
- version: 5
- exp: 1700000000
Redis(细粒度,可变):
- perm:user:123:version → 5(当前权限版本)
- perm:user:123:permissions → ["user:edit", "user:delete", "order:create"]
- perm:user:123:data_scope → "DEPT_1,DEPT_2"(数据权限)
权限校验流程:
请求到达 → 提取 JWT → 验签
│
▼
解析 Payload → 获取 userId, roles, version
│
▼
查 Redis 获取当前权限版本号:perm:user:{userId}:version
│
├── JWT version == Redis version → 权限未变更 → 放行
└── JWT version != Redis version → 权限已变更 → 触发刷新
│
▼
重新签发 JWT(使用最新权限版本和角色)
│
▼
读取 Redis 中的细粒度权限列表
│
▼
比对当前请求所需权限 → 通过则放行
│
▼
在响应 Header 中返回新的 JWT(前端自动替换)
权限刷新触发方式:
| 方式 | 实现 | 适用场景 |
|---|---|---|
| 版本号比对 | 每次鉴权比对版本号 | 实时性要求高 |
| 时间窗口刷新 | 每隔 N 分钟自动刷新 | 权限变更不频繁 |
| 权限主动推送 | WebSocket 推送权限变更通知 | 需要客户端即时感知 |
| 懒加载刷新 | 首次访问权限敏感接口时刷新 | 优化性能 |
4. 怎么使用
权限版本管理:
@Service
public class PermissionVersionService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String VERSION_PREFIX = "perm:user:";
private static final String PERM_PREFIX = "perm:user:";
// 获取用户当前版本号
public int getCurrentVersion(String userId) {
String version = redisTemplate.opsForValue()
.get(VERSION_PREFIX + userId + ":version");
return version == null ? 0 : Integer.parseInt(version);
}
// 权限变更时递增版本号
public void incrementVersion(String userId) {
redisTemplate.opsForValue()
.increment(VERSION_PREFIX + userId + ":version");
}
// 更新用户权限列表
public void updatePermissions(String userId, Set<String> permissions) {
String key = PERM_PREFIX + userId + ":permissions";
redisTemplate.delete(key);
redisTemplate.opsForSet().add(key, permissions.toArray(new String[0]));
incrementVersion(userId);
}
// 获取用户权限列表
public Set<String> getPermissions(String userId) {
return redisTemplate.opsForSet()
.members(PERM_PREFIX + userId + ":permissions");
}
// 检查是否有权限
public boolean hasPermission(String userId, String permission) {
return redisTemplate.opsForSet()
.isMember(PERM_PREFIX + userId + ":permissions", permission);
}
}
细粒度权限校验过滤器:
@Component
public class PermissionFilter extends OncePerRequestFilter {
@Autowired
private PermissionVersionService permissionService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}
Claims claims = jwtTokenProvider.parseToken(token);
String userId = claims.getSubject();
int jwtVersion = claims.get("version", Integer.class);
int currentVersion = permissionService.getCurrentVersion(userId);
if (jwtVersion < currentVersion) {
String newToken = jwtTokenProvider.refreshToken(token, currentVersion);
response.setHeader("X-New-Token", newToken);
claims = jwtTokenProvider.parseToken(newToken);
}
String requiredPermission = extractRequiredPermission(request);
if (requiredPermission != null) {
if (!permissionService.hasPermission(userId, requiredPermission)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("{\"error\":\"Permission denied\"}");
return;
}
}
filterChain.doFilter(request, response);
}
private String extractRequiredPermission(HttpServletRequest request) {
String method = request.getMethod();
String uri = request.getRequestURI();
return method + ":" + uri;
}
}
基于注解的细粒度权限控制:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
String[] value();
Logical logical() default Logical.AND;
}
@Aspect
@Component
public class PermissionAspect {
@Autowired
private PermissionVersionService permissionService;
@Around("@annotation(requirePermission)")
public Object checkPermission(ProceedingJoinPoint pjp,
RequirePermission requirePermission) throws Throwable {
String userId = SecurityContextHolder.getCurrentUserId();
Set<String> userPermissions = permissionService.getPermissions(userId);
boolean hasPermission = false;
if (requirePermission.logical() == Logical.AND) {
hasPermission = userPermissions.containsAll(
Arrays.asList(requirePermission.value()));
} else {
hasPermission = Arrays.stream(requirePermission.value())
.anyMatch(userPermissions::contains);
}
if (!hasPermission) {
throw new ForbiddenException("权限不足");
}
return pjp.proceed();
}
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
@RequirePermission("user:list")
public PageResult<User> listUsers() { ... }
@PutMapping("/{id}")
@RequirePermission("user:edit")
public void updateUser(@PathVariable Long id) { ... }
@DeleteMapping("/{id}")
@RequirePermission("user:delete")
public void deleteUser(@PathVariable Long id) { ... }
}
5. 适用场景
- 后台管理系统:菜单级、按钮级权限控制
- 多租户 SaaS 系统:租户级别的权限隔离
- 金融/支付系统:操作级别的细粒度授权
- 电商后台:商品管理、订单管理等不同模块的权限控制
- 需要权限即时生效的系统:权限变更后立即生效
6. 不适用场景与替代方案
- 简单角色权限系统:只有角色级别控制,无需细粒度
- 实时性要求不高:权限变更允许延迟到令牌过期
- 无 Redis 环境:可用数据库但性能差
替代方案:
- 权限随 JWT 刷新:客户端定时刷新 JWT,不需要版本号机制
- Spring Security + 数据库:传统 RBAC 方案,权限存数据库
- OPA(Open Policy Agent):独立的权限决策引擎
7. 优缺点与技术取舍
| 维度 | 版本号 + Redis 方案 | 纯 JWT(权限在 Payload) |
|---|---|---|
| 变更即时性 | 即时生效 | 需等令牌过期 |
| JWT 体积 | 小(只存角色) | 大(存所有权限) |
| 性能 | 每次鉴权需查 Redis | 仅需验签 |
| 复杂度 | 较高 | 低 |
| 灵活性 | 高(支持动态权限) | 低(静态权限) |
8. 常见问题及解决方案
Q1:版本号频繁更新是否会导致 JWT 频繁刷新?
不会。版本号只在权限变更时递增(通常低频),不会每次请求都刷新。大部分请求的版本号比对是命中的(权限未变更)。
Q2:Redis 权限数据如何与数据库保持同步?
策略:
- 双写策略:权限变更时同时更新数据库和 Redis
- Cache-Aside:Redis 作为缓存,数据库为持久化存储
- 事件驱动:权限变更事件通过 Kafka 通知所有服务刷新缓存
Q3:如何防止用户通过修改 JWT 中的 version 字段绕过权限?
JWT 签名保护了 version 字段不被篡改。修改 version 会导致验签失败,攻击者无法伪造更高的版本号。
9. 版本差异与实现边界
- Spring Security 6:
@PreAuthorize("hasAuthority('user:edit')")支持方法级权限控制 - Redis 7:
SET和INCR命令性能提升 - JDK 17:
HMAC算法性能优化 - Spring Cloud Gateway 3.x:支持在网关层做统一权限校验
10. 常见追问
- 追问1:为什么不把细粒度权限也放到 JWT 中?——JWT 体积会增大(可能超过 4KB),且权限变更时需强制刷新所有用户的 JWT。将细粒度权限存 Redis 可实现变更即时生效。
- 追问2:版本号方案和直接刷新 JWT 有什么区别?——版本号方案只在权限变更时触发刷新,大部分请求直接放行。直接刷新方案每次请求都要解析权限,开销更大。
- 追问3:权限变更后,已缓存的权限数据如何失效?——使用 Redis 的 TTL 自动过期 + 版本号递增触发主动刷新。双保险机制。
- 追问4:数据权限(如"只能看本部门的数据")如何实现?——数据权限应在业务查询层实现(MyBatis-Plus 的数据权限插件),而非在认证层。认证层只负责"有没有权限访问接口"。
11. 易错点
❌ 错误:JWT 可以动态修改 Payload 中的权限
✅ 正确:JWT 签发后 Payload 不可修改。任何变更都需要重新签发。
❌ 错误:版本号方案中版本号可以被用户篡改
✅ 正确:版本号存储在 Redis 中(服务端可控),JWT 中的版本号由私钥签名保护,篡改会导致验签失败。
❌ 错误:细粒度权限应该在认证层(Filter/Interceptor)校验
✅ 正确:接口级权限在认证层校验,数据级权限在业务层(Service/Mapper)校验。分层校验更清晰。
一句话总结
JWT 细粒度权限刷新的核心是"角色放 JWT、权限放 Redis、版本号做同步"的三层架构,通过版本号比对实现权限变更的即时感知和令牌的按需刷新,在无状态扩展性和动态权限之间取得平衡。
基于AOP思想,如何自定义拦截器防止SQL注入?
原始问法:
- 基于AOP思想,如何自定义拦截器防止SQL注入?
来源题目:
SRC-17-156-521
面试先答
基于 AOP 思想防止 SQL 注入的核心思路是"在 DAO 层切面拦截所有 SQL 操作,对参数进行统一检测和过滤"。具体实现是:定义一个 @SqlInjection防护 注解标记需要防护的方法,通过 Spring AOP 切面拦截 DAO 层方法调用,获取 MyBatis 的 Mapper 参数,使用 SQL 解析器(如 Druid 的 SQLParser)对参数值进行语法检查,识别注入特征(如 --、'; DROP TABLE、UNION SELECT 等危险模式),对可疑参数进行清洗或拒绝。同时配合 MyBatis 的 #{} 参数绑定(预编译)作为基础防护,AOP 作为增强防护层。
核心结论
- 基础防护:MyBatis
#{}预编译参数绑定是防止 SQL 注入的根本手段 - 增强防护:AOP 切面在 DAO 层统一检测 SQL 参数,作为第二道防线
- 纵深防御:预编译 + AOP 检测 + 输入校验 + WAF 规则的多层防护体系
1. 是什么
SQL 注入:攻击者通过在应用的 SQL 查询中嵌入恶意代码,操纵后端数据库执行非预期操作的攻击手段。
威胁模型:
- 攻击入口:表单输入、URL 参数、Cookie、User-Agent 等
- 攻击路径:用户输入 → 应用拼接 SQL → 数据库执行 → 泄露/篡改数据
- 攻击后果:数据泄露(脱库)、数据篡改、权限提升、删除表
注入类型:
| 类型 | 示例 | 危害 |
|---|---|---|
| 联合注入 | ' UNION SELECT password FROM users-- |
数据泄露 |
| 布尔注入 | ' AND 1=1-- |
数据枚举 |
| 时间注入 | '; WAITFOR DELAY '0:0:5'-- |
数据枚举(盲注) |
| 堆叠注入 | '; DROP TABLE users-- |
数据破坏 |
| 报错注入 | ' AND EXTRACTVALUE(1,CONCAT(0x7e,@@version))-- |
数据泄露 |
2. 为什么需要它
MyBatis 预编译的局限性:
#{}确实能防止参数值被解释为 SQL 代码- 但以下场景预编译无法防护:
- 使用
${}拼接表名、列名(动态表名/排序字段) - SQL 注解中硬编码的动态拼接
- 存储过程中动态 SQL 拼接
- 原生 JDBC 拼接 SQL
- 使用
AOP 统一防护的价值:
- 集中实现,每个 DAO 方法无需重复编码
- 覆盖所有 DAO 调用路径,包括遗漏的
${}使用 - 可扩展(增加新的检测规则无需修改业务代码)
- 与业务逻辑解耦,可插拔
3. 底层原理与完整流程
AOP 防护架构:
Controller
│
▼
Service 层(业务逻辑)
│
▼
AOP 切面 @SqlInjectionGuard ← 拦截点
│
▼
├── 提取方法参数
├── SQL 解析与检测
├── 危险模式匹配
├── 参数清洗/拒绝
│
▼
MyBatis Mapper(#{}/预编译) ← 基础防护
│
▼
数据库
检测流程:
1. AOP 拦截 DAO 方法调用
2. 获取所有参数值(MyBatis 的参数 Map)
3. 遍历每个参数值:
a. 使用正则匹配常见注入特征
b. 使用 SQL 解析器解析参数值
c. 检查是否包含危险关键字(SELECT, UNION, DROP, --, ; 等)
4. 如果检测到注入:
- 记录安全日志
- 拒绝请求(抛异常)或清洗参数
5. 如果通过检测,继续执行 DAO 方法
4. 怎么使用
自定义注解标记需要防护的方法:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SqlInjectionGuard {
boolean enabled() default true;
String[] ignoreFields() default {};
}
AOP 切面实现:
@Aspect
@Component
@Slf4j
public class SqlInjectionAspect {
private static final Pattern DANGEROUS_PATTERN = Pattern.compile(
"(?i)(UNION\\s+SELECT|DROP\\s+TABLE|INSERT\\s+INTO|DELETE\\s+FROM|UPDATE\\s+\\w+\\s+SET|" +
"(--|;|--|#)|('\\s*OR\\s+'1'\\s*=\\s*'1)|('\\s*OR\\s+1\\s*=\\s*1)|" +
"(EXEC\\s+sp_|EXECUTE\\s+xp_|xp_cmdshell)|" +
"(WAITFOR\\s+DELAY|BENCHMARK\\s*\\(|SLEEP\\s*\\()|" +
"(EXTRACTVALUE|UPDATEXML|EXPOSED_KEYS)|" +
"(CHAR\\s*\\(|CONCAT\\s*\\(|0x[0-9a-fA-F]+)"
);
private static final List<String> DANGEROUS_KEYWORDS = Arrays.asList(
"select", "union", "drop", "delete", "insert", "update",
"exec", "execute", "xp_", "sp_",
"waifor", "delay", "benchmark", "sleep",
"extractvalue", "updatexml"
);
@Around("@annotation(guard) || @within(guard)")
public Object guardSql(ProceedingJoinPoint pjp,
SqlInjectionGuard guard) throws Throwable {
Object[] args = pjp.getArgs();
for (Object arg : args) {
if (arg instanceof String) {
checkStringValue((String) arg, guard);
} else if (arg instanceof Map) {
checkMapValues((Map<?, ?>) arg, guard);
} else if (arg instanceof Collection) {
checkCollectionValues((Collection<?>) arg, guard);
}
}
return pjp.proceed();
}
private void checkStringValue(String value, SqlInjectionGuard guard) {
if (value == null || value.isEmpty()) return;
if (DANGEROUS_PATTERN.matcher(value).find()) {
log.warn("SQL注入检测:检测到可疑输入: {}",
value.substring(0, Math.min(value.length(), 100)));
throw new SecurityException("检测到潜在的SQL注入攻击");
}
String lowerValue = value.toLowerCase();
for (String keyword : DANGEROUS_KEYWORDS) {
if (lowerValue.contains(keyword) && !isLegitimateUsage(value, keyword)) {
log.warn("SQL注入检测:检测到危险关键字: {} in {}",
keyword, value.substring(0, Math.min(value.length(), 100)));
throw new SecurityException("检测到潜在的SQL注入攻击");
}
}
}
private boolean isLegitimateUsage(String value, String keyword) {
// 白名单:合法的关键字使用场景
String lower = value.toLowerCase();
if (keyword.equals("select") && lower.matches("^[\\w\\s,.*]+$")) {
return true;
}
if (keyword.equals("update") && lower.matches("^\\w+\\s*=\\s*[\\w\\s]+$")) {
return true;
}
return false;
}
private void checkMapValues(Map<?, ?> map, SqlInjectionGuard guard) {
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getValue() instanceof String) {
checkStringValue((String) entry.getValue(), guard);
}
}
}
private void checkCollectionValues(Collection<?> collection, SqlInjectionGuard guard) {
for (Object item : collection) {
if (item instanceof String) {
checkStringValue((String) item, guard);
}
}
}
}
在 DAO 层使用注解:
@Mapper
public interface UserMapper {
@SqlInjectionGuard
@Select("SELECT * FROM users WHERE username = #{username}")
User findByUsername(@Param("username") String username);
@SqlInjectionGuard
@Select("SELECT * FROM users WHERE id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>" +
"#{id}" +
"</foreach>")
List<User> findByIds(@Param("ids") List<Long> ids);
@SqlInjectionGuard(ignoreFields = {"sortOrder"})
@Select("SELECT * FROM users WHERE status = #{status} ORDER BY " +
"${sortColumn} ${sortOrder}")
List<User> findAll(@Param("status") String status,
@Param("sortColumn") String sortColumn,
@Param("sortOrder") String sortOrder);
}
更完善的方案:使用 Druid SQL 解析器:
@Aspect
@Component
public class DruidSqlInjectionAspect {
@Around("execution(* com.example.mapper..*.*(..))")
public Object guardSql(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
for (Object arg : args) {
if (arg instanceof String) {
checkSqlInjection((String) arg);
}
}
return pjp.proceed();
}
private void checkSqlInjection(String sql) {
try {
List<SQLStatement> statements = SQLUtils.parseStatements(sql, "mysql");
if (statements.isEmpty()) {
return;
}
for (SQLStatement stmt : statements) {
if (stmt instanceof MySqlInsertStatement
|| stmt instanceof MySqlUpdateStatement
|| stmt instanceof MySqlDeleteStatement
|| stmt instanceof MySqlSelectStatement) {
continue;
}
log.warn("检测到可疑SQL操作: {}", stmt.getClass().getSimpleName());
throw new SecurityException("非法的SQL操作");
}
} catch (Exception e) {
if (e instanceof SecurityException) {
throw (SecurityException) e;
}
log.error("SQL解析失败", e);
throw new SecurityException("SQL格式异常");
}
}
}
5. 适用场景
- 使用
${}动态拼接 SQL 的场景:动态表名、动态排序、动态条件 - 遗留系统改造:代码中已存在大量
${}调用,短期无法全面重构 - 多数据源场景:不同 DAO 操作不同数据库,需要统一防护
- 安全合规要求:金融、政务等行业要求 SQL 注入防护审计
6. 不适用场景与替代方案
- 全部使用
#{}的新项目:预编译已足够,AOP 检测增加无谓开销 - 高性能要求(>10K TPS):正则匹配和 SQL 解析增加延迟
- 复杂 SQL 解析场景:Druid 解析器对某些复杂 SQL 可能误判
替代方案:
- MyBatis-Plus 动态表名插件:从根本上避免
${}拼接 - 参数化查询框架:如 QueryDSL、jOOQ,完全类型安全
- WAF(Web 应用防火墙):网络层检测 SQL 注入特征
- 数据库审计:MySQL Audit Plugin 监控异常 SQL
7. 优缺点与技术取舍
| 方案 | 防护能力 | 性能影响 | 实现复杂度 |
|---|---|---|---|
MyBatis #{} |
高(值绑定) | 无 | 低 |
| AOP + 正则匹配 | 中(模式匹配) | 中(~0.1ms/参数) | 中 |
| AOP + SQL 解析器 | 高(语法分析) | 中高(~1ms/SQL) | 高 |
| WAF | 中(流量特征) | 低 | 低 |
| 数据库审计 | 事后检测 | 低 | 低 |
8. 常见问题及解决方案
Q1:AOP 防护能否完全替代预编译?
不能。AOP 是增强防护,预编译(#{})才是根本。应始终优先使用 #{},AOP 作为兜底。
Q2:正则匹配的误判率如何控制?
- 使用白名单机制:允许合法关键字(如 SELECT 在查询方法中使用)
- 精细化匹配:只在特定上下文(如 WHERE 条件)检测
- 参数化配置:不同 DAO 可配置不同的检测规则
Q3:动态表名/列名如何安全实现?
使用白名单映射:
@Service
public class DynamicTableService {
private static final Map<String, String> TABLE_MAPPING = Map.of(
"user", "t_user",
"order", "t_order",
"product", "t_product"
);
public String getSafeTable(String tableName) {
String safeName = TABLE_MAPPING.get(tableName);
if (safeName == null) {
throw new IllegalArgumentException("非法表名");
}
return safeName;
}
}
9. 版本差异与实现边界
- Spring AOP:基于动态代理,方法级拦截。类内部方法调用不触发切面
- AspectJ:编译期/加载期织入,可拦截内部方法调用
- Druid SQL Parser:支持 MySQL、PostgreSQL、Oracle、SQL Server 等主流数据库
- MyBatis-Plus 3.5+:
DynamicTableNameInnerInterceptor支持动态表名 - JDK 17:正则表达式引擎性能优化
10. 常见追问
- 追问1:为什么用
@Around而不是@Before?——@Around可以控制是否继续执行目标方法,适合做"检测后拒绝"的场景。@Before只能做前置检查,无法阻止方法执行(只能抛异常)。 - 追问2:AOP 内部方法调用会触发切面吗?——不会。Spring AOP 基于动态代理,只有通过代理对象调用才会触发。类内部方法调用(
this.method())直接调用原始对象,不经过代理。解决方法:使用self.method()或ApplicationContext.getBean()。 - 追问3:除了 AOP,还有哪些方式可以防止 SQL 注入?——1. 使用 MyBatis
#{}参数化查询;2. 使用参数化存储过程;3. 使用 ORM 框架(JPA/Hibernate);4. 输入验证和白名单;5. WAF 规则;6. 最小权限原则(数据库账号权限)。 - 追问4:如何处理合法 SQL 关键字在用户输入中的场景(如搜索"SELECT 课程")?——使用白名单机制,在明确知道该字段是搜索用途时跳过检测,或允许特定上下文中的关键字。
11. 易错点
❌ 错误:AOP 可以防止所有 SQL 注入
✅ 正确:AOP 只能拦截通过 Spring 代理的 DAO 方法。如果使用原生 JDBC、类内部方法调用、或绕过 DAO 层,AOP 无法防护。
❌ 错误:用了 AOP 检测就可以放心使用
${}✅ 正确:
${}直接拼接 SQL,即使 AOP 检测通过,仍可能存在编码绕过、二阶注入等风险。应尽可能使用#{}。❌ 错误:SQL 注入只发生在用户输入参数中
✅ 正确:SQL 注入可发生在任何拼接 SQL 的地方:表头、排序字段、表名、列名、甚至从数据库读取后再拼接的数据(二阶注入)。
一句话总结
基于 AOP 的 SQL 注入防护是在 DAO 层通过切面统一检测参数特征,作为 MyBatis 预编译的补充防线,与输入验证、WAF 规则共同构成"预编译为基、AOP 为盾、多层联动"的纵深安全体系。