ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Spring Boot登录校验:JWT+Filter+Interceptor实战指南

Spring Boot登录校验:JWT+Filter+Interceptor实战指南 登录校验 JWT、Filter、Interceptor做了几年后端登录校验这块踩过的坑比写的业务代码都多。从最开始用 Session 存登录态到后来前后端分离、接口被各个端调用之后Session 那套越来越别扭跨域要配一堆东西、服务端要维护会话状态、集群部署还得做 Session 同步。这时候 JWT 加 Filter 加 Interceptor 的组合成了主流解法。这篇文章就把我实际项目中这套登录取证方案的完整思路、核心代码和排坑经验讲清楚。适合刚接触 Spring Boot 做登录模块的后端开发以及准备从 Session 迁移到 JWT 的老项目维护者。1. 登录校验的整体设计思路1.1 为什么放弃 Session 改成 JWTSession 模型的核心是服务端存储用户登录后服务端生成一个 sessionId 写进 Cookie后续请求带上这个 ID服务端再去内存或 Redis 里查会话记录。单体应用下没毛病但到了前后端分离、接口要同时服务 Web 端和移动端的场景问题就来了第一移动端对 Cookie 的支持不如浏览器干净很多客户端框架处理 Cookie 得额外引库。第二服务端会话是状态化的部署多副本时得做 Session 共享引入 Redis 之后架构复杂度直接上一个台阶。第三一旦会话数据膨胀内存压力也跟着上来。JWTJSON Web Token走的是另一条路服务端把用户身份信息加密签名后直接发给客户端客户端后续请求带着这个 Token服务端验签通过就认为身份合法。服务端不需要存任何会话天然无状态多副本部署不需要额外同步。这点在现在的微服务和前后端分离架构下非常关键。需要说明的是JWT 不是银弹。它牺牲了一个能力Token 在有效期内无法主动失效登出操作只能靠客户端丢弃 Token。所以后面我会讲到续签和黑名单策略怎么配合把这套方案做得更完善。1.2 Filter 和 Interceptor 到底分工做什么很多新手搞不清 Filter 和 Interceptor 的区别其实它们对应的层次和应用场景完全不同。Filter 是 Servlet 规范里的东西在请求进入 DispatcherServlet 之前执行能拿到原始的 HttpServletRequest 和 HttpServletResponse也能拦截所有请求包括静态资源。Interceptor 是 Spring MVC 的组件在 HandlerMapping 定位到具体的 Controller 方法之后执行相当于在请求进入 Controller 之前做一个门卫。放在登录校验的场景里我的习惯做法是Filter 层处理 Token 的解析和校验决定这个请求是否放行。因为它是 Servlet 容器层面最早的入口可以统一处理请求头解析完 Token 后把用户信息写进 ThreadLocal 或直接放到 request attribute 里。Interceptor 层处理更业务化的逻辑比如判断当前用户有没有访问某个接口的权限、记录操作日志、注入用户上下文到业务代码。但实际项目中很多人只用一个就够。如果你只有登录校验的需求单独用 Filter 或 Interceptor 都能实现关键区别在于Filter 能拦到进入 Spring MVC 之前的请求包括静态资源和一些非 Controller 的转发Interceptor 只能管到 Controller 这一层。另外 Filter 由容器管理天然能处理跨域场景下的预检请求 OPTIONS这个在前后端分离项目中非常关键。2. JWT 的核心原理与安全细节2.1 三段式结构拆解JWT 由三部分组成Header头部、Payload载荷、Signature签名用点号连接形如xxxxx.yyyyy.zzzzz。Header 里放的是签名算法和 Token 类型Payload 里是业务声明比如用户 ID、用户名、过期时间Signature 则是用密钥对前两部分做签名后的结果。eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U这里的核心是 Signature 的生成过程把 Header 和 Payload 分别做 Base64Url 编码再用点号拼接最后用 HMAC SHA256对应 HS256 算法或 RSA对应 RS256签名。这个设计到底解决了什么问题Token 是发给客户端的数据没人能保证客户端不篡改。如果用户把自己的 userId 从 1001 改成 1002服务端验签时就会发现签名对不上直接拒绝。签名等于给 Token 加了一把防伪锁这是 JWT 比普通随机字符串 Token 更优雅的地方。2.2 密钥管理和算法选择签名算法选择上内部系统用 HS256 足够它是对称密钥生成和校验用同一个 secret简单高效。但对面向第三方的开放平台要用 RS256它用私钥签名、公钥验证签发方和验证方分离避免密钥泄露导致所有 Token 可伪造。密钥管理的几个实操要点重点1HS256 的 secret 不要写死在代码里放到配置中心或环境变量长度至少 32 字节建议 64 字节以上。有人用jwt或123456当密钥等于把锁的钥匙挂在门口。重点2历史上出现过将算法从 RS256 降级为 HS256 的攻击手法。攻击者把 Token 的 Header 改成 HS256再用已知公钥当密钥去签名如果服务端校验时没有指定算法就会验签通过。解决方法是校验时固定算法类型不要直接读取 Header 里的 alg。过期时间设置上我一般把登录 Token 设成 2 小时刷新 Token 设成 7 天。太短体验差太长安全性差。具体数字按业务调整但核心原则是最小有效时间。3. Filter 层实现登录校验的落地代码3.1 自定义 AuthFilter 的完整实现Filter 的正确打开方式是继承OncePerRequestFilter而不是直接实现Filter接口。原因在于一次请求经过转发后普通 Filter 会执行多次而这个抽象类保证了每个请求只执行一次过滤逻辑。下面是我项目里沉淀下来的一套通用写法Component public class AuthFilter extends OncePerRequestFilter { private static final String AUTH_HEADER Authorization; private static final String TOKEN_PREFIX Bearer ; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 放行预检请求CORS OPTIONS if (OPTIONS.equalsIgnoreCase(request.getMethod())) { filterChain.doFilter(request, response); return; } String authHeader request.getHeader(AUTH_HEADER); if (authHeader null || !authHeader.startsWith(TOKEN_PREFIX)) { // 无 Token 的直接放行交给 Interceptor 层判断 filterChain.doFilter(request, response); return; } String token authHeader.substring(TOKEN_PREFIX.length()); try { Claims claims JwtUtil.parseToken(token); // 将用户信息放入 request 属性供后续使用 request.setAttribute(userId, claims.get(userId)); request.setAttribute(username, claims.get(username)); } catch (Exception e) { // Token 无效直接返回 401 response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登录状态已失效请重新登录\}); return; } filterChain.doFilter(request, response); } }这里有个设计上的取舍Filter 层只负责解析有效 Token不做强制拦截。对于没有带 Token 的请求选择放行到后续的 Interceptor 再根据具体接口是否需要登录来决定是否拦截。好处是 Filter 不用背哪些 URL 必须登录这个配置清单把职责拆得更干净。3.2 注册方式和放行规则配置Filter 使用Component注解后会被 Spring Boot 自动注册但默认拦截路径是/*也就是所有请求。更重要的是多个 Filter 的执行顺序由Order注解控制登录校验 Filter 应该放在编码过滤器之后但放在跨域过滤器之前或紧邻其后。实际项目中容易踩的一个坑是Filter 中出现了非法 Token就返回 401。但有些接口是匿名可访问的这时候即便客户端莫名其妙传了一个过期 Token 过来也不应该直接拦死。所以更稳的做法是Token 合法就解析并放入上下文Token 不合法但请求的接口允许匿名访问就让它继续走由 Interceptor 做最终拦截判断。放行规则配置方面我推荐将所有无需登录的接口统一维护在一个配置类里public class PermitUrls { public static final ListString ANONYMOUS_URLS Arrays.asList( /api/auth/login, /api/auth/register, /api/captcha, /doc.html, /webjars/** ); }注意/doc.html和/webjars/**这类接口文档资源也需要放行否则 Swagger 页面会因为无法加载资源而白屏。这个细节我见过不少项目遗漏。4. Interceptor 的精细化控制4.1 拦截器注册与路径匹配Interceptor 的使用分两步写一个HandlerInterceptor实现类在WebMvcConfigurer里注册。核心代码如下Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 非接口请求直接放行可能是静态资源或错误页 if (!(handler instanceof HandlerMethod)) { return true; } Object userId request.getAttribute(userId); if (userId null) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\请先登录\}); return false; } // 将用户信息放入 ThreadLocal业务层可以直接取用 UserContext.setUserId(Long.valueOf(userId.toString())); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束必须清理 ThreadLocal防止线程池复用导致数据串号 UserContext.clear(); } }注册配置类Configuration public class WebConfig implements WebMvcConfigurer { Autowired private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /api/captcha); } }这个配置的含义是/api/开头的所有请求都走拦截器但排除登录、注册、验证码三个接口。路径匹配用的是 Ant 风格*匹配单级路径**匹配多级路径这个语义要记清楚。4.2 用户上下文传递与 ThreadLocal 的正确清理上面代码里我用了UserContext这个 ThreadLocal 工具类目的是让业务代码优雅获取当前登录用户不用每个方法都从 request 里取值传参public class UserContext { private static final ThreadLocalLong USER_ID new ThreadLocal(); public static void setUserId(Long userId) { USER_ID.set(userId); } public static Long getUserId() { return USER_ID.get(); } public static void clear() { USER_ID.remove(); } }高并发下最容易出的问题就是ThreadLocal没有及时清理。Tomcat 的工作线程是复用的这次请求往 ThreadLocal 里塞了 A 用户的 ID下次请求如果是 B 用户而 B 请求没有走到setUserId比如匿名接口那getUserId拿到的就还是 A 的 ID这就是典型的越权 Bug。所以我再三强调clear()方法必须放到afterCompletion里执行。这个方法无论 preHandle 是否通过、Controller 是否抛异常只要进入过拦截器就一定执行是清理 ThreadLocal 最可靠的位置。4.3 Filter 和 Interceptor 的协作边界回到最开始的问题既然我们有 Filter 解析 Token又用 Interceptor 拦截未登录请求为什么不让 Filter 一个干了原因在于职责分离带来的灵活性。举两个真实的场景第一个场景接口级权限控制。系统里有/api/admin/**只有管理员能访问这个判断依赖用户角色信息属于业务逻辑放在 Interceptor 里比放在 Filter 里更自然因为它要配合 HandlerMethod 拿 Controller 方法上的RequireRole注解做判断这个能力 Filter 做不到。第二个场景可插拔的校验策略。某些接口希望临时放开登录限制做个活动用 Interceptor 的excludePathPatterns改一行配置就搞定不需要动 Filter 代码。而 Filter 的路径匹配能力本来就弱改起来还容易影响全局。所以我的结论是Filter 管Token 怎么验Interceptor 管哪些接口要验两个配合使用各自职责内聚后续扩展权限、防刷、日志等功能时不会互相打架。5. Token 续签、登出与常见问题排查5.1 Token 续签的实现方案JWT 的无状态是一把双刃剑Token 在有效期内永远有效但用户操作到一半 Token 过期了体验非常差。续签的主流方案有两种。第一种是双 Token 方案。登录时同时返回 accessToken有效期短比如 30 分钟和 refreshToken有效期长比如 7 天。客户端每次请求带 accessToken当接口返回 401 时客户端用 refreshToken 调用刷新接口换取新的 accessToken。这个方案实现清晰但客户端需要处理异步重放逻辑稍微复杂一点。第二种是滑动续期方案。每次请求时检查 Token 剩余有效期如果低于某个阈值比如剩余不到一半时间就在响应头里返回一个新的 Token客户端用新 Token 替换旧 Token。实现简单体验也顺滑。我通常选第二种理由很实际它不需要客户端改造太多逻辑响应头里放个X-Token字段前端 axios 拦截器加几行代码就能处理。不过要注意这种方式会让短时间高频请求频繁刷新 Token所以续签阈值别设太激进比如 Token 有效期 2 小时剩余低于 20 分钟才触发续签。5.2 登出与 Token 黑名单前面说过 JWT 无法真正失效那登出功能怎么做实际项目中最实用的方案是维护一个黑名单布隆过滤器或 Redis Set。登录时给每个 Token 一个jtiJWT ID字段登出时把这个jti塞进 Redis设置过期时间和 Token 剩余有效期一致。Filter 校验 Token 时同时查一下黑名单查到了直接拒绝。借用 Redis 的过期机制黑名单数据不用手动清理到点自动消失。String jti UUID.randomUUID().toString(); // 生成 Token 时放入 claims.put(jti, jti); // 登出时加入黑名单 redisTemplate.opsForValue().set(logout: jti, 1, tokenExpireDuration, TimeUnit.MILLISECONDS); // 校验时查询 boolean isLogout redisTemplate.hasKey(logout: jti);这个方案算是用一点有状态换取关键安全性的折中在实际生产中足够可靠。如果连 Redis 都不想引入至少也要把密钥轮换机制做好出现问题时有快速让所有 Token 失效的后手。5.3 常见问题速查表问题现象根因分析解决方案Filter 里解析 Token 成功但 Interceptor 拿不到用户请求转发导致 Filter 多次执行改用 OncePerRequestFilter保证只执行一次Token 能生成但接口一直 401算法混淆攻击或密钥不匹配校验时固定算法类型检查签名密钥一致性登录后访问接口偶发 401Token 过期时间太短且没有续签机制引入滑动续签或双 Token前端同步处理新 Token多个 Filter 执行顺序错乱没有配置 Order 或 Order 数值错误明确编码、跨域、登录校验 Filter 的优先级并固定部署多个实例后登录状态不稳定各实例用不同的签名密钥密钥统一走配置中心或环境变量保证所有实例一致上传文件接口 Token 解析正常但超时大文件上传被 Filter 拦截处理时间过长对上传接口单独放行或调整 Filter 的超时策略ThreadLocal 用户信息串号请求结束未清理线程变量在 Interceptor 的 afterCompletion 中调用 clear()排查这类问题最有效的顺序是先看请求头里 Token 有没有携带不对就查前端再用 JWT 官网的 Debugger 工具验证 Token 是否有效能解析说明生成过程没问题最后看服务端过滤链日志确认请求走到哪一层被拦的。5.4 接口文档与跨域这两个附加坑Swagger 接口文档挂/doc.html的一定要把资源路径加到放行名单里不然前端拿着 token 去调文档里的接口调试时页面上一堆报错体验极差。开发环境可以在配置里单独开关拦截器生产环境再严格放行。跨域问题上前后端分离项目最常见的 401 不是真的没登录而是 OPTIONS 预检请求被拦截了。浏览器发起带自定义 Header 的跨域请求时会先发一个 OPTIONS 探路如果 Filter 直接对这个请求返回 401浏览器就认为跨域失败真实的 POST 请求根本发不出去。所以任何 Filter 里都要先判断OPTIONS方法直接放行这是前提条件。6. 这套方案的扩展空间做了基础登录校验之后我强烈建议把后续扩展提前想好不然功能越加越乱。接口幂等和防重放可以基于 JWT 的jti字段做同一个jti出现两次就拒绝能在不改接口签名的情况下预防一部分重放攻击。细粒度权限控制可以在 Interceptor 里配合HandlerMethod上的自定义注解实现比到处写if (hasRole(ADMIN))要优雅得多。操作日志直接复用 Filter 解析出来的用户信息在afterCompletion里记录接口耗时、状态码和操作人不需要侵入业务代码。我还见过把登录校验做到网关层的架构网关统一验签然后把用户信息通过 Header 传给下游服务。本质上思路都一样Token 解析和校验前置业务服务只管从上下文中拿当前用户。这套模式在微服务架构下更省事值得提前了解。写到这里关于 JWT、Filter、Interceptor 的登录校验方案基本上讲全了。最后给还没动手改造项目的朋友一个建议不要一上来就追求双 Token 加黑名单的完整方案先把 Filter 验签加 Interceptor 拦截这套主链路跑通再根据业务需要逐步加上续签和黑名单。登录校验是整个系统的安全底座宁可多花一点时间设计清楚也别上线之后出现一次越权事故再改那个时候的代价可比写这几百行代码大多了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表