☰
Spring Security整合JWT实战:从过滤器链到安全防御的完整指南
2026/10/3 2:54:46 网站建设 项目流程

前后端分离做到一半,我被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 Result<UserInfo> 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.x,Spring Security 6.x。先整理依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>

配置文件里放入密钥和过期时间:

app: jwt: # HS256要求至少32字节,也就是256位 secret: "请用一个足够长的随机字符串,至少32位,生产环境放到配置中心或环境变量" # 访问令牌有效时间,单位毫秒 access-token-expiration: 1800000 # 刷新令牌有效时间,单位毫秒 refresh-token-expiration: 604800000

secret一定不要写在代码里,也不要公开仓库。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, Collection<String> 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()); List<String> roles = claims.get("roles", List.class); List<GrantedAuthority> 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的写法是配置一个SecurityFilterChainBean:

@Configuration @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 Result<LoginResponse> 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. 签发访问令牌和刷新令牌 List<String> 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;charset=UTF-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 Result<LoginResponse> 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的jti(JWT 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相关的东西,我都会把这些防御点当成基准配置,而不是可选项。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询