SpringBoot3 登录注册基础篇(六) - 多设备管理与在线状态

SpringBoot3 登录注册基础篇(六) - 多设备管理与在线状态

第一章. 当前架构的盲点

在上一章中,我们实现了 Token 黑名单机制,用户可以主动注销登录。但现在我们面临一个新的问题:用户根本不知道自己有哪些设备在线

1.1. 看不见的在线设备

假设你现在打开手机,想查看自己的账号有哪些设备在线。你会发现:

  • 没有设备列表:不知道自己有几台设备在线
  • 没有登录时间:不知道每个设备是什么时候登录的
  • 没有设备信息:不知道是手机还是电脑,是 Chrome 还是 Safari

这就像你的家里有很多把钥匙,但你不知道每把钥匙在谁手里,也不知道谁最近用过。

1.2. 无法管理的设备列表

更严重的问题是:即使你发现了异常登录,也不知道如何处理。

场景一:发现异常登录

你在北京,突然收到一条通知:“您的账号在上海登录”。你确定自己没有去过上海,怀疑账号被盗。但你打开 App,只能看到一个"退出所有设备"的按钮。

点击后,你自己的手机也被踢下线了,必须重新登录。这就像为了抓一个小偷,把整个小区的门都锁上了。

场景二:账号共享泛滥

你的视频网站账号被朋友借用,结果他又分享给了其他人。现在有 10 个人在用你的账号,但你无法限制设备数量。

1.3. 业务场景梳理

在实际业务中,我们需要支持以下设备管理场景:

场景用户需求技术实现
查看在线设备我想知道有哪些设备在线查询 Redis 中的设备列表
远程踢出设备我想踢出某个可疑设备将指定设备的 JTI 加入黑名单
设备数量限制我想限制最多 3 台设备同时在线登录时检查设备数量,超过则踢出最早的设备
查看登录历史我想知道每个设备的登录时间使用 ZSet 按时间排序存储

第二章. 从 Set 到 ZSet 的架构升级

在第四章中,我们使用 Redis 的 Set 来存储用户的在线设备列表。但现在我们发现,Set 有一个致命缺陷:无法排序

2.1. Set 的局限性

回顾一下我们当前的数据结构:

1
2
Key: auth:user:tokens:1001
Value: {jti-device-1, jti-device-2, jti-device-3}

这个 Set 只能告诉我们"有哪些设备在线",但无法回答:

  • 哪个设备是最早登录的?
  • 哪个设备是最近登录的?
  • 如果要踢出一个设备,应该踢出哪个?

2.2. ZSet 的排序能力

ZSet(Sorted Set)是 Redis 的有序集合每个元素都有一个分数(score),按分数排序。我们可以用登录时间作为分数,这样设备列表就自动按登录时间排序了。

1
2
3
4
5
6
Key: auth:user:tokens:1001
Value: {
jti-device-1: 1735200000, # 2025-12-26 10:00:00
jti-device-2: 1735203600, # 2025-12-26 11:00:00
jti-device-3: 1735207200 # 2025-12-26 12:00:00
}

现在我们可以轻松回答:

  • 最早登录的设备ZRANGE auth:user:tokens:1001 0 0(返回 jti-device-1)
  • 最近登录的设备ZREVRANGE auth:user:tokens:1001 0 0(返回 jti-device-3)
  • 所有设备按时间排序ZRANGE auth:user:tokens:1001 0 -1 WITHSCORES

2.3. 设备信息的存储策略

但是,ZSet 只能存储 JTI 和登录时间,无法存储设备类型、IP、User-Agent 等详细信息。我们需要一个额外的数据结构来存储这些信息。

方案一:使用 Hash

1
2
3
4
5
6
7
8
Key: auth:device:jti-device-1
Value: {
"deviceType": "Mobile",
"os": "iOS 17.2",
"browser": "Safari",
"ip": "192.168.1.100",
"loginTime": "2025-12-26 10:00:00"
}

方案二:使用 String(JSON)

1
2
Key: auth:device:jti-device-1
Value: "{\"deviceType\":\"Mobile\",\"os\":\"iOS 17.2\",...}"

我们选择 方案一(Hash),原因如下:

对比维度HashString(JSON)
读取单个字段HGET 直接读取❌ 需要反序列化整个 JSON
更新单个字段HSET 直接更新❌ 需要读取、修改、写回
内存占用较低(Redis 优化)较高(JSON 字符串)
可读性✅ Redis 客户端直接查看❌ 需要 JSON 格式化工具

第三章. 设备信息模型设计

3.1. DeviceInfo 模型

首先,我们定义一个模型来承载设备信息。

📄 文件路径auth-core/src/main/java/com/example/auth/core/model/DeviceInfo.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
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 DeviceInfo implements Serializable {

/**
* JWT ID(设备唯一标识)
*/
private String jti;

/**
* 设备类型(Mobile / Desktop / Tablet)
*/
private String deviceType;

/**
* 操作系统(如 iOS 17.2, Windows 11)
*/
private String os;

/**
* 浏览器(如 Chrome 120, Safari 17)
*/
private String browser;

/**
* IP 地址
*/
private String ip;

/**
* 登录时间(时间戳,秒)
*/
private Long loginTime;

/**
* 登录时间(格式化字符串)
*/
private String loginTimeFormatted;
}

3.2. 设备信息提取工具类

现在我们需要从 HTTP 请求中提取设备信息。主要来源是:

  • User-Agent:包含浏览器、操作系统、设备类型等信息
  • IP 地址:需要考虑代理和负载均衡

📄 文件路径auth-core/src/main/java/com/example/auth/core/util/DeviceInfoExtractor.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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
package com.example.auth.core.util;

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;

/**
* 设备信息提取工具类
* <p>
* 用于从 HTTP 请求中提取客户端设备相关信息,包括设备类型、操作系统、浏览器和 IP 地址等。
* </p>
*/
@Slf4j
public class DeviceInfoExtractor {

/**
* 从请求中提取设备类型
*
* @param request HTTP 请求对象
* @return 设备类型:Mobile(手机)、Tablet(平板)、Desktop(桌面)或 Unknown(未知)
*/
public static String extractDeviceType(HttpServletRequest request) {
String userAgentStr = request.getHeader("User-Agent");
if (StrUtil.isBlank(userAgentStr)) {
return "Unknown";
}
UserAgent ua = UserAgentUtil.parse(userAgentStr);
if (ua.isMobile()) {
return "Mobile";
} else if (isTablet(userAgentStr)) {
return "Tablet";
} else {
return "Desktop";
}
}

/**
* 从请求中提取操作系统信息
*
* @param request HTTP 请求对象
* @return 操作系统名称,如 Windows、Mac OS X、Android、iOS 等,未知时返回 Unknown
*/
public static String extractOs(HttpServletRequest request) {
String userAgentStr = request.getHeader("User-Agent");
if (StrUtil.isBlank(userAgentStr)) {
return "Unknown";
}
UserAgent ua = UserAgentUtil.parse(userAgentStr);
return ua.getOs().getName();
}

/**
* 从请求中提取浏览器信息
*
* @param request HTTP 请求对象
* @return 浏览器名称和版本号,如 Chrome 120.0,未知时返回 Unknown
*/
public static String extractBrowser(HttpServletRequest request) {
String userAgentStr = request.getHeader("User-Agent");
if (StrUtil.isBlank(userAgentStr)) {
return "Unknown";
}
UserAgent ua = UserAgentUtil.parse(userAgentStr);
return ua.getBrowser().getName() + " " + ua.getVersion();
}

/**
* 从请求中提取客户端真实 IP 地址
* <p>
* 优先从代理相关请求头中获取真实 IP,支持以下请求头(按优先级):
* <ul>
* <li>X-Forwarded-For:标准的代理转发头,取第一个 IP</li>
* <li>X-Real-IP:Nginx 代理常用头</li>
* <li>Proxy-Client-IP:Apache 代理头</li>
* <li>WL-Proxy-Client-IP:WebLogic 代理头</li>
* </ul>
* 如果以上请求头都不存在,则使用 {@link HttpServletRequest#getRemoteAddr()} 获取。
* </p>
*
* @param request HTTP 请求对象
* @return 客户端真实 IP 地址
*/
public static String extractIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (isValidIp(ip)) {
return ip.split(",")[0].trim();
}
ip = request.getHeader("X-Real-IP");
if (isValidIp(ip)) {
return ip;
}
ip = request.getHeader("Proxy-Client-IP");
if (isValidIp(ip)) {
return ip;
}
ip = request.getHeader("WL-Proxy-Client-IP");
if (isValidIp(ip)) {
return ip;
}
return request.getRemoteAddr();
}

/**
* 校验 IP 地址是否有效
*
* @param ip IP 地址字符串
* @return 如果 IP 地址非空且不为 "unknown" 则返回 true,否则返回 false
*/
private static boolean isValidIp(String ip) {
return StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip);
}

/**
* 判断设备是否为平板电脑
* <p>
* 通过 User-Agent 字符串判断设备是否为平板,支持以下设备识别:
* <ul>
* <li>iPad</li>
* <li>Android 平板(包含 android 但不包含 mobile)</li>
* <li>其他平板关键词:tablet、kindle、silk、playbook</li>
* </ul>
* </p>
*
* @param userAgentStr User-Agent 字符串
* @return 如果是平板设备返回 true,否则返回 false
*/
private static boolean isTablet(String userAgentStr) {
String ua = userAgentStr.toLowerCase();
if (ua.contains("ipad")) {
return true;
}
if (ua.contains("android") && !ua.contains("mobile")) {
return true;
}
return ua.contains("tablet")
|| ua.contains("kindle")
|| ua.contains("silk")
|| ua.contains("playbook");
}
}

Hutool 的 UserAgentUtil:Hutool 提供了开箱即用的 User-Agent 解析工具,可以识别常见的浏览器、操作系统、设备类型。

3.3. 关键知识点

为什么要处理 X-Forwarded-For?

在生产环境中,用户的请求通常会经过多层代理:

1
用户 → CDN → 负载均衡 → Nginx → Spring Boot

如果直接使用 request.getRemoteAddr(),获取到的是 Nginx 的 IP,而不是用户的真实 IP。

X-Forwarded-For 是一个标准的 HTTP 头,用于记录请求经过的所有代理的 IP 地址。格式如下:

1
X-Forwarded-For: 用户真实IP, 代理1的IP, 代理2的IP

我们只需要取第一个 IP,就是用户的真实 IP。

为什么要判断 “unknown”?

某些代理服务器在无法获取真实 IP 时,会将 X-Forwarded-For 设置为 "unknown"。我们需要跳过这种无效值。


第四章. 扩展 Redis 管理器

4.1. 扩展 RedisKeyConstants

首先,我们在 RedisKeyConstants 中添加设备详情相关的 Key 前缀。

📄 文件路径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
package com.example.auth.core.constant;

public class RedisKeyConstants {
// ...原有内容

/**
* 设备详情 Key 前缀(Hash)
*/
public static final String DEVICE_INFO_PREFIX = "auth:device:";


/**
* 构建设备详情 Key
*/
public static String buildDeviceInfoKey(String jti) {
return DEVICE_INFO_PREFIX + jti;
}
}

4.2. 重构 TokenRedisManager(升级为 ZSet)

现在我们需要重构 TokenRedisManager,将 Set 升级为 ZSet,并添加设备详情的存储。

📄 文件路径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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
package com.example.auth.core.manager;

import cn.hutool.core.date.DateUtil;
import com.example.auth.core.constant.RedisKeyConstants;
import com.example.auth.core.model.DeviceInfo;
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.*;

/**
* 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, DeviceInfo deviceInfo) {
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. 将设备加入用户的在线设备列表(ZSet,按登录时间排序)
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
double score = deviceInfo.getLoginTime().doubleValue();
connection.zSetCommands().zAdd(
userKey.getBytes(),
score,
jti.getBytes()
);

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

// 4. 存储设备详情(Hash)
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
Map<byte[], byte[]> deviceMap = new HashMap<>();
deviceMap.put("jti".getBytes(), jti.getBytes());
deviceMap.put("deviceType".getBytes(), deviceInfo.getDeviceType().getBytes());
deviceMap.put("os".getBytes(), deviceInfo.getOs().getBytes());
deviceMap.put("browser".getBytes(), deviceInfo.getBrowser().getBytes());
deviceMap.put("ip".getBytes(), deviceInfo.getIp().getBytes());
deviceMap.put("loginTime".getBytes(), deviceInfo.getLoginTime().toString().getBytes());
connection.hashCommands().hMSet(deviceKey.getBytes(), deviceMap);

// 5. 给设备详情设置过期时间
connection.keyCommands().expire(
deviceKey.getBytes(),
expireSeconds
);

return null;
});

log.debug("存储 Refresh Token 和设备信息成功: userId={}, jti={}, 设备类型={}, 有效期={}秒",
userId, jti, deviceInfo.getDeviceType(), 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);
}

/**
* 获取用户的所有在线设备(按登录时间排序)
*/
public List<DeviceInfo> getUserOnlineDevices(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);

// 1. 从 ZSet 中获取所有 JTI(按登录时间倒序)
Set<String> jtiSet = redisTemplate.opsForZSet().reverseRange(userKey, 0, -1);

if (jtiSet == null || jtiSet.isEmpty()) {
return Collections.emptyList();
}

// 2. 批量获取设备详情
return jtiSet.stream()
.map(this::getDeviceInfo)
.filter(Objects::nonNull)
.toList();
}

/**
* 获取设备详情
*/
public DeviceInfo getDeviceInfo(String jti) {
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
Map<Object, Object> deviceMap = redisTemplate.opsForHash().entries(deviceKey);

if (deviceMap.isEmpty()) {
return null;
}

Long loginTime = Long.parseLong((String) deviceMap.get("loginTime"));

return DeviceInfo.builder()
.jti((String) deviceMap.get("jti"))
.deviceType((String) deviceMap.get("deviceType"))
.os((String) deviceMap.get("os"))
.browser((String) deviceMap.get("browser"))
.ip((String) deviceMap.get("ip"))
.loginTime(loginTime)
.loginTimeFormatted(DateUtil.formatDateTime(new Date(loginTime * 1000)))
.build();
}

/**
* 从用户在线设备列表中移除指定设备
*/
public void removeUserDevice(Long userId, String jti) {
// 1. 从 ZSet 中移除
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
redisTemplate.opsForZSet().remove(userKey, jti);

// 2. 删除设备详情
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
redisTemplate.delete(deviceKey);

log.debug("移除用户设备: userId={}, jti={}", userId, jti);
}

/**
* 清空用户的所有在线设备
*/
public void clearUserDevices(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);

// 1. 获取所有 JTI
Set<String> jtiSet = redisTemplate.opsForZSet().range(userKey, 0, -1);

if (jtiSet != null && !jtiSet.isEmpty()) {
// 2. 批量删除设备详情
List<String> deviceKeys = jtiSet.stream()
.map(RedisKeyConstants::buildDeviceInfoKey)
.toList();
redisTemplate.delete(deviceKeys);
}

// 3. 删除设备列表
redisTemplate.delete(userKey);

log.debug("清空用户所有设备: userId={}", userId);
}

/**
* 获取用户的在线设备数量
*/
public Long getUserDeviceCount(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
return redisTemplate.opsForZSet().zCard(userKey);
}

/**
* 获取最早登录的设备(用于顶号逻辑)
*/
public String getOldestDevice(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
Set<String> jtiSet = redisTemplate.opsForZSet().range(userKey, 0, 0);
return (jtiSet != null && !jtiSet.isEmpty()) ? jtiSet.iterator().next() : null;
}
}

4.3. 关键设计细节

为什么使用 reverseRange 而不是 range?

reverseRange 是倒序查询,返回的设备列表是按登录时间从新到旧排序的。这样用户看到的设备列表,最上面的是最近登录的设备,符合用户习惯。

为什么要批量删除设备详情?

clearUserDevices 方法中,我们需要删除所有设备的详情。如果一个一个删除,会产生大量的网络请求。使用 redisTemplate.delete(List<String> keys) 可以一次性删除多个 Key,大幅提升性能。

1
2
3
4
5
6
7
8
9
10
11
// ❌ 低效写法(N 次网络请求)
for (String jti : jtiSet) {
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
redisTemplate.delete(deviceKey);
}

// ✅ 高效写法(1 次网络请求)
List<String> deviceKeys = jtiSet.stream()
.map(RedisKeyConstants::buildDeviceInfoKey)
.toList()
redisTemplate.delete(deviceKeys);

为什么 ZSet 的 score 使用登录时间?

ZSet 的 score 必须是数值类型。我们使用 Unix 时间戳(秒)作为 score,这样:

  • 自动排序:Redis 会自动按 score 排序,无需额外操作
  • 范围查询:可以查询"最近 7 天登录的设备"(ZRANGEBYSCORE
  • 性能优化:数值比较比字符串比较快得多

第五章. 升级 AuthService(集成设备管理)

5.1. 在登录时记录设备信息

现在我们需要升级 AuthServicelogin 方法,在登录时记录设备信息。

📄 文件路径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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
package com.example.auth.core.service;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.IdUtil;
import com.example.auth.core.config.properties.JwtProperties;
import com.example.auth.core.manager.BlacklistRedisManager;
import com.example.auth.core.manager.TokenRedisManager;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.model.DeviceInfo;
import com.example.auth.core.util.JwtUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.List;

/**
* 认证服务(Facade 层 / 应用服务层)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

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

// ==================== 登录相关 ====================

/**
* 登录:创建双令牌(带设备信息)
*/
public AuthToken login(Long userId, String username, DeviceInfo deviceInfo) {
log.info("用户登录: userId={}, username={}, 设备类型={}",
userId, username, deviceInfo.getDeviceType());

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

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

// 3. 补充设备信息中的 JTI 和登录时间
deviceInfo.setJti(jti);
deviceInfo.setLoginTime(System.currentTimeMillis() / 1000);

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

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

// 6. 将 Refresh Token 和设备信息存入 Redis
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds, deviceInfo);

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

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

// ==================== 令牌刷新 ====================

/**
* 刷新令牌:使用 Refresh Token 换取新的令牌对
*/
public AuthToken refresh(String oldRefreshToken, DeviceInfo deviceInfo) {
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, deviceInfo);

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

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

// ==================== 令牌验证(集成黑名单检查) ====================

/**
* 验证 Token(集成黑名单检查)
*/
public Claims validateToken(String token) {
try {
// 1. 验证签名和过期时间(本地操作,无网络开销)
Claims claims = jwtUtil.parseToken(token);

// 2. 检查是否在黑名单中(Redis 查询,O(1) 复杂度)
String jti = claims.getId();
if (blacklistRedisManager.exists(jti)) {
log.warn("Token 已被注销: jti={}", jti);
throw new RuntimeException("Token 已被注销,请重新登录");
}

return claims;

} catch (ExpiredJwtException e) {
log.debug("Token 已过期: {}", e.getMessage());
throw new RuntimeException("Token 已过期,请重新登录");
} catch (JwtException e) {
log.warn("Token 验证失败: {}", e.getMessage());
throw new RuntimeException("Token 无效");
}
}

/**
* 快速验证(只返回布尔值)
*/
public boolean isTokenValid(String token) {
try {
validateToken(token);
return true;
} catch (Exception e) {
return false;
}
}

// ==================== 注销相关 ====================

/**
* 单设备注销(用户主动退出)
*/
public void logout(String accessToken, String refreshToken) {
log.info("单设备注销请求");

try {
// 1. 解析 Token 获取必要信息
Claims claims = jwtUtil.parseToken(accessToken);
String jti = claims.getId();
Date expiration = claims.getExpiration();
Long userId = Long.parseLong(claims.getSubject());

// 2. 将 Access Token 加入黑名单
blacklistRedisManager.add(jti, expiration);

// 3. 删除 Refresh Token
tokenRedisManager.deleteRefreshToken(refreshToken);

// 4. 从用户在线设备列表中移除
tokenRedisManager.removeUserDevice(userId, jti);

log.info("单设备注销成功: userId={}, jti={}", userId, jti);

} catch (Exception e) {
log.error("单设备注销失败", e);
throw new RuntimeException("注销失败", e);
}
}

/**
* 全设备注销(密码修改、管理员强制下线)
*/
public void logoutAll(Long userId) {
log.info("全设备注销请求: userId={}", userId);

try {
// 1. 获取用户的所有在线设备
List<DeviceInfo> devices = tokenRedisManager.getUserOnlineDevices(userId);

if (CollUtil.isEmpty(devices)) {
log.info("用户没有在线设备: userId={}", userId);
return;
}

// 2. 将所有 JTI 加入黑名单
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L;
devices.forEach(device -> blacklistRedisManager.add(device.getJti(), maxExpireSeconds));

// 3. 清空用户的在线设备列表
tokenRedisManager.clearUserDevices(userId);

log.info("全设备注销成功: userId={}, 设备数量={}", userId, devices.size());

} catch (Exception e) {
log.error("全设备注销失败: userId={}", userId, e);
throw new RuntimeException("全设备注销失败", e);
}
}

/**
* 踢出指定设备(远程注销)
*/
public void kickoutDevice(Long userId, String jti) {
log.info("踢出设备请求: userId={}, jti={}", userId, jti);

try {
// 1. 将 JTI 加入黑名单
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L;
blacklistRedisManager.add(jti, maxExpireSeconds);

// 2. 从用户在线设备列表中移除
tokenRedisManager.removeUserDevice(userId, jti);

log.info("踢出设备成功: userId={}, jti={}", userId, jti);

} catch (Exception e) {
log.error("踢出设备失败: userId={}, jti={}", userId, jti, e);
throw new RuntimeException("踢出设备失败", e);
}
}

// ==================== 设备管理 ====================

/**
* 查询用户的在线设备列表
*/
public List<DeviceInfo> getOnlineDevices(Long userId) {
log.info("查询在线设备: userId={}", userId);
return tokenRedisManager.getUserOnlineDevices(userId);
}

/**
* 获取设备详情
*/
public DeviceInfo getDeviceInfo(String jti) {
return tokenRedisManager.getDeviceInfo(jti);
}

// ==================== 辅助方法 ====================

/**
* 从 Token 中提取用户 ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = validateToken(token);
return Long.parseLong(claims.getSubject());
}
}

5.2. 关键设计细节

为什么 login 方法需要传入 DeviceInfo?

在之前的版本中,login 方法只需要 userIdusername。现在我们需要记录设备信息,所以必须传入 DeviceInfo 对象。

但是,DeviceInfo 的提取工作不应该在 Service 层完成,而应该在 Controller 层完成。因为:

  • Service 层不应该依赖 HttpServletRequest:Service 层是业务逻辑层,不应该依赖 Web 层的对象
  • 便于单元测试:如果 Service 层依赖 HttpServletRequest,单元测试时需要 Mock 这个对象,非常麻烦

为什么 refresh 方法也需要传入 DeviceInfo?

刷新令牌时,我们会生成一对全新的 Token。这意味着会产生一个新的 JTI,需要更新设备信息。

但是,刷新令牌时的设备信息应该和登录时的设备信息保持一致吗?不一定。

假设用户在手机上登录,然后切换到 Wi-Fi 网络,IP 地址变了。此时刷新令牌,应该更新 IP 地址。

所以,我们在刷新令牌时也需要传入最新的设备信息。


第六章. 设备管理接口实现

6.1. 升级 AuthController

现在我们需要升级 AuthController,添加设备管理相关的接口。

📄 文件路径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
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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.model.DeviceInfo;
import com.example.auth.core.service.AuthService;
import com.example.auth.core.util.DeviceInfoExtractor;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

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

/**
* 认证控制器
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthService authService;

/**
* 双令牌登录接口(带设备信息)
*/
@PostMapping("/login")
public Result<AuthToken> login(@RequestParam Long userId,
@RequestParam String username,
HttpServletRequest request) {
log.info("收到登录请求: userId={}, username={}", userId, username);

// 提取设备信息
DeviceInfo deviceInfo = buildDeviceInfo(request);

AuthToken authToken = authService.login(userId, username, deviceInfo);
return Result.ok(authToken);
}

/**
* 刷新令牌接口(带设备信息)
*/
@PostMapping("/refresh")
public Result<AuthToken> refresh(@RequestParam String refreshToken,
HttpServletRequest request) {
log.info("收到刷新令牌请求");
try {
// 提取设备信息
DeviceInfo deviceInfo = buildDeviceInfo(request);

AuthToken authToken = authService.refresh(refreshToken, deviceInfo);
return Result.ok(authToken);
} catch (Exception e) {
log.error("刷新令牌失败", e);
return Result.fail(401, e.getMessage());
}
}

/**
* 单设备注销接口
*/
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader("Authorization") String authHeader,
@RequestParam String refreshToken) {
log.info("收到注销请求");
try {
String accessToken = extractToken(authHeader);
authService.logout(accessToken, refreshToken);
return Result.ok();
} catch (Exception e) {
log.error("注销失败", e);
return Result.fail(500, "注销失败:" + e.getMessage());
}
}

/**
* 全设备注销接口
*/
@PostMapping("/logout/all")
public Result<Void> logoutAll(@RequestHeader("Authorization") String authHeader) {
log.info("收到全设备注销请求");
try {
String accessToken = extractToken(authHeader);
Long userId = authService.getUserIdFromToken(accessToken);
authService.logoutAll(userId);
return Result.ok();
} catch (Exception e) {
log.error("全设备注销失败", e);
return Result.fail(500, "全设备注销失败:" + e.getMessage());
}
}

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

Map<String, Object> data = new HashMap<>();
data.put("valid", true);
data.put("userId", claims.getSubject());
data.put("username", claims.get("username"));

return Result.ok(data);
} catch (Exception e) {
log.debug("Token 验证失败: {}", e.getMessage());
Map<String, Object> data = new HashMap<>();
data.put("valid", false);
data.put("reason", e.getMessage());
return Result.ok(data);
}
}

// ==================== 设备管理接口 ====================

/**
* 查询在线设备列表
*/
@GetMapping("/devices")
public Result<List<DeviceInfo>> getDevices(@RequestHeader("Authorization") String authHeader) {
log.info("收到查询在线设备请求");
try {
String accessToken = extractToken(authHeader);
Long userId = authService.getUserIdFromToken(accessToken);
List<DeviceInfo> devices = authService.getOnlineDevices(userId);
return Result.ok(devices);
} catch (Exception e) {
log.error("查询在线设备失败", e);
return Result.fail(500, "查询失败:" + e.getMessage());
}
}

/**
* 踢出指定设备
*/
@DeleteMapping("/devices/{jti}")
public Result<Void> kickoutDevice(@RequestHeader("Authorization") String authHeader,
@PathVariable String jti) {
log.info("收到踢出设备请求: jti={}", jti);
try {
String accessToken = extractToken(authHeader);
Long userId = authService.getUserIdFromToken(accessToken);
authService.kickoutDevice(userId, jti);
return Result.ok();
} catch (Exception e) {
log.error("踢出设备失败: jti={}", jti, e);
return Result.fail(500, "踢出设备失败:" + e.getMessage());
}
}

// ==================== 辅助方法 ====================

/**
* 从 Authorization Header 中提取 Token
*/
private String extractToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return authHeader;
}

/**
* 构建设备信息
*/
private DeviceInfo buildDeviceInfo(HttpServletRequest request) {
return DeviceInfo.builder()
.deviceType(DeviceInfoExtractor.extractDeviceType(request))
.os(DeviceInfoExtractor.extractOs(request))
.browser(DeviceInfoExtractor.extractBrowser(request))
.ip(DeviceInfoExtractor.extractIp(request))
.build();
}
}

6.2. 接口设计说明

为什么踢出设备使用 DELETE 方法?

RESTful API 的设计原则是:

  • GET:查询资源
  • POST:创建资源
  • PUT:更新资源
  • DELETE:删除资源

踢出设备本质上是"删除一个在线会话",所以使用 DELETE 方法更符合语义。

为什么路径是 /devices/{jti} 而不是 /devices/kickout

RESTful API 的设计原则是:资源用名词,操作用 HTTP 方法

  • /devices/kickout?jti=xxx(动词 + 参数)
  • /devices/{jti}(名词 + DELETE 方法)

第七章. 设备数量限制(顶号逻辑)

7.1. 业务场景

在视频网站、音乐平台等业务中,通常会限制同一账号的最大在线设备数。例如:

  • Netflix:标准套餐最多 2 台设备同时观看
  • Spotify:免费用户只能 1 台设备在线
  • 腾讯视频:VIP 会员最多 5 台设备登录

当用户在第 6 台设备上登录时,系统会自动踢出最早登录的设备。这就是顶号逻辑新设备登录时,自动踢出旧设备

7.2. 扩展 JwtProperties

首先,我们在配置类中添加设备数量限制的配置。

📄 文件路径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
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
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 {

/**
* 签发者标识 (Issuer)
*/
private String issuer = "auth-service";

/**
* Access Token 有效期(分钟)
*/
private Integer accessTokenExpireMinutes = 15;

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

/**
* 时钟偏差容忍度(秒)
*/
private Long clockSkewSeconds = 30L;

/**
* 公钥资源路径
*/
private String publicKeyResource = "certs/public_key.pem";

/**
* 私钥资源路径
*/
private String privateKeyResource = "certs/private_key.pem";

/**
* 最大在线设备数(0 表示不限制)
*/
private Integer maxDevices = 0;
}

更新配置文件。

📄 文件路径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
26
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
max-devices: 3 # 最多 3 台设备同时在线

7.3. 在 AuthService 中实现顶号逻辑

现在我们需要在 login 方法中添加设备数量检查。

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

login 方法的开头添加以下逻辑:

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
/**
* 登录:创建双令牌(带设备信息 + 设备数量限制)
*/
public AuthToken login(Long userId, String username, DeviceInfo deviceInfo) {
log.info("用户登录: userId={}, username={}, 设备类型={}",
userId, username, deviceInfo.getDeviceType());

// ========== 新增:设备数量限制检查 ==========
Integer maxDevices = jwtProperties.getMaxDevices();
if (maxDevices != null && maxDevices > 0) {
Long currentDeviceCount = tokenRedisManager.getUserDeviceCount(userId);

if (currentDeviceCount >= maxDevices) {
// 达到设备数量上限,踢出最早登录的设备
String oldestJti = tokenRedisManager.getOldestDevice(userId);
if (oldestJti != null) {
log.info("设备数量达到上限,踢出最早登录的设备: userId={}, jti={}", userId, oldestJti);
kickoutDevice(userId, oldestJti);
}
}
}
// ========== 设备数量限制检查结束 ==========

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

// ... 后续逻辑保持不变
}

7.4. 顶号流程图

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
┌─────────────────┐
│ 用户登录请求 │
└────────┬────────┘


┌─────────────────┐
│ 检查设备数量 │
└────────┬────────┘

├─ 未达上限 ──────────────┐
│ │
└─ 达到上限 │
│ │
▼ │
┌─────────────────┐ │
│ 获取最早的设备 │ │
└────────┬────────┘ │
│ │
▼ │
┌─────────────────┐ │
│ 踢出该设备 │ │
│ (加入黑名单) │ │
└────────┬────────┘ │
│ │
└────────────────┤


┌─────────────────┐
│ 生成新的 Token │
└─────────────────┘

7.5. 关键设计细节

为什么要先踢出旧设备,再生成新 Token?

如果先生成新 Token,再踢出旧设备,会出现一个短暂的时间窗口:设备数量超过了上限。虽然这个时间窗口很短(几毫秒),但在高并发场景下可能会被利用。

为什么使用 maxDevices > 0 而不是 maxDevices != null

配置文件中可能会设置 max-devices: 0,表示不限制设备数量。我们需要同时检查 != null> 0

为什么不使用分布式锁?

你可能会担心:如果用户同时在两台设备上登录,会不会出现竞态条件?

答案是:不会。因为我们的检查和踢出操作都是在同一个方法中完成的,而 Spring 的 Service 方法默认是单线程执行的(除非你手动开启异步)。

即使在高并发场景下,最坏的情况是:两个请求都通过了设备数量检查,导致设备数量暂时超过上限。但下一次登录时,会自动踢出多余的设备,系统会自动恢复到正常状态。

如果你的业务对设备数量有严格要求(如付费会员系统),可以使用 Redis 的分布式锁(Redisson)来保证原子性。

第八章. 完整测试流程

8.1. 测试设备列表查询

步骤 1:模拟多设备登录

注意:我们换成了 zset 来存储用户,所以如果还存在原有的 set 数据会报错,需要提前将 redis 内的数据清空

1
2
3
4
5
6
7
8
9
10
11
# 设备 1 登录(模拟 iPhone)
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \
-H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15"

# 设备 2 登录(模拟 Chrome 桌面版)
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0"

# 设备 3 登录(模拟 Android)
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \
-H "User-Agent: Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile"

每次登录都会返回一对 Token,请保存第一个设备的 accessToken,后续查询设备列表时需要用到。

步骤 2:查询在线设备列表

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"

预期响应

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
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "8bc942aa283e4a1cacdf6ea0c7cad99a",
"deviceType": "Mobile",
"os": "Android",
"browser": "Chrome 120.0.0.0",
"ip": "0:0:0:0:0:0:0:1",
"loginTime": 1770299441,
"loginTimeFormatted": "2026-02-05 21:50:41"
},
{
"jti": "adff3e578ea24320a008453d763bf32e",
"deviceType": "Desktop",
"os": "Windows 10 or Windows Server 2016",
"browser": "Chrome 120.0.0.0",
"ip": "0:0:0:0:0:0:0:1",
"loginTime": 1770299437,
"loginTimeFormatted": "2026-02-05 21:50:37"
},
{
"jti": "c775a8e53b444290a04bbe13513210c5",
"deviceType": "Mobile",
"os": "iPhone",
"browser": "Unknown null",
"ip": "0:0:0:0:0:0:0:1",
"loginTime": 1770299418,
"loginTimeFormatted": "2026-02-05 21:50:18"
}
]
}

观察要点:设备列表按登录时间倒序排列,最新登录的设备在最上面。

步骤 3:检查 Redis 数据

8.2. 测试远程踢出设备

步骤 1:获取要踢出的设备 JTI

从上一步的设备列表中,选择一个设备的 JTI(如 f6e5d4c3b2a1)。

步骤 2:踢出该设备

1
2
curl -X DELETE "http://localhost:8080/auth/devices/f6e5d4c3b2a1" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": null
}

步骤 3:再次查询设备列表(应该少了一个设备)

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "a1b2c3d4e5f6",
"deviceType": "Mobile",
"os": "Android",
"browser": "Chrome Mobile 120.0.0.0",
"ip": "127.0.0.1",
"loginTime": 1735280400,
"loginTimeFormatted": "2025-12-27 14:00:00"
},
{
"jti": "1a2b3c4d5e6f",
"deviceType": "Mobile",
"os": "iOS",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735280200,
"loginTimeFormatted": "2025-12-27 13:56:40"
}
]
}

步骤 4:验证被踢出的设备无法访问

使用被踢出设备的 Access Token 尝试访问:

1
2
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <KICKED_DEVICE_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false,
"reason": "Token 已被注销,请重新登录"
}
}

步骤 5:检查 Redis 数据

1
2
3
4
5
6
7
8
9
10
11
12
# 检查黑名单(应该存在)
127.0.0.1:6379> EXISTS auth:token:black:f6e5d4c3b2a1
(integer) 1

# 检查设备详情(应该不存在)
127.0.0.1:6379> EXISTS auth:device:f6e5d4c3b2a1
(integer) 0

# 检查用户在线设备列表(应该少了一个)
127.0.0.1:6379> ZRANGE auth:user:tokens:1001 0 -1
1) "1a2b3c4d5e6f"
2) "a1b2c3d4e5f6"

8.3. 测试设备数量限制(顶号逻辑)

步骤 1:修改配置文件,设置最大设备数为 3

1
2
jwt:
max-devices: 3

重启应用。

步骤 2:模拟 3 台设备登录

1
2
3
4
5
6
7
8
9
10
# 设备 1 登录
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (iPhone)"

# 设备 2 登录
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0)"

# 设备 3 登录
curl -X POST "好的,我继续完成第六章的剩余内容:

保存第一个设备的 Access Token,用于后续查询。

步骤 3:查询设备列表(应该有 3 台设备)

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"

预期响应

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
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "mac-device-jti",
"deviceType": "Desktop",
"os": "Mac OS X",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735281000,
"loginTimeFormatted": "2025-12-27 14:10:00"
},
{
"jti": "windows-device-jti",
"deviceType": "Desktop",
"os": "Windows 10",
"browser": "Chrome",
"ip": "127.0.0.1",
"loginTime": 1735280800,
"loginTimeFormatted": "2025-12-27 14:06:40"
},
{
"jti": "iphone-device-jti",
"deviceType": "Mobile",
"os": "iOS",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735280600,
"loginTimeFormatted": "2025-12-27 14:03:20"
}
]
}

步骤 4:第 4 台设备登录(触发顶号)

1
2
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (Linux; Android 14)"

观察服务端日志,应该看到:

1
2
3
4
INFO  - 用户登录: userId=2001, username=testuser, 设备类型=Mobile
INFO - 设备数量达到上限,踢出最早登录的设备: userId=2001, jti=iphone-device-jti
INFO - 踢出设备成功: userId=2001, jti=iphone-device-jti
INFO - 用户登录成功: userId=2001, jti=android-device-jti, IP=127.0.0.1

步骤 5:再次查询设备列表(应该还是 3 台,但最早的设备被替换了)

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <NEW_DEVICE_ACCESS_TOKEN>"

预期响应

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
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "android-device-jti",
"deviceType": "Mobile",
"os": "Android",
"browser": "Chrome Mobile",
"ip": "127.0.0.1",
"loginTime": 1735281200,
"loginTimeFormatted": "2025-12-27 14:13:20"
},
{
"jti": "mac-device-jti",
"deviceType": "Desktop",
"os": "Mac OS X",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735281000,
"loginTimeFormatted": "2025-12-27 14:10:00"
},
{
"jti": "windows-device-jti",
"deviceType": "Desktop",
"os": "Windows 10",
"browser": "Chrome",
"ip": "127.0.0.1",
"loginTime": 1735280800,
"loginTimeFormatted": "2025-12-27 14:06:40"
}
]
}

观察要点:iPhone 设备(最早登录)已经被踢出,Android 设备(最新登录)成功加入。

步骤 6:验证被踢出的设备无法访问

使用 iPhone 设备的 Access Token 尝试访问:

1
2
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <IPHONE_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false,
"reason": "Token 已被注销,请重新登录"
}
}

8.4. 测试设备信息的准确性

步骤 1:使用真实的浏览器访问

打开浏览器,访问以下 URL(需要先实现一个简单的登录页面,或使用 Postman):

1
POST http://localhost:8080/auth/login?userId=3001&username=browseruser

步骤 2:查询设备列表

1
2
GET http://localhost:8080/auth/devices
Authorization: Bearer <YOUR_ACCESS_TOKEN>

预期响应(以 Chrome 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "real-browser-jti",
"deviceType": "Desktop",
"os": "Windows 10",
"browser": "Chrome 120.0.0.0",
"ip": "192.168.1.100",
"loginTime": 1735281500,
"loginTimeFormatted": "2025-12-27 14:18:20"
}
]
}

验证要点:检查 deviceTypeosbrowser 是否与你的实际环境一致。


第九章. 本章小结

我们完成了多设备管理功能,实现了设备列表查询、远程踢出、设备数量限制等核心能力。

9.1. 本章回顾

本章涵盖了以下核心能力模块:

数据结构升级:将用户在线设备列表从 Set 升级为 ZSet,支持按登录时间排序。同时使用 Hash 存储设备详情,实现了高效的单字段读取和更新。

设备信息提取:封装了 DeviceInfoExtractor 工具类,从 HTTP 请求中提取设备类型、操作系统、浏览器、IP 地址等信息。特别处理了代理和负载均衡场景下的真实 IP 获取。

设备管理接口:提供了查询在线设备列表、远程踢出指定设备的 RESTful API。Controller 层负责提取设备信息,Service 层负责业务编排,Manager 层负责 Redis 操作,职责清晰。

设备数量限制:实现了顶号逻辑,当用户在新设备登录时,如果超过最大设备数限制,自动踢出最早登录的设备。支持通过配置文件灵活调整设备数量上限。

9.2. 核心汇总表

知识点使用场景跳转链接
Set vs ZSet需要对设备列表排序时2.1. Set 的局限性
Hash vs String存储设备详情时2.3. 设备信息的存储策略
DeviceInfo 模型定义设备信息结构3.1. DeviceInfo 模型
真实 IP 提取处理代理和负载均衡3.2. 设备信息提取工具类
TokenRedisManager 升级存储和查询设备信息4.2. 重构 TokenRedisManager
设备列表查询用户查看在线设备6.1. 升级 AuthController
远程踢出设备用户主动踢出可疑设备6.1. 升级 AuthController
设备数量限制防止账号共享7.3. 在 AuthService 中实现顶号逻辑

9.3. 方法速查表

类名方法名作用
DeviceInfoExtractorextractDeviceType(request)提取设备类型
DeviceInfoExtractorextractOs(request)提取操作系统
DeviceInfoExtractorextractBrowser(request)提取浏览器
DeviceInfoExtractorextractIp(request)提取真实 IP
TokenRedisManagersaveRefreshToken(..., deviceInfo)存储 Token 和设备信息
TokenRedisManagergetUserOnlineDevices(userId)获取在线设备列表
TokenRedisManagergetDeviceInfo(jti)获取设备详情
TokenRedisManagergetUserDeviceCount(userId)获取设备数量
TokenRedisManagergetOldestDevice(userId)获取最早登录的设备
AuthServicelogin(userId, username, deviceInfo)登录(带设备信息)
AuthServicegetOnlineDevices(userId)查询在线设备
AuthServicekickoutDevice(userId, jti)踢出指定设备

本章引用链接

在使用多设备管理功能的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:

架构设计相关

代码实现相关

测试验证相关

常见问题