news 2026/10/26 5:59:08

Spring Security整合JWT登录认证:过滤器链、401排查与双令牌实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Security整合JWT登录认证:过滤器链、401排查与双令牌实践

上周有个做后台系统的朋友把一段 401 日志甩给我,说自己照着教程把 Spring Security 和 JWT 拼到了一起,登录接口能正常吐出 token,可拿着 token 去访问业务接口,照样被拦在门外。这种"能登录、不能访问"的状态,几乎是每个第一次整合 Spring Security 加 JWT 做登录认证的人都会经历的阶段。我把这套链路在几个不同规模的后台系统里反复搭过,也帮人排查过不少类似问题,今天就把从设计取舍到代码落地、再到线上排障的完整过程摊开讲一遍。这篇内容适合已经会用 Spring Boot 但被 Security 的过滤器链绕晕的同学,也适合那些跑通了 demo、但不敢直接上生产的开发者。我会把每一步"为什么这么写"讲透,代码直接可抄,参数为什么取这个值也会给出计算过程。

1. 整体设计与选型:先想清楚再动手

整合 Spring Security 和 JWT 这件事,代码量其实不大,真正花时间的是想清楚边界。我见过太多项目,一开始图快,把 token 校验逻辑散落在各个 Controller 里,等到要加角色权限、要支持多端登录、要做强制下线时,发现整个认证层得推倒重来。所以这一章先不谈代码,把几个绕不开的决策讲清楚。

1.1 从一次 401 说起:问题往往出在链路理解上

那位朋友的问题最后定位到过滤器注册了两次。他的JwtAuthFilter类上挂了@Component,同时又在配置类里new了一个实例塞进过滤器链,Spring Boot 会把这个@Component的 Filter 自动注册到 Servlet 容器的过滤器列表里,于是请求进来被解析两遍,第二遍时 token 已经被消费过一次,上下文被清理,最终表现为"看起来 token 是对的,但就是没认证上"。

这个例子说明一件事:Spring Security 的认证不是"调个方法校验一下"那么简单,它是一整套基于过滤器链的上下文传递机制。你要让一个请求从"匿名"变成"已认证",需要三件事同时成立——过滤器在正确的时机执行、SecurityContext 被正确填充、授权规则在上下文填充之后才做判断。任何一环错位,表现出来的都是 401 或 403,但根因完全不同。

所以排查这类问题的第一步永远不是改代码,而是搞清楚请求在这条链上走到了哪一步。我一般的做法是在过滤器和AuthenticationEntryPoint里各打一行日志,把请求路径、是否携带 Authorization 头、解析结果、最终上下文里的 Principal 打出来。三行日志基本能定位八成问题。

1.2 无状态会话与有状态会话,到底怎么选

JWT 的核心卖点是"无状态",但无状态不是免费的。传统 Session 方案里,服务端持有会话数据,用户改密码或者管理员踢人,删掉 session 立刻生效;JWT 一旦签发,在过期之前服务端天然无法单方面作废,这是它的结构性缺陷,不是实现问题。

我在选型时通常按这三个维度判断:

维度Session 方案JWT 方案
水平扩展需要共享存储或粘性会话天然支持,多实例无压力
主动失效直接删会话,实时生效需要额外黑名单或版本号机制
跨端支持移动端、第三方对接较麻烦天然适配多端与开放接口
服务端存储成本与在线用户数成正比基本为零

如果系统是单体后台,用户量在几千以内,其实 Session 加 Redis 共享完全够用,还省掉了主动失效的麻烦。但如果是前后端分离、要多端登录、或者后面可能拆成多个服务,JWT 更省事。我自己的习惯是:只要是前后端分离的项目,默认走 JWT,同时在设计阶段就把"强制下线"的兜底方案留好,别等出事再补。

这里要提醒一点,无状态不等于"什么都不能存"。合理的做法是 token 本身不落库,但保留一个用户维度的tokenVersion字段,改密码或踢人时把版本号加一,校验时比对。这样既保持了无状态的扩展性,又拿到了近乎实时的失效能力,代价只是每次请求多一次缓存查询。

1.3 两个组件各自的职责边界

Spring Security 和 JWT 不是平级的东西,很多人混淆是因为教程把它们写在一起。准确地说:

  • JWT 是凭证格式,它负责"我怎么证明我是我",解决的是令牌的签发、携带、解析、校验。
  • Spring Security 是认证授权框架,它负责"这次请求该不该放行",解决的是过滤器链、上下文管理、权限表达式、异常处理。

换句话说,JWT 只是 Spring Security 众多认证方式中的一种。你可以换成 OAuth2、换成 Session、换成 API Key,Spring Security 的骨架不变,变的只是"谁来生产 Authentication 对象"。理解这一点,你就知道整合的重点不在 JWT 工具类,而在怎么把解析出来的身份信息包装成一个Authentication塞进SecurityContextHolder。

职责划清之后,代码结构就自然了:JWT 相关的东西(签发、解析、载荷定义)放一个独立的 service;与 Security 交互的部分(过滤器、UserDetailsService、异常处理器)放安全配置包里;业务代码完全感知不到这两者的存在,它只知道@PreAuthorize("hasRole('ADMIN')")或者从上下文里取当前用户 ID。

注意:业务层不要直接依赖 JWT 工具类,也不要自己在 Controller 里检查 token 有效性。一旦业务代码开始感知令牌,这套认证就再也换不动了。

2. 架构拆解:一次请求在过滤器链里经历了什么

上一章讲的是"为什么这么分",这一章讲"它是怎么跑的"。如果你能把一个带 token 的请求在过滤器链里的完整路径画出来,后面所有配置项就都有了归属,不再是照抄。

2.1 过滤器链的执行顺序,决定 JWT 过滤器插在哪

Spring Security 的过滤器链是有严格顺序的,官方的FilterOrderRegistration里定义了每个内置过滤器的位置。和我们要打的交道最多的几个,按执行先后大致是:CorsFilter→CsrfFilter→LogoutFilter→UsernamePasswordAuthenticationFilter→DefaultLoginPageGeneratingFilter→RequestCacheAwareFilter→SecurityContextHolderFilter→AnonymousAuthenticationFilter→ExceptionTranslationFilter→AuthorizationFilter。

关键点在于:认证必须在授权之前完成。AuthorizationFilter是链条末尾做放行判断的那个,所以任何负责填充上下文的过滤器都必须排在它前面。JWT 过滤器最常见的位置是addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class),这个位置足够靠前,能保证后续所有环节都能拿到已认证的上下文。

还有两个容易被忽略的细节。一是SecurityContextHolderFilter(旧版本叫SecurityContextPersistenceFilter)会在请求结束时清理上下文,所以你在异步线程里拿不到 Principal 是正常的,需要手动传递。二是AnonymousAuthenticationFilter会给没有认证信息的请求塞一个匿名 Authentication,这也是为什么未登录访问受保护接口时,某些配置下返回的是 403 而不是 401——匿名用户被当成"已认证但没有权限"处理了。

2.2 认证与授权是两个阶段,异常类型完全不同

这条经验是我踩坑踩出来的:401 和 403 的区别不是随便定的,它精确对应两个不同阶段。

  • 认证阶段失败(没带 token、token 过期、签名不对、用户不存在)→ 抛AuthenticationException→ 由AuthenticationEntryPoint处理 → 返回 401。
  • 授权阶段失败(认证过了,但没有访问该资源的角色)→ 抛AccessDeniedException→ 由AccessDeniedHandler处理 → 返回 403。

问题在于,当请求完全没带凭证时,AnonymousAuthenticationFilter会塞一个匿名身份,这个匿名身份在AuthorizationFilter眼里是"已认证的",于是它抛的是AccessDeniedException,前端就收到了 403。这会让前端很困惑:"我明明没登录,为什么说我没权限?"

解决办法有两个:一是在ExceptionTranslationFilter层面的判断里,如果当前是匿名身份,就转交给AuthenticationEntryPoint;二是配置.exceptionHandling()时把两个处理器都显式指定,并且在前端约定里,403 也可能意味着"需要重新登录"。我一般采用第一种,它更符合 HTTP 语义,前端拦截器只要判断 401 就跳登录页,逻辑最简单。

2.3 令牌载荷怎么设计才不臃肿

JWT 的 payload 是 Base64 编码不是加密,任何人拿到 token 都能解开看内容,这一点务必记住。所以载荷设计的第一原则是:不放敏感信息,不放体积大的数据。

我常用的字段规划是这样的:

字段类型说明是否必填
substring用户唯一标识(建议用 ID 而非用户名)是
iatnumber签发时间戳(秒)是
expnumber过期时间戳(秒)是
issstring签发方标识,便于多服务区分是
jtistring令牌唯一 ID,用于黑名单视需求
rolarray角色编码列表是
vernumber用户令牌版本号,用于强制失效视需求

为什么用 ID 而不是用户名做sub?因为用户名可能被改,改完之后旧 token 里的sub就对不上人了。ID 是不变的,能稳定关联。

为什么角色要放进 token?因为这样每次请求不用查库就能做权限判断,是性能的关键。但代价是角色变更后旧 token 里的角色还是旧的,直到过期。如果系统对权限实时性要求高,就在 token 里只放sub和ver,角色每次从缓存读,用一点性能换准确。这是个典型的取舍,我通常的做法是:角色变更频率低的系统放 token 里,权限敏感的系统走缓存。

至于为什么不用Authorization之外的自定义头——没必要。标准头的好处是所有网关、代理、客户端库都认,中间件不用改配置。唯一要避免的是把 token 放在 URL 参数里,那会被访问日志、Referer 头到处记录下来。

3. 落地实操:把认证链路完整跑通

前面两章是"想",这一章是"做"。下面这套代码是我在几个项目中沉淀下来的版本,基于 Spring Boot 3.x + Spring Security 6.x 的写法,如果你还在 5.x,WebSecurityConfigurerAdapter那套已经废弃了,建议直接升级思路。

3.1 依赖与最小配置基线

依赖不用多,四个就够:

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

选 jjwt 而不是java-jwt或者nimbus-jose-jwt,理由很实际:jjwt 的 API 封装得比较顺手,0.12 之后把parserBuilder简化成了parser(),链式写法读起来舒服;nimbus功能更全但配置偏重,做单纯的服务端签发校验有点杀鸡用牛刀。这个选择不影响架构,哪天要换也就是改一个工具类的事。

配置文件里我把密钥和时效都抽出来,密钥走环境变量,绝对不要写死在 yml 里提交到仓库:

app: jwt: secret: ${APP_JWT_SECRET} issuer: internal-auth access-ttl: 30m refresh-ttl: 7d clock-skew: 60s spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo_auth username: demo password: ${DB_PASSWORD}

clock-skew这个配置值得单独说。JWT 的exp是绝对时间戳,如果签发服务器和校验服务器之间有几秒到几十秒的时钟漂移,就会出现"刚签发的 token 立刻被判过期"或者"过期了还能用一会儿"的诡异现象。jjwt 支持设置允许的时钟偏移,60 秒是个常见值,比 NTP 的正常抖动大一点,又不至于让过期失效变得太宽松。

3.2 密钥生成与令牌工具类:参数是怎么算出来的

先说密钥。HS256 的签名强度依赖密钥长度,规范要求密钥至少 256 位,也就是 32 字节。如果你用 16 字节的字符串,jjwt 会直接抛WeakKeyException拒绝启动,这是好事,省得你上线后才发现签名可被暴力破解。

生成方式我一般直接用命令行:

openssl rand -base64 48

48 字节原始熵,Base64 编码后是 64 个字符,远超 HS256 的 32 字节下限,留足了余量。为什么不用刚好 32 字节?因为实际部署时密钥经常要被复制粘贴到配置中心、容器环境变量、CI 变量里,中间任何一次编码转换都可能截断字符,多留一截容错更稳妥。

时效参数也需要算。access-ttl定 30 分钟的依据是:一方面要覆盖用户一次连续操作的时长(写一篇文档、填一张表单),另一方面要限制 token 泄露后的可利用窗口。如果你的业务是纯浏览型,可以放宽到 2 小时;如果是资金类操作,缩到 10 分钟配合刷新令牌更合适。refresh-ttl定 7 天,对应"一周内不用重新登录"的产品预期。

工具类长这样:

@Component public class JwtService { private final SecretKey key; private final String issuer; private final Duration accessTtl; private final Duration refreshTtl; private final Duration clockSkew; public JwtService(@Value("${app.jwt.secret}") String secret, @Value("${app.jwt.issuer}") String issuer, @Value("${app.jwt.access-ttl}") Duration accessTtl, @Value("${app.jwt.refresh-ttl}") Duration refreshTtl, @Value("${app.jwt.clock-skew}") Duration clockSkew) { byte[] raw = Base64.getDecoder().decode(secret); if (raw.length < 32) { throw new IllegalStateException("secret too short for HS256: " + raw.length + " bytes"); } this.key = Keys.hmacShaKeyFor(raw); this.issuer = issuer; this.accessTtl = accessTtl; this.refreshTtl = refreshTtl; this.clockSkew = clockSkew; } public String issueAccessToken(Long userId, List<String> roles, long version) { return build(userId, roles, version, accessTtl); } public String issueRefreshToken(Long userId) { Instant now = Instant.now(); return Jwts.builder() .subject(String.valueOf(userId)) .issuer(issuer) .id(UUID.randomUUID().toString()) .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(refreshTtl))) .claim("typ", "refresh") .signWith(key) .compact(); } private String build(Long userId, List<String> roles, long version, Duration ttl) { Instant now = Instant.now(); return Jwts.builder() .subject(String.valueOf(userId)) .issuer(issuer) .id(UUID.randomUUID().toString()) .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(ttl))) .claim("rol", roles) .claim("ver", version) .claim("typ", "access") .signWith(key) .compact(); } public Claims parse(String token) { return Jwts.parser() .verifyWith(key) .requireIssuer(issuer) .clockSkewSeconds(clockSkew.toSeconds()) .build() .parseSignedClaims(token) .getPayload(); } }

这段里有两个点值得展开。requireIssuer(issuer)是防御性设计,防止有人拿另一个系统的 token 来访问你的服务——虽然密钥不同本来就验不过,但显式校验签发方能给你更清晰的报错。typ字段区分 access 和 refresh 是必须的,否则刷新令牌可以直接当访问令牌用,等于把有效期放大到 7 天,安全边界就没了。

3.3 安全配置类:SecurityFilterChain 的完整写法

Spring Security 6 之后统一用组件式配置,一个@Bean搞定:

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter, RestAuthEntryPoint authEntryPoint, RestAccessDeniedHandler deniedHandler) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(reg -> reg .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll() .requestMatchers("/api/public/**", "/actuator/health").permitAll() .anyRequest().authenticated()) .exceptionHandling(e -> e .authenticationEntryPoint(authEntryPoint) .accessDeniedHandler(deniedHandler)) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); } }

逐条解释几个不能省的。

csrf关掉是因为我们不用 Cookie 承载凭证,CSRF 攻击的前提是浏览器自动携带 Cookie,纯 token 方案不存在这个问题。如果你的方案里 refresh token 放在 HttpOnly Cookie 里,那 CSRF 就得开,这是另一个话题。

sessionCreationPolicy(STATELESS)是核心,它告诉 Security 不要创建也不要用 HttpSession。少了这行,Security 会在认证成功后把上下文写进 session,你的无状态设计就名存实亡了,多实例部署时反而会出各种诡异的登录态丢失。

OPTIONS放行是给 CORS 预检用的。浏览器在发跨域的非简单请求之前,会先发一个 OPTIONS 探路,这个请求不带 Authorization 头,如果不放行,预检直接 401,真正的请求根本发不出去。这个坑我在三个项目里都见过,前端同学会告诉你"请求根本没到后端,控制台只有一个 CORS 报错"。

BCryptPasswordEncoder(12)里的 12 是计算强度,对应 2^12 轮哈希。实测在普通开发机上单次大约 200 毫秒,生产服务器会快一些。这个值直接决定了登录接口的吞吐上限——单机每秒大概能处理几十次登录,如果你有秒杀级的登录洪峰,得配合限流,或者把强度降到 10(约 50 毫秒)。但别降到 10 以下,BCrypt 的设计就是故意慢的,慢了才抗暴力破解。

3.4 登录接口:AuthenticationManager 的正确用法

很多人写登录是自己查库比对密码,其实没必要,交给AuthenticationManager更好,因为它把"查用户、验密码、处理账号状态"这套逻辑统一了,将来加账号锁定、密码过期、多因子,都在 Provider 层扩展,接口不用改。

@RestController @RequestMapping("/api/auth") public class AuthController { private final AuthenticationManager authManager; private final JwtService jwtService; private final UserService userService; @PostMapping("/login") public LoginResponse login(@RequestBody @Valid LoginRequest req) { Authentication auth = authManager.authenticate( new UsernamePasswordAuthenticationToken(req.username(), req.password())); LoginUser principal = (LoginUser) auth.getPrincipal(); String access = jwtService.issueAccessToken( principal.getId(), principal.getRoles(), principal.getTokenVersion()); String refresh = jwtService.issueRefreshToken(principal.getId()); return new LoginResponse(access, refresh, "Bearer", 1800); } }

配套的UserDetailsService实现里,有两点必须注意。第一,用户不存在时抛UsernameNotFoundException还是统一抛BadCredentialsException——我选后者,因为前者会让攻击者通过错误信息判断某个用户名是否存在,属于信息泄露。第二,UserDetails的isEnabled、isAccountNonLocked、isAccountNonExpired这几个状态位要老老实实映射数据库字段,AuthenticationManager会在验密码之前检查它们,账号被锁定时直接拒绝,省得你到处写判断。

登录成功后的响应里带上expiresIn(秒),前端可以据此提前触发刷新,比让前端自己解析 token 里的exp靠谱得多,也避免前端依赖 JWT 结构。

3.5 JWT 过滤器:上下文填充的三个细节

过滤器是整个链路的心脏,写法不难,细节容易错:

public class JwtAuthFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserVersionCache versionCache; public JwtAuthFilter(JwtService jwtService, UserVersionCache versionCache) { this.jwtService = jwtService; this.versionCache = versionCache; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7).trim(); try { Claims claims = jwtService.parse(token); if ("access".equals(claims.get("typ", String.class))) { Long userId = Long.valueOf(claims.getSubject()); long tokenVer = claims.get("ver", Long.class); long currentVer = versionCache.getVersion(userId); if (tokenVer == currentVer) { List<String> roles = claims.get("rol", List.class); var authorities = roles.stream() .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) .toList(); var authentication = new UsernamePasswordAuthenticationToken( userId, null, authorities); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (JwtException | IllegalArgumentException ex) { SecurityContextHolder.clearContext(); } } chain.doFilter(request, response); } }

细节一:OncePerRequestFilter保证同一请求内只执行一次,内部转发和 include 不会重复走。它有个坑——默认对ASYNC和ERROR分发也生效,如果你用了异步 Servlet,可以用shouldNotFilterAsyncDispatch()控制。

细节二:解析失败时只清理上下文,不直接返回错误。让请求继续往下走,最后由授权阶段统一判定。这样做的好处是,公开接口即使带了过期 token 也能正常访问,不会因为"带了个坏 token"被拒绝。这个设计对前端很友好,你可以在拦截器里统一处理 token 失效,而不用每个请求都判断是不是公开接口。

细节三:过滤器不要加@Component,改用@Bean或者直接在配置类里new出来。原因就是开头说的那个双注册问题——Spring Boot 会自动把容器里的Filter类型 Bean 注册到 Servlet 过滤器链,导致它执行两次。如果你非要加@Component,那就必须同时注册一个FilterRegistrationBean把它的setEnabled(false)。

3.6 401 与 403 的统一响应

最后一块是把异常转成前端能理解的 JSON。默认情况下 Security 会返回一个 HTML 登录页或者空白 403,前后端分离场景下没法用。

@Component public class RestAuthEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest req, HttpServletResponse resp, AuthenticationException ex) throws IOException { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType(MediaType.APPLICATION_JSON_VALUE); resp.setCharacterEncoding(StandardCharsets.UTF_8.name()); resp.getWriter().write("{\"code\":40101,\"msg\":\"登录状态已失效,请重新登录\"}"); } }

AccessDeniedHandler的写法类似,状态码换成 403,错误码用 40301。错误码体系建议从一开始就规划好,认证类 401xx、权限类 403xx、参数类 400xx,前端拦截器可以按前缀做通用处理,比如 40101 就清本地凭证跳登录,40102(token 过期)就先尝试刷新。这套约定比让前端解析中文字符串要稳得多。

4. 进阶:刷新令牌、主动失效与并发控制

把上面的跑通,基本功能就有了。但真上生产,还得解决三个问题:token 过期后用户不能无感续期、管理员踢人踢不掉、同一账号多处登录互相覆盖。这一章讲这几个。

4.1 双令牌方案与无感续期

单 token 方案要么时效短导致频繁掉线,要么时效长导致风险窗口大。双令牌是常见解法:access token 短效(30 分钟)、refresh token 长效(7 天),前者用于业务请求,后者只用于换新的 access token。

刷新接口的逻辑:

@PostMapping("/refresh") public LoginResponse refresh(@RequestBody RefreshRequest req) { Claims claims = jwtService.parse(req.refreshToken()); if (!"refresh".equals(claims.get("typ", String.class))) { throw new BadCredentialsException("wrong token type"); } Long userId = Long.valueOf(claims.getSubject()); String jti = claims.getId(); if (refreshStore.isRevoked(jti)) { throw new BadCredentialsException("refresh token revoked"); } // 旋转:旧 refresh 立即作废,签发新的 refreshStore.revoke(jti, jwtService.refreshTtlRemaining(claims)); LoginUser user = userService.loadById(userId); String newAccess = jwtService.issueAccessToken( userId, user.getRoles(), user.getTokenVersion()); String newRefresh = jwtService.issueRefreshToken(userId); return new LoginResponse(newAccess, newRefresh, "Bearer", 1800); }

这里的刷新令牌旋转是关键。每次刷新都用旧的换新的,旧的立即失效。这样如果 refresh token 被盗用,攻击者用一次之后,真实用户下次刷新就会失败,等于变相告警。代价是服务端要存一份已用 jti 的记录,通常放缓存,过期时间对齐 token 剩余有效期即可,不会无限膨胀。

存储量的估算也有意思。假设日活 1 万,每人每天刷新 2 次,refresh token 有效期 7 天,那缓存里同时存在的记录大约是 1 万 × 2 × 7 = 14 万条。每条存 jti 加过期标记,几十字节,总共不到 10 MB,内存完全扛得住。要是日活 100 万,那就得考虑分片或者缩短有效期了。

前端配合上要注意:刷新请求本身不能被拦截器再次触发刷新,否则会死循环。常见做法是刷新接口单独用裸的 fetch,或者在拦截器里判断 URL。

4.2 主动失效:黑名单、版本号与并发登录

JWT 的失效问题有三种主流解法,各有适用场景。

黑名单方案:把已作废的 token 的 jti 存起来,校验时查一遍。优点是实现直接,缺点是要为每个被拉黑的 token 存到它自然过期,而且每次请求多一次查询。适合作废操作不频繁的场景。

版本号方案:用户表加一个token_version字段,签发时写进 token,校验时比对。改密码、踢人时版本号加一,该用户所有旧 token 全部失效。优点是存储成本固定(每个用户一个数字),缺点是粒度粗,一踢就是全端下线。这个方案我用得最多,因为它覆盖面刚好,实现又简单,版本号放缓存里,性能损耗可以忽略。

白名单方案:只承认服务端记录过的 token。这等于回到了有状态,失去了 JWT 的扩展性优势,除非有强合规要求,一般不选。

并发登录控制可以基于版本号做变体,比如把token_version拆成device_version,按端区分,允许"手机端和电脑端同时在线,但同端只能有一个"。实现上就是校验时比对对应端的版本号,登录时更新那一端的值。这是个很小的改动,但产品体验上差别很大,很多用户会同时在多个浏览器登录同一个账号。

注意:版本号方案有个隐藏前提——所有校验节点都能读到同一个版本号。如果你用的是本地缓存而不是集中式缓存,多实例之间会有延迟。缓存失效时间建议控制在 10 秒以内,或者干脆在关键操作后主动推送失效。

5. 常见问题与排查实录

这一章是我这些年被问得最多的几类问题,整理成对照表,遇到时可以直接查。

5.1 症状对照表

症状大概率原因排查动作
登录接口本身返回 401登录路径没放行,或AuthenticationManager未注入检查permitAll路径是否精确匹配
带 token 访问仍 401过滤器没执行、双注册、上下文被清在过滤器和 EntryPoint 各打日志
未登录访问返回 403 而非 401匿名身份被当作已认证在 EntryPoint 里判断AnonymousAuthenticationToken
跨域请求失败,控制台报 CORSOPTIONS 预检被拦放行HttpMethod.OPTIONS
token 刚签发就提示过期服务器时钟不同步配置clockSkewSeconds,检查 NTP
改密码后旧 token 还能用没做失效机制加 token 版本号比对
异步接口里拿不到当前用户上下文不跨线程用DelegatingSecurityContextExecutor包装线程池
多实例部署登录态时有时无没设 STATELESS,上下文写进了 session加sessionCreationPolicy
网关转发后 token 丢失网关没透传 Authorization 头检查网关的请求头白名单

这张表里,我最想强调的是"异步接口拿不到当前用户"这一条。Spring Security 的上下文默认存在ThreadLocal里,你把任务丢给线程池,新线程自然读不到。解决办法不是手动传参,而是用DelegatingSecurityContextExecutor包装线程池,它会在线程切换时把上下文带过去,用完再还原。这个包装一行代码的事,但不知道的人会绕很久。

5.2 几条踩出来的经验

第一条,密钥管理不要偷懒。我见过把密钥写死在代码里、跟着 Git 提交上去的,也见过所有环境共用一个密钥的。正确做法是每个环境独立密钥,通过环境变量或配置中心注入,定期轮换。轮换时会有一个新旧密钥并存的过渡期,这时候校验要支持多密钥,签发用新密钥。jjwt 可以用Jwts.parser().keyLocator(...)按kid头选择密钥,提前设计好这个扩展点,轮换时就不用停机。

第二条,不要在 token 里塞权限明细。有人把用户的菜单权限、按钮权限全塞进rol或自定义 claim,token 长度直接涨到几 KB,每个请求都要带这么大的头,网关日志也跟着膨胀。正确做法是 token 里只放角色编码,细粒度权限走缓存查询。我在一个项目里量过,把权限明细从 token 里挪走之后,平均请求头体积降了 70%,效果立竿见影。

第三条,登录失败要有熔断。这套认证只解决"凭证是否正确",不解决"是否有人在撞库"。同一 IP 或同一账号连续失败若干次后,必须锁定一段时间或者上验证码。这个逻辑可以放在AuthenticationProvider里,也可以放在登录接口的前置切面里。我倾向于放在 Provider 里,因为它是认证逻辑的一部分,放这里能覆盖所有认证入口。

第四条,日志要脱敏。token 千万别整体打进日志,截取前 8 位加后 8 位足够定位问题。异常堆栈里也可能带 token,尤其是JwtException的 message 有时会包含解析片段,记得在全局异常处理器里过滤。

第五条,测试用例要覆盖边界。至少要写这几个:过期 token、签名被篡改的 token、算法被改成none的 token、typ不对的 token(用 refresh 冒充 access)、sub对应用户已删除的 token。这几个用例加起来不到五十行,但能挡住绝大部分认证绕过问题。特别提一下none算法攻击——早期有些库默认接受alg: none的 token,等于完全无签名,jjwt 现在默认拒绝,但你如果用别的库,务必确认这一点。

第六条,前后端对时间的理解要一致。后端算的exp是 UTC 时间戳,前端如果拿本地时间比较,时区一错就乱套。统一用"距离过期还有多少秒"来传递,前端用Date.now()加秒数算,能避开时区问题。

6. 这一套下来,我心里的一条底线

写到这里,认证链路算是完整了。回头看,真正难的不是那些配置和代码,而是每次做取舍时问自己一句:这套设计的失效边界在哪里,出了事我能多快兜住。

我自己的底线是这么几条:密钥永远不进代码仓库,所有环境的密钥独立;access token 有效期不超过 1 小时,无论业务怎么要求;强制失效的通道必须常备,哪怕平时不用,也不能等到需要的时候现做;认证相关的一切异常,日志必须可追溯,但 token 本身不能落盘。

还有个小习惯分享给做同样事情的你——每接一个新项目,我都会先用 Postman 把"无 token 访问受保护接口""带过期 token 访问""带 refresh token 访问业务接口"这三个场景跑一遍,确认返回码和错误码都符合预期,再开始写业务。这三分钟花下去,后面能省掉好几天的联调扯皮。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 5:58:07

Pandas缺失值处理:按数据类型定制化方案

1. 项目概述&#xff1a;为什么Pandas里“空”比你想象的更复杂“数据分析-Pandas的类型数据的缺失处理”——这标题看着平平无奇&#xff0c;但我在带某高校数据科学实训课时发现&#xff0c;超过七成的学员卡在同一个地方&#xff1a;不是不会写df.dropna()&#xff0c;而是根…

作者头像 李华
网站建设 2026/10/26 5:58:01

Java技术栈下企业级Agent Harness的设计与实践

我们团队花了大半年时间&#xff0c;把 AI Agent 的前期原型从 Python 搬到 Java&#xff0c;最后沉淀出了一个内部代号叫 BizBuddy 的 Agent Harness 平台。整个过程踩过的坑不少&#xff0c;但最核心的收获是&#xff1a;企业级 Agent 应用真正缺的不是“能调用大模型”的脚手…

作者头像 李华
网站建设 2026/10/26 5:58:01

MySQL索引原理与B+树数据结构详解

1. 为什么数据库需要索引&#xff1f;在深入探讨MySQL索引的数据结构之前&#xff0c;我们需要先理解索引存在的根本意义。想象一下&#xff0c;你面前有一本500页的电话簿&#xff0c;里面记录了100万条联系人信息&#xff0c;现在需要找到"张三"的电话号码。如果没…

作者头像 李华
网站建设 2026/10/26 5:58:01

多模态对比学习避坑指南:数据、训练与评估实战经验

多模态对比学习这个方向&#xff0c;我从两年前开始断断续续地做&#xff0c;中间换过三个数据集、两套训练框架&#xff0c;踩过的坑如果全部写下来大概能出一本小册子。今天挑几个印象最深、也最容易让后来者重复踩的坑展开聊聊。需要先说明的是&#xff0c;这篇不是教程&…

作者头像 李华
网站建设 2026/10/26 5:57:54

Triplanar纹理映射技术解析与Unity实现

1. Triplanar节点技术解析Triplanar映射是一种先进的3D纹理投影技术&#xff0c;它通过同时在X/Y/Z三个轴向平面进行纹理采样&#xff0c;然后根据表面法线方向混合采样结果。这种技术最早由ShaderX系列技术书籍提出&#xff0c;现已成为游戏开发领域的标准解决方案之一。1.1 核…

作者头像 李华
网站建设 2026/10/26 5:57:48

CUDA安装完全指南:驱动、Toolkit与cuDNN版本关系及报错排查

很多刚接触 CUDA 的朋友&#xff0c;第一次动手装环境就懵了&#xff1a;NVIDIA 官网有驱动&#xff0c;又有 CUDA Toolkit&#xff0c;还有 cuDNN&#xff0c;到底该下哪个&#xff1f;装完以后&#xff0c;nvidia-smi显示一个 CUDA 版本&#xff0c;nvcc -V又显示另一个&…

作者头像 李华