XVIT

Jul 01, 2026

双Token认证(java+radis)

1. 前情提要

java的双Token认证我们一般使用的是无状态Jwt+uuid的方式。jwt无状态信息通过接口返回给前端,uuid会以刷新Token的有状态返回给前端,refreshToken会存放在前端的localStorage中,jwt会放在cookie中,键名为Token。当需要权限的接口被调用的时候,接口会先获取header中的Token,之后对Token解码判断是否合法,不合法就会调用refreshToken的接口获取新的jwt并重新获取数据。radius仅存储uuid,key为用户的uid。

2. 代码实现

2.1. Jwt工具类

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
@Component
public class JwtTokenTools {
@Value("${jwt.secret}")
private String secret; // 秘钥
@Value("${jwt.access-expire}")
private long accessExpireTime; // 权限token时长
private static final String AUTHOR = "xxxxxx-xxxx"; // 签发者
private static final String AUDIENCE = "xxxxxx"; // 接收者

/**
* 打包生成jwt结构
* Param {
* uid - 用户登录id,
* univ - 来自数据库固定ID
* rand_str - 随机uuid
* TimeNum - 过期时间
* }
*/
private String TokenCreate(String uid, String univ, String rand_str, long TimeNum) throws JoseException {
JwtClaims claims = new JwtClaims();
claims.setIssuer(AUTHOR);
claims.setSubject(uid);
claims.setAudience(AUDIENCE);
claims.setIssuedAt(NumericDate.now());
NumericDate expire = NumericDate.now();
expire.addSeconds(TimeNum * 60);
claims.setExpirationTime(expire);

JsonWebSignature jws = new JsonWebSignature();
jws.setPayload(claims.toJson());
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256);
jws.setHeader("typ", "JWT");
jws.setKey(new HmacKey(secret.getBytes(StandardCharsets.UTF_8)));
return jws.getCompactSerialization();
}

/**
* 生成临时权限分配token
* Param {
* uid - 用户登录id,
* univ - 来自数据库固定ID
* rand_str - 随机uuid
* }
*/
public String createAccessToken(String uid, String univ, String rand_str) throws JoseException {
return TokenCreate(uid, univ, rand_str, accessExpireTime);
}

/**
* 解码token是否有效
* Param {
* token - 需要解码的token
*/
public JwtClaims ValidateToken(String token) throws InvalidJwtException {
JwtConsumer consumer = new JwtConsumerBuilder()
.setRequireExpirationTime()
.setAllowedClockSkewInSeconds(30)
.setExpectedIssuer(AUTHOR)
.setRequireSubject()
.setExpectedAudience(AUDIENCE)
.setVerificationKey(new HmacKey(secret.getBytes(StandardCharsets.UTF_8)))
.build();
return consumer.processToClaims(token);
}
}

生成体里面的参数可以根据用户需求自定义,这里只是简单示例。下面我将给您列出默认jwt固定的参数。

name param desc
Issuer string 签发者
Subject string 主题
Audience string 受众
ExpirationTime long 过期时间戳
NotBefore long 生效时间戳
IssuedAt long 签发时间戳
JWTID string 令牌唯一ID
alg string 加密算法[header部分]: HS256、RS256、ES256
typ JWT 必须是JWT字符串,标志是JWT
kid Strubf 秘钥ID,多秘钥场景,匹配对应的校验秘钥

setClaim函数是创建自定义参数,setClaim("key", value)

2.2. Radis工具类

这里写radis是因为我们的状态refreshToken是要存在radis中的,用来刷新jwt的签名认证。当退出我们的jwt是无状态的所以必须要强制让他失效,所以我们需要给他设置blacklist

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
@Component
@RequiredArgsConstructor
public class AuthRedis {

private final StringRedisTemplate redisTemplate;

/** 保存RefreshToken到redis */
public void saveRefreshToken(String uid, String refreshToken, int expiresInSeconds) {
redisTemplate.opsForValue().set("refresh:uid:" + uid, refreshToken, expiresInSeconds, TimeUnit.SECONDS);
}

/** 根据uid提取对应的refreshtoken */
public String getRefreshToken(String uid) {
return redisTemplate.opsForValue().get("refresh:uid:" + uid);
}

/** 根据uid删除对应的refreshtoken记录 */
public void delRefreshToken(String uid) {
redisTemplate.delete("refresh:uid:" + uid);
}

/** 创建黑名单 */
public void addBlackList(String univ, int expireSeconds) {
redisTemplate.opsForValue().set("blacklist:user:" + univ, "1", expireSeconds, TimeUnit.SECONDS);
}

/** 检查是否在黑名单中 */
public boolean isBlackList(String univ) {
return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:user:" + univ));
}
}

2.3. 开启Spring Security全局控制

我们使用的是无状态的认证,所以不用OAUTH2.0的认证方式,我们只需要开启Spring Security全局控制即可。

SecurityConfig.java的代码如下所示:

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
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtTokenTools jwtTokenTools;
private final AuthRedis authRedis;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
.xssProtection(HeadersConfigurer.XXssConfig::disable)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/login", "/api/retoken").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JWTAuthFilter(jwtTokenTools, authRedis), UsernamePasswordAuthenticationFilter.class)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable);
return http.build();
}
}

这里是对所有的接口进行拦截,除了/api/login/api/retoken,其他的接口都需要进行jwt认证。

2.4. jwt权限拦截

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
@RequiredArgsConstructor
public class JWTAuthFilter extends OncePerRequestFilter {

private final JwtTokenTools jwtTokenTools;
private final AuthRedis authRedis;

private static final List<String> ALLOWED_PATHS = Arrays.asList(
"/api/retoken",
);

/**
* 反馈封装
* res - 反馈的函数 response或request
* status - 返回的函数 Result
*/
private void returnStatus(HttpServletResponse response, Result<?> result) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
try (PrintWriter writer = response.getWriter()) {
writer.write(result.toString());
writer.flush();
}
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestPath = request.getRequestURI();
if (isAllowedPath(requestPath)) {
filterChain.doFilter(request, response);
return;
}

String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
returnStatus(response, Result.Unauthorized());
return;
}

String token = auth.replace("Bearer ", "").trim();
JwtClaims claims;
try {
claims = jwtTokenTools.ValidateToken(token);
} catch (InvalidJwtException e) {
returnStatus(response, Result.Unauthorized());
return;
}
try {
String idStr = claims.getStringClaimValue("id_str");
if (authRedis.isBlackList(idStr)) {
returnStatus(response, Result.Unauthorized());
return;
}

String userId = claims.getSubject();
Authentication authToken = new UsernamePasswordAuthenticationToken(userId, null, null);
SecurityContextHolder.getContext().setAuthentication(authToken);
}catch (Exception e) {
returnStatus(response, Result.DiyErrFailed("出现了不可预知的错误,请联系管理员解决!"));
return;
}
filterChain.doFilter(request, response);
}
// 检查请求路径是否存在允许访问列表中
private boolean isAllowedPath(String requestPath) {
return ALLOWED_PATHS.stream().anyMatch(path -> requestPath.equals(path) ||
(requestPath.startsWith(path) && requestPath.length() >path.length() &&
requestPath.charAt(path.length()) == '/'));
}
}

我们这里的代码就是在前端请求接口的时候会在Authorization中携带jwt的token,我们在释放内容之前拦截请求拿取header中的token,之后进行校验是否有效,有效的话就释放请求进入到对应的函数处理数据,无权限的话就返回401。当然我们有些接口不需要权限也可以访问,比如:/api/retoken,这个接口是用来刷新token的,我们需要判断refreshToken是否有效即可不用判断jwt是否有效,所以就要把它先跳过,在者还有/api/login这个接口,这个接口是用来登录的,我们登录成功之后会返回jwtrefreshToken,所以这个接口也不需要权限。

3. 总结

通过上面的代码,我们就可以实现双token认证了,当然我们也可以根据需求进行修改,比如:jwt的过期时间,refreshToken的过期时间,jwtrefreshToken的生成方式等等。具体的使用可以参照接口的函数去调用,当然我们也可以根据需求进行修改。
具体的工作原理是:用户登录的时候会向后端发送用户的账号密码,之后后端会生成JwtrefreshToken两个参数,jwt会随着接口返回给前端(json格式),之后refreshToken会随着cookie传过去,之后后端会在radis中写入uid对应的refreshToken,当用户登录成功前端会拦截请求判断是否存在需要的参数返回到对应的首页或者其他页面,当用户需要获取其他需要权限的数据的时候,会携带Authorization作为权限的token,后端会解析是否有效,如果有效的话就会继续查询数据并且返回,如果无效则会返回401,如果refreshToken失效了,则会返回401,如果refreshToken有效,则会重新生成jwtrefreshToken,并且返回给前端,前端会更新Authorizationcookie中的refreshToken,之后就可以继续访问其他需要权限的接口了。如果使用户手动退出登录或者长时间未使用导致refreshToken失效,则会返回401,前端会跳转到登录页面,用户重新登录即可。

OLDER > < NEWER