资讯动态

Spring Boot拦截器+JWT登录验证方案详解与落地实践

发布时间:2026/10/24 7:02:26 来源:尧图企业网站定制
做了几年Java后端登录验证这块从最早的Session到后来的Token再到现在的JWT方案换了一茬又一茬。最近在带一个模拟项目X的开发好多同学在问拦截器配JWT做登录验证的事索性把这套方案的完整思路和落地代码整理出来从原理到踩坑一次说清楚。先说结论如果你要的是一个轻量、无状态、前后端分离友好的登录方案Spring Boot拦截器加JWT就是目前最顺手的选择。整个过程的核心就是两个点登录成功发Token后续请求验Token中间用拦截器把校验逻辑收敛到一个地方避免每个接口重复写认证代码。这套东西不是什么高深技术但细节确实不少比如Token过期怎么处理、哪些路径要放行、拦截器里怎么拿到当前用户信息、和跨域配置的冲突怎么解决这些坑我都替你踩过一遍了。下面从方案选型开始一步一步拆解。1. 为什么选拦截器配合JWT而不是Filter或Session1.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出自某知名认证服务商。我用的是jjwtAPI直观文档友好社区活跃排查问题的时候搜到的案例也多。Maven依赖加到pom里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 /dependencyjjwt-api是编译期要用的接口jjwt-impl和jjwt-jackson运行时才需要。注意一点大版本要一致你用了0.11.5就三个都0.11.5版本混用很容易冒出莫名其妙的NoClassDefFoundError。2.3 生成Token和解析Token的核心代码JWT工具类的代码量其实很小核心就三个方法生成Token、解析Token、校验Token。先写生成TokenComponent 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;charsetUTF-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注解配合HandlerMethodArgumentResolverController方法里直接声明参数就能拿到用户对象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(ListHandlerMethodArgumentResolver 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;charsetUTF-8每次重启服务后旧Token全部失效Token密钥是自动生成的随机值重启就变密钥放到配置文件中持久化不要自动生成6.2 Token过期时间怎么设计Token有效期没有标准答案取决于业务对安全性和便捷性的平衡。有效期短比如30分钟安全性高但用户体验差上个摸鱼时间回来就过期了。有效期长比如24小时用户不用频繁登录但Token泄露后的风险窗口也大。主流方案是双Token一个短期AccessToken用于接口访问一个长期RefreshToken用于刷新AccessToken。这个方案实现复杂度比单Token高不少需要单独一张刷新Token表或者在Redis里记录刷新票据很多框架都做成标准流程了。我的建议是项目初期先用短期Token2小时左右前端在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过期、还是密钥不对不用拿着前端截图猜来猜去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑