简介:多设备登录的难点不在签发多份令牌,而在对登录会话的精细化管理。本文系统拆解会话建模、令牌与会话绑定、单设备下线、同端互踢、登录数量限制及全端强制下线的实现思路,配以关键代码与存储结构设计,助你构建可控、可运营、可审计的多端登录体系。
产品需求的原话往往是:用户可以同时在手机和电脑上保持在线,但后台必须能看到当前登录的设备列表;如果发现陌生设备,可以单独把它踢下线;管理员还要支持“一键退出该账号的所有设备”。
很多开发者的第一反应是:已经有 JWT 了,登录后每台设备各发一份 Token 不就完了?真正动手之后,问题会迅速串成一条链:
做完这些你会得出一个关键结论:多设备登录真正需要管理的不是 JWT,而是会话(Session)。JWT 只是某个会话签发出去的访问凭证。如果服务端没有独立的会话实体,你连“踢掉某一台设备”这个操作的对象都找不到。
最初的登录实现通常长这样:
String accessToken = jwtService.createToken(user);
前端保存 Token,之后每个请求带上 Authorization: Bearer xxx,服务端只解析出 JWT 里的 userId 来识别用户。此时同一个账号在两台电脑的浏览器、一部手机 App、一台平板 App 上登录,服务端看到的全部信息就是 userId = 10086——设备维度完全不可见。
这带来三个无法回避的问题:
因此,改造的第一步是:给每一次登录建立一个真正的会话实体。
用户认证通过后,不再只是生成 JWT,而是先创建一个登录会话:
public record LoginSession(String sessionId, // 会话唯一标识,每次登录生成新的Long userId,DeviceType deviceType, // 设备大类:APP / WEB / PC_CLIENTString deviceId, // 设备指纹String deviceName, // 展示名,如"我的手机"String ip,String userAgent,SessionStatus status, // ACTIVE / KICKED / LOGOUT / EXPIREDInstant loginAt,Instant lastActiveAt) { }
逐字段说明设计意图:
配套枚举:
public enum DeviceType { APP, WEB, PC_CLIENT }public enum SessionStatus { ACTIVE, KICKED, LOGOUT, EXPIRED }
会话数据必须放在集中式存储(通常是分布式缓存、内存键值数据库)而不是应用本地内存,原因有二:多实例部署下任意节点都要能读到同一份会话状态;“强制下线”必须跨实例即时生效。
推荐两组键:
session:{sessionId} → 会话详情(Hash:userId、deviceType、status、loginAt、lastActiveAt...)user:sessions:{userId} → 该用户全部 sessionId 的集合(Set)
第一把键支持“通过会话查详情、校验状态”;第二把键支持“通过用户反查全部在线设备”,设备列表、单踢、全端下线都靠它。
两个容易踩坑的细节:
完整登录分四步:认证 → 会话策略裁决 → 写入会话 → 签发令牌。先抽象仓储接口,屏蔽底层存储差异:
public interface SessionRepository {void save(LoginSession session);LoginSession find(String sessionId);List<LoginSession> findActiveByUser(Long userId);void updateStatus(String sessionId, SessionStatus status);void batchUpdateStatus(Collection<String> ids, SessionStatus status);void touch(String sessionId); // 更新 lastActiveAt}
核心登录逻辑:
public LoginResult login(AuthRequest req, DeviceInfo device) {User user = authService.verify(req); // 1. 认证// 2. 同端互踢:同一大类的设备只保留最新会话if (policy.kickSameType()) {sessionRepo.findActiveByUser(user.getId()).stream().filter(s -> s.deviceType() == device.type()).forEach(s -> sessionRepo.updateStatus(s.sessionId(), KICKED));}// 3. 数量限制:超出上限则淘汰最久未活跃的会话List<LoginSession> actives = sessionRepo.findActiveByUser(user.getId());int limit = policy.maxSessionsFor(user);if (actives.size() >= limit) {actives.stream().sorted(Comparator.comparing(LoginSession::lastActiveAt)).limit(actives.size() - limit + 1).forEach(s -> sessionRepo.updateStatus(s.sessionId(), KICKED));}// 4. 创建会话并签发令牌String sessionId = secureRandomId();sessionRepo.save(new LoginSession(sessionId, user.getId(),device.type(), device.fingerprint(), device.name(),req.ip(), req.userAgent(), ACTIVE, Instant.now(), Instant.now()));TokenPair tokens = tokenService.issue(user.getId(), sessionId);return new LoginResult(tokens, sessionId);}
策略层要做成可配置,常见组合包括:
签发令牌时,payload 里除了 userId,必须携带 sessionId:
{"userId": 10086,"sessionId": "9f3c8a2e...","exp": 1735689600}
这样 JWT 就从“用户的凭证”升级为“某个会话的凭证”,后续所有校验都多了一步“回源查会话状态”。
这是整个方案最关键的一步。JWT 是无状态的,签发后无法主动撤回,怎么办?答案是在验签之后追加一次会话状态校验:
public boolean preHandle(HttpServletRequest request, Object handler) {String token = extractBearer(request);Claims claims = jwtService.parse(token); // 验签 + 验过期String sessionId = claims.get("sessionId", String.class);LoginSession session = sessionRepo.find(sessionId);if (session == null || session.status() != SessionStatus.ACTIVE) {throw new UnauthorizedException("登录状态已失效,请重新登录");}sessionRepo.touchAsync(sessionId); // 节流更新活跃时间return true;}
为什么不用黑名单或“用户级版本号”?
再配合短 TTL 的 Access Token(5~15 分钟)+ Refresh Token 换发机制,即使极端情况下校验被绕过,泄露令牌的影响窗口也很有限。
“退出当前设备”与“踢掉陌生设备”在实现上完全同构——都是把某个 sessionId 的状态置为非 ACTIVE,只是触发者与目标不同:
public void kickSession(String sessionId) {LoginSession session = sessionRepo.find(sessionId);if (session == null) return; // 幂等:不存在直接视为成功sessionRepo.updateStatus(sessionId, SessionStatus.KICKED);notifyService.pushOffline(session); // 可选:通知设备"已被下线"}
注意状态语义的区分:主动退出记 LOGOUT,被踢记 KICKED,自然过期记 EXPIRED。这不是咬文嚼字——客服排查“我什么都没做就被登出了”的投诉时,这个字段就是第一手证据。
有了 user 这把反查索引,全端下线就是集合遍历 + 批量改状态:
{userId}
public void forceLogoutAll(Long userId) {List<LoginSession> actives = sessionRepo.findActiveByUser(userId);sessionRepo.batchUpdateStatus(actives.stream().map(LoginSession::sessionId).toList(),SessionStatus.KICKED);}
几个实践要点:
这是最容易被忽略的边界问题。Refresh Token 必须与会话绑定:换发新 Access Token 时,同样先校验会话状态,再比对请求方上报的设备指纹与会在中记录的 deviceId 是否一致。
设备指纹的采集建议:客户端在首次安装/首次访问时生成随机 ID 持久化在本地安全存储中,结合平台可用的稳定标识(移动端设备标识、Web 端持久化 Cookie 等)综合生成,而不是仅依赖 User-Agent 这类容易伪造的字段。
回顾整个方案,多设备登录的本质是会话生命周期管理,可以抽象为三层能力:
先建会话,再签令牌,校验回源,策略裁决——记住这十六个字,多设备登录的骨架就立起来了。剩下的,无非是在这个骨架上叠加通知、审计与风控的细节工程。