ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Web 单一登录踢人:Filter + 全局 Session 注册表实现

Java Web 单一登录踢人:Filter + 全局 Session 注册表实现 简介面向Java Web开发者的账号单一登录实现资料围绕同一账号仅允许一处在线、后登录者自动踢出前者的核心需求讲解如何通过Filter过滤器配合HttpSession完成用户状态校验与强制下线。内容从需求背景、实现思路到具体步骤层层展开覆盖登录页AJAX提交、服务器端会话检查及踢人逻辑并结合示例说明轮询读取Session方案的资源占用与异步返回顺序冲突问题同时给出优缺点分析和加入验证码、双因素认证等安全扩展方向。资源为单个PDF文档大小149KB篇幅紧凑包含登录页与主页面的示例代码片段、jQuery请求写法及运行验证要点适合为管理系统或内部平台增加单点登录约束的初中级Java工程师参考。已有3377人学习下载方案可直接迁移到实际项目中快速实现防重复登录的踢人效果。1. 账号单一登录的“踢人”需求Filter 方案比轮询 Session 更值得学做 Java web 账号登录模块单一登录几乎是必选项同一账号不允许同时在两台设备上保持登录态后登录的人要把先登录的人踢下线效果类似 QQ 的“踢人”。网上常见的实现手段是写个定时器每隔 500ms 去读后台 Session 做校验但这种轮询方案不仅在浪费资源还会在 AJAX 并发时出现返回值错乱——请求 1 还没返回请求 2 先到了前端拿到的是错配的数据。这份资源用 Filter 全局 Session 注册表来实现踢人逻辑干净适合直接跑通验证效果也适合想把这套机制移植到自己项目里的人。2. 核心实现全局 Session 注册表与踢人逻辑的完整拆解2.1 为什么用 static HashMap 存 Session先看最核心的一行声明它放在 LoginServlet 里public static MapString, HttpSession user_Session new HashMapString, HttpSession();在 Servlet 容器中Servlet 本身是单例static 字段在整个 JVM 里只有一份所以所有请求都能访问到这份注册表。key 存用户名value 存该用户当前登录产生的 HttpSession。这样第二次登录时就能通过用户名找到旧会话并把它作废。维度设计说明key用户名业务上的唯一标识valueHttpSession旧登录的完整会话对象生命周期随应用进程存在不随单个请求结束而清空注意 HashMap 不是线程安全的。单机 demo 里并发量低问题不大如果放到生产环境两个请求同时操作这个 Map可能出现 put 覆盖后又被 remove 的竞态。常见做法是换成 ConcurrentHashMap或者对 put 和 remove 操作加同步锁。另外为什么不建议用 sessionId 做 key因为 sessionId 每次登录都会变第二次登录时拿不到“同一个用户”的旧会话也就无法实现踢人。2.2 LoginServlet 登录流程先踢旧人再存新人完整的 LoginServlet 核心逻辑如下这里省略了 doGet、doDelete 等模板方法重点看 doPost 和两个私有方法SuppressWarnings(serial) WebServlet(urlPatterns{/LoginServlet}) public class LoginServlet extends HttpServlet { private PrintWriter out; private String user; private String method; private HttpSession session; public static MapString, HttpSession user_Session new HashMapString, HttpSession(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(text/html); req.setCharacterEncoding(utf-8); resp.setCharacterEncoding(utf-8); out resp.getWriter(); user req.getParameter(username); method req.getParameter(method); session req.getSession(); switch (method) { case login: mLogin(); break; default: break; } out.flush(); out.close(); } private void mLogin() { removeUser(user); session.setAttribute(name, user); user_Session.put(user, session); System.out.println(user_Session); out.println(1); } private void removeUser(String user) { if (user_Session.containsKey(user)) { user_Session.get(user).invalidate(); } } }doPost 先解析出用户名、方法名和当前请求的 session然后按 method 分发。登录走 mLogin第一行先调用 removeUser把旧 session 失效然后把新 session 的属性 name 设置为用户名再放进全局注册表。顺序很重要如果先 put 再 invalidate注册表里存的是旧 session旧 session 被 invalidate 之后新登录的 session 反而进不了 Map最终新会话也是失效状态。out.println(1) 返回给前端一个固定值 1前端拿它判断登录是否成功。这个值是可以自己定的只要前后端约定一致就行。2.3 踢人真正的执行点不在 Filter而在 invalidate很多人第一次看这套代码会以为 Filter 是踢人的地方其实是理解偏了。过滤器只负责“检查当前请求是否还有效”真正把旧登录干掉的是 removeUser 里的invalidate()。当用户 B 带着同一账号再次登录时mLogin 里 removeUser 会从注册表中找到用户 A 的 session 并立即作废。作废之后用户 A 的浏览器再发起 SubmitServlet 请求过滤器里request.getSession().getAttribute(name)拿到的是 null于是返回自定义状态码 900前端就认为登录失效了。这样设计的优势在于不需要任何后台轮询线程也不会出现“请求 1 还没返回请求 2 先返回”这种响应错位。旧会话只在它自己发起下一次请求时才被校验校验动作和业务请求是串行的天然避免了并发冲突。这个点理解透了后面很多问题都能自己排查。3. 前端 AJAX 与 Servlet 联动登录、提交、900 状态码处理3.1 两个 JSP 页面登录页与主页面的结构登录页 index.jsp 为了演示被刻意简化没有密码只有一个用户名输入框和登录按钮body input idusername nameusername typetext button idbtnlogin namebtnlogin登录/button script typetext/javascript srcjs/jquery-1.11.3.js/script script typetext/javascript srcjs/jsSubmit.js/script /body主页面 singlecount.jsp 更简单只放了一个提交按钮body 已登录. br button idbtnsubmit namesubmit提交/button script typetext/javascript srcjs/jquery-1.11.3.js/script script typetext/javascript srcjs/jsSubmit.js/script /body两个页面都引入 jquery-1.11.3.js 和 jsSubmit.js。演示项目里不做密码校验是为了把注意力集中在 session 管理和踢人机制上实际项目中替换成真实账号密码校验即可登录成功后同样用 AJAX 拿到确认值再跳转。3.2 jsSubmit.js登录、提交与被踢回调所有前端逻辑都集中在 jsSubmit.js 里AJAX 回调处理了三件事登录成功跳转、提交结果提示、被踢后返回登录页。$(document).ready(function() { // 登录按钮 $(#btnlogin).click(function() { $.ajax({ url: LoginServlet?methodlogin, type: post, data: {username: $(input[nameusername]).val()}, success: function(msg) { if (msg 1) { window.location.href singlecount.jsp; } }, error: function() { window.alert(错误); } }); }); // 提交按钮 $(#btnsubmit).click(function() { $.ajax({ url: SubmitServlet?methodsubmit, type: post, success: function(msg) { if (msg 1) { window.alert(提交总数 msg); } }, error: function(jqXHR) { if (jqXHR.status 900) { window.alert(登录状态失效请重新登录); window.location.href /OneLogin; } } }); }); });登录 AJAX 把用户名通过 data 参数提交给 LoginServlet返回 1 就跳到主页。提交 AJAX 访问 SubmitServlet成功后弹出当前提交总数。被踢的判断在 error 回调里jqXHR.status 等于 900 时提示登录失效并跳回项目根路径。这里有两个细节值得注意。第一dataType 没有设置成 json因为后端返回的是纯数字字符串按 text 处理最不容易出问题如果设置成 json反而可能因为返回类型不匹配走 error 分支。第二被踢后跳转的/OneLogin是项目部署时的 context path如果你的工程改了名字这个路径必须同步改否则会跳到一个不存在的地址。3.3 Filter 与 web.xml 的配合方式SessionFilter 的职责是拦截需要登录态保护的请求判断 session 中是否有 name 属性。核心代码如下WebFilter(/SessionFilter) public class SessionFilter implements Filter { Override public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) arg0; HttpServletResponse response (HttpServletResponse) arg1; String strURL request.getRequestURL().toString(); if (strURL.indexOf(SubmitServlet) ! -1 || strURL.indexOf(singlecount.jsp) ! -1) { if (request.getSession().getAttribute(name) null) { request.getSession().invalidate(); response.sendError(900, 登录失效请重新登录); return; } else { arg2.doFilter(arg0, arg1); } } else { arg2.doFilter(arg0, arg1); } } }过滤器判断请求路径里是否包含 SubmitServlet 或 singlecount.jsp包含则检查 session 属性。属性为 null 说明用户已经被踢或从未登录此时先 invalidate 当前 session再通过 sendError 返回 900。前端 AJAX 的 error 回调会捕获这个状态码从而弹出重新登录提示。web.xml 里面的配置决定了过滤器具体拦哪些 URLfilter filter-namefilter.SessionFilter/filter-name filter-classfilter.SessionFilter/filter-class /filter filter-mapping filter-namefilter.SessionFilter/filter-name url-pattern/singlecount.jsp/url-pattern /filter-mapping filter-mapping filter-namefilter.SessionFilter/filter-name url-pattern/SubmitServlet/url-pattern /filter-mapping这里有一个容易翻车的细节SessionFilter 类上写了WebFilter(/SessionFilter)但真正生效的是 web.xml 里两个 filter-mapping。注解里的路径/SessionFilter实际并不存在它不会拦截 SubmitServlet。如果部署环境只支持注解扫描这个过滤器会完全失效。后面避坑章我会专门说这个问题。4. 避坑排查AJAX 返回值错乱、过滤器边界与测试盲区4.1 现象AJAX 并发时请求 1 拿到请求 2 的返回值有博客实现的踢人方式是定期读后台 session比如每隔 500ms 检查一次。这种办法除了额外消耗资源还会踩到 AJAX 响应顺序的坑。请求 1 先提交但请求 2 先返回前端就收到了错配的数据。后台两个请求都执行成功了前端逻辑却是乱的。原因在于多线程并发下session 校验线程和业务线程是并行执行的返回顺序完全不可控。解决方式就是本资源用的这种同步踢人在登录请求里就完成旧 session 的 invalidate不要在后台开轮询。旧页面下一次发起请求时才被拦截整个过程没有额外线程参与也就不存在返回值串台的窗口。4.2 现象过滤器把静态资源也拦截页面 JS 加载不出来如果把 filter-mapping 的 url-pattern 写成/*jquery-1.11.3.js 和 jsSubmit.js 都会经过过滤器。第一次访问页面时 session 里还没有 name 属性所有静态资源都会被 sendError 900前端脚本根本加载不出来。解决方法是只对需要保护的后端接口和页面配置拦截。示例里只拦 singlecount.jsp 和 SubmitServlet非常精准。如果项目有多个需要保护的路径建议用前缀匹配比如/admin/*并且在过滤器里放行静态文件if (strURL.endsWith(.js) || strURL.endsWith(.css) || strURL.endsWith(.png)) { arg2.doFilter(arg0, arg1); return; }记住一条原则过滤器拦的是访问入口不是让页面变干净的工具。该放行的资源一定要放行。4.3 现象同一浏览器开两个标签页测不出踢人这是我在最早调试这套代码时踩过的坑。同一个浏览器里两个标签页共享同一个 JSESSIONID Cookie。第二个标签页登录时request.getSession() 拿到的还是第一个标签页的 session。removeUser 里 invalidate 把这个 session 作废然后第二次登录又基于这个已经失效的 session 继续操作最终两个页面同时失效根本不出现“新登录踢掉旧登录”的效果。解决办法很简单用两个不同浏览器测试Chrome 一个窗口、Firefox 一个窗口或者用浏览器的隐身模式。这样才能产生两个独立的 JSESSIONID模拟两台真实客户端。4.4 现象WebFilter(/SessionFilter)导致过滤器不生效原工程的 SessionFilter 类上写了WebFilter(/SessionFilter)web.xml 里又配置了 filter-mapping。注解里的路径是随意写的实际不存在这个 URL真正起作用的是 web.xml。如果换个只扫描注解的部署环境Filter 不会拦截 SubmitServlet登录状态检查就完全失效了。解决方法是二选一保留 web.xml 就把WebFilter注解删掉用注解扫描就把路径改成真实要拦的资源WebFilter(urlPatterns{/SubmitServlet, /singlecount.jsp})不要在一个工程里同时依赖两种配置方式很容易让人误判到底哪段配置生效排查问题时多花一晚上。4.5 现象被踢后旧页面跳转 404前端在被踢后跳转的是/OneLogin这是部署时的 context path。如果项目部署名称不叫 OneLogin比如打成 war 包后改叫demo那跳转就变成/demo才能访问原来的固定路径就会 404。解决方法是不要在前端写死路径。用 JSP 动态拼 basePath跳转时使用相对路径window.location.href %basePath%;或者在前端直接跳相对路径index.jsp让浏览器基于当前路径自动补全。做登录模块时凡是涉及页面跳转的字符串都值得检查一下是不是硬编码。5. 进阶技巧给踢人加友好提示并给多节点部署留好后路5.1 未登录直接访问主页面时的自动跳转原资源里用户被踢后只有点击提交按钮才会看到“登录状态失效”的提示。如果用户直接在地址栏输入http://localhost:8080/OneLogin/singlecount.jsp过滤器会返回 900 状态码浏览器显示的是错误页体验很生硬。常见的做法是在主页面加载时就做一次会话探活。在 singlecount.jsp 中加入一段初始化 AJAX请求 LoginServlet 新增的 check 分支$(document).ready(function() { $.ajax({ url: LoginServlet?methodcheck, type: post, success: function(msg) { if (msg ! 1) { window.location.href index.jsp; } }, error: function(jqXHR) { if (jqXHR.status 900) { window.location.href index.jsp; } } }); });对应在 LoginServlet 的 doPost 里增加一个分支case check: if (session.getAttribute(name) ! null) { out.println(1); } else { out.println(0); } break;这样无论是被踢的用户还是没登录就手动输入地址的用户打开主页都会在几百毫秒内被带回登录页不会看到一段冷冰冰的状态码。5.2 单机 HashMap 撑不了多节点注册表要换 Redisstatic HashMap 的注册表只在单个 JVM 里有意义。一旦项目部署在多台 Tomcat 后面加 Nginx 负载均衡用户第一次登录落在节点 A第二次登录落在节点 B两边各有一份 user_Session踢人逻辑就彻底失效了。常见做法是把注册表从 JVM 内存搬到 Redis用 userId 作为 keysessionId 作为 value。登录时写入过滤时判断// 登录时 jedis.set(login: userId, sessionId); jedis.expire(login: userId, 1800); // 过滤时 String current jedis.get(login: userId); if (current ! null !current.equals(session.getId())) { response.sendError(900, 登录失效请重新登录); return; }TTL 设置为 1800 秒和 Tomcat 默认 session 超时保持一致避免 Redis 里的记录永久堆积。这样即使两次请求落在不同节点也能通过 Redis 判断同一账号是否已经在别处登录。再往下做还可以用消息队列通知旧节点及时失效 session不过对小项目来说Redis 注册表这一层已经足够。从那以后我每次做登录模块都会先想清楚注册表的存储边界和过滤器的拦截范围再写前端回调。这套 Filter Session 注册表的写法虽然基础但把职责拆清楚之后后面加 Redis、加权限控制都顺理成章。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进