Bootstrap

Spring Cloud + JWT实现双Token刷新

        在现代微服务架构中,使用 JWT(JSON Web Token)进行身份验证和授权已成为一种标准做法。然而,JWT 的无状态特性虽然带来了灵活性,但也需要我们在安全性方面多加考虑。为了解决 JWT 的一些潜在安全问题,比如令牌的有效期和刷新机制,双 Token 刷新策略是一个非常有效的解决方案。

        本文博主将详细介绍如何在 Spring Cloud 项目下实现双 Token 刷新。

1.背景介绍

在使用 JWT 进行身份验证时,通常会有两个主要的 Token:

  • ACCESS_TOKEN(访问令牌):用于实际的 API 请求,通常具有较短的有效期。
  • REFRESH_TOKEN(刷新令牌):用于获取新的 Access Token,通常具有较长的有效期。

 双 Token 刷新机制的基本思路是:

  1. 客户端在登录时,会生成两个 TokenACCESS_TOKEN REFRESH_TOKEN),[通常情况下,是将两个TOKEN 都返回给客户端,但是博主这里只返回了ACCESS_TOKEN给前端,REFRESH_TOKEN USER_ID 一起存储到 Redis 中]
  2. ACCESS_TOKEN 在每次 API 请求时进行验证,过期后需要使用 REFRESH_TOKEN 来获取新的 ACCESS_TOKEN
  3. Refresh Token 可以定期更新,进一步提高系统的安全性

2.微服务架构设计

  1. 授权服务 (Authentication-Service):负责校验用户信息,生成 ACCESS_TOKEN 令牌 和 REFRESH_TOKEN 令牌;
  2. 资源服务 (User-Service):负责处理用户请求,完成业务流程;
  3. 网关服务 (Gateway-Service):提供项目的统一 API 入口,校验 TOKEN 的有效性

3.微服务代码实现

3.1.编写处理Token的工具类 

        因为 授权服务 和 网关服务 都需要处理Token,所以博主这里将处理Token的工具类抽取成为一个公共类进行调用

 添加需要用到的 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部分的代码]

编写 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网址后,我们输入登录信息进行提交:

如图,我们已经拿到了服务返回的 ACCESS_TOKEN,接下来进行业务测试:

 直接带上Token查询当前用户信息:

然后,我们等待5min (Token过期时间) ,再次访问:

由于Token过期,服务器直接将请求拦截了下来,并且返回了一个全新的Token 

接下来,我们将新的ACCESS_TOKEN进行替换,再次查询:

可见,携带新的Token后,就能够正常访问业务了 (完结!撒花~~~) 

;