「Spring Security」基于Redis的Token自动续签优化

本文基于上一篇文章:《Spring Security(三)整合 JWT 实现无状态登录示例》。

SpringSecurity 整合 JWT 实现无状态登录示例中,我们在 JwtAuthenticationFilter (自定义JWT认证过滤器) 解析 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
37
38
/**
* 刷新Token的时机:
* 1. 当前时间 < token过期时间
* 2. 当前时间 > (签发时间 + (token过期时间 - token签发时间)/2)
*/
private void refreshToken(HttpServletResponse response, Claims claims) {
// 当前时间
long current = System.currentTimeMillis();
// token签发时间
long issuedAt = claims.getIssuedAt().getTime();
// token过期时间
long expiration = claims.getExpiration().getTime();
// (当前时间 < token过期时间) && (当前时间 > (签发时间 + (token过期时间 - token签发时间)/2))
if ((current < expiration) && (current > (issuedAt + ((expiration - issuedAt) / 2)))) {
/*
* 重新生成token
*/
Calendar calendar = Calendar.getInstance();
// 设置签发时间
calendar.setTime(new Date());
Date now = calendar.getTime();
// 设置过期时间: 5分钟
calendar.add(Calendar.MINUTE, 5);
Date time = calendar.getTime();
String refreshToken = Jwts.builder()
.setSubject(claims.getSubject())
// 签发时间
.setIssuedAt(now)
// 过期时间
.setExpiration(time)
// 算法与签名(同生成token):这里算法采用HS512,常量中定义签名key
.signWith(SignatureAlgorithm.HS512, ConstantKey.SIGNING_KEY)
.compact();
// 主动刷新token,并返回给前端
response.addHeader("refreshToken", refreshToken);
log.info("刷新token执行时间: {}", (System.currentTimeMillis() - current) + " 毫秒");
}
}

这里的逻辑是:Token 未过期并且当前时间已经超过 Token 有效时间的一半,重新生成一个 refreshToken,并返回给前端,前端需要用 refreshToken 替换之前旧的 Token

Token续签优化方案

预期效果:前端不需要手动替换 Token,每次用 Token 请求资源时自动续期。

实现方案:引入 Redis,实现逻辑:

  1. 登录成功后将 Token 存储到 Redis 里面(k,v都为 Token 的值),并设置 Redis 过期时间为: Token 过期时间。
  2. 用户发起请求时,每次都根据k为Token的键去换取 Redis 的值,这里命名为 cacheToken
    • cacheToken 在有效期内,重设 Redis 过期时间为:当前时间 + (cacheToken过期时间 - cacheToken签发时间)。
    • cacheToken 已过期(Redis 在有效期内),则 JWT 重新生成 Token 并覆盖v值(这时候k、v值不一样了),然后设置 Redis 过期时间为: cacheToken 过期时间。
    • Redis 也过期,取不到 cacheToken,则拒绝访问或返回错误信息,需要重新登录。

具体实现

1. 在 pom.xml 中引入 Redis 依赖:
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 在 application.yml 配置文件中配置 Redis
1
2
3
4
5
6
7
8
9
spring:
redis:
host: 127.0.0.1
port: 6379
password: 123456
# Redis数据库索引(默认为0)
database: 0
# 连接超时时间(毫秒)
timeout: 5000
3. 简单的 RedisService 封装
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
@Service
public class RedisService {
@Resource
private RedisTemplate<Serializable, Object> redisTemplate;
/**
* 读取缓存
*/
public Object get(String key) {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
return operations.get(key);
}
/**
* 判断缓存中是否存在
*/
public boolean exists(String key) {
return StringUtils.hasLength(key) && Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* 删除缓存
*/
public void remove(String key) {
if (exists(key)) {
redisTemplate.delete(key);
}
}
/**
* 写入缓存
*/
public boolean set(String key, Object value) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 写入缓存 并 加上过期时间
*/
public boolean set(String key, Object value, Date date) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expireAt(key, date);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 写入过期时间(毫秒)
*/
public boolean expire(String key, Long expireTimeMillis) {
boolean result = false;
try {
redisTemplate.expire(key, expireTimeMillis, TimeUnit.MILLISECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
}
4. 修改JWT登录过滤器 JwtLoginFilter,构造方法中加入 RedisService,并生成 Token 后存入 Redis:
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
78
79
80
81
82
@Slf4j
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {

private final AuthenticationManager authenticationManager;
private final RedisService redisService;
public JwtLoginFilter(AuthenticationManager authenticationManager, RedisService redisService) {
this.authenticationManager = authenticationManager;
this.redisService = redisService;
}

/**
* 尝试身份认证(接收并解析用户凭证)
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = request.getParameter("username");
String password = request.getParameter("password");
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password, new ArrayList<>())
);
}

/**
* 认证成功(用户成功登录后,这个方法会被调用,我们在这个方法里生成token)
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication auth) {
try {
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
// 定义存放角色集合的对象
List<String> roleList = new ArrayList<>();
for (GrantedAuthority grantedAuthority : authorities) {
roleList.add(grantedAuthority.getAuthority());
}
/*
* 生成token
*/
Calendar calendar = Calendar.getInstance();
// 设置签发时间
calendar.setTime(new Date());
Date now = calendar.getTime();
// 设置过期时间: 5分钟
calendar.add(Calendar.MINUTE, 5);
Date time = calendar.getTime();
String token = Jwts.builder()
.setSubject(auth.getName() + "-" + roleList)
// 签发时间
.setIssuedAt(now)
// 过期时间
.setExpiration(time)
// 自定义算法与签名:这里算法采用HS512,常量中定义签名key
.signWith(SignatureAlgorithm.HS512, ConstantKey.SIGNING_KEY)
.compact();
// 将token存入redis,并设置超时时间为token过期时间
redisService.set(token, token, time);
/*
* 返回token
*/
log.info("用户登录成功,生成token={}", token);
// 登录成功后,返回token到header里面
response.addHeader("Authorization", token);
// 登录成功后,返回token到body里面
ResponseJson<String> result = ResponseJson.success("登录成功", token);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(JSON.toJSONString(result));
} catch (IOException e) {
log.error("IOException:", e);
}
}

/**
* 认证失败调用
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
log.warn("登录失败[{}],AuthenticationException={}", request.getRequestURI(), exception.getMessage());
// 登录失败,返回错误信息
ResponseJson<Void> result = ResponseJson.error(exception.getMessage(), null);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(JSON.toJSONString(result));
}
}
5. 修改JWT认证过滤器 JwtAuthenticationFilter,构造方法中加入 RedisService,并添加 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
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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
@Slf4j
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

private final RedisService redisService;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, RedisService redisService) {
super(authenticationManager);
this.redisService = redisService;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
UsernamePasswordAuthenticationToken authentication = getAuthentication(request, response);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}

private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request, HttpServletResponse response) {
/*
* 解析token
*/
String token = request.getHeader("Authorization");
if (StringUtils.hasLength(token)) {
String cacheToken = String.valueOf(redisService.get(token));
if (StringUtils.hasLength(token) && !"null".equals(cacheToken)) {
String user = null;
try {
Claims claims = Jwts.parser()
// 设置生成token的签名key
.setSigningKey(ConstantKey.SIGNING_KEY)
// 解析token
.parseClaimsJws(cacheToken).getBody();
// 取出用户信息
user = claims.getSubject();
// 重设Redis超时时间
resetRedisExpire(token, claims);
} catch (ExpiredJwtException e) {
log.info("Token过期续签,ExpiredJwtException={}", e.getMessage());
Claims claims = e.getClaims();
// 取出用户信息
user = claims.getSubject();
// 刷新Token
refreshToken(token, claims);
} catch (UnsupportedJwtException e) {
log.warn("访问[{}]失败,UnsupportedJwtException={}", request.getRequestURI(), e.getMessage());
} catch (MalformedJwtException e) {
log.warn("访问[{}]失败,MalformedJwtException={}", request.getRequestURI(), e.getMessage());
} catch (SignatureException e) {
log.warn("访问[{}]失败,SignatureException={}", request.getRequestURI(), e.getMessage());
} catch (IllegalArgumentException e) {
log.warn("访问[{}]失败,IllegalArgumentException={}", request.getRequestURI(), e.getMessage());
}
if (user != null) {
// 获取用户权限和角色
String[] split = user.split("-")[1].split(",");
ArrayList<GrantedAuthority> authorities = new ArrayList<>();
for (String s : split) {
authorities.add(new GrantedAuthorityImpl(s));
}
// 返回Authentication
return new UsernamePasswordAuthenticationToken(user, null, authorities);
}
}
}
log.warn("访问[{}]失败,需要身份认证", request.getRequestURI());
return null;
}

/**
* 重设Redis超时时间
* 当前时间 + (`cacheToken`过期时间 - `cacheToken`签发时间)
*/
private void resetRedisExpire(String token, Claims claims) {
// 当前时间
long current = System.currentTimeMillis();
// token签发时间
long issuedAt = claims.getIssuedAt().getTime();
// token过期时间
long expiration = claims.getExpiration().getTime();
// 当前时间 + (`cacheToken`过期时间 - `cacheToken`签发时间)
long expireAt = current + (expiration - issuedAt);
// 重设Redis超时时间
redisService.expire(token, expireAt);
}

/**
* 刷新Token
* 刷新Token的时机: 当cacheToken已过期 并且Redis在有效期内
* 重新生成Token并覆盖Redis的v值(这时候k、v值不一样了),然后设置Redis过期时间为:新Token过期时间
*/
private void refreshToken(String token, Claims claims) {
// 当前时间
long current = System.currentTimeMillis();
/*
* 重新生成token
*/
Calendar calendar = Calendar.getInstance();
// 设置签发时间
calendar.setTime(new Date());
Date now = calendar.getTime();
// 设置过期时间: 5分钟
calendar.add(Calendar.MINUTE, 5);
Date time = calendar.getTime();
String refreshToken = Jwts.builder()
.setSubject(claims.getSubject())
// 签发时间
.setIssuedAt(now)
// 过期时间
.setExpiration(time)
// 算法与签名(同生成token):这里算法采用HS512,常量中定义签名key
.signWith(SignatureAlgorithm.HS512, ConstantKey.SIGNING_KEY)
.compact();
// 将refreshToken覆盖Redis的v值,并设置超时时间为refreshToken过期时间
redisService.set(token, refreshToken, time);
// 打印日志
log.info("刷新token执行时间: {}", (System.currentTimeMillis() - current) + " 毫秒");
}
}
6. 修改 SpringSecurity 配置类,注入 RedisService
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
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Resource
private RedisService redisService;

@Resource
private UserDetailsService userDetailsService;

@Resource
private BCryptPasswordEncoder bCryptPasswordEncoder;

/**
* 全局请求忽略规则配置
*/
@Override
public void configure(WebSecurity web) {
// 需要放行的URL
web.ignoring().antMatchers("/register", "/hello");
}

/**
* 自定义认证策略:登录的时候会进入
*/
@Override
public void configure(AuthenticationManagerBuilder auth) {
// 2. 通过实现 AuthenticationProvider 自定义身份认证验证组件
auth.authenticationProvider(new AuthenticationProviderImpl(userDetailsService, bCryptPasswordEncoder));
}

/**
* 自定义 HTTP 验证规则
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 关闭Session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 所有请求需要身份认证
.and().authorizeRequests().anyRequest().authenticated()
.and()
// 自定义JWT登录过滤器
.addFilter(new JwtLoginFilter(authenticationManager(), redisService))
// 自定义JWT认证过滤器
.addFilter(new JwtAuthenticationFilter(authenticationManager(), redisService))
// 自定义认证拦截器,也可以直接使用内置实现类Http403ForbiddenEntryPoint
.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPointImpl())
// 允许跨域
.and().cors()
// 禁用跨站伪造
.and().csrf().disable();
}
}

源码地址:https://github.com/chaooo/spring-security-jwt.git,
这里我将本文的基于Redis的Token自动续签优化放在github源码tag的V2.0中,防止后续修改后代码对不上。