使用jwt + Redis实现单点登录

结构设计

使用Redis存储JWT令牌,并为每个用户保存两个键值对:一个是访问令牌,一个是刷新令牌。

  • 键(key)的格式为:access_token:userIdrefresh_token:userId,其中userId是用户的唯一标识符。

    例如,如果userId为123,则其访问令牌的键为access_token:123。

  • 值(value)是JWT令牌本身,是一个字符串类型的值。

  • 所有的键值对都设置了相同的过期时间,以确保在一定时间内自动清除失效的令牌。

  • 分别生成了访问令牌刷新令牌访问令牌用于验证用户身份并访问受保护的资源,而刷新令牌用于获取新的访问令牌

    这里设置过期时间为 1 Day

引入依赖

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

JWT token结构

JWT 主要由三部分组成:头部、载荷和签名。

头部(Header) JWT 头部通常由两部分组成:令牌的类型(即 JWT)以及所使用的签名算法。例如:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

上述头部指定了使用 HMAC-SHA256 算法进行签名,并且令牌的类型为 JWT。

载荷(Payload) JWT 载荷包含要传输的数据,可以包含注册的声明(建议但不强制),用户自定义的声明以及一些预定义的声明。

1
2
3
4
5
6
7
8
9
10
{
exp, // 过期时间,这个过期时间必须要大于签发时间
iat, // 签发时间
[data,] // 如果你想的话,可以塞一些非敏感信息
[iss,] // 签发者
[sub,] // 面向的用户
[aud,] // 接收的一方
[nbf,] // 定义一个时间,即在该时间之前,这个jwt是不可用状态
[jti] // 唯一身份标识,主要用来作为一次性token,从而回避重放攻击
}
  1. 例如:
1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}

上述载荷指定了一个主题为 1234567890,名称为 John Doe 的用户,并且令牌的发放时间为 2018 年 1 月 18 日 10 点 23 分 42 秒。

需要注意的是前2部分只用了base64加密的,而第三部分作为凭证留在了客户端,因此,token里不能存储敏感信息

签名(Signature) JWT 的第三部分是签名,这个部分使用密钥对头部和载荷进行加密生成。例如:

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

其中 base64UrlEncode 是对头部和载荷进行 Base64 编码,并将编码结果中的 +/= 替换成 -_""。密钥(secret)是用于签名和验证令牌的加密密钥。

最终,JWT 的结构就是由这三个部分组成的字符串,每个部分之间使用点号(.)分隔。例如:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JwtTokensService接口

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
public interface JwtTokensService  {

/**
* 生成JWT访问token
* @param user
* @return
*/
String generateAccessToken(UserEntity user);


/**
* 生成refreshToken
* @param user
* @return
*/
String generateRefreshToken(UserEntity user);


/**
* 验证token
*
* @param token
* @return
*/
UserEntity validateToken(String token);

/**
* 获取令牌中的用户id
* @param token
* @return
*/
String getUserIdFromToken(String token);

/**
* 撤销JWT令牌
* @param user
*/
public void revokeToken(UserEntity user) ;


/**
* 验证token是否过期
* @param token
* @return
*/
boolean isTokenExpired(String token);

/**
* 清除过期的令牌
*/
void cleanExpiredTokens();

/**
* 保存token到redis
* @param jwtToken
* @param user
*/
void save2Redis(JwtToken jwtToken,UserEntity user);
}

双token验证机制

首先我们想象这样的一个场景 , 当你在往上冲浪的时候 , 由于你的token突然过期了, 那么就不得不重新去登录一次 , 那么这就会给用户很不好的体验 , 因此这里我们使用一个双token的认证机制 来解决这个问题

为什么不把token的过期时间设置的长一些呢 ?

  • 这是为了确保系统的安全性和数据的保护。如果一个Token的有效期过长,那么在这个时间段内,即使用户已经退出或者改变了权限,恶意用户仍然可以利用该Token来访问系统资源。

另外,在一些敏感的应用场景中,比如金融、医疗等领域,Token的过长有效期也会增加数据泄露的风险。

因此,为了最大限度地减少潜在的安全威胁和数据泄露的风险,通常建议将Token的有效期设置为较短的时间,例如几小时或几天,并且需要定期更新Token,以确保系统的安全性。

那么所谓双token , 就是access_token 以及 refresh_token ,

其中access_token 的过期时间比较短,refresh_token 的过期时间相对于access_token 而言相当长,且会不断的刷新每次刷新后的refreshToken都是不同的

  • access_token 较短保证了用户的登录态的安全性
  • refresh_token 则是保证用户无需在短时间内进行反复的登录操作来保证登录状态的有效性 , 而refresh_token 不断刷新也进一步保证了安全性

验证流程

  1. 首先在Redis中存储每个用户的JWT token和refresh token,并设置相应的过期时间
  2. 当用户进行登录验证时,检查其凭证(如用户名和密码)是否正确。如果验证通过,则生成JWT token和refresh token,并将它们存储到Redis中。
  3. 在每个HTTP请求的头部附带上JWT token,在后端拦截器中进行解析和验证,以确保该token是有效的、未被篡改的,并且没有过期。
  4. 当JWT token过期时,使用refresh token获取一个新的JWT token。在拦截器中实现这一逻辑,可以通过检查JWT token的过期时间来确定是否需要更新token。
  5. 如果refresh token也已经过期,那么用户将需要重新进行登录验证。
  6. 为了防止多次请求同时触发token刷新操作,应该在拦截器中添加一个锁机制,以确保只有一个请求能够成功地刷新token。
  7. 最后,要注意保护好Redis中存储的JWT token和refresh token,以避免它们被恶意攻击者盗取或篡改。可以使用加密技术来防止这种情况的发生。
tokenRedis

配置拦截器

用户登录接口代码

用户账号密码验证成功那么我们就把accessToken 以及 refreshToken存入redis , 同时吧accessToken 返回给前端

  • 前端需要在之后的请求中携带上这个accessToken

    request.getHeader("Authorization")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public BaseResponse login(String userAccount, String password) {
//1. 获取的加密密码
UserEntity user = query().eq("user_account",userAccount).one();
String handlerPassword=user.getPassword();

//2. 查询用户密码是否正确
boolean checkpw = BCrypt.checkpw(password, handlerPassword);
if(!checkpw){
return ResultUtil.error(ErrorCode.PARAMS_ERROR,"账户名或密码错误!");
}
//3. 获取jwt的token并将token写入Redis
String token = jwtTokensService.generateAccessToken(user);
String refreshToken = jwtTokensService.generateRefreshToken(user);
JwtToken jwtToken = new JwtToken(token,refreshToken);
jwtTokensService.save2Redis(jwtToken,user);
// 返回jwtToken
return ResultUtil.success(token);
}

用户登录完之后redis中的数据如下

那么配置拦截器的时候我们只需要通过请求头获取携带的accessToken , 然后对token进行相关的操作

需要注意的是我们在生成token的时候已经把用户的基本信息 ( 包括 userID, userName , avatarUrl ) 等存入到了token中 , 之后直接通过解析token中的数据即可获取到这几个基本信息

拦截器的主要流程如下

  1. 从请求头中获取accessToken

  2. 解析token中的数据 , 验证accessToken 是否合法

    • 合法 => 数据保存到ThreadLocal

    • 不合法

      1. 通过解析的数据拼接key

      2. 从redis中获取refreshToken

      3. 判断refreshToken 是否过期

        • 没有过期

          生成新的accessToken , 保存到redis中, 同时设置响应头

          设置基本信息到ThreadLocal (注意这些操作对于用户来说是无感的)

        • 过期

          拦截 , 需要用户重新登录

  1. 当JWT token过期时,使用refresh token获取一个新的JWT token是常见的Token Refresh机制。在拦截器中实现这一逻辑可以通过检查JWT token的过期时间来确定是否需要更新token。如果JWT token过期,则使用refresh token向服务器请求一个新的JWT token,并将新的JWT token返回给客户端。客户端可以使用这个新的JWT token继续进行API请求。
  2. 如果refresh token也已经过期,那么用户将需要重新进行登录验证,并且系统会生成一个新的JWT token和refresh token,以供后续使用。因为JWT token和refresh token都是由服务器签发的,所以在这种情况下,返回给用户的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
public class ReFreshTokenInterceptor implements HandlerInterceptor {

JwtTokensService jwtTokensService;

StringRedisTemplate stringRedisTemplate;

public ReFreshTokenInterceptor(StringRedisTemplate stringRedisTemplate, JwtTokensService jwtTokensService){
this.jwtTokensService=jwtTokensService;
this.stringRedisTemplate=stringRedisTemplate;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization"); //从请求头中获取JWT access_token
if(StringUtils.isEmpty(token)){
throw new BusinessException(ErrorCode.NOT_LOGIN,"missing jwt token");
}
try {
// 解析并验证JWT token是否合法
boolean isTokenExpired = jwtTokensService.isTokenExpired(token);
UserEntity user = jwtTokensService.validateToken(token);
if(isTokenExpired){
// 如果token过期 , 那么需要通过refresh_token生成一个新的access_token
String refreshTokenKey = RedisConstant.REFRESH_TOKEN_PREFIX+ user.getUserId();
String refreshToken = stringRedisTemplate.opsForValue().get(refreshTokenKey);
if(StringUtils.isEmpty(refreshToken)){
throw new BusinessException(ErrorCode.NOT_LOGIN,"missing refresh token");
}
if(jwtTokensService.isTokenExpired(refreshToken)){
throw new BusinessException(ErrorCode.NOT_LOGIN,"超时, 请重新登录");
}
// 生成新的accessToken , 同时保存到redis
String accessToken = jwtTokensService.generateAccessToken(user);
String accessTokenKey = RedisConstant.ACCESS_TOKEN_PREFIX +user.getUserId();
stringRedisTemplate.opsForValue().set(accessTokenKey,accessToken,
JwtConstant.EXPIRATION_TIME, TimeUnit.SECONDS);

response.setHeader("Authorization",accessToken);
// 更新token这个动作在用户看来是未知的, 更新完之后需要在ThreadLocal中添加UserDTO
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
UserHolder.setUser(userDTO);
}else{
// 如果token没有过期, 那么直接添加用户的数据
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
UserHolder.setUser(userDTO);
}
return true;
} catch (Exception e) {
throw new RuntimeException("Invalid JWT token");
}
}
}

注意需要在springmvc的配置类中配置拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
StringRedisTemplate stringRedisTemplate;

@Resource
JwtTokensService jwtTokensService;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new ReFreshTokenInterceptor(stringRedisTemplate,jwtTokensService)).addPathPatterns("/**")
.excludePathPatterns("/**/login","/**/register");
WebMvcConfigurer.super.addInterceptors(registry);
}
}

需要注意的是在配置拦截器的时候 , 由于我们的拦截器并没有交给spring管理, 因此需要通过构造器来初始化JwtTokensService 以及 StringRedisTemplate

JwtTokenService代码

完整的JwtTokensServiceImpl代码如下

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
118
119
120
121
122
@Service
public class JwtTokensServiceImpl implements JwtTokensService {

@Resource
StringRedisTemplate stringRedisTemplate;

@Override
public String generateAccessToken(UserEntity user) {
Claims claims = Jwts.claims().setSubject(String.valueOf(user.getUserId()));
claims.put("avatar", user.getAvatarUrl());
Date now = new Date();
Date expirationDate = new Date(now.getTime() + EXPIRATION_TIME * 1000);
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();

String key = ACCESS_TOKEN_PREFIX + user.getUserId();
stringRedisTemplate.opsForValue().set(key, token, EXPIRATION_TIME, TimeUnit.SECONDS);

return token;
}

@Override
public String generateRefreshToken(UserEntity user) {
Claims claims = Jwts.claims().setSubject(user.getUserId().toString());

Date now = new Date();
Date expirationDate = new Date(now.getTime() + EXPIRATION_TIME * 1000);

String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();

String key = REFRESH_TOKEN_PREFIX + user.getUserId();
stringRedisTemplate.opsForValue().set(key, token, EXPIRATION_TIME, TimeUnit.SECONDS);
return token;
}


@Override
public UserEntity validateToken(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();

String userName = claims.getSubject();
String userId = getUserIdFromToken(token);
String avatarUrl = (String) claims.get("avatar") ;
String key = ACCESS_TOKEN_PREFIX + userId;
String storedToken = stringRedisTemplate.opsForValue().get(key);

if (storedToken != null && storedToken.equals(token)) {
// 如果Redis中存储的令牌与传入的令牌匹配,则验证通过
return new UserEntity(Long.parseLong(userId), userName ,avatarUrl);
}
} catch (Exception e) {
// 解析过程中发生异常,验证失败
System.out.println(e.getMessage());
}
return null;
}

@Override
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}

@Override
public void revokeToken(UserEntity user) {
String accessKey = ACCESS_TOKEN_PREFIX + user.getUserId();
String refreshKey = REFRESH_TOKEN_PREFIX + user.getUserId();
stringRedisTemplate.delete(accessKey);
stringRedisTemplate.delete(refreshKey);
}



@Override
public void cleanExpiredTokens() {
stringRedisTemplate.keys("*").forEach(key -> {
if (key.startsWith(ACCESS_TOKEN_PREFIX) || key.startsWith(REFRESH_TOKEN_PREFIX)) {
String token = stringRedisTemplate.opsForValue().get(key);
if (token != null && isTokenExpired(token)) {
stringRedisTemplate.delete(key);
}
}
});
}

@Override
public boolean isTokenExpired(String token) {
Date expirationDate = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody()
.getExpiration();
return expirationDate.before(new Date());
}


@Override
public void save2Redis(JwtToken jwtToken,UserEntity user) {
String token = jwtToken.getToken();
String refreshToken = jwtToken.getRefreshToken();
String accessKey = ACCESS_TOKEN_PREFIX + user.getUserId();
String refreshKey = REFRESH_TOKEN_PREFIX + user.getUserId();
stringRedisTemplate.opsForValue().set(accessKey,token,EXPIRATION_TIME, TimeUnit.SECONDS);
stringRedisTemplate.opsForValue().set(refreshKey,refreshToken,EXPIRATION_TIME, TimeUnit.SECONDS);
}
}

参考内容