简介:面向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 Map<String, HttpSession> user_Session = new HashMap<String, HttpSession>();在 Servlet 容器中,Servlet 本身是单例,static 字段在整个 JVM 里只有一份,所以所有请求都能访问到这份注册表。key 存用户名,value 存该用户当前登录产生的 HttpSession。这样第二次登录时,就能通过用户名找到旧会话并把它作废。
| 维度 | 设计说明 |
|---|---|
| key | 用户名,业务上的唯一标识 |
| value | HttpSession,旧登录的完整会话对象 |
| 生命周期 | 随应用进程存在,不随单个请求结束而清空 |
注意 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 Map<String, HttpSession> user_Session = new HashMap<String, 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 id="username" name="username" type="text"> <button id="btnlogin" name="btnlogin">登录</button> <script type="text/javascript" src="js/jquery-1.11.3.js"></script> <script type="text/javascript" src="js/jsSubmit.js"></script> </body>主页面 singlecount.jsp 更简单,只放了一个提交按钮:
<body> 已登录. <br> <button id="btnsubmit" name="submit">提交</button> <script type="text/javascript" src="js/jquery-1.11.3.js"></script> <script type="text/javascript" src="js/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?method=login', type: 'post', data: {username: $("input[name='username']").val()}, success: function(msg) { if (msg == 1) { window.location.href = "singlecount.jsp"; } }, error: function() { window.alert("错误!"); } }); }); // 提交按钮 $("#btnsubmit").click(function() { $.ajax({ url: 'SubmitServlet?method=submit', 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 里面的配置决定了过滤器具体拦哪些 URL:
<filter> <filter-name>filter.SessionFilter</filter-name> <filter-class>filter.SessionFilter</filter-class> </filter> <filter-mapping> <filter-name>filter.SessionFilter</filter-name> <url-pattern>/singlecount.jsp</url-pattern> </filter-mapping> <filter-mapping> <filter-name>filter.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?method=check', 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 撑不了多节点,注册表要换 Redis
static HashMap 的注册表只在单个 JVM 里有意义。一旦项目部署在多台 Tomcat 后面加 Nginx 负载均衡,用户第一次登录落在节点 A,第二次登录落在节点 B,两边各有一份 user_Session,踢人逻辑就彻底失效了。
常见做法是把注册表从 JVM 内存搬到 Redis,用 userId 作为 key,sessionId 作为 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、加权限控制都顺理成章。希望帮到你。
本文还有配套的精品资源,点击获取