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 WITHSCORES2.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) ,原因如下:
对比维度 Hash String(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 { private String jti; private String deviceType; private String os; private String browser; 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;@Slf4j public class DeviceInfoExtractor { 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" ; } } 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(); } 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(); } 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(); } private static boolean isValidIp (String ip) { return StrUtil.isNotBlank(ip) && !"unknown" .equalsIgnoreCase(ip); } 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 { public static final String DEVICE_INFO_PREFIX = "auth:device:" ; 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.*;@Slf4j @Component @RequiredArgsConstructor public class TokenRedisManager { private final StringRedisTemplate redisTemplate; public void saveRefreshToken (Long userId, String refreshToken, String jti, long expireSeconds, DeviceInfo deviceInfo) { redisTemplate.executePipelined((RedisCallback<Object>) connection -> { String refreshKey = RedisKeyConstants.buildRefreshTokenKey(refreshToken); connection.stringCommands().setEx( refreshKey.getBytes(), expireSeconds, userId.toString().getBytes() ); String userKey = RedisKeyConstants.buildUserOnlineKey(userId); double score = deviceInfo.getLoginTime().doubleValue(); connection.zSetCommands().zAdd( userKey.getBytes(), score, jti.getBytes() ); connection.keyCommands().expire( userKey.getBytes(), expireSeconds ); 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); connection.keyCommands().expire( deviceKey.getBytes(), expireSeconds ); return null ; }); log.debug("存储 Refresh Token 和设备信息成功: userId={}, jti={}, 设备类型={}, 有效期={}秒" , userId, jti, deviceInfo.getDeviceType(), expireSeconds); } public Long getUserIdByRefreshToken (String refreshToken) { String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken); String userId = redisTemplate.opsForValue().get(key); return userId == null ? null : Long.parseLong(userId); } 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); Set<String> jtiSet = redisTemplate.opsForZSet().reverseRange(userKey, 0 , -1 ); if (jtiSet == null || jtiSet.isEmpty()) { return Collections.emptyList(); } 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) { String userKey = RedisKeyConstants.buildUserOnlineKey(userId); redisTemplate.opsForZSet().remove(userKey, jti); String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti); redisTemplate.delete(deviceKey); log.debug("移除用户设备: userId={}, jti={}" , userId, jti); } public void clearUserDevices (Long userId) { String userKey = RedisKeyConstants.buildUserOnlineKey(userId); Set<String> jtiSet = redisTemplate.opsForZSet().range(userKey, 0 , -1 ); if (jtiSet != null && !jtiSet.isEmpty()) { List<String> deviceKeys = jtiSet.stream() .map(RedisKeyConstants::buildDeviceInfoKey) .toList(); redisTemplate.delete(deviceKeys); } 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 for (String jti : jtiSet) { String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti); redisTemplate.delete(deviceKey); } 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. 在登录时记录设备信息 现在我们需要升级 AuthService 的 login 方法,在登录时记录设备信息。
📄 文件路径 :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;@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()); String accessToken = jwtUtil.createToken(userId, username, null ); String jti = jwtUtil.getJtiFromToken(accessToken); deviceInfo.setJti(jti); deviceInfo.setLoginTime(System.currentTimeMillis() / 1000 ); String refreshToken = IdUtil.fastSimpleUUID(); long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L ; tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds, deviceInfo); log.info("用户登录成功: userId={}, jti={}, IP={}" , userId, jti, deviceInfo.getIp()); return AuthToken.builder() .accessToken(accessToken) .refreshToken(refreshToken) .expiresIn((long ) jwtProperties.getAccessTokenExpireMinutes() * 60 ) .build(); } public AuthToken refresh (String oldRefreshToken, DeviceInfo deviceInfo) { log.info("刷新令牌请求" ); Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken); if (userId == null ) { log.warn("Refresh Token 无效或已过期" ); throw new RuntimeException ("刷新令牌无效或已过期,请重新登录" ); } String username = "User-" + userId; AuthToken newTokenPair = login(userId, username, deviceInfo); tokenRedisManager.deleteRefreshToken(oldRefreshToken); log.info("令牌刷新成功: userId={}" , userId); return newTokenPair; } public Claims validateToken (String token) { try { Claims claims = jwtUtil.parseToken(token); 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 { Claims claims = jwtUtil.parseToken(accessToken); String jti = claims.getId(); Date expiration = claims.getExpiration(); Long userId = Long.parseLong(claims.getSubject()); blacklistRedisManager.add(jti, expiration); tokenRedisManager.deleteRefreshToken(refreshToken); 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 { List<DeviceInfo> devices = tokenRedisManager.getUserOnlineDevices(userId); if (CollUtil.isEmpty(devices)) { log.info("用户没有在线设备: userId={}" , userId); return ; } long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L ; devices.forEach(device -> blacklistRedisManager.add(device.getJti(), maxExpireSeconds)); 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 { long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L ; blacklistRedisManager.add(jti, maxExpireSeconds); 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); } public Long getUserIdFromToken (String token) { Claims claims = validateToken(token); return Long.parseLong(claims.getSubject()); } }
5.2. 关键设计细节 为什么 login 方法需要传入 DeviceInfo?
在之前的版本中,login 方法只需要 userId 和 username。现在我们需要记录设备信息,所以必须传入 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()); } } 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 { private String issuer = "auth-service" ; private Integer accessTokenExpireMinutes = 15 ; private Integer refreshTokenExpireDays = 7 ; private Long clockSkewSeconds = 30L ; private String publicKeyResource = "certs/public_key.pem" ; private String privateKeyResource = "certs/private_key.pem" ; 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
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); } } } 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 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" 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" 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
重启应用。
步骤 2:模拟 3 台设备登录
1 2 3 4 5 6 7 8 9 10 curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (iPhone)" curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0)" 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" } ] }
验证要点 :检查 deviceType、os、browser 是否与你的实际环境一致。
第九章. 本章小结 我们完成了多设备管理功能,实现了设备列表查询、远程踢出、设备数量限制等核心能力。
9.1. 本章回顾 本章涵盖了以下核心能力模块:
数据结构升级 :将用户在线设备列表从 Set 升级为 ZSet,支持按登录时间排序。同时使用 Hash 存储设备详情,实现了高效的单字段读取和更新。
设备信息提取 :封装了 DeviceInfoExtractor 工具类,从 HTTP 请求中提取设备类型、操作系统、浏览器、IP 地址等信息。特别处理了代理和负载均衡场景下的真实 IP 获取。
设备管理接口 :提供了查询在线设备列表、远程踢出指定设备的 RESTful API。Controller 层负责提取设备信息,Service 层负责业务编排,Manager 层负责 Redis 操作,职责清晰。
设备数量限制 :实现了顶号逻辑,当用户在新设备登录时,如果超过最大设备数限制,自动踢出最早登录的设备。支持通过配置文件灵活调整设备数量上限。
9.2. 核心汇总表 9.3. 方法速查表 类名 方法名 作用 DeviceInfoExtractor extractDeviceType(request)提取设备类型 DeviceInfoExtractor extractOs(request)提取操作系统 DeviceInfoExtractor extractBrowser(request)提取浏览器 DeviceInfoExtractor extractIp(request)提取真实 IP TokenRedisManager saveRefreshToken(..., deviceInfo)存储 Token 和设备信息 TokenRedisManager getUserOnlineDevices(userId)获取在线设备列表 TokenRedisManager getDeviceInfo(jti)获取设备详情 TokenRedisManager getUserDeviceCount(userId)获取设备数量 TokenRedisManager getOldestDevice(userId)获取最早登录的设备 AuthService login(userId, username, deviceInfo)登录(带设备信息) AuthService getOnlineDevices(userId)查询在线设备 AuthService kickoutDevice(userId, jti)踢出指定设备
本章引用链接
在使用多设备管理功能的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:
架构设计相关
代码实现相关
测试验证相关
常见问题