多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring Security + JWT 实战:过滤器链与无状态登录认证全解析

Spring Security + JWT 实战:过滤器链与无状态登录认证全解析 1. 为什么是Spring Security JWT先理清两个老朋友的分工再说集成前后端分离的SPA项目但凡做到登录认证这一步十个人里有九个会在Spring Security和JWT的组合上先卡一阵壳。我也一样最早想偷懒用拦截器ThreadLocal自己写一套结果用户权限、会话管理、接口放行规则越加越多代码烂成一锅粥最后老老实实回到Spring Security这条正路上来。在动手写代码之前我强烈建议先花十分钟搞清楚一个问题Spring Security和JWT它们各自到底在解决什么。很多人集成不好就是因为把这两件事混成了一件事。1.1 Spring Security的真正内核不是拦截器Spring Security底层虽然也用过滤器Filter但它真正厉害的地方是一整套过滤链Filter Chain机制。每个请求进来会依序经过一串过滤器比如CSRF过滤器、会话管理过滤器、匿名认证过滤器、异常处理过滤器再到你的业务接口。每一环只干一件事环环相扣最后决定这个请求到底放不放行。理解这一层之后很多怪问题就有了答案。比如你写了PreAuthorize(hasRole(ADMIN))却完全不生效很可能是授权配置的过滤器顺序没配对再比如你明明放行了/api/auth/login但请求进来还是401多半是放行规则写在了anyRequest().authenticated()之后Spring Security压根没读到那条规则。用生活里的场景打个比方Spring Security就是一套完整的场馆安保系统大门口安检、走廊巡查、贵宾室放行层层都有专人负责。而JWT只是你发给访客的一张盖章通行证。1.2 JWT解决了Session模式的哪些痛点JWT全称JSON Web Token说人话就是把用户身份信息塞进一串经过签名的字符串里客户端每次请求把这串字符串带回来服务器验一下签名没问题就认这个身份。传统Session模式的问题做过分布式的人应该深有体会用户登录信息存在服务器内存里负载均衡到另一台机器就找不到了还得引入Session共享、粘性会话之类的东西。而且Session天然带状态服务器得一直记着这个用户登录过一旦会话失效或者服务器重启用户的登录态就全没了。JWT把这套逻辑整个倒过来服务器不存任何会话信息身份凭证全在客户端手里。服务器只负责两件事——签发token的时候用密钥签名收到token的时候用密钥验签。签名对得上有效期没过我就认你。这就是无状态认证天然适合横向扩展。1.3 它们俩组合起来到底是在干什么Spring Security负责的是认证流程和访问控制的框架JWT负责的是用户凭证的生成、传递和验真。整合之后大致是这么个感觉用户登录成功后Spring Security完成用户名密码的校验然后我们手动生成一个JWT返回给前端后续每个请求前端带着JWT过来我们写一个过滤器提前把token解析出来塞进Spring Security的上下文里让框架知道当前请求是哪个用户、他有什么权限。所以这个组合的本质是用Spring Security的过滤器链做安全防线用JWT做无状态凭证。下面我会把从零搭建的完整过程一步步拆开顺便把验证码登录、Token续签和那些年踩过的安全坑一起讲透。2. 动手前的概念准备认证、授权、安全上下文三层关系不能乱直接上手写代码容易但如果不把下面三个概念理清出了错都不知道去哪儿排查。我见过太多同事在SecurityContextHolder.getContext()拿不到用户的时候一脸懵其实就是没搞懂Spring Security内部的数据流转。2.1 Authentication和Authorization先分清楚这两个词长得像实际是两回事。Authentication叫认证解决的是你是谁的问题也就是登录校验Authorization叫授权解决的是你能干什么的问题比如某个接口只有管理员能调。在Spring Security里认证成功后的结果会封装成一个Authentication对象里面带着用户信息Principal、凭证Credentials和权限列表Authorities。授权阶段就是拿这个Authentication里的权限去和接口要求的权限做匹配。JWT本身主要解决认证凭证的问题但它也能把用户的角色权限信息一起放进去。我在项目中会把角色列表放进token的claims里这样过滤器解析完token直接就能构建出带权限的Authentication对象授权判断一条龙走完。2.2 SecurityContextHolder里到底存了什么SecurityContextHolder是Spring Security的一个静态容器底层是ThreadLocal——说白了每个线程自己保存一份数据互不干扰。当过滤器链执行到你的过滤器时你把认证信息塞进去后面的Controller里通过SecurityContextHolder.getContext().getAuthentication()就能拿到当前登录用户。这里有个微观层面的细节值得注意ThreadLocal里存的数据在请求结束之后必须清掉否则线程池复用线程时会串号。Spring Security的SecurityContextPersistenceFilter默认会在请求结束清理上下文但如果你写的是自定义过滤器手动设置认证信息后一定要记住这个坑在异步处理场景下尤其明显。2.3 自定义过滤器到底插在哪一环Spring Security的过滤链是有顺序的我们自定义的JWT认证过滤器通常插在UsernamePasswordAuthenticationFilter之前。原因很简单UsernamePasswordAuthenticationFilter是处理表单登录的它会尝试从请求参数里取用户名密码做认证而我们的JWT方案中请求参数里没有用户名密码只有一个token。所以必须在它之前先把token解析成认证信息放进上下文让后面的过滤器以为用户已经登录过了。在Spring Security 6.x的写法中我们不用再继承什么WebSecurityConfigurerAdapter了直接定义SecurityFilterChain的Bean用Lambda风格配置过滤链。下面进入实操我把代码一步一步贴出来并解释每一步为什么这么写。3. 实操完整搭建一套基于JWT的Spring Security认证环境我这边用的是Spring Boot 3.x Java 17 Spring Security 6.xJWT库选了JJWT 0.11.5。如果你还在用Spring Boot 2.x配置写法会有些差异我会在关键位置标注出来。3.1 引入依赖和基础配置pom.xml里除了spring-boot-starter-security之外还需要加入Redis依赖验证码和后续黑名单要用、JJWT三个包以及验证码生成工具我用Hutool的captcha模块省得自己画图dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-captcha/artifactId version5.8.25/version /dependencyJWT密钥我有两点强烈建议第一长度至少32个字符因为HS256算法要求密钥至少256位第二从配置中心读取不要硬编码在代码里更不要提交到Git仓库。我见过有人直接把jwt.secret写在application.yml里上传到公开仓库几秒钟就被扫描工具扒出来整个系统等于裸奔。3.2 编写JWT工具类生成、解析、校验JWT工具类是整个认证体系的心脏我习惯把生成、解析、校验三个方法都放在一个组件里Component public class JwtUtil { private final SecretKey key; private final long expiration; public JwtUtil(Value(${jwt.secret}) String secret, Value(${jwt.expiration}) long expiration) { this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.expiration expiration; } public String generateToken(String username) { Date now new Date(); return Jwts.builder() .setSubject(username) .setIssuedAt(now) .setExpiration(new Date(now.getTime() expiration)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String getUsernameFromToken(String token) { return parseClaims(token).getSubject(); } public boolean validateToken(String token) { try { parseClaims(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } private Claims parseClaims(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }有一个细节我提醒一下JJWT在解析时会自动校验exp过期时间过期会抛ExpiredJwtException签名错误会抛SignatureException这些都是JwtException的子类。所以只捕获JwtException就够了不要因为图省事去捕获所有异常那样会把空指针之类的问题也吞掉排查时很难受。3.3 核心过滤器JwtAuthenticationFilter过滤器的作用是每个请求进来尝试从请求头取token有效就解析出用户信息构建Authentication塞进SecurityContextHolder然后放行无效就不设置任何上下文让后面的匿名认证机制和授权逻辑自然处理。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) { this.jwtUtil jwtUtil; this.userDetailsService userDetailsService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (StringUtils.hasText(token) jwtUtil.validateToken(token)) { String username jwtUtil.getUsernameFromToken(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }继承OncePerRequestFilter而不是普通的Filter目的是保证一次请求只执行一次过滤逻辑。在Servlet的过滤器链里如果存在转发forward或包含include普通Filter会被执行多次而OncePerRequestFilter通过内部标记避免重复解析token。这个类名虽然长但你应该每次都用它。还有一点loadUserByUsername每次请求都会查一次数据库如果性能敏感可以考虑把用户的基础信息和角色一起放进token的claims里解析时直接构建Authentication跳过数据库查询。代价是用户角色变更后旧token在过期前仍然带着旧权限。我一般选择中间策略角色列表放token用户基础信息每次查库这样兼顾了权限时效和查询开销。3.4 SecurityConfig配置与异常处理万事俱备就差把过滤器挂到链条上Configuration EnableWebSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/captcha, /api/auth/refresh).permitAll() .anyRequest().authenticated()) .exceptionHandling(ex - ex .authenticationEntryPoint(new RestAuthenticationEntryPoint()) .accessDeniedHandler(new RestAccessDeniedHandler())) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }两个注意点。第一个csrf必须禁用。因为CSRF防护针对的是基于Cookie的Session认证而我们在请求头里自定义携带token不依赖浏览器自动带Cookie所以CSRF防护在这个场景下没有意义还容易拦截掉正常请求。第二个.cors(Customizer.withDefaults())不是随便写的。SPA项目前后端通常不同源如果不在Spring Security里配置CORS请求会被浏览器拦在预检阶段。具体源、方法、请求头的白名单配置要单独定义CorsConfigurationSource的Bean这里先开个口子后面详细展开。异常处理里RestAuthenticationEntryPoint是处理未登录访问受保护接口的返回401 JSONRestAccessDeniedHandler处理已登录但权限不足的返回403 JSON。这两个类实现接口的代码量不大但一定要写成JSON输出否则前端拿到的是默认的HTML错误页解析逻辑直接崩。4. 登录接口实战验证码校验、用户信息更新、JWT令牌返回全流程很多讲Spring Security JWT的教程登录接口就一句调用AuthenticationManager认证然后返回token。但真实项目里登录还要处理验证码、记录登录日志、更新用户登录信息、冻结连续输错密码的账号。这一段我把完整链路写出来。4.1 登录流程的整体设计我把登录接口的流程拆成五步步骤动作结果1前端请求验证码接口后端生成图片和缓存Key返回图片Base64和captchaKey2用户填写用户名、密码、验证码后提交请求参数包含username、password、captchaKey、captchaCode3后端先校验验证码失败直接返回验证码错误不触发密码认证4AuthenticationManager校验用户名密码失败返回用户名或密码错误可记录失败次数5登录成功更新登录信息生成并返回JWT返回token 用户基础信息这里我把验证码校验放在密码认证之前一是防止自动化脚本用暴力破解来撞库二是验证码是一次性的一旦校验成功立刻从Redis删除避免同一个验证码被反复重放。4.2 验证码的生成和校验生成验证码我用Hutool的LineCaptcha它的优点是自带干扰线和扭曲字符防机器识别效果比纯数字好一些GetMapping(/captcha) public MapString, String captcha() { LineCaptcha captcha CaptchaUtil.createLineCaptcha(120, 40, 4, 30); String code captcha.getCode(); String uuid UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(captcha: uuid, code, 5, TimeUnit.MINUTES); MapString, String result new HashMap(); result.put(captchaKey, uuid); result.put(captchaImage, captcha.getImageBase64Data()); return result; }有经验的读者应该注意到这个接口本身是要放行的否则用户还没登录根本拿不到验证码。所以在SecurityConfig里我把/api/auth/captcha加进了permitAll()列表。验证码校验时还有两个容易被忽略的细节第一验证码在Redis里的Key最好带上业务标识避免和其他缓存数据冲突第二校验通过后必须立即删除缓存这样即使攻击者截获了这个验证码也无法二次使用。4.3 登录核心逻辑认证、签发JWT、更新登录信息登录接口的代码我尽量精简但该有的环节一个不少PostMapping(/login) public ResponseEntityLoginResponse login(RequestBody LoginRequest request, HttpServletRequest httpRequest) { // 1. 校验验证码 String cachedCode redisTemplate.opsForValue() .get(captcha: request.getCaptchaKey()); if (cachedCode null || !cachedCode.equalsIgnoreCase(request.getCaptchaCode())) { throw new BizException(验证码错误或已过期); } redisTemplate.delete(captcha: request.getCaptchaKey()); // 2. 用户名密码认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword())); // 3. 生成JWT User user (User) authentication.getPrincipal(); String token jwtUtil.generateToken(user.getUsername()); // 4. 更新用户登录信息 userService.updateLoginInfo(user.getId(), getClientIp(httpRequest)); // 5. 返回token和用户信息 return ResponseEntity.ok(new LoginResponse(token, user.getUsername(), user.getNickname(), user.getRoles())); }这里有个概念值得展开authenticationManager.authenticate()内部全套流程是Spring Security自动执行的它调用UserDetailsService.loadUserByUsername拿用户再用PasswordEncoder.matches()比对密码比对成功才返回认证对象。所以你必须实现自己的UserDetailsService并注册成Bean否则Spring Security找不到用户数据源认证会直接失败。失败次数的统计我放在UserDetailsService里做如果密码匹配失败会把Redis里该用户的失败计数加一超过5次就锁定账号15分钟。这个逻辑代码量不大但能挡住绝大部分暴力破解尝试值得写在登录设计里。4.4 更新用户登录信息别只更新一个时间字段更新用户登录信息这件事看着简单做精细了能帮运维省很多事。我在登录成功后做了三件事更新last_login_time为当前时间更新last_login_ip为请求来源IP累加login_count登录次数其中last_login_ip的获取要小心代理层request.getRemoteAddr()拿到的是上一跳的地址如果项目前面有多层Nginx或网关这里拿到的可能是内网IP。真实的客户端IP还需要从X-Forwarded-For头解析而且这个头可以被伪造安全要求高的项目应该由网关统一清洗后在X-Real-IP里透传后端只信任网关传过来的值。还有一个细节登录成功之后最好把用户当前拥有的角色、权限重新从数据库查一遍而不是信任前端传来的任何参数。有的登录接口设计会把角色列表让前端填然后无脑签发token这就是典型的越权漏洞。我在第四步会重新调一遍用户表的角色字段保证token里的claims和服务端数据一致。5. JWT漏洞总结这5个坑我全踩过防御清单直接抄JWT最大的卖点是无状态但它也带来了别样的安全麻烦。下面这些坑我是在真实项目和自测中踩过的有些属于配置失误有些是攻击手法。我把它们整理成一张速查表再逐个展开。5.1 算法混淆攻击与alg:none攻击这两种攻击的本质都是JWT的Header里有alg字段服务器解析时通常会信任这个字段攻击者就利用这一点把算法换成更弱的甚至none。alg:none攻击最简单粗暴把Header里的alg改成none把签名部分整个删掉服务器如果没校验算法白名单直接就放行了。我早期自测的时候为了调试方便在解析代码里加过一段如果验签失败就尝试不验签的临时逻辑忘了删结果用alg:none签了一个admin的token直接进了后台。这个教训特别惨痛。算法混淆攻击更隐蔽服务器配置的是RS256非对称签名验签用的是公钥攻击者把alg改成HS256然后用公开的公钥内容当作HMAC对称密钥去签token。因为公钥是公开的任何人都能拿到所以攻击者等于是用公钥给自己签了一张能通过验签的通行证。防御手段就一条在解析token时强制指定算法白名单比如parserBuilder只允许HS256绝对不允许none同一项目里也不要混用多种算法。JJWT有一个setAllowedClockSkewSeconds之类的能力但算法白名单需要你自己在代码里判断Header的alg值别偷懒。5.2 密钥硬编码与弱密钥问题HS256是对称签名签发和验签用的是同一个密钥密钥一旦泄露任何人都能给自己签发有效token。我见过两类问题第一类是硬编码今天项目的密钥写死在代码里改一次密钥要改代码重新发布于是密钥常年不变一旦泄露等于长期后门。建议密钥放配置中心或环境变量配合定期轮换机制。第二类是弱密钥网上有公开的JWT密钥字典攻击者可以拿字典里的几十万个常见密钥逐个尝试签名一一比对签名是否一致。HS256要求密钥至少256位32字节但我在一些项目的配置文件里见过jwt.secret: 123456这种密钥用不了几秒钟就能被爆破出来。我在JwtUtil里加了一个启动自检密钥字节长度小于32就拒绝启动报错提示开发者修改。这个检查几十行代码但能在第一时间拦住最弱的一类配置。5.3 Token泄露与XSS风险JWT无状态意味着token一旦泄露在过期之前攻击者可以一直用。token放哪儿最安全这是前端架构问题但后端必须在设计时考虑风险。很多SPA项目习惯把token存在localStorage方便JS随时读取放到请求头里。但localStorage对任何同源下的脚本都开放只要页面被注入一段XSS脚本token就被直接盗走。相比之下把token放在HttpOnly的Cookie里JS完全无法读取XSS攻击只能触发请求但无法拿到token本体安全等级高一个档次。不过Cookie方案也不是没有代价它还要求后端做好CSRF防护因为浏览器会自动携带Cookie攻击者可以诱导用户对接口发出恶意请求。这就回到了第3.4节为什么要禁用CSRF的问题如果你决定用请求头方式携带tokenCSRF可以禁如果改成Cookie方案CSRF必须开而且要认真配置。5.4 其他容易被忽略的安全细节除了上面三类大坑还有几个项目里经常遇见的细节exp过期时间校验不能关。有的人为了调试方便把过期时间设成100年或者解析时跳过exp校验这在生产环境就是定时炸弹。JWT的Payload只是Base64编码不是加密里面不要放手机号、身份证号等敏感信息。网上有些人说JWT加密了很安全这是误解任何人都能解码Payload看内容。日志打印要脱敏。日志框架如果打印了完整的Authorization头token会泄露到日志系统里一旦日志泄露账号全完。我在日志过滤器里专门把token替换成***。退出登录要处理。无状态认证没有服务端会话可销毁用户点了退出token在过期前仍然有效。折中方案是Redis黑名单把退出用户的token塞进黑名单直到过期代价是又引入了状态存储也可以引入token版本号每次登录或退出都递增版本验签时比对版本旧token自然失效。6. Token续签的三种方案从定时刷新到Refresh Token全都试过了无状态认证的另一个麻烦是token不能太长否则泄露后风险窗口太大也不能太短否则用户用着用着突然要重新登录体验很差。解决这个矛盾的方法就是Token续签。下面三种方案我都实现过各有各的适用场景。6.1 方案一前端定时刷新Token这个方案最简单后端不需要额外工作只在前端加一个定时器。逻辑是每次登录成功后记录下token的过期时间然后起一个定时任务在过期前比如5分钟调用后端一个刷新接口带着当前还有效的token换一个新的。后端的刷新接口很简单校验当前token还有效直接签一个全新的token返回。前端用新token替换本地存储继续使用。优点是不需要引入Refresh Token概念代码量最小。缺点是请求是有状态的——如果前端页面在后台被挂起了一段时间定时器被浏览器休眠等用户切回页面时token已经过期了还是得重新登录。改进办法是在axios响应拦截器里捕获401统一调用刷新接口后再重放原请求这样用户完全无感。6.2 方案二后端滑动续期滑动续期的思路是每来一个请求后端检查token剩余有效期如果快过期了比如只剩总时长的20%就在响应头里夹带一个新token前端在响应拦截器里发现新token就替换旧的。这种方案的好处是足够滑用户在正常使用过程中token不知不觉就被续了只要一直在用理论上不会掉线。坏处是响应头放token和业务响应结构耦合得比较紧得约定固定的响应头名称比如X-Refresh-Token而且如果前端没做响应拦截新token换不上去续了个寂寞。我实际用下来有个体会滑动续期更适合那种用户持续在操作的业务比如在线文档编辑如果用户习惯打开页面半天不动滑动续期起不到作用还是要配合前端定时刷新。6.3 方案三Refresh Token双Token机制这是目前生产环境里比较正统的做法一个Access Token有效期短15分钟到2小时一个Refresh Token有效期长7天到30天只用于换取新的Access Token不参与业务接口的鉴权。用户登录成功后同时返回两个token。Access Token过期后前端用Refresh Token去调刷新接口换一个新的Access Token回来。业务接口只认Access Token所以刷新接口本身的逻辑也简单清晰。Refresh Token可以存在Redis里支持吊销用户退出时把Redis里的Refresh Token删掉这个用户的续命能力就被收回去了。PostMapping(/refresh) public ResponseEntityTokenResponse refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); if (StringUtils.hasText(refreshToken) jwtUtil.validateRefreshToken(refreshToken)) { String username jwtUtil.getUsernameFromToken(refreshToken); // 检查Redis中是否存在并被标记为有效 String stored redisTemplate.opsForValue().get(refresh: username); if (refreshToken.equals(stored)) { String newAccessToken jwtUtil.generateToken(username); return ResponseEntity.ok(new TokenResponse(newAccessToken)); } } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); }Refresh Token最大的好处是业务接口的Access Token保持短时效泄露了风险窗口很小而真正决定长期登录态的Refresh Token可以随时吊销用户改密码、踢人下线这种操作立刻生效。代价是多了一个token要管理前端和微信小程序之类的地方本地存储的代码要多写一套后端Redis的读写也多了。6.4 三个方案怎么选我的建议方案后端改动量失效风险用户体验适用场景前端定时刷新小后台挂起会失效较好后端逻辑简单、需求迭代快的项目后端滑动续期中长时间不动会失效好用户持续操作型业务Refresh Token双Token较大可控可吊销最好中大型系统、强制下线、多端登录管理我现在的习惯是简单的管理后台用方案一够用且不折腾面向C端的正式项目直接上方案三虽然前期代码多写一些但后面无论是用户主动退出、改密码失效所有token还是后台强制下线都有一条清晰的路子可以走。最后再分享一个和Token续签强相关的小技巧签发Access Token时在claims里带一个jtiJWT ID字段存一个UUID。后面要封禁某个token或踢用户下线时拿这个jti进Redis黑名单验签时多查一次黑名单就能精确控制单个token的生死。这比用用户名做Key再判断版本号要精细得多尤其是同一用户在多端登录时你不会想让他所有设备的token一起失效。我在项目里用过很多次这套组合说实话Spring Security JWT这条路上真正常见的阻碍不是代码难写而是概念理不清、配置顺序放不对、安全边界认识不完整。按上面这套链路把依赖、工具类、过滤器、配置、登录流程、漏洞防御和续签方案一次性理通后面再做权限模型扩展、多登录端管理这些东西就有了一个可靠的地基。你先从验证码登录和JWT签发跑通第一版再把黑名单和续签补上这个认证体系就算真正成型了。
返回列表