☰
Java Web单一登录:Redis+Token覆盖会话实现踢人效果
2026/10/9 4:17:52 网站建设 项目流程

简介:面向 Java Web 开发者的账号单一登录解决方案 PDF,聚焦同一账号重复登录带来的安全隐患与数据紊乱问题,实现类似 QQ 登录的“踢人效果”。文档从需求分析入手,明确使用 Filter 过滤器作为核心实现手段,并结合 HttpSession 校验登录状态,后续登录用户可将前一登录会话踢出;同时登录请求通过 Ajax 提交,避免异步返回顺序干扰,提高交互稳定性。内容包含完整实现步骤、关键代码片段、优缺点对比,并指出可结合验证码、双因素认证等机制进一步增强系统安全性,适合需要为 Web 系统快速接入单一登录机制的中级开发者参考。资源为 1 个 PDF 文档,压缩包大小约 149KB,已有 3377 人学习下载,适合直接阅读或作为相关项目的设计参考。

1. 账号单一登录:把“踢人效果”做成接口校验而不是销毁 Session

先明确一个容易混淆的概念:这里说的“单一登录”,不是多个系统之间的单点登录(SSO),而是“同一个账号在同一个系统里只能有一个在线会话”。后台管理系统、课程平台、运营工具这类 Java Web 项目经常有这个需求,本质是“新登录顶掉旧登录”,也就是俗称的踢人效果。很多工程师第一反应是操作 Session,比如登录时记录一个标志位,登录前把旧 Session 销毁。这个思路在单机、小并发下能跑通,但在分布式的 web 工程里,Session 是一个黑匣子,你根本定位不到另一个设备上的 Session,更不用说执行失效操作。这篇文章按一条可落地的路径走:用全局存储记录“账号当前有效的令牌”,在拦截器统一校验,新令牌写入的一瞬间旧令牌就失效,被踢的一端在下一个请求收到明确的业务状态码并自动退出。整个过程会覆盖选型、代码、多节点并发边界、旧项目平滑改造,以及最后怎么验收效果。

2. 方案选型:为什么“Session 里存标志位”是黑匣子,Redis + Token 才可查

2.1 反模式:在 Session 里标记登录状态,为什么踢不掉别人

Java Web 里最早的登录实现一般是登录成功后往 Session 里记一个用户对象:

HttpSession session = request.getSession(); session.setAttribute("loginUser", user);

然后在过滤器里判断 loginUser 是否存在,存在就放行。这个套路在单用户、无并发要求时没问题,但一旦产品提出“同账号不能重复登录”,它立刻暴露短板。原因很简单:Session 不是全局对象。Tomcat 默认把 Session 放在本节点内存里,两条请求如果落在同一个容器内,你还能通过 SessionId 找到旧 Session;一旦项目部署成两台机器、前面挂 Nginx 做负载均衡,节点 A 上创建的 Session,节点 B 根本看不到。用户 B 在节点 B 上登录,代码里拿不到用户 A 在那个节点上的 Session 引用,只能设置 Session 超时时间让它自己过期,这显然不是“踢人”而是“等”。

就算你用 Spring Session 把 Session 持久化到 Redis,做所谓的 Session 共享,问题也在:该方案需要动态遍历所有 Session 才能找到某账号对应的那个并强制失效。Spring Session 提供了findByIndexNameAndIndexNameValue,但查询范围越广,性能越难看。另外 Session 是 Servlet 规范里完整的会话上下文,强行销毁它,可能把用户正在进行的文件上传、表单提交等请求一起打断。这里有个血泪经验:不要为了踢人而销毁 Session,除非你确认被踢用户绝对不在任何业务流程进行中。

所以,在线状态不该放在 Session 里管。Session 可以继续用来存业务上下文,但“当前账号的有效会话版本”必须放到一个能按账号维度直接查询的全局存储中,最常见的就是 Redis。

2.2 Redis 数据结构设计:一个 Key 存一个 Token,过期时间与登录有效期对齐

用 Redis 做单一登录,核心是一张映射表:账号 ID 对应当前有效令牌。每次登录,重新生成随机令牌并覆盖写入,旧令牌自然失效。设计上有几个细节先定下来。

第一,Key 的命名要带业务前缀,避免和其他缓存数据冲突。我习惯用login:token:<userId>,比如login:token:10086。Value 只存一个没有业务含义的随机字符串,例如 UUID 去掉横线。不要图省事把用户信息、角色列表全塞进 Value。拦截器每次请求都读一次,Value 越小序列化越快;用户信息应该由业务接口查询,不该塞进登录态。

第二,过期时间要和登录有效期对齐。如果要求 7 天内免登录,就设 7 天。这里有个副作用:有效期内用户一直没访问,令牌一直在;一旦过期,Redis 删除 Key,账号变为离线,下次访问被拦截器提示重新登录。很多团队会加“活跃续期”逻辑:每次请求有效时重置过期时间。续期要注意,它会让长期活跃账号的 Key 一直不过期,被安全审计盯上。

第三,不要设计成“多个令牌存在一个列表里”。有同事写过这种方案,用 RPUSH 追加 token,踢人时根据参数删除列表元素。功能能做,但代码复杂,还要处理并发下 remove 的原子性和列表拉长的问题。单值覆盖是业界最常见的做法,也最好排查:登录后打开 Redis,GET login:token:10086看到的就是当前应该有效的令牌,新旧一目了然。

2.3 不引入 Redis 行不行:本地内存 Map 的适用边界

如果项目还在单机开发阶段,没有 Redis 实例,可以先用 ConcurrentHashMap 实现同样效果,先跑通业务:

public class LoginTokenHolder { private static final ConcurrentHashMap<String, String> TOKEN_MAP = new ConcurrentHashMap<>(); public static void kickOtherDevices(String userId, String newToken) { TOKEN_MAP.put(userId, newToken); } public static boolean check(String userId, String token) { return token != null && token.equals(TOKEN_MAP.get(userId)); } }

这段代码在单机模式可用,但有几个明显边界:应用重启后内存清空,所有用户要重新登录;多实例部署时每台机器各持一份 Map,A 机器踢不掉 B 机器上的用户;如果后面接 Nginx 负载均衡,同一账号的两次请求可能落到不同节点,就会出现“刚登录完又提示未登录”的玄学问题。所以这个方案只适合拿来写 demo 和联调,不适合直接上生产。

用 Redis 没有这些问题,因为所有节点读写的是同一个 Key。如果团队暂时没有运维 Redis 的条件,可以把 Map 抽成一个接口,本地环境用内存实现,测试环境用 Redis 实现,后面迁分布式时不用改业务代码。下表给你做个选型参考:

方案能否跨节点踢人重启是否丢状态实现成本生产可用性
单节点 Session 标志否丢低低
Spring Session + Redis勉强能不丢中中
本地 ConcurrentHashMap否丢低低
Redis 单 Value 存 Token能不丢低高

3. 代码落地:从登录接口到拦截器,实现“新登录顶掉旧会话”

3.1 登录接口的改造:生成 Token 并覆盖写入 Redis

先改登录入口代码,以 Spring Boot + Spring Data Redis 为例。假设你已经用 Spring Boot 的RedisTemplate,登录逻辑从“设置 Session”改成“生成 Token 写 Redis”:

@PostMapping("/api/login") public Result doLogin(@RequestBody LoginRequest req) { // 这里是你的真实认证逻辑,比如查库比对密码或验证码 User user = userService.login(req.getUsername(), req.getPassword()); if (user == null) { return Result.fail("用户名或密码错误"); } // 生成全局唯一令牌,格式建议:userId + ":" + 随机串 String rawToken = UUID.randomUUID().toString().replace("-", ""); String token = user.getId() + ":" + rawToken; String redisKey = String.format("login:token:%d", user.getId()); // 核心操作:覆盖写入,旧 token 瞬间失效,同一账号只保留一个有效令牌 redisTemplate.opsForValue().set(redisKey, token, 8, TimeUnit.HOURS); return Result.ok(token); }

这段代码有两个关键点。第一,令牌里拼了 userId,拦截器拿到 token 就能解析出账号 ID,不需要再查一次数据库。第二,写入时直接覆盖,这是踢人的根基:用户 B 登录时,用户 A 的令牌在 Redis 里被替换。下次用户 A 拿旧令牌访问,拦截器读到的是 B 的令牌,自然判定不一致。

有一个常见误用是登录前先delete(redisKey)再set。这样会在两次操作之间留一个空窗,此时 A 的请求恰好经过拦截器,会读到 null 并提示“未登录”。直接覆盖则没有窗口期。Redis 单 Key 写入是原子操作,后写覆盖先写,天然满足“最后一次登录生效”的语义。

这里要特别提醒一个顺序问题:必须先把 Redis 写入成功,再向前端返回 token。有些项目里登录接口同时会生成两个令牌,一个用于登录态,一个用于“记住我”,如果返回先执行、Redis 写入在事务提交后延迟执行,前端拿到 token 立刻访问业务接口会被拦截器误杀。我把登录日志和 Redis 写入放在同一个事务里,或者至少保证写入返回成功后再输出响应。

3.2 拦截器校验 Token:不一致就是被踢了

下一步是写拦截器,对所有受保护接口统一校验。这里用 Spring 的HandlerInterceptor:

@Component public class LoginInterceptor implements HandlerInterceptor { private final RedisTemplate<String, Object> redisTemplate; public LoginInterceptor(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { return sendKicked(response, "未登录或登录已失效"); } String userId = token.split(":")[0]; String redisKey = String.format("login:token:%s", userId); Object current = redisTemplate.opsForValue().get(redisKey); if (current == null) { return sendKicked(response, "登录已过期,请重新登录"); } if (!current.toString().equals(token)) { // 当前 Redis 里的令牌与请求携带的不一致,说明这个账号已在别处登录 return sendKicked(response, "您的账号已在其他设备登录"); } // 校验通过,把用户 ID 放到请求上下文里 request.setAttribute("currentUserId", Long.valueOf(userId)); return true; } private boolean sendKicked(HttpServletResponse response, String message) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(200); response.getWriter().write("{\"code\":4010,\"msg\":\"" + message + "\"}"); return false; } }

这里有一个容易被坑的细节:sendKicked的 HTTP 状态码不要用 401,建议响应状态保持 200,把业务状态放进 JSON 的 code 字段。因为很多前端框架会对非 2xx 响应统一弹“网络异常”,你写的“账号已在其他设备登录”反而到不了用户眼前。我在项目里统一约定:code = 4010 表示被踢,前端 axios 拦截器里单独处理这个 code。

拦截器注册也很关键,只拦需要登录的接口:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebMvcConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor = loginInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/login", "/api/register", "/api/captcha", "/static/**" ); } }

重点是排除路径必须写全,登录接口、验证码接口、静态资源记得都排除掉,否则前端加载静态资源都会被踢回登录页,看起来像页面错乱。

3.3 前端拿到 4010 之后的被踢退出逻辑

后端负责识别,前端负责执行退出。以 Vue + axios 为例:

service.interceptors.response.use( (response) => { if (response.data.code === 4010) { localStorage.removeItem("token"); window.location.href = "/login?reason=kicked"; } return response.data; }, (error) => { return Promise.reject(error); } );

逻辑不复杂:清除本地保存的 token 和用户信息,跳回登录页。需要注意一点,如果你的项目把登录态放在 Cookie 里,后端要在响应里种一个清除 Cookie 的 Set-Cookie,否则前端跳走了,Cookie 还在,下次请求又会带旧 Cookie 触发拦截器,出现反复跳转。

被踢用户停留在某个页面不动,没有发起任何请求,他只能在下一次刷新或点击时被踢生效。所以,这个方案严格说是“下一个请求到来时踢人”,不是“即时踢人”。要做到即时效果,要么前端定期轮询一个轻量接口,要么用 WebSocket 推送下线事件,我把这两条路径放在最后一章展开。

4. 登录并发、注销与多节点避坑:先记住这四个边界

4.1 并发登录同一账号:只能覆盖,不能“先查后删”

现象:同一个账号被两个人同时登录,最后生效的登录者不是后点击的人,而是先登录的那位,功能表现和预期正好相反。

原因:登录接口写成了三步:先查旧 token,删掉旧 token,再写新 token。“查”和“删”不是原子操作,两个请求交错执行时,可能出现请求 1 删除、请求 2 删除、请求 2 写入、请求 1 写入的乱序,最后 Redis 里存的是请求 1 的令牌。

解决:不要显式删除,登录时直接set覆盖:

// 不写 delete,直接覆盖 redisTemplate.opsForValue().set(redisKey, newToken, 8, TimeUnit.HOURS);

Redis 单 Key 写入是原子的,写入时自动替换旧值,后写的一定覆盖先写的,天然保证“最后一次登录生效”。如果你的登录流程里确实需要先删除别的数据,就用 Lua 脚本把查、删、写包成一个原子操作,但一般业务用不上。

4.2 注销接口要校验 Token 归属,否则会误踢新登录用户

现象:用户 A 被踢后,拿着旧 Token 调了一次注销接口,结果把用户 B 的会话也注销了。司机 B 明明刚登录成功,访问业务接口却提示“登录已过期”。

原因:注销接口直接按账号 Key 执行redisTemplate.delete(redisKey),把 Redis 里当前存的有效令牌当作自己的令牌删了。

解决:注销前比对令牌是否一致,一致才删:

@PostMapping("/api/logout") public Result logout(HttpServletRequest request) { String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { return Result.fail("未登录"); } String userId = token.split(":")[0]; String redisKey = String.format("login:token:%s", userId); Object current = redisTemplate.opsForValue().get(redisKey); if (current != null && current.toString().equals(token)) { redisTemplate.delete(redisKey); } return Result.ok(); }

这样旧 Token 被带进来也不会影响到新登录用户的会话。实际开发中,注销接口往往在拦截器之后执行,可以直接使用拦截器设置的currentUserId,避免重复解析 Token。

4.3 登录接口返回 Token 前必须确认 Redis 已经写成功

现象:用户登录成功后,前端立即拿 Token 请求业务接口,却被拦截器判定“未登录”,刷新后才恢复。

原因:登录接口代码里先返回了 Token,Redis 写入逻辑在返回之后或者异步队列里执行,前端拿到 Token 时 Redis 里还没有值。

解决:写入 Redis 必须在 return 语句之前完成。注意,是“完成且确认成功”,不是“提交了命令”。Spring Data Redis 默认同步执行写入,只有当set返回后才会往下走。如果项目里把写入操作丢进线程池异步执行,必须改成同步。类似的问题也出现在分布式事务里:Redis 写入成功但事务回滚,会导致账号能登录但用户数据不完整,反过来也是隐患。

4.4 多节点部署时 Redis 超时与降级策略

现象:Redis 某个时间点抖动或重启,日志显示大量连接超时,紧接着所有已登录用户访问系统都被提示登录过期,连登录页都进不去。

原因:拦截器里redisTemplate.opsForValue().get()抛出异常后,异常被容器当作系统异常返回,用户看到 500 或空白页。

解决:拦截器里 catch 异常,并设定降级策略。这里没有标准答案,我一般是根据系统安全等级定:

Object current = null; try { current = redisTemplate.opsForValue().get(redisKey); } catch (Exception e) { log.error("Redis read fail, userId={}", userId, e); } if (current == null) { // Redis 不可用时,按你的策略决定是放行还是拦截 // 银行、支付系统通常选择拦截,宁可系统暂时不可用也要保证安全 // 一般管理系统通常选择放行并记录日志,避免全线雪崩 }

如果你选择放行,需要在业务 Service 层记下“降级期间未做登录校验”的日志,待 Redis 恢复后补做审计。这个策略要在架构评审时定下来,而不是故障发生那一刻临时拍脑袋。

5. 旧项目改造:传统 Session 工程怎么平滑升级到单一登录

5.1 不替换登录接口,在 Servlet 过滤器上做兼容过渡

很多 Java Web 工程还在用 Spring MVC + web.xml,甚至纯 Servlet。把这种项目整体改成 Token 拦截器,工作量不算大,但风险在“老用户正在使用”。如果直接要求所有请求必须带 Token,当前登录中的用户下一次刷新就会被踢下来,需要重新登录,体验很差。

我一般先写一个过滤器,放在请求处理最前面,做双通道兼容:

  • 新设备登录走 Token 模式;
  • 老 Session 里已有登录信息的,在过滤器里自动把登录态迁移成 Token,并种 Cookie,用户无感。
public class LoginCompatibilityFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 如果请求已经带了有效的 token,直接放行 String token = req.getHeader("Authorization"); if (token != null && checkToken(token)) { chain.doFilter(req, resp); return; } // 如果请求带了 JSESSIONID 且 Session 里有登录用户,就为该用户生成 token HttpSession session = req.getSession(false); Object loginUser = session == null ? null : session.getAttribute("loginUser"); if (loginUser != null) { String generatedToken = generateTokenForUser(loginUser); // 写入 Redis 并种 Cookie,后续请求就走 Token 校验 resp.addHeader("Set-Cookie", "AUTH_TOKEN=" + generatedToken + "; Path=/; HttpOnly"); req.setAttribute("compatNewToken", generatedToken); } chain.doFilter(req, resp); } }

这段代码的核心思路是:不主动销毁 Session,也不强制要求前端改造。老用户第一次带着 JSESSIONID 访问时,自动拿到一个 AUTH_TOKEN Cookie,后续请求就可以走 Token 校验了。注意Set-Cookie的 Path 必须设置成/,否则只在部分子路径生效。

过滤器要在 web.xml 里先注册,或者用@Component + @Ordered让 Spring Boot 识别。如果你用的是 Spring Boot 项目,尽量把它实现成OncePerRequestFilter,避免转发时被多次执行:

@Component public class LoginCompatibilityFilter extends OncePerRequestFilter { // 逻辑同上面 Filter doFilter }

5.2 改造 Session 共享项目时,Cookie 的 Path 与 Domain 要一起处理

如果旧项目已经用 Spring Session 做 Session 共享,Session 在 Redis 里是全局的,Token 校验也在 Redis,两者可以并存,但要处理两个细节。

第一,JSESSIONID 的 Cookie Path 要统一设为/,否则某些子路径请求不携带该 Cookie,过滤器里拿不到 Session,自动迁移逻辑失效。第二,Cookie 的 Domain 要一致,尤其是一个域名下挂多个子应用时,比如oa.company.com和mis.company.com,如果种 Cookie 的 Domain 只写了oa.company.com,另一个子域拿不到。

另外,不要在过滤器里调用session.invalidate()。Session 里可能还存着 CSRF Token、验证码、临时购物车等业务数据,贸然销毁会让当前请求后续步骤拿不到数据。正确顺序是:先生成并下发 Token,等整个请求处理完成,再根据业务需要决定是否清理 Session。

5.3 “记住我”功能怎么和单一登录共存

旧项目里常见“记住我”,本质是延长登录有效期。如果 Token 有效期固定成 8 小时,勾选“记住我”也没意义。我的处理方式是这样:普通登录 Token 有效期 8 小时,勾选“记住我”后有效期 30 天,Redis Key 不变,只是set时的过期时间参数不同。

Duration expire = rememberMe ? Duration.ofDays(30) : Duration.ofHours(8); redisTemplate.opsForValue().set(redisKey, token, expire.toMinutes(), TimeUnit.MINUTES);

提醒一下,30 天有效期意味着 Redis 里长期存在大量 Key,离职员工账号可能被长期挂着。建议对“记住我”的 Token 做活跃续期:每次请求通过校验时,查剩余有效期,小于 7 天就重置过期时间,并把“上次活跃时间”写入日志。这样既能保住体验,也方便安全审计。更简单粗暴的退路是:宁可让不活跃用户重新登录,也不要为了便利放任长期令牌。

6. 验证与进阶:用两个浏览器压一遍,再决定要不要上 WebSocket

验证套路不需要测试平台,一个 Chrome 一个 Edge 就够。先把应用启动,用无痕窗口分别模拟两台设备。

第一轮,验证基本踢人。无痕窗口 A 登录账号,复制请求头里的 Authorization 值;无痕窗口 B 用同一账号登录;回到窗口 A,点击任意需要登录的按钮,正常情况会看到 code 4010,并且前端自动跳回登录页。这轮是关键,别只看跳转,还要确认 Redis 里login:token:<userId>的值已经变成窗口 B 的 Token。

第二轮,验证注销不误踢。窗口 B 登录后,用窗口 A 的旧 Token 调用注销接口,然后窗口 B 继续访问业务接口,应当正常访问。这验证的是 4.2 节的 Token 归属校验。

第三轮,用 curl 模拟并发登录:

# 窗口1 登录用户 1001,拿到 token1 curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"u","password":"p"}' # 窗口2 再登录一次,拿到 token2 curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"u","password":"p"}' # 带着 token1 访问受保护接口,预期返回 code 4010 curl -X GET http://localhost:8080/api/user/info \ -H "Authorization: token1"

最后一个命令行输出的应该是 4010。如果 token1 还能访问,说明代码里多半没有覆盖写入,或者 Redis Key 被刻意追加了多个值。

进阶需求是即时踢人。当前的拦截器方案在被踢用户下一次请求时才生效,如果用户停留在页面不动,感知不到。对于后台管理系统,这个延迟通常可以接受;但如果是客服工作台或在线课堂,要求被挤掉的用户立刻弹窗下线,就需要加推送通道。常见做法是:登录接口把 Token 写入 Redis 后,通过 Redis 订阅或消息队列发布一个“下线事件”,用户保持长连接的 WebSocket 端点收到后主动关闭连接并跳转登录页。无论用哪种推送组件,核心是把“踢人状态变更”从“下一个请求查询”变成“事件即时推送”。

我的习惯是先把纯拦截器版本上线跑一周,观察线上是否出现“被踢后还能继续访问”的反馈,再决定要不要上 WebSocket。不少项目最后发现,用户根本不太在意被踢的即时性,真正在意的是再登录时新会话稳定有效。过度设计推送通道,反而给运维多养一个组件。这个取舍没有绝对正确,但按这个节奏走,能帮你省下很多无谓的加班量。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询