JWT 是无状态的——签发后无法主动失效。但业务需求是:用户改密码后旧 Token 必须失效、退出登录后 Token 立即作废、Token 被盗用后不能在其他设备使用。怎么解决这些矛盾?本文用 4 个真实项目的代码,把 JWT 安全认证的完整知识体系从头到尾讲透。
第一章:认证体系全景
1.1 纯 JWT 的局限性
JWT(JSON Web Token)的设计哲学是无状态——服务端不存储 Token,验证时只检查签名和过期时间。这带来了高性能(不需要查 Redis/DB),但也带来了致命问题:
| 问题 |
描述 |
纯JWT能解决? |
| 主动失效 |
用户退出登录后 Token 立即作废 |
❌ |
| 改密失效 |
用户修改密码后旧 Token 失效 |
❌ |
| Token 盗用 |
Token 被窃取后在其他设备使用 |
❌ |
| 强制踢出 |
管理员强制用户下线 |
❌ |
1.2 解决方案演进
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 纯JWT(无状态) │ 不够安全 ▼ JWT + Redis黑名单(Token废弃后加入黑名单) │ 黑名单无限增长 ▼ JWT + Redis白名单(只存当前有效Token) │ Token被盗仍可使用 ▼ JWT + IP绑定 + Redis白名单(Token绑定IP) │ 移动端IP变化导致掉线 ▼ JWT + 密码做密钥 + 自动续期(改密自动失效) │ ← 当前最优方案
|
1.3 三重校验架构
本文的核心方案采用三重校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ┌──────────┐ ①登录请求 ┌──────────────┐ │ 客户端 │ ──────────────────> │ Controller │ │ │ │ (login) │ │ │ <────────────────── │ │ │ │ ②返回JWT Token └──────┬───────┘ │ │ │ ③生成Token │ │ ▼ (写入IP) │ │ ┌──────────────┐ │ │ ④携带Token │ JwtUtils │ │ │ ──────────────────> │ │ │ │ └──────┬───────┘ │ ┌───────┤ │ ⑤验签名+IP │ │ 拦截器 │ <─── ⑥Redis校验 ─── ┌──────────────────┐ │ │ │ ──── ⑦放行/拒绝 ──> │ RedisTokenService │ │ └───────┘ │ (Token白名单) │ │ └──────────────────┘
|
三重校验:
- JWT 签名验证(防篡改)
- IP 绑定校验(防盗用)
- Redis 存在性校验(主动失效)
第二章:Token 生成——把 IP 写入 Claims
2.1 JwtUtils 完整实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| @Component public class JwtUtils {
@Value("${jwt.secret}") private String secret;
@Value("${jwt.expiration}") private long expiration;
public String generateToken(Long userId, String username, String tableName, String role, String ip) { Map<String, Object> claims = new HashMap<>(); claims.put("userId", userId); claims.put("username", username); claims.put("tableName", tableName); claims.put("role", role); claims.put("ip", ip);
return Jwts.builder() .claims(claims) .subject(username) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSigningKey()) .compact(); }
public boolean validateToken(String token, String requestIp) { try { Claims claims = parseToken(token); String tokenIp = claims.get("ip", String.class); if (tokenIp != null && !tokenIp.isEmpty() && !tokenIp.equals(requestIp)) { return false; } return true; } catch (JwtException | IllegalArgumentException e) { return false; } }
private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } }
|
设计要点:tableName 字段区分管理员(users 表)和普通用户(yonghu 表),同一系统两套登录入口共用一套 JWT 体系。ip 字段的 null 检查是兼容移动端的关键——Web 端绑 IP,移动端不绑。
2.2 客户端真实 IP 获取
Nginx 反向代理时 request.getRemoteAddr() 返回的是 Nginx 的 IP(127.0.0.1),需要从 HTTP 头获取真实 IP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public static String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } return ip; }
|
Header 优先级:
1
| X-Forwarded-For → Proxy-Client-IP → WL-Proxy-Client-IP → X-Real-IP → RemoteAddr
|
Nginx 配置(必须设置这些 Header):
1 2 3 4 5 6
| location / { proxy_pass http://backend; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; }
|
$proxy_add_x_forwarded_for 会自动追加当前请求的 $remote_addr 到 X-Forwarded-For 链。多级代理时,第一个值是真实客户端 IP。
第三章:Redis 白名单——让 JWT 可主动失效
3.1 RedisTokenService 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| @Service public class RedisTokenService {
@Value("${token.redis-prefix}") private String redisPrefix;
@Value("${token.expire-seconds}") private long expireSeconds;
public void saveToken(String token, Long userId, String username, String tableName, String role) { String key = redisPrefix + token; Map<String, Object> session = new HashMap<>(); session.put("userId", userId); session.put("username", username); session.put("tableName", tableName); session.put("role", role);
redisTemplate.opsForHash().putAll(key, session); redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); }
public Map<Object, Object> getTokenSession(String token) { String key = redisPrefix + token; Map<Object, Object> session = redisTemplate.opsForHash().entries(key); if (session.isEmpty()) { return null; } redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); return session; }
public void removeToken(String token) { redisTemplate.delete(redisPrefix + token); } }
|
3.2 滑动过期 vs 固定过期
| 策略 |
Token 有效期 |
用户体验 |
Redis 内存 |
| 固定过期 |
签发时设定,不续期 |
活跃用户也会被迫重新登录 |
可预测 |
| 滑动过期 |
每次有效请求刷新TTL |
活跃用户持续操作不会掉线 |
可能偏高 |
本项目用滑动过期——每次有效请求都刷新 1 小时 TTL,活跃用户在持续操作期间不会被迫重新登录。代价是 Redis 内存可能偏高(活跃用户的 Token 不会过期),但每个 Token 只占一个 Hash(约 200 字节),影响可忽略。
第四章:拦截器——三重校验
4.1 AuthorizationInterceptor 完整实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| @Component public class AuthorizationInterceptor implements HandlerInterceptor {
public static final String LOGIN_TOKEN_KEY = "Token";
@Autowired private JwtUtils jwtUtils;
@Autowired private RedisTokenService redisTokenService;
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals(RequestMethod.OPTIONS.name())) { response.setStatus(HttpStatus.OK.value()); return false; }
IgnoreAuth annotation; if (handler instanceof HandlerMethod) { annotation = ((HandlerMethod) handler).getMethodAnnotation(IgnoreAuth.class); if(annotation != null) { return true; } } else { return true; }
String token = request.getHeader(LOGIN_TOKEN_KEY);
String clientIp = JwtUtils.getClientIp(request); if (StringUtils.isNotBlank(token) && jwtUtils.validateToken(token, clientIp)) { Map<Object, Object> session = redisTokenService.getTokenSession(token); if (session != null) { request.getSession().setAttribute("userId", Long.valueOf(session.get("userId").toString())); request.getSession().setAttribute("role", session.get("role").toString()); request.getSession().setAttribute("tableName", session.get("tableName").toString()); request.getSession().setAttribute("username", session.get("username").toString()); return true; } }
response.setCharacterEncoding("UTF-8"); response.setContentType("application/json; charset=utf-8"); response.getWriter().print(JSONObject.toJSONString(R.error(401, "请先登录"))); return false; } }
|
4.2 为什么需要双重校验?
JWT 本身是无状态的,签发后无法主动失效。配合 Redis 后:
- 第一重(JWT验证):检查签名是否被篡改、Token 是否过期、IP 是否匹配
- 第二重(Redis校验):检查 Token 是否在 Redis 白名单中
退出登录时删除 Redis 中的 Token → 即使 JWT 本身没过期,第二重校验也会失败 → Token 立即作废。
4.3 拦截器注册
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Configuration public class InterceptorConfig extends WebMvcConfigurationSupport{
@Bean public AuthorizationInterceptor getAuthorizationInterceptor() { return new AuthorizationInterceptor(); }
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(getAuthorizationInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/static/**") .excludePathPatterns( "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**" ) .excludePathPatterns("/actuator/**"); super.addInterceptors(registry); } }
|
第五章:登录与退出完整流程
5.1 登录流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @IgnoreAuth @RateLimit(key = "login:users", count = 5, period = 60, message = "登录请求过于频繁,请1分钟后再试") @PostMapping(value = "/login") public R login(@RequestBody Map<String, String> params, HttpServletRequest request) { String username = params.get("username"); String password = params.get("password"); UserEntity user = userService.getOne( new QueryWrapper<UserEntity>().eq("username", username)); if(user == null || !PasswordEncoder.matches(password, user.getPassword())) { return R.error("账号或密码不正确"); } String clientIp = JwtUtils.getClientIp(request); String token = jwtUtils.generateToken( user.getId(), username, "users", user.getRole(), clientIp); redisTokenService.saveToken(token, user.getId(), username, "users", user.getRole()); return R.ok().put("token", token); }
|
5.2 退出登录——主动失效
1 2 3 4 5 6 7 8 9 10
| @PostMapping("/logout") public R logout(HttpServletRequest request) { String token = request.getHeader("Token"); if (token != null) { redisTokenService.removeToken(token); } return R.ok(); }
|
第六章:Token 自动续期——Renew-Token 方案
6.1 方案原理
在 springboot-core-arch 项目中,Token 续期用了另一种方案——通过响应头自动返回新 Token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 请求到达 │ ▼ JwtAuthenticationFilter │ 1. 验证Token签名 │ 2. 解析过期时间 │ 3. 判断:剩余有效期 < 30分钟? │ ├── 否 → 正常放行 │ └── 是 → 生成新Token(24小时) │ 放入响应头 Renew-Token │ ▼ 响应返回 │ 客户端 │ 检查响应头有没有 Renew-Token │ ├── 有 → 替换本地Token │ └── 无 → 继续用旧Token
|
6.2 过滤器实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| @Component public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String uri = request.getRequestURI(); if (isWhiteListed(uri)) { filterChain.doFilter(request, response); return; }
String token = request.getHeader("token"); if (StrUtil.isBlank(token)) { token = request.getParameter("token"); } if (StrUtil.isBlank(token)) { filterChain.doFilter(request, response); return; }
String audience = JWT.decode(token).getAudience().get(0); String[] split = audience.split("-"); String userId = split[0]; String role = split[1];
AccountService service = serviceMap.get(role); Account account = service.selectById(userId); JWTVerifier verifier = JWT.require(Algorithm.HMAC256(account.getPassword())).build(); verifier.verify(token);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( account.getUsername(), null, Collections.emptyList()); SecurityContextHolder.getContext().setAuthentication(authToken);
if (shouldRenewToken(token, 30 * 60 * 1000)) { String newToken = createToken( userId + "-" + role, account.getPassword(), 24 ); response.setHeader("Renew-Token", newToken); }
filterChain.doFilter(request, response); }
private boolean shouldRenewToken(String token, long threshold) { try { Date expiresAt = JWT.decode(token).getExpiresAt(); return expiresAt != null && (expiresAt.getTime() - System.currentTimeMillis() < threshold); } catch (Exception e) { return false; } }
private String createToken(String audience, String secret, int hours) { return JWT.create() .withAudience(audience) .withExpiresAt(DateUtil.offsetHour(new Date(), hours)) .sign(Algorithm.HMAC256(secret)); } }
|
6.3 前端处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import axios from 'axios'
const service = axios.create({ baseURL: '/api', timeout: 10000 })
service.interceptors.response.use( response => { const renewToken = response.headers['renew-token'] if (renewToken) { localStorage.setItem('token', renewToken) console.log('Token已自动续期') } return response.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } )
export default service
|
前端只需 5 行代码:检查响应头 → 替换本地 Token。不需要写刷新接口、不需要处理并发刷新、不需要管理 RefreshToken。
6.4 续期时机分析
1 2 3 4 5 6 7 8
| Token有效期: 24小时 续期阈值: 30分钟(剩余 < 30分钟时触发)
时间轴: 0h 23h 23.5h 24h │----------│-----------│--------------│ │ 正常 │ 触发续期 │ 新Token 24h │ 旧Token过期 │ 使用 │ │ │ (已被替换)
|
- 0-23h:Token 有效期充足,不续期
- 23-24h:剩余 < 30 分钟,每次请求都返回新 Token
- 新 Token 有效期从当前时刻开始算 24 小时
效果:用户持续操作期间永远不会掉线。只有连续 24 小时不操作才会过期。
第七章:密码做密钥——改密自动失效
7.1 设计原理
在 springboot-core-arch 和钢铁工厂微服务中,JWT 的签名密钥不是固定的全局密钥,而是用户密码的 BCrypt 哈希值:
1 2 3 4 5 6
| String token = TokenUtils.createToken( userId + "-" + role, dbAdmin.getPassword(), clientIp );
|
验证时:从数据库查出用户密码,用密码验证 Token 签名:
1 2 3 4 5 6 7
| String password = (String) redisTemplate.opsForValue() .get("token:password:" + audience);
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(password)).build(); verifier.verify(token);
|
7.2 改密自动失效原理
1 2 3 4 5 6
| 用户改密码: 旧密码: $2a$10$abc... ← 旧Token的签名密钥 新密码: $2a$10$xyz... ← 数据库中的密码已更新 旧Token到达 → 查数据库获取密码 → 用新密码验证旧Token签名 → ✗ 验证失败! → 旧Token自动失效,无需操作Redis
|
优势:改密码后旧 Token 自动失效,不需要额外操作 Redis。
7.3 网关层的密码验证
在钢铁工厂微服务中,网关需要验证 Token 但无法直接查用户数据库——密码存储在 Redis 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| String passwordKey = "token:password:" + data; staticRedisTemplate.opsForValue().set(passwordKey, sign, 2, TimeUnit.HOURS);
String password = (String) redisTemplate.opsForValue() .get("token:password:" + audience); if (password == null) { return unauthorized(exchange); }
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(password)).build(); verifier.verify(token);
|
第八章:双 Token 机制
8.1 AccessToken + RefreshToken
在 IntensifiedMyCode 项目中,认证用了双 Token 方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @Component public class TokenUtils {
public String createAccessToken(String userId, String role) { String audience = userId + "-" + role + "-access"; String token = JWT.create() .withAudience(audience) .withExpiresAt(DateUtil.offsetMinute(new Date(), 30)) .sign(Algorithm.HMAC256(jwtSecret)); String tokenKey = "token:access:" + userId + ":" + role; redisUtils.set(tokenKey, token, 30, TimeUnit.MINUTES); return token; }
public String createRefreshToken(String userId, String role) { String audience = userId + "-" + role + "-refresh"; String token = JWT.create() .withAudience(audience) .withExpiresAt(DateUtil.offsetDay(new Date(), 7)) .sign(Algorithm.HMAC256(jwtSecret)); String tokenKey = "token:refresh:" + userId + ":" + role; redisUtils.set(tokenKey, token, 7, TimeUnit.DAYS); return token; } }
|
8.2 Token 刷新流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @PostMapping("/refresh") public R refresh(@RequestBody Account account) { String userId = account.getId().toString(); String role = account.getRole(); String oldRefreshToken = account.getRefreshToken();
if (!tokenUtils.isRefreshTokenValid(userId, role, oldRefreshToken)) { return R.error("RefreshToken已失效,请重新登录"); }
tokenUtils.removeTokens(userId, role);
Map<String, String> newTokens = tokenUtils.createTokens(userId, role); return R.success(newTokens); }
|
8.3 双 Token 的优势
| Token |
有效期 |
用途 |
存储 |
| AccessToken |
30分钟 |
携带在每次请求中,用于鉴权 |
Redis白名单 |
| RefreshToken |
7天 |
AccessToken过期后用它换新 |
Redis白名单 |
AccessToken 短期有效 → 被盗风险窗口小;RefreshToken 长期有效 → 用户不用频繁登录。
第九章:网关统一鉴权 + 用户信息透传
9.1 微服务网关鉴权
在微服务架构中,每个服务都做鉴权太重复——网关统一鉴权后,通过 Header 传递用户信息给下游:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| @Component public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath();
if (isWhiteListed(path)) { return chain.filter(exchange); }
String token = request.getHeaders().getFirst("token"); if (StrUtil.isBlank(token)) { token = request.getQueryParams().getFirst("token"); } if (StrUtil.isBlank(token)) { return unauthorized(exchange); }
String audience = JWT.decode(token).getAudience().get(0); String[] split = audience.split("-"); String userId = split[0]; String role = split[1];
String password = (String) redisTemplate.opsForValue() .get("token:password:" + audience); if (password == null) return unauthorized(exchange);
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(password)).build(); verifier.verify(token);
String clientIp = getClientIp(request); String storedIp = (String) redisTemplate.opsForValue().get("token:" + audience); if (storedIp == null || !clientIp.equals(storedIp)) { return unauthorized(exchange); }
ServerHttpRequest mutatedRequest = request.mutate() .header("X-User-Id", userId) .header("X-User-Role", role) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); }
@Override public int getOrder() { return -1; } }
|
9.2 用户信息透传原理
1 2 3 4 5 6 7 8
| 客户端 网关 下游服务 │ │ │ │── 请求(Token) ────────>│ │ │ │ 解析Token获取userId/role │ │ │ 添加 X-User-Id Header ──────>│ │ │ 添加 X-User-Role Header ─────>│ │ │ │ 下游服务通过request.getHeader("X-User-Id")获取 │<── 响应 ──────────────│<─────────────────────────────│
|
下游服务不需要再次解析 JWT——网关统一鉴权后,通过自定义 Header 传递用户信息。
1 2 3 4 5 6
| @GetMapping("/profile") public R getProfile(HttpServletRequest request) { String userId = request.getHeader("X-User-Id"); return R.success(userService.getById(userId)); }
|
第十章:4 种方案横向对比
10.1 方案概览
| 方案 |
项目 |
核心特点 |
Token密钥 |
主动失效 |
| A |
community-group-buying-system |
JWT + IP绑定 + Redis白名单 |
固定密钥 |
Redis删除Token |
| B |
IntensifiedMyCode |
双Token + Redis白名单 + 策略模式 |
固定密钥 |
Redis删除Token |
| C |
springboot-core-arch |
密码做密钥 + Renew-Token续期 |
用户密码 |
改密自动失效 |
| D |
steel-pipe-factory-microservices |
密码做密钥 + IP绑定 + 网关鉴权 |
用户密码 |
Redis删除Token |
10.2 对比表
| 维度 |
方案A |
方案B |
方案C |
方案D |
| 密钥来源 |
固定 |
固定 |
用户密码 |
用户密码 |
| IP绑定 |
✅ |
❌ |
❌ |
✅ |
| Token数 |
1 |
2 |
1 |
1 |
| 续期方式 |
Redis滑动 |
RefreshToken |
Renew-Token |
无 |
| 改密失效 |
需删Redis |
需删Redis |
自动 |
自动 |
| 多设备 |
共享Token |
共享Token |
独立Token |
独立Token |
| Redis依赖 |
强 |
强 |
弱 |
强 |
| 前端改动 |
无 |
大(刷新逻辑) |
小(拦截响应头) |
无 |
| 适合架构 |
单体 |
单体 |
单体/微服务 |
微服务 |
| 安全性 |
中高 |
中高 |
高 |
高 |
10.3 选择建议
| 场景 |
推荐方案 |
原因 |
| 单体应用,Web端为主 |
方案A |
IP绑定防盗用,Redis白名单支持主动失效 |
| 需要最高安全级别 |
方案D |
三重保护(密码密钥+IP+Redis) |
| 移动端为主,IP频繁变化 |
方案B |
不绑IP,双Token兼顾安全和体验 |
| 微服务架构 |
方案C + 网关 |
Renew-Token透明续期,网关统一鉴权 |
第十一章:IP 绑定的局限性与解决方案
11.1 移动端 IP 频繁变化
手机从 WiFi 切到 4G/5G,IP 会变化,导致 Token 失效:
| 方案 |
优点 |
缺点 |
| IP 绑定(严格) |
安全性最高 |
移动端体验差 |
| IP 段绑定(C 段) |
兼容IP微调 |
安全性降低 |
| 设备指纹绑定 |
不受IP变化影响 |
实现复杂 |
| 仅Redis白名单 |
不受IP影响 |
Token被盗可继续使用 |
11.2 推荐方案:按场景选择
1 2 3 4 5 6
| String token = jwtUtils.generateToken(userId, username, "users", role, clientIp);
String token = jwtUtils.generateToken(userId, username, "yonghu", role, null);
|
validateToken 中的判断 tokenIp != null && !tokenIp.isEmpty() 已经做了兼容——如果生成时没写入 IP,验证时自动跳过 IP 校验。
第十二章:登出与黑名单
12.1 方案A/B的登出(Redis白名单删除)
1 2 3 4 5 6 7 8
| @PostMapping("/logout") public R logout(HttpServletRequest request) { String token = request.getHeader("Token"); if (token != null) { redisTokenService.removeToken(token); } return R.ok(); }
|
退出登录后,即使 JWT 本身还没过期(1小时TTL),Redis 中已不存在该 Token,下次请求会被拦截器第二重校验拒绝。
12.2 方案C的登出(Redis黑名单)
1 2 3 4 5 6 7 8 9 10
| @PostMapping("/logout") public R<?> logout(HttpServletRequest request) { String token = JwtUtil.getTokenFromRequest(request); if (token != null && JwtUtil.isTokenValid(token)) { long expire = JwtUtil.getRemainingTime(token); redisUtil.set("blacklist:" + token, "true", expire, TimeUnit.MILLISECONDS); } return R.ok("登出成功"); }
|
黑名单 vs 白名单:
- 黑名单:存废弃的 Token(过期自动清除)
- 白名单:存有效的 Token(删除即失效)
Renew-Token 方案配合黑名单更合适——登出时把旧 Token 加入黑名单,但 Renew 出来的新 Token 不受影响。
第十三章:BCrypt 密码加密
13.1 PasswordEncoder 封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public class PasswordEncoder { private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder();
public static String encode(String rawPassword) { return ENCODER.encode(rawPassword); }
public static boolean matches(String rawPassword, String encodedPassword) { if (rawPassword == null || encodedPassword == null) { return false; } return ENCODER.matches(rawPassword, encodedPassword); } }
|
13.2 BCrypt 为什么安全
1 2 3
| 明文: "123456" 加密1: $2a$10$N9qo8uLOickgx2ZMRZoMy... ← 不同盐值 加密2: $2a$10$X3P5vQ7rS1tW8yK0zBmN... ← 不同盐值
|
| 特性 |
说明 |
| 自动加盐 |
每次加密生成随机盐值,相同明文加密结果不同 |
| 防彩虹表 |
盐值嵌入在哈希结果中,预计算的彩虹表无法使用 |
| 可调复杂度 |
$2a$10$ 中的 10 是 cost factor,可调大增加计算时间 |
| 防暴力破解 |
单次验证需要 ~100ms,暴力枚举成本极高 |
13.3 Spring Security 的取舍
社区团购项目有一个特殊设计——引入了 spring-boot-starter-security 依赖,但排除了 Security 的自动配置:
1 2 3 4
| @SpringBootApplication(exclude = { SecurityAutoConfiguration.class, SecurityFilterAutoConfiguration.class })
|
为什么这么做:只需要 BCryptPasswordEncoder 这个工具类,不需要 Security 的过滤器链、默认登录页、CSRF 保护等。排除自动配置后,Security 不会干扰自定义拦截器。
第十四章:JWT 配置
1 2 3 4 5 6 7 8 9
| jwt: secret: CommunityGroupBuying2024SecretKeyForJWTTokenGenerationMustBeLongEnough expiration: 3600000 refresh-expiration: 86400000
token: redis-prefix: "token:" expire-seconds: 3600
|
密钥长度:HMAC-SHA256 要求密钥至少 256 位(32 字节)。上面的密钥 72 字节,满足要求。
多环境:生产环境密钥应该从环境变量注入,不要硬编码:
1 2 3
| jwt: secret: ${JWT_SECRET}
|
总结
| 校验层 |
防御目标 |
实现 |
方案A |
方案B |
方案C |
方案D |
| JWT签名 |
Token篡改 |
HMAC-SHA256 |
✅ |
✅ |
✅ |
✅ |
| IP绑定 |
Token盗用 |
Claims写入IP |
✅ |
❌ |
❌ |
✅ |
| Redis白名单 |
主动失效 |
Hash存储+删除 |
✅ |
✅ |
❌ |
✅ |
| 密码做密钥 |
改密失效 |
HMAC256(密码) |
❌ |
❌ |
✅ |
✅ |
| Token续期 |
体验优化 |
Renew-Token |
❌ |
RefreshToken |
✅ |
❌ |
| BCrypt |
密码泄露 |
自适应哈希 |
✅ |
✅ |
✅ |
✅ |
| 限流 |
暴力破解 |
Lua/Bucket4j |
✅ |
✅ |
❌ |
❌ |
核心思想:没有最好的方案,只有最适合场景的方案。选择的关键不是”哪个更先进”,而是”你的场景需要哪个级别的安全”和”你的团队能承受多复杂度的运维”。