Java登录校验实战:JWT + Filter + Interceptor 统一认证与权限控制
2026/9/24 18:55:00 网站建设 项目流程

做 Java 后端这几年,登录校验是每个项目都躲不开的基本功。早期我用 Session + 登录状态,后来项目拆了微服务,发现 Session 在多个服务之间同步特别别扭,才认真研究JWT 令牌。等我把验证逻辑真正落地的时候,又发现FilterInterceptor这些概念看着简单,实际用起来坑很多:要么 Filter 里注入的 Redis 是 null,要么拦截器重复解析 token,要么拦截顺序不对导致白名单失效。这篇文章我系统梳理一遍,从原理到代码实战,最后附一份我总结的面试考点清单,希望能给正在学 Java 登录校验、或者准备面试的同学一些参考。

1. 为什么登录校验要收口:从一段让我抓狂的重复代码说起

1.1 我看到的一段烂代码

很多项目做登录校验时,第一反应就是在 Controller 里写。我接手过一个项目,几乎每个 Controller 都是这种风格:

@GetMapping("/order/list") public Result list(HttpServletRequest request) { String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { return Result.fail("请先登录"); } // 解析token... try { JwtUtil.parseToken(token.replace("Bearer ", "")); } catch (Exception e) { return Result.fail("token已过期"); } // 业务逻辑... return Result.success(orderService.list()); }

你没看错,这段 try-catch 在每个需要登录的接口里复制了一份。后来项目迭代,安全部门要求把 JWT 过期时间从 1 小时改成 30 分钟,还要加白名单逻辑,团队成员找遍了十几个 Controller 才把所有解析 token 的代码改完,中间漏掉一个,导致那个接口依然用旧规则。更离谱的是,有个新人为了让自己的接口能通过测试,直接注释掉了 token 校验,留下一个无认证就能访问的数据接口。

这种代码最大的问题不是重复,而是安全性不可控。登录校验属于横切关注点,它不应该散落在业务代码里,而是应该在一个统一的地方做收口。只有这样,安全团队做规则调整时,只需要改一个类;前端对接时,所有接口的 401 返回格式都是一致的;审计人员看代码时,也能清晰确认哪些路径是受保护的。

1.2 登录校验的本质:横切关注点与统一收口

“横切关注点”这个词听起来学术,其实类比一下就懂了:业务接口是做菜的厨房,而登录校验就像进厨房之前必须带卫生帽。你不能要求每个厨师一边炒菜一边提醒自己戴帽,而是应该在厨房入口统一检查。Java Web 里能承担这个“入口检查”职责的机制有三种:

机制执行层级能拿到什么典型场景
FilterServlet 容器层HttpServletRequest/Response编码、CORS、全局登录校验
InterceptorSpring MVC 层HandlerMethod、ModelAndView权限控制、日志记录
AOP 切面Spring Bean 层Method、参数、返回值业务逻辑通用处理

这三个都能拦截请求,但各自的出生地不同。Filter 是 Servlet 规范里的东西,你的应用只要还是跑在 Tomcat 等 Servlet 容器上,它就是最外层的那道门;Interceptor 是 Spring MVC 自己的机制,它在请求进入 DispatcherServlet 之后、进入 Controller 方法之前工作;AOP 切面则是 Spring 容器内的方法级织入,理论上能对任何 Bean 的方法做增强。

我个人的经验是:用 Filter 做登录认证,用 Interceptor 做业务权限控制。这样可以做到单一职责,不会在一个组件里又解析 token 又判断角色。后面我会给出具体代码。

1.3 从 Session 到 JWT:为什么很多团队转向无状态令牌

Session 方案不是不好,它有一大堆优点:服务端可控、主动失效简单、没有签名算法导致的性能开销。但真正让团队换掉它的场景,几乎都绕不开下面几个问题。

第一是分布式部署的尴尬。默认情况下 Session 存在单台 Tomcat 的内存里,请求落到别的节点就找不到了。解法是引入 Spring Session 把 Session 数据放到 Redis,但引入了额外组件和运维成本。第二是跨域和移动端不友好。Session 依赖 Cookie,而移动端 App、不同域名下的前端应用,对 Cookie 的支持差异很大。第三是在前后端分离的架构里,SessionId 本身没有业务含义,服务端每次都要查 Redis 才能知道用户是谁,这多了一次网络往返。

JWT(JSON Web Token)的出现,把这些痛点变成了它的优点:token 本身就是一段自包含的数据,解析出来就知道用户 id、用户名、角色、过期时间,服务端不需要保存任何会话数据。认证服务签发一个 JWT,业务服务拿着公钥就能验证真伪,非常适合微服务场景。

当然 JWT 不是银弹。后面面试考点部分我会详细讲它的缺点和应对方案。这里只做一个结论:选 JWT 不是因为 Session 落后,而是因为无状态模型更适合分布式、多端接入的现代应用架构

2. JWT 的工作原理:三段字符串背后的签名逻辑与算法选型

2.1 拆解一个 JWT:Header.Payload.Signature

JWT 的字符串长这样:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSIsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzAwMDAwMDB9.qBvEfqGs3dNlZbNOwd8DUQyMPkONY6nL0Z5bH4Hl4NY

用点号分成三段,分别是 Header、Payload、Signature。

  • Header:JSON 字符串的 Base64URL 编码,内容一般包含{"alg":"HS256","typ":"JWT"},表示签名算法和类型。
  • Payload:存放业务声明的 JSON 编码,可以放sub(用户标识)、usernameroleexp(过期时间)、iat(签发时间)等。
  • Signature:对前两段做签名得到的结果,用来防止 token 被篡改。

这里有一个关键细节:Header 和 Payload 只是 Base64URL 编码,不是加密,任何拿到 token 的人都能解码看到内容,所以千万不要把密码、身份证号这类敏感信息放进 Payload。

Base64URL 和标准 Base64 的区别,是因为 JWT 经常出现在 URL 或 Header 里,标准 Base64 产生的字符+/在 URL 中有特殊含义,因此 JWT 把这两个字符替换成了-_,同时去掉末尾的=填充字符。如果你在 Java 里手动做 Base64 编码,要记得用Base64.getUrlEncoder().withoutPadding(),而不是getEncoder()

2.2 HS256 与 RS256 的选型逻辑

签名算法是 JWT 防篡改的核心。开发环境里最常碰到的两种算法是 HS256 和 RS256。

HS256(HMAC-SHA256)是对称签名:生成签名和校验签名用的是同一个密钥。打个比方,它像一个家用保险箱的密码——知道密码的人既能打开也能合上。优点是实现简单、性能好,适合单服务或小团队,密钥配置在服务端即可。缺点是密钥一旦泄露,攻击者可以自己伪造任意 token,而且如果你有多个服务都在验签,它们必须共享同一个密钥,密钥管理容易失控。

RS256(RSA-SHA256)是非对称签名:认证中心持有私钥,其他服务只拿公钥验签。它更像银行的印章制度——只有银行有印章,任何支行都能验证这个章是不是真的。优点是私钥不用分发到所有服务,只要认证中心不泄露,业务服务即使被拖了数据库也无法伪造新 token。缺点是需要管理证书和密钥对,性能略低于 HS256。

选型上我的建议很简单:

  • 单体应用、内部系统、开发测试环境:先用 HS256,快、省事。
  • 微服务架构,有独立的认证中心、多个业务服务:上 RS256,避免私钥全链路分发。
  • 如果以后可能对接第三方系统,提前用 RS256,让对方拿着公钥校验你的 token,比你每次远程调用认证中心更安全。

2.3 服务端一定要校验过期时间

JWT 的一个核心设计是过期时间。签发 token 时在 Payload 里放exp,服务端解析时第一件事就是判断当前时间是否已经超过了exp。在 Java 的 jjwt 库里,这个判断不需要你手动写:

Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody();

如果 token 已过期,parseClaimsJws会直接抛出ExpiredJwtException;如果签名不对,会抛出JwtException;如果 token 格式本身是乱写的,可能抛MalformedJwtException。我们只需要在 catch 里区分“过期”和“非法”这两种情况,做不同的业务处理。

有一种反面写法很常见:先解析出Claims,然后手动claims.getExpiration().before(new Date())判断是否过期。这样也能工作,但属于重复造轮子,而且一旦漏掉异常处理,校验就可能形同虚设。直接用库的异常机制,代码更少、语义更清晰。

3. 实战:封装 JWT 工具类并在登录接口中签发令牌

3.1 引入 jjwt 依赖,注意 0.11.5 和 0.12.x 的 API 差异

Java 生态里 JWT 工具库很多,国内用 jjwt 的比较多,API 算稳定。我用的是 0.11.5 版本,因为它和网上大多数中文教程一致,你照着写不容易踩坑。

<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>

这里特别提醒一下,如果你去 GitHub 上看最新版本,2024 年之后出的 0.12.x / 0.13.x 把 API 改了。最大的变化是parserBuilder().setSigningKey(key)老方法变成了parser().verifyWith(key)。如果你从旧项目抄代码,然后升级了 jjwt,会出现编译报错cannot find symbol,别慌,去官方 README 看对应版本写法即可。

3.2 工具类完整实现:生成、解析、刷新

我封装的工具类包含三个核心方法:createToken生成 token,parseToken解析 token,isTokenExpired判断是否过期。密钥我放在常量里,但生产环境必须放在配置文件或环境变量中,不能硬编码在代码里提交到 Git,否则等于把保险箱密码贴在门口。

import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.HashMap; import java.util.Map; public class JwtUtil { // 生产环境请放到配置中心或环境变量,这里仅为演示 private static final String SECRET = "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"; private static final long EXPIRE_TIME = 60 * 60 * 1000L; private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); public static String createToken(Long userId, String username, String role) { Date now = new Date(); Date expireDate = new Date(now.getTime() + EXPIRE_TIME); Map<String, Object> claims = new HashMap<>(8); claims.put("username", username); claims.put("role", role); return Jwts.builder() .setSubject(String.valueOf(userId)) .addClaims(claims) .setIssuedAt(now) .setExpiration(expireDate) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } public static boolean isTokenExpired(String token) { try { parseToken(token); return false; } catch (ExpiredJwtException e) { return true; } catch (JwtException e) { // 签名错误或者格式错误,统一视为无效 return true; } } }

这段代码里有几个关键点值得逐一说清楚:

第一,Keys.hmacShaKeyFor要求密钥字节长度至少 256 位(32 字节),否则 HS256 会抛出WeakKeyException。如果你临时用一个八个字符的字符串当密钥,跑起来准报错。这也是很多人第一步就跑不通的常见原因。

第二,setSubject放用户 ID,其他地方通过claims.getSubject()拿;用户名、角色这些字段用addClaims放在自定义声明里。将来做权限控制时,可以直接从 token 里取role,不用再查数据库。

第三,EXPIRE_TIME我给了 1 小时。实际项目里,access token 通常会短一些(比如 30 分钟),搭配一个 refresh token 来续期。这个机制等会儿在面试考点部分展开讲。

3.3 登录接口:签发 token 的正确姿势

有了工具类,登录接口就变得非常清爽:

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody @Valid LoginDTO dto) { // 参数校验(如账号密码不为空)、用户名校验交给Spring Validation User user = userService.checkLogin(dto.getUsername(), dto.getPassword()); if (user == null) { return Result.fail("用户名或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return Result.success(token); } }

一个容易忽略的细节是@Valid注解。热搜词里“登录失败:表单提交校验失败”多数就是在这里出的问题——前端传过来的 JSON 少了一个字段,后端 DTO 上标了@NotBlank(message = "用户名不能为空"),校验不通过,Spring 抛MethodArgumentNotValidException,如果全局异常处理器没写好,用户就会看到一句笼统的“登录失败:表单提交校验失败,请刷新后重试”。排查方向有两条:一是看后端日志有没有BindingExceptionMethodArgumentNotValidException;二是确认前端请求头Content-Typeapplication/json,后端用@RequestBody接收,两边别一个发 JSON 一个收表单。

4. Filter 和 Interceptor 的执行位置,以及它们的职责边界

4.1 一次请求的完整链路

很多面试者能说出 Filter 和 Interceptor 的概念,但问他俩的执行顺序就迷糊。我习惯画一条请求链路来看:

客户端请求进入 Tomcat 后,先经过 Filter 链,然后到 DispatcherServlet,DispatcherServlet 再调用 HandlerMapping 找到对应的 Controller 方法,这个过程中会依次执行 Interceptor 的preHandle→ Controller 方法 →postHandleafterCompletion,最后响应原路返回。

这条链路里最关键的一句话:Filter 在 DispatcherServlet 之前执行,Interceptor 在 DispatcherServlet 之后、进入 Controller 方法之前执行。所以 Filter 天然比 Interceptor 更“外层”,能更早拦截请求。也正因为位置不同,Filter 里出现了异常时,Interceptor 不会执行;而 Interceptor 里放行后的业务异常,Filter 的响应处理还是能兜底。

4.2 Filter 与 Interceptor 的关键差异

我把两者的核心差异整理成一张表,方便你记忆和使用:

对比维度FilterInterceptor
规范归属Servlet 规范Spring MVC 规范
执行阶段Servlet 容器内、DispatcherServlet 之前DispatcherServlet 之后、Controller 之前
依赖容器不依赖 Spring,普通 Filter 甚至拿不到 Spring Bean必须依赖 Spring 容器
可以获取的参数HttpServletRequest/ResponseHandlerMethod、ModelAndView、Exception
拦截粒度URL 路径URL 路径 + Handler 方法
典型场景编码、CORS、登录认证权限校验、日志、性能监控

这张表是面试高频考点,建议背熟。但实际问题中,更重要的是理解为什么需要这两个东西。

单靠 Filter,其实已经能完成登录校验,但它拿不到“这个请求最终要调到哪个 Controller 的哪个方法”。比如你有/user/detail接口,既要登录,又要求管理员角色才能访问,Filter 只能根据 URL 判断,判断 URL 又容易和 Controller 里定义的路径产生耦合。而 Interceptor 的preHandle方法会收到一个HandlerMethod对象,你可以从它身上拿到方法上的注解、方法所在类的注解,从而精准地做方法级权限控制。这就让 Interceptor 成了业务权限控制最自然的选择。

4.3 实操中的选型建议

我见过的成功案例,多数遵守这种分工:

  • 编码过滤器、CORS 过滤器、防 XSS 过滤器:Filter。
  • 登录认证(解析 JWT、校验是否过期):Filter,统一返回 401。
  • 业务权限(角色判断、接口访问限制):Interceptor + 自定义注解。
  • 操作日志、性能耗时统计:可以放 Interceptor,也可以放 AOP,看具体需求。

顺序上,Filter 放在最外层,Interceptor 在其后。请求先进 Filter,Filter 做掉“你登录了吗”这件事;Interceptor 做“你登录了,但你够不够权限做这件事”的检查。各管一段,代码才不会拧成一团。

5. 实战:用 Filter 拦截所有接口,统一解析 JWT

5.1 OncePerRequestFilter 与 Filter 的正确写法

Spring Boot 里我会直接继承OncePerRequestFilter。它是一个抽象类,核心目的是保证过滤器在一次请求中只被调用一次。为什么需要这个保证?因为 Spring 框架自身(比如转发跳转)可能让同一个请求被过滤器处理两次,普通 Filter 在这种情况下会重复执行。登录校验这种逻辑重复执行虽然不会崩溃,但白白多解析一次 JWT,没意义;而如果是记录请求内容的 Filter,重复执行会引发不可预期行为。

@Component public class JwtAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String uri = request.getRequestURI(); // 白名单直接放行 if (isWhitelist(uri)) { filterChain.doFilter(request, response); return; } String token = resolveToken(request); if (token == null) { writeUnAuthorized(response, "未登录,请先登录"); return; } try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.getSubject()); request.setAttribute("username", claims.get("username")); request.setAttribute("role", claims.get("role")); filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { writeUnAuthorized(response, "登录已过期,请重新登录"); } catch (JwtException e) { writeUnAuthorized(response, "无效令牌"); } } private String resolveToken(HttpServletRequest request) { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } return null; } private boolean isWhitelist(String uri) { return uri.startsWith("/api/auth/login") || uri.startsWith("/api/auth/register") || uri.startsWith("/doc.html") || uri.startsWith("/webjars/") || uri.startsWith("/v3/api-docs") || uri.equals("/error"); } private void writeUnAuthorized(HttpServletResponse response, String msg) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"" + msg + "\"}"); } }

一个很容易踩的坑是:@Component标注后,Spring Boot 默认会把它注册为一个过滤所有请求的 Bean,所以不用再额外写FilterRegistrationBean。如果你之前手动在配置类里new JwtAuthFilter(),就要小心注入问题。后面我会专门讲 Redis 注入为 null 的排查过程。

这段代码里还有一个细节值得说明:resolveTokenBearer前缀处理。前端通常在Authorization头里传"Bearer eyJ..."。很多新手直接拿整个字符串去解析,结果jjwtJwtException,然后报“无效令牌”。处理方式是先判断前缀,再截取后面的有效部分。

5.2 白名单路径设计:登录接口、注册、Swagger、静态资源

白名单的维护是非常容易被忽视的地方。我见过有人把所有接口都拦上,结果 Swagger 文档看不了,前端拿着登录接口也进不去,第一轮联调就卡死。

白名单至少要考虑这四类:

  • 认证类接口:登录、注册、刷新 token。
  • 接口文档:/doc.html/webjars/**/v3/api-docs/**/swagger-resources/**
  • 静态资源:/images/**/css/**/js/**,如果前后端不分离重启了静态页面。
  • Spring 自带错误页:/error

白名单不要散落在 Filter 代码里。建议抽一个SecurityProperties配置类,或者用@ConfigurationProperties读取application.yml里的security.whitelist列表,这样运维调整白名单时不用改代码重发。一个小项目阶段我可以理解写死在 Filter 里,但项目稍微成熟一点,改白名单要发版这件事就会被人骂。

5.3 解析成功后用户信息怎么传递

Filter 里解析出了用户信息,但 Controller 和业务代码还需要用。最直接的传递方式是request.setAttribute("userId", ...),然后在 Controller 方法的参数里加HttpServletRequest request,再request.getAttribute("userId")取出来。

如果你不想在业务代码里到处写request.getAttribute,可以封装一个UserContext工具类,底层用 ThreadLocal:

public class UserContext { private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); public static void set(LoginUser user) { HOLDER.set(user); } public static LoginUser get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }

但这个方案有个隐患:必须保证请求处理完之后清理 ThreadLocal,否则线程池复用线程时,上一次请求的用户信息会被下一次请求读到,造成严重的数据越权问题。所以用 ThreadLocal 时,一定在 Filter 的finally块里调用UserContext.clear(),或者放进 Interceptor 的afterCompletion里清理。

我在实际项目中更倾向直接把信息放进 request attribute,因为它的生命周期天然跟随请求,线程池复用也不会有残留问题,业务代码通过@RequestAttribute就能拿到。

5.4 踩坑实录:Filter 里注入 Redis 为什么是 null

如果你的过滤器中需要操作 Redis(比如把 token 加入黑名单),你可能会遇到一个印象深刻的报错:java.lang.NullPointerException,因为redisTemplate是 null。我当年排查这个问题花了将近半天。

先把排查链路完整列出来,你遇到类似问题可以参考这个思路。

第一步,确定过滤器类是否被 Spring 管理。怎么确定?在doFilterInternal里加一行日志打印当前类的类名和ClassLoader,然后看日志里是否存在。如果过滤器是被 Spring 容器管理的,它的名字会带上$$SpringCGLIB$$这样的代理标记;如果是容器直接 new 出来的,类名很干净。

第二步,检查过滤器是怎么注册的。Spring Boot 中有三种常见方式:

  • 方式一:类上标注@Component+@Order,直接让 Spring Boot 自动注册。
  • 方式二:自定义FilterRegistrationBean来注册。
  • 方式三:类上标注@WebFilter,然后在启动类上加@ServletComponentScan

前两种方式过滤器由 Spring 容器创建,@Autowired可以正常工作。但第三种方式下,过滤器由 Servlet 容器接管创建,生命周期和 Spring 容器完全没关系,你就算在字段上写了@Autowired,Spring 也没有机会执行注入,运行时就是 null。许多老项目留下来的@WebFilter代码,加上@ServletComponentScan,就是这个问题的常见来源。

第三步,检查是否有人手动new了过滤器。比如为了设置某些初始化参数,有人在配置类里写了return new JwtAuthFilter(),这样 Spring 也无法注入了。

解决方案我推荐两种:

方案一(推荐):统一走 Spring Bean 方式。类上保留@Component,通过构造器或属性注入拿到 RedisTemplate。

方案二(兜底):把过滤器变“瘦”。过滤器只负责解析 JWT 和设置用户信息,凡是需要 Redis 的逻辑(比如做黑名单判断)放到 Interceptor 里。如果连 Interceptor 也无法避免注入问题,再考虑写ApplicationContextAware手动获取 Bean,但这是不优雅的兜底方案,能用上面两种方式就不要用它。

5.5 登录状态校验:401 还是 200?统一返回结构

Filter 在拦截到未登录请求时,必须保持响应格式统一。我见过老项目里有的接口返回 401,有的返回 200 但 body 里code=1,前端写拦截器的时候苦不堪言。正确的做法是:未登录统一返回 HTTP 401,前端 Axios 或 fetch 的响应拦截器看到 401 就跳转登录页;登录过期也返回 401,但 body 里msg区分原因。

我给的示例代码里统一用response.setStatus(401),这就是为了让前端可以按状态码做统一处理。注意不要简单粗暴地返回一个纯文本,JSON 格式才是联调友好的方案。

6. 实战:用 Interceptor 做方法级权限控制,和 Filter 分工不重复

6.1 注册拦截器并配置拦截路径

Interceptor 的注册比 Filter 繁琐一点,需要实现WebMvcConfigurer接口:

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

注意excludePathPatterns和 Filter 里的白名单不要重复维护。一个简单的约定:Filter 管全局的登录认证白名单(包括文档、静态资源),Interceptor 只管业务路径/api/**的权限控制。两边各维护各的,职责不要混淆。

6.2 自定义 @RequireRole 注解实现细粒度权限

Filter 阶段已经确认了“你是谁”,Interceptor 阶段要解决“你能不能做这件事”。最优雅的方式是自定义注解,把它标到需要权限的 Controller 方法上:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }

然后在拦截器里读取这个注解:

@Component public class PermissionInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole == null) { return true; } String role = (String) request.getAttribute("role"); String[] allowedRoles = requireRole.value(); for (String allowedRole : allowedRoles) { if (allowedRole.equals(role)) { return true; } } response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"msg\":\"无权限访问\"}"); return false; } }

使用方式是在 Controller 方法上标注:

@GetMapping("/admin/statistics") @RequireRole("ADMIN") public Result statistics() { return Result.success(statisticsService.getData()); }

这个方案的巧妙之处在于,它不用硬编码 URL 和角色的映射关系。你可以把权限声明直接放在方法旁边,阅读代码时一眼就能看出这个接口需要什么角色。

这里要特别提一点:HandlerMethod类型判断很重要。Spring MVC 中,handler参数不一定是 HandlerMethod,如果只是请求favicon.ico等静态资源,handler 可能是ResourceHttpRequestHandler。如果不做instanceof判断直接强转,静态资源请求会让整个应用报 500 错误。这也是一个容易踩的坑,而且往往在开发环境一切正常,部署到正式环境因为静态资源和接口路由不同就炸了,排查时还不容易想到。

6.3 双层的配合逻辑:避免重复解析 token

Filter 已经解析过一次 JWT 了,Interceptor 还要再用吗?两种思路都可以,区别在于取舍。

我的建议是:Filter 负责解析并设置用户信息,Interceptor 只读 request attribute,不再调用 JwtUtil.parseToken。这样整个请求周期里只做一次签名验证,性能上更优,而且逻辑上也清晰:Filter 干“认证”,Interceptor 干“授权”。

如果你想更进一步防止重复解析,可以在 Filter 设置一个标记参数,比如request.setAttribute("jwt_resolved", Boolean.TRUE),Interceptor 判断已有标记就跳过。但老实说,如果你的 Filter 和 Interceptor 职责划分如上,标记是多余的,因为 Interceptor 根本不去解析 token。加这个标记反而意味着你的结构有重复。

6.4 登录时同时记录 Redis 会话,是否更稳

很多同学会问:既然说 JWT 无状态,那我在 Filter 里注入 Redis 干什么?这就要回到 JWT 的痛点——它无法主动失效。实际项目中,我们往往需要“临时”的会话状态:比如封禁用户、强制退出、修改密码后让旧 token 立即失效。

常见的折中方案是把 token 的jti(JWT ID)和用户会话标记存到 Redis。Filter 或 Interceptor 里用jti查一下 Redis,如果值存在就放行,不存在就拒绝。这样既保留了 JWT 的无状态性能优势,又获得了主动失效的能力。

我这里先提一个方向,后面面试考点里会再说几种更完整的方案。

7. 面试高频考点:关于 JWT 和登录校验的终极问答

7.1 JWT 和 Session 的区别

面试官问这道题,通常是看你能不能从“存储位置”“跨域”“分布式”“续期失效”多角度对比。

回答思路:Session 是服务端存储,浏览器通过 Cookie 里的 SessionId 找到对应会话数据,优点是服务端可控、可随时注销;缺点是分布式部署需要额外共享存储、跨域场景处理麻烦。JWT 是客户端存储,服务端收到 token 后验签解析,不需要保存状态,天然适合分布式和跨域;缺点是无法主动失效、token 体积较大、被盗后难以回收。

加分表达是:JWT 适合“一次签发、多处验证”的微服务场景,Session 适合“强状态、易注销”的传统单体场景,没有绝对优劣,只有是否匹配场景。

7.2 Filter 和 Interceptor 的区别

直接套用第 4 节的表格口头回答一遍。重点是点明:Filter 属于 Servlet 容器层,优先于 Interceptor 执行;Interceptor 属于 Spring MVC 层,能拿到 HandlerMethod;两者职责定位不同。如果面试官追问“登录校验应该用哪个”,可以回答:认证用 Filter、授权用 Interceptor,原因就是 HandlerMethod。

7.3 JWT 有哪些缺点,怎么解决

这是最能看得出项目经验的问题。只会说“JWT 不能主动失效”是不够的,还要给出解决方案。

  • 无法主动失效:把 token 的jti放进 Redis,做一个短时效的白名单或黑名单,Filter/Interceptor 查一下。
  • 续期麻烦:双 token 机制,access token 短过期(30 分钟)+ refresh token 长过期(7 天),刷新接口用 refresh token 换新 access token。
  • token 被盗用:强制 HTTPS、缩短有效期、绑定客户端标识(User-Agent、设备指纹),敏感操作再加二次验证。
  • 信息泄露:Header 和 Payload 只做 Base64URL 编码,不放敏感数据。确实需要保密内容,可以用加密算法对 Payload 再加密。

7.4 双 token 机制该怎么设计

双 token 是近几年大厂项目里比较常见的方案。简单说:登录成功后返回两个 token。

  • Access Token:有效期短,比如 30 分钟,业务接口用它做访问凭证。
  • Refresh Token:有效期长,比如 7 天,存到 Redis 并和用户绑定。

客户端拿着 access token 请求业务接口,快过期时前端主动调/api/auth/refresh,后端验证 refresh token 有效且未过期,签发一个新的 access token。因为 access token 有效期短,即使被盗,影响时间窗口也有限;refresh token 虽然有效期长,但它通常不会出现在前端的业务请求头里,不容易被窃取。

这个方案的难点在于:刷新接口本身要避免 token 被重放。业界常见的做法是 refresh token 用一次就作废,每次刷新时重新生成一个 refresh token 并更新 Redis。

7.5 用户注销/修改密码后,如何让旧 token 立即失效

一个完整的企业级方案里,JWT 无状态不是绝对的不保存任何状态。为了让旧 token 立即失效,我见过三种做法:

  • Redis 黑名单:注销或改密时,把 token 的jti写入 Redis,设置存活时间等于 token 剩余有效期。Filter 查黑名单,命中就拒绝。
  • 用户版本号:在 JWT 的 Payload 里放一个ver字段,用户改密后让 Redis 里的版本号加 1,解析 token 时比对版本号,不一致就拒绝。缺点是多一次 Redis 查询。
  • 短 token + 刷新机制:把 access token 的有效期压到 15 分钟,用户改密后旧 token 最多 15 分钟自动失效。这个方案实现最简单,但安全窗口比前两种长。

实际项目中我会组合使用:短 token + 改密时让 Redis 记录失效时间,解析时判断 token 的iat是否早于失效时间,早于就拒绝。

7.6 登录/注册接口频繁被刷怎么办

如果面试官从登录校验聊到了安全问题,下面这个点能加分:登录接口本身往往也在 Filter 白名单里。它的风险是暴力破解和短信轰炸。常见防护手段包括:

  • 图形验证码或行为验证码。
  • 登录失败 N 次后锁定账号或 IP 一段时间。
  • 手机短信验证码加时效和次数限制。
  • 加上限流(Rate Limit),可以用 Redis 的 INCR + EXPIRE 实现简单的滑动窗口计数。

这些话题一展开能聊很久,面试官也能看出来你不是只会背概念。

7.7 为什么有时候解析 token 报 JwtException?

JwtException是一个宽泛异常,可能的原因包括:签名密钥不对、token 被篡改、token 格式不正确、token 里带了非法字符。排查时先看是不是ExpiredJwtException,不是的话多半是密钥配置不一致。最常见的情形:本地开发时密钥写死,测试环境通过环境变量传入,两边密钥不同,导致测试环境所有 token 解析失败。用 RSA 算法时,这个问题会变成“开发环境拿着真实公钥验不了本地私钥签的 token”,本质一样,都是密钥不统一。

8. 最后补充:写这套登录校验收尾的几个经验

我在多个项目里落地过 JWT + Filter + Interceptor 这套结构,最后分享几个实际沉淀下来的经验。

第一,Filter 和 Interceptor 里的异常处理要全部走完链路。未登录返回 401、权限不足返回 403,这两件事一定要在 Filter/Interceptor 层直接完成,不要在 Controller 里抛出异常再让全局处理器兜底。因为如果用了错误参数,比如 Controller 里没人接住 401,就可能被全局异常处理器转成 200,前端就没法根据状态码统一跳登录页了。

第二,配置文件的白名单一定要可读、可改。我推荐的做法是把security.whitelist放到application.yml里,新增一个健康检查接口或者工具页面时,不需要重新编译代码,运维在配置中心改一行重启就够了。

第三,不要暴露出 token 的完整内容。服务端日志打印时,只打印 token 前 10 位作为关联 ID,不要整个 token 落日志,否则一旦日志系统被入侵,所有用户的登录凭证就泄光了。

第四,加一个登录拦截的自动化测试。测试里至少覆盖四条链路:无 token 访问受保护接口返回 401、过期 token 返回 401、有效 token 访问无权限接口返回 403、有效 token 且角色匹配正常通过。这四条链路一旦自动化起来,比人工反复点页面省事得多。

这一套做下来,登录校验的骨架就完整了。你如果之前一直用 Session,转换到 JWT 会有一个适应期,尤其是遇到“如何主动踢人下线”这类需求时,特别容易怀疑无状态方案是不是选错了。我的体会是:没有一劳永逸的方案,只有更适合你业务形态的取舍。如果你能根据项目特点灵活组合无状态令牌和有状态会话,面试和实战都会游刃有余。

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

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

立即咨询