1. 需求背景:让指定用户下线,难点到底在哪
先讲一个我真实接手的场景:运营后台要做风控,运营同事看到某个账号在刷接口,要求在后台点一个按钮,把这个用户立刻踢下线,禁止继续访问。需求听起来很简单,不就是把用户会话失效吗?但真正动手时你会发现,问题比想象中复杂得多。
第一层问题是,你怎么找到这个用户的会话?Servlet 容器里的HttpSession成千上万,你不可能遍历所有会话再比对用户名。第二层问题是,你找到会话后,怎么「安全地」让它失效?直接调session.invalidate()当然可以,但如果这个会话不在当前节点、或者后端用的是无状态 JWT,这条路直接走不通。第三层问题更隐蔽:就算你调用了 Spring Security 的SessionRegistry把会话标记为过期,如果过滤器链里没有对应的环节,用户下一次请求照样畅通无阻。
所以「Spring Security 踢出指定用户」本质不是一个 API,而是一整套链路设计:会话注册、过期检测、过滤器拦截、分布式同步、甚至无状态 token 的撤销策略。这篇文章我会把从单机版到分布式、再到 JWT / OAuth 2.1 场景的完整做法都拆开讲,每个方案都给出可复现代码和我踩过的坑,适合正在做后台管理、风控系统、以及接手老项目需要加「强制下线」功能的朋友参考。
1.1 管理员界面上的一个按钮,背后是什么
在很多管理系统中,「踢下线」最后呈现的形态就是一个POST /admin/users/{username}/kick接口。但这个按钮背后至少涉及四个环节:
- 找到这个用户名对应的所有活跃会话;
- 把这些会话标记为「已过期」或者直接销毁;
- 确保下一次请求时,安全过滤器链能识别并拦截;
- 在高并发、多实例环境下,保证踢人指令能到达目标会话所在节点。
任何一个环节缺失,都会出现「后台显示操作成功,用户却还在正常请求」的诡异现象。这也是这篇文章要重点解决的问题。
1.2 为什么不能简单通过 HttpSession.invalidate 解决
很多新手会写这样的代码:
HttpSession session = request.getSession(); session.invalidate();这只是让「当前请求自己的会话」失效。运营后台的管理员请求携带的 session 是被踢用户吗?显然不是。如果要按用户名踢,就必须有一个「从用户反查会话」的注册表。Spring Security 提供了SessionRegistry,但它的注册时机、过期机制和过滤器配合关系,文档里写得很隐晦,这也正是单机版看似简单、实则到处是坑的原因。
2. 先把 Spring Security 的“在线状态”模型吃透:SessionRegistry 与过滤器链路
2.1 用户“在线”在服务端到底意味着什么
在传统的服务端会话模型下,用户登录成功之后,服务端会在内存或 Redis 里创建一个HttpSession,并把SecurityContext(里面包含 Authentication)塞进 session。之后每次请求,Spring Security 的过滤器都会从 session 里把SecurityContext取出来,放到SecurityContextHolder里,这样后续的授权判断就知道「当前你是谁」。
注意,这里的关键点是:「在线」不是一个布尔值,而是一段 Web 会话的存活状态。同一用户可能同时在多个浏览器、多个设备上登录,也就是一个用户名对应多个 session。所以按用户名踢人时,理论上要把这些 session 全部处理掉,不能只踢一个。
2.2 SessionRegistry、SessionInformation 与注册时机
SessionRegistryImpl是 Spring Security 提供的默认会话注册表,它的核心数据结构是内存 Map:一个是从sessionId到SessionInformation的映射,一个是从 principal(登录主体)到 sessionId 集合的映射。
SessionInformation保存三样东西:
- principal,也就是认证后的主要对象;
- sessionId;
- 最后请求时间
lastRequest; - 过期标记
expired。
记住一个关键点:单纯定义一个SessionRegistryBean 并不会让登录会话自动注册。让会话注册到SessionRegistry的,是ConcurrentSessionControlAuthenticationStrategy,而这个策略只有在调用sessionManagement().maximumSessions(...)时才会被装进SessionAuthenticationStrategy组合里。换句话说,你没配置最大并发会话数,SessionRegistry基本就是个空壳。
2.3 ConcurrentSessionFilter:真正按下“过期开关”的过滤器
Spring Security 过滤器链里有一个很容易被忽略的过滤器:ConcurrentSessionFilter。它的职责很单纯:每次请求进来时,根据当前 sessionId 去SessionRegistry查SessionInformation,如果发现这个会话已经被expireNow()标记为过期,就执行登出逻辑并跳转到 expiredUrl。
这个过滤器是手动踢人的最后一道关卡。很多人的实现是只调了sessionInformation.expireNow(),但因为没有启用并发控制,过滤器链里根本没有ConcurrentSessionFilter,过期标记永远不会被检查,用户自然也不会被踢掉。
所以,无论你最多允许几个并发会话,只要想用SessionRegistry手动踢人,配置里至少要有一次maximumSessions()的调用,哪怕你写maximumSessions(1000),目的是让框架把整套「注册 + 过期检查」链路拉起来。
3. 本地单机版实战:按用户名踢出用户的完整代码
3.1 第一步:注册 HttpSessionEventPublisher 并暴露 SessionRegistry
HttpSessionEventPublisher是一个 Servlet 监听器,它会把 session 创建、销毁事件转发给 Spring 容器。这样当某一个HttpSession超时或被invalidate后,SessionRegistryImpl能及时清理对应的SessionInformation,避免内存里残留一堆脏数据。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } @Bean public static ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() { return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher()); } }注意HttpSessionEventPublisher的import是org.springframework.security.web.session.HttpSessionEventPublisher,别导错。
3.2 第二步:在 SecurityFilterChain 中启用并发控制
Spring Security 6 和 Boot 3 的写法是 lambda 风格,老式的.and()链式写法在新版本里已经被移除了。
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .permitAll() ) .sessionManagement(session -> session .maximumSessions(100) .sessionRegistry(sessionRegistry) .expiredUrl("/login?expired") ); return http.build(); }这里maximumSessions(100)不是真的要限制 100 个并发,而是通过这个配置让框架生成ConcurrentSessionControlAuthenticationStrategy和ConcurrentSessionFilter,手动踢人才有完整的检查链路。如果你确实只想限制同一用户最多一个会话,把 100 改成 1 即可,但要注意这会导致「用户新设备登录后,旧设备被自动顶下线」,不一定符合业务预期。
3.3 第三步:编写按用户名踢人的核心 Service
核心逻辑分两步走:先遍历SessionRegistry里所有 principal,找到用户名匹配的用户;再拿到该用户的所有活跃会话,逐个标记过期并主动销毁底层HttpSession。
@Service public class UserSessionKickService { private final SessionRegistry sessionRegistry; public UserSessionKickService(SessionRegistry sessionRegistry) { this.sessionRegistry = sessionRegistry; } public int kickByUsername(String username) { int kickedCount = 0; for (Object principal : sessionRegistry.getAllPrincipals()) { if (!principalMatches(principal, username)) { continue; } List<SessionInformation> sessions = sessionRegistry.getAllSessions(principal, false); for (SessionInformation information : sessions) { try { information.expireNow(); Object sessionObject = information.getSession(); if (sessionObject instanceof HttpSession httpSession) { httpSession.invalidate(); } kickedCount++; } catch (IllegalStateException ignored) { // 会话已经被销毁,这里直接跳过,不干扰其他会话的处理 } } } return kickedCount; } private boolean principalMatches(Object principal, String username) { if (principal instanceof UserDetails userDetails) { return userDetails.getUsername().equals(username); } if (principal instanceof Authentication authentication) { return authentication.getName().equals(username); } if (principal instanceof String name) { return name.equals(username); } return principal.toString().equals(username); } }为什么expireNow()之后还要调invalidate()?因为expireNow()只是把SessionInformation标记为过期,并不会立即销毁底层HttpSession。如果用户刚好在标记之后的下一次请求到来,ConcurrentSessionFilter会拦截并让他跳转;但如果你希望更干脆一点,直接销毁底层 session,用户当前持有的 cookie 就失效了。实践里两个一起做,效果最明显。
3.4 第四步:通过管理端接口触发并保护权限
写一个管理接口,供运营后台调用。这个接口必须放在/admin/**之下,配合前面 SecurityConfig 里的hasRole("ADMIN")做权限保护,避免普通用户自己把自己或者别人踢掉。
@RestController @RequestMapping("/admin") public class AdminUserKickController { private final UserSessionKickService kickService; public AdminUserKickController(UserSessionKickService kickService) { this.kickService = kickService; } @PostMapping("/users/{username}/kick") public ResponseEntity<Void> kick(@PathVariable String username) { int kickedCount = kickService.kickByUsername(username); if (kickedCount > 0) { return ResponseEntity.ok().build(); } return ResponseEntity.noContent().build(); } }测一下效果:用被踢账号登录,拿到 session;再用管理员账号调用踢人接口;回到被踢账号的浏览器刷新任意受保护页面,会被重定向到/login?expired。
单机版到这里已经能跑通了,但如果你直接把它部署到生产,大概率会遇到一个更头痛的问题:多实例环境下,用户会话落在另一台机器上。
4. 分布式部署:一台机器踢不掉另一台机器的会话怎么办
4.1 问题的本质:SessionRegistry 是进程内缓存
默认的SessionRegistryImpl是用 JVM 内存保存 sessionId 和 principal 映射的。假设你有三台后端节点,用户的 session 创建在节点 A,管理员踢人的请求被负载均衡转发到了节点 B。节点 B 的SessionRegistry里根本没有这个用户的会话记录,你调kickByUsername返回 0,用户依然在节点 A 上稳稳地访问。
解决办法有三个方向,我按推荐程度给你分析。
4.2 方案 A:Spring Session + Redis 索引直接查库删会话
如果你的项目已经用了 Spring Session,并且 store 类型是 Redis,那么会话本身已经集中存储在 Redis 里了。这时可以利用RedisIndexedSessionRepository的findByPrincipalName方法,按用户名找到所有 session,然后deleteById直接删除。
@Service public class RedisSessionKickService { private final RedisIndexedSessionRepository sessionRepository; public RedisSessionKickService(RedisIndexedSessionRepository sessionRepository) { this.sessionRepository = sessionRepository; } public void kickByUsername(String username) { Map<String, ? extends Session> sessions = sessionRepository.findByPrincipalName(username); sessions.keySet().forEach(sessionRepository::deleteById); } }使用这个方案有两个前置条件:
- 配置
spring.session.store-type=redis,并引入spring-session-data-redis依赖; - 确保 Spring Security 把
SecurityContext写入 session 后,能被索引到 principal。默认情况下,RedisIndexedSessionRepository会基于 session 里的SecurityContext中的 principal name 建索引,所以只要登录后能正常findByPrincipalName(username),基本就能查到。
它的问题也很明显:findByPrincipalName的索引是异步更新的,极端情况下可能有延迟;而且如果你没有在 resource server 场景使用 session,这个类根本不存在。
4.3 方案 B:Redis 在线标记 + 请求过滤器(推荐)
与其费劲地去维护 Spring Session 的索引,不如自己用 Redis 保存一份「在线状态表」。登录成功时写入一个标记,例如online:user:{username} = sessionId,设置过期时间等于 session 超时时间;踢人时直接删除这个标记,或者写一个黑名单标记。
然后加一个自定义过滤器,在 Spring Security 把SecurityContext加载到SecurityContextHolder之后,检查当前登录用户是否还在线。如果不在线,立即清空上下文并返回 401。
public class UserStatusFilter extends OncePerRequestFilter { private final StringRedisTemplate redisTemplate; public UserStatusFilter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)) { String username = authentication.getName(); String onlineKey = "online:user:" + username; if (Boolean.FALSE.equals(redisTemplate.hasKey(onlineKey))) { SecurityContextHolder.clearContext(); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User has been kicked out"); return; } } filterChain.doFilter(request, response); } }注册这个过滤器时要注意位置,放在SecurityContextHolderFilter之后,AuthorizationFilter之前:
http.addFilterAfter(userStatusFilter, SecurityContextHolderFilter.class);有人可能担心每次请求都查一次 Redis 有性能开销。实际场景中,可以给在线状态加一个合理的过期时间(与 session 或 JWT 有效期对齐),也可以加一层本地缓存;不过要记得,踢人时要清掉本地缓存,否则又会出现「Redis 标记已删、过滤器还认为在线」的问题。
4.4 方案 C:Pub/Sub 广播,各节点本地清理
还有一种是保留各节点的本地SessionRegistry,但通过 Redis Pub/Sub 广播「踢人」事件。每个节点都订阅同一个 channel,收到事件后,在本地SessionRegistry里找到该用户对应的SessionInformation并expireNow()。
这种方式的好处是改动最小,兼容原有单机版的SessionRegistry逻辑;坏处是必须保证所有节点都能收到消息,而且如果某个节点短暂不可用,消息会丢。实际生产里我一般只在事件量很小的管理后台操作中用这个方案。
5. 无状态 JWT 和 OAuth 2.1 下的“踢出用户”:没有会话可销毁怎么办
5.1 JWT 下为什么不能像销毁 HttpSession 一样踢人
如果你的资源服务器用的不是 HttpSession,而是无状态 JWT,那么「踢人」的底层逻辑完全变了。JWT 的签名只保证 token 内容没有被篡改,有效期由exp声明控制;只要 token 没过期,资源服务器每回都会验签通过。服务端手里根本没有一个可以销毁的 session 对象,所以你必须引入额外状态,让原本无状态的校验变得「有状态」。
5.2 方案 A:把 jti 拉进 Redis 黑名单
这是最常见、也最容易落地的方案。JWT 标准里有一个jti声明,用于唯一标识一个 token。签发 token 时给它一个 UUID;做校验时,在完成 JWT 解码之后、执行业务逻辑之前,先查一下 Redis 黑名单里有没有这个jti,有就直接拒绝。
签发时自定义 JWT:
Jwt jwt = Jwt.withSubject(user.getUsername()) .id(UUID.randomUUID().toString()) .claim("uid", user.getId()) .issuedAt(now) .expiresAt(now.plusSeconds(accessTokenTtl)) .signWith(rsaKey) .build();踢人时,把该用户所有未过期的 token 的jti写入黑名单:
public void revokeJwt(String jwtId, long expiresAtEpochSecond) { long now = System.currentTimeMillis() / 1000; long ttlSeconds = Math.max(1, expiresAtEpochSecond - now); redisTemplate.opsForValue().set( "token:blacklist:" + jwtId, "1", Duration.ofSeconds(ttlSeconds) ); }重点:黑名单的 TTL 一定要设置为jwt.exp - now,因为 token 过期之后黑名单条目就没必要保留了,这样一个 Redis key 的生命周期和 token 生命周期对齐,不会越积越多。
校验过滤器可以在BearerTokenAuthenticationFilter之后执行,因为这时候认证信息已经包含了解码后的Jwt对象:
@Component public class JwtRevocationFilter extends OncePerRequestFilter { private final StringRedisTemplate redisTemplate; public JwtRevocationFilter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getCredentials() instanceof Jwt jwt) { String jti = jwt.getId(); if (jti != null && Boolean.TRUE.equals(redisTemplate.hasKey("token:blacklist:" + jti))) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "token revoked"); return; } } filterChain.doFilter(request, response); } }管理端踢人时,需要拿到这个用户的所有活跃 token 的jti。这要求你在登录时维护一张「用户 -> jti 列表」的索引,例如user:tokens:{userId}用 Set 结构存所有 jti,踢人时遍历 Set 写入黑名单并删除索引。
5.3 方案 B:白名单与用户版本号
黑名单方案适合 token 数量可控的场景。如果用户会频繁登录,产生大量 token,黑名单列表会变得很大。更优雅的做法是「白名单」或者「用户版本号」。
登录时给用户生成一个版本号version,写进 JWT 的 claim;每次请求在过滤器里从 Redis 取用户当前版本号,如果和 JWT 里的不一致,就判定为 token 失效。踢人时只需要把 Redis 里的版本号加一,所有旧 token 全部作废。
redisTemplate.opsForValue().increment("user:version:" + userId);这个方法不需要遍历维护 token 列表,而且踢人即时生效。代价是每次请求多一次 Redis 读取;如果对性能敏感,可以结合本地短缓存,但踢人时一定要把缓存一起删掉。
5.4 在 Spring Authorization Server(OAuth 2.1)中的落地
很多项目现在的技术栈已经升级到 Spring Authorization Server,对应 OAuth 2.1 规范。这里要提醒大家一个反差事实:OAuth 2.1 协议本身并没有要求授权服务器必须支持 access token 的即时吊销。/oauth2/revoke端点主要针对 refresh token,对已经签发的 JWT access token,即使你在授权服务器里删除了授权记录,已经拿到 access token 的客户端依然可以访问资源服务器。
所以如果你用的是 Spring Authorization Server 下发 JWT,想踢用户下线,正确做法是把「授权服务器」和「资源服务器」两个层面打通:
- 授权服务器自定义
OAuth2TokenCustomizer,在签发 JWT 时写入jti、uid、user_version等信息; - 资源服务器在 JWT 校验过滤器里,用
jti黑名单或用户版本号做二次检查; - 管理端踢人时,调用自定义的 revoke 逻辑,把用户的
jti拉黑或更新版本号,再调用/oauth2/revoke注销 refresh token,防止用户通过 refresh token 再换新的 access token。
还有一条路是改用 opaque token(不透明 token),资源服务器每次通过 introspection 端点向授权服务器确认 token 状态。这样授权服务器能实时响应吊销状态,位置是「远程校验一次」,性能开销更大,但管理能力和一致性最好。到底是 JWT 黑名单还是 opaque+introspection,取决于你的业务对「踢人即时性」和「接口延迟」的取舍。
6. 踩坑复盘:从“接口调用成功用户还能访问”到定位完整链路
6.1 复现路径:调用成功,用户却没掉线
我在给一个老项目加踢人功能时,第一次上线就翻车了。管理后台点击「踢下线」,接口返回成功,但被踢用户刷新页面后,业务接口照样正常返回数据。复盘之后发现,问题不止一个,而是好几个坑叠加在一起。
6.2 第一个根因:principal 对象没有正确匹配
当时登录后的 principal 是一个自定义的AuthPrincipal类,类里没有重写equals()和hashCode()。SessionRegistryImpl在内部用一个Map<Object, Set<String>>保存 principal 到 sessionId 的映射,Map 的 key 用的是 principal 对象。我踢人时新建了一个new AuthPrincipal(username)去getAllSessions(principal, false),两个对象的 hashCode 不同,自然查不到任何会话。
解决办法就是遍历getAllPrincipals(),对每个 principal 用getUsername()或getName()做字符串比较,而不是直接用整个对象做匹配。刚才的UserSessionKickService里已经体现了这个思路。
6.3 第二个根因:没有启用 ConcurrentSessionFilter
排查完第一个根因后,调用踢人接口已经能返回被踢会话数量大于 0 了,但用户还是不掉线。进一步检查配置才发现,我只定义了SessionRegistryBean,SecurityFilterChain 里没有调用.maximumSessions(),所以ConcurrentSessionFilter根本没有进过滤器链。
sessionInformation.expireNow()只是改了内存里的一个布尔值,没有人去读这个布尔值,那它就跟没改一样。后来我在.sessionManagement()里加上maximumSessions(100)之后,再调踢人接口,用户下次请求才真正被拦下来。
6.4 第三个根因:直接 invalidate 抛异常后静默吞掉
还有一次,代码在遍历information.getSession()时,因为没有先判断 session 是否已经失效,直接强转并调用invalidate(),结果抛了IllegalStateException。那时代码用了catch (Exception e) {}空捕获,导致其中一个会话处理失败后,后面的会话也不再处理。
正确做法是像前面代码那样,先expireNow(),再取HttpSession并判空,用catch (IllegalStateException ignored)处理已经被销毁的会话,而且千万不要空捕获吞掉所有异常,至少得打印日志。
6.5 第四个根因:多实例下会话不在本机
单测全部通过,部署到预发环境后又翻车。原因是预发环境有两台节点,负载均衡把请求分发到不同机器上。用户登录和踢人请求落在了不同节点,本地SessionRegistry根本查不到目标会话。
这个问题的排查特征非常明显:日志里getAllPrincipals().size()很小,只有当前节点的会话;而用户所在的另一台节点上有这个用户。后来我按第 4 章的方法,改成 Redis 在线标记 + 过滤器校验,彻底绕开了「每台机器各自维护 SessionRegistry」的问题。
6.6 验证踢人效果的自检清单
如果你也正在做类似功能,我建议按下面的清单逐项验证,能省掉不少弯路:
- 登录后调用
sessionRegistry.getAllPrincipals(),确认会话真的注册进去了; - 检查 SecurityFilterChain 里是否配置了
maximumSessions(),确认ConcurrentSessionFilter存在; - 用管理员账号调踢人接口,确认返回值不是 0;
- 被踢用户的下一次请求,确认会跳转 expiredUrl 或返回 401;
- 多实例环境,确认用户会话所在节点也能收到踢人指令;
- 无状态 JWT 场景,确认登录时记录了 jti,踢人后黑名单 key 存在。
最后分享一个我自己的习惯:这个功能做出来后,我会额外加一个操作审计日志,记录是谁在什么时间踢了哪个用户、踢出了多少个会话。一方面运营需要追溯操作,另一方面排查问题时有据可查。「踢出指定用户」看似是一个小功能,但它把会话管理、过滤器链、分布式一致性和 token 撤销机制全串起来了,值得认真对待。