单设备登录:同一账号只能一处在线
单设备登录本质是 会话互斥:新登录踢掉旧会话,或旧 token 全部失效。我用 JWT + Redis 的方案讲,这是业务系统里最常见、面试也好展开的写法。
题目
如何实现单设备登录?让账号只能在一个设备登录,不能同时在线。
参考答案(口述要点)
- 登录成功生成 token,Redis 记录
userId → 当前有效 tokenId(或 token 本身)- 新设备登录覆盖 Redis 记录,旧 token 校验失败 → 返回 401,提示「账号已在其他设备登录」
- 每次请求在过滤器里比对:请求带的 token 是否等于 Redis 里存的「当前唯一会话」
- 登出删 Redis key;可选 WebSocket 推送让旧端主动跳登录页
- Refresh Token 也要绑在同一会话版本号上,防止旧 refresh 续期
详细解析
为什么不能只靠 JWT 无状态
纯 JWT 下发后无法主动作废,除非:
- 等过期(单设备互斥等不了)
- 维护 黑名单(等于又要存储)
所以常见做法是:JWT 仍携带身份,Redis 存「当前唯一会话标识」,二者结合。
Redis 键设计
session:user:{userId} → String: 当前有效的 tokenId(或 jti)
可选:session:meta:{userId} → Hash: deviceType, loginTime, ip
TTL:与 refresh token 最长有效期一致,或登录续期登录流程
public LoginResponse login(LoginRequest req) {
User user = authService.verify(req.getUsername(), req.getPassword());
String tokenId = UUID.randomUUID().toString();
String accessToken = jwtService.createAccessToken(user.getId(), tokenId);
String refreshToken = jwtService.createRefreshToken(user.getId(), tokenId);
// 覆盖 = 踢掉旧设备
redis.opsForValue().set(
"session:user:" + user.getId(),
tokenId,
Duration.ofDays(7)
);
return new LoginResponse(accessToken, refreshToken);
}请求校验过滤器
@Component
public class SingleSessionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String token = resolveBearer(request);
if (token == null) {
chain.doFilter(request, response);
return;
}
Claims claims = jwtService.parse(token);
Long userId = claims.get("uid", Long.class);
String tokenId = claims.get("jti", String.class);
String current = redis.opsForValue().get("session:user:" + userId);
if (current == null || !current.equals(tokenId)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("{\"code\":\"KICKED\",\"message\":\"账号已在其他设备登录\"}");
return;
}
chain.doFilter(request, response);
}
}放在 Spring Security 链里,在 UsernamePasswordAuthenticationFilter 之后即可。
Refresh Token 也要校验同一 tokenId
public TokenPair refresh(String refreshToken) {
Claims claims = jwtService.parse(refreshToken);
Long userId = claims.get("uid", Long.class);
String tokenId = claims.get("jti", String.class);
String current = redis.opsForValue().get("session:user:" + userId);
if (!tokenId.equals(current)) {
throw new BizException("会话已失效,请重新登录");
}
// 签发新 accessToken,tokenId 可不变或轮换
return jwtService.issueNewAccess(userId, tokenId);
}否则旧设备可以用 refresh 一直续命,单设备登录就破了。
代码与实践
和 Spring Security 集成
- 登录接口
permitAll - 业务接口
authenticated() - 自定义
JwtAuthenticationFilter+ 上面的tokenId比对 - 踢下线时前端根据
code=KICKED清 localStorage 跳/login
可选:主动通知旧设备
- 旧连接若开着 WebSocket,登录覆盖 Redis 后向旧
sessionId推FORCE_LOGOUT - 纯 HTTP 场景靠下一次请求 401 即可,实现更简单
多端策略扩展(连问时常考)
| 策略 | Redis 结构 |
|---|---|
| 严格单设备 | session:user:{id} 单个 tokenId |
| 单端类互斥(如只能一个 App + 一个 Web) | session:user:{id}:app、session:user:{id}:web 各一个 |
| 设备数上限 N | session:user:{id} 用 ZSET,按时间踢最旧 |
面试说清产品要 「全端互斥」还是「同类互斥」,别默认只有一种。
安全补充
- 登录接口加验证码 / 限流,防撞库
- token 放 Header,别放 URL
- HTTPS 必须;敏感操作可再加二次验证
延伸 / 易错点
- 只删客户端 token 不更新 Redis:新登录必须 覆盖 Redis,不是 append。
- JWT 过长过期时间不配合 Redis TTL:Redis key 过期后所有人都要重登,TTL 要和 refresh 策略对齐。
- 分布式:多实例共享同一 Redis 即可,别用本地 Map 存会话。
- 和「记住我」冲突:产品若允许多端,就不要做单设备;需求先对齐。
写在最后
单设备登录一句话:Redis 里每个用户只认一个 tokenId,新登录覆盖,旧 token 自然失效。把登录、鉴权、刷新三条链路都带上 tokenId 校验,面试官一般不会再追问「旧 token 怎么办」。
创作不易,转载请注明出处和作者。
