在前后端分离的项目里做登录验证,我基本都会用“拦截器 + JWT”这组组合来解决。很多同学在找类似这个标题的完整落地教程,我这里就以最近一个实际项目为例,把整个方案从原理、设计到代码、排坑完整捋一遍。
这个方案解决的核心问题很明确:接口怎么知道请求是谁发来的、能不能信任、Token过期了怎么办,以及前端在分离架构下怎么维持登录态。适合刚接触Spring Web开发、准备把单体项目改造成前后端分离的开发者参考。全文会把每个关键决策背后的“为什么”也讲清楚,不只是贴代码。
1. 先想清楚方案再动手:为什么选拦截器 + JWT
1.1 从需求场景说起:无状态的登录验证到底难在哪
传统单体应用里,登录验证最常见的做法是Session。用户登录成功之后,服务端往Session里存一份用户信息,同时通过Cookie把SessionId带回浏览器,下次请求带上这个Cookie,服务端就能从内存里把用户信息取出来。这套逻辑在同一个服务、同一个域名下跑得很顺,但一旦拆成前后端分离,问题就接踵而至。
前端可能部署在静态服务器上,后端是独立接口服务,两者甚至不在同一个域名下。Cookie跨域携带、CSRF防护、分布式部署时Session同步,每一项都是额外的坑。更麻烦的是,如果后端起多个实例,Session要么得靠粘性会话把同一个用户固定在某个节点上,要么得额外引入一套共享存储,复杂度立刻上来了。
JWT则把“用户身份”变成一串加密的、自包含的字符串,由后端签发,前端每次请求时通过Header把这串字符串带回来,后端只负责验签,不查库、不查状态,天然就是无状态的。服务端不用存Token,随便扩容几个实例都能验签通过,只要密钥一致。这一点对于现在的分布式部署、前后端分离架构来说实在太合适。
1.2 对比Session、过滤器、AOP,为什么最终锁定这组组合
很多人会问,Spring家族里有过滤器(Filter)、能用AOP,甚至直接上Spring Security,为什么非要挑拦截器?
先说Session的跨域和分布式问题。实际上Token在移动端、小程序端的适配也是个麻烦事,Cookie并不是所有客户端都会自动帮你带上。JWT放在Header里,对任何HTTP客户端都是平等的,统一得多。
再说Filter和Interceptor的区别。Filter是Servlet层面的,比Spring MVC更底层,能拦到静态资源,也能提前排除掉带病请求,但缺点是不能感知Spring MVC里的Handler,也不太好用精细的排除规则。Interceptor是Spring MVC自带的,能感知到Controller方法、能拿到方法上的注解,路径排除规则写起来方便得多。如果项目纯粹是Spring Boot + Controller接口,拦截器就是最贴合的实现层次。
AOP也能做登录鉴权,但AOP一般切在Service层,对“HTTP请求”这个对象没有感知,没法直接操作Response返回401,也不方便拿请求头。用它做登录校验,等于让高性能发动机去开拖拉机,能跑,但目标错位了。Spring Security当然更完整,但对很多中小项目来说,引入一套安全框架的成本可能比业务代码还高,配置、过滤器链、认证管理器、权限模型,学习曲线太陡。我是在需求里有复杂权限模型、OAuth2对接的时候才考虑它;普通的登录验证用拦截器 + JWT,代码量少、逻辑透明,出问题也好查。
2. 关键前置知识:JWT结构、拦截器机制与登录校验原理
2.1 JWT到底在保护什么?Token三段的含义与签名校验流程
JWT全称是JSON Web Token,本质上就是三段Base64Url编码的字符串,用点号分隔:Header、Payload、Signature。
Header里通常声明了签名算法,比如{"alg":"HS256","typ":"JWT"}。Payload里是业务用的声明,可以放用户ID、用户名、角色这些信息。但一定要记住:Payload只是Base64编码,不是加密,谁拿到都能解码看内容。真正防止Token被篡改的是第三段Signature,它是由Header、Payload加上服务端保存的密钥一起,通过指定算法算出来的签名。
校验Token的过程,就是服务端用自己手里的密钥,对接收到的Header和Payload重新计算一遍签名,再和Token携带的签名比对。只要密钥不泄露,攻击者就无法伪造Token,因为改了Payload里的任何字段,签名就会不匹配。过期时间也是放在Payload里的exp字段,解析的时候校验一下当前时间是否还没超过exp,就能判断Token是否有效。
这里有个很多人容易忽略的点:JWT校验的其实是“签名是否正确 + 是否过期”,并不保证Payload里的信息是最新的。比如用户被删了、密码改了,旧Token在过期之前理论上还是能通过校验。所以如果你需要“改密后立刻踢下线”这种强一致性需求,纯JWT是不够的,还得配合服务端的黑名单或版本号方案。这个后文会专门讲。
2.2 HandlerInterceptor的三个钩子方法,哪个才是验证主战场
Spring MVC的拦截器接口HandlerInterceptor定义了三个默认方法:preHandle、postHandle、afterCompletion。
preHandle在Controller方法执行之前触发,返回值是boolean,返回false就会中断整个请求链路,后面的Controller、postHandle都不会执行,所以这里是做登录校验的主战场。postHandle在Controller执行之后、视图渲染之前触发,适合做返回值包装或者给ModelAndView补充公共数据。afterCompletion在请求整个结束之后触发,适合做资源清理,比如从ThreadLocal里移除当前用户信息,防止线程复用时数据串号。
写登录拦截器时,最关键的就是在preHandle里去解析请求头、校验JWT、放行或拒绝。postHandle和afterCompletion这次我们只用afterCompletion来清理ThreadLocal,postHandle在前后端分离项目里其实用得不多,因为接口基本不返回视图。
2.3 注册拦截器的正确姿势:WebMvcConfigurer怎么配
拦截器写完之后,必须注册到Spring容器里才会生效。过去不少老项目还在用继承WebMvcConfigurationSupport的方式,那个写法会让Spring Boot的自动配置失效,导致静态资源处理、消息转换器都出问题。正确做法是实现WebMvcConfigurer接口,重写addInterceptors方法。
在这个方法里通过registry.addInterceptor(interceptor),然后调用.addPathPatterns声明要拦截哪些路径,再用.excludePathPatterns声明哪些路径放行。路径规则遵循Ant风格,/api/**表示匹配/api下所有层级,/api/auth/*只匹配一级子路径。
注册时机也要注意一个重要问题:addInterceptor注册的是单例,多个拦截器之间如果有依赖,要注意它们各自是否线程安全。JWT工具类基本上只持有一个不可变的密钥对象,天然线程安全,可以放心注入。
3. 手把手落地:从登录接口到全局拦截校验
3.1 项目依赖与JWT工具类准备
项目技术栈以Spring Boot 2.x + Java 8为例。JWT库我建议用JJWT,API在2.x之后比较清晰,新旧版本的写法差异也要注意区分。在pom.xml里加入jjwt-api、jjwt-impl、jjwt-jackson这三个坐标,实际跑起来就是下面这套。
<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>然后是JwtUtil工具类。核心就两个方法:生成Token和解析Token。生成时设置三个关键参数:subject存用户ID、claim放用户名或角色、expiration设置过期时间。解析时用parserBuilder指定签名密钥,然后调用parseClaimsJws,这个方法会一次性完成签名校验和过期校验,任何一步不通过都会抛异常。
密钥这里要求至少32字节的随机字符串,也就是256位,满足HS256算法对密钥长度的要求。实际项目里一定不要把密钥硬编码在类里,可以用配置中心、环境变量或者JVM的参数注入。
3.2 登录成功后签发Token的完整代码与参数选择
登录接口的逻辑一般是:接收用户名密码,校验通过,生成Token返回给前端。密码校验本身是另外一套逻辑,这里用演示性质的代码代替,重点看Token生成。
@Service public class AuthService { @Autowired private JwtUtil jwtUtil; public LoginResponse login(LoginRequest request) { // 1. 校验用户名密码,这里省略查库和密码比对的过程 // 2. 可以顺带查一下用户角色、状态,确认账号可用 User user = userMapper.findByUsername(request.getUsername()); if (user == null || !passwordMatcher.matches(request.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } // 3. 签发Token String token = jwtUtil.createToken(String.valueOf(user.getId()), user.getUsername()); return new LoginResponse(token, user.getUsername()); } }JwtUtil.createToken的具体实现如下。这里的builder链式调用顺序没有强制要求,但主题和claim的语义要清晰。
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-millis}") private long expireMillis; private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String createToken(String userId, String username) { Date now = new Date(); Date expireDate = new Date(now.getTime() + expireMillis); return Jwts.builder() .setSubject(userId) .claim("username", username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } }Token里的claim设计很有讲究。subject建议存用户ID而不是用户名,因为用户名可能被修改,一旦修改了,旧Token里的用户名就过时了,但用户ID一般是永久的。username放claim里,纯粹是为了方便下游做展示或埋点,每次请求解析出来直接用,不用再查库。
过期时间要根据业务场景定。普通后台管理系统我一般给2小时,长时在线的App可以放到7天。过期时间太长,泄露风险大;太短,前端要频繁刷新,交互体验差。有些团队会做双Token方案,短期AccessToken负责接口访问,长期RefreshToken负责续期,这个复杂度更高,后文会单独讲。
3.3 登录拦截器核心代码与Bearer Token解析
拦截器里第一步要处理的是“预检放行”。前后端分离下,前端经常配置跨域请求,跨域时会先发一个OPTIONS预检请求,这个请求是没有Authorization头的,如果不放行,就会被拦截器误杀,导致浏览器报CORS错误。判断方法很简单,看请求方法是不是OPTIONS,是就直接返回true放行。
第二步从Header里取Authorization,规范是Bearer前缀加Token,也就是“Authorization: Bearer eyJhbGciOi...”这种形式。拆Token时我习惯用substring(7),不要用split(" "),因为split涉及正则和空字符串问题,写起来也不够直接。
第三步调用JwtUtil解析,成功则把用户ID放到request attribute里,也可以放到ThreadLocal,看项目习惯。parseClaimsJws一旦解析成功,说明Token没被篡改且未过期,直接放行;解析失败,统一返回401,前端看到401就知道用户登录态失效,会跳转登录页或者主动刷新Token。
@Component public class LoginInterceptor 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; } String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { writeUnauthorized(response, "未携带登录凭证"); return false; } String token = authHeader.substring(7); try { Claims claims = jwtUtil.parseToken(token); request.setAttribute("currentUserId", claims.getSubject()); request.setAttribute("currentUsername", claims.get("username")); return true; } catch (JwtException | IllegalArgumentException e) { writeUnauthorized(response, "登录凭证无效或已过期"); return false; } } private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":40100,\"message\":\"" + message + "\"}"); } }JwtUtil里解析Token的方法就是靠parseClaimsJws的异常体系。JJWT把所有问题分得很清楚:签名不对抛SignatureException,过期抛ExpiredJwtException,格式错误抛MalformedJwtException,统一由JwtException兜底。
public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .build() .parseClaimsJws(token) .getBody(); }3.4 拦截器注册、路径规则与白名单放行
注册拦截器时,我习惯把“哪些路径不用登录”显式列出来用excludePathPatterns排除,而不是把拦截范围缩小到所有需要登录的路径之外。这么做的好处是白名单一眼望去很清晰。
登录接口、注册接口、验证码接口、健康检查接口通常是白名单。有一个地方特别容易漏:Spring Boot默认的错误处理路径/error。当拦截器拦了某个接口返回401或404时,DispatcherServlet会把请求转发到/error处理,如果/error也被拦截了,可能引发递归异常,表现成奇怪的状态码。所以务必把/error和/actuator这类路径排除掉。
@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", "/error", "/actuator/**" ); } }静态资源如果和应用在同一个Spring Boot进程下,也建议一起排除,比如/static/**、/favicon.ico。不过现在很多项目已经选择前后端分开部署,静态资源不会再走后端Servlet容器,这个需求也就没有了。如果你的项目里静态资源和接口在一起,就把静态资源路径也加到排除列表里,避免用户没登录就连个logo图都加载不出来。
4. 上线前必须处理的边界问题与排查实录
4.1 Token过期后状态码怎么统一,前端如何配合刷新
项目中最常见的问题就是Token过期之后,前端界面突然报错,或者接口返回一串莫名其妙的异常信息,用户体验极差。根本原因在于后端没有约定统一的“登录态失效”响应结构,前端也就没法统一判断。
我的做法是:在拦截器里捕获到JWT解析异常时,向Response写入一个JSON,状态码设为401,Body里带一个固定的错误码,比如{code: 40100, message: "登录已过期"}。前端在axios的响应拦截器里判断HTTP状态码为401且错误码是40100,就清掉本地存储的Token并跳转登录页,把这段逻辑收敛在一个地方,而不是每个页面各自处理。
前端配合的后端建设也很重要。跨域请求时,如果前端fetch或axios配置了withCredentials,那么后端CORS配置里必须明确allowedOrigins的具体域名,不能用*,而且allowedMethods和allowedHeaders要包含OPTIONS、Authorization。否则前端即使看到拦截器返回401,也会因为CORS策略读不到响应内容,误以为请求直接失败了。
4.2 跨域请求下OPTIONS预检被拦截的坑
这个坑看起来很小,却是实际项目里最容易碰到的低级错误。前后端分离项目里,前端代码跑在一个域名,后端接口在另一个域名,浏览器发非简单请求前,会自动发一个OPTIONS请求做预检。如果你后端做了CORS全局配置,但拦截器没有放行OPTIONS,那么预检请求根本到不了CORS处理环节,或者到了也被拦截器拦下,前端会收到“跨域请求被阻止”的错误,而且业务代码异常里什么都不显示。
有的同学会把CORS配置写在拦截器里,这虽然能解决一部分问题,但容易把代码搅成一团。更好的办法是在WebMvcConfigurer里单独配置CorsRegistry,与登录拦截器各司其职,拦截器只做身份校验,CORS交给MVC统一配置。
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("http://localhost:8080", "https://your-frontend-domain.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); }4.3 多个拦截器并存时的顺序问题
当项目里既有登录拦截器,又有日志拦截器、权限拦截器时,执行顺序就成了一门必修课。Spring MVC里多个拦截器的执行顺序是按注册顺序执行的,也就是说先注册的preHandle先执行,但afterCompletion是逆序执行的,像一个洋葱圈。展示一个场景会更清楚。
先注册日志拦截器,再注册登录拦截器,请求进来时,日志先记,接着登录校验;请求结束释放资源时,登录拦截器的afterCompletion先执行,然后才是日志拦截器。这正好符合直觉:登录校验的结果应该影响日志的最终内容。如果登录失败了,日志拦截器还能记录下这次未授权的访问。
有个常见误区是以为可以用@Order注解控制拦截器顺序,这里要说明一下:@Order能控制的是不同WebMvcConfigurer之间配置类的加载顺序,对同一个Configurer里addInterceptor的注册顺序没有直接作用。要控制拦截器顺序,最直观的就是注意addInterceptor的调用先后。
4.4 Controller里如何拿到当前登录用户
登录校验通过之后,业务代码里经常要拿当前用户ID来查询数据。最简单的方案是拦截器里解析出Claims后,把userId放到request.setAttribute("userId", userId),Controller方法里再用@RequestAttribute("userId")接收。但这种方法每写一个接口都要加参数,比较繁琐。
更优雅的是配合ThreadLocal做一个UserContext工具类。在preHandle里set进去,在afterCompletion里remove掉,业务代码直接用UserContext.getUserId()随时拿。ThreadLocal用起来必须小心,因为Tomcat的工作线程是池化复用的,如果不清理,下一次请求可能读到上一个请求的用户信息,造成越权风险,这是非常严重的线上Bug。
public class UserContext { private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>(); private static final ThreadLocal<String> USERNAME = new ThreadLocal<>(); public static void setUserId(Long userId) { USER_ID.set(userId); } public static Long getUserId() { return USER_ID.get(); } public static void setUsername(String username) { USERNAME.set(username); } public static String getUsername() { return USERNAME.get(); } public static void clear() { USER_ID.remove(); USERNAME.remove(); } }在preHandle里设置,在afterCompletion里清理,顺序不要搞反。
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); }4.5 常见报错速查表
把平时遇到最多的几个异常和原因整理成一张表,排查时可以按图索骥。
| 异常/现象 | 可能原因 | 处理建议 |
|---|---|---|
| JwtException / Invalid signature | 密钥不一致,或Token被篡改 | 确认签发和解析使用同一密钥,更换密钥后旧Token会全部失效 |
| ExpiredJwtException | Token过期 | 检查系统时间是否准确,再看过期时间配置是否合理 |
| SignatureException | 签名算法不匹配或密钥长度不足 | HS256密钥长度至少32字节,确认算法参数一致 |
| 请求头里Token丢失 | 前端请求拦截器没拼接Authorization头 | 检查前端的请求封装逻辑,Token别只存在localStorage而忘记注入 |
| 跨域报错但没有具体业务信息 | OPTIONS预检被拦截,或CORS配置缺失 | 拦截器放行OPTIONS,CORS在WebMvcConfigurer里单独配置 |
| 拦截器没生效 | 配置类没加@Configuration,或存在多个WebMvcConfigurer | 确认注册逻辑执行,断点打在preHandle第一行 |
5. 进阶优化与个人避坑心得
5.1 密钥与过期时间的合理配置
密钥管理是我每次写JWT方案时都会强调的环节。代码里写死密钥,一旦代码库泄露,等于所有用户的身份都能被伪造。建议把密钥放到环境变量或配置中心,并在不同环境使用不同密钥。另外密钥不要用太短的字符串,见过有人用“123456”当密钥,那等于没有签名。
过期时间上不能拍脑袋。要把用户习惯、接口频率、安全等级都考虑进去。后台管理系统如果要求长时间不操作就踢下线,建议30分钟到1小时;移动端登录保持型应用,可以给7天甚至更长,但一定要配合下面讲的注销机制,防止设备丢失后带来的持续风险。
5.2 主动注销、踢人下线和并发控制
纯JWT的一个天生缺陷是服务端不带状态,想主动让一个Token失效,比如用户改了密码、账号被封、用户被踢下线,没有直接手段。这时候有几种常见方案。
简单粗暴的办法是前端把Token从本地删掉,但这只对当前设备有效,已经泄露出去的Token依然能通过校验。稍微可靠一点的办法是服务端维护一个Token黑名单,把注销的Token的jti标识加进去,每次校验时查一下黑名单;更轻量的做法是维护一个用户版本号,把版本号写进Token里,Redis里存当前用户的版本号,每次校验时比对一下,版本不符直接拒绝。最后一种在“踢人下线”场景里特别实用。
Redis方案和JWT本来想强调的无状态理念有点冲突,但业务需求永远大于信仰。该用状态的时候果断用,只要把校验逻辑封装好,在拦截器里做一次Redis查询,性能损耗基本可以忽略。
5.3 关于这套方案,我最想提醒的三件事
第一,不要把敏感信息放进Payload。虽然Token有签名保护不被篡改,但Payload本身是明文,身份证号、手机号、密码这些数据一旦解码就全暴露了。Token里顶多放用户ID、昵称、角色这类标识性信息。
第二,拦截器里不要做太重的业务操作。拦截器会被所有接口请求调用,如果在里面查数据库、调外部服务,相当于给每个接口都叠加了一层额外耗时,性能损耗会线性放大到整个系统。把校验逻辑控制得快而轻,该做的权限校验放到Service层去。
第三,别为了省事把所有接口都拦一遍。登录验证的目的是保护业务接口,像登录接口、注册接口、验证码获取、健康检查这些必然不需要Token的路径,务必在excludePathPatterns里明确列出来。每多拦一个没必要拦截的接口,就多了一个前端联调时踩坑的潜在点。
最后再分享一个小技巧。本地调试时发现拦截器明明写了、代码也注册了,但请求还是一路放行,先检查项目里是不是存在多个WebMvcConfigurer实现,并且其中一个用@EnableWebMvc注解改了默认配置;或者查Spring Boot的版本,某些版本下静态资源路径会默认被拦截,需要额外排除。排查思路就是断点打在拦截器的preHandle第一行,没进来,说明是注册或路径匹配的问题;进来了却直接返回false,说明是校验逻辑的问题。按这个方向查,大部分奇奇怪怪的登录失效问题都能很快定位。
实战做一个前后端分离的登录验证,最核心的思路总结起来就八个字:接口无状态,身份靠Token。只要理解了为什么用拦截器、JWT的签名校验是怎么回事、路径怎么放行、过期换新怎么配合前端,这套方案就能稳稳支撑起绝大多数中小型项目。后面的权限管理、审计日志、多端登录控制,也都是在这个基础上自然长出来的。