阶段三:从有状态羁绊到无状态自由(基于 Spring Security 6 + JWT 的前后端分离认证实践)

承接阶段二 Spring Security 6 + Redis 分布式 Session 有状态认证方案,在解决集群会话共享、持久化的基础上,针对有状态架构的终端适配缺陷与纯 JWT 认证的安全管控、用户体验短板,完成单服务架构从分布式 Session 到 JWT 混合无状态认证的核心升级。通过 JWT 实现无状态轻量化认证,结合 Redis 完成令牌全生命周期管控与无感刷新机制,兼顾无状态架构的通用性、跨域能力、用户体验与企业级会话安全管控要求,构建标准化、可落地、可扩展的前后端分离认证体系,为微服务架构下的统一认证授权奠定技术底座。

一、传统认证架构的核心局限性:

阶段二实现的 Redis 分布式 Session 方案,仅解决了单体集群的会话共享问题,未改变服务端存储会话状态、客户端依赖 Cookie 传输的核心架构;而纯 JWT 无状态认证虽突破了终端与跨域限制,但缺失企业级安全管控能力与无感刷新体验。两种方案均无法满足现代前后端分离、多终端异构接入的生产级需求,核心缺陷如下:

1、多终端适配能力不足:

基于 Cookie 传输 SessionID 的模式,仅适配 Web 浏览器场景,在移动端 APP、小程序、第三方开放接口等场景中无法自动携带凭证,无法实现全终端统一认证。

2、传输规范不符合行业标准:

采用浏览器隐式 Cookie 传参,不符合前后端分离架构「请求头显式携带令牌」的行业通用最佳实践,会导致接口在多终端场景下的通用性、可移植性大幅降低。

3、架构耦合度高,轻量化不足:

服务端强依赖 Redis 存储全量会话状态,单服务架构仍需绑定外部中间件,提升了部署与运维成本,违背无状态服务轻量化设计原则。

4、跨域场景存在稳定性风险:

跨域场景下的 Cookie 携带受浏览器同源策略、容器环境限制,生产环境易出现凭证丢失、认证失效等问题。

5、纯 JWT 认证无企业级管控与用户体验缺陷:

令牌签发后无法主动失效,不支持登出吊销、强制下线、会话限流等能力;且单令牌模式过期后需用户重新登录,无无感刷新机制,用户体验极差,无法满足企业级身份安全与会话管理要求。

二、JWT 的组成结构:

16.1

JWT(JSON Web Token)是一种轻量级、自包含、基于 JSON 的安全令牌格式,核心遵循「Header(头部).Payload(载荷).Signature(签名)」三段式标准结构,三部分通过 Base64URL 编码后以.连接,最终生成紧凑、URL 安全的字符串,可在各方之间安全传输身份、权限等核心信息,是本项目 Spring Security 6+JWT 前后端分离无状态认证体系的核心数据载体,既实现了无状态轻量化的全终端适配,又为 Redis 令牌生命周期管控提供了基础支撑。

1、Header(头部):

描述令牌元数据,声明加密算法、令牌类型等基础信息,是令牌校验的核心依据。

维度

说明

格式规范

标准 JSON 对象,固定包含两个核心字段:

  • alg:签名算法,本项目采用HS256(HMAC-SHA256 对称加密算法)
  • typ:令牌类型,固定为JWT,标识令牌格式

项目示例

{

  "alg": "HS256",

  "typ": "JWT"

}

编码处理

将上述 JSON 对象进行 Base64URL 编码,生成 JWT 令牌的第一段字符串

2、Payload (载荷):

存储令牌核心业务数据,承载用户身份、权限、有效期等无状态认证所需信息,是 JWT 实现无状态认证的关键。

维度

说明

字段分类

  • 注册声明(标准字段,RFC 7519 规范):iss(签发者)、exp(过期时间)、sub(主题)、jti(令牌 ID)
  • 私有声明(自定义字段):本项目自定义存储用户核心业务信息

项目示例

{

  "userId": 1001,         // 用户ID

  "username": "admin",   // 用户名

  "roles": "ADMIN,USER",  // 角色权限

  "exp": 1760000000      // 过期时间(Unix时间戳)

}

编码处理

将上述 JSON 对象进行 Base64URL 编码,生成 JWT 令牌的第二段字符串

注:Payload 仅做 Base64URL 编码(非加密),禁止存储密码、密钥等敏感信息。

3、Signature(签名):

保障令牌防篡改、验真伪,是 JWT 安全性的核心保障。

维度

说明

生成规则

  • 拼接编码后的Header + . + 编码后的Payload
  • 采用 Header 中声明的HS256对称加密算法
  • 配合服务端密钥(secret)进行加密运算,最终生成签名字符串

生成公式

Signature = HMACSHA256(

  Base64UrlEncode(Header) + "." + Base64UrlEncode(Payload),

  密钥

)

三、无感刷新机制:

在 JWT 无状态认证体系中,令牌一旦签发,过期时间便固定不可修改。若未设计续期机制,AccessToken 过期后所有请求都会鉴权失败,用户必须重新登录,导致频繁 “强制下线”,严重影响使用体验。因此,无感刷新机制是企业级系统保障会话连续性、提升用户体验的必备能力,能够在令牌过期前后自动完成续期,全程无需用户感知与手动操作。

1、方案一:Refresh Token 双令牌机制(主流标准方案)

采用短期 AccessToken + 长期 RefreshToken分离架构:

  • AccessToken 负责业务接口鉴权,时效短、安全性高;
  • RefreshToken 专门用于令牌续期,时效更长,通常存储在 HttpOnly Cookie 中;
  • AccessToken 过期时,前端自动调用刷新接口,使用 RefreshToken 换取新令牌;
  • 后端校验通过后签发新令牌,客户端自动替换,实现无感续期。

2、方案二:滑动过期(Sliding Expiration)机制

服务端在每次请求时检查令牌剩余有效期:

  • 若令牌即将过期,则自动生成新令牌返回给前端;
  • 前端更新本地令牌,延长会话时间;
  • 实现简单,但会破坏 JWT 无状态特性,较少用于纯无状态架构。

3、方案三:客户端自动刷新机制(前端拦截实现)

由前端请求拦截器统一处理令牌过期逻辑:

  • 前端拦截响应,当捕获到令牌过期错误码(如 401)时;
  • 不提示用户,自动调用后端刷新接口获取新令牌;
  • 获取成功后自动重试原请求;
  • 全程无感知、无跳转、无操作,用户体验最流畅。

注:本方案不是独立刷新方案,而是双令牌机制的前端配合实现,两者必须搭配使用,是目前前后端分离项目的标准组合。

四、JWT + Redis 混合无状态认证核心思想:

本阶段采用"JWT无状态核心 + Redis令牌管控增强 + 无感刷新机制"的企业级混合架构,既保留无状态架构的优势,又补齐纯JWT的安全短板与体验缺陷,是当前业界标准的生产级认证方案(注:本方案虽名为"无状态",但通过Redis实现轻量级令牌管控,并非完全无状态,而是"弱状态"或"半无状态"架构)。

1、无状态认证基底:

服务端不存储任何用户会话状态,将用户身份、权限、有效期等核心信息通过数字签名封装为 JWT 令牌,由客户端自主存储;请求时通过标准 Authorization 请求头显式传递令牌,服务端仅通过密钥完成签名、合法性、有效期校验,无需查询数据库,实现轻量化、全终端兼容、跨域无感知的无状态认证。

2、Redis 令牌生命周期增强:

在不破坏无状态核心的前提下,基于 Redis 实现令牌全局管控,弥补纯 JWT 无法主动失效的缺陷,实现令牌黑名单、双令牌存储、单用户会话限流、强制登出等企业级安全能力。

3、无感刷新核心机制:

采用短期访问令牌 (AccessToken) + 长期刷新令牌 (RefreshToken) 双令牌架构:访问令牌负责接口鉴权(短时效、高安全),刷新令牌负责令牌续期(长时效、低暴露);当访问令牌过期时,前端无需用户操作、无感知自动请求刷新接口,后端验证刷新令牌合法性后签发新令牌,实现会话自动续期,极致优化用户体验。

五、本阶段核心落地任务:

本阶段完全复用阶段二核心能力(用户角色数据库体系、BCrypt密码加密、动态权限校验、全局异常处理),复用Redis依赖与配置,仅改造认证传输方式与状态管理模式,不引入微服务组件(当前为单体架构,但Redis令牌存储方案天然支持微服务共享),最终实现单服务无状态认证+无感刷新完整闭环。核心任务如下:

1、无状态架构改造:

关闭Spring Security原生会话管理,配置SessionCreationPolicy.STATELESS无状态策略;禁用默认会话Cookie机制,彻底脱离分布式Session强依赖,实现服务无状态化。

2、JWT核心能力封装:

封装JWT工具类,实现令牌签发(承载用户ID、用户名、角色权限)、数字签名校验、过期校验、载荷解析等核心能力,保证令牌防篡改、可解析、高安全。

3、Redis令牌全生命周期管理:

基于Redis实现企业级令牌管控能力,支撑后续微服务共享认证:

  • 令牌注册:登录成功后生成双令牌,绑定用户信息与过期时间存入Redis;
  • 令牌黑名单:登出、强制下线时将令牌加入黑名单,请求时优先校验;
  • 双令牌机制:分离访问令牌与刷新令牌,支撑无感刷新;
  • 会话管控:使用Redis Lua脚本实现原子性会话管理,限制单用户最大会话数,超限时自动踢掉最旧会话,支持异地登录强制下线;
  • 双重校验:JWT合法性校验+ Redis黑名单/有效性校验,提升安全等级。

4、无感刷新机制全链路实现:

  • 刷新令牌安全存储:RefreshToken存入HttpOnly Cookie,防XSS窃取;
  • 刷新接口白名单配置:刷新接口置于Security白名单,在服务层校验刷新令牌合法性,避免与JWT认证过滤器冲突;
  • Refresh Token轮换机制:每次刷新时生成新的Refresh Token,并将旧Refresh Token加入黑名单,防止令牌泄露后的重放攻击;
  • 自动续期逻辑:验证通过后签发新双令牌,覆盖客户端存储;
  • 全程无感知:用户无需重新登录,会话自动延续,体验无损。

5、认证流程全链路重构:

重构登录、登出、令牌刷新、强制登出接口,适配JWT +无感刷新架构;开发JWT认证过滤器,从请求头解析令牌并完成全量校验,校验通过后构建CustomUserDetails作为Principal并存入Spring Security认证上下文,确保UserContextUtil可获取完整用户信息。

6、原有能力无缝集成复用:

将JWT认证过滤器集成至Spring Security过滤器链,完全复用阶段二的动态权限授权(@PreAuthorize)、全局异常处理能力,保证权限体系一致性,最小化代码改造。

7、全终端标准化适配:

统一前端认证规范:Authorization: Bearer {JWT}请求头传参,支持Web、APP、小程序、第三方接口全场景适配;客户端无需处理会话Cookie,仅需适配无感刷新逻辑,降低对接成本。

8、单服务无状态认证+无感刷新闭环验证:

完成端到端功能测试,覆盖令牌签发、双重校验、过期、无感自动刷新、黑名单、会话管控、权限控制全流程,确保架构可运行、可测试、可运维,满足企业生产环境要求。

六、JWT 无状态认证相关实践:

1、数据库设计:

本阶段复用阶段二数据库方案,采用 MySQL 存储用户身份、角色及关联关系数据,沿用用户-角色多对多关联模型设计数据表。

2、项目结构:

17

技术

版本

Java

17

Spring Boot

3.2.2

Spring Security

6.2.1

MyBatis-Plus

3.5.5

Redis

-

MySQL

8.x

JJWT

0.11.5

Lombok

1.18.30

FastJSON2

2.0.32

(1)、Redis-Key 结构说明表:

Key 前缀 / 完整格式

作用说明

过期时间 (TTL)

token:{accessToken}

存储 Access Token 核心信息,包含用户 ID、用户名、角色列表、签发时间

1 小时 (3600000ms)

refresh:token:{refreshToken}

存储 Refresh Token 核心信息,包含用户 ID、用户名、角色列表、签发时间

7 天 (604800000ms)

token:blacklist:{accessToken}

Access Token 黑名单,用于标记已失效令牌,存储值固定为 1

沿用原 Token 剩余过期时间

refresh:token:blacklist:{refreshToken}

Refresh Token 黑名单,用于标记已失效刷新令牌,存储值固定为 1

沿用原 Refresh Token 剩余过期时间

user:session:{userId}

用户会话管理(有序集合 ZSet),存储用户全量 Refresh Token,按登录时间排序

7 天 (604800000m)

3、用户登录认证流程:

21

4、请求访问控制流程:

22

5、令牌刷新流程:

23

6、登出流程:

24

7、Maven依赖:

<properties>
    <java.version>17</java.version>
    <spring-boot.version>3.2.2</spring-boot.version>
</properties>

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring Security -->
    <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>
    
    <!-- MySQL驱动 -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- MyBatis-Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>3.5.5</version>
    </dependency>
    
    <!-- JJWT -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    
    <!-- 工具类 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>com.alibaba.fastjson2</groupId>
        <artifactId>fastjson2</artifactId>
        <version>2.0.32</version>
    </dependency>
</dependencies>

8、YML配置:

server:
  port: 8080

spring:
  application:
    name: stage3-security6-demo

  # 数据源配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123

  # Redis 连接核心配置
  data:
    redis:
      database: 1          # Redis数据库索引(默认为0)
      host: 127.0.0.1      # Redis服务器地址
      password:            # Redis服务器连接密码
      port: 6379           # Redis服务器连接端口
      timeout: 300000      # 连接超时时间(毫秒),即最大等待时间
      lettuce:
        pool:
          max-active: 8    # 连接池最大活跃连接数(默认8,高并发可调至20)
          max-idle: 5      # 连接池最大空闲连接数(默认5)
          max-wait: -1ms   # 最大阻塞等待时间(-1ms=无限制,3.2.2需显式单位)
          min-idle: 0      # 连接池最小空闲连接数(默认0)
        shutdown-timeout: 100ms # 连接池关闭超时时间

# 权限规则配置
permission:
  # 白名单路径,无需登录即可访问
  white-list:
    - /api/auth/login
    - /v1/hello
    - /api/auth/v1/refresh-token

# MyBatis-Plus配置
mybatis-plus:
  configuration:
    # 开启驼峰命名转换
    map-underscore-to-camel-case: true
    # 打印SQL日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      # 主键自增策略
      id-type: AUTO

# JWT配置
jwt:
  # JWT密钥: 测试环境使用,生产环境请更换
  secret: your-secret-key-change-in-production
  # JWT有效期
  access-token-expire: 3600000  # 1小时
  # Refresh Token有效期
  refresh-token-expire: 604800000  # 7天
  # Refresh Token Cookie过期时间(秒)
  refresh-token-cookie-max-age: 604800  # 7天
  # Refresh Token Cookie是否使用Secure标志
  # 是否仅 HTTPS 传输:开发环境设为false,生产环境设为true
  refresh-token-cookie-secure: false

9、核心实现流程:

18

(1)、配置类实现:

RedisConfig.java -Redis配置

配置 Redis 序列化方式

/**
 * Redis 配置类
 */
@Configuration
public class RedisConfig {

    /**
     * RedisTemplate 序列化配置
     */
    @Bean
    @SuppressWarnings("all")
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer =
                new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        jackson2JsonRedisSerializer.setObjectMapper(om);

        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        template.setKeySerializer(stringRedisSerializer);
        template.setHashKeySerializer(stringRedisSerializer);
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();

        return template;
    }


}

(2)、工具类实现:

JwtUtil.java - JWT工具类

实现JWT令牌的生成、解析、验证等核心功能

import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;

/**
 * JWT工具类
 * <p>
 * 实现JWT令牌的签发、校验、解析等核心功能
 * </p>
 */
@Slf4j
@Component
public class JwtUtil {

    /**
     * JWT密钥
     */
    @Value("${jwt.secret:your-secret-key-change-in-production}")
    private String secret;

    /**
     * 访问令牌过期时间(毫秒)
     */
    @Value("${jwt.access-token-expire:3600000}")
    private long accessTokenExpire;

    /**
     * 生成JWT访问令牌
     * @param claims 自定义载荷
     * @return JWT令牌
     */
    public String generateAccessToken(Map<String, Object> claims) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + accessTokenExpire);

        JwtBuilder builder = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expireDate)
                .signWith(getSecretKey(), SignatureAlgorithm.HS256);

        return builder.compact();
    }

    /**
     * 解析JWT令牌
     * @param token JWT令牌
     * @return 载荷
     * @throws JwtException JWT异常
     */
    public Claims parseToken(String token) throws JwtException {
        return Jwts.parserBuilder()
                .setSigningKey(getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 验证JWT令牌
     * @param token JWT令牌
     * @return 是否有效
     */
    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.warn("Token已过期: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.warn("Token格式不支持: {}", e.getMessage());
        } catch (MalformedJwtException e) {
            log.warn("Token格式错误: {}", e.getMessage());
        } catch (io.jsonwebtoken.security.SignatureException e) {
            log.warn("Token签名错误: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            log.warn("Token参数错误: {}", e.getMessage());
        }
        return false;
    }

    /**
     * 检查令牌是否即将过期
     * @param token JWT令牌
     * @param threshold 阈值(毫秒)
     * @return 是否即将过期
     */
    public boolean isTokenExpiringSoon(String token, long threshold) {
        try {
            Claims claims = parseToken(token);
            Date expiration = claims.getExpiration();
            long timeUntilExpiration = expiration.getTime() - System.currentTimeMillis();
            return timeUntilExpiration < threshold;
        } catch (Exception e) {
            log.error("检查令牌过期时间失败: {}", e.getMessage());
            return true;
        }
    }

    /**
     * 获取密钥
     * @return 密钥
     */
    private SecretKey getSecretKey() {
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        return new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
    }

}

(3)、令牌服务实现:

TokenService.java - 令牌服务接口

/**
 * Token服务接口
 *
 * 职责说明:
 * - 定义令牌生命周期管理接口
 * - 提供令牌的生成、验证、刷新和查询功能
 *
 * 工作流程:
 * 1. 登录认证:验证用户凭证,生成accessToken和refreshToken并存储到Redis
 * 2. 验证令牌:检查令牌有效性(黑名单、Redis、JWT签名)
 * 3. 刷新令牌:使用refreshToken获取新的accessToken
 * 4. 查询令牌:从Redis获取令牌关联的用户信息
 */
public interface TokenService {

    /**
     * 注册令牌
     *
     * @param userId 用户ID
     * @param username 用户名
     * @param roleList 角色列表
     * @return 包含accessToken和refreshToken的Map
     */
    Map<String, String> registerToken(Long userId, String username, List<String> roleList);

    /**
     * 验证令牌
     *
     * @param token 访问令牌
     * @return 是否有效
     */
    boolean validateToken(String token);

    /**
     * 验证刷新令牌
     *
     * @param refreshToken 刷新令牌
     * @return 是否有效
     */
    boolean validateRefreshToken(String refreshToken);

    /**
     * 使用刷新令牌刷新访问令牌
     *
     * @param refreshToken 刷新令牌
     * @return 包含访问令牌和刷新令牌的Map
     */
    Map<String, String> refreshTokenWithRefreshToken(String refreshToken);

    /**
     * 获取用户ID
     *
     * @param token 访问令牌
     * @return 用户ID
     */
    Long getUserIdFromToken(String token);

    /**
     * 获取用户名
     *
     * @param token 访问令牌
     * @return 用户名
     */
    String getUsernameFromToken(String token);

    /**
     * 获取角色列表
     *
     * @param token 访问令牌
     * @return 角色列表
     */
    String[] getRolesFromToken(String token);

    /**
     * 从Redis中获取令牌信息
     *
     * @param token 访问令牌
     * @return 令牌信息
     */
    Map<String, Object> getTokenInfo(String token);

    /**
     * 从Redis中获取刷新令牌信息
     *
     * @param refreshToken 刷新令牌
     * @return 令牌信息
     */
    Map<String, Object> getRefreshTokenInfo(String refreshToken);

}

TokenServiceImpl.java - 令牌服务实现

基于Redis实现令牌生命周期管理

  • 双Token机制:Access Token(1小时)+ Refresh Token(7天)
  • 令牌存储:将令牌信息存储到Redis
  • 黑名单机制:支持令牌主动失效
  • 会话管理:支持多端登录,最大2个会话
/**
 * Token服务实现类
 *
 * 职责说明:
 * - 实现TokenService接口
 * - 基于Redis存储JWT令牌和刷新令牌
 * - 提供令牌生命周期管理(生成、验证、刷新、查询)
 * - 支持多会话管理和会话并发控制
 * - 支持令牌黑名单机制
 *
 * 工作流程:
 * 1. 用户登录时生成accessToken和refreshToken
 * 2. accessToken存储在Redis,refreshToken存储在Redis并关联用户会话
 * 3. 请求验证时检查Redis和黑名单
 * 4. refreshToken刷新时创建新令牌并使旧令牌失效
 */
@Slf4j
@Service
public class TokenServiceImpl implements TokenService {

    /**
     * Redis令牌前缀
     */
    private static final String REDIS_TOKEN_PREFIX = "token:";

    /**
     * Redis刷新令牌前缀
     */
    private static final String REDIS_REFRESH_TOKEN_PREFIX = "refresh:token:";

    /**
     * Redis令牌黑名单前缀
     */
    private static final String REDIS_TOKEN_BLACKLIST_PREFIX = "token:blacklist:";

    /**
     * Redis刷新令牌黑名单前缀
     */
    private static final String REDIS_REFRESH_TOKEN_BLACKLIST_PREFIX = "refresh:token:blacklist:";

    /**
     * Redis用户会话前缀
     */
    private static final String REDIS_USER_SESSION_PREFIX = "user:session:";

    /**
     * 每个用户最大会话数
     */
    private static final int MAX_SESSIONS_PER_USER = 2;

    /**
     * JWT工具类
     */
    @Autowired
    private JwtUtil jwtUtil;

    /**
     * Redis操作模板
     */
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * StringRedisTemplate
     * <p>
     * key和value都使用String序列化,适用于存储纯字符串数据
     * </p>
     */
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 访问令牌过期时间
     */
    @Value("${jwt.access-token-expire:3600000}")
    private long accessTokenExpire;

    /**
     * 刷新令牌过期时间
     */
    @Value("${jwt.refresh-token-expire:604800000}")
    private long refreshTokenExpire;

    /**
     * 注册并生成令牌
     *
     * 令牌生成流程:
     * 1. 根据用户名查询用户信息
     * 2. 查询用户角色列表
     * 3. 清理用户过期的会话记录
     * 4. 检查用户当前会话数,超过最大限制则踢掉最旧会话
     * 5. 将角色列表转换为角色代码集合
     * 6. 生成JWT访问令牌
     * 7. 生成UUID刷新令牌
     * 8. 将令牌信息存储到Redis
     * 9. 返回accessToken和refreshToken
     *
     * @param userId 用户ID
     * @param username 用户名
     * @param roleList 角色列表
     * @return 包含accessToken和refreshToken的Map
     */
    @Override
    public Map<String, String> registerToken(Long userId, String username, List<String> roleList) {
        // 1. 清理用户过期的会话记录
        cleanExpiredTokens(userId);

        // 2. 生成访问令牌和刷新令牌
        String userSessionKey = REDIS_USER_SESSION_PREFIX + userId;

        // 生成访问令牌 (JWT)
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("username", username);
        claims.put("roles", roleList);
        String accessToken = jwtUtil.generateAccessToken(claims);

        // 生成刷新令牌 (opaque token using UUID)
        String refreshToken = UUID.randomUUID().toString();

        // 3. 使用Lua脚本检查会话数并添加新会话(使用Refresh Token作为会话标识),返回被踢掉的旧Refresh Token
        String luaScript = "" +
                "local sessionCount = redis.call('ZCARD', KEYS[1])\n" +
                "local kickedToken = nil\n" +
                "if sessionCount >= tonumber(ARGV[2]) then\n" +
                "    -- 超过最大会话数,删除最旧的会话\n" +
                "    local oldestToken = redis.call('ZRANGE', KEYS[1], 0, 0)\n" +
                "    if oldestToken[1] then\n" +
                "        kickedToken = oldestToken[1]\n" +
                "        redis.call('ZREM', KEYS[1], kickedToken)\n" +
                "    end\n" +
                "end\n" +
                "-- 添加新会话(使用Refresh Token)\n" +
                "redis.call('ZADD', KEYS[1], tonumber(ARGV[1]), ARGV[3])\n" +
                "-- 设置过期时间\n" +
                "redis.call('EXPIRE', KEYS[1], tonumber(ARGV[4]))\n" +
                "return kickedToken\n";

        Object result = stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, String.class),
                Collections.singletonList(userSessionKey),
                String.valueOf(System.currentTimeMillis()),
                String.valueOf(MAX_SESSIONS_PER_USER),
                refreshToken,
                String.valueOf(refreshTokenExpire / 1000));

        // 处理被踢掉的旧Refresh Token,如果踢掉了旧会话,将其加入黑名单
        if (result != null) {
            String kickedToken = (String) result;
            // 将被踢掉的Refresh Token加入黑名单
            addRefreshTokenToBlacklist(kickedToken);
            // 从Redis中删除被踢掉的Refresh Token
            String kickedTokenKey = REDIS_REFRESH_TOKEN_PREFIX + kickedToken;
            redisTemplate.delete(kickedTokenKey);
            log.info("用户 {} 的旧会话被踢掉: {}", userId, kickedToken);
        }

        // 4. 存储accessToken信息到Redis
        String tokenKey = REDIS_TOKEN_PREFIX + accessToken;
        Map<String, Object> tokenInfo = new HashMap<>();
        tokenInfo.put("userId", userId);
        tokenInfo.put("username", username);
        tokenInfo.put("roles", roleList);
        tokenInfo.put("issuedAt", new Date());
        redisTemplate.opsForValue().set(tokenKey, tokenInfo, accessTokenExpire, TimeUnit.MILLISECONDS);

        // 5. 存储refreshToken信息到Redis
        String refreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + refreshToken;
        Map<String, Object> refreshTokenInfo = new HashMap<>();
        refreshTokenInfo.put("userId", userId);
        refreshTokenInfo.put("username", username);
        refreshTokenInfo.put("roles", roleList);
        refreshTokenInfo.put("issuedAt", new Date());
        redisTemplate.opsForValue().set(refreshTokenKey, refreshTokenInfo, refreshTokenExpire, TimeUnit.MILLISECONDS);

        log.info("用户 {} 登录成功,生成令牌", username);

        // 6. 返回令牌
        Map<String, String> tokenResult = new HashMap<>();
        tokenResult.put("accessToken", accessToken);
        tokenResult.put("refreshToken", refreshToken);
        return tokenResult;
    }

    /**
     * 验证访问令牌
     *
     * 验证流程:
     * 1. 检查令牌是否在黑名单中
     * 2. 检查令牌是否在Redis中存在
     * 3. 验证JWT签名和过期时间
     *
     * @param token 访问令牌
     * @return 验证是否通过
     */
    @Override
    public boolean validateToken(String token) {
        // 检查令牌是否在黑名单中
        if (isTokenInBlacklist(token)) {
            log.warn("令牌在黑名单中: {}", token);
            return false;
        }

        // 检查令牌是否在Redis中
        String tokenKey = REDIS_TOKEN_PREFIX + token;
        if (!redisTemplate.hasKey(tokenKey)) {
            log.warn("令牌不在Redis中: {}", token);
            return false;
        }

        // 验证JWT签名和过期时间
        return jwtUtil.validateToken(token);
    }

    /**
     * 验证刷新令牌
     *
     * 验证流程:
     * 1. 检查刷新令牌是否在黑名单中
     * 2. 检查刷新令牌是否在Redis中存在
     *
     * @param refreshToken 刷新令牌
     * @return 验证是否通过
     */
    @Override
    public boolean validateRefreshToken(String refreshToken) {
        // 检查刷新令牌是否在黑名单中
        String blacklistKey = REDIS_REFRESH_TOKEN_BLACKLIST_PREFIX + refreshToken;
        if (redisTemplate.hasKey(blacklistKey)) {
            log.warn("刷新令牌在黑名单中: {}", refreshToken);
            return false;
        }

        // 检查刷新令牌是否在Redis中
        String refreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + refreshToken;
        if (!redisTemplate.hasKey(refreshTokenKey)) {
            log.warn("刷新令牌不在Redis中: {}", refreshToken);
            return false;
        }

        return true;
    }

    /**
     * 清理用户过期的会话记录
     *
     * 遍历用户的会话列表,删除已过期的refreshToken对应的会话记录
     *
     * @param userId 用户ID
     */
    private void cleanExpiredTokens(Long userId) {
        String userSessionKey = REDIS_USER_SESSION_PREFIX + userId;
        Set<ZSetOperations.TypedTuple<String>> tokensWithScores =
            stringRedisTemplate.opsForZSet().rangeWithScores(userSessionKey, 0, -1);
        if (tokensWithScores != null && !tokensWithScores.isEmpty()) {
            for (ZSetOperations.TypedTuple<String> tokenWithScore : tokensWithScores) {
                String refreshToken = tokenWithScore.getValue();
                String refreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + refreshToken;
                // 如果refreshToken已过期,从会话列表中移除
                if (!redisTemplate.hasKey(refreshTokenKey)) {
                    stringRedisTemplate.opsForZSet().remove(userSessionKey, refreshToken);
                }
            }
        }
    }

    /**
     * 从令牌中提取用户ID
     *
     * @param token 访问令牌
     * @return 用户ID
     */
    @Override
    public Long getUserIdFromToken(String token) {
        try {
            Claims claims = jwtUtil.parseToken(token);
            Object userId = claims.get("userId");
            if (userId instanceof Integer) {
                return ((Integer) userId).longValue();
            } else if (userId instanceof Long) {
                return (Long) userId;
            }
        } catch (Exception e) {
            log.error("从令牌中提取用户ID失败: {}", e.getMessage());
        }
        return null;
    }

    /**
     * 从令牌中提取用户名
     *
     * @param token 访问令牌
     * @return 用户名
     */
    @Override
    public String getUsernameFromToken(String token) {
        try {
            Claims claims = jwtUtil.parseToken(token);
            return (String) claims.get("username");
        } catch (Exception e) {
            log.error("从令牌中提取用户名失败: {}", e.getMessage());
            return null;
        }
    }

    /**
     * 从令牌中提取角色列表
     *
     * @param token 访问令牌
     * @return 角色数组
     */
    @Override
    public String[] getRolesFromToken(String token) {
        try {
            Claims claims = jwtUtil.parseToken(token);
            Object roles = claims.get("roles");
            if (roles instanceof String[]) {
                return (String[]) roles;
            } else if (roles instanceof List) {
                List<String> rolesList = (List<String>) roles;
                return rolesList.toArray(new String[0]);
            }
        } catch (Exception e) {
            log.error("从令牌中提取角色失败: {}", e.getMessage());
        }
        return new String[0];
    }

    /**
     * 检查令牌是否在黑名单中
     *
     * @param token 访问令牌
     * @return 是否在黑名单中
     */
    private boolean isTokenInBlacklist(String token) {
        String blacklistKey = REDIS_TOKEN_BLACKLIST_PREFIX + token;
        return redisTemplate.hasKey(blacklistKey);
    }

    /**
     * 将访问令牌加入黑名单
     *
     * 黑名单机制用于实现令牌失效:
     * 1. 获取令牌剩余过期时间
     * 2. 将令牌key存入黑名单,value为"1"
     * 3. 设置与原令牌相同的过期时间
     *
     * @param token 访问令牌
     */
    public void addTokenToBlacklist(String token) {
        try {
            String tokenKey = REDIS_TOKEN_PREFIX + token;
            // 获取令牌剩余过期时间
            Long expireTime = redisTemplate.getExpire(tokenKey, TimeUnit.MILLISECONDS);
            String blacklistKey = REDIS_TOKEN_BLACKLIST_PREFIX + token;
            if (expireTime != null && expireTime > 0) {
                // 设置与原令牌相同的过期时间
                redisTemplate.opsForValue().set(blacklistKey, "1", expireTime, TimeUnit.MILLISECONDS);
            } else {
                // 如果无法获取过期时间,默认设置1小时
                redisTemplate.opsForValue().set(blacklistKey, "1", 3600000, TimeUnit.MILLISECONDS);
            }
        } catch (Exception e) {
            log.error("将令牌加入黑名单失败: {}", e.getMessage());
        }
    }

    /**
     * 将刷新令牌加入黑名单
     *
     * 黑名单机制用于实现刷新令牌失效:
     * 1. 获取刷新令牌剩余过期时间
     * 2. 将刷新令牌key存入黑名单,value为"1"
     * 3. 设置与原刷新令牌相同的过期时间
     *
     * @param refreshToken 刷新令牌
     */
    public void addRefreshTokenToBlacklist(String refreshToken) {
        try {
            String refreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + refreshToken;
            // 获取刷新令牌剩余过期时间
            Long expireTime = redisTemplate.getExpire(refreshTokenKey, TimeUnit.MILLISECONDS);
            String blacklistKey = REDIS_REFRESH_TOKEN_BLACKLIST_PREFIX + refreshToken;
            if (expireTime != null && expireTime > 0) {
                // 设置与原刷新令牌相同的过期时间
                redisTemplate.opsForValue().set(blacklistKey, "1", expireTime, TimeUnit.MILLISECONDS);
            } else {
                // 如果无法获取过期时间,默认设置1小时
                redisTemplate.opsForValue().set(blacklistKey, "1", 3600000, TimeUnit.MILLISECONDS);
            }
        } catch (Exception e) {
            log.error("将刷新令牌加入黑名单失败: {}", e.getMessage());
        }
    }

    /**
     * 删除Redis中的访问令牌
     *
     * @param token 访问令牌
     */
    public void deleteAccessToken(String token) {
        String tokenKey = REDIS_TOKEN_PREFIX + token;
        redisTemplate.delete(tokenKey);
    }

    /**
     * 删除Redis中的刷新令牌
     *
     * @param refreshToken 刷新令牌
     */
    public void deleteRefreshToken(String refreshToken) {
        String refreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + refreshToken;
        redisTemplate.delete(refreshTokenKey);
    }

    /**
     * 从用户会话中移除刷新令牌
     *
     * @param userId 用户ID
     * @param refreshToken 刷新令牌
     */
    public void removeRefreshTokenFromSession(Long userId, String refreshToken) {
        String userSessionKey = REDIS_USER_SESSION_PREFIX + userId;
        stringRedisTemplate.opsForZSet().remove(userSessionKey, refreshToken);
    }

    /**
     * 使用刷新令牌刷新访问令牌
     *
     * 刷新流程:
     * 1. 验证刷新令牌有效性
     * 2. 从Redis获取刷新令牌关联的用户信息
     * 3. 生成新的accessToken和refreshToken
     * 4. 将新令牌存储到Redis
     * 5. 将旧刷新令牌加入黑名单并删除
     * 6. 更新用户会话列表
     *
     * @param refreshToken 刷新令牌
     * @return 包含新accessToken和refreshToken的Map
     * @throws RuntimeException 刷新令牌无效时抛出
     */
    @Override
    public Map<String, String> refreshTokenWithRefreshToken(String refreshToken) {
        // 验证刷新令牌有效性
        if (!validateRefreshToken(refreshToken)) {
            throw new RuntimeException("刷新令牌无效");
        }

        // 从Redis获取刷新令牌关联的用户信息
        String refreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + refreshToken;
        Map<String, Object> refreshTokenInfo = (Map<String, Object>) redisTemplate.opsForValue().get(refreshTokenKey);
        Long userId = (Long) refreshTokenInfo.get("userId");
        String username = (String) refreshTokenInfo.get("username");
        List<String> roleList = (List<String>) refreshTokenInfo.get("roles");

        // 生成新的accessToken
        Map<String, Object> newClaims = new HashMap<>();
        newClaims.put("userId", userId);
        newClaims.put("username", username);
        newClaims.put("roles", roleList);
        String newAccessToken = jwtUtil.generateAccessToken(newClaims);

        // 生成新的refreshToken
        String newRefreshToken = UUID.randomUUID().toString();

        // 存储新的accessToken到Redis
        String newAccessTokenKey = REDIS_TOKEN_PREFIX + newAccessToken;
        Map<String, Object> newAccessTokenInfo = new HashMap<>();
        newAccessTokenInfo.put("userId", userId);
        newAccessTokenInfo.put("username", username);
        newAccessTokenInfo.put("roles", roleList);
        newAccessTokenInfo.put("issuedAt", new Date());
        redisTemplate.opsForValue().set(newAccessTokenKey, newAccessTokenInfo, accessTokenExpire, TimeUnit.MILLISECONDS);

        // 存储新的refreshToken到Redis
        String newRefreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + newRefreshToken;
        Map<String, Object> newRefreshTokenInfo = new HashMap<>();
        newRefreshTokenInfo.put("userId", userId);
        newRefreshTokenInfo.put("username", username);
        newRefreshTokenInfo.put("roles", roleList);
        newRefreshTokenInfo.put("issuedAt", new Date());
        redisTemplate.opsForValue().set(newRefreshTokenKey, newRefreshTokenInfo, refreshTokenExpire, TimeUnit.MILLISECONDS);

        // 将旧刷新令牌加入黑名单
        addRefreshTokenToBlacklist(refreshToken);
        // 删除旧刷新令牌
        redisTemplate.delete(refreshTokenKey);

        // 更新用户会话列表:移除旧refreshToken,添加新refreshToken
        String userSessionKey = REDIS_USER_SESSION_PREFIX + userId;
        stringRedisTemplate.opsForZSet().remove(userSessionKey, refreshToken);
        stringRedisTemplate.opsForZSet().add(userSessionKey, newRefreshToken, System.currentTimeMillis());
        stringRedisTemplate.expire(userSessionKey, refreshTokenExpire, TimeUnit.MILLISECONDS);

        log.info("用户 {} 使用刷新令牌刷新成功", username);

        Map<String, String> result = new HashMap<>();
        result.put("accessToken", newAccessToken);
        result.put("refreshToken", newRefreshToken);
        return result;
    }

    /**
     * 获取令牌信息
     *
     * @param token 访问令牌
     * @return 令牌信息Map
     */
    @Override
    public Map<String, Object> getTokenInfo(String token) {
        String tokenKey = REDIS_TOKEN_PREFIX + token;
        return (Map<String, Object>) redisTemplate.opsForValue().get(tokenKey);
    }

    /**
     * 获取刷新令牌信息
     *
     * @param refreshToken 刷新令牌
     * @return 刷新令牌信息Map
     */
    @Override
    public Map<String, Object> getRefreshTokenInfo(String refreshToken) {
        String refreshTokenKey = REDIS_REFRESH_TOKEN_PREFIX + refreshToken;
        return (Map<String, Object>) redisTemplate.opsForValue().get(refreshTokenKey);
    }

}

(4)、过滤器实现:

JwtAuthenticationFilter.java - JWT认证过滤器

从请求头提取JWT令牌并完成认证

工作流程:

  • 从请求头提取Bearer令牌
  • 调用TokenService验证令牌有效性
  • 从Redis获取令牌关联的用户信息
  • 构建 CustomUserDetails 对象作为 Principal
  • 创建 UsernamePasswordAuthenticationToken 并设置到 SecurityContext
/**
 * JWT认证过滤器
 *
 * 职责说明:
 * - 拦截HTTP请求,提取并验证JWT令牌
 * - 将有效的用户信息设置到Spring Security上下文
 * - 实现无状态认证
 *
 * 工作流程:
 * 1. 从请求头Authorization中提取Bearer令牌
 * 2. 调用TokenService验证令牌有效性
 * 3. 从Redis获取令牌关联的用户信息
 * 4. 构建Spring Security认证令牌并设置到上下文
 * 5. 继续过滤器链执行
 */
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    /**
     * Token服务
     */
    @Autowired
    private TokenService tokenService;


    /**
     * 处理请求过滤
     *
     * 过滤流程:
     * 1. 从请求头提取JWT令牌
     * 2. 验证令牌有效性
     * 3. 获取令牌关联的用户信息
     * 4. 构建认证令牌并设置到安全上下文
     *
     * @param request HTTP请求
     * @param response HTTP响应
     * @param filterChain 过滤器链
     * @throws ServletException Servlet异常
     * @throws IOException IO异常
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 1. 从请求头提取JWT令牌
        String token = extractTokenFromHeader(request);

        if (token != null) {
            try {
                // 2. 验证令牌有效性
                if (tokenService.validateToken(token)) {
                    // 3. 从Redis获取令牌关联的用户信息
                    Map<String, Object> tokenInfo = tokenService.getTokenInfo(token);

                    // 4. 创建 CustomUserDetails 对象
                    SysUser sysUser = new SysUser();
                    sysUser.setId((Long) tokenInfo.get("userId"));
                    sysUser.setUsername((String) tokenInfo.get("username"));

                    List<String> roleList = (List<String>) tokenInfo.get("roles");
                    Set<String> roleSet = Set.copyOf(roleList);

                    CustomUserDetails userDetails = new CustomUserDetails(sysUser, roleSet);

                    // 5. 创建认证对象(使用 CustomUserDetails 作为 Principal)
                    // UserContextUtil.getCurrentUser() 期望 Principal 是 CustomUserDetails 类型
                    Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

                    // 6. 设置到安全上下文
                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    log.debug("用户 {} 认证成功", sysUser.getUsername());
                } else {
                    log.warn("令牌验证失败: {}", token);
                    // 令牌无效,清除安全上下文
                    SecurityContextHolder.clearContext();
                }
            } catch (Exception e) {
                log.error("JWT认证失败: {}", e.getMessage());
                // 认证失败,清除安全上下文
                SecurityContextHolder.clearContext();
            }
        }

        // 7. 继续过滤器链执行
        filterChain.doFilter(request, response);
    }

    /**
     * 从请求头提取JWT令牌
     *
     * @param request HTTP请求
     * @return JWT令牌字符串,如果不存在则返回null
     */
    private String extractTokenFromHeader(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring("Bearer ".length());
        }
        return null;
    }

}

注:

JWT过滤器必须将 CustomUserDetails 对象设置为 Principal,而不是简单的字符串用户名。 这样才能保证 UserContextUtil.getCurrentUser() 正常工作,获取到完整的用户信息。

(5)、异常处理实现:

GlobalExceptionHandler.java -全局异常处理器

统一处理认证和授权异常,返回 JSON 格式错误响应

  • 实现 AuthenticationEntryPoint:处理未认证异常(401)
  • 实现 AccessDeniedHandler:处理权限不足异常(403)
/**
 * 全局统一异常处理器
 *
 * 职责说明:
 * - 捕获Controller、业务层抛出的各类业务异常
 * - 处理Spring Security过滤器层异常(未登录、权限不足)
 * - 统一返回JSON格式错误响应,避免浏览器显示空白错误页面
 *
 * 实现的Security接口:
 * - AuthenticationEntryPoint - 处理未认证异常(HTTP 401)
 * - AccessDeniedHandler - 处理权限不足异常(HTTP 403)
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler implements AuthenticationEntryPoint, AccessDeniedHandler {

    // ======================== 业务层异常处理 ========================

    /**
     * 处理方法级认证异常(HTTP 401)
     *
     * 处理 @PreAuthorize 等注解触发的认证失败。与下方 commence() 的区别:
     * - 本方法:处理 Controller 方法执行阶段的异常(AOP 切面层)
     * - commence():处理过滤器链阶段的异常(Filter 层,如未携带 Token)
     */
    @ExceptionHandler(AuthenticationException.class)
    public Response handleAuthenticationException(AuthenticationException e) {
        log.warn("认证失败:{}", e.getMessage());
        return Response.error(Response.UNAUTHORIZED, "认证失败,请重新登录");
    }

    /**
     * 处理方法级授权异常(HTTP 403)
     *
     * 处理 @PreAuthorize 等注解触发的权限不足。与下方 handle() 的区别:
     * - 本方法:处理方法级权限校验失败(如角色不匹配)
     * - handle():处理 URL 级别权限拦截(SecurityConfig 中配置的规则)
     */
    @ExceptionHandler(AccessDeniedException.class)
    public Response handleAccessDeniedException(AccessDeniedException e) {
        log.warn("权限不足:{}", e.getMessage());
        return Response.error(Response.FORBIDDEN, "权限不足,无法访问");
    }

    /**
     * 处理JWT异常
     *
     * 捕获JWT令牌相关的异常,如令牌格式错误、签名错误、过期等
     */
    @ExceptionHandler(JwtException.class)
    public Response handleJwtException(JwtException e) {
        log.warn("JWT异常:{}", e.getMessage());
        return Response.error(Response.UNAUTHORIZED, "令牌无效");
    }

    /**
     * 兜底异常处理
     * <p>
     * 捕获所有未被明确处理的异常,作为全局异常兜底方案
     * 记录异常堆栈信息便于排查问题,返回统一的错误响应
     * </p>
     * @param e 异常对象
     * @return 统一错误响应
     */
    @ExceptionHandler(Exception.class)
    public Response handleException(Exception e) {
        log.error("系统异常:", e);
        return Response.error("系统内部错误");
    }

    // ======================== Security 过滤器层异常处理 ========================

    /**
     * 处理过滤器层认证异常(HTTP 401)
     * <p>
     * 实现 {@link AuthenticationEntryPoint} 接口
     * 当用户未登录或认证凭证无效时访问受保护资源时触发
     * </p>
     * @param request HTTP请求对象
     * @param response HTTP响应对象
     * @param e 认证异常对象
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
        log.warn("未认证访问受保护接口: {}", request.getRequestURI());
        writeJson(response, Response.UNAUTHORIZED, "请先登录");
    }

    /**
     * 处理过滤器层授权异常(HTTP 403)
     * <p>
     * 实现 {@link AccessDeniedHandler} 接口
     * 当用户已登录但缺少访问目标资源所需权限时触发
     * </p>
     * @param request HTTP请求对象
     * @param response HTTP响应对象
     * @param e 权限拒绝异常对象
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) {
        log.warn("权限不足访问接口: {}", request.getRequestURI());
        writeJson(response, Response.FORBIDDEN, "权限不足,无法访问");
    }

    // ======================== 私有辅助方法 ========================

    /**
     * 输出错误 JSON 响应
     * @param response 响应对象
     * @param code 错误码
     * @param message 错误信息
     */
    private void writeJson(HttpServletResponse response, int code, String message) {
        try {
            response.setContentType("application/json; charset=utf-8");
            response.setCharacterEncoding("UTF-8");
            response.setStatus(code);
            response.getWriter().write(JSONObject.toJSONString(Response.error(code, message)));
        } catch (IOException e) {
            log.error("JSON 响应输出失败", e);
        }
    }

}

(6)、控制器实现:

LoginController.java -登录/登出控制器

1)、登录流程:

  • 验证用户名和密码不能为空
  • 使用AuthenticationManager执行认证
  • 调用TokenService生成令牌
  • 将Refresh Token设置到HttpOnly Cookie
  • 返回Access Token和用户信息

2)、登出流程:

  • 从请求头获取Access Token
  • 从Cookie获取Refresh Token
  • 将令牌加入黑名单
  • 清除Redis中的令牌记录
  • 清除Refresh Token Cookie

3)、刷新令牌流程:

  • 从Cookie获取Refresh Token
  • 调用TokenService刷新令牌
  • 更新Refresh Token Cookie
  • 返回新的Access Token
/**
 * 登录控制器
 *
 * 职责说明:
 * - 处理用户登录、登出、令牌刷新等操作
 * - 适配JWT+Redis架构
 * - 提供无状态认证的接口
 *
 * 工作流程:
 * 1. 登录:验证用户凭证,生成令牌返回给客户端
 * 2. 登出:清除上下文,将令牌加入黑名单
 * 3. 刷新令牌:使用刷新令牌获取新的访问令牌
 */
@Slf4j
@RestController
@RequestMapping("/api/auth")
public class LoginController {

    /**
     * 认证管理器
     */
    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * 令牌服务
     */
    @Autowired
    private TokenServiceImpl tokenService;

    /**
     * 用户Mapper
     */
    @Autowired
    private SysUserMapper sysUserMapper;

    /**
     * 角色Mapper
     */
    @Autowired
    private SysRoleMapper sysRoleMapper;

    /**
     * 刷新令牌Cookie最大存活时间
     */
    @Value("${jwt.refresh-token-cookie-max-age:604800}")
    private int refreshTokenCookieMaxAge;

    /**
     * 刷新令牌Cookie是否仅HTTPS传输
     */
    @Value("${jwt.refresh-token-cookie-secure:false}")
    private boolean refreshTokenCookieSecure;

    /**
     * 用户登录
     *
     * 登录流程:
     * 1. 验证用户名和密码不能为空
     * 2. 使用Spring Security认证
     * 3. 生成访问令牌和刷新令牌
     * 4. 将刷新令牌设置到HttpOnly Cookie中
     * 5. 返回访问令牌和用户信息
     *
     * @param loginData 登录数据,包含username和password
     * @param response HTTP响应,用于设置Refresh Token Cookie
     * @return 登录结果
     */
    @PostMapping("/login")
    public Response login(@RequestBody Map<String, String> loginData, HttpServletResponse response) {
        String username = loginData.get("username");
        String password = loginData.get("password");

        // 1. 验证用户名和密码不能为空
        if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
            return Response.error("用户名或密码不能为空");
        }

        try {
            // 2. 使用Spring Security认证
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(username, password)
            );

            // 3. 生成访问令牌和刷新令牌
            // 根据用户名查询用户信息
            SysUser sysUser = sysUserMapper.selectByUsername(username);
            // 查询用户角色列表
            List<SysRole> roles = sysRoleMapper.selectByUserId(sysUser.getId());
            // 将角色列表转换为角色代码列表
            List<String> roleList = Optional.ofNullable(roles)
                    .orElse(Collections.emptyList())
                    .stream()
                    .map(SysRole::getRoleCode)
                    .collect(Collectors.toList());
            // 注册令牌
            Map<String, String> tokens = tokenService.registerToken(sysUser.getId(), username, roleList);
            String accessToken = tokens.get("accessToken");
            String refreshToken = tokens.get("refreshToken");

            // 4. 将刷新令牌设置到HttpOnly Cookie中
            Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
            refreshTokenCookie.setHttpOnly(true);
            // Cookie 有效路径(整个网站都可用)
            refreshTokenCookie.setPath("/");
            // 是否仅 HTTPS 传输(生产环境应为 true)
            refreshTokenCookie.setSecure(refreshTokenCookieSecure);
            // Cookie 有效期
            refreshTokenCookie.setMaxAge(refreshTokenCookieMaxAge);
            // 添加到响应,浏览器会自动保存
            response.addCookie(refreshTokenCookie);

            log.info("用户 {} 登录成功", username);

            // 5. 返回访问令牌和用户信息
            return Response.success(Map.of(
                    "accessToken", accessToken,
                    "username", username,
                    "roles", roleList
            ));
        } catch (BadCredentialsException e) {
            log.warn("登录失败:{}", username);
            return Response.error("用户名或密码错误");
        } catch (LockedException e) {
            log.warn("账号已锁定:{}", username);
            return Response.error("账号已锁定,请联系管理员");
        } catch (AuthenticationException e) {
            log.warn("认证失败:{}", e.getMessage());
            return Response.error("登录失败,请稍后重试");
        }
    }

    /**
     * 用户登出
     *
     * 登出流程:
     * 1. 从请求头获取访问令牌
     * 2. 从Cookie获取刷新令牌
     * 3. 检查访问令牌是否在Redis中
     * 4. 获取用户ID
     * 5. 将访问令牌加入黑名单
     * 6. 删除Redis中的访问令牌
     * 7. 检查刷新令牌是否在Redis中
     * 8. 将刷新令牌加入黑名单
     * 9. 删除Redis中的刷新令牌
     * 10. 从用户会话中移除刷新令牌
     * 11. 清除用户上下文
     * 12. 清除刷新令牌Cookie
     *
     * @param request HTTP请求
     * @param response HTTP响应
     * @return 登出结果
     */
    @PostMapping("/logout")
    public Response logout(HttpServletRequest request, HttpServletResponse response) {
        // 1. 从请求头获取访问令牌
        String authorization = request.getHeader("Authorization");
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            return Response.error("令牌不能为空");
        }

        String token = authorization.substring("Bearer ".length());
        // 2. 从Cookie获取刷新令牌
        String refreshToken = getRefreshTokenFromCookie(request);

        try {
            // 3. 检查令牌是否在Redis中
            Map<String, Object> tokenInfo = tokenService.getTokenInfo(token);
            if (tokenInfo == null) {
                return Response.error("令牌无效或已过期");
            }

            Long userId = (Long) tokenInfo.get("userId");

            // 4. 将访问令牌加入黑名单
            tokenService.addTokenToBlacklist(token);
            // 5. 删除Redis中的访问令牌
            tokenService.deleteAccessToken(token);

            // 处理刷新令牌
            if (refreshToken != null) {
                // 检查刷新令牌是否在Redis中
                Map<String, Object> refreshTokenInfo = tokenService.getRefreshTokenInfo(refreshToken);
                if (refreshTokenInfo != null) {
                    // 6. 将刷新令牌加入黑名单
                    tokenService.addRefreshTokenToBlacklist(refreshToken);
                    // 7. 删除Redis中的刷新令牌
                    tokenService.deleteRefreshToken(refreshToken);
                    // 8. 从用户会话中移除刷新令牌
                    if (userId != null) {
                        tokenService.removeRefreshTokenFromSession(userId, refreshToken);
                    }
                }
            }

            // 9. 清除用户上下文
            SecurityContextHolder.clearContext();
            // 10. 清除刷新令牌Cookie
            clearRefreshTokenCookie(response);

            log.info("用户登出成功");
            return Response.success("登出成功");
        } catch (Exception e) {
            log.error("登出失败: {}", e.getMessage());
            return Response.error("登出失败");
        }
    }

    /**
     * 刷新令牌
     *
     * 前端调用时机:
     * - 访问令牌即将过期时(建议在过期前5分钟)
     * - 收到401错误时(访问令牌已过期)
     *
     * 刷新流程:
     * 1. 从Cookie获取刷新令牌
     * 2. 使用刷新令牌获取新的访问令牌
     * 3. 更新刷新令牌Cookie
     * 4. 返回新的访问令牌
     *
     * @param request HTTP请求
     * @param response HTTP响应
     * @return 新的访问令牌
     */
    @PostMapping("/v1/refresh-token")
    public Response refreshToken(HttpServletRequest request, HttpServletResponse response) {
        try {
            // 1. 从Cookie获取刷新令牌
            String refreshToken = getRefreshTokenFromCookie(request);
            if (refreshToken == null) {
                return Response.error("刷新令牌不能为空");
            }

            // 2. 使用刷新令牌获取新的访问令牌
            Map<String, String> tokens = tokenService.refreshTokenWithRefreshToken(refreshToken);
            String newAccessToken = tokens.get("accessToken");
            String newRefreshToken = tokens.get("refreshToken");

            // 3. 更新刷新令牌Cookie
            Cookie refreshTokenCookie = new Cookie("refreshToken", newRefreshToken);
            refreshTokenCookie.setHttpOnly(true);
            // Cookie 有效路径(整个网站都可用)
            refreshTokenCookie.setPath("/");
            // 是否仅 HTTPS 传输(生产环境应为 true)
            refreshTokenCookie.setSecure(refreshTokenCookieSecure);
            // Cookie 有效期
            refreshTokenCookie.setMaxAge(refreshTokenCookieMaxAge);
            // 添加到响应,浏览器会自动保存
            response.addCookie(refreshTokenCookie);

            log.info("令牌续期成功");

            // 4. 返回新的访问令牌
            return Response.success(Map.of("accessToken", newAccessToken));
        } catch (Exception e) {
            log.error("令牌续期失败: {}", e.getMessage());
            return Response.error("令牌续期失败: " + e.getMessage());
        }
    }

    /**
     * 从Cookie中获取刷新令牌
     *
     * @param request HTTP请求
     * @return 刷新令牌值,如果不存在则返回null
     */
    private String getRefreshTokenFromCookie(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("refreshToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    /**
     * 清除刷新令牌Cookie
     *
     * @param response HTTP响应
     */
    private void clearRefreshTokenCookie(HttpServletResponse response) {
        Cookie cookie = new Cookie("refreshToken", null);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setSecure(refreshTokenCookieSecure);
        // MaxAge=0表示立即过期,浏览器会删除该Cookie
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }

}

(7)、安全配置实现:

SecurityConfig.java - Spring Security核心配置

Spring Security 的核心配置类,定义整个安全策略

配置项

说明

@EnableMethodSecurity

启用方法级安全注解(@PreAuthorize)

/**
 * Spring Security 核心配置类
 *
 * 职责说明:
 * - 配置认证机制:用户身份验证、密码加密、JWT令牌验证
 * - 配置授权规则:URL访问权限控制、白名单管理
 * - 配置会话管理:无状态JWT策略
 * - 配置跨域处理:CORS配置
 * - 配置异常处理:认证和授权异常统一处理
 *
 * 关键特性:
 * - 基于JWT的无状态认证
 * - Redis存储令牌和会话信息
 * - 黑名单机制支持令牌失效
 * - 自定义异常处理器返回JSON格式错误
 * - 白名单URL无需认证即可访问
 */
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    /**
     * 全局异常处理器
     *
     * 处理Spring Security过滤器层抛出的认证和授权异常:
     * - AuthenticationException(未认证):返回401状态码
     * - AccessDeniedException(权限不足):返回403状态码
     */
    @Resource
    private GlobalExceptionHandler globalExceptionHandler;

    /**
     * 跨域配置源
     *
     * 提供CORS(跨域资源共享)配置信息
     */
    @Resource
    private CorsConfigurationSource corsConfigurationSource;

    /**
     * 白名单配置
     *
     * 管理无需认证即可访问的URL列表
     */
    @Resource
    private WhitelistConfig whitelistConfig;

    /**
     * JWT认证过滤器
     *
     * 专门用于验证JWT令牌,从请求头提取令牌并验证有效性
     *
     * @return JwtAuthenticationFilter实例
     */
    @Resource
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    /**
     * 密码加密器
     *
     * 使用BCrypt算法对密码进行加密存储
     *
     * BCrypt特性:
     * - 自动加盐:每次加密都会生成随机盐值,防止彩虹表攻击
     * - 单向哈希:不可逆,无法从密文还原明文
     * - 可调强度:通过work factor参数调整计算复杂度
     * - 安全性高:已被广泛验证,适合生产环境使用
     *
     * @return BCryptPasswordEncoder实例
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 认证管理器
     *
     * 负责处理用户认证请求,是Spring Security认证流程的核心组件
     *
     * 认证流程:
     * 1. 接收认证请求(用户名、密码)
     * 2. 使用DaoAuthenticationProvider从数据库加载用户信息
     * 3. 使用PasswordEncoder验证密码
     * 4. 返回认证成功或失败的结果
     *
     * @param userDetailsService 用户详情服务,用于加载用户信息
     * @param passwordEncoder 密码加密器,用于验证密码
     * @return AuthenticationManager实例
     */
    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService,
                                                       PasswordEncoder passwordEncoder) {
        // 匹配合适的AuthenticationProvider(DaoAuthenticationProvider)
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return new ProviderManager(provider);
    }

    /**
     * 安全过滤器链配置
     *
     * 配置Spring Security的核心安全规则,按执行顺序包括:
     *
     * 1. CSRF防护:禁用CSRF保护(JWT无状态认证不需要CSRF)
     * 2. 跨域处理:配置CORS规则,允许跨域请求
     * 3. 会话管理:配置无状态策略,不创建Session
     * 4. 异常处理:自定义认证和授权异常处理器
     * 5. 授权规则:配置URL访问权限,白名单放行
     * 6. JWT过滤器:添加JWT认证过滤器
     * 7. 登录登出:禁用默认表单登录和登出,使用自定义接口
     * 8. HTTP Basic:禁用HTTP Basic认证
     *
     * @param http HttpSecurity配置对象
     * @return SecurityFilterChain实例
     * @throws Exception 配置异常
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // ======================== CSRF防护 ========================
                // 禁用CSRF保护
                // 原因:JWT认证是无状态的,CSRF防护意义不大
                // 注意:生产环境如需启用,需在请求头携带CSRF Token
                .csrf(csrf -> csrf.disable())

                // ======================== 跨域处理 ========================
                // 配置CORS,使用自定义的CORS配置源
                .cors(cors -> cors.configurationSource(corsConfigurationSource))

                // ======================== 请求缓存 ========================
                // 禁用 RequestCache(请求缓存机制)。
                // 作用:Spring Security 默认会缓存未认证用户的请求,认证成功后自动重定向到该缓存请求。
                // 在前后端分离项目中,API 接口不需要这种页面跳转行为,禁用后可避免意外重定向,让前端完全控制路由。
                .requestCache(cache -> cache.disable())

                // ======================== 异常处理 ========================
                // 配置认证和授权异常的处理器
                .exceptionHandling(exception -> exception
                        // 认证异常处理:未登录或认证失败时触发(返回401)
                        .authenticationEntryPoint(globalExceptionHandler)
                        // 授权异常处理:已登录但权限不足时触发(返回403)
                        .accessDeniedHandler(globalExceptionHandler)
                )

                // ======================== 授权规则 ========================
                // 配置URL访问权限
                .authorizeHttpRequests(authorize -> authorize
                        // 白名单URL:无需认证即可访问
                        // 包括登录、注册、静态资源等公开接口
                        .requestMatchers(whitelistConfig.getWhitelistArray()).permitAll()
                        // 其他所有请求:必须认证后才能访问
                        .anyRequest().authenticated()
                )

                // ======================== 会话管理 ========================
                // 配置无状态JWT策略
                // JWT认证是无状态的,不需要Session存储用户信息
                .sessionManagement(session -> session
                        // Session创建策略
                        // - ALWAYS:总是创建Session(即使不需要)
                        // - NEVER:不主动创建Session,但可使用已存在的
                        // - IF_REQUIRED:需要时才创建(推荐)
                        // - STATELESS:无状态,完全不使用Session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )

                // ======================== JWT过滤器 ========================
                // 添加JWT认证过滤器
                //
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

                // ======================== 登录登出 ========================
                // 禁用默认表单登录,使用自定义登录接口
                .formLogin(form -> form.disable())
                // 禁用默认登出,使用自定义登出接口
                .logout(logout -> logout.disable())

                // ======================== HTTP Basic ========================
                // 禁用HTTP Basic认证
                // 原因:前后端分离项目使用JWT认证,不需要Basic认证
                .httpBasic(basic -> basic.disable());

        return http.build();
    }

}

19

20

 

posted on 2026-06-24 00:19  爱文(Iven)  阅读(8)  评论(0)    收藏  举报

导航