前阵子接手一个老项目的登录模块改造,技术栈定的是Spring Security + JWT。说实话,这两样东西单拎出来都不算难,但把它们真正组合起来部署到生产环境,中间隔着大量文档里不会写的细节。这套组合我前后在不同项目里用过三四回,每次都还是能踩出新坑,所以干脆把原理、落地代码、漏洞防御和续签方案一次性整理出来。这篇文章适合正在做无状态认证改造的Java后端同学,也适合那些已经接上JWT但总担心哪里埋了雷的团队。
1. 为什么Spring Security和JWT会成为认证首选:从Session到无状态
1.1 传统Session方案在分布式环境下的尴尬
很早以前做Web登录,大家习惯用Session + Cookie。流程很简单:用户提交账号密码,服务端校验通过后往Session里存一份用户信息,同时下发一个JSESSIONID写到浏览器Cookie,后续请求带着这个ID,服务端就能认出你是谁。
单机部署没有任何问题。但到了多实例部署时代,这个方案就开始难受了。请求被负载均衡打到不同节点,Session落在A机器,下一次却被转发到B机器,B机器查不到Session,用户就被迫重新登录。当时社区里主要有三种补救方案:
- Session黏滞:让同一个用户的请求始终打到同一台机器上。简单但不优雅,节点重启时Session全丢。
- Session复制:多实例之间广播同步Session数据。集群规模一大,广播风暴能把网络打垮。
- Redis集中存储:把Session挪到Redis里,多实例共享。这是当时的主流解法,但代价是每次请求都要做一次序列化和反序列化,还得额外维护一套Redis的高可用。
更麻烦的是跨域和移动端场景。Cookie在跨域请求里天然受限,SameSite策略一收紧,前端连登录态都带不上来。移动端App压根没有Cookie的概念,还得去手动拼请求头。这些痛点累积到一定程度,就逼着大家寻找一种完全不需要“服务端状态”的认证方式。
1.2 JWT的三段结构,为什么它天然适配无状态
JWT(JSON Web Token)就是一种典型的无状态凭据。它本身就是一个加密签名的JSON字符串,分成三段,用点号分隔。
Header里记录签名算法,通常是HS256或RS256。Payload里放核心声明数据,比如sub(用户名)、iat(签发时间)、exp(过期时间),也可以塞自定义字段,比如用户角色、租户ID。最后的Signature是对前两段做签名后的结果,保证内容不可被篡改。
一个简化后的JWT长这样:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ6aGFuZ3NhbiIsImlhdCI6MTcxMDAwMDAwMCwiZXhwIjoxNzEwMDAyMDAwfQ.dQxY0uYwV2mUVgVbWnH1E7HmE5P3T0WmXrZk2oXVJkQ服务端拿到这个Token,用本地密钥验签,签名合法,再检查exp没过期,就直接信任Payload里声明的用户身份,完全不需要去数据库或缓存里查任何会话记录。这就解决了Session方案里最头疼的分布式状态共享问题——每个节点都能独立完成验签,水平扩容变得极其轻松。
需要注意一点:Payload只是Base64URL编码,并不是加密。用工具一解码就能看到里面的明文内容,所以千万别把密码、手机号这类敏感数据直接塞进JWT。这条我在下文的漏洞部分还会强调。
1.3 Spring Security的过滤器链模型,给JWT留好了插槽
很多初学者被Spring Security的配置吓到,其实它的核心模型很朴素:一条过滤器链。一次HTTP请求进来,会依次经过一系列过滤器,每个过滤器决定放行、拦截或做一些前置处理。
Spring Boot内置的默认链路里,UsernamePasswordAuthenticationFilter负责处理用户名密码登录,FilterSecurityInterceptor负责做最后的授权判断。我们接JWT时做的事情,本质就是往这条链路的合适位置插入一个自定义过滤器——JwtAuthenticationFilter,让它先于认证逻辑执行:从请求头提取Token,验签,然后把认证结果塞进SecurityContextHolder。
这个模型的好处是,Spring Security原有的方法级权限控制(@PreAuthorize)、URL级权限控制(authorizeHttpRequests)全部照常工作,我们只是在它们前面多了一步“用Token解析出用户身份”。所以Spring Security和JWT不是谁替代谁的关系,而是过滤器链上的自然协作。
2. 一次登录请求背后的完整链路:从AuthenticationManager到SecurityContextHolder
2.1 登录接口:简单的三行代码,背后是一整套认证流程
先看最直观的登录接口代码:
@PostMapping("/api/auth/login") public ResponseEntity<?> login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); String accessToken = tokenProvider.createAccessToken(authentication); String refreshToken = tokenProvider.createRefreshToken(authentication); return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken)); }很多人疑惑authenticationManager.authenticate()这一行背后发生了什么。实际上,AuthenticationManager会遍历所有注册的AuthenticationProvider,找到能处理UsernamePasswordAuthenticationToken的那个Provider。Provider内部会调用UserDetailsService去数据库加载用户记录,再通过PasswordEncoder比对密码哈希。
密码校验通过后,返回一个已认证的Authentication对象,里面包含了完整的GrantedAuthority权限列表。我们就是从这个对象里取出用户名和角色,塞进JWT的Payload。
有一个配置上的坑必须提醒:默认情况下Spring Security不会自动在容器里注册AuthenticationManager的Bean,需要手动暴露:
@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }如果忘了这个,启动时接口里注入AuthenticationManager会直接报没有可用Bean的错误,排查起来还得翻一会儿源码。
2.2 JwtAuthenticationFilter:所有业务接口的守门员
登录接口产出了Token,后续每一个请求都需要经过JwtAuthenticationFilter来验证Token。这个过滤器通常继承OncePerRequestFilter,确保一次请求只执行一次逻辑:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && tokenProvider.validateToken(token)) { Claims claims = tokenProvider.parseToken(token); String username = claims.getSubject(); 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; } }两个细节值得展开说。
第一个是resolveToken的判断逻辑。按照惯例,Token放在Authorization请求头里,带Bearer前缀。代码里用substring(7)跳过前缀,这里的7就是"Bearer "的字符长度。如果你从Cookie里取Token,逻辑就不一样了,这点根据项目具体场景来定。
第二个是loadUserByUsername。这里有个性能与实时性的权衡。每次请求都查一次数据库,高并发下压力很大;但如果完全不查库,用户被禁用或者密码被修改后,已签发的Token仍然有效。我的做法是引入Spring Cache,给用户信息加一个5分钟级别的缓存,这样既不会每个请求都穿透到数据库,又能在最多5分钟内感知到用户状态变化。对多数业务系统来说,这个延迟完全可接受。
2.3 SecurityContextHolder:一次请求线程内的一次性上下文
过滤器里做了SecurityContextHolder.getContext().setAuthentication(...),之后业务代码怎么拿当前登录用户?标准写法是:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String username = authentication.getName(); List<String> roles = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList();SecurityContextHolder底层用的是ThreadLocal,意味着认证信息绑定在当前处理请求的线程上。一次请求从进入到返回,这条线程拿到的都是同一个上下文。这也是Spring Security这么多年设计中非常顺手的一个点——业务代码不用把用户对象作为参数层层传递,随时可以从静态方法里取。
但ThreadLocal有个著名的副作用:异步线程拿不到父线程的上下文。如果你在业务代码里用@Async开了子线程,子线程里SecurityContextHolder.getContext().getAuthentication()会返回null。解决方式是在提交异步任务前先取出Authentication对象,作为参数显式传给子线程,或者配置Spring Security官方提供的DelegatingSecurityContextExecutor装饰器。这个坑在日志系统里特别常见——异步日志需要记录操作人,结果拿不到用户名。
3. 落地代码:JwtTokenProvider、SecurityConfig与异常处理三件套
3.1 JwtTokenProvider:Token的生成、解析、校验全在这一处
为了避免各处散落JWT相关代码,建议把逻辑全部收敛到一个工具类里。我用的是jjwt 0.12版本,这个版本的API相比老的0.9有比较大的调整,代码里体现的是新版写法:
@Component public class JwtTokenProvider { private final SecretKey key; private final long accessTokenValidityMs; private final long refreshTokenValidityMs; public JwtTokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.access-token-validity-seconds}") long accessTokenValiditySeconds, @Value("${jwt.refresh-token-validity-seconds}") long refreshTokenValiditySeconds) { byte[] keyBytes = Decoders.BASE64.decode(secret); this.key = Keys.hmacShaKeyFor(keyBytes); this.accessTokenValidityMs = accessTokenValiditySeconds * 1000; this.refreshTokenValidityMs = refreshTokenValiditySeconds * 1000; } public String createAccessToken(Authentication authentication) { String username = authentication.getName(); Instant now = Instant.now(); return Jwts.builder() .subject(username) .claim("roles", authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList()) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusMillis(accessTokenValidityMs))) .signWith(key, Jwts.SIG.HS256) .compact(); } public String createRefreshToken(Authentication authentication) { String username = authentication.getName(); Instant now = Instant.now(); return Jwts.builder() .subject(username) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusMillis(refreshTokenValidityMs))) .signWith(key, Jwts.SIG.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); } public boolean validateToken(String token) { try { parseToken(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }代码里有几个点要单独拎出来讲。
密钥的生成和编码方式是很多人忽略的地方。HS256算法要求密钥长度至少256位,也就是32字节。Decoders.BASE64.decode(secret)表示配置里的jwt.secret是一段Base64编码的密钥,Spring会在启动时解码成SecretKey对象。如果你直接配置一个简单的字符串比如"my-secret",长度远不够,启动就会报弱密钥异常。
claims里面放了roles,这样授权判断时可以不用每次都查库拼权限。但代价是角色变更不会立刻生效,和上文提到的用户状态校验一样,是性能和实时性之间的权衡。我的习惯是:角色放Token里,权限变更的容忍度控制在Token过期时间以内。
3.2 SecurityConfig:放行规则、无状态会话与过滤器插入位置
过滤器有了,Token处理器有了,接下来把它们接进Spring Security的链路:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/captcha", "/api/auth/refresh").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(ex -> ex.authenticationEntryPoint(restAuthenticationEntryPoint())) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这里最需要理解的是addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)这一句。它把我们自定义的过滤器插入到UsernamePasswordAuthenticationFilter之前,意味着请求先进我们的过滤器,完成Token校验后,后面的Spring Security认证流程自动感知到SecurityContextHolder里的认证信息,直接跳转到授权判断阶段。
SessionCreationPolicy.STATELESS是JWT方案的标配。它告诉Spring Security不要创建HttpSession,也不用Session去维护security上下文,彻底走无状态路线。如果你忘了配这一项,默认情况下Spring Security还会去创建Session,等于保留了有状态的后门,认证行为就会变得诡异且难以排查。
csrf禁用同样是因为无状态Token方案天然免疫CSRF攻击。CSRF的核心是利用浏览器自动携带Cookie的特性,而我们的Token放在自定义Header里,攻击者无法跨域伪造,所以直接关掉。
3.3 自定义AuthenticationEntryPoint:未登录时返回什么
默认的未登录行为是返回302跳转到登录页,那是给传统Web页面用的。在前后端分离项目里,接口未认证时应该返回标准的401状态码和JSON错误信息。所以需要一个自定义入口点:
@Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); } }这样一个简单的类,解决了前端统一拦截401跳转登录页的需求。我在不止一个项目里见过前端疯狂抱怨后端没有返回标准化错误体,根源就是后端忽略了AuthenticationEntryPoint的定制。
4. JWT漏洞盘点:算法混淆、密钥泄露与过期时间那些坑
4.1 算法混淆攻击:老版本JWT库最经典的漏洞
JWT库发展早期遇到过一类非常危险的攻击:篡改Header里的alg字段。
最原始的漏洞是alg: none。攻击者把Header里的算法改成none,同时去掉签名部分,旧版库如果默认支持none算法,就会把这种“无签名”的Token当成合法Token直接信任。攻击者可以伪造任意身份,畅行无阻。
另一类更隐蔽的攻击是算法混淆。应用用的是非对称算法RS256(私钥签名、公钥验签),攻击者把alg改成HS256,然后用服务端的公钥作为HMAC的对称密钥,给自己伪造的Token签名。如果服务端库没有严格校验算法与密钥类型,直接用公钥去验HMAC签名,签名是能通过的,攻击就成功了。
防御手段也不复杂,但必须在起步时就做对:
- 升级JWT库到新版本。我用的是jjwt 0.12.x,新版本在解析时会强制要求指定验签密钥,并且不会接受
none算法。 - 在
Jwts.parser()里通过verifyWith(key)显式绑定密钥,相当于强制锁死了算法族。 - 生产环境禁用一切“自动探测算法”的配置项。
4.2 密钥硬编码和弱密钥:最大的隐形风险
JWT的安全性完全建立在密钥之上,但从我接触过的项目看,密钥管理恰恰是重灾区。
最常见的错误是密钥直接写死在配置代码里,甚至提交到Git仓库。一旦仓库泄露,等于把整个系统的认证凭据拱手送人。攻击者拿到密钥之后,可以自己签发任意用户、任意角色、任意过期时间的Token,比解密密码还可怕,这是纯“信任根”级别的灾难。
密钥强度也是问题。HS256要求32字节以上,RS256要求至少2048位的RSA密钥。有些团队图省事复制一个短字符串作为密钥,能跑通但防线等同于没有。
比较稳妥的做法是:
- 密钥放在环境变量、配置中心或云厂商的KMS密钥管理服务里。
- 密钥长度至少256位,用
openssl rand -base64 48这类命令生成。 - 支持密钥轮换。在Token里加入
kid(Key ID),服务端同时维护多把密钥,新Token用新密钥签发,旧密钥保留到老Token全部过期再下线。这个方案对大部分业务系统来说性价比很高。
4.3 过期时间设太长、签发时间不校验的后患
很多小项目贪图方便,把Token过期时间设置成7天甚至30天。这么做的后果是,一个Token泄露后,攻击者有整整一周或一个月的时间窗口可以做坏事。最佳实践是让access token的过期时间保持在15到30分钟,让Token的泄漏影响面尽量小。
还有个容易被忽略的坑是签发时间iat。部分自定义解析代码只验exp,根本不检查iat。比如用户2019年签发的Token,用默认值“永久有效”的解析逻辑一直能用。所以解析时必须依赖库自带的过期校验,而不是自己临时写一个“仅比较exp”的解析逻辑。我推荐直接使用parseSignedClaims,它会内部校验exp;如果要手动处理,也需要显式调用claims.getExpiration().before(new Date())这类判断。
另一个真实生产环境的问题是服务器时钟偏差。如果集群里多台机器的系统时间不同步,A机器签发的Token在B机器上可能显示还没生效或已过期。部署时一定要做NTP时间同步,这是一项事后很难排查、但预防起来极容易的措施。
4.4 Token传递方式:不要把Token放在URL里
我在一个旧系统里见过把Token拼在URL查询参数上的做法,每次用户访问接口,Token都会出现在访问日志、网关日志、CDN日志甚至浏览器历史记录里。任何一份日志泄露,Token就跟着泄露。正确的做法是只通过Authorization: Bearer <token>请求头传递。
前端存储方面,有人用localStorage,有人用SessionStorage,有人用Cookie。从纯安全角度讲,HttpOnlyCookie更稳,因为它隔绝了Java脚本的读取路径,但使用Cookie又需要面对CSRF,虽然我们禁用了CSRF,因为JWT放在自定义Header里而非Cookie,但如果你把Token放进Cookie,CSRF防线就得重新拉起来。实际取舍上,前后端分离项目用localStorage +AuthorizationHeader + 短时Token的组合非常常见,核心思路就是:Token本来就短命,localStorage的XSS风险可在可控范围内。
我另外建议在所有打印日志的地方对Token做脱敏,只保留前四位和后四位,避免日志系统成为另一种泄露渠道。规则很简单:Token像密码一样对待。
5. Token续签方案:Refresh Token与滑动过期怎么选
5.1 双Token机制:access token短命,refresh token负责续命
因为access token的过期时间被压到15到30分钟,就会出现一个很现实的问题:用户正在页面上操作,Token悄悄过期了,下一次请求直接401,体验很割裂。所以需要一种让用户无感知续期的机制,业界主流就是双Token方案。
结构是:登录时同时下发两个Token。
- Access Token:有效期短,15到30分钟,用于访问业务接口。
- Refresh Token:有效期长,7到30天,只在一个接口使用——换新的Access Token。
前端拦截到401响应时,可以携带Refresh Token调用刷新接口:
@PostMapping("/api/auth/refresh") public ResponseEntity<?> refresh(@RequestBody RefreshTokenRequest request) { Claims claims = tokenProvider.parseToken(request.getRefreshToken()); String username = claims.getSubject(); UserDetails userDetails = userDetailsService.loadUserByUsername(username); Authentication authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); String newAccessToken = tokenProvider.createAccessToken(authentication); String newRefreshToken = tokenProvider.createRefreshToken(authentication); return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken)); }刷新接口在SecurityConfig里配置为permitAll,因为它需要依靠Refresh Token自身来做认证,而不是依赖过期的access token。这是把双Token机制跑通的必要配置。
5.2 滑动过期:不想维护Refresh Token时的简化方案
如果觉得双Token的架构有点重,还有一个轻量替代方案——滑动过期。核心逻辑是:单Token方案里,只要用户活跃,就不断给他换发新Token;用户长时间不活跃,Token自然过期。
实现方式在过滤器里做一点增强。验证Token合法后,检查剩余有效时间,如果低于某个阈值(比如5分钟),就生成新Token并通过响应头返回给前端:
long remaining = claims.getExpiration().getTime() - System.currentTimeMillis(); long threshold = 5 * 60 * 1000L; if (remaining < threshold) { String newToken = tokenProvider.createAccessToken(authentication); response.setHeader("X-Renewed-Token", newToken); }前端在axios响应拦截器里检测到这个自定义头,就替换本地存储的旧Token。这套逻辑的优点是只需要维护一个Token,缺点是每次续签都要多一次Token生成和替换,而且后端没法做到“强制下线”——只要用户每4分钟刷一次接口,Token就永远不会过期,这不符合某些安全敏感业务的要求。
5.3 续签机制的安全细节
不管是双Token还是滑动过期,都有几个安全细节容易漏。
Refresh Token本质是长期凭据,泄露危害比access token大得多。所以refresh token的交易通道要额外加固:刷新接口必须限流,防止被暴力重放;Refresh Token每次刷新后最好轮换一次——旧的作废,发新的,这样即使某个Refresh Token被偷,泄漏窗口也只在两次刷新之间。
要支持Refresh Token撤销,最常用的方案是Redis黑名单。签发时把Refresh Token的jti(JWT ID)存进Redis,设置和Token相同的过期时间,刷新或登出时把jti标记为失效。校验Refresh Token时先查一下黑名单。当然这只是可选方案,对多数业务来说,Refresh Token 7天过期已经够用了。
6. 登录验证码、SPA集成与生产环境的最后一公里
6.1 验证码的生成、存储与登录联动
登录接口如果完全开放,就很容易被撞库和暴力破解。所以加上验证码或者更简单的滑块、扫码方案,成了SPA项目登录流程的标配。
验证码实现其实不复杂,我用的是Google Kaptcha或EasyCaptcha生成图片,在服务端生成时做两件事:返回给前端一张图片,同时把一个UUID作为Key、验证码内容作为Value存进Redis,过期时间5分钟。
关键代码逻辑:
@GetMapping("/api/auth/captcha") public CaptchaResponse captcha() { String uuid = UUID.randomUUID().toString().replace("-", ""); String code = generateCaptchaText(); // 4位随机数字或字母 BufferedImage image = generateCaptchaImage(code); redisTemplate.opsForValue().set("captcha:" + uuid, code, 5, TimeUnit.MINUTES); String base64Image = Base64.getEncoder().encodeToString(toByteArray(image)); return new CaptchaResponse(uuid, "data:image/png;base64," + base64Image); }登录接口的改动也很简单,先校验验证码,再走用户名密码认证:
public void verifyCaptcha(String uuid, String code) { String saved = redisTemplate.opsForValue().get("captcha:" + uuid); if (saved == null || !saved.equalsIgnoreCase(code)) { throw new BusinessException("验证码错误或已过期"); } redisTemplate.delete("captcha:" + uuid); // 一次性使用 }这里有个细节容易被忽略:验证码校验通过后一定要立即删除,防止同一个验证码被重放多次。如果不删,攻击者只需要在5分钟内反复尝试同一个验证码,能让暴力破解的成本降低很多。与其说这是验证码,更像一个轻量的一次性凭证,用过即焚。
6.2 SPA前端携带Token的标准姿势与CORS配置
前端配合的姿势其实很套路化,axios的请求拦截器统一加上请求头:
axios.interceptors.request.use(config => { const token = localStorage.getItem('accessToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });响应拦截器里处理401,调用刷新接口后重放失败请求,逻辑上没有太特殊的技术含量,但有一个小坑需要提前规避——401响应不代表Token一定过期了,后端给401的场景还可能包括权限不足。所以前端一般建议根据业务错误码来区分,而不是见401就跳登录页。
CORS这边也有细节。Spring Security集成CORS容易出问题的点是:你配了allowedOrigin("*"),但同时又要allowCredentials(true),这两个配置在浏览器规范里是冲突的,服务端会直接拒绝。Spring Security推荐的做法是通过CorsConfigurationSource统一配置,再把cors配置挂进SecurityFilterChain里,这样过滤器链上的CORS处理会早于认证逻辑触发,跨域请求的预检OPTIONS请求才能正常放行。
6.3 生产环境的最后一公里:用户状态、服务间调用与日志
JWT是纯无状态的,但也带来一个经典问题:用户已经被管理员禁用或者删除,手中的Token在过期之前仍然有效。解决思路通常是在过滤器里加入轻量级用户状态检查。我上面提到了用缓存优化loadUserByUsername,这里就能复用——每个请求从缓存里拿一下用户状态,禁用用户直接拒绝。缓存命中时开销非常小,却能把“用户被禁用后还能访问接口”这个痛点解决掉。
还有密码修改后的Token失效问题。常规做法是签发Token时把用户密码的最后修改时间戳放进claims,在过滤器校验时对比数据库中的修改时间。如果密码在Token签发之后被改过,直接判定Token失效,所有已签发的Token全部作废。这是一个很实用的强制下线手段,比维护黑名单简单得多。
日志脱敏再提一次。很多系统用户操作日志里会记录完整的请求头或请求参数,Token就在其中。我见过安全事故复盘报告,泄露渠道居然是Debug日志把Authorization整个打了出来。务必要在日志过滤器里对Authorization做掩码,或者干脆在生产环境禁止打印该Header。
7. 过滤器顺序、并发登录控制与无状态改造的经验复盘
7.1 过滤器顺序:为什么必须在UsernamePasswordAuthenticationFilter之前
这个部分的坑比较隐蔽。一次请求进入JwtAuthenticationFilter后,如果Token合法,我们向SecurityContextHolder写入了认证信息。问题在于:如果JWT过滤器放在UsernamePasswordAuthenticationFilter后面,底层逻辑就变成先尝试用Session或表单参数做一次认证,再执行JWT逻辑。对于什么都没有的API请求,表单过滤器的结果往往是“未认证”,走到FilterSecurityInterceptor时因为上下文里还没有认证信息,直接抛权限异常,我们的JWT过滤器根本没有执行机会。
正确的做法就是在UsernamePasswordAuthenticationFilter前面插队,抢先完成认证写入。这也是为什么Spring Security的配置代码里,addFilterBefore的参数几乎永远是UsernamePasswordAuthenticationFilter.class——这个类是整个认证流程的关键节点,所有自定义登录逻辑都围绕它做插入或替换。
7.2 并发登录控制:一个Token很难实现的业务需求
无状态认证在并发登录控制上天然比较弱。想要实现“同账号最多同时在线两个设备”这样的业务规则,需要额外引入会话控制逻辑。一个可行的方案是:登录成功时把userId:sessionId映射存进Redis,每次请求时校验当前Token对应的sessionId是不是Redis里的最新值,如果不是,拒掉旧Token。
这个方案改造起来对现有JWT体系影响很小,只需要在签发Token时生成一个随机sid放进claims,过滤器里追加一次Redis比对即可。缺点是一次请求多一次Redis访问,但对绝大多数业务来说,这个成本可以接受。
7.3 服务端无状态改造的边界:完全无状态是个伪需求
围绕JWT聊到最后,我想特别说一个观点:过度追求“服务端完全无状态”在真实业务里反而会制造问题。上文提到的验证码、Refresh Token撤销、用户黑名单、并发登录控制,哪一个都需要服务端状态。区别只是这些状态从Session里挪到了Redis里,而且只保存必要的短期状态,而不是整个会话内容。
所以我在项目中始终提倡的架构是:JWT负责常规认证,Redis负责轻量级状态,两者分工明确。既享受了JWT无状态带来的扩容优势,又能满足业务对强制下线、并发控制、验证码等实际需求。把这条边界想清楚,很多设计上的纠结会迎刃而解。
8. 结合项目的最终建议
如果你正在准备把Spring Security和JWT集成到项目里,我给出的验收建议很简单:跑通以后,不要急着上线部署,先做一遍十六个字的自查——改密钥权限,改Token过期,拉出一份完整的请求日志看看有没有Token泄漏,再模拟一次密钥泄露的场景看看能否快速轮换。
如果这些都没问题,剩下的就可以放心交给时间了。我个人的经验是,这套技术栈本身是可靠的,绝大多数安全事故都不是JWT本身被破解,而是错误使用方式导致的密钥泄露、算法混乱、过期时间形同虚设。把这篇文章里提到的坑一个个填掉,你就能得到一个称得上比较稳的认证体系了。