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

文章详情

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

Spring Security整合JWT实战:从过滤器链到安全防御的完整指南

Spring Security整合JWT实战:从过滤器链到安全防御的完整指南 前后端分离做到一半我被Spring Security的默认登录页折磨得不轻接口返回302、CORS跨域拦截、Session不共享明明是一个登录校验硬生生憋出好几个问题。后来把登录态从服务端Session换成JWT Token整个认证链路清爽了很多。这篇文章就把我基于Spring Security的JWT认证机制完整梳理了一遍会讲清楚过滤器链的接入位置、令牌签发与解析、续签和退出、多实例部署以及不少从漏洞总结里扒出来的防御细节。适合正在改造SPA项目、想把Spring Security和JWT接起来的后端开发者。1. 为什么前后端分离项目先要重新审视认证方案1.1 Session与Cookie在跨域和接口调用下的不适感Spring Security默认的认证方式是表单登录加Session管理。服务端给浏览器种一个JSESSIONID Cookie用户后续请求都靠这个会话ID去服务端内存里找登录态。这个模型在传统服务端渲染页面时非常流畅但放到前后端分离就变得别扭。跨域环境里要处理Cookie的SameSite、Domain、Credentials配置前后端域名不一致时浏览器默认不携带第三方Cookie。即使你调通了CORS接口调用方是手机App、微信小程序这类非浏览器客户端时它们根本没有原生Cookie概念只能自己去维护会话ID。服务端Session本身也有问题默认存储是内存多实例部署时Session不共享。你在A实例登录了负载均衡把下一个请求转发到B实例B实例没有Session直接给你一个401。虽然可以用Redis做Session共享但这等于让认证流程额外依赖一个集中式存储代价不小。所以不少人转向Token认证客户端保存一段无状态令牌每次请求放在Authorization头里服务端验签通过就能认定身份不依赖服务端存储。这也是SPA项目里最常见的JWT应用场景。1.2 JWT与Opaque Token之间怎么选才不后悔Token方案有两种常见路线一种是自包含的JWT另一种是Opaque Token不透明令牌。很多人默认JWT好用实际上两者各有一本账。JWT的优势是自包含、可离线校验。令牌里可以直接携带用户ID、角色、昵称等声明服务端拿到后验签即可不需要去数据库或者Redis查一次。缺点是签发后内容在到期前基本不可变遇到账号封禁、权限调整、强制下线这类操作旧令牌很难立刻作废。Opaque Token只是一串随机ID服务端必须拿着ID去Redis或数据库查对应用户信息能随时删除但每次请求都多一次I/O。我的建议是接口调用频繁、对性能敏感、读多写少的场景优先考虑JWT而需要频繁踢人、实时权限变更、Token回收要求严格的场景Opaque Token更稳妥。如果你选择了JWT就要接受短过期时间刷新令牌这套组合拳而不是寄希望于一份永久有效的长令牌。2. Spring Security中JWT真正生效的关键位置2.1 过滤器链顺序为什么把JWT过滤器放在最前面Spring Security的核心是一张过滤器链内置了很多过滤器SecurityContextHolderFilter、HeaderWriterFilter、UsernamePasswordAuthenticationFilter、ExceptionTranslationFilter等。它们在HttpSecurity配置后自动被组织起来。一个请求会按顺序经过这些过滤器每个过滤器只做一件事最后才到Controller。JWT认证要插入的位置通常是在UsernamePasswordAuthenticationFilter之前。原因很直接用户名密码登录过滤器需要从表单或请求参数里解析用户名和密码而我们希望把JWT令牌转换成Authentication再塞给SecurityContext。只有认证信息提前就位后续的授权判断、PreAuthorize注解才有依据。实操中一般这样注册http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)严格来说应该放在SecurityContextHolderFilter之后、AuthorizationFilter之前。因为SecurityContextHolderFilter负责创建空的上下文授权过滤器读取上下文里的认证信息。放在它前面不但没有意义还可能因为上下文还没初始化而出问题。常见写法是加在UsernamePasswordAuthenticationFilter前面这对大多数人来说是最顺手的。2.2 JWT解析库的选型对比Spring Security本身不提供JWT的解析实现它依赖第三方库。造轮子当然不用但选库还是有几个讲究。我实际比较过这三类方案风格优点注意点jjwt-api / jjwt-impl / jjwt-jackson链式API接近主流习惯代码简洁社区资料多0.11.x版本要写特征Nimbus JOSE JWTSpring Security OAuth2资源服务器默认使用和Spring生态结合自然很多接口偏底层代码略啰嗦auth0 java-jwt轻量、直观上手快自定义claim的组合不如jjwt方便我用的是JJWT 0.11.5三件套依赖都配置好之后写起来比较顺手。如果你后续要用spring-security-oauth2-resource-server那Nimbus更合适如果是纯手写过滤器JJWT足够了。2.3 Authentication对象如何流入Controller这里的链路很多人理解得模棱两可。JWT过滤器解析出令牌后要构造一个UsernamePasswordAuthenticationToken把用户标识放在principal里权限列表放在authorities里然后执行SecurityContextHolder.getContext().setAuthentication(authentication);之后在Controller里可以用AuthenticationPrincipal直接取当前用户信息GetMapping(/me) public ResultUserInfo me(AuthenticationPrincipal String userId) { return Result.success(userService.getById(userId)); }AuthenticationPrincipal是Spring Security 4.2以后提供的注解取的是Authentication对象里principal字段。如果想同时拿到角色等额外信息可以给principal放一个自定义的UserInfo对象或者用AuthenticationPrincipal加SpEL表达式去提取。这个链路搞清楚以后写接口时就不用每次手动从SecurityContextHolder取了。3. 从零搭建一个可落地的JWT认证模块3.1 引入依赖和基础配置项目基于Spring Boot 3.xSpring Security 6.x。先整理依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/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配置文件里放入密钥和过期时间app: jwt: # HS256要求至少32字节也就是256位 secret: 请用一个足够长的随机字符串至少32位生产环境放到配置中心或环境变量 # 访问令牌有效时间单位毫秒 access-token-expiration: 1800000 # 刷新令牌有效时间单位毫秒 refresh-token-expiration: 604800000secret一定不要写在代码里也不要公开仓库。HS256的密钥一旦泄露等于任何人都可以伪造令牌。后面在漏洞章节我会细讲。3.2 令牌生成与解析Service令牌服务是核心类。生成令牌时我会把用户ID、用户名、角色列表放进payload访问令牌里放sub、uid、roles、iat、exp、iss等声明。public class JwtTokenProvider { private final SecretKey key; private final long accessExpiration; private final long refreshExpiration; public JwtTokenProvider(String secret, long accessExpiration, long refreshExpiration) { this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.accessExpiration accessExpiration; this.refreshExpiration refreshExpiration; } public String createAccessToken(Long userId, String username, CollectionString roles) { Date now new Date(); Date expireAt new Date(now.getTime() accessExpiration); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(roles, roles) .setIssuedAt(now) .setIssuer(my-app) .setExpiration(expireAt) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String createRefreshToken(Long userId) { Date now new Date(); Date expireAt new Date(now.getTime() refreshExpiration); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(type, refresh) .setIssuedAt(now) .setExpiration(expireAt) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }注意解析时只能用同样的密钥。parseClaimsJws会在签名错误、过期、篡改时抛出异常调用方做好异常分支就行。3.3 自定义OncePerRequestFilter并写入SecurityContext过滤器是JWT认证的入口。我选择继承OncePerRequestFilter它保证同一个请求只执行一次过滤逻辑避免因为内部转发而重复解析。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) { this.tokenProvider tokenProvider; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { try { Claims claims tokenProvider.parseToken(token); if (!isTokenAllowed(claims)) { SecurityContextHolder.clearContext(); } else { Long userId Long.valueOf(claims.getSubject()); ListString roles claims.get(roles, List.class); ListGrantedAuthority authorities roles.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userId, null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (JwtException | IllegalArgumentException ex) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { return header.substring(7); } return null; } }需要特别提醒很多新手在过滤器里遇到过期令牌就直接向前端返回401。这里有个设计原则需要想清楚——如果你在过滤器里输出响应体并return那所有带有无效令牌的接口都会中断而有些接口可能允许匿名访问比如登录接口本身。更好的做法是只清理上下文不直接写响应让后面的ExceptionTranslationFilter和AuthorizationFilter决定该返回401还是放行。把异常链路交给统一异常处理比在过滤器里各写各的要好维护得多。3.4 SecurityConfig配置与登录接口联动SecurityConfig负责声明哪些路径不需要认证、哪些路径需要认证、会话策略、异常输出方式。Spring Security 6.x的写法是配置一个SecurityFilterChainBeanConfiguration EnableWebSecurity public class SecurityConfig { private final AuthenticationManager authenticationManager; private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(AuthenticationManager authenticationManager, JwtAuthenticationFilter jwtAuthenticationFilter) { this.authenticationManager authenticationManager; this.jwtAuthenticationFilter jwtAuthenticationFilter; } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/refresh).permitAll() .anyRequest().authenticated() ) .exceptionHandling(e - e .authenticationEntryPoint((request, response, authException) - writeJson(response, HttpServletResponse.SC_UNAUTHORIZED, 未登录或令牌已过期)) .accessDeniedHandler((request, response, accessDeniedException) - writeJson(response, HttpServletResponse.SC_FORBIDDEN, 无权限访问)) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里有两个细节很关键。第一csrf.disable()只适合纯Token认证、不使用Cookie的接口服务。如果你还用到了Cookie千万别无脑关否则会出现CSRF漏洞。第二sessionCreationPolicy(SessionCreationPolicy.STATELESS)能确保Spring Security不创建服务端会话这才真正发挥JWT无状态的优势。3.5 登录接口验证码、密码校验和令牌签发一起做SPA项目里的登录接口往往不止校验密码还有图形验证码或短信验证码。我的登录接口结构一直是先验证码、后密码、最后签发令牌并在同一个事务流程里更新用户登录信息。PostMapping(/api/auth/login) public ResultLoginResponse login(RequestBody LoginRequest request) { // 1. 校验图形验证码 boolean codeValid captchaService.validate(request.getCaptchaKey(), request.getCaptchaCode()); if (!codeValid) { return Result.fail(验证码错误); } // 2. 使用Spring Security的AuthenticationManager走认证 UsernamePasswordAuthenticationToken authRequest new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); Authentication authentication authenticationManager.authenticate(authRequest); // 3. 加载UserDetails UserPrincipal principal (UserPrincipal) authentication.getPrincipal(); // 4. 更新登录信息 userInfoService.updateLoginInfo(principal.getId(), request.getLoginIp(), LocalDateTime.now()); // 5. 签发访问令牌和刷新令牌 ListString roles principal.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); String accessToken tokenProvider.createAccessToken(principal.getId(), principal.getUsername(), roles); String refreshToken tokenProvider.createRefreshToken(principal.getId()); return Result.ok(new LoginResponse(accessToken, refreshToken, principal.getUsername())); }如果你使用了AuthenticationManager就需要配置一个AuthenticationProvider或者类似UserDetailsService的Bean。也可以用DaoAuthenticationProvider结合UserDetailsService实现。这种结构的价值在于Spring Security负责密码加密校验业务代码只关注验证码、令牌签发和登录信息更新。3.6 统一异常输出上面配置里用到的writeJson是手写的JSON响应方法。推荐的方案是引入统一响应对象Result用ObjectMapper手动写出去private void writeJson(HttpServletResponse response, int status, String message) throws IOException { response.setStatus(status); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(objectMapper.writeValueAsString(Result.fail(message))); }需要注意一点认证异常处理器的响应也要走全局异常格式不要让框架默认的HTML堆栈暴露给前端。我在实际项目中遇到过因为统一异常处理没覆盖ExceptionTranslationFilter导致的404排查半天才发现是入口点写的不对。4. 登录信息更新、token续签与退出的实现细节4.1 更新用户登录信息后再签新令牌登录成功之后更新用户表的最后登录IP和最后登录时间这是很常见的需求。但很多人纠结一个问题先更新登录信息再生成令牌还是先生成令牌再更新登录信息我的经验是如果更新操作本身需要写数据库就把它放在令牌生成之前如果更新之后还要查询用户的完整信息来生成用户对象那顺序更要靠前。你自己写的时候要注意的是不要在生成JWT之后又去修改数据库里的用户级别、角色字段因为令牌里的信息已经固化数据库的改动无法同步到已经签发的令牌里。比如一个用户登录后刚拿到token管理员立刻把该用户禁用那你这个token在过期之前仍然能用。想解决这个问题就要用到后面讲的状态校验。4.2 Token续签滑动窗口实现JWT设过期时间不可避免但用户用着用着突然过期体验很差。方案就是引入刷新令牌机制。前端发现访问令牌快过期时调用/api/auth/refresh接口携带刷新令牌服务端校验后返回一对新令牌。PostMapping(/api/auth/refresh) public ResultLoginResponse refresh(RequestBody RefreshRequest request) { String oldRefreshToken request.getRefreshToken(); Claims claims tokenProvider.parseToken(oldRefreshToken); // 校验类型是refresh if (!refresh.equals(claims.get(type))) { return Result.fail(非刷新令牌); } Long userId Long.valueOf(claims.getSubject()); // 这里可以再查一次用户状态 UserPrincipal principal userService.loadUserById(userId); if (principal null || !principal.isEnabled()) { return Result.fail(用户不可用); } String accessToken tokenProvider.createAccessToken(...); String refreshToken tokenProvider.createRefreshToken(userId); return Result.ok(new LoginResponse(accessToken, refreshToken)); }这里有个关键竞态问题如果用户同时发多个刷新请求服务端会签发多对令牌。大多数场景下这不会致命但严谨一点的做法是刷新令牌签发后立即把旧的刷新令牌作废。具体可以在Redis里存一个刷新令牌的唯一标识刷新时对比传入标识发现不匹配就整体退出登录强制重新登录。这样既防滥用也能在令牌被盗时多一层发现机制。4.3 退出登录黑名单机制与短TTL的权衡因为JWT无状态服务端默认删除不了已经签发的令牌。退出登录的常见方案是维护黑名单。比如把用户退出时还处于有效期内的token的jtiJWT ID存到Redis存活时间设为该token剩余有效期下次请求发现jti在黑名单里就直接拒绝。public boolean isTokenRevoked(Claims claims) { String jti claims.getId(); return Boolean.TRUE.equals(redisTemplate.hasKey(blacklist:jwt: jti)); }但黑名单本质上引入了集中式状态和无状态设计是有冲突的。所以我的建议是如果业务允许优先把访问令牌的过期时间设计得很短比如15分钟或30分钟退出的时候只需要把刷新令牌删除即可。刷新令牌拿不到用户重新获取访问令牌的链路就断了旧访问令牌最多存活30分钟这个容忍度大多数系统都能接受。如果系统对退出即时性要求很高再叠加黑名单机制。5. JWT认证已知漏洞汇总与防御清单5.1 算法混淆攻击alg:none与HS256/RS256的坑JWT头里有一个alg参数服务端解析时如果完全信任它就会被攻击。最经典的场景是攻击者把alg改成none再把payload改成自己想要的用户。如果服务端对alg:none未做禁用就直接通过了。算法混淆攻击是另一个高频漏洞。简单说服务端用RS256签名令牌时公钥是公开的攻击者伪造一个alg:HS256的令牌用已知的公钥内容当作HMAC密钥签名。服务端如果还在用同一个公钥去verify这一段的HS256签名会发现验签居然是成功的。因为HS256要求对称密钥而RS256的公钥本身是一段公开文字正好被当作对称密钥使用。防御要点是解析时固定算法不要从令牌里取算法来动态决定。JJWT的parserBuilder配合setSigningKey虽然默认禁止alg:none但保险起见还要在验证时明确声明只接受预期算法。如果你用的是NimbusJwtDecoder还需要配置JWTProcessors或自定义JWTProcessor去掉无关算法。很多在线漏洞总结里这一条排第一不是没有道理。5.2 Payload里的敏感信息Base64不是加密JWT的payload只做了Base64URL编码任何人拿到令牌都能直接解码看到内容。很多人把手机号、邮箱、身份证号放进去这等于把敏感信息公开给对方日志系统如果打印了完整token那更是灾难。原则是payload只放必要的信息比如用户ID、用户名、角色其他用户资料接口需要的时候再去查。真有必要放敏感信息就对payload做JWE加密而不是简单签名。JWE和JWS是不同的规范JWS保证数据完整性JWE才保证机密性这两个概念别混淆。5.3 密钥管理、过期时间与签发方/接收方校验再列几个在上线评审时我必查的点密钥长度HS256要求256位密钥HS384要求384位HS512要求512位。用Keys.secretKeyFor(SignatureAlgorithm.HS256)生成可以避免密钥强度不足。密钥轮换长期不变更的密钥风险很大。比较稳妥的做法是把密钥拆成kid版本管理签发时带kid解析时根据kid找到对应密钥。过期时间访问令牌不建议设置为7天甚至30天尽量短刷新令牌再放宽但配套轮换策略。签发方/接收方校验解析时要校验iss和aud确认令牌是本系统签发的而不是别的地方拿过来重放。时钟偏移服务器和客户端时钟不一致时紧贴exp的令牌可能在边界处被拒绝或误放行。解析时适当设置setAllowedClockSkewSeconds比如30秒到60秒能减少无意义的401。5.4 常见逻辑漏洞令牌重放与用户角色更新除了算法层面的漏洞业务逻辑里也容易埋雷。比如用户角色被降级后旧令牌里的roles仍然是旧角色权限控制立刻失效。我在项目里踩过这个坑。解决方向是在每个关键操作里再查一次用户当前权限或者引入令牌版本号。令牌版本号是一个缓存的整数每次角色变更、密码重置、账号禁用都把它加一。令牌解析时带着版本号服务端调Redis比对版本不一致就认为令牌无效。这样既有JWT的无状态好处又能解决权限实时变更的问题。6. 多实例部署下的JWT实践补充6.1 无状态设计的收益JWT最常见的价值宣传就是无状态。多个应用实例无需共享会话存储只要每个实例配置相同的密钥任何实例都能校验令牌。这样横向扩容就省去了Session同步的复杂度也减轻了数据库压力。但如果你的业务里需要撤销令牌、强制下线、权限实时变更那完全无状态就只能是一个营销词。真实系统大多是有混合状态需求的。我的做法是核心认证保持无状态状态把少量动态状态放到Redis里。不是所有信息都进Redis只有需要撤销校验或版本控制的场景才查。6.2 Redis承载用户状态与令牌版本核验用户状态统一存Redis时的key设计要清晰我常用的格式是login:state:{userId} - {version, disabled, lastLogoutTime} refresh:token:{userId}:{jti} - {ttl by refresh token expiration} blacklist:jwt:{jti} - {ttl by remaining access token validity}过滤器解析令牌之后如果系统开启了状态校验就去按用户ID查一下login:state对比令牌里的version声明。这样密码重置后旧令牌的version对不上自然失效管理员禁用用户时删除或标记该状态用户立刻被踢出。6.3 刷新令牌的存储与轮换策略刷新令牌通常生命周期更长也必须更强地管理。我把刷新令牌对应的jti存到Redis用户刷新接口拿着令牌来换新令牌时对比Redis里存的jti。如果一致则删除旧jti并写入新的jti如果不一致说明刷新令牌被重复使用按被盗处理直接清理该用户所有刷新令牌。这个策略是典型的“刷新令牌轮换”思路。它的核心价值是即使有刷新令牌被泄露攻击者一旦使用一次原用户下次刷新时就会因为新令牌不匹配而触发报警或强制下线。代价是Redis里需要保留一定数据但这部分状态占用的空间很小换来的是可控的安全边界。最终审计上线时我会带着安全清单逐条打勾算法固定、payload无敏感信息、密钥强度足够且轮换、过期时间合理、iss/aud校验、状态版本控制、刷新令牌轮换。这些细节看起来琐碎但每一个都是从真实漏洞总结里换来的教训。有一次我就是因为alg校验不严格压测通过后上了生产差点背一口大锅。现在再写JWT相关的东西我都会把这些防御点当成基准配置而不是可选项。
返回列表