
1. 项目概述多终端认证到底难在哪先说结论这两年做后端接口几乎每个系统都得碰认证授权。尤其是项目从单一Web端扩展到App、小程序、H5、桌面客户端之后认证这块立刻变成最容易出问题的地方。我们当时面临的场景很典型——用户可能在手机上刷着应用同时电脑网页还挂着后台甚至平板、公众号里也登录着同一个账号。如果沿用传统的Session共享方案要么被单点登录逻辑反复踢下线要么就得为一套会话同步机制操碎了心。最终我们选定的是JWT Spring Security 6的组合把多终端认证从“能不能登录”升级成“谁在什么地方登录、能不能管理这些会话”。这套方案的核心价值在于无状态、可扩展、每个终端拥有独立令牌互不干扰同时还能做到主动踢人、续签、验证码联动。这篇文章适合正在做或准备做Spring Boot项目的后端开发尤其是要同时支撑Web、移动端、桌面端等多场景登录的同学。我会把整个项目从设计到落地再到安全加固的过程梳理一遍针对Spring Security 6的配置变化、JWT在多终端下的会话模型、Token续签、SPA验证码联动以及网上各路文章中很少系统讲到的JWT漏洞与防御给出我们实测过、能直接落地的方案。文章里的代码都是Spring Boot 3 Spring Security 6 JWT的写法Java 17起步。2. 整体设计方案选型背后的思路拆解2.1 多终端认证的核心需求不仅仅是登录在设计这套系统之前我们先把需求捋了一遍。所谓“多终端认证”不是简单地在登录接口里发一个Token就完事它至少包含这几个层次的问题第一并发共存。同一账号在手机、浏览器、桌面端同时登录时彼此不能互相挤下线。这就意味着不能用一个全局的Token或Session覆盖另一个。每个终端必须持有独立凭证服务端能区分这个Token来自哪个设备。第二终端识别与展示。用户得知道当前账号在哪些设备上登录了最好能列出设备类型、登录时间、最近活跃时间并且支持“踢掉某台设备”。比如发现手机丢了远程把手机端的登录状态废掉网页端还能正常用。第三安全差异。不同终端的安全等级不一样。Web端有浏览器环境、Cookie或Header限制App端有本地存储风险桌面端可能长期在线。令牌的有效期、刷新策略、敏感操作校验强度都该有所区别。第四无状态与可控性的平衡。纯JWT天然无状态、好扩展但如果完全不落服务端状态就会出现“改密码后旧Token依然有效”的尴尬也没法实现“踢人下线”。所以要引入Redis做会话状态的辅助记录形成“JWT无状态认证 Redis有状态控制”的组合。这套设计里最关键的认知转变是JWT承载身份信息Redis承载会话生命周期。两者不是替代关系而是互补关系。JWT负责让鉴权过程不查库、不查Session快速放行Redis负责在需要撤销、续签、限流的时候提供“紧急刹车”能力。这个思路在我们后来的实际运行中几乎挑不出什么大毛病。2.2 技术选型为什么是Spring Security 6团队里也有同事提过认证逻辑能不能自己写拦截器搞定不引入Spring Security我的答案是如果只是给内部工具做个登录没问题但要支撑多终端、多策略、可扩展的认证体系自己写拦截器迟早要翻车。Spring Security经过这么多年的迭代已经不是一个简单的过滤器链框架它提供了一套完整的认证授权模型尤其是6.x版本之后配置方式大变样反而更清爽了。Spring Security 6有几件事是自研拦截器很难替代的认证过滤器链的编排能力。默认过滤器链里有很多现成的过滤器异常处理、会话管理、SecurityContext写入、匿名认证、Remember-Me等。JWT过滤器只需要把UsernamePasswordAuthenticationFilter这个位置卡住就能无缝嵌入整条链。方法级安全。PreAuthorize、Secured注解可以做接口粒度的权限控制多终端下不同角色的访问控制非常依赖这个能力自己写AOP虽然也能做但没有它成熟。安全响应头。默认注入X-Content-Type-Options、X-Frame-Options等一整套安全响应头在安全评审时能省掉不少麻烦。社区活跃、踩坑资料多。Spring Security 6发布后网上关于5.x的旧资料容易误导人但正经踩过坑、跑通流程的人都知道6.x的lambda DSL配置其实更容易阅读和维护。2.3 多终端会话模型从Session到JWT的迁移思考在动手写代码前我们在白板上画了不下五版会话模型图。最终确认的这个模型基本逻辑是这样的每个终端登录成功后服务端生成一个JWT令牌令牌中携带用户ID、终端类型、设备唯一标识比如设备号的哈希值、令牌唯一IDjti和签发时间。同时在Redis中以login:token:{jti}为Key记录令牌的短期信息以login:user:{userId}:sessions为Key维护该用户当前有效的全部终端会话列表。这个Key的Value是一个Hash或Set结构里面放的是终端信息与jti的映射。请求进到后端时JWT过滤器先解析令牌验签再检查Redis中这个jti是否仍然有效。如果Redis里没有这个Key说明令牌已被注销或者过期了直接拦截。如果有效把用户信息与终端信息塞进SecurityContext放行到Controller。这个模型的可贵之处在于JWT本身解决了“我是谁”的问题Redis解决了“你现在还有没有资格”的问题。即使JWT还没到过期时间只要Redis里会话被删掉立刻失效。这完美破解了“JWT一旦签发就无法撤销”的经典痛点。3. 核心流程实现认证链路的完整代码落地3.1 环境准备与依赖配置项目基础环境Spring Boot 3.2.x、Spring Security 6.2.x、JDK 17、Redis 7。以下是核心依赖的配置我直接放Maven坐标dependencies { implementation org.springframework.boot:spring-boot-starter-security implementation org.springframework.boot:spring-boot-starter-web implementation org.springframework.boot:spring-boot-starter-data-redis implementation org.springframework.boot:spring-boot-starter-validation implementation io.jsonwebtoken:jjwt-api:0.12.5 runtimeOnly io.jsonwebtoken:jjwt-impl:0.12.5 runtimeOnly io.jsonwebtoken:jjwt-jackson:0.12.5 implementation cn.hutool:hutool-captcha:5.8.26 }这里要提一下jjwt选型。很多人还在用0.9.x老版本的jwt工具包那个版本依赖Jackson版本冲突严重而且API设计比较粗糙。0.12.x版本的API重构过用起来舒服不少密钥相关操作也更明确。另外验证码工具我在Hutool的captcha和自研之间犹豫过最后选了Hutool原因是它内置了算术、线条干扰、扭曲等几种验证码生成器开箱即用省得自己处理图形绘制。Spring Boot的application.yml里还有两块配置很关键JWT的密钥与有效期配置以及Redis连接配置。密钥这块特别提醒一下不要在配置里放固定密钥尤其是不要提交到Git仓库。我们用的是环境变量注入的方式本地开发用默认值兜底线上强制通过env传入后面安全加固部分我会细说。3.2 JWT令牌生成把终端信息塞进载荷JWT本质上就是一个三段式的字符串Header. Payload. Signature。我们团队在实际项目里把这三段分别理解为算法声明、身份信息、防篡改指纹。生成Token的时候核心是设计Payload里的Claims。我建议至少包含这些字段private String createToken(LoginUser loginUser) { String jti UUID.randomUUID().toString().replace(-, ); MapString, Object claims new HashMap(); claims.put(uid, loginUser.getUserId()); claims.put(device, loginUser.getDeviceType()); claims.put(deviceId, loginUser.getDeviceId()); claims.put(role, loginUser.getRoleCodes()); claims.put(jti, jti); return Jwts.builder() .claims(claims) .subject(String.valueOf(loginUser.getUserId())) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() expireTime)) .signWith(secretKey) .compact(); }这里有几个细节值得展开jti是必须的。每个令牌的唯一ID是后面做Redis会话管理、强制下线、续签的唯一凭证。没有jti的JWT就像没有车牌的车出事都找不到是谁。不要放敏感数据。比如手机号、身份证号、真实姓名放进Payload等于明文广播给任何能截获Token的人。放在Redis里JWT里只放用户ID和角色编码就够了。终端类型用数字枚举或者短字符串。device字段我们用WEB、APP、MINIAPP、DESKTOP这种字符串虽然Token会变大一点点但可读性好排查问题时一眼能看出来。secretKey的定义。jjwt 0.12.x要求密钥至少256位32字节HS256算法下长度不够会直接抛异常。建议用Keys.hmacShaKeyFor(bytes)来构造不要用字符串随便塞。3.3 登录接口验证码校验与多终端发号登录接口是整个链条的起点我们把它拆成三步校验验证码、校验账号密码、签发令牌并写入会话。第一步验证码校验。考虑到SPA项目就是前后端分离的前端应用中验证码是用户先获取、再填写、最后随账号密码一起提交的逻辑我们设计的接口是GET /auth/captcha先返回Base64格式的验证码图片和验证码ID到前端同时把验证码答案存进RedisKey是captcha:{captchaId}有效期5分钟。登录时后端先取出Redis里存的答案并删除一次性使用再和用户提交的验证码比对忽略大小写。这里有个小细节容易踩坑验证码无论校验成功还是失败都必须在Redis里立刻删除防止同一个验证码被暴力重放。我们曾经因为只在失败时没删导致攻击者拿到一个验证码可以反复尝试密码这等于验证码形同虚设。第二步账号密码校验。用户提交用户名、密码、验证码ID、验证码答案、设备类型、设备ID。密码用BCryptPasswordEncoder校验。注意这里不要自己拼接SQL或轻量级比较直接用Spring Security提供的PasswordEncoder它对BCrypt的支持非常成熟校验强度在线。第三步签发令牌并写会话。用户通过校验后构建LoginUser对象用户ID、用户名、设备类型、设备ID、角色列表生成Token然后把会话信息写入Redis。核心代码如下public LoginResult login(LoginRequest request) { // 1. 校验验证码 validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); // 2. 校验账号密码 UsernamePasswordAuthenticationToken authToken new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); Authentication authentication authenticationManager.authenticate(authToken); // 3. 构建登录用户签发Token LoginUser loginUser buildLoginUser(authentication, request.getDeviceType(), request.getDeviceId()); String token createToken(loginUser); saveSession(loginUser, token); return LoginResult.success(token, loginUser.getSessionInfo()); }saveSession方法负责两件事一是以jti为Key写入login:token:{jti}设置过期时间和JWT的过期时间一致二是维护login:user:{userId}:sessions这个Hash结构field是{deviceType}:{deviceId}value是jti和登录时间的序列化字符串。这样我们在“我的设备”页面就能把这个Hash全部捞出来渲染成设备列表。3.4 JWT认证过滤器无状态请求的守门员Spring Security的JWT处理核心是一个OncePerRequestFilter。它的特点是保证在一次请求中只执行一次避免被过滤器链多次调用。我们写的JwtAuthenticationFilter逻辑大概是这样的public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { String token resolveToken(request); if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { if (jwtTokenProvider.validateToken(token)) { Long userId jwtTokenProvider.getUserId(token); String jti jwtTokenProvider.getJti(token); // 检查Redis中jti是否存在 if (redisService.hasKey(RedisKeys.buildTokenKey(jti))) { LoginUser loginUser userService.getLoginUser(userId); if (loginUser ! null loginUser.isEnabled()) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } } chain.doFilter(request, response); } }这里面有几个判断顺序值得注意先验签再查Redis。JWT验签是纯本地计算耗时极低如果验签都过不了根本没有查Redis的必要。Redis查的是jti不是用户ID。用jti作为Key才能精确撤掉单个终端的会话如果只按用户ID查一个用户有多个终端时就没法精准控制。SecurityContextHolder的清理。过滤器只管往上下文里放Authentication但不要忘了在过滤器链结束后清空上下文。因为当前线程可能被线程池复用不清空会造成身份串线。我们在OncePerRequestFilter里其实不用太担心这个Spring Security的SecurityContextHolderFilter会在每轮请求结束自动清理但在异步处理场景下要特别注意。3.5 Security 6 安全配置告别WebSecurityConfigurerAdapterSpring Security 5时代大家惯用继承WebSecurityConfigurerAdapter的方式Spring Security 6里这种方式彻底没了。现在的要求是直接声明SecurityFilterChain Bean配合lambda DSL配置。我们项目的配置类核心部分长这样Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/auth/captcha, /auth/login, /auth/refresh, /error).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .exceptionHandling(ex - ex .authenticationEntryPoint(restAuthenticationEntryPoint) .accessDeniedHandler(restAccessDeniedHandler) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } }这里哪些点容易踩坑我按实际优先级列一下CSRF必须显式关闭。无状态JWT的场景天然不需要CSRF防护因为不存在基于Cookie的浏览器自动携带凭证机制。Spring Security 6默认开启CSRF很多从5.x迁移过来的项目会突然发现POST接口全部403八成就是忘了关。requestMatchers替换antMatchers。6.x里authorizeRequests()变成authorizeHttpRequests()antMatchers换成requestMatchers写法上有硬性要求。不换的话编译期可能不报错但运行期规则不生效这种隐性坑最坑人。异常处理尤其重要。未登录访问接口时要返回JSON格式的401而不是跳转到登录页。我们实现了AuthenticationEntryPoint的匿名内部类往response里写入一个统一错误结构前端拿到401就知道要跳登录。权限不足返回403也是同理。这段配置还有一个细节容易被忽略放行路径要精确设计。我们把登录、验证码、刷新令牌的接口设为permitAll其余全部走认证。如果你有Swagger等接口文档也需要在放行列表里加进去否则开发环境调试接口也是一堆401。4. 高级功能实现续签、验证码与终端管理4.1 Token续签滑动窗口方案不让用户莫名掉线JWT设置过期时间是个两难问题时间太短用户正在写文章突然掉线体验极差时间太长Token泄露后的风险窗口太大。我们的解法是“短令牌 滑动续期”。具体做法登录签发的Token有效期设为30分钟但增加一个/auth/refresh接口。当客户端在请求时发现服务端返回的响应头中带有X-Token-Renewed: true表示这个Token快过期了已经自动续签过或者客户端自己判断Token剩余有效期不足10分钟时就主动调用刷新接口拿旧Token换新Token。服务端刷新逻辑是这样的收到旧Token后先验签再检查其jti在Redis中是否存在。若不存在说明Token已被注销拒绝刷新。若存在生成新Token使用新的jti删除旧的jti对应的Redis记录写入新jti记录。这个方案本质上是“滑动窗口续期”只要用户一直在活跃他的Token就不会在30分钟整点被卡掉而是不断往后顺延。public RefreshResult refresh(String oldToken) { // 校验并解析旧Token Claims claims jwtTokenProvider.parseToken(oldToken); String oldJti claims.get(jti, String.class); String redisKey RedisKeys.buildTokenKey(oldJti); // 如果Redis里没有说明这个Token已失效或者已被续签过拒绝 if (!redisService.hasKey(redisKey)) { throw new TokenExpiredException(令牌已失效请重新登录); } // 构建新的LoginUser信息 LoginUser loginUser buildLoginUserFromClaims(claims); String newToken createToken(loginUser); // 删除旧jti写入新jti redisService.delete(redisKey); saveSession(loginUser, newToken); return RefreshResult.success(newToken); }注意这里面有个并发问题如果同一时刻两个请求同时带旧Token来刷新可能有一个成功一个失败。我们最开始的方案没处理这个线上出现了“用户端偶发被踢下线”的投诉。后来排查发现就是并发刷新导致旧jti先被删第二个请求自然失败。解决办法是给刷新操作加一个分布式锁Key是refresh:lock:{userId}:{deviceId}确保同一终端同一时刻只有一个刷新请求能成功。锁的过期时间设为5秒足够因为刷新操作本身很快。4.2 SPA项目中的JWT验证码实现前后端联调的完整链路现在SPA项目基本都是前后端彻底分离前端通过Ajax调用接口没有服务端渲染的页面去承载Session里的验证码。所以验证码这块必须设计成无状态流程。我们的流程是这样的第一步前端进入登录页时请求GET /auth/captcha。后端生成一张图片验证码同一个Base64字符串返回给前端用于img标签显示同时把验证码ID和答案存进Redis。返回结构类似{ captchaId: b0f1e3d2-..., captchaImage: data:image/png;base64,iVBORw0KGgo..., expiresIn: 300 }第二步用户输入账号、密码、验证码前端把captchaId和用户填写的验证码一起POST到/auth/login。第三步后端校验验证码。这里有个顺序问题先校验验证码还是先校验账号密码我们倾向于先校验验证码原因是验证码的校验成本极低如果验证码不匹配就没必要去走BCrypt密码校验那种高计算量的操作了同时还能规避很多恶意批量撞库的请求打爆数据库连接池。但要注意先校验验证码有个副作用攻击者可以通过错误验证码来消耗服务端的Redis连接。所以我们在验证码接口上叠加了简单的限流比如同一个IP每分钟最多获取5次验证码。还有个小细节SPA里的验证码刷新。前端点击验证码图片可以重新获取这时候旧captchaId在Redis里会残留到过期时间。我们在生成新验证码时会顺便删除该IP之前未使用的captchaId记录避免Redis里堆满垃圾Key。Hutool的CaptchaUtil生成验证码非常省事LineCaptcha captcha CaptchaUtil.createLineCaptcha(130, 48, 4, 150); String code captcha.getCode(); String base64 captcha.getImageBase64Data();它生成的Code是纯数字和字母忽略大小写比对。我们在测试时发现有个坑Hutool默认生成的验证码容易把“0”和“O”、“1”和“I”混淆用户经常输错。后来在生产环境我们调研了一圈最终在MathCaptcha算术验证码与字符验证码之间选择了LineCaptcha并做了字符黑名单过滤把O、I、L等容易混淆的字符剔除掉。4.3 多终端会话管理查看设备、强制下线、单终端限制搞定了登录和续签多终端的重头戏在于“会话管理”。我们做了三个能力查询已登录设备、强制某设备下线、限制最大登录终端数。查询已登录设备的核心就是从Redis中读取login:user:{userId}:sessions这个Hash。这个Hash里每一个field是一台设备value是登录时间和jti。为了让前端展示更友好value里还会带上Ip归属地、设备名称、最近活跃时间。设备名称怎么拿登录接口里传参前端通过UA解析出浏览器和操作系统我们自己拼一个字符串传过来。强制下线接口是DELETE /auth/device/{deviceId}。后端拿着当前登录用户的userId从Hash里找到field对应的jti然后删除login:token:{jti}和Hash里的field。这样该设备下次请求时JWT过滤器发现Redis里没有jti就会直接返回401。前端拿到401统一跳登录页用户感知就是“账号在别处被踢了”或“登录状态已失效”。限制最大登录终端数这块我们做成可配置的。业务上默认限制最多5个终端同时在线安卓端、iOS端、Web端、桌面端总共加起来不能超过5个。超过限制时是踢掉最久没活跃的那台还是拒绝新登录我们选择了“挤掉最旧、允许最新”——因为用户通常最新使用的设备就是当前设备体验上更合理。实现上就是在saveSession时如果Hash的size已经达到上限就遍历这个Hash找到最早登录的那个field先删掉它的jti再插入新会话。这里有个隐含的坑需要提醒同一个用户同一个终端类型只能保留一条会话。比如用户在同一个浏览器的无痕模式和普通模式分别登录如果不做去重Hash里可能出现两个WEB会话。我们的策略是field用{deviceType}:{deviceId}拼接deviceId由前端生成并存储同一浏览器每次登录应该复用同一个deviceId。这样同一个浏览器刷新登录时后登录的会直接覆盖前一个的会话记录。4.4 动态权限刷新角色变了怎么办多终端系统还有一个隐蔽需求用户角色或权限变更时已签发的JWT如何感知JWT的设计决定了它在过期之前是“自包含”的角色信息一旦写进去服务端无法直接修改。我们的处理方法是把权限相关的变化落进Redis。具体思路是在login:user:{userId}:profile这个Key里冗余一份用户的当前角色列表。JWT过滤器在做认证时从JWT里解析出userId再从Redis里读取最新的角色列表以Redis里的为准来构建Authentication对象。这样改密码、被封号、角色调整等场景都能做到秒级生效而不必等待JWT自然过期。代价是多了一次Redis读取但对整体性能影响很小Redis的读取通常是亚毫秒级的。实现上updateUserProfile(userId)方法里会重新构建profile哈希。JwtAuthenticationFilter里把原本从claims取角色改成从Redis取角色ListString roles redisService.getHashValues(RedisKeys.buildProfileKey(userId));这个设计带来的一个额外好处是即便JWT里的角色被篡改只要Redis里的角色列表未被篡改篡改也是无效的。当然前提是JWT验签本身是安全的。5. 安全加固JWT漏洞实战排查与防护5.1 这些JWT漏洞你八成遇到过一两个网上关于JWT漏洞总结的文章不少但很多偏CTF、偏渗透真正落到实际项目里的不多。我整理了我们在开发、评审、甚至上线后被安全扫描揪出来的几类问题第一类算法混淆攻击。攻击者把Token的Header里alg字段改成none或HS256如果服务端代码里只校验签名通过与否、不校验算法类型就可能被绕过。更隐蔽的是RS256公钥被当成HMAC密钥的变种服务端用公钥验签RS256攻击者把算法改成HS256并且用同一个公钥其实是公开的去签名服务端如果用原来的公钥字节去验签HS256就能验签通过。防御方法很简单解析Token时固定校验算法必须为HS256jjwt的parser()方法会读取Header的alg我们增加一层显式判断任何非预期算法直接拒绝。第二类弱密钥与密钥硬编码。之前看到有的项目把secret直接写在application.yml里甚至提交到Git仓库。一旦仓库泄露攻击者就能伪造任意用户的Token。弱密钥也一样HS256秘要至少32字节但网上很多示例密钥是“secret”、mysecretkey123这种用字典直接爆破秒破。生产环境我们强制密钥从环境变量读取且长度不低于48字节每半年轮换一次。第三类Payload里的敏感信息泄露。有个老系统的JWT里放了手机号和邮箱被安全团队扫描出来。JWT的Payload是Base64编码不是加密任何人拿到你的Token用在线工具一解码就能看到明文。所以Payload里只放必要标识用户ID、终端信息、角色编码其余信息一律通过Redis或数据库检索。第四类过期Token的处理不严格。有的项目只在验签时做一次过期判断但如果系统时间被篡改或者服务器和客户端时钟不同步就会出现错误判定。更严重的是过期Token如果jti还在Redis里且Redis没有设置过期时间理论上会一直可用。我们对Token设置了两个层面的过期JWT自带的exp字段30分钟和Redis Key的TTL同步设置30分钟。两者互相兜底即使exp校验逻辑有漏洞Redis也会兜住。第五类Token泄露与重放。JWT放在请求头里传输如果前端存localStorage里一旦XSS脚本注入Token就会被偷走。我们的建议是如果是Web端且使用HttpOnly Cookie承载Token能防住XSS窃取但多终端里App端没有Cookie机制只能在本地存储里放Token那就更需要做好CI/CD流水线上的XSS扫描和CSP策略。重放攻击的话服务端无法像Session那样天然防重放我们能做的是对敏感操作改密、支付叠加短时有效的验证码或动态口令。5.2 防御清单与安全基线上线前逐条过项目上线前我们整理了一个安全自查清单逐条打勾。你直接拿过去用也行检查项具体要求状态密钥管理生产环境密钥必须来自环境变量或KMS长度≥48字节禁入配置文件和Git历史必改算法锁定解析JWT时显式校验加密算法仅允许HS256或RS256视实现而定必改Payload最小化只放userId、jti、终端信息、角色编码严禁放手机号、身份证、地址等个人数据必改过期策略JWT的exp和Redis TTL必须同时设置过期时间根据业务风险差异化配置必改注销机制必须支持按jti删除Redis记录实现强制下线不能只依赖JWT过期必改接口限流登录接口、验证码接口、刷新接口必须做限流防止撞库、爆破、Token更换风暴建议传输安全生产环境强制HTTPSWeb端Token尽量存入HttpOnly Cookie建议异常信息隔离认证失败、签名错误、Token过期返回统一的401消息不要暴露“签名不匹配”之类细节建议关于Token续签的安全问题单独提醒一句刷新接口同样要限流否则攻击者拿到一个泄露的Token可以不断刷新成一个新Token相当于变相延长了攻击时间窗口。我们给/auth/refresh单独加了按userIddeviceId维度的限流每分钟最多5次消息队列里再叠加一层IP限流。5.3 常见漏洞排查思路如果上线后怀疑JWT相关出了问题我建议的排查顺序是先看请求日志里401的比例。如果突然有大量401大概率是密钥轮换导致旧Token全部失效这不算漏洞但要确认业务方是否接受所有用户强制重新登录。再看Redis里login:token:*的Key数量和TTL分布。如果出现大量长期未过期的Key说明代码里saveSession时忘了设置过期时间或者续签时旧Key删除失败。我们曾遇到过续签时Redis删除旧Key失败的情况——因为用了同一个连接事务执行顺序搞反旧Key没删掉结果Redis里堆积了大量“幽灵会话”。最后检查异常堆栈中有没有JwtException、SignatureException的大规模抛出。如果有大概率有人在批量伪造Token配合安全组做来源IP封禁。5.4 多终端会话的数据一致性细节最后补一个容易被忽略的点Redis中会话信息的原子更新。强制下线、自动挤掉最旧会话、续签删除旧jti这些操作必须保持原子性。我们用Lua脚本来保证login:user:{userId}:sessionsHash和login:token:{jti}Key的一致性。比如强制下线一个设备Redis操作如果“先删了Hash里的field再去删token Key”中间进程挂了就会出现用户会话列表里看不到这台设备但该设备的Token却还能请求。Lua脚本在一个原子操作里完成两个删除彻底避免这个中间态。-- KEYS[1]: login:token:{jti} -- KEYS[2]: login:user:{userId}:sessions -- ARGV[1]: field(deviceType:deviceId) if redis.call(EXISTS, KEYS[1]) 1 then redis.call(DEL, KEYS[1]) end redis.call(HDEL, KEYS[2], ARGV[1]) return 1这类细节在实际项目中特别能体现出水平。很多团队做完第一版能跑通就收工了但一到线上并发一上来各种脏数据、幽灵会话就冒出来了。把会话数据的每个变更操作都放到Lua脚本或事务里虽然代码量没增加多少但线上少了一大类排查问题的时间。6. 常见问题与排查技巧实录6.1 高频问题速查表项目开发到上线这段时间团队在群里讨论最多的问题我整理成一个速查表。这些问题几乎每个做Spring Security 6 JWT的团队都会碰上问题现象可能原因解决方案所有POST接口返回403Spring Security 6默认CSRF开启未关闭http.csrf(AbstractHttpConfigurer::disable)登录接口返回404配置了permitAll但路径拼写不一致检查requestMatchers里的路径和Controller实际路径是否精确匹配自定义过滤器不生效过滤器没有被加入到SecurityFilterChain在addFilterBefore里挂载并确认没有重复注册FilterToken签发生效但请求仍401过滤器里异常被吞掉SecurityContext未写入检查过滤器里是否catch了JwtException却没有抛出或设置响应刷新后旧Token还能用续签时未删除旧jti的Redis记录确认续签逻辑里在写入新jti前先删除旧jti用户角色变更后权限未生效JWT里角色信息过期改成从Redis读取角色或让用户重新登录多终端互相挤下线会话Hash按userId维度覆盖或全局统一Token确保会话Key包含userIddeviceTypedeviceId维度同一个验证码可以反复请求登录验证码校验成功后未在Redis删除无论成功失败都要删除验证码KeyApp端一直正常Web端偶发掉线Web端Cookie与Token双份逻辑冲突统一Token获取来源检查resolveToken方法是否遗漏Header名6.2 排查工具和调试技巧JWT相关的排查我平时依赖三个工具一是jwt.io的Debugger。用来快速解码一个Token看它的Payload里有什么、过期时间是什么时候。注意线上Token不要随便粘贴到不信任的第三方网站可以自己写一个本地解码脚本核心就是Base64解码中间那段。二是Redis的Keys命令。开发环境查看login:token:*和login:user:*确认会话有没有正确写入和清除。生产环境不要用KEYS命令会阻塞Redis要用SCAN代替。三是Wireshark或浏览器DevTools。看完整请求链路里Token是怎么传递的验证是否走了HTTPSHeader名、值有没有拼错。调试时我自己习惯开一个临时配置把JWT过滤器的日志级别调到DEBUG。Spring Security的日志会打出每一步过滤器决策配合Token解析日志基本能定位80%的问题。上线前记得把这个日志级别调回INFO不然一天几个GB日志不是开玩笑。6.3 经验心得多终端认证项目真正值钱的部分做完整套系统我最深的感受是JWT和Spring Security 6本身都不是新技术网上教程一抓一大把但真正决定项目成败的是那些“教程不会告诉你”的细节。比如多终端会话怎么建模、怎么做到既能踢人又能续签、角色变更怎么秒级生效、Redis和JWT怎么分工——这些问题才是多终端认证系统的核心难点。另一个心得是不要把认证逻辑搞得太花哨。我们最初还尝试过给每种终端设置不同的Token过期时间Web端30分钟、App端7天、桌面端15天。后来发现太复杂了登录、续签、下线都要分别处理搞得团队焦头烂额。最后收敛为统一30分钟过期加滑动续签App端只要在活跃期内就会被不断续签用户几乎感觉不到过期。把“长Token”转为“频繁续签”复杂度和安全性反而都更可控。如果你是在做类似的项目我建议先跑通最简版本登录发Token、过滤器校验、Redis会话管理。跑通之后再逐步加续签、终端列表、强制下线、限流这些“外挂能力”。千万别一上来就追求满配认证系统是所有业务的门神门神如果没立稳后面所有功能都会在线上抖三抖。