Springboot

JWT 认证安全体系全解:从 IP 绑定到自动续期,4 种方案横评

2026-07-07 #java#Springboot#Redis#JWT

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白名单) │
│ └──────────────────┘

三重校验

  1. JWT 签名验证(防篡改)
  2. IP 绑定校验(防盗用)
  3. 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; // 1小时(毫秒)

/**
* 生成 JWT Token
* ★ 将客户端IP写入Claims实现IP绑定
*
* @param userId 用户ID
* @param username 用户名
* @param tableName 用户类型表名(区分管理员users和普通用户yonghu)
* @param role 角色
* @param ip 客户端IP(★ IP绑定的核心)
*/
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); // 区分用户类型: users/yonghu
claims.put("role", role);
claims.put("ip", ip); // ★ IP绑定核心

return Jwts.builder()
.claims(claims)
.subject(username)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey()) // HMAC-SHA256
.compact();
}

/**
* 验证 Token —— 签名 + 过期 + IP绑定 三重校验
*/
public boolean validateToken(String token, String requestIp) {
try {
Claims claims = parseToken(token);
String tokenIp = claims.get("ip", String.class);

// ★ IP 绑定验证:Token中记录的IP必须与当前请求IP匹配
// 如果生成时没写入IP(tokenIp == null),跳过IP校验
// 这是兼容移动端的策略——移动端可以不绑IP
if (tokenIp != null && !tokenIp.isEmpty() && !tokenIp.equals(requestIp)) {
return false; // IP不匹配 → Token被盗用
}
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();
}
// 多级代理时取第一个非unknown的IP
// X-Forwarded-For 格式: client, proxy1, proxy2
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_addrX-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; // "token:"

@Value("${token.expire-seconds}")
private long expireSeconds; // 3600秒

/**
* 存储 Token 会话到 Redis(Hash结构)
*
* 为什么用 Hash 而非 String?
* Hash 可以存储多个字段(userId/username/tableName/role)
* 验证时可以取出单个字段,不需要反序列化整个对象
*/
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);
}

/**
* 获取 Token 会话 —— ★ 滑动续期
*
* 每次有效请求都刷新TTL,活跃用户不会被迫重新登录
*/
public Map<Object, Object> getTokenSession(String token) {
String key = redisPrefix + token;
Map<Object, Object> session = redisTemplate.opsForHash().entries(key);
if (session.isEmpty()) {
return null; // Token不存在或已过期
}
// ★ 续期:每次有效请求刷新TTL
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
return session;
}

/**
* 删除 Token(退出登录时调用)
* ★ 主动失效:即使JWT本身没过期,Redis中删除后也会被拦截器拒绝
*/
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 {
// 1. 跨域预检请求放行
if (request.getMethod().equals(RequestMethod.OPTIONS.name())) {
response.setStatus(HttpStatus.OK.value());
return false;
}

// 2. @IgnoreAuth 注解的方法直接放行
IgnoreAuth annotation;
if (handler instanceof HandlerMethod) {
annotation = ((HandlerMethod) handler).getMethodAnnotation(IgnoreAuth.class);
if(annotation != null) {
return true;
}
} else {
return true;
}

// 3. 从请求头获取 Token
String token = request.getHeader(LOGIN_TOKEN_KEY);

// ★ 第一重:JWT 签名 + IP 绑定校验
String clientIp = JwtUtils.getClientIp(request);
if (StringUtils.isNotBlank(token) && jwtUtils.validateToken(token, clientIp)) {

// ★ 第二重:Redis 白名单校验
Map<Object, Object> session = redisTokenService.getTokenSession(token);
if (session != null) {
// 将用户信息注入到请求上下文,后续Controller可以直接用
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;
}
}

// 校验失败 → 401
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/**")
// ★ 排除Swagger文档路径,否则文档页面也要登录
.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");

// 1. 查用户
UserEntity user = userService.getOne(
new QueryWrapper<UserEntity>().eq("username", username));

// 2. BCrypt 密码验证
if(user == null || !PasswordEncoder.matches(password, user.getPassword())) {
return R.error("账号或密码不正确");
}

// 3. 获取客户端IP
String clientIp = JwtUtils.getClientIp(request);

// 4. 生成 Token(写入IP)
String token = jwtUtils.generateToken(
user.getId(), username, "users", user.getRole(), clientIp);

// 5. 存入 Redis 白名单
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) {
// ★ 从 Redis 删除 Token → 立即失效
// 即使 JWT 本身还没过期(1小时TTL),拦截器第二重校验也会拒绝
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;
}

// 提取 Token
String token = request.getHeader("token");
if (StrUtil.isBlank(token)) {
token = request.getParameter("token");
}
if (StrUtil.isBlank(token)) {
filterChain.doFilter(request, response);
return;
}

// 解析 Token:audience = "userId-role"
String audience = JWT.decode(token).getAudience().get(0);
String[] split = audience.split("-");
String userId = split[0];
String role = split[1];

// ★ 密码做密钥:用用户密码验证JWT签名
AccountService service = serviceMap.get(role);
Account account = service.selectById(userId);
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(account.getPassword())).build();
verifier.verify(token);

// 设置 Security 上下文
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
account.getUsername(), null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authToken);

// ★ 自动续期逻辑
// 如果token剩余有效期少于30分钟,则自动续签24小时
if (shouldRenewToken(token, 30 * 60 * 1000)) {
String newToken = createToken(
userId + "-" + role,
account.getPassword(),
24 // 新Token有效期24小时
);
response.setHeader("Renew-Token", newToken);
}

filterChain.doFilter(request, response);
}

/**
* 判断Token是否即将过期
* @param threshold 续签阈值(毫秒),30分钟 = 30 * 60 * 1000
*/
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
})

// ★ 响应拦截器:检查 Renew-Token(只需5行代码)
service.interceptors.response.use(
response => {
// 检查是否有续期Token
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
// 生成Token时,用用户密码作为HMAC256密钥
String token = TokenUtils.createToken(
userId + "-" + role, // audience
dbAdmin.getPassword(), // ★ 密码作为签名密钥
clientIp
);

验证时:从数据库查出用户密码,用密码验证 Token 签名:

1
2
3
4
5
6
7
// 从Redis获取密码(用于验证JWT签名)
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
// 用户服务登录时,把密码存入Redis
String passwordKey = "token:password:" + data; // data = "userId-role"
staticRedisTemplate.opsForValue().set(passwordKey, sign, 2, TimeUnit.HOURS);

// 网关验证时,从Redis获取密码
String password = (String) redisTemplate.opsForValue()
.get("token:password:" + audience);
if (password == null) {
return unauthorized(exchange); // Token已过期或用户已改密
}

// 用密码验证JWT签名
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 {

/**
* 生成 AccessToken(短期,30分钟)
* 携带在每次请求中,用于鉴权
*/
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));

// 存入Redis白名单
String tokenKey = "token:access:" + userId + ":" + role;
redisUtils.set(tokenKey, token, 30, TimeUnit.MINUTES);
return token;
}

/**
* 生成 RefreshToken(长期,7天)
* AccessToken过期后用它换新的
*/
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();

// 1. 验证RefreshToken有效性
if (!tokenUtils.isRefreshTokenValid(userId, role, oldRefreshToken)) {
return R.error("RefreshToken已失效,请重新登录");
}

// 2. 删除旧Token对
tokenUtils.removeTokens(userId, role);

// 3. 生成新Token对
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();

// 1. 白名单检查
if (isWhiteListed(path)) {
return chain.filter(exchange);
}

// 2. 提取Token(Header或URL参数)
String token = request.getHeaders().getFirst("token");
if (StrUtil.isBlank(token)) {
token = request.getQueryParams().getFirst("token");
}
if (StrUtil.isBlank(token)) {
return unauthorized(exchange);
}

// 3. 解析Token
String audience = JWT.decode(token).getAudience().get(0);
String[] split = audience.split("-");
String userId = split[0];
String role = split[1];

// 4. 从Redis获取密码验证签名
String password = (String) redisTemplate.opsForValue()
.get("token:password:" + audience);
if (password == null) return unauthorized(exchange);

// 5. 验证JWT签名
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(password)).build();
verifier.verify(token);

// 6. IP绑定验证
String clientIp = getClientIp(request);
String storedIp = (String) redisTemplate.opsForValue().get("token:" + audience);
if (storedIp == null || !clientIp.equals(storedIp)) {
return unauthorized(exchange);
}

// 7. ★ 用户信息透传给下游微服务
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
// 下游服务获取用户ID
@GetMapping("/profile")
public R getProfile(HttpServletRequest request) {
String userId = request.getHeader("X-User-Id"); // 从网关注入的Header获取
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
// Web端:严格IP绑定
String token = jwtUtils.generateToken(userId, username, "users", role, clientIp);

// 移动端:仅Redis白名单,不绑IP
String token = jwtUtils.generateToken(userId, username, "yonghu", role, null);
// validateToken 中 ip == null 时跳过IP校验

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); // 删除Redis中的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)) {
// 将Token加入Redis黑名单(剩余有效期内)
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
# application-dev.yml
jwt:
secret: CommunityGroupBuying2024SecretKeyForJWTTokenGenerationMustBeLongEnough
expiration: 3600000 # 1小时(毫秒)
refresh-expiration: 86400000 # 24小时

token:
redis-prefix: "token:"
expire-seconds: 3600 # Redis中Token的TTL(秒)

密钥长度:HMAC-SHA256 要求密钥至少 256 位(32 字节)。上面的密钥 72 字节,满足要求。

多环境:生产环境密钥应该从环境变量注入,不要硬编码:

1
2
3
# application-prod.yml
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

核心思想:没有最好的方案,只有最适合场景的方案。选择的关键不是”哪个更先进”,而是”你的场景需要哪个级别的安全”和”你的团队能承受多复杂度的运维”。

评论
分享