SpringBoot3 登录注册基础篇(四) - 双令牌机制与 Redis 状态管理

SpringBoot3 登录注册基础篇(四) - 双令牌机制与 Redis 状态管理

第一章. 单令牌的困境

在上一篇中,我们封装了 JWT 工具类。现在我们来实现一个最简单的登录系统,然后暴露它的问题。

1.1. 单令牌登录实现

首先,我们创建一个简单的 TokenService 来管理令牌。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/TokenService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.auth.core.service;

import com.example.auth.core.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class TokenService {

private final JwtUtil jwtUtil;

/**
* 创建令牌(单令牌版本)
*/
public String createToken(Long userId, String username) {
log.info("开始创建令牌: userId={}, username={}", userId, username);
String token = jwtUtil.createToken(userId, username, null);
log.info("令牌创建成功: userId={}", userId);
return token;
}

/**
* 验证令牌是否有效
*/
public boolean validateToken(String token) {
return jwtUtil.validateToken(token);
}
}

然后创建 Controller 提供登录接口。

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.example.auth.web.controller;

import com.example.auth.common.model.Result;
import com.example.auth.core.service.TokenService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final TokenService tokenService;

/**
* 单令牌登录接口
*/
@PostMapping("/login")
public Result<Map<String, Object>> login(@RequestParam Long userId,
@RequestParam String username) {
log.info("收到登录请求: userId={}, username={}", userId, username);

String token = tokenService.createToken(userId, username);

Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("tokenType", "Bearer");

return Result.ok(data);
}

/**
* 验证令牌接口
*/
@GetMapping("/validate")
public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String token) {
log.info("收到验证令牌请求");

if (token.startsWith("Bearer ")) {
token = token.substring(7);
}

boolean valid = tokenService.validateToken(token);

Map<String, Object> data = new HashMap<>();
data.put("valid", valid);

return Result.ok(data);
}
}

创建启动类。

📄 文件路径auth-web/src/main/java/com/example/auth/web/AuthApplication.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.auth.web;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication(scanBasePackages = "com.example.auth")
public class AuthApplication {

public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}

配置文件。

📄 文件路径auth-web/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8080

spring:
application:
name: auth-service

jwt:
issuer: pro-auth-service
access-token-expire-minutes: 15
clock-skew-seconds: 30
public-key-resource: certs/public_key.pem
private-key-resource: certs/private_key.pem

1.2. 接口验证与测试

代码编写完成后,我们需要验证以下两个核心流程:

  1. 登录获取 Token:确保能根据用户信息生成合法的 JWT。
  2. 携带 Token 访问:确保服务端能正确解析并验证 Token 的有效性。

⚠ 前置准备:请确保你的 resources/certs 目录下已经存在上一章生成的公私钥文件(private_key.pempublic_key.pem
),否则启动会报错。

1. 启动服务

运行 AuthApplicationmain 方法,观察控制台日志,确保没有报错,且端口运行在 8080

2. 测试登录(获取 Token)

使用 Postman 或终端 Curl 发起登录请求。

  • 请求方式:POST
  • URLhttp://localhost:8080/auth/login
  • 参数userId=1001, username=admin

Curl 命令示例:

1
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

预期响应结果:

1
2
3
4
5
6
7
8
9
{
"code": 200,
"msg": "success",
"data": {
"tokenType": "Bearer",
"token": "eyJhGciOiJ...<省略的长字符串>...W8i9s"
}
}

3. 测试验证(校验 Token)

复制上一步获取的 token 值,添加到请求头的 Authorization 字段中。

  • 请求方式:GET
  • URLhttp://localhost:8080/auth/validate
  • HeaderAuthorization: Bearer <你的Token>

Curl 命令示例:

1
2
3
# 请将 <YOUR_TOKEN> 替换为实际获取的 Token
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <YOUR_TOKEN>"

预期响应结果:

1
2
3
4
5
6
7
{
"code": 200,
"msg": "success",
"data": {
"valid": true
}
}

1.3. 暴露问题:单令牌的“死局”

至此,我们的单令牌登录功能看似完美运行。但是,请尝试修改 application.yml,将过期时间改得极短(例如 1 分钟):

1
2
jwt:
access-token-expire-minutes: 1 # 测试用,改为1分钟

重启服务后,我们会发现一个严重的用户体验问题:

  1. 用户登录,获得 Token。
  2. 用户正常浏览页面(耗时 1 分钟)。
  3. 第 61 秒,用户点击某个按钮,前端携带 Token 请求。
  4. 服务端返回 401 或验证失败,因为 Token 已过期。
  5. 后果:用户被强制登出,必须重新输入密码登录。

这就是 单令牌模式的死局

  • 有效期设置太长(如 7 天):Token 一旦泄露,黑客有 7 天时间为所欲为,服务端无法主动注销(除非引入黑名单,但这增加了复杂度和查库成本)。
  • 有效期设置太短(如 15 分钟):安全性高了,但用户每隔 15 分钟就要重新登录一次,体验极其糟糕。

为了解决这个“安全”与“体验”的矛盾,我们需要引入 双令牌机制(Access Token + Refresh Token)


1.2. 单令牌的两大致命问题

现在我们的单令牌系统已经能够正常工作了。但在实际使用中,我们会遇到两个致命问题:

问题一:无法主动注销

假设用户在公司电脑上登录了系统,下班后忘记退出。第二天他发现后,想要远程注销这个登录会话。

但在单令牌机制下,这是做不到的。因为 Token 是自包含的Token 本身携带所有信息,服务器不存储任何状态,服务器没有存储任何状态。只要 Token 没有过期,它就一直有效。

即使用户点击了 “退出登录” 按钮,前端删除了 Token,但如果有人之前复制了这个 Token,他依然可以在 Token 过期前继续使用。

问题二:有效期困境

我们现在面临一个两难的选择:

  • 有效期设置太短(如 15 分钟):用户体验差。用户正在编辑文档,突然 Token 过期了,所有操作都失败了,必须重新登录。
  • 有效期设置太长(如 7 天):安全风险高。如果 Token 被盗,攻击者可以在 7 天内随意访问用户的数据。

这两个问题看起来是矛盾的:要么牺牲用户体验,要么牺牲安全性。


第二章. 双令牌机制设计

2.1. 设计理念

我们不再只发一个 Token,而是发两个:

  • Access Token(访问令牌):短效(15 分钟),真正用来请求接口的凭证
  • Refresh Token(刷新令牌):长效(7 天),专门用来换取新 Access Token 的凭证

这就像你去银行办业务:

  • Access Token = 排队号码牌(有效期很短,过期就作废)
  • Refresh Token = 身份证(有效期很长,可以用来重新取号)

当你的号码牌过期了,你不需要重新排队,只需要拿着身份证去柜台换一个新号码牌。

2.2. 架构流程

mermaid-diagram-2026-01-27-153849

2.3. 为什么需要 Redis

你可能会问:我们不是说好了 JWT 是无状态的吗?为什么还需要 Redis?

确实,JWT 的设计理念是无状态。但在实际业务中,我们需要一些 “状态管理” 的能力:

  • 主动注销:当用户点击 “退出登录” 时,我们需要立即让 Token 失效
  • Token 刷新:当 Access Token 过期时,我们需要验证 Refresh Token 是否有效
  • Token 轮换:每次刷新都生成新的 Refresh Token,旧的立即失效(检测盗用)

这些功能都需要服务器 “记住” 一些状态。而 Redis 就是最适合存储这些临时状态的中间件。

为什么选择 Redis 而不是 MySQL?

对比维度MySQLRedis
读写速度毫秒级(磁盘 IO)微秒级(内存操作)
过期机制需要定时任务清理原生支持 TTL 自动清理
数据结构只有表(Table)支持 String、Set、ZSet 等
适用场景持久化存储临时状态存储

第三章. Redis 状态管理

3.1. Redis 数据结构选型

Redis 支持五种数据结构,我们在认证系统中主要会用到:

  • String:存储 Refresh Token、黑名单标记
  • Set:存储用户的在线设备列表
  • ZSet:存储用户的在线设备列表(按登录时间排序,用于多设备限制)

3.2. StringRedisTemplate 核心语法

Spring 提供了 StringRedisTemplate 来操作 Redis。它的 API 设计非常直观:先选择数据结构,再执行操作

核心方法

  • opsForValue():操作 String
  • opsForSet():操作 Set
  • opsForZSet():操作 ZSet

String 操作(opsForValue)

String 是最常用的数据结构,用于存储简单的键值对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 存储 Refresh Token(7 天过期)
redisTemplate.opsForValue().

set(
"auth:token:refresh:abc123", // Key
"1001", // Value(用户 ID)
7, // 过期时间
TimeUnit.DAYS // 时间单位
);

// 获取 Refresh Token 对应的用户 ID
String userId = redisTemplate.opsForValue().get("auth:token:refresh:abc123");

// 检查 Token 是否在黑名单中
Boolean isBlacklisted = redisTemplate.hasKey("auth:token:black:jti123");

// 记录登录失败次数
Long failCount = redisTemplate.opsForValue().increment("auth:login:fail:admin");
if(failCount ==1){
// 第一次失败,设置 15 分钟过期
redisTemplate.

expire("auth:login:fail:admin",15,TimeUnit.MINUTES);
}

常用方法速查

方法作用示例
set(key, value, timeout, unit)存储并设置过期时间set("token:123", "1001", 15, TimeUnit.MINUTES)
get(key)获取字符串get("token:123")
increment(key)自增increment("login:fail:admin")
delete(key)删除键delete("token:123")
hasKey(key)检查键是否存在hasKey("token:123")
expire(key, timeout, unit)设置过期时间expire("token:123", 10, TimeUnit.MINUTES)

Set 操作(opsForSet)

Set 是无序不重复集合,用于存储不需要排序的元素列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 记录用户的在线设备
redisTemplate.opsForSet().

add("auth:user:tokens:1001","jti123");

// 获取用户的所有在线设备
Set<String> devices = redisTemplate.opsForSet().members("auth:user:tokens:1001");

// 检查某个设备是否在线
Boolean isOnline = redisTemplate.opsForSet().isMember("auth:user:tokens:1001", "jti123");

// 用户注销,移除设备
redisTemplate.

opsForSet().

remove("auth:user:tokens:1001","jti123");

// 获取在线设备数量
Long deviceCount = redisTemplate.opsForSet().size("auth:user:tokens:1001");

常用方法速查

方法作用示例
add(key, values)添加元素add("user:1001:devices", "jti123")
members(key)获取所有元素members("user:1001:devices")
isMember(key, value)检查元素是否存在isMember("user:1001:devices", "jti123")
remove(key, values)移除元素remove("user:1001:devices", "jti123")
size(key)获取元素数量size("user:1001:devices")

Pipeline 批量操作

在实际业务中,我们经常需要执行多个 Redis 操作。如果每个操作都是一次网络请求,性能会很差。

问题场景

假设用户登录时,我们需要做三件事:

  1. 存储 Refresh Token
  2. 将 Token ID 记录到用户的在线设备列表
  3. 给在线列表设置过期时间

普通写法(3 次网络请求):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 第 1 次网络请求
redisTemplate.opsForValue().

set("auth:token:refresh:abc123","1001",7,TimeUnit.DAYS);

// 第 2 次网络请求
redisTemplate.

opsForSet().

add("auth:user:tokens:1001","jti123");

// 第 3 次网络请求
redisTemplate.

expire("auth:user:tokens:1001",7,TimeUnit.DAYS);

每次网络请求的耗时大约是 1-2 毫秒(局域网环境)。3 次请求就是 3-6 毫秒

Pipeline 写法(1 次网络请求):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
redisTemplate.executePipelined((RedisCallback<Object>) connection ->{

// 操作 1:存储 Refresh Token
connection.

stringCommands().

setEx(
"auth:token:refresh:abc123".getBytes(),
7*86400, // 7 天(秒)
"1001".

getBytes()
);

// 操作 2:记录在线设备
connection.

setCommands().

sAdd(
"auth:user:tokens:1001".getBytes(),
"jti123".

getBytes()
);

// 操作 3:设置过期时间
connection.

keyCommands().

expire(
"auth:user:tokens:1001".getBytes(),
7*86400 // 7 天(秒)
);

return null;
});

Pipeline 中的命令是顺序执行的,不是原子性的。如果需要原子性,应该使用 Lua 脚本或 Redis 事务。

3.3. Redis Key 命名规范

为了防止 Key 冲突,我们需要定义统一的 Key 命名规范。

命名规范:项目:模块:业务:主键

层级说明示例
项目项目名称auth(认证系统)
模块模块名称token(令牌模块)
业务业务类型refresh(刷新令牌)
主键业务主键a1b2c3d4(refreshToken)

完整示例

1
2
3
auth:token:refresh:a1b2c3d4e5f6    # 认证系统 - 令牌模块 - 刷新令牌 - Token ID
auth:token:black:550e8400e29b # 认证系统 - 令牌模块 - 黑名单 - JTI
auth:user:tokens:1001 # 认证系统 - 用户模块 - 在线设备 - 用户ID

📄 文件路径auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.auth.core.constant;

public class RedisKeyConstants {

/**
* 刷新令牌映射 Key 前缀
*/
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";

/**
* 用户在线设备索引 Key 前缀
*/
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";

/**
* 构建刷新令牌 Key
*/
public static String buildRefreshTokenKey(String refreshToken) {
return TOKEN_REFRESH_PREFIX + refreshToken;
}

/**
* 构建用户在线设备 Key
*/
public static String buildUserOnlineKey(Long userId) {
return USER_ONLINE_TOKENS_PREFIX + userId;
}
}

第四章. 双令牌机制实现

4.1. 定义 AuthToken 模型

首先,我们需要定义一个模型来承载双令牌。

📄 文件路径auth-core/src/main/java/com/example/auth/core/model/AuthToken.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.auth.core.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthToken implements Serializable {

/**
* 访问令牌(短效,JWT)
*/
private String accessToken;

/**
* 刷新令牌(长效,随机串)
*/
private String refreshToken;

/**
* Access Token 剩余有效期(秒)
*/
private Long expiresIn;

/**
* 令牌类型
*/
@Builder.Default
private String tokenType = "Bearer";
}

4.2. 扩展 JwtProperties

在原有的 JwtProperties 类中,追加 Refresh Token 的配置。

📄 文件路径auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.auth.core.config.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

// ...之前的内容

/**
* Refresh Token 有效期(天)
*/
private Integer refreshTokenExpireDays = 7;
}

更新配置文件。

📄 文件路径auth-web/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server:
port: 8080

spring:
application:
name: auth-service
data:
redis:
host: localhost
port: 6379
database: 0
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0

jwt:
issuer: pro-auth-service
access-token-expire-minutes: 15
refresh-token-expire-days: 7
clock-skew-seconds: 30
public-key-resource: certs/public_key.pem
private-key-resource: certs/private_key.pem

4.3. 分层架构设计

在实现双令牌机制之前,我们先来解决一个架构问题。如果把所有逻辑都堆在 Service 层,会导致:

  • 职责不清TokenServiceTokenStoreServiceTokenBlacklistService 看起来是平级的,但实际上有些是底层基础设施,有些是上层业务逻辑
  • Controller 保姆化:Controller 需要注入多个 Service,知道了太多细节
  • 依赖混乱:像蜘蛛网一样的网状依赖

我们采用 三层架构 来解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌─────────────────────────────────────────────────────────────┐
│ Controller 层 │
│ 只注入一个 AuthService │
└─────────────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Facade 层 (AuthService) │
│ "大管家",负责编排所有业务流程 │
└─────────────────────────┬───────────────────────────────────┘

┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌───────────┐ ┌─────────────────────┐
│ JwtUtil │ │ │ │ │
│ (纯算法工具) │ │ │ │ │
└─────────────────┘ │ │ │ │
│ Manager │ │ Manager │
┌─────────────────┐ │ 层 │ │ 层 │
│ JwtProperties │ │ │ │ │
│ (配置类) │ │ │ │ │
└─────────────────┘ └───────────┘ └─────────────────────┘
│ │
▼ ▼
┌───────────────┐ ┌─────────────────────┐
│TokenRedisManager│ │BlacklistRedisManager│
│ (Token存取) │ │ (黑名单存取) │
└───────────────┘ └─────────────────────┘

层级职责

层级类名职责特点
ControllerAuthController接收请求,返回响应只注入 AuthService
FacadeAuthService编排业务流程“大管家”,统一入口
UtilJwtUtilToken 生成/解析纯算法,无 IO
ManagerTokenRedisManagerToken 的 Redis 存取只有 CRUD,无业务逻辑
ManagerBlacklistRedisManager黑名单的 Redis 存取只有 CRUD,无业务逻辑

心态转变:Manager 不是 Service,它们是"底层苦力",只有存取方法,没有复杂的 if-else。

4.4. 封装 TokenRedisManager(基础设施层)

现在我们创建 Manager 层,负责 Token 在 Redis 中的存储、查询、删除等操作。

📄 文件路径auth-core/src/main/java/com/example/auth/core/manager/TokenRedisManager.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
package com.example.auth.core.manager;

import com.example.auth.core.constant.RedisKeyConstants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Set;

/**
* Token Redis 管理器(基础设施层)
* <p>
* 职责:只负责 Token 在 Redis 中的 CRUD 操作,不包含业务逻辑判断。
* 这是一个"底层苦力",只有存取方法,没有复杂的 if-else。
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenRedisManager {

private final StringRedisTemplate redisTemplate;

/**
* 存储 Refresh Token(使用 Pipeline 批量操作)
*/
public void saveRefreshToken(Long userId, String refreshToken,
String jti, long expireSeconds) {
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {

// 1. 存储 Refresh Token (K = refreshToken, V = userId)
String refreshKey = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
connection.stringCommands().setEx(
refreshKey.getBytes(),
expireSeconds,
userId.toString().getBytes()
);

// 2. 记录用户的在线设备
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
connection.setCommands().sAdd(
userKey.getBytes(),
jti.getBytes()
);

// 3. 给设备记录也设置过期时间
connection.keyCommands().expire(
userKey.getBytes(),
expireSeconds
);

return null;
});

log.debug("存储 Refresh Token 成功: userId={}, jti={}, 有效期={}秒",
userId, jti, expireSeconds);
}

/**
* 根据 Refresh Token 获取用户 ID
*/
public Long getUserIdByRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
String userId = redisTemplate.opsForValue().get(key);
return userId == null ? null : Long.parseLong(userId);
}

/**
* 删除 Refresh Token
*/
public void deleteRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
redisTemplate.delete(key);
log.debug("删除 Refresh Token: {}", refreshToken);
}

/**
* 获取用户的所有在线设备(JTI 列表)
*/
public Set<String> getUserOnlineDevices(Long userId) {
String key = RedisKeyConstants.buildUserOnlineKey(userId);
return redisTemplate.opsForSet().members(key);
}

/**
* 从用户在线设备列表中移除指定设备
*/
public void removeUserDevice(Long userId, String jti) {
String key = RedisKeyConstants.buildUserOnlineKey(userId);
redisTemplate.opsForSet().remove(key, jti);
log.debug("移除用户设备: userId={}, jti={}", userId, jti);
}

/**
* 清空用户的所有在线设备
*/
public void clearUserDevices(Long userId) {
String key = RedisKeyConstants.buildUserOnlineKey(userId);
redisTemplate.delete(key);
log.debug("清空用户所有设备: userId={}", userId);
}
}

4.5. 封装 AuthService(Facade 层)

现在我们创建"大管家" AuthService,负责编排所有认证相关的业务流程。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/AuthService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
package com.example.auth.core.service;

import cn.hutool.core.util.IdUtil;
import com.example.auth.core.config.properties.JwtProperties;
import com.example.auth.core.manager.TokenRedisManager;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
* 认证服务(Facade 层 / 应用服务层)
* <p>
* 职责:作为"大管家",负责编排所有认证相关的业务流程。
* Controller 只需要注入这一个 Service,不需要关心底层的实现细节。
* </p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final JwtUtil jwtUtil;
private final JwtProperties jwtProperties;
private final TokenRedisManager tokenRedisManager;

/**
* 登录:创建双令牌
*/
public AuthToken login(Long userId, String username) {
log.info("用户登录: userId={}, username={}", userId, username);

// 1. 生成 Access Token (JWT) - 纯内存操作
String accessToken = jwtUtil.createToken(userId, username, null);

// 2. 从 Access Token 中提取 JTI
String jti = jwtUtil.getJtiFromToken(accessToken);

// 3. 生成 Refresh Token(随机字符串)
String refreshToken = IdUtil.fastSimpleUUID();

// 4. 计算 Refresh Token 的过期时间(秒)
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;

// 5. 将 Refresh Token 存入 Redis - IO 操作
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds);

log.info("用户登录成功: userId={}, jti={}", userId, jti);

// 6. 打包返回
return AuthToken.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn((long) jwtProperties.getAccessTokenExpireMinutes() * 60)
.build();
}

/**
* 刷新令牌:使用 Refresh Token 换取新的令牌对
*/
public AuthToken refresh(String oldRefreshToken) {
log.info("刷新令牌请求");

// 1. 验证 Refresh Token 并获取用户 ID
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);

if (userId == null) {
log.warn("Refresh Token 无效或已过期");
throw new RuntimeException("刷新令牌无效或已过期,请重新登录");
}

// 2. 查询用户信息(实际业务中应该从数据库查询)
String username = "User-" + userId;

// 3. 令牌轮换 - 生成一对全新的令牌
AuthToken newTokenPair = login(userId, username);

// 4. 立即销毁旧的 Refresh Token(防止重放攻击)
tokenRedisManager.deleteRefreshToken(oldRefreshToken);

log.info("令牌刷新成功: userId={}", userId);
return newTokenPair;
}

/**
* 快速验证令牌
*/
public boolean isTokenValid(String token) {
return jwtUtil.validateToken(token);
}
}

4.6. 升级 AuthController(极简版)

现在我们升级 AuthController,它只需要注入一个 AuthService

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package com.example.auth.web.controller;

import com.example.auth.common.model.Result;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
* 认证控制器
* <p>
* 设计理念:Controller 只注入一个"大管家" AuthService,
* 不需要关心底层的 TokenRedisManager 等实现细节。
* </p>
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

// ✅ 只注入一个 Facade 服务
private final AuthService authService;

/**
* 双令牌登录接口
*/
@PostMapping("/login")
public Result<AuthToken> login(@RequestParam Long userId,
@RequestParam String username) {
log.info("收到登录请求: userId={}, username={}", userId, username);
AuthToken authToken = authService.login(userId, username);
return Result.ok(authToken);
}

/**
* 刷新令牌接口
*/
@PostMapping("/refresh")
public Result<AuthToken> refresh(@RequestParam String refreshToken) {
log.info("收到刷新令牌请求");
try {
AuthToken authToken = authService.refresh(refreshToken);
return Result.ok(authToken);
} catch (Exception e) {
log.error("刷新令牌失败", e);
return Result.fail(401, e.getMessage());
}
}

/**
* 验证令牌接口
*/
@GetMapping("/validate")
public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String authHeader) {
log.info("收到验证令牌请求");
String token = extractToken(authHeader);
boolean valid = authService.isTokenValid(token);
Map<String, Object> data = new HashMap<>();
data.put("valid", valid);
return Result.ok(data);
}

private String extractToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return authHeader;
}
}

完成上述代码开发后,我们通过 HTTP 请求来验证双令牌机制的完整流程,重点观察 Redis 中数据的变化以及令牌轮换(Token
Rotation)的效果。

首先启动 Spring Boot 应用,使用 Postman 或 curl 发起 登录请求,模拟用户 admin (ID: 1001) 登录:

1
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

响应结果应包含一对 Token:

1
2
3
4
5
6
7
8
9
10
11
{
"code": 200,
"msg": "success",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ...",
"refreshToken": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

此时,连接 Redis 终端查看数据,可以看到 Refresh Token 已被存储,且关联了用户 ID:

1
2
3
4
5
6
7
8
# 查看 Refresh Token
127.0.0.1:6379> GET auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab
"1001"

# 查看过期时间(约 7 天 = 604800 秒)
127.0.0.1:6379> TTL auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab
(integer) 604795

接下来,我们模拟 Access Token 过期,使用刚才获取的 refreshToken 调用 刷新接口

1
curl -X POST "http://localhost:8080/auth/refresh?refreshToken=YOUR_REFRESH_TOKEN"

响应结果会返回一对 全新的 Token(实现了令牌轮换):

1
2
3
4
5
6
7
8
9
10
11
{
"code": 200,
"msg": "success",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ...",
"refreshToken": "f9e8d7c6-b5a4-3210-fedc-0987654321zy",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

最后再次检查 Redis,验证 旧令牌销毁 机制是否生效。旧的 Refresh Token 应该不存在,只能查到新的 Refresh Token:

1
2
3
4
5
6
7
8
# 检查旧 Token(应返回 nil)
127.0.0.1:6379> GET auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab
(nil)

# 检查新 Token(应存在)
127.0.0.1:6379> GET auth:token:refresh:f9e8d7c6-b5a4-3210-fedc-0987654321zy
"1001"

通过以上步骤,我们成功验证了基于 Redis 的双令牌颁发、存储、刷新以及旧令牌自动作废的完整闭环。