首页 / 资讯中心 / 文章详情

Session Hook实战:从会话生命周期管理到在线用户强制下线

Session Hook实战:从会话生命周期管理到在线用户强制下线 ★ FEATURED ARTICLE
做权限类项目的时候我接了一个听着很简单的需求后台要能看到当前在线用户列表要能查某个账号是不是同时在好几个终端登录最后还要有一键踢人的按钮。当时我的第一反应是把登录用户ID往Redis一存退出或者超时的时候删掉这不就完了吗真做起来才发现问题全卡在“退出或者超时的时候”这几个字上——你根本不知道用户的Session是几点创建的、几点销毁的、登录态是几点写进去的。要感知这些节点就得在Servlet容器和Spring Boot之间搭一根“钩子”这就是Session Hook技术要解决的事。这篇文章我把做Session Hook的完整思路、核心代码、选型逻辑和踩坑记录都拆开写一遍。适合正在做登录鉴权、在线用户统计、重复登录控制、会话审计这类功能的后端同学参考。老实说这技术本身不复杂但它把“会话生命周期”这个平时完全黑盒的部分变成了可编程的事件流很多运维和审计需求都依赖它。1. Session Hook到底钩的是什么1.1 先澄清一个容易混淆的理解Session Hook并不是Spring Boot里的某个独立注解或者专用类而是一组基于Servlet规范和Spring事件机制的组合玩法。它的目标是同一件事在Session创建、销毁、属性变更、ID变化这些生命周期节点上插入我们自己的逻辑。说白了Session本身就是一个存放在服务端的KV容器但谁什么时候往里放了值、什么时候整个容器被清空业务代码如果不主动去记录是完全无感知的。Hook就是用来补上这个感知能力的。一个很直观的类比是快递驿站里的门口感应器。Session就是那个货架你往里放包裹存数据往外取包裹取数据驿站关门Session销毁。感应器不会关心你放的包裹是什么但它能在你进门和出门的时候响一声甚至能统计今天多少人进出。Session Hook就是那个感应器它不掺和业务数据但能在关键节点上通知你。Servlet规范为此提供了三个核心接口监听Session整个生命周期的HttpSessionListener、监听Session中属性增删改的HttpSessionAttributeListener、以及监听Session ID变化的HttpSessionIdListener。Spring Boot里注册它们的方式是WebListener加上ServletComponentScan或者用ServletListenerRegistrationBean手动注册。这就是最底层的Hook任何上层业务都是在这个基础上叠加的。1.2 没有Hook时这些功能为什么做不出来做一个后台登录功能只需要再简单不过的代码登录校验通过后把用户ID塞进session然后在需要的地方session.getAttribute(userId)取出来。但一旦涉及以下需求这套写法就完全失灵了。在线用户统计。你想知道现在有多少用户在线这不能遍历所有session——Tomcat的StandardManager根本不提供业务层面的遍历接口。就算你用JMX或者反射硬拿在多实例部署下也是错的。Hook可以解决因为sessionCreated和sessionDestroyed这两个节点就是在线人数的增减点在这里登记和注销即可。登录日志审计。公司要求每次登录都要记录IP、浏览器信息、登录时间退出还要记录在线时长。你可以在登录接口里写日志但“在线时长”你根本算不出来因为你不知道session真正销毁的时刻。只有sessionDestroyed回调能告诉你这个session什么时候结束的。重复登录强制下线。同一账号最多只能有一个会话新登录顶掉旧会话。这个逻辑看似简单但要做到“顶掉旧会话”且不是仅靠Redis覆盖标记就需要能定位到旧的HttpSession对象并把它标记失效。SessionRegistry就是为此设计的。这些需求不约而同地指向同一个结论只靠请求处理链路里的代码是远远不够的必须把逻辑挂在Session生命周期的事件上。这是Session Hook存在的根本原因。1.3 一条主线从容器事件到业务感知Spring Boot应用里Servlet容器Tomcat内嵌管理着HttpSession的真实生命周期。监听器收到的是容器事件比如HttpSessionEvent。但容器事件是孤立的它只知道session创建了、销毁了并不知道这个session属于哪个用户、对应哪条业务记录。要把容器事件翻译成业务事件就需要一个桥接过程。桥接分两步。第一步在监听器里从session的属性或外部存储读取当前登录用户信息然后发布一个Spring的ApplicationEvent。第二步在项目里写EventListener方法消费这个事件做业务处理。这样做的好处是业务模块完全不依赖Servlet API测试也方便。我在项目里就是按这条主线做的监听器只负责采集原始信号真正的业务逻辑全部放在事件监听方法里。2. 四种方案选型别一上来就写代码2.1 四种Hook的定位对比实现Session Hook这件事至少有四种主流方案。它们不是替代关系而是层级关系选哪一个完全看你要钩什么。方案钩取位置侵入性典型场景Listener监听器Session创建、销毁、属性增删改低不碰代码逻辑在线统计、登录审计、状态同步Filter过滤器每次请求进入时可包装Session对象中需要覆写Request包装类全链路日志、权限统一校验Interceptor拦截器Handler执行前后可拿到方法信息中与业务入口强相关登录态校验、接口级Session检查AOP切面任意标注了注解或匹配规则的方法较高需要切点设计精细化控制比如SessionHook注解Listener的优势在于它被容器保证触发时机准确比如session真正销毁时一定会回调。劣势是它拿不到完整的请求上下文sessionCreated里能取到HttpServletRequest但sessionDestroyed里拿不到当前请求。Filter恰好相反每次请求都会经过但正因为每次都经过它更适合做“每次请求都检查一次”的逻辑而不是做“某个时间点只发生一次”的逻辑。Interceptor是Spring MVC层面的适合做业务拦截但Session的销毁事件它永远感知不到。AOP适合做方法级别的钩子它能在你调用某个方法的前后附加逻辑但无法钩住容器内部行为。2.2 我的选型经验我的实践结论是做全局生命周期类功能比如在线统计、会话超时清理、登录审计首选Listener做请求级别的增强比如给Session操作加日志、统一校验选Filter做业务接口级别的控制比如UNAUTHORIZED跳转选Interceptor如果团队喜欢注解风格想用一行SessionHook标注某个方法需要自动注入当前用户和会话信息那就上AOP。一个比较容易犯的错误是什么都往Interceptor里放。因为Interceptor能在请求进来时拿到session做校验很多同学就自然地把在线用户统计也放在Interceptor里。但这样有一个致命问题用户session超时或被服务器清理时不会有任何请求经过Interceptor你永远等不到“清理离线用户”这个动作。所以凡是涉及“用户已经不在线”的事件必须依赖Listener不能依赖请求链路。3. HttpSessionListener实战在线用户统计、登录日志与强制下线3.1 创建与销毁两个钩子的基础实现先来一个最直接的例子统计在线用户。在Spring Boot中注册Listener很灵活我用WebListener加Component双注解Spring Boot会自动识别并注册。import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import org.springframework.stereotype.Component; Component WebListener public class SessionLifecycleListener implements HttpSessionListener { Override public void sessionCreated(HttpSessionEvent se) { HttpSession session se.getSession(); // 设置会话超时时间单位秒这里配的30分钟 session.setMaxInactiveInterval(30 * 60); System.out.println([sessionHook] 新会话创建: session.getId()); } Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session se.getSession(); System.out.println([sessionHook] 会话销毁: session.getId()); // 这里你getAttribute(LOGIN_USER_ID)大概率拿不到值 // 原因后面单独说 } }这里有个知识点sessionCreated回调里的session如果要保证不同应用的超时策略一致最好在监听器里统一调用setMaxInactiveInterval而不是依赖web.xml或yml配置。这样做的好处是如果有人改了配置文件导致超时时间漂移监听器这里还有个底线兜住。sessionDestroyed是垃圾回收和主动登出都会触发的它是清理在线用户记录的唯一可靠时机。3.2 用AttributeListener补上“谁登录了”的关键拼图在线统计如果只统计“有多少活跃session”根本不算在线用户统计顶多叫活跃会话统计。真正的需求是按用户维度统计这就要知道每个session对应哪个用户。答案很自然用户登录成功后登录接口里执行session.setAttribute(LOGIN_USER_ID, userId)然后我们在Session的属性监听器里感知这个写入动作把它登记为一次正式登录。import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSessionAttributeListener; import javax.servlet.http.HttpSessionBindingEvent; import org.springframework.stereotype.Component; Component WebListener public class LoginAttributeListener implements HttpSessionAttributeListener { public static final String LOGIN_USER_KEY LOGIN_USER_ID; Override public void attributeAdded(HttpSessionBindingEvent event) { if (LOGIN_USER_KEY.equals(event.getName())) { String sessionId event.getSession().getId(); Object userId event.getValue(); // 在这里登记当前sessionId对应的在线用户是userId System.out.println([sessionHook] 用户登录: sessionId sessionId , userId userId); } } Override public void attributeRemoved(HttpSessionBindingEvent event) { if (LOGIN_USER_KEY.equals(event.getName())) { System.out.println([sessionHook] 用户登出: sessionId event.getSession().getId()); } } Override public void attributeReplaced(HttpSessionBindingEvent event) { if (LOGIN_USER_KEY.equals(event.getName())) { Object oldValue event.getValue(); Object newValue event.getSession().getAttribute(LOGIN_USER_KEY); System.out.println([sessionHook] 用户会话被重新绑定: old oldValue , new newValue); } } }attributeReplaced有一个非常值得注意的细节HttpSessionBindingEvent.getValue()返回的是旧值。为什么因为事件是在属性替换完成之前生成的所以它携带的是旧值。如果你在业务里想拿到新值只能再调一次getAttribute(LOGIN_USER_KEY)。这个细节我一开始没注意结果在“用户被踢下线后重新登录”的场景里拿到的userId始终是旧设备的用户ID排查了半天。3.3 强制下线功能是怎么挂上钩子的踢人下线本质上是让目标session失效。最直接的手段是调用HttpSession.invalidate()但你要从后台管理端拿到目标session的对象。在单体、单节点部署下可以借助SessionRegistry来定位session并标记过期。Spring Security里有一个现成的SessionRegistry实现先注册一个HttpSessionEventPublisher把容器的session事件转发给Spring容器。import org.springframework.boot.web.servlet.ServletListenerRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.web.session.HttpSessionEventPublisher; Configuration public class SessionRegistryConfig { Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } Bean public ServletListenerRegistrationBeanHttpSessionEventPublisher httpSessionEventPublisher() { return new ServletListenerRegistrationBean(new HttpSessionEventPublisher()); } }有了SessionRegistry踢人逻辑就很简单了import java.util.List; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; Service public class SessionKickService { private final SessionRegistry sessionRegistry; public SessionKickService(SessionRegistry sessionRegistry) { this.sessionRegistry sessionRegistry; } public void kickUser(String username) { ListSessionInformation sessions sessionRegistry.getAllSessions(username, false); for (SessionInformation info : sessions) { info.expireNow(); // 会触发session后续请求被拒绝同时容器的session被标记失效 } } }这里有个前提你的项目必须通过Spring Security做登录因为SessionRegistry的会话注册依赖Security的会话管理机制。如果你的权限系统是自己写Filter实现的那建议走另一条路在Redis里维护一个“已踢下线用户ID”的黑名单然后在统一的Session校验Filter里发现用户ID在黑名单就直接拒绝并销毁session。两种方案没有对错取决于你的登录体系是否基于Spring Security。4. 用Filter和Wrapper给Session做一次“全景日志”4.1 为什么需要Filter级别的钩子Listener能感知生命周期但它感知不到“某次请求访问了session里的哪个数据”。有一次我排查两个会话之间数据串了的问题非常希望有一个日志能记录“哪个request调用了getAttribute、setAttribute”。这种情况下Listener帮不了太多就需要一个Filter级别的钩子在请求到达时用一个包装类把原始的HttpServletRequest包起来然后覆写getSession(boolean)方法返回值也是包装过的Session对象这样所有对Session的调用都经过我们的代码。先说一个注意点Filter本身不会自动生效需要在Spring Boot配置里注册或者直接在Filter类上加Component再设置Order。import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; Component Order(1) public class SessionTraceFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { HttpServletRequestWrapper wrapper new HttpServletRequestWrapper(request) { Override public HttpSession getSession(boolean create) { HttpSession session super.getSession(create); if (session ! null) { String currentUser (String) session.getAttribute(LOGIN_USER_ID); System.out.println([sessionHook] 请求访问Session, URI request.getRequestURI() , user currentUser , sessionId session.getId()); } return session; } Override public HttpSession getSession() { return this.getSession(true); } }; filterChain.doFilter(wrapper, response); } }4.2 覆写getSession的精髓与注意事项上面的代码里getSession()和getSession(boolean)都覆写了这是为了拦截所有获取session的入口。如果只覆写getSession(boolean)有部分代码直接调用不带参数的getSession()时就会绕过我们的日志。这个包装器模式还有一个很实用的衍生用法强制会话统一刷新过期时间、在写Session前附加操作、以及阻止某些接口误用session。但要注意getSession(false)在会话不存在时必须返回null千万不要在create参数为false时也去强制创建一个session否则会造成大量空session产生。我见过线上被这个bug打爆过OOM频繁发生就是因为包装类里没判断create参数导致每个请求都创建了新session。另一个坑是这个Filter适合做日志不适合做业务判断。因为getSession()可能被同一个请求调用多次你的打印也会跟着出现多次如果还要在里面做在线状态变更就会重复执行。所以Filter级别的Hook我建议定位在“观测”而不是“修改”。5. Spring Security场景下的Session Hook5.1 并发登录控制的钩子配置Spring Security天然把Session管理做成了安全策略的一部分。你可以让同一用户最多只允许一个有效会话或者只允许指定数量这是通过SessionManagement来配置的。import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.core.session.SessionRegistry; Configuration EnableWebSecurity public class SecurityConfig { private final SessionRegistry sessionRegistry; public SecurityConfig(SessionRegistry sessionRegistry) { this.sessionRegistry sessionRegistry; } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement(session - session .maximumSessions(1) .maxSessionsPreventsLogin(false) .expiredSessionStrategy(event - { System.out.println([sessionHook] 会话过期通知客户端重新登录); }) .sessionRegistry(sessionRegistry) ); return http.build(); } }这里的maximumSessions(1)表示同一账号最多在线一个会话。maxSessionsPreventsLogin(false)表示当新登录发生时旧的会话会被踢下线而不是阻止新登录如果设为true则用户在新设备登录时会收到“该账号已在别处登录”的提示。这个选择没有标准答案大多数产品选false因为用户换设备时希望新设备能立刻登录。5.2 登录后换SessionId防固定会话攻击固定会话攻击是一个安全风险攻击者先获取一个有效SessionId然后诱导用户用它登录登录成功后攻击者依然可以通过这个SessionId冒充用户。解决方案是在登录成功后更换SessionId让攻击者握着的旧ID失效。Spring Security默认就开启了会话固定保护但如果你在自定义登录接口里手动session.setAttribute可能会绕过安全策略。一个稳妥的写法是在登录成功处理器里主动更换SessionId。import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; public class SessionFixationSuccessHandler implements AuthenticationSuccessHandler { Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { // 强制更换SessionId防止会话固定攻击 request.changeSessionId(); // 后续再写入用户信息到session request.getSession().setAttribute(LOGIN_USER_ID, authentication.getName()); response.sendRedirect(/); } }5.3 用SessionRegistry实现踢人下线上文已经展示了SessionRegistry的用法。实际项目里踢人下线还要考虑一个问题SessionRegistry保存的会话信息是注册在内存里的expireNow()只是把该session标记为过期如果用户当前正在请求请求结束前session可能仍然可用但下一次请求会被Security拦截掉。如果需要立即彻底销毁还要拿到原始的HttpSession对象并调用invalidate。一个更偏业务的实现是把“被踢状态”同步到Redis然后在下一次请求进来时由Filter先判断该账号是否处于被踢状态如果是就直接重定向到登录页。这种方案与Spring Security解耦适用范围更广。6. 分布式会话下Hook要在Spring Session Redis里换一套打法6.1 单机Listener到集群为什么会失效单体应用部署时所有Session都存活在同一个Tomcat容器里Listener注册在哪个容器就监听哪个容器的Session一切正常。但一旦部署多副本比如两个实例用户的Session可能落在实例A上而第二次请求被负载均衡转发到了实例B实例B发现本地没有这个Session就会重新创建一个。如果不做Session共享用户就掉线了。解决Session共享的主流方案是Spring Session加Redis。所有Session数据集中存到Redis任意实例都能从Redis读取同一个Session。但问题来了Redis里Session的创建和过期并不会自动触发我们之前在Tomcat里注册的HttpSessionListener回调。因为Session数据已经不属于某个具体容器的内存了容器无法感知它的创建和销毁。此时要Hook“Session创建/销毁”事件就得走Spring Session自己提供的事件机制。6.2 用Spring事件监听Session生命周期Spring Session为Redis实现了几个漂亮的事件对象SessionCreatedEvent、SessionDeletedEvent、SessionExpiredEvent。要在Spring Boot中感知它们直接写事件监听方法即可。import org.springframework.context.event.EventListener; import org.springframework.session.events.SessionCreatedEvent; import org.springframework.session.events.SessionDeletedEvent; import org.springframework.session.events.SessionExpiredEvent; import org.springframework.stereotype.Component; Component public class RedisSessionEventListener { EventListener public void handleSessionCreated(SessionCreatedEvent event) { System.out.println([sessionHook] Redis会话创建: event.getSessionId()); } EventListener public void handleSessionDeleted(SessionDeletedEvent event) { System.out.println([sessionHook] Redis会话删除: event.getSessionId()); } EventListener public void handleSessionExpired(SessionExpiredEvent event) { System.out.println([sessionHook] Redis会话过期: event.getSessionId()); } }这套机制的原理是Spring Session在往Redis写入或删除Session数据时会同步发布对应的ApplicationEvent。所以只要是你的应用主动调用了sessionRepository.save()或deleteById()事件一定会发。但“过期”事件比较特殊它不是应用主动触发的而是Redis通过Key过期通知发送给应用的。6.3 Redis过期通知这个坑部署前一定要确认Spring Session Redis要正常感知Session过期必须在Redis服务端配置过期事件通知。具体的配置项是notify-keyspace-events至少要包含Ex。如果你的Redis是云厂商提供的通常可以在控制台改参数如果是自建Redis在redis.conf里改notify-keyspace-events Ex改完重启Redis或者执行CONFIG SET notify-keyspace-events Ex。如果没有这个配置SessionExpiredEvent就永远不会触发。我记得第一次在测试环境模拟session超时发现事件一直不来最后查了很久才发现是这台自建Redis根本没开过期通知。另一个要提前预期的情况是Redis的Key过期事件不是精确实时的它是惰性策略key过期后只有被再次访问或到了后台周期性扫描才会真正清理并发送通知所以事件可能有一丁点延迟。还有一个与业务相关的坑一旦Session数据放到了Redis不管它是不是被成功消费只要Redis里的Key过期事件就会发出来。如果你的SessionExpiredEvent处理逻辑里依赖业务数据表就要防止重复处理和幂等问题。我在项目里就遇到过重试机制把用户登出日志重复写了两遍。7. 避坑手册Session Hook实战常见问题速查现象原因解决方案监听器完全不生效没扫描到listener组件检查类上是否有Component或WebListener确保启动类上有ServletComponentScan事件触发了两次同一个Listener被注册了两遍排查是不是同时加了WebListener和手动ServletListenerRegistrationBean二选一sessionDestroyed里getAttribute拿不到值销毁回调阶段属性已被清理提前在AttributeListener里记录userId或在销毁前主动保存集群下Listener只在一台机器生效Session分布式后容器事件缺失改用Spring Session的事件监听SessionExpiredEvent一直不触发Redis未开启过期通知设置notify-keyspace-events为Ex登录后旧session还能用未正确调用changeSessionId登录成功处理器中调用changeSessionId踢人下线后用户还是能访问接口只提交了expireNow当前请求未结束在下一次请求拦截里再校验session状态并重定向这里再补两个独家经验。第一所有Listener的处理逻辑都要做成幂等的。Session销毁事件和Session过期的业务处理可能会被多次调用一定要用Redis记录处理状态或者保证操作的可重入性。第二Session Hook的代码不要太重。它就是挂一个旁路监控如果Handler里要做网络请求或者复杂计算会拖慢甚至阻塞容器线程建议把事件发布到消息队列异步处理。我在实际项目里最终沉淀了一套相对完整的流程用AttributeListener标记登录和登出用SessionLifecycleListener配合Redis记录在线状态用Spring Session事件处理分布式下的会话过期用SessionRegistry做并发控制和踢人。这套组合下来在线用户模块的准确率很高运维那边的反馈也比较满意。最后再分享一个小技巧。调试Session Hook的时候千万别依赖日志系统异步输出先用同步的System.out.println配合打印sessionId确认回调顺序。我之前遇到过日志组件本身做了异步批处理导致事件回调顺序和日志输出顺序对不上排错排了很久。Hook技术最忌讳的就是“回调时机不明确”先保证回调触发顺序是对的再优化日志策略。
阅读完成 · 觉得有帮助?
咨询建站