拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java Web登录模块如何用Session和Filter实现同账号互踢

简介一份面向Java Web开发者的单一登录实战文档主要解决同一账号重复登录带来的安全漏洞与数据紊乱问题核心思路是使用Filter过滤器配合HttpSession记录登录会话实现类似QQ的“后登录踢掉先登录”效果。文档按需求分析、实现思路、操作步骤、优缺点和扩展方向展开既说明该方案的简洁高效也指出增加服务器端会话校验会带来复杂度与负载同时特别提醒了前后端交互中AJAX返回值乱序的坑避免直接套用定时读Session方案导致请求结果错乱。包体为单份PDF共1个文件大小149KB内容集中便于快速阅读与对照实践。目前已有3377人浏览学习文档中包含过滤器设计、Session失效处理、登录页与AJAX提交示例等关键内容可直接迁移到实际项目中适合有一定Servlet/JSP基础、想提升登录模块安全性的开发者参考。1. 单一登录为什么要用“踢人”而不是“拒绝”做 Java web 登录模块时“同一账号多人同时在线”常常是第一个被忽视、上线后被吐槽最多的点。直接怼一个“该账号已在别处登录”的提示看起来简单但用户在网吧、办公室、手机电脑之间切换时会被这种提示卡到怀疑人生。更实用的做法是 QQ 那种“后登先踢”——后登录的请求正常通过前一个会话被强制失效前端下一次请求就会收到登录失效的信号并跳回登录页。这个项目用的就是典型的“踢人”方案登录时用一个 Map 记住“用户名 → HttpSession”的对应关系新登录进来时如果 Map 里已经有同一个 user 的旧 session直接调用invalidate()把它干掉。后面所有受保护资源的访问都走一个过滤器检查 session 里有没有用户标记没有就返回自定义的 900 状态码前端 ajax 拿到这个状态码后弹提示并跳转登录页。整体链路不复杂但把 session 生命周期、Filter 拦截时机、ajax 错误回调这几个点串起来了适合正在做用户体系、想搞清楚“同账号互踢”到底怎么落的开发者。2. 登录时用 Map用户名, HttpSession 实现互踢的核心机制2.1 为什么不能只靠 session 里存标记最简单的“防重复登录”写法是登录时session.setAttribute(name, user)然后每个请求都去 session 里查这个标记。但这只能判断“当前会话有没有登录”根本拦不住两个不同浏览器各自创建自己的 session 同时登录同一个账号。因为 session 是每个客户端独立的服务端默认不感知“这两个 session 属于同一个人”。要让服务端感知就必须有一个全局的注册表账号名作为 key当前有效的 session 作为 value。这样当第二个登录请求进来时发现 key 已经存在就能定位到旧 session 并销毁它。这个 Map 就是整个踢人方案的核心数据结构。2.2 LoginServlet 里如何登记和踢出旧会话直接看登录的核心代码注意removeUser(user)方法的调用时机// LoginServlet.java package servlet; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.io.PrintWriter; import java.util.HashMap; import java.util.Map; WebServlet(urlPatterns {/LoginServlet}) public class LoginServlet extends HttpServlet { private PrintWriter out; private String user; private String method; private HttpSession session; // 全局静态Map用户名 - 该用户名当前有效的HttpSession 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写入用户名标记 session.setAttribute(name, user); // 将“用户名 - 新session”登记进全局Map user_Session.put(user, session); System.out.println(user_Session); out.println(1); } /** * 判断是否有重复用户 * 若出现重复用户踢掉前面登录的用户即删除其session */ private void removeUser(String user) { if (user_Session.containsKey(user)) { user_Session.get(user).invalidate(); } } }逻辑说明user_Session是static的意味着它属于整个应用所有客户端共享这一份数据这是“全局登记”的关键。removeUser先查再删如果 Map 里已有该用户名对应的旧 session直接调用invalidate()让它失效。这个调用会让 Tomcat 立刻销毁该 session之后旧客户端带着那个 session id 来访问时服务端找不到对应 sessiongetSession(false)返回 null过滤器就能立刻识别出“这是一个已失效的会话”。session.setAttribute(name, user)是给当前登录的“新 session”打标记后面过滤器检查的就是这个属性。最后把新 session 覆盖写入 Map保证 Map 里永远只保留“最新一次登录”的 session。这里有一个容易踩的坑removeUser调用invalidate()后旧 session 对应的HttpSession对象虽然还在user_Session的 value 里但已经不能用了。因为随后user_Session.put(user, session)会覆盖掉同一个 key 的旧 value所以旧对象很快会被 GC 回收不会造成内存泄漏。但如果某个会话是超时自动失效的Map 里对应的 entry 会一直留着直到同一个用户名再次登录时才会被覆盖。这个问题在单机演示时无所谓生产环境建议配合HttpSessionListener的sessionDestroyed回调同步清理 Map后面第 5 章会展开讲。2.3 登录模式的两个细节为什么返回 1 而不是 JSON登录成功后out.println(1)前端 ajax 里判断msg 1就跳转singlecount.jsp。这里没有用 JSON 格式是因为业务足够简单一个数字就能表达“登录成功”。如果用 JSON 字符串前端还得JSON.parse反而多一步出错的可能。不过要注意这个 Servlet 设置了resp.setContentType(text/html)实际上返回的是纯文本 “1”不是 JSON。如果你要扩展成同时返回用户名、角色等更多信息再考虑改成 JSON 格式并调整前端解析逻辑。登录请求本身没有走过滤器拦截因为登录页是公开资源。项目里过滤器只拦截了SubmitServlet和singlecount.jsp这样设计是合理的登录接口如果也被拦了那用户永远进不来。3. 过滤器拦截的边界哪些请求要拦哪些要放行3.1 Filter 的核心职责过滤器在这个项目里的职责只有一个判断请求所携带的 session 里有没有登录标记。注意它并不负责“踢人”踢人的动作已经在登录时做完了。过滤器的任务是在每次受保护请求进来时检查当前 session 是否还有效、是否带上了用户标记。如果 session 已经因为重复登录被invalidate()掉了那这次请求就会被直接拦截返回 900 状态码。// SessionFilter.java package filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebFilter(/SessionFilter) public class SessionFilter implements Filter { Override public void destroy() { } 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(); // 只过滤来自SubmitServlet请求和singlecount.jsp的加载 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); } } Override public void init(FilterConfig arg0) throws ServletException { } }逻辑说明通过request.getRequestURL()拿到完整 URL 字符串用indexOf判断是否包含特定关键字。这是一个比较取巧的做法好处是配置简单坏处是 URL 里只要包含SubmitServlet的路径都会命中可能存在误拦实际生产里更推荐在 web.xml 里精确配置 url-pattern。request.getSession().getAttribute(name)的调用要特别小心默认getSession()不带参数时如果当前请求没有有效 session会创建一个新的空 session然后返回 null 的属性值白白创建一个 session 对象。更严谨的写法是先request.getSession(false)没有会话直接判定为未登录。当判定为未登录时response.sendError(900, 登录失效请重新登录)会往响应里写一个自定义状态码 900 和一段文本。为什么不用 401、403因为 401 是 HTTP 标准状态码浏览器的处理逻辑可能是弹原生对话框或者直接被浏览器拦截用 900 这种非标准状态码浏览器不会做任何特殊处理响应会正常传给 ajax 的error回调这正是我们想要的行为。3.2 web.xml 中过滤器映射的配置要点项目里用了注解WebFilter和 web.xml 两种方式声明过滤器实际上WebFilter(/SessionFilter)这一行并没有真正起拦截作用真正生效的是 web.xml 里的配置filter 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参数说明filter-class必须是过滤器的完整类名这里是filter.SessionFilter对应src/main/java/filter/SessionFilter.java。两个url-pattern分别把singlecount.jsp和SubmitServlet这两个受保护资源纳入拦截范围。注意这里没有拦截LoginServlet因为登录接口必须放行。如果项目里有多套过滤器filter-mapping的声明顺序决定了过滤器的执行顺序先声明的先执行。3.3 tml 文件修改后是否走过滤器需要注意singlecount.jsp这个页面本身是一个静态 JSP过滤器会对它的每次直接访问做拦截。也就是说用户手动在地址栏输入http://localhost:8080/OneLogin/singlecount.jsp时请求会先经过过滤器如果 session 里没有name属性直接被sendError(900)拦掉JSP 内容根本不会被渲染。这就实现了“未登录直接访问受保护页面进不去”的效果。但有一个边界情况登录成功后前端用window.location.href singlecount.jsp跳转此时携带的是同一个浏览器里的 session cookiesession 里已经有了name属性过滤器会放行。所以整个流程是通的。4. 前端 ajax 如何配合 900 状态码做被踢提示4.1 ajax 默认行为非 2xx 状态码都进 error 回调jQuery 的$.ajax有一个默认行为只要 HTTP 状态码不是 2xx 范围例如 900就会触发error回调而不是success。项目中jsSubmit.js里提交按钮的 ajax 就是利用这一点来处理“被踢”场景的// jsSubmit.js $(document).ready(function() { // 提交按钮 $(#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; } } }); }); });逻辑说明error: function(jqXHR)里的jqXHR.status就是响应状态码。sendError(900)发的 900 会被 jQuery 解释为错误进入这里。拿到 900 后弹窗提示并跳转到项目根路径/OneLogin根路径对应的是index.jsp登录页。这个方案的好处是被踢的客户端在无感知的状态下下一次点击任何受保护的按钮时立刻被弹回登录页。不需要轮询不需要定时任务请求驱动检测资源占用为零。4.2 提问一个容易被忽略的前端问题原项目里登录按钮的 ajax 只有success和error没有complete。如果你要在被踢后自动跳转登录页把跳转逻辑写在error回调的if (jqXHR.status 900)分支里是对的但不能把它写在success回调里。因为服务端踢人后旧会话再发请求是进不了success的它必然走error。如果有一天你发现“被踢了但前端没有反应”优先检查是不是error回调里缺少了window.location.href跳转或者浏览器缓存了旧的响应导致没有走到 900 分支。4.3 单机模拟多客户端测试的操作步骤测试踢人效果需要两个独立的浏览器会话Chrome 和 Firefox 之间天然隔离 cookie最方便启动 Tomcat访问http://localhost:8080/OneLogin/在 Chrome 里输入用户名testuser登录进入singlecount.jsp。打开 Firefox 或 Chrome 的无痕窗口输入同一个 URL用同一个用户名testuser登录。此时user_Session里testuser对应的 session 已经被 Firefox 的新 session 覆盖Chrome 里那个 session 已经被invalidate()。回到 Chrome 的singlecount.jsp页面点击“提交”按钮。观察 Network 面板请求SubmitServlet的状态码是 900页面弹出“登录状态失效请重新登录”随后跳转到登录页。这一步能同时验证两个点Filter 的拦截逻辑是否正确、前端 900 状态码的处理是否生效。5. 单机方案的边界与生产环境的改造思路这个项目放在单台 Tomcat 里跑完全没问题但如果你打算把这个逻辑搬到生产环境有几个边界必须心里有数。5.1 静态 HashMap 的内存与并发问题LoginServlet.user_Session是一个static HashMap在多线程环境下两个请求同时登录同一个账号时put和invalidate交叉执行可能造成数据不一致。比如请求 A 登录 user1请求 B 登录 user1两个都先执行containsKey都返回 true然后都去invalidate()对方的 session最后 Map 里可能同时存在两个失效的 session或者 key 被覆盖成后写的那一个但先写的那个 session 没有被 invalidate。解决方式很简单把HashMap换成ConcurrentHashMappublic static MapString, HttpSession user_Session new ConcurrentHashMapString, HttpSession();ConcurrentHashMap内部用分段锁JDK 8 后改为 CAS synchronized 锁桶保证单个 bucket 的并发安全put和get都不会抛ConcurrentModificationException。但对“先检查再操作”的复合操作containsKey然后invalidate仍建议用synchronized或compute方法包一层。更简单粗暴的做法是invalidate操作本身是幂等的调用两次无非是多抛一次IllegalStateException在removeUser里用 try-catch 包住即可。5.2 session 超时导致 Map 数据残留Tomcat 默认 session 超时是 30 分钟。用户登录后一直不操作session 会被容器自动销毁但user_Session里的 entry 还在指向一个已经失效的HttpSession对象。这时候如果同账号又登录一次removeUser会调用invalidate()对已经失效的 session 再调invalidate()会抛IllegalStateException: Cannot call sendError() after the response has been committed之类的异常。稳妥的方案是加一个 HttpSessionListenerpackage listener; import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import servlet.LoginServlet; WebListener public class SessionListener implements HttpSessionListener { Override public void sessionDestroyed(HttpSessionEvent se) { // session失效时从Map中移除对应的entry String username (String) se.getSession().getAttribute(name); if (username ! null) { LoginServlet.user_Session.remove(username); } } }注意这里有个逻辑如果旧 session 被踢失效sessionDestroyed触发此时新 session 还没put进去Map 里还是旧 session 的 entry移除它是安全的。但如果是新登录流程先removeUser踢旧、再put新中间几乎没有时间窗口不至于误删新 session。实际做的时候也可以不在mLogin里调用removeUser而是只依赖 listener 在sessionDestroyed时清理。5.3 多实例部署时 Map 需要换成 RedisHashMap存在每个 Tomcat 进程的内存里两台服务器各自维护一份user_Session就会出现机器 A 登录了 user1机器 B 登录同一个 user1 时A 的 Map 根本不知道这件事两边都能在线。真正的生产环境要么用 Redis 存“用户最近一次登录生成的 token”要么用 Redis 的SETNX加过期时间做互斥。以 Redis 方案为例SET login:user1 token EX 1800每次请求都带上 token服务端在过滤器中比对 Redis 里的值不一致就判定为“被踢”。这种做法的好处是天然支持多实例坏处是每次请求多一次 Redis 读操作对性能有一定影响。实际实现时可以用管道或 Lua 脚本把“校验 token 并续期”合到一次网络往返里。5.4 如果要做到“旧会话立即失效”而不是“下次请求才感知”当前方案的踢人效果是“被动感知”——旧客户端要等下一次请求才会发现 session 失效。这个延迟在实际使用中几乎无感因为用户不会凭空盯着页面总要有点击操作。但如果你的业务有 WebSocket 长连接或 SSE 推送旧连接不会自动断开需要额外维护一个“用户 → 连接”的映射在被踢时主动 close 连接。这个属于长连接场景的定制不在 Filter 的能力范围内。最后补一个对生产环境很实用的小技巧把 900 这个魔法数字定义成一个常量放在一个公共类里前端和后端共用一份文档约定避免前端写死、后端改值后两边不一致。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门