阶段三:从有状态羁绊到无状态自由(基于 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 的组成结构:

JWT(JSON Web Token)是一种轻量级、自包含、基于 JSON 的安全令牌格式,核心遵循「Header(头部).Payload(载荷).Signature(签名)」三段式标准结构,三部分通过 Base64URL 编码后以.连接,最终生成紧凑、URL 安全的字符串,可在各方之间安全传输身份、权限等核心信息,是本项目 Spring Security 6+JWT 前后端分离无状态认证体系的核心数据载体,既实现了无状态轻量化的全终端适配,又为 Redis 令牌生命周期管控提供了基础支撑。
1、Header(头部):
描述令牌元数据,声明加密算法、令牌类型等基础信息,是令牌校验的核心依据。
|
维度 |
说明 |
|
格式规范 |
标准 JSON 对象,固定包含两个核心字段:
|
|
项目示例 |
{ "alg": "HS256", "typ": "JWT" } |
|
编码处理 |
将上述 JSON 对象进行 Base64URL 编码,生成 JWT 令牌的第一段字符串 |
2、Payload (载荷):
存储令牌核心业务数据,承载用户身份、权限、有效期等无状态认证所需信息,是 JWT 实现无状态认证的关键。
|
维度 |
说明 |
|
字段分类 |
|
|
项目示例 |
{ "userId": 1001, // 用户ID "username": "admin", // 用户名 "roles": "ADMIN,USER", // 角色权限 "exp": 1760000000 // 过期时间(Unix时间戳) } |
|
编码处理 |
将上述 JSON 对象进行 Base64URL 编码,生成 JWT 令牌的第二段字符串 |
注:Payload 仅做 Base64URL 编码(非加密),禁止存储密码、密钥等敏感信息。
3、Signature(签名):
保障令牌防篡改、验真伪,是 JWT 安全性的核心保障。
|
维度 |
说明 |
|
生成规则 |
|
|
生成公式 |
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、项目结构:

|
技术 |
版本 |
|
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、用户登录认证流程:

4、请求访问控制流程:

5、令牌刷新流程:

6、登出流程:

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、核心实现流程:

(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(); } }


浙公网安备 33010602011771号