在现代微服务架构中,使用 JWT(JSON Web Token)进行身份验证和授权已成为一种标准做法。然而,JWT 的无状态特性虽然带来了灵活性,但也需要我们在安全性方面多加考虑。为了解决 JWT 的一些潜在安全问题,比如令牌的有效期和刷新机制,双 Token 刷新策略是一个非常有效的解决方案。
本文博主将详细介绍如何在 Spring Cloud 项目下实现双 Token 刷新。
1.背景介绍
在使用 JWT 进行身份验证时,通常会有两个主要的 Token:
- ACCESS_TOKEN(访问令牌):用于实际的 API 请求,通常具有较短的有效期。
- REFRESH_TOKEN(刷新令牌):用于获取新的 Access Token,通常具有较长的有效期。
双 Token 刷新机制的基本思路是:
- 客户端在登录时,会生成两个 Token(ACCESS_TOKEN 和 REFRESH_TOKEN),[通常情况下,是将两个TOKEN 都返回给客户端,但是博主这里只返回了ACCESS_TOKEN给前端,REFRESH_TOKEN 和 USER_ID 一起存储到 Redis 中]
- ACCESS_TOKEN 在每次 API 请求时进行验证,过期后需要使用 REFRESH_TOKEN 来获取新的 ACCESS_TOKEN 。
- Refresh Token 可以定期更新,进一步提高系统的安全性
2.微服务架构设计
- 授权服务 (Authentication-Service):负责校验用户信息,生成 ACCESS_TOKEN 令牌 和 REFRESH_TOKEN 令牌;
- 资源服务 (User-Service):负责处理用户请求,完成业务流程;
- 网关服务 (Gateway-Service):提供项目的统一 API 入口,校验 TOKEN 的有效性
3.微服务代码实现
3.1.编写处理Token的工具类
因为 授权服务 和 网关服务 都需要处理Token,所以博主这里将处理Token的工具类抽取成为一个公共类进行调用
- 关于微服务公共类的抽取,请参考:Spring Cloud微服务项目公共类抽取
添加需要用到的 Maven 依赖:
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.52</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.12.3</version>
</dependency>
编写JwtUtils工具类:
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecureDigestAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
@Component
@RequiredArgsConstructor
public class JwtUtil {
// refresh token
private static final long DEFAULT_EXPIRATION_TIME_MS = 1000 * 60 * 60 * 24 * 7;
// access token
private static final long DEFAULT_REFRESH_TIME_MS = 1000 * 60 * 5;
// 这里的密钥替换成自己的
private static final String KEY = PublicKey.getPublicKey();
/**
* Create Refresh Token
*/
public static String createRefreshToken(String userId) {
// JWT secret key
SecretKey SECRET_KEY = Keys.hmacShaKeyFor(KEY.getBytes());
SecureDigestAlgorithm<SecretKey, SecretKey> algorithm = Jwts.SIG.HS512;
// Expiration time
Date expirationDate = new Date(System.currentTimeMillis() + DEFAULT_EXPIRATION_TIME_MS);
return Jwts.builder()
.signWith(SECRET_KEY, algorithm)
.expiration(expirationDate)
.claim("USER_ID", userId)
.compact();
}
/**
* Create Access Token
*/
public static String createAccessToken(String userId, String userEmail) {
SecretKey SECRET_KEY = Keys.hmacShaKeyFor(KEY.getBytes());
SecureDigestAlgorithm<SecretKey, SecretKey> algorithm = Jwts.SIG.HS512;
Date expirationDate = new Date(System.currentTimeMillis() + DEFAULT_REFRESH_TIME_MS);
return Jwts.builder()
.signWith(SECRET_KEY, algorithm)
.expiration(expirationDate)
.claim("USER_ID", userId)
.claim("USER_EMAIL", userEmail)
.compact();
}
/**
* Parsing tokens
*/
public static Jws<Claims> parseJWT(String token) {
// JWT secret key
SecretKey SECRET_KEY = Keys.hmacShaKeyFor(KEY.getBytes());
try {
return Jwts.parser()
.verifyWith(SECRET_KEY)
.build()
.parseSignedClaims(token);
} catch (JwtException | IllegalArgumentException e) {
throw new RuntimeException(e);
}
}
/*
* 直接使用Base64对token进行解码
*/
public static JSONObject parseJWTTime(String token) {
// 分离 JWT 的三部分
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("JWT 格式不正确");
}
// 解码负载部分
String payload = parts[1];
String decodedPayload = new String(Base64.getUrlDecoder().decode(payload));
// 解析 JSON 对象
return JSON.parseObject(decodedPayload);
}
/**
* Check if the token has expired
*/
public static boolean isTokenExpired(String token) {
try {
// Check if the current date is after the expiration date
Long expirationTimestamp = (Long) parseJWT(token).getPayload().get("exp");
Date expirationDate = new Date(expirationTimestamp * 1000);
return !expirationDate.before(new Date());
} catch (ExpiredJwtException e) {
// Token has expired
return false;
} catch (Exception e) {
// Other exceptions, consider token as invalid or tampered
return false;
}
}
}
因为REFRESH_TOKEN是直接存储在Redis中的,所以这里需要从ACCESS_TOKEN中解析出USER_ID,但由于直接使用jjwt工具进行解析会报Token过期的异常,博主这里就直接使用Base64直接提取JWT中的载荷部分的信息。
3.2.编写授权服务
在Authentication-Service模块添加所需的Maven依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Druid data source driver -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.9</version>
</dependency>
<!-- Mysql Plugin -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!-- MyBatis Plus Plugin -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
<!-- Knife4j Plugin -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<!-- 微服务公共类依赖 -->
<dependency>
<groupId>common</groupId>
<artifactId>common_utils</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
编写application.yml配置文件:
server:
port: 8100
spring:
application:
name: authentication-service
cloud:
nacos:
config:
import-check:
enabled: false
server-addr: ${your_server_host}
discovery:
namespace: ${your_nacos_id}
username: ${your_nacos_name}
password: ${your_nacos_passwd}
data:
redis:
port: 6379
host: ${your_redis_host}
password: ${your_redis_passwd}
datasource:
url: jdbc:mysql://${your_database_host}:3306/${your_database}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: name
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
springdoc:
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs
group-configs:
- group: 'system-service'
paths-to-match: '/**'
packages-to-scan: authentication.controller
knife4j:
enable: false
setting:
language: zh_cn
编写SecurityConfig:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests(
auth -> auth
// 博主这里为了省事,直接全部放行,反正也就只有登录接口放在这个微服务模块
// 实际操作中,博主还是不建议这样做
.anyRequest().permitAll()
);
return http.build();
}
}
编写AuthenticationService:
@Service
public class AuthenticationService extends ServiceImpl<UserMapper, UserInfoModel> {
private RedisTemplate<String, String> redisCache;
private final Map<String, String> token = new HashMap<>();
@Autowired
public void setRedisCache(RedisTemplate<String, String> redisCache) {
this.redisCache = redisCache;
}
/**
* 账号密码 登录
*/
public Map<String, String> loginAccount(String userEmail, String password) {
if (userEmail == null || password == null) {
token.put("ERROR", "NULL");
return token;
}
LambdaQueryWrapper<UserInfoModel> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserInfoModel::getUserEmail, userEmail);
UserInfoModel userInfo = baseMapper.selectOne(queryWrapper);
boolean isPassword = SaltedSHA256Util.verifyPassword(password, userInfo.getUserPassword());
if (!isPassword) {
token.put("ERROR", "ERROR");
return token;
}
if (userInfo.getAccountStatus() == AccountStatusEnum.OFF) {
token.put("ERROR", "ACCOUNT");
return token;
}
token.put("ACCESS_TOKEN", JwtUtil.createAccessToken(userInfo.getUserId(), userEmail));
redisCache.opsForValue().set(userInfo.getUserId(), JwtUtil.createRefreshToken(userInfo.getUserId()));
return token;
}
/**
* 邮箱验证码 登录
*/
public Map<String, String> loginEmail(UserInfoModel userInfo) {
/* 根据邮箱获取验证码 */
String code = redisCache.opsForValue().get(userInfo.getUserEmail());
/* 检查验证码是否正确 */
if (code == null || !code.equals(userInfo.getCode())) {
token.put("ERROR", "ERROR");
return token;
}
/* 校验完验证码,从Redis中删除验证码 */
redisCache.delete(userInfo.getUserEmail());
/* 检查用户账号状态是否异常 */
LambdaQueryWrapper<UserInfoModel> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserInfoModel::getUserEmail, userInfo.getUserEmail());
UserInfoModel user = baseMapper.selectOne(queryWrapper);
/* 如果用户不存在 */
if (user == null) {
token.put("ERROR", "NULL");
return token;
}
/* 如果用户账号被禁用 */
if (user.getAccountStatus() == AccountStatusEnum.OFF) {
token.put("ERROR", "ACCOUNT");
return token;
}
/* 用户身份校验完毕,返回token */
token.put("ACCESS_TOKEN", JwtUtil.createAccessToken(userInfo.getUserId(), userInfo.getUserEmail()));
token.put("REFRESH_TOKEN", JwtUtil.createRefreshToken(userInfo.getUserId()));
return token;
}
}
博主这里使用的是 MyBatis-Plus,所以直接继承 ServiceImpl 。[博主这里已省略Mapper部分的代码]
- 关于MyBatis的集成,请参考:Spring Cloud微服务项目集成MyBatis
编写 AuthenticationController:
@RestController
@RequestMapping("/api/login")
@Tag(name = "授权中心")
public class AuthenticationController {
private final AuthenticationService authenticationService;
@Autowired
public AuthenticationController(AuthenticationService authenticationService) {
this.authenticationService = authenticationService;
}
@PostMapping("/account")
@Operation(summary = "账号密码登录")
public Result<Object> login(HttpServletRequest httpServletRequest, @RequestBody UserInfoModel model) {
Map<String, String> result = authenticationService.loginAccount(model.getUserEmail(), model.getUserPassword());
return getObjectResult(result);
}
@PostMapping("/emailCode")
@Operation(summary = "邮箱验证码登录")
public Result<Object> loginEmailCode(@RequestBody UserInfoModel model) {
Map<String, String> result = authenticationService.loginEmail(model);
return getObjectResult(result);
}
private Result<Object> getObjectResult(Map<String, String> result) {
if (Objects.equals(result.get("ERROR"), "ERROR") || Objects.equals(result.get("ERROR"), "NULL")) {
return Result.USER_INFO_ERROR();
}
if (Objects.equals(result.get("SUCCESS"), "ACCOUNT")) {
return Result.FAILURE("The account status is abnormal");
}
return Result.SUCCESS(result);
}
}
3.3.编写网关服务
在 Gateway-Service 微服务模块添加所需的 Maven 依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- knife4j Aggregate documents -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-gateway-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 因为 Gateway 与 Spring-web 相互冲突,所以我们这里需要先排除一些特定的依赖 -->
<dependency>
<groupId>common</groupId>
<artifactId>common_utils</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
</exclusion>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</exclusion>
<exclusion>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.12.3</version>
</dependency>
编写 application.yml 配置文件:
server:
port: 8000
spring:
application:
name: gateway-service
cloud:
nacos:
config:
import-check:
enabled: false
server-addr: ${your_server_host}
discovery:
namespace: ${your_nacos_id}
username: ${your_nacos_name}
password: ${your_nacos_passwd}
gateway:
discovery:
locator:
enabled: true
routes:
- id: authentication-service
uri: lb://authentication-service
predicates:
- Path=/api/login/**
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/public/**, /api/auth/user/**
- id: web-service
uri: lb://web-service
predicates:
- Path=/api/public/**, /api/auth/web/**
data:
redis:
port: 6379
host: ${your_redis_host}
password: ${your_server_passwd}
knife4j:
gateway:
enabled: true
strategy: manual
discover:
enabled: true
version: openapi3
excluded-services:
- gateway-service
routes:
- name: user-service
service-name: user-service
context-path: /
url: /user-service/v3/api-docs?group=user-service
order: 1
- name: authentication-service
service-name: authentication-service
context-path: /
url: /authentication-service/v3/api-docs?group=web-service
order: 2
编写校验Token的过滤器:
@Component
public class TokenValidationFilter implements GlobalFilter, Ordered {
private static final String[] EXCLUDED_PATHS = {"/api/public/**", "/api/login/**",
"/doc,html/**", "/user-service/v3/**", "/order-service/v3/**", "/system-service/v3/**", "/authentication-service/v3/**",
"/finance-service/v3/**", "/web-service/v3/**", "/swagger-resources/**", "/webjars/**"
}; // 不需要token验证的路径
private RedisTemplate<String, String> redisCache;
private final Map<String, String> responseBody = new HashMap<>();
@Autowired
public void setRedisCache(RedisTemplate<String, String> redisCache) {
this.redisCache = redisCache;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
if (isExcludedPath(path)) {
return chain.filter(exchange); // 路径匹配到不需要token验证的路径,直接通过
}
HttpHeaders headers = exchange.getRequest().getHeaders();
String token = headers.getFirst("Authorization");
// 进行token验证
switch (isValidToken(token)) {
case 0, 3 -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete(); // Token不合法,返回401错误
}
case 2 -> {
String jwt = null;
if (token != null) {
jwt = token.substring(14);
}
String userId = null;
if (jwt != null) {
userId = JwtUtil.parseJWTTime(jwt).getString("USER_ID");
}
String userEmail = null;
if (jwt != null) {
userEmail = JwtUtil.parseJWTTime(jwt).getString("USER_EMAIL");
}
String refreshToken = null;
if (userId != null) {
refreshToken = redisCache.opsForValue().get(userId);
}
if (refreshToken != null) {
String new_token = JwtUtil.createAccessToken(userId, userEmail);
responseBody.put("ACCESS_TOKEN", new_token);
exchange.getResponse().setStatusCode(HttpStatus.OK);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
try {
return exchange.getResponse().writeWith(
Mono.just(exchange.getResponse().bufferFactory().wrap(
new ObjectMapper().writeValueAsBytes(responseBody)
))
);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete(); // Token不合法,返回401错误
}
default -> {
return chain.filter(exchange); // Token合法,继续处理请求
}
}
}
private boolean isExcludedPath(String path) {
for (String excludedPath : EXCLUDED_PATHS) {
if (path.startsWith(excludedPath.replace("**", ""))) {
return true;
}
}
return false;
}
private int isValidToken(String header) {
/* 检验 header 是否有效 */
if (header == null || !header.startsWith("LINGUA-DIALOG ")) {
// 如果 header 无效,返回未授权响应
return 0;
} else {
/* 提取 header */
String token = header.substring(14);
/* 检验 token 是否过期 */
if (!isValidJwt(token)) {
return 2;
}
/* 校验 token 是否合法 */
Jwt<JwsHeader, Claims> jwt = JwtUtil.parseJWT(token);
String userId = jwt.getPayload().get("USER_ID").toString();
String userEmail = jwt.getPayload().get("USER_EMAIL").toString();
boolean isNull = userId != null && userEmail != null;
if (!isNull) {
return 3;
} else {
return 1;
}
}
}
@Override
public int getOrder() {
return -1; // 确保过滤器的顺序
}
private boolean isValidJwt(String token) {
// Check whether the token has expired
return JwtUtil.isTokenExpired(token);
}
}
博主这里偷了个懒(O(∩_∩)O),正常情况下,还应当刷新REFRESH_TOKEN,不过由于REFRESH_TOKEN没有返回给前端,所以博主觉得就没有这个刷新的必要了
为 Gateway 配置跨域:
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsFilter() {//Create a CorsWebFilter instance
//Create a CorsConfiguration object to configure CORS policies
CorsConfiguration config = new CorsConfiguration();
//Allow all HTTP methods to access across domains
config.addAllowedMethod("*");
//Allow cross domain access from all sources (Origin)
config.addAllowedOrigin("*");
//Allow all request header information to be accessed across domains
config.addAllowedHeader("*");
//Create a URL based CORS configuration source and use PathPatternParser to parse URL paths
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
//Apply the configured CorsConfiguration object to all URL paths
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
为 Gateway 添加全局过滤器:
@Configuration
public class GatewayConfig {
@Bean
public TokenValidationFilter tokenValidationFilter() {
return new TokenValidationFilter();
}
}
4.服务测试
博主这里集成了Swagger接口文档,就不使用ApiFox、PostMan等接口测试工具了。
- Swagger的集成请参考:Spring Cloud微服务项目聚合Swagger文档
进入Swagger网址后,我们输入登录信息进行提交:
如图,我们已经拿到了服务返回的 ACCESS_TOKEN,接下来进行业务测试:
直接带上Token查询当前用户信息:
然后,我们等待5min (Token过期时间) ,再次访问:
由于Token过期,服务器直接将请求拦截了下来,并且返回了一个全新的Token
接下来,我们将新的ACCESS_TOKEN进行替换,再次查询:
可见,携带新的Token后,就能够正常访问业务了 (完结!撒花~~~)