ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

JWT、Filter与Interceptor协同实现登录认证与Token续签实践

JWT、Filter与Interceptor协同实现登录认证与Token续签实践 做Java后端这几年要说哪三个词被放在一起讨论最多JWT、Filter、Interceptor绝对排得上号。每次面试官问“认证怎么做”、每次联调时前端同事问“token过期怎么办”背后都是这三者在协同工作。很多新手容易把Filter和Interceptor搞混又把JWT当作“加密工具”等真正上手写代码时才发现这三者之间的关系和边界远不是背两个概念能搞定的。这篇文章我把这套组合拳完整拆开讲。从JWT的原理边界讲起到Filter和Interceptor各自的定位再落到登录认证、Token续签、常见漏洞和排错实录全部用我实际开发中验证过的代码和方案。不管你是在做传统后端接口还是SPA项目对接这篇文章都能帮你把这套认证链路彻底理顺。1. 先把三样东西掰开揉碎——JWT、Filter、Interceptor到底各自负责什么1.1 JWT不是“加密”它是“签名自包含”很多人第一次接触JWT第一反应是“把用户信息加密一下”。这句话其实只说对了一半。JWT全称是JSON Web Token它的核心机制是签名不是加密。加密的目的是让数据不可读签名的目的是让数据不可篡改。JWT由三段构成Header头部、Payload载荷、Signature签名中间用点号分隔形如xxxxx.yyyyy.zzzzz。Header里声明了算法和token类型Payload里放用户标识、过期时间、签发时间等业务字段Signature则是用密钥把前两段内容做哈希后再签名。这个结构决定了JWT是自包含的——服务端不需要存session只要密钥在手拿到token就能验真伪、读身份。这里有个关键点很多人没想明白Payload里的内容默认是Base64Url编码的不是加密的。也就是说任何人把token拿去解码都能直接看到你放在里面的用户名、角色、邮箱等信息。我在项目里就见过有人把手机号、身份证号直接放Payload这等于把敏感信息写在门牌上。JWT的设计初衷是“防篡改”不是“防偷看”想隐藏内容需要额外做加密不要让JWT背这个锅。1.2 Filter和Interceptor一个在门外一个在门内Filter是Servlet规范里的组件属于Web容器层它的执行时机在所有请求进入Servlet之前。这意味着Filter能拦到静态资源、能修改请求和响应对象甚至能在请求到达DispatcherServlet之前就把请求挡回去。Filter的作用域是“全局”的只要在web.xml或Spring Boot里注册了它就管理所有匹配路径的请求。Interceptor是Spring MVC提供的组件它工作在DispatcherServlet内部在HandlerAdapter执行处理器方法之前介入并且只能拦截Controller层的请求静态资源它管不了。Interceptor和HandlerExecutionChain绑定可以在请求处理前、处理后、完成渲染后三个时机插入逻辑天然跟业务方法绑定得更紧密。拿生活类比Filter是小区门口的保安所有进出的人和车都要过一遍不管是业主还是外卖员Interceptor是单元楼的门禁只有要进你这层楼的人才会经过。保安管的是“能不能进小区”门禁管的是“能不能上这层楼”。在开发里Filter适合做全局的编码处理、跨域、日志记录Interceptor适合做登录校验、权限判断、接口调用统计这类跟Controller业务直接相关的逻辑。1.3 三个概念存在的意义为什么不只用Session既然Session认证模式已经存在这么多年为什么现在大家纷纷转向JWT最直接的原因是分布式和前后端分离的架构变了。传统Session模式下用户登录信息存在一台服务器的内存里做负载均衡时要么做Session粘滞要么引入Redis统一存储这套方案的维护成本随着节点数量直线上升。JWT的出现把状态“踢”给了客户端。服务端无状态化之后水平扩展变得极其轻松——任何一台机器只要持有同样的密钥就能验证同一个token不再需要关心用户之前登录在哪台机器上。对SPA项目、移动端App来说JWT配合Authorization头也比CookieSession更契合。但无状态也带来了问题token一旦签发在自然过期之前很难主动作废。用户改密码、账号被顶下线、管理员禁用账号这些场景用原生JWT都不好处理。所以现在主流的做法不是“全盘JWT替代Session”而是JWT做短时效的访问凭证、Redis或数据库做可撤销的会话状态两边配合边界就清晰了。Filter和Interceptor这两个“关卡”存在的意义正是把认证这件事从业务代码里抽出来放到统一的管道层去处理。2. 三者在登录认证中的分工协作——从登录到请求放行的完整链路2.1 登录接口签发Token的那一步登录认证的起点永远是登录接口。用户在登录页输入用户名密码服务端校验通过后生成JWT返回给前端。这一步没有技术含量但有两个设计细节值得注意。第一个细节是token里放什么。我的习惯是只放userId和username必要时加一个role字段其他像邮箱、手机号、头像这类信息一律不放。为什么因为token是每次请求都要携带的信息越多HTTP头部越臃肿而且这些信息放在Payload里是明文可见的泄露出去反而增加风险面。要拿用户详细信息前端拿到token后可以再调一次/user/info接口。第二个细节是过期时间的设置。访问token的过期时间不宜过长我一般设置为30分钟到2小时。设置太长会导致token泄露后的风险窗口变大设置太短又会引发频繁重新登录的糟糕体验。折中方案是短token配合刷新机制也就是后面要讲的续签方案。登录成功后的返回结构也有讲究。不能只返回一个token字符串建议返回一个包含accessToken和expireAt的对象让前端明确知道这个token多久过期方便前端提前做续签触发。另外别忘了登录接口本身要限流防止爆破。2.2 用Filter还是Interceptor来做认证校验这是让很多开发者纠结的问题——认证校验到底写在Filter里还是Interceptor里目前主流的Spring Security做法是在Filter链里做认证。但如果你没用Spring Security自己实现认证逻辑时我建议按下面这个标准来选。如果你的项目是纯REST API或者你对“是否登录”的判断需要在所有请求上生效包括静态资源、自定义Servlet那就用Filter。Filter的执行时机最早在请求到达业务代码之前就把非法请求挡掉了性能开销最小逻辑也最集中。如果你的项目是Spring MVC项目且只需拦截Controller层的接口用Interceptor就足够了。Interceptor可以拿到HandlerMethod对象可以精准判断“这个方法上是否有RequirePermission注解”做细粒度的权限控制比Filter方便得多。我这里给一个实际推荐认证校验用Filter做因为登录状态的判断是全项目统一的需求权限校验用Interceptor做因为权限需要根据不同接口动态判断。Filter解决“你是谁”的问题Interceptor解决“你能做什么”的问题职责分离互不干扰。2.3 用户信息更新后如何让Token“感知”到一个现实场景用户在系统里修改了自己的手机号或者管理员更改了用户的角色。这个时候用户当前持有的token里如果还存着旧手机号或旧角色就会出现数据不一致的问题。更麻烦的是用户在修改密码后旧的token在到期前依然有效。这个问题的本质是JWT的无状态特性。没有服务端状态自然无法感知变化。解决方案不外乎三种第一种把用户信息做成动态查询token里只放userId业务代码里每次从数据库或缓存获取最新用户信息权限判断也用最新数据这是最推荐的做法第二种引入会话状态管理把token的“有效性版本号”存在Redis里用户信息变更时自增版本号旧的token自动失效第三种修改密码时强制让该用户的所有token过期通过维护一个用户维度的token黑名单或刷新版本号实现。我在实际项目中通常采用“第一种第二种”的组合token里只放userId缓存里存“token版本号”每次校验时比对版本。这样既保留了JWT无状态的大部分优势又解决了用户信息变更后token不感知的问题。注意引入Redis验证版本号会让一次请求多一次缓存查询需要权衡性能。3. Token续签方案——从“快到期”到“无缝切换”3.1 续签的几种主流思路Token过期是开发中绕不过去的痛点。用户正填着表单突然提示登录过期填的内容全丢了这种体验说“灾难”不为过。JWT续签方案主要有三种思路我逐一讲清楚适用场景和取舍。第一种是固定过期时间重新登录最简单粗暴适合管理后台、内部系统这种低频使用的场景缺点是用户频繁被踢下线。第二种是滑动续签用户每次请求时检查剩余有效期如果token剩余的存活时间少于某个阈值就签发一个新的token返回给前端。实现最简单但缺点是只要用户在持续使用token理论上可以永久续签下去安全性上要打折扣。第三种是双Token方案也就是一次登录签发两个token——短期有效的accessToken和长期有效的refreshToken。accessToken过期后用refreshToken换取新的accessTokenrefreshToken本身也会定期轮换。SPA项目基本都用这种方案。续签方案选型要结合业务性质来定。金融类、支付类系统对token生命周期要求严格滑动续签不合适社区类、工具类产品体验优先双Token方案会更顺手。在继续往下看之前先确定你的业务允许token“永续”还是必须定期重新认证。3.2 双Token方案实现步骤双Token方案最核心的设计是refreshToken不放在前端localStorage里而是放在HttpOnly Cookie中且作用域严格限制在认证接口的路径下。这样做的原因有两个localStorage很容易被XSS脚本读取一旦泄露攻击者可以冒充用户无限刷新token而HttpOnly Cookie里JavaScript无法访问天然免疫XSS窃取同时配合Secure标志保证只走HTTPS。具体实现流程是这样的登录成功后服务端生成两个tokenaccessToken有效期30分钟refreshToken有效期7天。refreshToken存入Rediskey为refresh:{userId}:{随机串}value为过期时间同时写入HttpOnly Cookie。前端把accessToken存内存变量每次请求通过拦截器自动带上Authorization头。当accessToken过期时接口返回401前端拦截到后调用/auth/refresh接口服务端校验Cookie里的refreshToken存在且未过期就签发新的accessToken并返回。这里有一个容易被忽略的细节refreshToken必须是“一次性”的。每次刷新成功后旧的refreshToken立即作废签发一个新的refreshToken并更新Redis和Cookie。如果不做一次性消费refreshToken被截获后攻击者可以长期使用。另外刷新接口一定要校验refreshToken和用户是否匹配并且对刷新频率做限制防止被恶意利用批量刷新。3.3 续签过程中的并发问题双Token方案有一个经典的并发坑前端同时发起多个请求所有请求都带着快过期的accessToken服务端陆续返回401前端拦截器被触发多次向刷新接口同时打多个请求导致refreshToken被刷新多次前面几次的刷新结果作废最终用户被莫名其妙踢下线。解决思路是给前端的刷新操作加一个“互斥锁”。用JavaScript实现时用一个全局变量标记是否正在刷新如果刷新请求还没返回后续的401请求都等待同一个刷新Promise完成而不是各自发起新的刷新请求。具体的做法是把刷新请求封装成一个单例式的Promise多个401回调共享同一个Promise刷新完成后统一重放之前的请求。服务端也要做相应兜底刷新接口在极短时间内收到同一个refreshToken的并发请求时只处理第一个后面的直接拒绝并返回当前最新token或者通过Redis的原子操作确保refreshToken只能被消费一次。我在项目里把刷新接口的并发控制和服务端一次性校验都做了实战下来稳定性明显提升。4. JWT安全漏洞与防坑指南——这些坑我全踩过4.1 最常见的几类JWT漏洞JWT不是绝对安全的它有一堆已知的攻击手段我挑最常遇到的三类讲。第一类是算法混淆攻击。很多旧的JWT库在验签时没有严格指定算法攻击者把token的Header里的alg字段改成none服务端就跳过了签名校验或者把RS256改成HS256用公钥当作HMAC密钥来伪造签名。这个漏洞的本质是服务端没有锁定算法。解决办法很直接在解析token时明确指定算法比如Jwts.parser().setSigningKey(key).parseClaimsJws(token)并且对algnone的token直接拒绝。第二类是弱密钥爆破。HS256是对称算法加密解密用同一个密钥。很多人图省事用secret、123456这类弱密钥结果token被在线字典库十几分钟就跑出来了。攻击者拿到密钥后理论上是想生成谁的token就生成谁的。这类攻击的防护就是密钥必须足够长、足够随机建议至少32字节以上并且定期轮换。第三类是token泄露。token放在URL里通过?token传递会被日志、浏览器历史、代理服务器记录放在localStorage里XSS一打就穿。这类问题属于使用姿势错误防护手段是token只放Authorization头或HttpOnly Cookie配好CSP策略减少XSS面。4.2 密钥管理别把“秘密”写在代码里我相信不少人在项目里见过这样的代码——SecretKey key Keys.hmacShaKeyFor(123456.getBytes())在开发环境图方便可以理解上生产还这么干就是等着被脱裤。JWT的签名密钥相当于整个认证体系的根它一旦泄露所有用户的token都可以被伪造。正确的做法是把密钥放到环境变量、配置中心或密钥管理服务里并且不同环境的密钥必须不同。开发、测试、生产各有一套不能复用。部署时通过JWT_SECRET环境变量注入代码里只读取环境变量不硬编码。另外密钥的轮换机制也要提前设计好程序启动时先从配置中心拉取密钥版本列表验签时根据token里的kidKey ID字段选择对应的密钥这样轮换时旧token还能正常验证不用把所有用户都踢下线。4.3 安全加固清单我在多个项目里总结过一套JWT安全清单整理成下面这张速查表。检查项危险做法推荐做法签名算法未指定alg或接受none锁定HS256或其他明确算法密钥强度少于16字节的弱密码至少32字节随机密钥定期轮换密钥存储硬编码在代码或配置文件中环境变量或密钥管理服务Payload内容放手机号、身份证等敏感信息只放userId等最小必要标识过期时间7天甚至永久有效accessToken 30分钟~2小时配合刷新Token传输位置URL查询参数、localStorageAuthorization头或HttpOnly Cookie刷新令牌可重复使用永不轮换一次性消费刷新即轮换日志记录打印完整token脱敏或只记录token前缀每次做安全评审时拿出来对照一遍能拦住绝大多数因使用不当导致的问题。JWT本身没有原罪出问题的大多是用错了地方。把密钥管好、算法锁死、过期时间控制住这套体系是经得起考验的。5. 实操记录一个SPA项目从登录到退出的完整代码实现5.1 项目结构与依赖这里我以一个Spring Boot 3项目为例完整展示从登录到鉴权到退出这条链路怎么落地。先说项目的依赖除了Spring Web之外还需要一个JWT库我用的是jjwt版本选0.11.5以上API更清晰。dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency项目结构保持简洁核心包括几个类JwtUtils负责token的生成和解析JwtAuthFilter继承OncePerRequestFilter做认证AuthInterceptor实现HandlerInterceptor做权限校验LoginController提供登录和刷新接口UserContext用ThreadLocal在请求线程内传递用户信息。5.2 核心代码JWT工具类、认证过滤器、拦截器先看JWT工具类。这个类包了token生成和解析的全部逻辑密钥从环境变量读取。generateToken方法里设置了签发时间、过期时间、用户ID和用户名parseToken方法解析并校验签名和有效期。有一点要注意parserBuilder()可以指定setAllowedClockSkewSeconds(60)允许60秒的时间偏差避免服务器和客户端时钟不一致导致的验签失败。Component public class JwtUtils { Value(${jwt.secret}) private String secret; Value(${jwt.expire-seconds}) private long expireSeconds; private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username) { Date now new Date(); Date expireAt new Date(now.getTime() expireSeconds * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(now) .setExpiration(expireAt) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .setAllowedClockSkewSeconds(60) .build() .parseClaimsJws(token) .getBody(); } }然后是认证过滤器。继承OncePerRequestFilter是为了保证请求只在一次链路中执行一次过滤避免代理环境下重复执行。过滤器里从Authorization头取出token调用JwtUtils.parseToken解析解析成功就把用户ID塞进UserContext失败则根据异常类型返回401或400。放行白名单路径的写法有很多我习惯在过滤器里维护一个shouldNotFilter方法匹配到白名单就直接返回不处理。Component public class JwtAuthFilter extends OncePerRequestFilter { Autowired private JwtUtils jwtUtils; Override protected boolean shouldNotFilter(HttpServletRequest request) { String path request.getRequestURI(); return path.equals(/api/auth/login) || path.equals(/api/auth/refresh); } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtUtils.parseToken(token); Long userId Long.valueOf(claims.getSubject()); UserContext.setUserId(userId); chain.doFilter(request, response); return; } catch (ExpiredJwtException e) { response.setStatus(401); response.getWriter().write(token expired); return; } catch (JwtException e) { response.setStatus(401); response.getWriter().write(invalid token); return; } } chain.doFilter(request, response); } }拦截器做权限校验拿到HandlerMethod后读取方法上的自定义注解比如RequireRole(admin)再对比当前用户角色。校验不通过时可以直接抛出异常由RestControllerAdvice统一捕获转成对应的HTTP状态码也可以直接在拦截器里写响应。配置类里要把过滤器和拦截器都注册进去。过滤器用FilterRegistrationBean注册并指定顺序拦截器实现WebMvcConfigurer接口注册。跨域配置也建议直接配上CORS和Filter的协作关系在下一节讲。5.3 前端如何配合前端这一侧我用Vue3Axios演示。核心逻辑是请求拦截器加上Authorization头响应拦截器统一处理401并触发刷新。刷新请求要做“互斥”用同一个Promise保证并发的401请求只触发一次刷新逻辑。大致代码如下let refreshPromise null service.interceptors.request.use(config { if (accessToken) { config.headers.Authorization Bearer ${accessToken} } return config }) service.interceptors.response.use( response response, async error { const { response, config } error if (response response.status 401 !config._retry) { config._retry true try { refreshPromise refreshPromise || refreshAccessToken() await refreshPromise refreshPromise null config.headers.Authorization Bearer ${accessToken} return service(config) } catch (e) { refreshPromise null window.location.href /login } } return Promise.reject(error) } )登录成功后前端把accessToken存在内存变量中页面刷新后调用一次/auth/refresh接口用服务器下发的HttpOnly Cookie换取内存token。这个设计的核心是refreshToken永远不经过JavaScriptXSS脚本偷不到比localStorage方案安全得多。6. 排错实录JWT Filter Interceptor组合拳最容易翻车的点6.1 过滤器里拿不到请求体做登录接口的时候如果要把请求体里的JSON解析出来做校验你可能会遇到这样一个问题request.getInputStream()或request.getReader()在过滤器里调用一次之后到了Controller里再取一次就取不到了。这是因为ServletInputStream只能读一次第一次读取后流就被消耗掉了。解决办法是包装请求对象用HttpServletRequestWrapper做一层缓存把输入流一次性读进字节数组覆写getInputStream和getReader方法让后续读取都从缓存里取。注意这个包装后的对象在过滤器链的后续阶段都要传递下去不能自己在过滤器解析完就丢弃。我在这个坑上耗过一整个下午最后的教训是认证过滤器里尽量只读Header不读Body。如果必须读Body要么用包装器模式要么把参数校验放到拦截器里做。很多业务参数的解析其实到Controller里用RequestBody注解处理更省事不要在过滤器里做本该由Spring MVC做的事。6.2 拦截器放行了但过滤器拦截了这个问题的典型场景是你给某个接口加了权限拦截器测试时发现请求根本到不了拦截器而是被过滤器直接拦了。排查顺序应该是先看Filter的执行链再看Interceptor的执行链。因为Filter执行在前如果Filter里抛了异常或直接写响应了后面的Interceptor根本不会被触发。另一个隐蔽的点是静态资源的404。SPA项目里前端路由是/user/profile这样的路径后端如果配置了/*的过滤器路径可能把前端的静态资源请求也过滤掉。这里要区分两件事后端只提供API服务时过滤器的路径应该限制在/api/*而不是拦截所有路径如果是前后端一起部署的静态资源路径要显式放行。我习惯把API和静态资源用路径前缀的区别彻底分开避免这种纠缠。6.3 跨域配置与Filter的“相爱相杀”前后端分离项目必然涉及跨域。如果你配置了CORS又在Filter里做了认证拦截很可能发现带自定义Authorization头的跨域请求直接被浏览器拦了。原因在于跨域请求会先发一个OPTIONS预检请求这个请求不带业务数据如果你在Filter里强行要求所有请求必须有token预检请求就会收到401浏览器自然就报跨域错误。解决办法是在Filter里对OPTIONS请求直接放行并且设置好CORS响应头。如果你的项目里既配置了Spring的CorsFilter又注册了自己写的JwtAuthFilter要注意过滤器顺序CorsFilter必须排在认证过滤器之前否则预检请求的跨域头还没加上认证过滤器就把请求拦了。我在项目里的习惯是不用Spring Security的cors()配置直接在FilterRegistrationBean里注册一个自实现的CORS过滤器指定order1认证过滤器order2顺序明确不依赖Spring内部机制的默认行为排查起来也快。6.4 认证失败返回状态码的规范问题认证失败该返回401还是403这个问题别看简单很容易混。我的原则是未登录、token缺失、token过期返回401 Unauthorized前端收到后走刷新或跳登录页已经登录但权限不够返回403 Forbidden前端收到后提示“无权限访问”不做跳转。如果后端不分清楚前端就只能在401和403之间瞎猜体验自然差。还有一点要提醒401响应里最好带一个约定好的错误码字段比如{code: 40101, message: token_expired}前端根据错误码判断“刷新token”还是“跳转登录”。因为业务里有些接口即便401也不能刷新比如刷新接口本身返回401就代表refreshToken也失效了这时候必须强制重新登录不能又触发一次刷新导致死循环。6.5 排错思路总结遇到JWT、Filter、Interceptor相关的问题我自己的排查顺序固定这样走先确认请求有没有到达后端。浏览器Network面板一看便知状态码是200还是401Response有没有回来。再看是Filter拦的还是Interceptor拦的。加日志是最快的办法在Filter开头打印请求路径在Interceptor的preHandle打印当前用户一看日志就定位到具体是哪一层出的事。检查过滤器顺序和注册路径。很多诡异问题都是注册顺序不对、路径匹配范围太大导致的。最后检查JWT本身的问题。用jwt.io网站粘贴token查看Payload和过期时间确认签名算法和密钥对上。这个流程帮我解决过不下几十个认证相关的问题。逻辑上先确认“请求到没到后端”再确认“被哪层拦了”再确认“为什么拦”层层递进比瞎猜快得多。说到最后我还是想强调一个观点Filter和Interceptor不是竞争关系它们各有各的定位。我自己在实际开发中最舒服的组合是——Filter管认证和跨域Interceptor管权限和日志JWT管身份凭证。这个分层思路保证了我们后端的认证代码不用散落在各个Controller里也让新来的同事接手项目时能一眼看懂“谁在做什么”。实际写代码时也会遇到各种边界情况但把这些边界一个个补齐的过程恰恰是这套技术栈最好玩的地方。
返回列表