Web 状态管理核心技术详解 + JWT 双 Token (Access/Refresh Token) 自动登录
Web 状态管理的核心目标是解决 HTTP 协议的无状态特性,让服务端能够识别客户端身份、保留用户操作记录或会话信息。
一、核心概念:为什么需要状态管理?
HTTP 是无状态协议,每次请求都是独立的,服务端无法直接通过请求判断两次请求是否来自同一个客户端。
典型场景:用户登录后,后续访问个人中心、下单等操作,服务端需要识别 "这是刚才登录的用户";
核心需求:在客户端与服务端之间建立 "身份关联",并安全存储用户状态数据(如登录态、偏好设置、购物车信息)。
二、主流 Web 状态管理技术分类
(一)客户端存储技术:状态存在客户端
将状态数据直接存储在浏览器或客户端设备中,无需服务端持久化,适用于非敏感、客户端专属的状态。
| 技术 | 存储位置 | 核心特点 | 适用场景 | 局限性 |
|---|---|---|---|---|
| Cookie | 浏览器本地文件(受同源策略限制) | 1. 大小限制 4KB,数量限制约 50 个 / 域名2. 自动携带到服务端(请求头)3. 支持 HttpOnly/Secure/SameSite 安全属性 |
传递 Session ID、保存小型配置(如语言、主题) | 容量小、易被 CSRF/XSS 攻击(未配置安全属性时) |
| LocalStorage | 浏览器本地文件 | 1. 大小限制 5-10MB2. 永久存储(除非手动清除)3. 同源策略限制,仅客户端可读 | 保存用户偏好(如暗色模式)、离线缓存数据 | 不支持跨域、数据易被 JS 篡改(敏感数据不适用) |
| SessionStorage | 浏览器内存 | 1. 大小限制 5-10MB2. 会话级存储(关闭标签页即失效)3. 同源 + 同标签页限制 | 临时存储表单数据、单会话内的状态 | 数据不跨标签页共享 |
| IndexedDB | 浏览器本地数据库 | 1. 无明确大小限制(受磁盘空间约束)2. 支持结构化数据(JSON / 二进制)、异步操作、索引查询3. 永久存储 | 离线应用(如电子书、离线编辑器)、大量客户端数据存储 | API 复杂、需处理异步回调 |
(二)服务端存储技术:状态存在服务端
将状态数据存储在服务端,通过唯一标识 关联客户端,安全性高,适用于敏感、需要服务端管控的状态。
| 技术 | 存储位置 | 核心机制 | 适用场景 | 局限性 |
|---|---|---|---|---|
| Session | 服务端内存 / Redis / 数据库 | 1. 服务端为每个客户端生成唯一 Session ID2. Session ID 通常通过 Cookie 传递给客户端3. 服务端存储用户核心数据(如登录态、权限) |
传统 Web 应用(如 JSP/Servlet)、需要主动吊销会话的场景 | 1. 单机存储时无法支持分布式集群2. 占用服务端资源,需配置超时销毁策略 |
| 数据库持久化 | 关系型数据库(MySQL)/ 分布式缓存(Redis) | 1. 通过用户唯一标识(如用户 ID)关联状态数据2. 数据持久化存储,支持跨设备、跨会话共享 | 长期状态(如订单记录、积分、用户等级) | 需频繁读写数据库,高并发场景需搭配缓存优化 |
(三)令牌(Token)机制:无状态的身份凭证
服务端生成加密令牌(Token)并返回给客户端,客户端后续请求携带 Token 证明身份,服务端无需存储 Token 状态(部分方案需存储),是前后端分离、微服务架构的首选。
1. 主流 Token 类型
| 类型 | 核心特点 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|---|
| JWT(JSON Web Token) | 1. 自包含:Token 内直接存储用户信息(Base64 编码 + 签名)2. 无状态:服务端无需存储 Token,仅验证签名合法性3. 支持跨域、跨服务传递 | 前后端分离应用、微服务、第三方 API 授权 | 分布式友好、无需服务端存储、性能高 | 1. 无法主动吊销(除非配合黑名单)2. 载荷部分可解码,敏感数据需加密 |
| 自定义 Token | 1. 结构灵活(如 UUID + 用户 ID 加密)2. 服务端需在 Redis 中存储 Token 状态(含有效期、黑名单)3. 支持主动吊销、动态刷新 | 金融、电商等对安全性要求高的场景 | 可主动吊销、安全性可控 | 服务端需维护 Token 存储,增加缓存开销 |
2. JWT 核心结构
JWT 由 Header.Payload.Signature 三部分组成(. 分隔):
- Header :声明算法和类型(如
{"alg":"HS256","typ":"JWT"}); - Payload:存储用户信息(如用户 ID、过期时间),不建议存敏感数据;
- Signature:用密钥对 Header+Payload 签名,防篡改。
(四)第三方授权技术:跨应用状态共享
通过第三方平台(如微信、GitHub)的身份认证,实现跨应用的状态共享,无需用户在第三方应用注册账号。
1. 主流协议:OAuth 2.0 + OpenID Connect
- OAuth 2.0 :授权协议,允许第三方应用获取用户在授权服务器的有限资源 (如头像、昵称),核心是
Access Token; - OpenID Connect :基于 OAuth 2.0 的身份认证协议,增加
ID Token(JWT 格式),用于确认用户身份。
2. 核心流程(授权码模式)
- 用户点击 "微信登录",第三方应用跳转至微信授权页面;
- 用户授权后,微信返回授权码给第三方应用;
- 第三方应用用授权码向微信服务器换取
Access Token和ID Token; - 第三方应用通过
Access Token获取用户信息,创建本地用户状态。
三、技术选型对比表
| 技术类型 | 核心优势 | 核心劣势 | 推荐场景 |
|---|---|---|---|
| Cookie + Session | 传统方案成熟、安全性高(Session 数据在服务端) | 分布式集群需解决 Session 共享问题 | 传统单体 Web 应用 |
| JWT Token | 无状态、分布式友好、跨域支持好 | 无法主动吊销、载荷可解码 | 前后端分离、微服务应用 |
| 客户端存储(LocalStorage/IndexedDB) | 无需服务端资源、离线可用 | 安全性低、数据易被篡改 | 客户端偏好设置、离线应用 |
| 数据库持久化 | 状态长期存储、跨设备共享 | 读写开销大、需缓存优化 | 订单、积分等核心业务数据 |
| OAuth 2.0 第三方授权 | 免注册、提升用户体验 | 依赖第三方平台、流程复杂 | 社交登录、第三方应用授权 |
四、不同架构下的选型
单体架构(如 Spring MVC、JSP/Servlet)
1. 架构特点
- 前端与后端代码耦合,部署在同一服务器;
- 客户端通过浏览器直接请求后端接口,Cookie 自动携带无跨域问题;
- 并发量适中,通常为单机或小规模集群部署。
2. 诉求
- 方案成熟、开发成本低;
- 会话状态可控,支持主动登出、超时销毁。
3. 推荐选型:Cookie + Session(Redis 共享 Session)
(1)理由
传统方案适配性强,与 Servlet 容器(Tomcat、Jetty)深度集成;
Session 存储在服务端,安全性高,支持主动吊销;
小规模集群可通过 Redis 共享 Session 解决单机存储瓶颈。
(2)配置(Spring Boot 示例)
xml
<!-- 引入 Spring Session Redis 依赖 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yaml
spring:
session:
store-type: redis # Session 存储到 Redis
timeout: 1800s # Session 超时时间 30 分钟
redis:
host: localhost
port: 6379
servlet:
session:
cookie:
http-only: true # 防 XSS 攻击
secure: true # 仅 HTTPS 传输
same-site: Lax # 防 CSRF 攻击
(3)注意:
禁止在 Session 中存储大量数据(如用户完整信息),仅存 userId 等关键标识,查询时通过 userId 从数据库获取详情;
配置 Session 超时自动销毁,用户登出时手动调用 session.invalidate();
集群部署时,确保负载均衡器无需开启「会话粘滞」(Redis 共享 Session 已解决)。
备选:自定义 Token(Redis 存储)
适用场景:需要兼容少量移动端请求(移动端 Cookie 支持差);
实现思路:登录成功后生成 UUID Token,与 userId 绑定存储到 Redis,客户端将 Token 存在请求头(如 Token: xxx)。
前后端分离架构(如 Vue/React + Spring Boot)
1. 架构特点
前端与后端完全分离,独立部署(前端域名 www.example.com,后端域名 api.example.com);
前端通过 Ajax/axios 请求后端接口,存在跨域问题;
客户端可能是浏览器、移动端 App,Cookie 兼容性差。
2. 诉求
无状态设计,服务端不存储会话信息;
支持跨域请求,适配多端客户端;
部署灵活,支持水平扩展。
3. 推荐选型:JWT Token + 刷新令牌(Refresh Token)
(1)理由
无状态特性:服务端无需存储 Token,仅验证签名合法性,适配分布式部署;
跨域友好:Token 存储在前端 localStorage/sessionStorage,通过请求头 Authorization: Bearer <token> 传递;
支持多端:浏览器、App 均可统一使用 Token 认证。
(2)流程
-
登录阶段:用户提交账号密码,后端验证通过后生成两个 Token
Access Token:有效期短(如 2 小时),用于接口访问;
Refresh Token:有效期长(如 7 天),用于 Access Token 过期后无感续期;
-
请求阶段:前端每次请求携带 Access Token 到后端,后端验证签名和有效期;
-
续期阶段:Access Token 过期后,前端携带 Refresh Token 调用
/refresh接口,后端验证通过后生成新的 Access Token。
(3)核心配置(JWT + Spring Boot)
java
// Refresh Token 存储到 Redis(有效期 7 天)
@Service
public class RefreshTokenService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String PREFIX = "refresh_token:";
private static final long EXPIRATION = 7 * 24 * 3600; // 7 天
// 存储 Refresh Token
public void saveToken(String userId, String refreshToken) {
redisTemplate.opsForValue()
.set(PREFIX + userId, refreshToken, EXPIRATION, TimeUnit.SECONDS);
}
// 验证 Refresh Token 合法性
public boolean validateToken(String userId, String refreshToken) {
String storedToken = redisTemplate.opsForValue().get(PREFIX + userId);
return refreshToken.equals(storedToken);
}
// 用户登出时删除 Refresh Token
public void deleteToken(String userId) {
redisTemplate.delete(PREFIX + userId);
}
}
(4)实践
Access Token 有效期不宜过长,降低被盗用风险;
Refresh Token 必须存储在服务端(Redis),支持主动吊销(如用户改密码时删除);
前端避免将 Token 存在 localStorage(易被 XSS 窃取),优先使用 HttpOnly Cookie 存储 Refresh Token,Access Token 存在内存中;
跨域请求时,后端配置 CORS 允许携带凭证:
java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("https://www.example.com")
.allowedMethods("*")
.allowCredentials(true) // 允许携带 Cookie
.maxAge(3600);
}
}
4. 备选:OAuth 2.0 授权码模式
适用场景:需要支持第三方登录(如微信、GitHub 登录)的前后端分离应用。
微服务架构(如 Spring Cloud、Dubbo)
1. 架构特点
- 系统拆分为多个独立微服务(如用户服务、订单服务、支付服务);
- 服务间通过 RPC 或 HTTP 通信,存在跨服务身份传递问题;
- 大规模分布式部署,要求状态管理无中心化、高可用。
2. 诉求
- 全局统一的身份认证,支持跨服务传递用户状态;
- 无状态设计,服务实例可随时扩容 / 缩容;
- 支持细粒度的权限控制(如基于角色的访问控制 RBAC)。
3. 推荐选型:JWT Token + 网关统一认证(Gateway)
(1)理由
网关统一认证:所有请求先经过 API 网关(如 Spring Cloud Gateway),由网关验证 Token 合法性,微服 务无需关心认证逻辑;
跨服务传递:JWT Token 自包含用户信息(如 userId、roles),微服务可直接解析 Token 获取身份, 无需调用用户服务;
无状态扩展:服务端无需存储 Token,微服务实例可水平扩展,不依赖共享存储。
(2)核心架构流程
携带 JWT Token 请求
验证 Token 合法性
合法
非法
解析 Token 获取用户信息
返回结果
客户端
API 网关 Gateway
验证结果
路由到目标微服务
返回 401 未授权
处理业务逻辑
(3)核心配置(Spring Cloud Gateway 统一认证)
java
@Component
public class JwtAuthFilter implements GlobalFilter, Ordered {
@Autowired
private JwtUtil jwtUtil;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 跳过登录接口
if (request.getPath().toString().contains("/login")) {
return chain.filter(exchange);
}
// 获取请求头中的 Token
String token = request.getHeaders().getFirst("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 验证 Token
String realToken = token.substring(7);
String userId = jwtUtil.validateToken(realToken);
if (userId == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 将用户信息传递到微服务(请求头)
ServerHttpRequest newRequest = request.mutate()
.header("X-User-Id", userId)
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
}
@Override
public int getOrder() {
return -100; // 优先级高于路由过滤器
}
}
(4)实践
Token 轻量化:JWT Payload 仅存储必要信息(userId、roles、exp),避免存储大量数据导致 Token 过长;
权限与认证分离:网关负责 Token 认证,微服务负责基于角色的权限校验;
支持 Token 黑名单:高安全场景下,结合 Redis 维护 JWT 黑名单(如用户登出、改密码时将 Token 加入黑名单),网关验证时先查黑名单;
服务间通信认证:微服务之间的内部调用,可使用客户端证书或内部专用 Token,避免外部 Token 滥用。
4. 备选方案:分布式 Session(Spring Session + Redis)
适用场景:微服务改造自单体应用,历史代码依赖 Session,迁移成本高。
五、注意
-
敏感状态绝不存客户端:登录态、权限信息等核心数据,优先用
Session或 服务端存储的 Token,避免存在 LocalStorage; -
Cookie 必须配置安全属性:设置
HttpOnly(防 XSS)、Secure(仅 HTTPS 传输)、SameSite=Strict/Lax(防 CSRF); -
JWT 优化策略:
缩短有效期(如 2 小时),配合刷新令牌(Refresh Token)实现无感续期;
敏感数据需加密存储在 Payload,或仅存用户 ID,服务端查库获取详细信息;
高安全场景搭配 Redis 黑名单,实现 Token 主动吊销;
-
分布式场景选型:优先选择 JWT 或 Redis 共享 Session,避免单机 Session 存储的局限性。
六、补充
Java 双 Token 机制(Access Token + Refresh Token)实现自动登录
Java 中基于 双 Token 机制 实现无感自动登录的完整方案,这是目前最主流、最安全的登录态维持方案,核心思想 :用短期有效的Access Token做接口鉴权、用长期有效的Refresh Token做过期后无感刷新令牌,既保证安全性,又实现用户无感知自动登录。
1.两个令牌的标准命名和职责
Access Token是访问令牌,refresh token 是 刷新令牌,两者分工明确,缺一不可:
Access Token (访问令牌):核心业务令牌,有效期极短(推荐 30 分钟~2 小时),用于所有接口的身份鉴权。前端每次请求后端接口,都要在请求头(如Authorization: Bearer token值)中携带该令牌,后端校验通过才允许访问接口。
Refresh Token (刷新令牌):令牌刷新凭证,有效期很长(推荐 7 天~30 天),唯一作用就是:当 Access Token 过期时,向后端申请「刷新并获取新的一对 Token」,无其他业务用途。
2.双 Token 的核心设计优势(为什么不用单 Token)
单 Token 如果设置有效期长:令牌被盗后,攻击者能长期盗用用户身份,风险极高;
单 Token 如果设置有效期短:用户频繁被要求重新登录,体验极差;
双 Token 完美平衡:短期 AccessToken 保证安全(被盗也只能用一会儿),长期 RefreshToken 保证体验(过期自动刷新,用户无感),且 RefreshToken 仅能用于刷新接口,无法访问业务接口,即使泄露危害也极小。
3.原则(易有安全漏洞)
Access Token 有效期 < Refresh Token 有效期;
前端存储规则:
两个令牌都存在前端本地(Cookie/localStorage),前端不做令牌有效期校验,只负责「携带令牌请求、接收新令牌更新本地存储」;
后端存储规则:
Refresh Token 必须持久化存储(MySQL/Redis,推荐使用 Redis),存储内容:
refresh_token值 -> 用户ID + 过期时间 + 登录设备;
Access Token 后端不存储,因为 Access Token 是JWT 令牌(无状态令牌,核心技术选型),后端通过密钥验签 + 解析过期时间即可完成校验,无需存储,性能极高;
令牌一致性:一个用户的同一个登录设备,只会存在一对有效的 Token,刷新令牌后,旧的一对 Token 全部失效,新 Token 生效。
4.选型
令牌格式:JWT (Java Web Token) ------ 无状态、支持加密、自带过期时间、解析高效,是 Java 实现 Token 的标准方案;
令牌存储:Redis ------ 高性能、支持设置过期时间自动删除,完美匹配 Refresh Token 的生命周期管理;
加密方式:JWT 采用非对称加密(RSA)或对称加密(HS256),推荐 HS256(足够安全 + 性能高);
核心依赖:jjwt(Java 官方推荐的 JWT 工具包)+ Spring Boot + Redis。
流程:
验证失败
验证成功
有效
无效/过期
无效/过期
有效且一致
用户登录
账号密码验证
登录失败
生成双Token
AccessToken+RefreshToken
Redis持久化存储RefreshToken
前端缓存双Token,发起业务请求
AccessToken是否有效
正常返回业务数据
RefreshToken是否有效
跳转至登录页重新登录
生成新双Token,更新Redis+前端缓存
自动重试原业务请求
用户主动退出登录
删除Redis中RefreshToken+清空前端缓存
5.实现
(1)依赖
xml
<!-- Spring Boot Web核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis 依赖(必须) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT核心依赖 -->
<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>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.20</version>
</dependency>
(2)配置类( JWT配置和 Token常量)
统一配置 JWT 密钥、令牌有效期,所有常量抽离,便于维护
java
import org.springframework.stereotype.Component;
@Component
public class TokenConfig {
/**
* JWT加密密钥(生产环境务必放到配置文件/配置中心,且足够复杂)
*/
public static final String JWT_SECRET = "java-token-@2025#refresh-token-auto-login-!666";
/**
* Access Token 有效期:3600秒 = 1小时(推荐值)
*/
public static final long ACCESS_TOKEN_EXPIRE_SECONDS = 3600L;
/**
* Refresh Token 有效期:7*24*3600秒 = 7天(推荐值)
*/
public static final long REFRESH_TOKEN_EXPIRE_SECONDS = 604800L;
/**
* Token请求头名称
*/
public static final String TOKEN_HEADER = "Authorization";
/**
* Token前缀
*/
public static final String TOKEN_PREFIX = "Bearer ";
}
(3)封装 Token 返回实体(统一返回格式)
登录成功 / 刷新令牌成功后,向前端返回的令牌对象,包含两个令牌和基本信息
java
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 双Token返回实体
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenVO implements Serializable {
/**
* 访问令牌
*/
private String accessToken;
/**
* 刷新令牌
*/
private String refreshToken;
/**
* AccessToken过期时间(秒)
*/
private Long accessExpire;
/**
* RefreshToken过期时间(秒)
*/
private Long refreshExpire;
}
(4)核心工具类 JwtTokenUtil(生成 + 校验 + 解析 Token)
所有 Token 的生成、校验、解析都在这里,封装成工具类,全局调用
java
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import static com.example.demo.config.TokenConfig.*;
@Slf4j
@Component
public class JwtTokenUtil {
/**
* 生成双Token(核心方法)
* @param userId 用户ID(令牌中只存用户唯一标识,不存敏感信息)
* @return 双Token对象
*/
public TokenVO generateToken(Long userId) {
// 1. 构建JWT的公共载荷(可以添加用户角色、昵称等非敏感信息)
Map<String, Object> claims = new HashMap<>(2);
claims.put("userId", userId);
// 2. 生成AccessToken
long accessCurrentTime = System.currentTimeMillis();
Date accessExpireDate = new Date(accessCurrentTime + ACCESS_TOKEN_EXPIRE_SECONDS * 1000);
String accessToken = Jwts.builder()
.setClaims(claims)
.setExpiration(accessExpireDate)
.signWith(io.jsonwebtoken.security.Keys.hmacShaKeyFor(JWT_SECRET.getBytes()), SignatureAlgorithm.HS256)
.compact();
// 3. 生成RefreshToken(载荷和AccessToken一致,有效期更长)
long refreshCurrentTime = System.currentTimeMillis();
Date refreshExpireDate = new Date(refreshCurrentTime + REFRESH_TOKEN_EXPIRE_SECONDS * 1000);
String refreshToken = Jwts.builder()
.setClaims(claims)
.setExpiration(refreshExpireDate)
.signWith(io.jsonwebtoken.security.Keys.hmacShaKeyFor(JWT_SECRET.getBytes()), SignatureAlgorithm.HS256)
.compact();
// 4. 返回封装后的Token对象
return new TokenVO(accessToken, refreshToken, ACCESS_TOKEN_EXPIRE_SECONDS, REFRESH_TOKEN_EXPIRE_SECONDS);
}
/**
* 校验Token是否有效(过期/签名错误都会抛异常)
* @param token 待校验的令牌(去除Bearer前缀)
* @return true=有效,false=无效
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(io.jsonwebtoken.security.Keys.hmacShaKeyFor(JWT_SECRET.getBytes()))
.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.error("Token已过期:{}", e.getMessage());
return false;
} catch (MalformedJwtException e) {
log.error("Token格式错误:{}", e.getMessage());
return false;
} catch (SignatureException e) {
log.error("Token签名错误:{}", e.getMessage());
return false;
} catch (IllegalArgumentException e) {
log.error("Token参数非法:{}", e.getMessage());
return false;
} catch (Exception e) {
log.error("Token校验失败:{}", e.getMessage());
return false;
}
}
/**
* 从Token中解析出用户ID
* @param token 令牌(去除Bearer前缀)
* @return 用户ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(io.jsonwebtoken.security.Keys.hmacShaKeyFor(JWT_SECRET.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
return Long.valueOf(claims.get("userId").toString());
}
/**
* 判断Token是否过期(单独判断,用于刷新令牌的前置校验)
*/
public boolean isTokenExpired(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(io.jsonwebtoken.security.Keys.hmacShaKeyFor(JWT_SECRET.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
}
(5)Redis 操作封装(存储 RefreshToken)
RefreshToken 的持久化存储,Redis 的 Key 建议设计成 refresh_token:{userId},Value 为 RefreshToken 值,同时设置过期时间和 Token 有效期一致,自动过期清理。
java
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
import static com.example.demo.config.TokenConfig.REFRESH_TOKEN_EXPIRE_SECONDS;
@Component
@RequiredArgsConstructor
public class RedisTokenManager {
private final StringRedisTemplate stringRedisTemplate;
/**
* 存储RefreshToken到Redis
* @param userId 用户ID
* @param refreshToken 刷新令牌
*/
public void saveRefreshToken(Long userId, String refreshToken) {
String key = "refresh_token:" + userId;
stringRedisTemplate.opsForValue().set(key, refreshToken, REFRESH_TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
}
/**
* 从Redis获取RefreshToken
* @param userId 用户ID
* @return 刷新令牌
*/
public String getRefreshToken(Long userId) {
String key = "refresh_token:" + userId;
return stringRedisTemplate.opsForValue().get(key);
}
/**
* 删除Redis中的RefreshToken(用户退出登录时调用)
* @param userId 用户ID
*/
public void deleteRefreshToken(Long userId) {
String key = "refresh_token:" + userId;
stringRedisTemplate.delete(key);
}
/**
* 校验前端传入的RefreshToken和Redis中存储的是否一致
*/
public boolean checkRefreshToken(Long userId, String refreshToken) {
String redisToken = getRefreshToken(userId);
return StrUtil.isNotBlank(redisToken) && redisToken.equals(refreshToken);
}
}
(6)核心业务接口(登录 + 刷新令牌 + 退出登录)
所有核心业务逻辑,自动登录的核心是「刷新令牌接口」,前端在 Access Token 过期时,调用该接口无感获取新令牌,无需用户重新登录。
java
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import static com.example.demo.config.TokenConfig.*;
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtTokenUtil jwtTokenUtil;
private final RedisTokenManager redisTokenManager;
/**
* 1. 用户登录接口(账号密码校验通过后,生成双Token)
* @param userId 登录成功后的用户ID(真实场景:传入username+password,校验通过后获取userId)
* @return 双Token信息
*/
@PostMapping("/login")
public ResponseEntity<?> login(@RequestParam Long userId) {
// 真实业务:校验账号密码是否正确,省略...
TokenVO tokenVO = jwtTokenUtil.generateToken(userId);
// 存储RefreshToken到Redis
redisTokenManager.saveRefreshToken(userId, tokenVO.getRefreshToken());
return ResponseEntity.ok(tokenVO);
}
/**
* 2. 刷新令牌接口【核心:实现自动登录的关键】
* 前端逻辑:当调用业务接口返回「401 Token过期」时,立即调用该接口,获取新Token后继续请求
* @param refreshToken 前端传入的刷新令牌
* @return 新的双Token
*/
@PostMapping("/refresh-token")
public ResponseEntity<?> refreshToken(@RequestParam String refreshToken) {
// 校验RefreshToken是否为空
if (StrUtil.isBlank(refreshToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("刷新令牌不能为空");
}
// 步骤1:校验RefreshToken是否有效(签名+是否过期)
if (!jwtTokenUtil.validateToken(refreshToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("刷新令牌已过期,请重新登录");
}
// 步骤2:解析RefreshToken中的用户ID
Long userId = jwtTokenUtil.getUserIdFromToken(refreshToken);
// 步骤3:校验前端传入的RefreshToken和Redis中存储的是否一致(防止令牌被盗用)
if (!redisTokenManager.checkRefreshToken(userId, refreshToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("刷新令牌无效,请重新登录");
}
// 步骤4:生成新的双Token
TokenVO newTokenVO = jwtTokenUtil.generateToken(userId);
// 步骤5:更新Redis中的RefreshToken(旧的失效,新的生效)
redisTokenManager.saveRefreshToken(userId, newTokenVO.getRefreshToken());
// 步骤6:返回新令牌,前端更新本地存储即可实现无感自动登录
return ResponseEntity.ok(newTokenVO);
}
/**
* 3. 用户退出登录接口
* 核心:删除Redis中的RefreshToken,即使令牌没过期也会失效
* @param token 请求头中的AccessToken
* @return 退出成功
*/
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader(TOKEN_HEADER) String token) {
if (StrUtil.isBlank(token) || !token.startsWith(TOKEN_PREFIX)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("令牌无效");
}
// 去除Bearer前缀
String accessToken = token.replace(TOKEN_PREFIX, "");
Long userId = jwtTokenUtil.getUserIdFromToken(accessToken);
// 删除Redis中的RefreshToken
redisTokenManager.deleteRefreshToken(userId);
return ResponseEntity.ok("退出登录成功");
}
}
(7)全局 Token 拦截器(接口鉴权)
实现所有业务接口的 Token 校验,只有携带有效未过期的 Access Token才能访问接口,过期则返回 401,让前端触发刷新令牌逻辑。
java
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import static com.example.demo.config.TokenConfig.*;
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenAuthInterceptor implements HandlerInterceptor {
private final JwtTokenUtil jwtTokenUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取请求头中的Token
String token = request.getHeader(TOKEN_HEADER);
// 2. 放行白名单接口(登录、刷新令牌、退出登录等,根据业务自行配置)
String requestURI = request.getRequestURI();
if (requestURI.contains("/api/auth/login") || requestURI.contains("/api/auth/refresh-token")) {
return true;
}
// 3. 校验Token格式
if (StrUtil.isBlank(token) || !token.startsWith(TOKEN_PREFIX)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("请先登录");
return false;
}
// 4. 去除Bearer前缀
String accessToken = token.replace(TOKEN_PREFIX, "");
// 5. 校验Token是否有效
if (!jwtTokenUtil.validateToken(accessToken)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("登录已过期,请刷新令牌");
return false;
}
// 6. 解析用户ID,放入请求头/ThreadLocal,供后续业务使用
Long userId = jwtTokenUtil.getUserIdFromToken(accessToken);
request.setAttribute("userId", userId);
// 7. 校验通过,放行
return true;
}
}
6.前端配合逻辑
用户登录成功后,接收后端返回的 AccessToken + RefreshToken,存储到 localStorage / Cookie 中;
前端封装全局请求拦截器:所有请求在发送前,都在请求头中添加 Authorization: Bearer ${accessToken};
前端封装
全局响应拦截器:
统一捕获后端返回的响应码:
如果返回 200:正常处理业务数据;
如果返回 401 未授权/Token过期:立即触发刷新令牌逻辑;
刷新令牌逻辑:
javascript
// 伪代码
async function refreshToken() {
const refreshToken = localStorage.getItem("refreshToken");
// 调用后端刷新令牌接口
const res = await axios.post("/api/auth/refresh-token", {refreshToken});
if(res.code === 200) {
// 刷新成功:更新本地存储的新令牌
localStorage.setItem("accessToken", res.data.accessToken);
localStorage.setItem("refreshToken", res.data.refreshToken);
// 重新发起刚才失败的请求(无感,用户无感知)
return retryRequest();
} else {
// 刷新失败:RefreshToken也过期了,跳转到登录页,让用户重新登录
localStorage.clear();
router.push("/login");
}
}
7.补充
异常处理:
AccessToken 过期,RefreshToken 有效 → 调用刷新接口,获取新令牌,无感自动登录
AccessToken 过期,RefreshToken 也过期 → 刷新接口返回 401,前端跳登录页
AccessToken 被盗用 → 有效期只有 1 小时,被盗后危害极小
RefreshToken 被盗用 → 原用户刷新令牌后,旧 RefreshToken 在 Redis 中被覆盖,盗用者的令牌失效
用户主动退出登录 → 删除 Redis 中的 RefreshToken,所有令牌失效
8.优化
JWT 密钥 JWT_SECRET 不要硬编码,放到 application.yml 或 Nacos 配置中心,且足够复杂;
可以为 RefreshToken 增加设备标识(如手机型号、浏览器标识),存储到 Redis,校验时多一层设备校验,防止异地登录盗用;
AccessToken 的有效期可以根据业务调整(如后台管理系统 30 分钟,移动端 2 小时);
Redis 建议开启持久化,防止服务重启后 RefreshToken 丢失;
可以增加「令牌黑名单」,针对异常令牌手动拉黑。