
做 Java 后端这几年登录校验是每个项目都躲不开的基本功。早期我用 Session 登录状态后来项目拆了微服务发现 Session 在多个服务之间同步特别别扭才认真研究JWT 令牌。等我把验证逻辑真正落地的时候又发现Filter和Interceptor这些概念看着简单实际用起来坑很多要么 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 才能知道用户是谁这多了一次网络往返。JWTJSON Web Token的出现把这些痛点变成了它的优点token 本身就是一段自包含的数据解析出来就知道用户 id、用户名、角色、过期时间服务端不需要保存任何会话数据。认证服务签发一个 JWT业务服务拿着公钥就能验证真伪非常适合微服务场景。当然 JWT 不是银弹。后面面试考点部分我会详细讲它的缺点和应对方案。这里只做一个结论选 JWT 不是因为 Session 落后而是因为无状态模型更适合分布式、多端接入的现代应用架构。2. JWT 的工作原理三段字符串背后的签名逻辑与算法选型2.1 拆解一个 JWTHeader.Payload.SignatureJWT 的字符串长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSIsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzAwMDAwMDB9.qBvEfqGs3dNlZbNOwd8DUQyMPkONY6nL0Z5bH4Hl4NY用点号分成三段分别是 Header、Payload、Signature。HeaderJSON 字符串的 Base64URL 编码内容一般包含{alg:HS256,typ:JWT}表示签名算法和类型。Payload存放业务声明的 JSON 编码可以放sub用户标识、username、role、exp过期时间、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。HS256HMAC-SHA256是对称签名生成签名和校验签名用的是同一个密钥。打个比方它像一个家用保险箱的密码——知道密码的人既能打开也能合上。优点是实现简单、性能好适合单服务或小团队密钥配置在服务端即可。缺点是密钥一旦泄露攻击者可以自己伪造任意 token而且如果你有多个服务都在验签它们必须共享同一个密钥密钥管理容易失控。RS256RSA-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 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这里特别提醒一下如果你去 GitHub 上看最新版本2024 年之后出的 0.12.x / 0.13.x 把 API 改了。最大的变化是parserBuilder().setSigningKey(key)老方法变成了parser().verifyWith(key)。如果你从旧项目抄代码然后升级了 jjwt会出现编译报错cannot find symbol别慌去官方 README 看对应版本写法即可。3.2 工具类完整实现生成、解析、刷新我封装的工具类包含三个核心方法createToken生成 tokenparseToken解析 tokenisTokenExpired判断是否过期。密钥我放在常量里但生产环境必须放在配置文件或环境变量中不能硬编码在代码里提交到 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); MapString, 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如果全局异常处理器没写好用户就会看到一句笼统的“登录失败表单提交校验失败请刷新后重试”。排查方向有两条一是看后端日志有没有BindingException或MethodArgumentNotValidException二是确认前端请求头Content-Type是application/json后端用RequestBody接收两边别一个发 JSON 一个收表单。4. Filter 和 Interceptor 的执行位置以及它们的职责边界4.1 一次请求的完整链路很多面试者能说出 Filter 和 Interceptor 的概念但问他俩的执行顺序就迷糊。我习惯画一条请求链路来看客户端请求进入 Tomcat 后先经过 Filter 链然后到 DispatcherServletDispatcherServlet 再调用 HandlerMapping 找到对应的 Controller 方法这个过程中会依次执行 Interceptor 的preHandle→ Controller 方法 →postHandle→afterCompletion最后响应原路返回。这条链路里最关键的一句话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 在其后。请求先进 FilterFilter 做掉“你登录了吗”这件事Interceptor 做“你登录了但你够不够权限做这件事”的检查。各管一段代码才不会拧成一团。5. 实战用 Filter 拦截所有接口统一解析 JWT5.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;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\ msg \}); } }一个很容易踩的坑是Component标注后Spring Boot 默认会把它注册为一个过滤所有请求的 Bean所以不用再额外写FilterRegistrationBean。如果你之前手动在配置类里new JwtAuthFilter()就要小心注入问题。后面我会专门讲 Redis 注入为 null 的排查过程。这段代码里还有一个细节值得说明resolveToken中Bearer前缀处理。前端通常在Authorization头里传Bearer eyJ...。很多新手直接拿整个字符串去解析结果jjwt抛JwtException然后报“无效令牌”。处理方式是先判断前缀再截取后面的有效部分。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工具类底层用 ThreadLocalpublic class UserContext { private static final ThreadLocalLoginUser 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 中有三种常见方式方式一类上标注ComponentOrder直接让 Spring Boot 自动注册。方式二自定义FilterRegistrationBean来注册。方式三类上标注WebFilter然后在启动类上加ServletComponentScan。前两种方式过滤器由 Spring 容器创建Autowired可以正常工作。但第三种方式下过滤器由 Servlet 容器接管创建生命周期和 Spring 容器完全没关系你就算在字段上写了AutowiredSpring 也没有机会执行注入运行时就是 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 里code1前端写拦截器的时候苦不堪言。正确的做法是未登录统一返回 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;charsetUTF-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 双层的配合逻辑避免重复解析 tokenFilter 已经解析过一次 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 的jtiJWT 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 报 JwtExceptionJwtException是一个宽泛异常可能的原因包括签名密钥不对、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 会有一个适应期尤其是遇到“如何主动踢人下线”这类需求时特别容易怀疑无状态方案是不是选错了。我的体会是没有一劳永逸的方案只有更适合你业务形态的取舍。如果你能根据项目特点灵活组合无状态令牌和有状态会话面试和实战都会游刃有余。