ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Security踢出用户:Session和JWT/Redis实现方案

Spring Security踢出用户:Session和JWT/Redis实现方案 做后台管理系统的同学十有八九会遇到这种需求运营在后台看到某用户在线要立刻把这个人强制下线或者账号只允许单端登录新设备一登录老设备就被挤掉又或者用户反馈账号被盗需要一键把其他未知会话全部踢出去。这个动作在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 三种主流方案对比我整理了下面这个表后续的篇幅就是围绕这三种方案展开的。方案核心思想适合场景优点缺点SessionRegistrySpringSecurity把在线会话注册到一个注册表踢人时把对应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是否已过期如果过期就做相应处理。HttpSessionEventPublisherHttpSession生命周期事件监听器把Session的创建和销毁事件同步给SpringSecurity。这套东西的运作机制一句话概括就是登录成功时把会话信息注册进SessionRegistry每次请求经过过滤器时检查会话状态踢人时把对应的SessionInformation标记为过期这样下一次请求就被拦截。2.2 基于Session的踢出原理基于Session实现踢出关键点不是“删除Session”而是“把一个已注册的会话标记为过期”。SpringSecurity的SessionRegistryImpl内部维护了一张映射表key是principalvalue是该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?expired1) .maxSessionsPreventsLogin(false) ) .csrf(csrf - csrf.disable()); return http.build(); } Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } Bean public static ServletListenerRegistrationBeanHttpSessionEventPublisher httpSessionEventPublisher() { return new ServletListenerRegistrationBean(new HttpSessionEventPublisher()); } }这里我特别说明几个参数maximumSessions(1)表示一个用户最多只允许1个会话。maxSessionsPreventsLogin(false)表示新登录时不会阻止新会话而是让旧会话过期实现“后来者居上”。expiredUrl(/login?expired1)是Session被标记为过期后下一次请求被重定向到的地址。如果你有多端登录需求就把maximumSessions(1)改成maximumSessions(-1)或者干脆不配这个限制。3.2 第二步自定义踢出接口配置完核心操作就简单了。先写一个辅助方法按用户名找到这个用户所有没过期的会话信息Service public class OnlineUserService { private final SessionRegistry sessionRegistry; public OnlineUserService(SessionRegistry sessionRegistry) { this.sessionRegistry sessionRegistry; } public ListSessionInformation getOnlineSessionsByUsername(String username) { ListSessionInformation 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) { ListSessionInformation sessions onlineUserService.getOnlineSessionsByUsername(username); if (sessions.isEmpty()) { return 该用户当前无在线会话; } for (SessionInformation session : sessions) { session.expireNow(); } return 已踢出 sessions.size() 个会话; } }调一次expireNow()会把SessionInformation的过期标志置为true。当那个被踢用户的浏览器下一次发起请求时ConcurrentSessionFilter检测到会话过期就会执行登出清理并重定向到/login?expired1。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的claimRedis里存一份当前最新版本号每次请求在过滤器里比对不一致就拒绝。登录签发token时大概是这样Service public class TokenService { private final String secret your-secret-key; private final RedisTemplateString, Object redisTemplate; public TokenService(RedisTemplateString, 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 RedisTemplateString, 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时生成一个唯一jtipublic 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; SetObject 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 RedisTemplateString, String redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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 ListOnlineUserVO listOnlineUsers() { ListOnlineUserVO result new ArrayList(); for (Object principal : sessionRegistry.getAllPrincipals()) { ListSessionInformation 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配置一个重定向地址前端登录页可以接收expired1参数并弹出提示“您的账号已在其他设备登录”。但要注意如果用户正在站内某个页面进行操作突然被重定向浏览器历史里会留下当前页面。我一般会在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 TokengetAllSessions(username)查不到会话principal是UserDetails对象不是String用户名遍历getAllPrincipals后匹配username或重写UserDetails的equalsSession过期后一直重定向循环expiredUrl指向了一个需要认证的页面expiredUrl必须指向permitAll的路径比如登录页并发会话控制不生效没有注册SessionRegistry或MaximumSessions配置位置错误确认SessionRegistry注册为Bean并注入sessionManagementSession销毁后SessionRegistry记录不清没注册HttpSessionEventPublisher注册ServletListenerRegistrationBean监听Session事件多实例下踢了这个节点另一个节点还放行SessionRegistryImpl是内存实现各实例独立引入Spring Session或实现Redis版SessionRegistryJWT被踢后refresh token还能换新token只作废了Access Token没处理Refresh Token在刷新接口增加踢人标记或Refresh Token版本号校验Redis里jti读取出来是乱码序列化器配置不对统一使用String序列化踢人接口权限没控制好直接暴露RestController没加权限限制在踢人接口上配置hasRole(ADMIN)并限制IP6.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里塞一条kickoutReasonJWT方案在Redis里以userId为key存一条login:kickout:reason:{userId}TTL设置10分钟被踢后前端拉取一次提示即可。我在实际项目中更推荐JWT方案配合版本号黑名单组合因为现在的前后端分离项目太普遍了Session方案在跨域、多端、移动端场景下都不够灵活。但如果你做的是传统服务端渲染的管理后台SessionRegistry方案依然是最快、最不出错的选择。技术选型没有绝对的对错先把会话存储方式想清楚剩下的事情就水到渠成了。
返回列表