☰
Spring Boot拦截器+JWT登录验证方案详解与落地实践
2026/10/24 7:02:21 网站建设 项目流程

做了几年Java后端,登录验证这块从最早的Session到后来的Token,再到现在的JWT,方案换了一茬又一茬。最近在带一个模拟项目X的开发,好多同学在问拦截器配JWT做登录验证的事,索性把这套方案的完整思路和落地代码整理出来,从原理到踩坑一次说清楚。

先说结论:如果你要的是一个轻量、无状态、前后端分离友好的登录方案,Spring Boot拦截器加JWT就是目前最顺手的选择。整个过程的核心就是两个点:登录成功发Token,后续请求验Token,中间用拦截器把校验逻辑收敛到一个地方,避免每个接口重复写认证代码。

这套东西不是什么高深技术,但细节确实不少,比如Token过期怎么处理、哪些路径要放行、拦截器里怎么拿到当前用户信息、和跨域配置的冲突怎么解决,这些坑我都替你踩过一遍了。下面从方案选型开始,一步一步拆解。

1. 为什么选拦截器配合JWT,而不是Filter或Session

1.1 无状态认证是前后端分离的基础

传统Session方案需要服务端存会话状态,用户在A服务器登录了,下次请求打到B服务器,B不认识这个SessionId,就得做Session共享。要么用Spring Session配合Redis,要么做粘滞会话,都能用但都要额外维护一套东西。

JWT的思路完全不同,用户登录后服务端生成一个包含用户信息的签名Token,返回给前端,前端存着,每次请求带上。服务端拿到Token验一下签名,验完就知道是谁。服务端不存任何会话状态,天然支持水平扩展,多加几台机器也不用担心会话同步问题。这也是现在微服务架构下绝大多数项目把JWT作为首选认证方案的原因。

1.2 拦截器和过滤器,两个不要搞混

Java Web里能拦截请求的组件有好几个,Filter、Interceptor、AOP都能干这事,但侧重点不一样。

Filter是Servlet规范里的,优先执行,粒度最粗,连Controller都没进就能干活。Interceptor是Spring MVC里的,工作在DispatcherServlet之后、Controller之前,能拿到HandlerMethod信息,这在做细粒度权限控制时非常有用。AOP更往下层走,切的是Service或者Mapper层,一般不会用来做HTTP层的登录验证。

具体到登录验证这个场景,Interceptor是最合适的。原因有几点:

  • 能拿到HandlerMethod,可以判断当前请求到底映射到哪个Controller的哪个方法,方便做方法级权限控制
  • 能用Spring的依赖注入,密码学工具类、用户服务、Redis客户端都能直接注入进来,Filter里要拿Spring Bean还得费点劲
  • 和Spring MVC是一套体系,有明确的addInterceptors注册机制,加排除路径也很方便

Filter并不是不能用,但实际写下来你会发现,Filter里做这种校验要么写得很底层很别扭,要么得手动从Spring容器里捞Bean,维护成本明显更高。

1.3 这套方案解决了哪些实际问题

把登录验证做成拦截器加JWT,核心解决的是三个问题:

第一个是登录态怎么发出去。传统做法是登录成功往Session里塞个对象,现在改成登录成功返回一个JWT字符串。

第二个是登录态怎么验回来。每个需要登录的接口,在请求到达Controller之前,先经过拦截器,拦截器把Token取出来、验签名、看是否过期,全部通过才放行。

第三个是代码怎么复用。登录验证逻辑只在拦截器里写一遍,后续加接口、加模块,只要配置好路径规则,认证逻辑自动生效,不会出现一个项目里多种认证写法的混乱局面。

这个方案的落地工程量其实很小,写一个JWT工具类,写一个拦截器,再写一个配置类注册拦截器,核心代码加起来不超过两百行。相比引入完整的Spring Security加SecurityContext方案,配置量小得多,理解成本低得多,特别适合中小型项目或者想快速迭代的前后端分离项目。

2. JWT工具类的设计与实现细节

2.1 JWT的数据结构,拆开看就三个部分

JWT全称JSON Web Token,本质就是一个经过签名的JSON字符串,用两个点分成三段。用调试工具或者直接在代码里打日志看,长这样:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEwMSwiZXhwIjoxNzEyMDAwMDAwfQ.Kx9P2pGzV0g6VJ7cGgJY8m9dFf3KxLxQ7nZvY3p1aXQ

三段分别是Header、Payload、Signature。

Header声明了这个Token的签名算法和类型,默认长这样:{"alg":"HS256","typ":"JWT}",被Base64URL编码后就是第一段。

Payload是载荷,里面放着你要传递的业务信息。标准的字段有iss(签发者)、sub(面向的用户)、exp(过期时间)等,也可以自定义字段,比如userId。这段被Base64URL编码后是第二段,注意只用Base64编码,等于明文,千万不能往里放密码。

Signature是签名结果。服务端用一个密钥,把第一段和第二段拼起来做HMACSHA256计算,得到第三段。谁拿到这个Token都能解码出Payload的内容,但没有密钥就伪造不了签名。

写个JMeter脚本或者直接用调试工具解码一下,你会发现很多所谓JWT加密的说法都是不准确的,这玩意叫签名比叫加密更恰当。理解这一点很重要,因为签名验证流程的错误排查几乎全靠这个认知。

2.2 选择JWT的Java实现库

Java生态里JWT的开源实现主要就两个,一个叫jjwt,一个叫java-jwt(出自某知名认证服务商)。我用的是jjwt,API直观,文档友好,社区活跃,排查问题的时候搜到的案例也多。

Maven依赖加到pom里:

<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>

jjwt-api是编译期要用的接口,jjwt-impl和jjwt-jackson运行时才需要。注意一点:大版本要一致,你用了0.11.5就三个都0.11.5,版本混用很容易冒出莫名其妙的NoClassDefFoundError。

2.3 生成Token和解析Token的核心代码

JWT工具类的代码量其实很小,核心就三个方法:生成Token、解析Token、校验Token。

先写生成Token:

@Component public class JwtUtil { // 实际项目中这个密钥从配置中心读取,不要写死在代码里 @Value("${jwt.secret}") private String secret; // Token有效期,单位毫秒,这里设成2小时 @Value("${jwt.expire}") private Long expire; private SecretKey getKey() { // 密钥长度至少256位,否则HS256算法会直接抛异常 return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username) { Date now = new Date(); Date expireTime = new Date(now.getTime() + expire); return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(now) .setExpiration(expireTime) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .build() .parseClaimsJws(token) .getBody(); } }

claim("userId", userId)是自定义载荷,setSubject存用户名,setExpiration设过期时间。parseToken里如果Token过期了,会直接抛ExpiredJwtException,签名不对会抛SignatureException,解析的时候要做好异常捕获。

2.4 Payload里到底放哪些信息,我的建议

Payload里放什么信息,我建议只放能标识用户身份的最少字段。一般就是userId和username,顶多加个角色列表。角色列表这个看情况,如果你做的是方法级权限校验,可以在Token里带个roles字段,免得每个请求都查一次数据库。

但是有几种数据绝对不要放Token里:

  • 密码、手机号、身份证号这类敏感信息,Token里是Base64编码,谁都能解码看
  • 大字段、长文本,JWT每多1KB内容,每次请求就多1KB的请求头开销,网络全浪费在传冗余数据上
  • 频繁变动的数据,比如用户状态、可用次数,这类数据应该以数据库或缓存为准,Token里存了反而容易读到脏数据

我见过有的同事把整个用户对象都塞Payload里,Token又大又丑,用户改个昵称还得重新登录,完全是给自己挖坑。

2.5 密钥管理的几个硬性要求

密钥管理是JWT方案里最容易翻车的地方,我整理三个硬性要求:

第一,生产环境密钥长度必须够,HS256要求至少32字节,也就是256位。短了,jjwt会直接抛出弱密钥异常。

第二,密钥不能写在代码里。我在模拟项目X里用@Value从application.yml读取,这只是演示级做法。真实项目请用配置中心或者环境变量,最好能做到定期轮换。

第三,每个环境用不同的密钥。开发环境、测试环境、生产环境的密钥必须分开,防止有人拿测试环境的密钥伪造生产环境的Token。

jwt: secret: "your-256-bit-secret-key-in-base64-or-any-string-longer-than-32-bytes" expire: 7200000

注意secret这个配置,如果字符串不足32字节,启动就能看到异常。调试的时候用"abcdefg"这种短密钥能跑起来,那是因为你代码里用的是Keys.hmacShaKeyFor,它会校验长度,短了会报错;如果你用别的方式构造密钥可能不校验,但生成的签名强度也打折扣了。

3. 登录接口与拦截器校验的实现

3.1 登录接口,签发Token的入口

登录接口的逻辑很简单:接收用户名密码、校验通过、生成Token返回。

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @Autowired private JwtUtil jwtUtil; @PostMapping("/login") public Result login(@RequestBody LoginRequest request) { User user = userService.authenticate(request.getUsername(), request.getPassword()); String token = jwtUtil.generateToken(user.getId(), user.getUsername()); return Result.success(token); } }

注意到没,登录接口本身没有做拦截校验,因为它在白名单里。用户都没登录呢,你要验Token也拿不到Token,所以这种接口必须放行。

密码校验这块,业界标准做法是存哈希不存明文,校验的时候对输入的密码做同样的哈希运算再比对。这块不是本文重点,但你要做真实项目,建议直接接入成熟框架的密码加密模块,千万别自己实现哈希算法,自己写的哈希算法几乎一定存在漏洞。

3.2 拦截器核心逻辑,preHandle里做三件事

拦截器核心就一个类,实现HandlerInterceptor接口,重写preHandle方法。请求进Controller之前,preHandle先执行,返回true放行,返回false拦截。

@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 从请求头获取Token String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } // Token为空,直接拒绝 if (token == null || token.isEmpty()) { writeUnauthorized(response, "未登录或登录已过期"); return false; } try { Claims claims = jwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("username", claims.getSubject()); return true; } catch (ExpiredJwtException e) { writeUnauthorized(response, "登录已过期,请重新登录"); return false; } catch (JwtException e) { writeUnauthorized(response, "无效的登录凭证"); return false; } } }

preHandle里做了三件事:第一,从请求头把Token拿出来;第二,解析验证Token;第三,把用户信息塞到请求属性里,方便后续Controller直接拿。

有一个细节是Authorization头,按照惯例,JWT一般放在请求头的Authorization字段里,值以Bearer开头,一个空格再加Token本身。所以取Token的时候要先判断是否有Bearer前缀,有就去掉,没有就是格式不对。

3.3 返回结果统一格式,前端才能好处理

拦截器拦截到未登录请求后,不能啥都不做就返回,要给前端一个明确的结构化提示。很多前端就通过HTTP状态码判断登录状态,所以401这个状态码很关键。

private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); Result result = Result.error(401, message); response.getWriter().write(JSON.toJSONString(result)); }

401状态码语义很明确,前端收到401就知道是登录失效了。有一种常见设计是前端收到401后,弹个提示框,然后自动跳回登录页,这个流程能不能跑顺,完全靠后端把401和返回体格式固定下来。

注意几个坑:返回的ContentType一定要带UTF-8,不然中文会乱码;response.getWriter()用完不用手动关,容器会管理;对于OPTIONS预检请求必须放行,这个和第5节说跨域问题有关。

3.4 注册拦截器并配置放行路径

写完拦截器,Spring Boot还需要知道哪些路径要拦截、哪些路径要放行。这一步用WebMvcConfigurer的addInterceptors方法实现:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/auth/captcha" ); } }

addPathPatterns("/api/**")表示拦截所有/api/开头的请求,excludePathPatterns把不需要登录的接口全部排除掉。

这里我建议路径规则用/api/**这种粗粒度,不要用细粒度的一个个接口去加。项目初期接口少,细粒度看着清晰,但接口多了之后,每加一个公开接口都改配置类,很容易漏改,漏改的结果就是前端调接口莫名其妙401,排查起来还得看代码。

按模块划分是一个比较合适的度:/api/auth/**里的登录、注册、验证码公开,/api/admin/**全拦,/api/user/**全拦,清晰又好维护。

4. Controller层如何获取当前登录用户

4.1 从请求属性里取用户信息

拦截器校验通过后,把用户信息放到了request属性里,Controller里直接取:

@GetMapping("/profile") public Result profile(HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); String username = (String) request.getAttribute("username"); // 查用户资料并返回 return Result.success(userService.getProfile(userId)); }

这样写能跑,但每个接口都得声明HttpServletRequest参数,再手动取属性转类型,回头看看代码全是重复样板。更优雅的做法是用自定义注解加参数解析器,把用户信息直接注入到方法参数里。

4.2 进阶做法:自定义注解自动注入用户

做一个@CurrentUser注解,配合HandlerMethodArgumentResolver,Controller方法里直接声明参数就能拿到用户对象:

@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface CurrentUser { }
@Component public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentUser.class) && parameter.getParameterType().equals(UserInfo.class); } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); Long userId = (Long) request.getAttribute("userId"); String username = (String) request.getAttribute("username"); return UserInfo.of(userId, username); } }

再在WebMvcConfig里注册这个解析器:

@Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new CurrentUserArgumentResolver()); }

Controller里就变成这样:

@GetMapping("/profile") public Result profile(@CurrentUser UserInfo user) { return Result.success(userService.getProfile(user.getUserId())); }

这个方法值得写进你的项目里。接口签名一眼能看出需要当前用户,测试的时候Mock用户也方便,Controller的代码看起来清爽很多。

4.3 注意:参数解析器执行在拦截器之后

这里有一个执行顺序的问题要强调:HandlerMethodArgumentResolver的resolveArgument在拦截器的preHandle之后执行。所以在拦截器里被拒绝的请求,根本走不到参数解析这一步。换句话说,如果请求能到Controller方法,request属性里一定有用户信息,参数解析器直接取就不会取到null。

但是如果你绕过了拦截器,或者某条路径没走到拦截器,参数解析器取到的就是null。这个设计上要保证有拦截器兜底,对应的注解只在被拦截的路径里用,不然会玄学空指针。

5. 跨域配置与Token传递的联合作战

5.1 跨域预检请求是一个经典坑

前后端分离项目,前端跑在localhost:5173,后端跑在localhost:8080,跨域是必然的事。浏览器在发非简单请求之前,会先发一个OPTIONS预检请求,确认服务端允不允许这个跨域请求。

问题来了:预检请求没有Authorization头,如果拦截器把OPTIONS请求也拦了,前端就会看到CORS报错,而且那个报错信息让人怀疑人生——明明跨域配置都写了,怎么还跨域失败。

我在模拟项目X里就遇到过这个坑。现象是前端登录一切正常,登录后发正式请求就报跨域错,但fetch的Network面板里只有一个红色请求,怎么看怎么像后端没配跨域。干了半天发现是拦截器把OPTIONS请求拦了。

5.2 解决跨域与拦截器冲突的三种姿势

第一种,拦截器里直接放行OPTIONS,这是最简单直接的方法。需要一个简单请求或者复杂请求的预检,都在preHandle里先判断:

if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }

第二种,跨域配置用CorsFilter而不是WebMvcConfigurer里的addCorsMappings。因为CorsFilter是Servlet过滤器,执行顺序天然在拦截器之前,它能正确处理预检请求并直接返回响应,根本不会走到拦截器这一步。

第三种,拦截器注册的时候把OPTIONS的路径排除掉,但这和拦截器的路径模式不是一个维度上的事,配置起来有点别扭,我一般不推荐。

实际操作中,我在项目里用的是第一种加第二种的组合,拦截器放行OPTIONS,跨域配置用CorsFilter的方式处理。这个组合实测最稳,不管跨域配置怎么调都不会和拦截器打架。

6. 常见问题排查与实践经验总结

6.1 常见问题速查表

现象可能原因处理方式
登录接口正常,其他接口全部401拦截器配置了/api/**但没排除登录接口检查excludePathPatterns是否包含登录接口路径
前端报跨域错误,Network里全是OPTIONS请求拦截器拦截了OPTIONS预检请求preHandle里放行所有OPTIONS请求
Token过期后没有提示,前端像卡死了一样前端没有统一处理401状态码前端封装请求拦截,收到401统一跳登录页
签名验证一直报错,但Token看起来没问题Token里多了一个空格,或者前缀格式不是"Bearer "用调试工具看实际请求头,确认Token格式
接口返回中文乱码response.setContentType没加charset设置application/json;charset=UTF-8
每次重启服务后旧Token全部失效Token密钥是自动生成的随机值,重启就变密钥放到配置文件中持久化,不要自动生成

6.2 Token过期时间怎么设计

Token有效期没有标准答案,取决于业务对安全性和便捷性的平衡。

有效期短,比如30分钟,安全性高,但用户体验差,上个摸鱼时间回来就过期了。有效期长,比如24小时,用户不用频繁登录,但Token泄露后的风险窗口也大。

主流方案是双Token,一个短期AccessToken用于接口访问,一个长期RefreshToken用于刷新AccessToken。这个方案实现复杂度比单Token高不少,需要单独一张刷新Token表或者在Redis里记录刷新票据,很多框架都做成标准流程了。

我的建议是,项目初期先用短期Token(2小时左右),前端在Token过期前定时刷新,或者用户每次访问页面时调一次刷新接口。项目用户量起来之后,再逐步演进到双Token方案,不要一上来就整复杂的刷新流程,项目和团队都需要一个适应期。

6.3 多环境密钥与并发问题

密钥环境的隔离做不好,最容易出的问题是测试环境的Token可以明文拼接着玩,生产环境一验就挂。还有另一种情况是同一套密钥上了生产,某个开发从配置中心把密钥拉出去,伪造一个永不过期的Token,后果就严重了。

每次部署环境切换,务必检查jwt.secret有没有跟着环境变量切换。我在实际项目里见过不止一次因为密钥写死在application.yml的default段,导致测试环境联调时Token验不过的,一查是配置中心的密钥和本地yml里不一致。

并发方面,JWT本身是无状态不涉及并发问题。但是如果同一个用户多点登录,每个端一个Token,某个端退出了,其他端不受影响,这算正常行为。要控制单用户只能在一个端登录,得在Redis里存当前有效的Token,用户重新登录就覆盖旧的,这个方案需要在拦截器里查Redis,比纯JWT多一步存储,看具体业务是否需要。

6.4 拦截器与公共服务中一些容易被忽略的请求

写拦截器的时候要特别考虑这几类请求:文件上传接口、导出下载接口、定时任务回调、第三方平台回调。

文件上传和普通接口没有本质区别,带Token就能过。但如果是转录文件时用了异步请求,可能需要在请求头里手动带Token,这个前端逻辑要注意。下载接口如果是浏览器直接打开URL链接,请求头没法自定义,常见的解决办法是临时换用带Token参数的GET请求,但这样Token会出现在URL和日志里,有泄露风险,更合理的方式是用Cookie携带Token,或者下载链接用一次性短时Token。

第三方回调场景,比如支付回调、消息推送回调,这些请求是服务端到服务端,没有用户Token。这类路径一定要在excludePathPatterns里排除掉,并且最好再做一层IP白名单验证,不要只靠不拦截。

定时任务如果走HTTP触发,也同理,建议用独立的内部Token或者IP白名单,不要和用户JWT体系混在一起。

6.5 给新手的三个循序渐进练习建议

如果你是第一次接触这套方案,建议按这个顺序练:

第一步,纯接口层面练习。把JwtUtil和拦截器写好,用调试工具调登录接口拿Token,再带着Token调其他接口,分别验证Token正确、Token过期、无Token三种情况的结果。这一遍把所有代码跑通,先建立起整体认知。

第二步,加前端联调。前端用一个简单页面,登录成功后把Token存进localStorage,每次请求在请求拦截器里带上Authorization: Bearer,同时全局处理401跳登录页。这一步能帮你把前后端的分工和约定吃透。

第三步,加双Token和Redis。AccessToken过期的时侯用RefreshToken换新的,同时把RefreshToken存Redis,支持强制下线等操作。到这一步你已经可以独立设计一个生产级的登录方案了。

6.6 再聊几句对JWT方案的看法

有个观点说JWT该被放弃了,理由是注销困难、无法踢人、Payload敏感信息泄露风险大,这些问题真实存在。但问题的核心不在JWT技术本身,而在于很多人把JWT当万能药。单靠一个JWT解决不了所有认证场景,尤其是不确定需求的场景。用对场景,JWT非常趁手。公开API、无状态服务、临时授权链接,这些场景简直是JWT的主场。

我在模拟项目X里的实际体会是,拦截器加JWT这套组合让人舒服的点在于:代码量少、边界清晰、出了问题能按图索骥。前后端联调的时候,前端只要关心Token有没有带上,后端只要关心拦截规则对不对,调试成本比Session时代低了一大截。

最后分享一个提升调试效率的小技巧:拦截器的日志一定要在返回401时打印出请求路径和原因。哪怕只加一行log.warn("拦截请求: {}, 原因: {}", request.getRequestURI(), message),排查线上问题时这条日志能帮你省掉大量猜测时间。我在排线上问题时报过很多次这种方式的好处,一个完整的日志链路能直接把问题定位到具体是没带Token、Token过期、还是密钥不对,不用拿着前端截图猜来猜去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询