2026/10/11 17:35:07

Spring Boot认证实战:JWT、Filter与Interceptor协作解析

Spring Boot认证实战:JWT、Filter与Interceptor协作解析 刚接手某个后端项目时我翻代码看到请求链路里既有OncePerRequestFilter又有HandlerInterceptor注册再加上一堆JwtTokenUtil的静态方法说实话第一反应是这架构是不是重叠了。但真正把线上问题排查一遍之后才明白这三个东西从来不是一个选择题——它们在 Spring Boot 的请求生命周期里各管一段配合得好认证鉴权才算真正闭环。这篇就把我对 JWT 令牌、过滤器 Filter、拦截器 Interceptor 的理解和踩过的坑一次讲清楚。1. 认证链路的演进从 Cookie-Session 到 Token 的关键转变要理解 Filter 和 Interceptor 为什么存在得先搞明白 JWT 这类令牌方案解决了什么老问题。早期单体应用大多用 Session 认证用户登录后服务端在内存或 Redis 里存一份 sessionId浏览器通过 Cookie 自动携带这个 ID。这套方案直观但也有几个绕不过去的痛服务端要维护会话状态集群部署时得引入会话共享移动端调用接口时 Cookie 处理比较别扭CSRF 攻击风险还得额外防护。引入 Token 之后整个逻辑变成用户登录成功后服务端签发一个结构化令牌返回给客户端客户端在后续请求的Authorization头里带上它。服务端不保存会话记录只需要验签就能确认请求身份——这就是无状态认证。JWTJSON Web Token是 Token 体系里标准化的一个实现三段式结构Header.Payload.Signature每一段都是 Base64URL 编码中间用点号隔开。JWT 的核心价值在于自包含Payload 里可以塞用户 ID、角色、过期时间等声明签名保证了内容没有被篡改。但正因为它自包含服务端无法主动让一个已签发的 token 失效除非引入黑名单或短过期时间这个特性后面会影响不少设计决策。从实际部署角度看JWT 的选择往往不是因为它比 Session 更先进而是因为它切合前后端分离、接口跨端、微服务间鉴权等场景。理解了这一点很多源码里的设计才能看得通。2. 过滤器 Filter 与拦截器 Interceptor 的执行顺序先弄清楚请求在哪一步被谁处理Spring Boot 中一个 HTTP 请求的完整链路大致为Tomcat 接收请求之后先经过 Servlet Filter 链再进入 DispatcherServlet再被 HandlerInterceptor 拦截最后才到达 Controller。认证和鉴权这两个动作理论上可以在不同层级做选哪一层取决于你要不要访问 Controller 或方法参数。2.1 Servlet Filter容器级的前置处理Filter 是 Servlet 规范的一部分它不感知 Spring 的存在。在 Spring Boot 里注册 Filter 有两种常用方式一种是写一个实现Filter接口的类并标Component但要注意这会让 Filter 对所有 URL 生效顺序依赖Order另一种是定义FilterRegistrationBean手动指定 URL 模式与顺序。Filter 最大的特点就是够早。在一些框架设计里CORS 处理、字符编码、日志记录都会放在 Filter 层因为它们要在请求进入业务代码之前进行处理。认证相关若放在 Filter 层核心逻辑是从Authorization头或 Cookie 里取出 token若能验签且未过期则把用户信息放入上下文比如ThreadLocal或RequestContext然后放行否则直接终止链路返回 401。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 (StringUtils.hasText(token) tokenProvider.validateToken(token)) { Long userId tokenProvider.getUserId(token); JwtUserContext.set(new JwtUserContext(userId, tokenProvider.getRoles(token))); } 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; } }这里有个很容易踩的坑Filter 里一旦验签失败是立刻返回 JSON 错误还是放行让后续拦截器去处理我倾向于 Filter 只做解析且校验通过则注入上下文的动作把明确的拒绝逻辑放在 Interceptor 层。这样 Filter 逻辑更纯粹也方便排查。2.2 HandlerInterceptorMVC 层面的业务拦截Interceptor 是 Spring MVC 的机制它已经进入 DispatcherServlet 的处理器映射阶段能拿到HandlerMethod意味着你能在代码里读到 Controller 类和方法上的注解。这一层比 Filter 更适合做URL 级 注解级的细粒度权限校验。Interceptor 有三个回调方法preHandle在 Controller 方法调用前执行返回false则中断请求postHandle在 Controller 方法执行后、视图渲染前执行afterCompletion在请求完成后触发类似 finally。对认证场景主要在preHandle里判断上下文里有没有用户信息、是否有权限访问。注册拦截器需要自定义配置类实现WebMvcConfigurer并重写addInterceptors方法Configuration public class WebMvcConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; public WebMvcConfig(AuthInterceptor authInterceptor) { this.authInterceptor authInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/login, /api/auth/refresh, /api/public/** ); } }addPathPatterns和excludePathPatterns的组合是 Interceptor 最直观的控制入口。白名单之外的所有接口都会被拦而具体的 Url 匹配规则以 Ant 风格为准——/api/**是匹配任意层级/api/*只匹配一层这个细节不熟悉的话很容易出现明明放行了却还在被拦截的问题。2.3 两者的层级对比与误用陷阱很多初学者会问既然两者都能做认证为什么还要同时用因为层级不同决策时机不同。Filter 做的最早可以做全局限流、全链路 TraceId 注入、CORS、编码处理Interceptor 偏业务能感知具体是哪个 Controller 哪个方法适合做这个接口要求什么权限的判断。维度FilterInterceptor标准归属Servlet 规范Spring MVC 组件触发时机进入 DispatcherServlet 之前进入 Controller 方法之前能否获取 HandlerMethod不能能适合做的事CORS、编码、通用前置处理、认证解析权限校验、业务日志、注解校验是否经 Spring 管理不一定取决于注册方式是一个常见的误用是试图在 Filter 里读到RequiresRole注解——这是做不到的因为 Filter 阶段处理器映射还未完成你拿不到对应的HandlerMethod。反过来你在 Interceptor 里做 WebSocket 握手拦截也会失效因为 WebSocket 握手不属于 MVC 的请求路径。所以架构设计的第一原则是越底层的机制越通用越上层的机制越精细按需选择才是正解。3. JWT 的结构与验签细节令牌为什么能自证清白JWT 的三段结构分别对应Header声明签名算法与令牌类型Payload存放业务声明如sub、exp、iat、rolesSignature则是根据 Header 和 Payload 生成的签名。签名过程是HMACSHA256(base64UrlEncode(Header) . base64UrlEncode(Payload), secret)接收方可以用同一个密钥或公钥来验证签名是否匹配以此确认内容未被篡改。3.1 HMAC 与 RSA/ECDSA 的选择逻辑签名算法常见的有两类对称算法 HS256 需要同一个密钥做签名和验签适合单体内部校验非对称算法 RS256/ES256 用私钥签名、公钥验签适合多服务之间共享验证能力。我维护过的内部系统一般用 HS256因为密钥只在一个服务里管理而微服务网关做统一校验时会倾向用 RS256公钥可以下发私钥只在认证中心持有。密钥管理是 JWT 安全的核心但常被忽视。HS256 的 secret 至少要有一定长度和随机性不能用一个简单固定字符串。很多项目的配置里写着jwt.secret: abc123这等于把门锁的备用钥匙写在门口。用对称算法时我还会把 secret 放到环境变量或配置中心而不是提交进 Git 仓库同时定期轮换。轮换的平滑过渡方式是在 TokenProvider 里维护一个 keyId 到 secret 的映射验签时根据 Header 的kid字段取对应密钥。3.2 过期时间、续签与不可控失效JWT 的过期时间是硬性限制exp声明一旦过期就无法通过验签。于是过期了怎么续签就成了设计要点。常见方案有Refresh Token Access Token 双令牌机制。Access Token 短过期如 15 分钟Refresh Token 长过期如 7 天客户端在 Access Token 过期前用 Refresh Token 换取新的 Access Token。但这里有个工程细节Refresh Token 是否允许被记住如果每次刷新都依赖于同一个未变更的 Refresh Token一旦泄露就相当于永久凭证泄露。我的做法是Refresh Token 采用一次性使用服务端在刷新后返回新的 Refresh Token旧的一律作废。这样可以把泄露窗口控制在单个刷新周期内。由于 JWT 无状态服务端没法主动踢人——除非维护一个黑名单或把版本号带上。对于后台管理系统管理员封禁某用户这类场景我会额外引入一个用户全局 token 版本号存 Redis验签时对比 Payload 里的版本号与该用户当前版本号不匹配则拒绝。这样既保留 JWT 的轻量特性又补上了主动失效的能力。3.3 Payload 里该放什么、不该放什么Payload 适合放与认证鉴权直接相关的声明比userId、username、roles、exp、iat不适合放敏感数据因为 Base64URL 编码类似明文只是做了可传输的编码处理。不要在 JWT 里放身份证号、手机号、密码哈希。另外 Payload 体积会直接附加在每个请求头上放越多数据请求越臃肿。还有一个容易犯的错误直接在 Payload 里塞整个用户对象包括不需要的字段这会让 token 体积膨胀而且一旦字段更新token 里的数据还是旧的。更好的做法是只存用户 ID 和权限标识需要完整信息时再查一次缓存或数据库。4. 三方协作的完整链路设计从请求打进来到 Controller 拿到用户信息前面分别介绍了三个组件现在把它们串成一套完整的请求认证链路。以一个典型的/api/order/list请求为例看每一步分别被谁拦截、做了什么4.1 链路拆解Filter、Interceptor、Controller 各司其职第一步请求进入 TomcatJwtAuthenticationFilter先执行。它从请求头尝试解析 Bearer Token如果存在且验签通过就往ThreadLocal上下文中塞入 userId 和角色列表。这个阶段的失败不需要抛异常——只需要保持上下文为空。第二步请求进入 DispatcherServlet被AuthInterceptor拦截。preHandle检查上下文是否存在用户信息若不存在说明要么没带 token要么 token 非法此时直接返回 401 JSON。若存在再检查当前请求对应的 HandlerMethod 是否标注了权限注解比如PreAuthorize(hasRole(ADMIN))不满足返回 403。第三步Controller 方法正常执行。方法内部需要当前登录用户时从上下文工具类获取即可不再重复解析 token。整个链路下来认证和鉴权职责被分离在两处Filter 只负责你是谁的解析Interceptor 负责你能不能进这个门的决策Controller 专注于业务本身。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } JwtUserContext.User user JwtUserContext.get(); if (user null) { writeJson(response, 未认证, 401); return false; } // 校验权限注解 RequiresRole requiresRole handlerMethod.getMethodAnnotation(RequiresRole.class); if (requiresRole ! null !hasAnyRole(user, requiresRole.value())) { writeJson(response, 无权限, 403); return false; } return true; } }这其实引出了一个设计判断不一定每个项目都要同时用 Filter 和 Interceptor。如果系统只需要登录了才能访问所有接口只写一个 Interceptor 就够了如果还要做 CORS、网关级校验、响应体包装等那 Filter 是必不可少的。核心是理解每层的边界而不是追求代码分层最多。4.2 ThreadLocal 上下文的传递与清理陷阱用ThreadLocal存用户上下文时最大的隐患是线程池复用导致的用户信息串号。比如请求 A 在一个线程里设置了用户 A请求处理完后没清掉线程被线程池回收后再服务请求 B如果 B 代码里没有重新写入用户上下文就可能读到 A 的信息。这个 bug 极其隐蔽而且难以复现。解决办法是请求处理结束时必须清理。在 Filter 的finally块里执行JwtUserContext.clear()是最保险的方式因为 Filter 的包裹范围包含了所有 Controller 层级的逻辑try { filterChain.doFilter(request, response); } finally { JwtUserContext.clear(); }同样的道理也适用于拦截器的afterCompletion但因为异步请求或异常传播路径的差异Filter 的 finally 更可靠。我多次在异步处理的场景下验证过拦截器可能因抛异常而不走afterCompletion而 Filter 的 finally 一定能执行。4.3 跨域与自定义头的配置问题前后端分离项目里Authorization头属于自定义请求头跨域时必须显式允许。很多项目踩过的坑是CORS 配置里只允许了常见的内容类型和请求方法没加上Authorization结果接口从前端发出去变成 Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response。Spring Boot 里做 CORS 配置时要么用CorsFilter要么实现WebMvcConfigurer#addCorsMappings。不管哪种方式allowedHeaders里要带上AuthorizationexposedHeaders里如果前端需要读取响应头比如刷新后的新 token也要一并配置。还有一个细节allowCredentials(true)时allowedOrigins不能使用*必须明确指定来源。5. 实战中遇到的 401/403 的排查链路与经验沉淀说实在的理论都能懂线上问题才是最磨人的地方。我这里整理几个实战高频坑以及对应的排查思路。5.1 常见问题对照表与定位路径现象可能根因排查手段所有 /api 请求都返回 401token 未从请求头取出或 Filter 未生效日志打印 Filter 是否执行检查 Base64URL 解码是否区分填充字符某些接口放行失败拦截器路径匹配规则写错确认 Ant 规则用测试请求逐个验证登录后第一次请求正常后续偶发 403ThreadLocal 未清理导致用户信息混淆给上下文对象加请求 ID日志对比token 过期后刷新仍失败Refresh Token 与 Access Token 的过期时间设置有逻辑冲突查看时间戳比对确认刷新接口是否也在拦截器白名单内生产环境验签失败本地正常密钥/算法配置被覆盖对比配置中心的 secret确认是否存在多份配置优先级覆盖我见过一个典型问题Filter 注册用了Component同时又通过FilterRegistrationBean注册了一次导致过滤器执行了两遍字段被覆盖还不好查。Spring Boot 会直接忽略同名同类型的重复注册但如果你用不同的 Bean 名称注册了同一个 Filter 对象就会出现双执行。排查时可以输出一次 filter 调用的堆栈一翻就知道有几个 Filter 实例。5.2 一次完整的排错过程请求为何被 401 拦在了 Filter 外有一次对接一个小程序端前端开发反馈登录后所有接口都返回 401。我一看服务端日志没有报异常Filter 逻辑也没打印。于是我在 Filter 入口加了一行日志发现这个请求根本没进 Filter。继续查原来前端是用 WebSocket 连接走的接口而 WebSocket 握手在 Tomcat 的 servlet 层面由专用处理器管理Spring Boot 的Filter默认不会拦截容器的 WebSocket 通道。后来改用握手拦截器来处理认证问题就解决了。另一次是批量接口偶尔返回 403排查发现是ThreadLocal清理时机不对。那段代码在 Controller 方法执行后没有清理线程池复用把上一个请求的用户信息带给了下一个请求。最典型的场景是接口里异步调用了别的服务子线程又去读用户上下文因为ThreadLocal不跨线程传播空指针和 403 就这么产生。我当时的处理是在调用异步任务前手动把需要的用户 ID 作为参数传入而不是在子线程里再去取上下文。5.3 设计层面的取舍经验什么情况下只用 Filter什么情况必须加 Interceptor如果目标只是给一组接口做统一身份校验Filter 可以直接读原始请求头然后决定放行或拒绝这种做法的好处是不依赖 Spring MVC 环境单元测试里模拟起来也相对简单。但如果需要更细粒度的权限判断——比如某些接口只允许管理员操作——就一定需要 HandlerMethod 上的注解信息这只有 Interceptor 才能提供。一个务实的选择标准是认证解析在 Filter权限决策在 Interceptor。如果微服务网关已经统一做了认证解析那么下游服务甚至可以不写 Filter直接把 filterChain 透传使用网关写入的 userId 请求头。如果能做到认证统一、鉴权分散团队协作时依赖关系会清晰不少。另外还要注意和 Spring Security 的关系。如果项目已经引入了 Security 的过滤器链再手写一套 JWT Filter 时容易跟 Security 的UsernamePasswordAuthenticationFilter或BearerTokenAuthenticationFilter冲突。Spring Security 本身就是基于 Filter 构建的它有自己的内部顺序。与其叠加一套自定义 Filter不如实现 Security 的过滤器或使用 Security 的认证机制把 JWT 解析挂在它的过滤器链里。每当我看到项目里同时有 Spring Security 和一堆自定义认证 Filter就基本可以断定逻辑会比较混乱了。5.4 关于性能与缓存的一个提醒JWT 验签本身是一次签名算法计算HS256 的效率很高并不需要格外担心。但每次请求都去数据库查用户即使有 token 也要拖慢响应。所以我在 Filter 里解析完 token 后会优先尝试从 Redis 缓存里拿用户信息缓存未命中再回表查库。这里有一个取舍如果对数据一致性要求很严缓存策略要谨慎否则用户已被封禁这类操作可能因为缓存未同步而延迟生效——这也是我在 3.2 里引入版本号再去校验的原因。把这些心得总结一下其实就是一句话Filter、Interceptor、JWT 各自解决的问题不同把它们放在一条链路上各司其职认证和鉴权的整体架构才算真正稳定。这块内容对新手来说最大的价值不是背住某个具体实现而是建立起请求生命周期每个阶段适合做什么的全局视图。以后再遇到认证相关的 bug你能直接定位到是哪个环节出了问题。如果你准备在项目里落地这套链路建议先用最小的接口跑通 FilterJWT 解析再加 Interceptor 的权限注解校验最后补上 Refresh Token 和 ThreadLocal 清理逻辑。一步步来比一次性铺开复杂设计要稳得多。