做后台管理系统的同学,十有八九会遇到这种需求:运营在后台看到某用户在线,要立刻把这个人强制下线;或者账号只允许单端登录,新设备一登录老设备就被挤掉;又或者用户反馈账号被盗,需要一键把其他未知会话全部踢出去。这个动作在SpringSecurity体系里,叫法很直白——踢出指定用户,也叫强制下线、强制失效会话、在线用户管理。
这篇文章就围绕这个场景展开。我会先拆解需求背后到底在解决什么问题,然后分别给出基于Session和基于JWT/Redis两套完整可落地的实现方案,中间穿插多端登录、在线用户列表、OAuth2.1下的注意事项,最后把我实测踩过的坑整理成一张速查表。适合正在做后台管理、安全风控、账号体系,或者被“把用户踢下线”这个需求卡住的同学参考。
先说结论:踢人这个功能本身不复杂,但它和你用的登录会话存储方式强相关。Session方案用SpringSecurity自带的SessionRegistry就能搞定;JWT方案则要自己维护token的黑名单或版本号。下面按我的实践顺序慢慢拆。
1. 踢出指定用户的需求拆解与方案选型
1.1 先搞清楚“踢人”到底是什么操作
踢出指定用户,本质上是让某个已经认证过的用户在后续请求中,无法继续使用当前有效的登录凭证。这句话听起来简单,但落到实现上,你需要先搞清楚“当前有效的登录凭证”到底存在哪。
我把实际业务里遇到的“踢人”需求归纳成三类:
- 按用户踢:以用户名或用户ID为维度,把这个人的全部在线会话都失效。比如封号、禁用账号、用户泄露后强制下线。
- 按会话踢:只踢某一个设备或某一次登录的会话,不影响其他设备。比如用户看到“其他设备登录”告警,只把可疑设备踢掉。
- 按规则踢:新登录挤掉旧登录,或者单端登录、多端限制。这就是SpringSecurity里并发会话控制的老本行。
这三类需求的难度不一样。按规则踢是SpringSecurity内置能力,配置一下就有;按用户踢和按会话踢需要手动操作会话注册表或token状态。
1.2 认证状态存储方式决定踢人方案
SpringSecurity本身不强制你用什么方式存登录状态,它是通过SecurityContext、Session、Filter链这一套抽象来工作的。但实际项目中,登录状态存储基本就两条路线:
- 有状态Session路线:登录成功后把认证信息放在HttpSession里,后续请求通过Cookie里的JSESSIONID找到会话,从Session中恢复SecurityContext。
- 无状态Token路线:登录成功后签发JWT等自包含token,客户端在Header里带着token,服务端每次请求验签,不依赖Session。
这两种路线下,“踢出指定用户”的实现方式完全不同。Session路线可以直接销毁或者使Session失效;Token路线不能销毁服务端不存在的东西,只能靠“让token退休”来完成。
1.3 三种主流方案对比
我整理了下面这个表,后续的篇幅就是围绕这三种方案展开的。
| 方案 | 核心思想 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|---|
| SessionRegistry | SpringSecurity把在线会话注册到一个注册表,踢人时把对应SessionInformation标记为过期 | 单体应用、有状态Session、后台管理系统 | 实现简单,SpringSecurity原生支持,和会话管理天然结合 | 多实例部署要自己扩展,和JWT不兼容 |
| Token版本号 | 每个用户一个版本号,JWT里携带版本号,每次请求比对Redis中的当前版本号 | 单端登录、按用户整体踢出、JWT服务 | 一条INCR命令就能踢掉用户全部token,实现简单 | 粒度是用户级别,无法精准踢单个会话;每次请求多一次Redis查询 |
| Token黑名单 | 签发token时生成唯一jti,踢人时把jti加入黑名单 | 需要精准踢某个会话、支持多端token | 粒度精准,能精确控制单个会话 | 黑名单要维护过期时间,长期token多时Redis压力增大 |
我实际项目里,如果项目用的是Session,首选SessionRegistry;如果用的是JWT,首选“版本号+黑名单”组合。
2. 前置准备与核心概念补课
2.1 SpringSecurity会话管理核心类
想不踩坑,必须先搞清楚SpringSecurity在这块提供了哪些类和接口。我把最常用的几个列一下:
- SecurityContext:认证信息的持有者,里面放着Authentication对象。
- SecurityContextHolder:默认通过ThreadLocal保存当前请求的SecurityContext。
- SessionRegistry:会话注册表接口,负责记录“哪个principal在哪些sessionId上是活跃的”。
- SessionInformation:封装一次会话的信息,包括principal、sessionId、最后请求时间和是否过期。
- ConcurrentSessionFilter:并发会话控制过滤器,会检查当前Session对应的SessionInformation是否已过期,如果过期就做相应处理。
- HttpSessionEventPublisher:HttpSession生命周期事件监听器,把Session的创建和销毁事件同步给SpringSecurity。
这套东西的运作机制,一句话概括就是:登录成功时把会话信息注册进SessionRegistry,每次请求经过过滤器时检查会话状态,踢人时把对应的SessionInformation标记为过期,这样下一次请求就被拦截。
2.2 基于Session的踢出原理
基于Session实现踢出,关键点不是“删除Session”,而是“把一个已注册的会话标记为过期”。
SpringSecurity的SessionRegistryImpl内部维护了一张映射表,key是principal,value是该principal下所有活跃sessionId对应的SessionInformation。当你调用sessionInformation.expireNow()时,它只是把内部的一个过期标志设为true,并没有立刻销毁底层的HttpSession。那Session什么时候真正失效呢?答案是在下一次请求进来时,ConcurrentSessionFilter发现这个sessionId对应的SessionInformation已经过期,于是执行登出清理逻辑,把SecurityContext清空、通知SessionRegistry删除记录,然后重定向到配置的expiredUrl。
这套设计看起来很绕,但好处是显而易见的:它让“会话过期”这件事变成了一个自然的、Lazy的检查过程,不必在踢人接口里强行操作一个跨线程、可能正在被使用的Session对象。
2.3 基于Token的踢出原理
JWT这类无状态token,服务端不保存会话,所以“踢出指定用户”只能在服务端维护一份“作废名单”或“最新版本号”。
- 黑名单思路:每个token都有一个唯一的jti,服务端在签发时记录下来。踢人时,把要作废的jti扔进Redis黑名单。过滤器在每次请求里先查一下这个jti在不在黑名单,在就直接拒绝。
- 版本号思路:每个用户对应一个版本号,登录时把这个用户的当前版本号写入JWT的claim里。服务端Redis里也存一份用户当前的最新版本号。请求进来时,对比token里的版本号和Redis里的版本号,不一致就说明该token已经被作废。
这两种思路不冲突,反而可以互补。黑名单适合“精确踢某个会话”,版本号适合“把一个用户的所有会话全部作废”。
2.4 为什么直接SecurityContextHolder.clearContext()不够
很多人一上来就写SecurityContextHolder.clearContext(),以为清空当前线程的上下文就能把人踢下线。这个写法在我的项目里也出现过,我必须说:它基本无效。
原因很简单:SecurityContextHolder是线程绑定的,你踢人的接口在处理管理员的HTTP请求,它的线程上根本没有被踢用户的SecurityContext。你清空的只是当前线程的上下文,和被踢用户的那个请求线程毫无关系。真正有效的操作必须是作用于“那个用户下一次请求”的,要么让Session过期,要么让token失效,这样才能在后续请求进来时重新走到认证流程里被拦下。
3. 基于SessionRegistry的踢出实现
3.1 第一步:把SessionRegistry和监听器配置成Bean
在Spring Boot项目中,SessionRegistryImpl一般要注册成Spring容器里的Bean,因为你需要把它注入到踢出接口里操作。同时要注册HttpSessionEventPublisher,否则Session销毁时SessionRegistry里的记录不会自动清理,时间长了会出现“幽灵会话”。
以Spring Boot 3 / Spring Security 6为例,配置大致如下:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/error", "/admin/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/index") ) .sessionManagement(session -> session .maximumSessions(1) .sessionRegistry(sessionRegistry()) .expiredUrl("/login?expired=1") .maxSessionsPreventsLogin(false) ) .csrf(csrf -> csrf.disable()); return http.build(); } @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } @Bean public static ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() { return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher()); } }这里我特别说明几个参数:
maximumSessions(1)表示一个用户最多只允许1个会话。maxSessionsPreventsLogin(false)表示新登录时不会阻止新会话,而是让旧会话过期,实现“后来者居上”。expiredUrl("/login?expired=1")是Session被标记为过期后,下一次请求被重定向到的地址。
如果你有多端登录需求,就把maximumSessions(1)改成maximumSessions(-1)或者干脆不配这个限制。
3.2 第二步:自定义踢出接口
配置完,核心操作就简单了。先写一个辅助方法,按用户名找到这个用户所有没过期的会话信息:
@Service public class OnlineUserService { private final SessionRegistry sessionRegistry; public OnlineUserService(SessionRegistry sessionRegistry) { this.sessionRegistry = sessionRegistry; } public List<SessionInformation> getOnlineSessionsByUsername(String username) { List<SessionInformation> result = new ArrayList<>(); for (Object principal : sessionRegistry.getAllPrincipals()) { if (principalMatches(principal, username)) { result.addAll(sessionRegistry.getAllSessions(principal, false)); } } return result; } private boolean principalMatches(Object principal, String username) { if (principal instanceof UserDetails userDetails) { return userDetails.getUsername().equals(username); } return String.valueOf(principal).equals(username); } }注意我为什么没有直接调用sessionRegistry.getAllSessions(username, false)。这是个隐藏很深的坑:SessionRegistry存储的principal不是用户名,而是认证成功后的Authentication.getPrincipal()对象,通常是UserDetails实例。如果你直接传字符串username进去,大概率匹配不到任何会话。要么你在自定义UserDetails里重写equals方法让它基于username判断,要么就用上面这种循环匹配的方式,我推荐后者,更通用。
踢出接口如下:
@RestController @RequestMapping("/admin") public class UserKickoutController { private final OnlineUserService onlineUserService; public UserKickoutController(OnlineUserService onlineUserService) { this.onlineUserService = onlineUserService; } @PostMapping("/kickout") public String kickout(@RequestParam String username) { List<SessionInformation> sessions = onlineUserService.getOnlineSessionsByUsername(username); if (sessions.isEmpty()) { return "该用户当前无在线会话"; } for (SessionInformation session : sessions) { session.expireNow(); } return "已踢出 " + sessions.size() + " 个会话"; } }调一次expireNow(),会把SessionInformation的过期标志置为true。当那个被踢用户的浏览器下一次发起请求时,ConcurrentSessionFilter检测到会话过期,就会执行登出清理并重定向到/login?expired=1。
3.3 为什么这样踢完还要等下一次请求才生效
这个机制很多刚接触的人不理解,我展开讲一下。
SessionInformation并不是HttpSession本身,它是SpringSecurity在SessionRegistry里保存的一条记录。expireNow()只是把这条记录标记为“过期”,没有触发容器的Session销毁逻辑。这也是设计上的一个安全考虑:如果你在管理员的HTTP请求线程里直接去销毁另一个用户的HttpSession,可能刚好那个session正在被并发请求使用,强行销毁容易出问题。
SpringSecurity选择了懒处理:让那个会话的下一次请求自己“撞枪口”。ConcurrentSessionFilter在每次请求时,都会根据当前sessionId从SessionRegistry找到对应的SessionInformation,检查它是不过期。过期了就调用LogoutHandler清理认证信息,再重定向。
如果你觉得等下一次请求太慢,想立刻让session失效,可以通过sessionId拿到HttpSession后手动invalidate()。但要注意跨请求操作HttpSession有并发风险,而且分布式环境下不一定拿得到,我一般不建议这么干。SpringSecurity的懒失效机制虽然看似慢半拍,但配合重定向和前端跳转,用户其实感知不到多少延迟。
3.4 多实例部署下的SessionRegistry问题
SessionRegistryImpl是单机内存实现,服务重启后在线会话记录全丢,多实例负载均衡下各实例各存各的。如果项目是多实例,要么引入Spring Session并把会话存储放到Redis,要么自己实现一个基于Redis的SessionRegistry。
用Spring Session时,SessionRegistry和HttpSessionEventPublisher依然可以工作,因为HttpSession的创建和销毁事件会被Spring Session的Redis存储触发同步。只要把spring-session-data-redis依赖加上,再配合spring.session.redis.*配置,SessionRegistry就能感知到分布式的会话。
如果不想引入Spring Session,也有土办法:踢出接口里不依赖SessionRegistry,而是从Redis的在线用户ZSet里查到该用户的sessionId列表,然后把sessionId对应的Redis Session key删掉。这个方案要自己维护会话存储,工作量大一点,但可控性更强。
4. 基于JWT和Redis的踢出实现
4.1 JWT无状态带来的麻烦
JWT最大的特点是“无状态”:token里自己携带用户信息、过期时间、签名,服务端不用存session。好处是水平扩展容易、接口幂等性好,坏处是——你没办法主动销毁一个还没过期的token。比如你给用户签了一个有效期2小时的JWT,用户发了一条违规内容被运营看到,你总不能等2小时token自己过期吧。
所以JWT项目里做踢人,必须引入一个“可以变的状态”。我常用的方案就是这个状态放在Redis里,分两种粒度:用户级版本号、会话级jti黑名单。
下面我把这两种方案都写了,你可以根据业务需要二选一或者组合。
4.2 用户级版本号方案:一条INCR踢掉所有会话
这个方案核心是:给每个用户维护一个版本号,登录时把版本号写进JWT的claim;Redis里存一份当前最新版本号;每次请求在过滤器里比对,不一致就拒绝。
登录签发token时大概是这样:
@Service public class TokenService { private final String secret = "your-secret-key"; private final RedisTemplate<String, Object> redisTemplate; public TokenService(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } public String createToken(Long userId, String username) { // 注意:每次签发新token时都让版本号递增,保证新登录的token一定是最新的 Long version = redisTemplate.opsForValue().increment("login:token:version:" + userId); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("version", version) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public void kickoutUser(Long userId) { // 版本号+1,所有旧token的version都比不过这个最新值 redisTemplate.opsForValue().increment("login:token:version:" + userId); } }这里有一个非常重要的点:每次签发token都要重新INCR版本号,而不是读旧版本号。为什么?因为如果登录时读旧值,那么被踢后用户重新登录拿到的还是旧版本号,旧token又会“复活”,踢人就失效了。只有在签发时递增版本号,才能保证用户重新登录会拿到一个严格大于被踢版本的新token,旧token彻底失效。
然后在鉴权过滤器里加一道版本校验:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final RedisTemplate<String, Object> redisTemplate; // 构造器注入略 @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && isTokenValid(token)) { Long userId = Long.valueOf(jwtService.getUserId(token)); Long currentVersion = getCurrentVersion(userId); Long tokenVersion = jwtService.getVersion(token); if (currentVersion == null || !currentVersion.equals(tokenVersion)) { throw new AccessDeniedException("会话已过期,请重新登录"); } } filterChain.doFilter(request, response); } }这个方案的优点是实现简单,踢一个用户就一条INCR命令;缺点是它是用户级的,不能只踢某一个会话。而且每次请求都会多一次Redis查询,JWT无状态的优势有所弱化。为了减小影响,可以给版本号key加一个合理TTL,比如24小时,用户长时间不登录就让它自动过期。
4.3 会话级jti黑名单方案:精确踢出单个会话
如果业务要求“只踢掉某个设备,其他设备不受影响”,版本号方案就做不到。这时候用jti黑名单。
登录签发token时生成一个唯一jti:
public String createTokenWithJti(Long userId, String username) { String jti = UUID.randomUUID().toString().replace("-", ""); return Jwts.builder() .setSubject(String.valueOf(userId)) .setId(jti) .claim("username", username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); }登录成功后,在Redis里建立一个用户和jti的映射,方便按用户找到所有会话:
// 登录后记录会话 String jti = jwtService.getJti(token); String userSessionKey = "login:user:sessions:" + userId; redisTemplate.opsForSet().add(userSessionKey, jti); redisTemplate.expire(userSessionKey, Duration.ofHours(2));踢单个会话时,把该jti加入黑名单:
public void kickoutSession(Long userId, String jti) { // 把jti加入黑名单 redisTemplate.opsForValue().set("login:token:blacklist:" + jti, "1", Duration.ofHours(2)); // 从用户的会话集合里移除 redisTemplate.opsForSet().remove("login:user:sessions:" + userId, jti); }踢出该用户全部会话时,遍历用户会话集合:
public void kickoutAllSessions(Long userId) { String userSessionKey = "login:user:sessions:" + userId; Set<Object> jtis = redisTemplate.opsForSet().members(userSessionKey); if (jtis == null || jtis.isEmpty()) { return; } for (Object jti : jtis) { redisTemplate.opsForValue().set("login:token:blacklist:" + jti, "1", Duration.ofHours(2)); } redisTemplate.delete(userSessionKey); }过滤器里多一步查黑名单:
String jti = jwtService.getJti(token); if (Boolean.TRUE.equals(redisTemplate.hasKey("login:token:blacklist:" + jti))) { throw new AccessDeniedException("该会话已被强制下线"); }这个方案胜在精细,但要注意黑名单的TTL要和JWT剩余有效期匹配。我的习惯是把黑名单TTL设成和token有效期一样长,或者直接设一个固定值比如2小时。如果token有效期是30分钟,黑名单却设了24小时,内存浪费不至于太大,但长时间高频踢人会有积压。比较稳妥的做法是在签发token时就把过期时间也单独存一份,踢人时按剩余有效期设置黑名单TTL。
4.4 别忘了处理Refresh Token
很多文章讲到这里就结束了,但我在实际项目里遇到一个特别尴尬的坑:access token被踢掉了,refresh token还活着,用户拿着refresh token换一个新的access token,又满血复活了。踢人踢了个寂寞。
如果你用了Refresh Token机制,一定要把“踢人”的逻辑同步到Refresh Token上。我的做法比较朴素:在Redis里给每个用户存一个login:token:refresh:block:标记,踢人时写入,刷新token的接口在处理请求前先查这个标记,如果存在就拒绝刷新。
更彻底的做法是给Refresh Token也套一层版本号,用户被踢后版本号递增,刷新接口校验版本号,不一致就拒绝签发新的Access Token。这样即使Refresh Token有效期很长,也逃不过被同步作废的命运。务必要把这一步设计进去,否则踢人功能是半成品。
4.5 Redis存储和序列化的几个细节
用RedisTemplate操作这些key时,我踩过序列化的坑。如果你直接用JDK序列化或默认的JdkSerializationRedisSerializer,存进去的Set成员会出现乱码,过滤器里读jti时对不上。建议统一配置String序列化:
@Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new StringRedisSerializer()); return template; }另外一个容易被忽略的点:用redisTemplate.keys("login:token:blacklist:" + userId + ":*")这种模式查key,生产环境千万别这么干。Keys命令在Redis大key多的时候会阻塞整个实例,量一大就把线上拖垮。要么在登录时用一个Set维护userId下所有jti,要么用Scan命令。我在上面示例里用的是Set维护,这个习惯从项目上线后一直稳定运行,推荐照抄。
5. 多端登录与在线用户管理实战
5.1 Session方案里的多端登录和单端登录
很多业务对多端登录的态度不一样:有的允许多端,有的只允许单端,还有的是“单端但可以手动踢其他端”。这三种态度在SpringSecurity里配置也不同。
- 允许多端:不配maximumSessions,或者配成
maximumSessions(-1),踢人时通过SessionRegistry精确获取某个用户的全部会话,可以全踢也可以只踢一个。 - 单端但允许新挤旧:
maximumSessions(1) + maxSessionsPreventsLogin(false),新登录会触发旧会话过期。这个方案配合expiredUrl就能让老设备自动跳转登录页。 - 单端但禁止新挤旧:
maximumSessions(1) + maxSessionsPreventsLogin(true),新用户登录时会直接登录失败,提示“当前账号已在其他设备登录”。
注意,maxSessionsPreventsLogin(true)在Spring Security 6里对应的是maxSessionsPreventsLogin(true),但它的实际行为是“拒绝新登录”,并不会给用户一个很友好的提示。如果你要在前端展示“已有其他设备登录,请先踢出”,需要自己捕获异常后做额外处理。
5.2 在线用户列表怎么统计
踢人功能通常和“在线用户列表”一起出现。管理员得先看到谁在线,才能去踢谁。Session方案下可以直接从SessionRegistry拿:
public List<OnlineUserVO> listOnlineUsers() { List<OnlineUserVO> result = new ArrayList<>(); for (Object principal : sessionRegistry.getAllPrincipals()) { List<SessionInformation> sessions = sessionRegistry.getAllSessions(principal, false); for (SessionInformation session : sessions) { String username = extractUsername(principal); result.add(new OnlineUserVO(username, session.getSessionId(), session.getLastRequest())); } } return result; }SessionInformation提供了getSessionId()和getLastRequest(),可以拿到会话ID和最后活跃时间,用来做“最近活跃时间”展示刚刚好。如果还想显示登录IP、设备信息,可以在登录成功时把这些信息塞进Session的attribute里,列表接口再取出来。
JWT方案下在线用户统计更依赖Redis。登录成功时把jti、userId、username、登录时间维护进一个ZSet,比如online:users的score存登录时间戳,value存userId:jti。用户每次请求时更新ZSet里对应成员的score。踢人或token失效时从ZSet移除。这种方式能直接按时间排序,处理“在线时长”“活跃度排行”都很方便。
5.3 被踢之后的用户体验处理
被踢后用户看到什么,直接影响到运营体验。
Session方案里通过expiredUrl配置一个重定向地址,前端登录页可以接收expired=1参数并弹出提示“您的账号已在其他设备登录”。但要注意,如果用户正在站内某个页面进行操作,突然被重定向,浏览器历史里会留下当前页面。我一般会在expiredUrl对应页面上加一个fromExpired标记,让前端自动清理浏览器历史记录。
JWT方案里,过滤器可以返回统一的JSON错误结构:
{ "code": 401, "message": "您的会话已被强制下线,请重新登录" }前端在axios拦截器里识别这个code,做一次window.location.href = '/login'跳转即可。还有一个小细节:被踢后如果前端一直轮询某个接口,会连续收到401,容易给后端造成压力。我的做法是在前端拦截器里加一个防抖,同一个会话连续被拒后只跳转一次。
5.4 OAuth2.1场景下的特殊考虑
现在Spring Security OAuth2.1和Spring Authorization Server的组合越来越常见。如果你把踢人需求放在OAuth2.1架构下,要稍微调整一下思路。
OAuth2.1下,客户端拿到的是Access Token,资源服务器只负责校验token,并不维护用户的登录会话。把用户踢下线有两种落地方式:
- 资源服务器自己维护token黑名单:每次请求都查Redis黑名单,被加入黑名单的token直接拒绝。这个方式和上面JWT黑名单一致。
- 接入授权服务器的Token Revocation端点:授权服务器提供RFC7009标准的撤销令牌接口,资源服务器或业务后台调用这个接口让指定的Access Token或Refresh Token失效。
在Spring Authorization Server落地时,我的经验是:短周期Access Token配合权限校验,再加上黑名单作为兜底。比如Access Token有效期压到5到10分钟,即使Refresh Token泄露也能很快过期;同时被踢用户的黑名单标记在刷新token的接口处拦截,从源头阻断续命。OAuth2.1本身把客户端凭据和用户授权分得很清楚,踢人的重心也从“销毁用户Session”变成了“撤销token状态”,这个认知不转过来,很容易在路由和过滤器层面找错位置。
6. 问题排查与避坑实录
6.1 常见问题速查表
我把这几年在群里、博客和实际项目里遇到的高频问题整理成了表,方便直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 踢出后用户刷新还能访问 | 使用的是JWT方案,只清理了SessionRegistry,没有让token失效 | 改用版本号或黑名单方案,并处理Refresh Token |
| getAllSessions(username)查不到会话 | principal是UserDetails对象,不是String用户名 | 遍历getAllPrincipals后匹配username,或重写UserDetails的equals |
| Session过期后一直重定向循环 | expiredUrl指向了一个需要认证的页面 | expiredUrl必须指向permitAll的路径,比如登录页 |
| 并发会话控制不生效 | 没有注册SessionRegistry,或MaximumSessions配置位置错误 | 确认SessionRegistry注册为Bean并注入sessionManagement |
| Session销毁后SessionRegistry记录不清 | 没注册HttpSessionEventPublisher | 注册ServletListenerRegistrationBean监听Session事件 |
| 多实例下踢了这个节点,另一个节点还放行 | SessionRegistryImpl是内存实现,各实例独立 | 引入Spring Session,或实现Redis版SessionRegistry |
| JWT被踢后refresh token还能换新token | 只作废了Access Token,没处理Refresh Token | 在刷新接口增加踢人标记或Refresh Token版本号校验 |
| Redis里jti读取出来是乱码 | 序列化器配置不对 | 统一使用String序列化 |
| 踢人接口权限没控制好,直接暴露 | @RestController没加权限限制 | 在踢人接口上配置hasRole("ADMIN"),并限制IP |
6.2 我实测踩过的几个坑
第一个坑是踢人后UserDetailsService缓存导致“假复活”。项目里用户被禁用后,JWT过滤器走数据库查用户状态时用了Caffeine本地缓存,结果被禁用的人还能继续访问一段时间。排查到最后发现是缓存没在踢人时同步清理。这个问题的教训是:踢人不只是让会话失效,如果用户数据本身有状态变更,还要清缓存、清授权信息。
第二个坑是Session方案里同时配了rememberMe。记住我功能会在Session过期后用rememberMe cookie重新认证,导致你踢了人,用户一刷新又自动登录回来了。处理方式是在踢人时同步清理rememberMe相关cookie,或者全局禁用rememberMe。我当时排查了很久,最后在日志里看到一条自动登录记录才意识到。
第三个坑是关于maxSessionsPreventsLogin和旧会话的。一开始我配了true,业务方反馈说“踢人之后用户重新登录竟然提示密码错误”,其实是被并发会话控制挡了,提示又没做定制化。后来我改成false,让新会话直接顶掉旧会话,业务上才达到预期。
6.3 一个锦上添花的小技巧
最后分享一个小技巧:无论Session还是JWT方案,都可以在踢人接口里加一个“踢人原因”字段。把原因写到Redis或Session的attribute里,被踢用户下次请求时,接口能返回这个原因,前端可以展示“您的账号已被强制下线,原因:发布违规内容”。这个功能看起来小,但对客服和风控的价值很高,能直接减少大量“为什么我登录不上”的工单。
原因存储我一般这样处理:Session方案,在SessionInformation对应的Session attribute里塞一条kickoutReason;JWT方案,在Redis里以userId为key存一条login:kickout:reason:{userId},TTL设置10分钟,被踢后前端拉取一次提示即可。
我在实际项目中更推荐JWT方案配合版本号+黑名单组合,因为现在的前后端分离项目太普遍了,Session方案在跨域、多端、移动端场景下都不够灵活。但如果你做的是传统服务端渲染的管理后台,SessionRegistry方案依然是最快、最不出错的选择。技术选型没有绝对的对错,先把会话存储方式想清楚,剩下的事情就水到渠成了。