1. 这一章,先解决“为什么登录之后还要做校验”
先聊点实在的。Java后端做登录校验,几乎每个项目都躲不开。从最早期的Session/Cookie,到后来前后端分离架构下的JWT,再到Spring Security、Sa-Token这类封装好的框架,核心要解决的始终是同一个问题:HTTP协议本身是无状态的,服务器怎么知道“当前发起请求的人,是不是之前登录的那个人”?
所以你去看各个公司的Java面试题,登录校验、JWT、Filter、Interceptor几乎是必考题。不是面试官卷,而是这个点确实能考察一个开发者的基本功——懂不懂HTTP无状态特性、有没有真正理解Token是什么、会不会在框架层面拦截请求做统一校验。它不像某个冷门框架的源码那么偏,但能问的层次非常深。
这篇博文我会从底层原理开始,把Session和Token两条路对比清楚,然后手写一个基于JWT的登录校验Demo,分别用Filter和Interceptor两种方式实现,最后把常见的面试题和实战中容易踩的坑一次说透。整个篇幅会比较长,但每一段都尽量保证不废话,该给代码给代码,该讲原理讲原理。
如果你现在处于这几个阶段,这篇文章应该能帮到你:
- 刚学完Java Web基础,准备接触真实的登录认证场景
- 工作中要在前后端分离项目里做接口鉴权
- 在准备Java后端面试,想系统整理登录校验相关知识点
- 自己写项目练手,想让登录功能更规范、更接近企业级做法
我在写代码示例时尽量保持轻依赖,核心逻辑手写,让你能看清每一步到底发生了什么,而不是被框架封装得稀里糊涂。
2. 登录校验的整体思路:两种方案,四条主线
2.1 Session 方案到底是什么
先说最早也最经典的方案——Session。它的工作流程其实很简单:
用户第一次来访问,服务器收到请求后,在内存里(或者Redis里)创建一条会话记录,生成一个全局唯一的Session ID,然后通过响应头Set-Cookie把Session ID发给浏览器。浏览器之后每次请求都会自动带上这个Cookie,服务器拿着它去查对应的会话记录,如果查到了、也没过期,就认为请求是合法的。
这个方案在传统的服务端渲染项目里非常好用,因为前后端在一个部署环境里,Cookie天然被浏览器管理,开发者甚至不需要手动处理Token的存储。但它的痛点也很明显:
第一,Session数据如果放在服务器内存里,那么无法横向扩展。你部署多台应用服务器,用户在A服务器登录了,下一次请求被负载均衡转发到B服务器,B服务器内存里没有这个Session,用户就得重新登录。虽然可以用粘滞会话(Sticky Session)或者Session同步来解决,但架构上就变得很别扭。
第二,Cookie在跨域场景下非常难处理。现在的前后端分离项目,前端跑在localhost:5173,后端跑在localhost:8080,Cookie要配Domain、要处理SameSite,一个不小心就会被浏览器拦掉。我自己就在这个事上浪费过很长时间。
第三,Session天生是“有状态”的。服务器必须记住每一个登录用户,如果用户量上来,存储压力会越来越大,分布式环境下还要引入Redis做集中式存储,整套东西并不轻松。
2.2 Token 方案又好在哪
Token方案(主要是JWT)走的是完全相反的路子:服务端不保存登录状态。用户登录成功后,服务器把用户ID、过期时间这些信息通过签名算法打包生成一个字符串,这个字符串就是Token,返回给客户端。客户端(通常是前端)把它存在localStorage里,之后每次请求在请求头里加上Authorization: Bearer <token>。服务端拿到Token后验签、解析,确认没问题就直接认为请求合法。
这样服务端就彻底“无状态”了,不占内存、天然支持水平扩展。你部署一百台服务器,用户拿着同一个Token在任意一台验签都能通过,根本不需要关心请求落到哪台机器上。
JWT的另一个好处是结构标准化。它是一个经过编码的JSON对象,由三部分组成:Header(头部)、Payload(载荷)、Signature(签名)。每一部分用Base64URL编码后用点号拼接,最终长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c用任何在线工具甚至自己写代码解码,都能看到它的内容:
Header部分包含签名算法和Token类型,Payload部分包含业务信息(比如用户ID、用户名、过期时间),Signature则是对前两部分做签名运算后的结果,用来防止内容被篡改。
2.3 为什么我今天重点讲JWT而不是Session
其实聊到这里你应该能感觉到,JWT方案更适合当前主流的前后端分离架构,也是Spring Boot默认推荐的方向之一。它解决了Session的分布式共享痛点,也让前后端在接口对接时不需要关心Cookie的各种限制。
但JWT也不是没有坑:Token一旦签发,在过期之前是“无法主动作废”的。用户改了密码怎么办?管理员把用户封了怎么办?这些问题都要靠额外手段兜底。所以我会在后面专门用一个章节讨论JWT的局限性以及项目中实际的补救做法。
3. JWT核心原理拆解:三段结构,三个关键点
3.1 Header、Payload、Signature 逐个击破
先看一下Header部分,它是JSON格式的:
{ "alg": "HS256", "typ": "JWT" }alg是签名算法,常用的是HS256(对称加密)和RS256(非对称加密),typ固定是JWT。这部分的Base64URL编码不是乱码,是能解出来的。
Payload部分存放实际要传递的信息,也叫Claims(声明)。JWT规范里预定义了一些字段,比如sub(主题)、exp(过期时间)、iat(签发时间)、iss(签发者),你完全可以自定义字段:
{ "sub": "1234567890", "name": "张三", "role": "admin", "iat": 1516239022, "exp": 1617239022 }你可能会问,Payload里能放密码吗?放手机号行不行?我的建议是:只放非敏感信息。因为Payload只是Base64URL编码,并没有加密,任何拿到Token的人都可以直接解码看到里面的内容。你把密码放进去等于明文裸奔。
Signature部分是整个JWT的安全核心。如果用的是HS256,它的签名公式是这样的:
HMACSHA256(base64UrlHeader + "." + base64UrlPayload, secret)也就是说,服务端用同一个密钥,把前两段的编码结果拼接起来做哈希,得到一段签名。这个签名附在Token末尾,客户端理论上是无法伪造的,因为它不知道你的密钥。
3.2 Base64URL 和 Base64 的区别
这里有一个很多新手会忽略的细节:JWT用的不是普通的Base64编码,而是Base64URL编码。它俩的区别在于,Base64编码里有两个字符在URL场景下会有问题——+和/。放在URL参数里,+会被解析成空格,/会破坏路径结构。所以Base64URL把+换成-,把/换成_,并且去掉末尾的=填充符。
因此你看到的JWT字符串里永远不会有+和/,这就是特意为URL场景做的优化。
3.3 算法选择:HS256 vs RS256
这两个算法在实际项目里的选择,面试里基本都会问到。
HS256是对称加密,签名和验签都用同一个密钥。它的优点是实现简单、性能好,缺点是密钥一旦泄露,攻击者可以同时伪造和验签。对于纯后端内部系统来说,HS256完全够用。
RS256是非对称加密,私钥在认证服务手里,签名时用私钥;验签的公钥可以分发给任意微服务实例,验签时用公钥。它的好处是安全边界更清晰,非常适合分布式微服务架构。比如你有一个独立的认证中心,其他业务服务只需要拿到公钥就能验签,根本不需要知道私钥。
通常在单体项目或者中小型系统里,HS256足够。到了微服务架构,我更建议直接上RS256,把验签用的公钥配到每个服务里,避免密钥通过内网传输带来风险。
4. 代码实战:手写一个基于JWT的登录校验
4.1 项目结构设计与技术选型
接下来进入代码环节。我先把工程结构摆出来,你照着搭就行。技术栈是Spring Boot 2.7 + JWT工具库jjwt 0.11.5。
com.example.jwtlogin ├── controller │ ├── AuthController.java │ └── UserController.java ├── interceptor │ └── JwtInterceptor.java ├── filter │ └── JwtAuthFilter.java ├── util │ └── JwtUtil.java ├── config │ ├── WebConfig.java │ └── FilterConfig.java └── JwtLoginApplication.java我用的数据库是MySQL,但为了降低复杂度,这里不做数据库操作,用户表简化成一个静态Map。实际项目中换成MyBatis-Plus或JPA即可,登录鉴权的核心逻辑不受影响。
先来看pom.xml里需要引入的依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <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>4.2 JWT工具类:生成与解析的核心代码
JwtUtil是整个登录系统的核心,封装了Token的生成、解析和校验逻辑。我用HS256算法,密钥直接写死在配置里,这个在生产环境要放到配置中心或环境变量里。
@Component public class JwtUtil { // 生产环境一定不要硬编码,建议放到环境变量或配置中心 private static final String SECRET_KEY = "my-secret-key-my-secret-key-my-secret-key"; // 至少32字节 private static final long EXPIRE_TIME = 1000 * 60 * 60 * 24; // 24小时 private static final SecretKey key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); /** * 生成Token */ public static String generateToken(Long userId, String username, String role) { Date now = new Date(); Date expireDate = new Date(now.getTime() + EXPIRE_TIME); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("role", role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(key, SignatureAlgorithm.HS256) .compact(); } /** * 解析Token,获取Claims */ public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } /** * 判断Token是否过期 */ public static boolean isExpired(String token) { try { Claims claims = parseToken(token); return claims.getExpiration().before(new Date()); } catch (ExpiredJwtException e) { return true; } } }关于上述代码有几点要特别说明:
密钥长度不能太短,HS256要求密钥至少256位(32字节),否则运行时直接抛异常,这是一个很容易踩的坑。如果你用短字符串,比如"secret",jjwt会报WeakKeyException,而且这个异常不是编译期报错,是启动后第一次调用才炸出来。
.claim("username", username)是自定义字段的写法,实际项目中常见放userId、角色、昵称等非敏感信息。解析时通过claims.get("username")取出来。
过期时间配置为24小时,这在一些企业后台系统中偏短,但安全性更好。实际项目建议根据业务场景定,比如APP端的Token有效期可以做到7天甚至更长。
4.3 Filter 实现登录校验:基于 javax.servlet 的方案
Filter是Servlet层面的组件,它最早被定义在javax.servlet包中,在Spring Boot应用里可以拦截所有请求,优先级高于Interceptor。
我需要先建一个继承了OncePerRequestFilter的过滤器类。这个类和普通的Filter接口区别在于,它保证了一次请求只会执行一次过滤逻辑,避免某些容器环境下过滤器被重复调用的尴尬:
@Component public class JwtAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 放行登录请求,也就是 /login 接口 String requestURI = request.getRequestURI(); if (requestURI.startsWith("/login")) { filterChain.doFilter(request, response); return; } // 获取请求头中的Authorization String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { setUnauthorizedResponse(response); return; } String token = authHeader.substring(7); try { Claims claims = JwtUtil.parseToken(token); // 将用户信息放入request属性,方便后续Controller获取 request.setAttribute("userId", Long.valueOf(claims.getSubject())); request.setAttribute("username", claims.get("username")); request.setAttribute("role", claims.get("role")); filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { setUnauthorizedResponse(response, "Token已过期"); } catch (JwtException | IllegalArgumentException e) { setUnauthorizedResponse(response, "Token无效"); } } private void setUnauthorizedResponse(HttpServletResponse response) throws IOException { setUnauthorizedResponse(response, "未登录或Token无效"); } private void setUnauthorizedResponse(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 + "\"}"); } }过滤器里我把登录接口直接放行,因为用户还没有Token,不能让过滤器拦住。对于需要认证的接口,先从Authorization请求头里取Bearer开头的Token串,然后调用JwtUtil.parseToken验证签名和过期时间。验签通过后,把用户信息塞进request属性,后续所有处理链上的代码都能拿到当前用户信息。
需要注意几个小细节:
- 请求头名称是
Authorization,这是HTTP标准推荐的自定义认证头。前端代码里用Axios的话,需要配置拦截器统一加上这个头。 Bearer前缀是一种约定,来自OAuth2.0规范。你也可以不用,直接放Token,但建议遵循惯例,因为很多框架和网关天然识别这种格式。- 异常处理必须细化。
ExpiredJwtException和JwtException要分别处理,这是一个非常影响使用体验的细节——用户Token过期了,你返回的提示语应该告诉他是“过期”而不是笼统的“无效”。
接下来把过滤器注册到Spring Boot的过滤器链中:
@Configuration public class FilterConfig { @Bean public FilterRegistrationBean<JwtAuthFilter> jwtFilterRegistration(JwtAuthFilter filter) { FilterRegistrationBean<JwtAuthFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(filter); registration.addUrlPatterns("/*"); registration.setOrder(1); return registration; } }setOrder(1)是设置执行顺序,数字越小优先级越高。如果你的项目里还有其他过滤器,比如跨域过滤器、日志过滤器,这个顺序需要规划好。我通常会让CORS过滤器在最前面,登录校验过滤器紧随其后,因为CORS跨域的问题不解决,后续的请求根本到不了业务逻辑。
4.4 Interceptor 实现登录校验:Spring MVC 的 HandlerInterceptor
如果说Filter是Servlet时代的产物,那么HandlerInterceptor就是Spring MVC框架精心设计的拦截器。它的API设计更贴合开发者习惯,可以在请求到达Controller之前和渲染完成之后分别插入逻辑。
一个典型的登录拦截器长这样:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) { return true; } // 非控制器方法直接放行 if (!(handler instanceof HandlerMethod)) { return true; } String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { writeErrorResponse(response); return false; } String token = authHeader.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", Long.valueOf(claims.getSubject())); request.setAttribute("username", claims.get("username")); return true; } catch (ExpiredJwtException e) { writeErrorResponse(response, "Token已过期"); return false; } catch (JwtException | IllegalArgumentException e) { writeErrorResponse(response, "Token无效"); return false; } } private void writeErrorResponse(HttpServletResponse response) throws IOException { writeErrorResponse(response, "未登录或Token无效"); } private void writeErrorResponse(HttpServletResponse response, String msg) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(401); response.getWriter().write("{\"code\":401,\"msg\":\"" + msg + "\"}"); } }preHandle方法的返回值很关键:返回true表示放行,进入Controller处理;返回false表示拦截,请求到此为止。
然后通过实现WebMvcConfigurer接口来注册拦截器:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/error"); } }这里有一个很爽的点:Interceptor可以精确配置需要拦截的路径和放行的路径。比如/login、/register这种不需要登录的接口,直接在excludePathPatterns里排除掉,不用像Filter那样在代码里手动判断URI前缀。
4.5 登录 Controller 和业务逻辑联调
基本的拦截和过滤器已经写完了,现在需要一个登录接口和一个需要认证的业务接口来验证整个链路是否跑通。
@RestController @RequestMapping("/api") public class AuthController { @PostMapping("/login") public Result login(@RequestBody LoginRequest req) { // 模拟数据库查询 if ("admin".equals(req.getUsername()) && "123456".equals(req.getPassword())) { String token = JwtUtil.generateToken(1001L, "admin", "role_admin"); return Result.ok(token); } return Result.error("用户名或密码错误"); } @GetMapping("/user/info") public Result userInfo(HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); String username = (String) request.getAttribute("username"); // 实际业务中通过userId去数据库查询用户信息 return Result.ok("用户" + username + "的信息,ID=" + userId); } }LoginRequest就是一个简单的实体类,包含username和password两个字段,用@RequestBody接收JSON。
启动项目后,先用Postman或curl请求登录接口:
curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'拿到返回的Token后,再访问需要认证的接口:
curl -X GET http://localhost:8080/api/user/info \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx.yyy"测试成功后,把Token改一个字符再请求,应该返回401。
到此,一个基于JWT的登录校验Demo就跑通了。这套代码麻雀虽小五脏俱全,后面你往里接数据库、接Redis、接Spring Security都是在这个骨架上做扩展。
5. Filter 和 Interceptor 的异同,一张表说透
刚才两套实现,代码看起来功能一样,但实际上它们是两个完全不同的东西。面试里这个问题也是高频考点,我给你们一个可以直接参考的对比表:
| 维度 | Filter | Interceptor |
|---|---|---|
| 规范归属 | Servlet规范 | Spring MVC框架 |
| 执行时机 | 请求进入Servlet之前 | Servlet处理后、进入Controller之前 |
| 作用范围 | 所有经过Servlet的请求 | 由Spring MVC管理的请求 |
| 是否可访问Spring Bean | 需要手动注册,能注入但有些容器环境下不便 | 本身就是Spring Bean,自动注入 |
| 路径配置 | 使用URL通配符,如/* | 使用Ant表达式,如/**、/api/* |
| 获取处理器方法信息 | 不能获取到具体Controller方法 | 可以拿到HandlerMethod,判断是不是Controller方法 |
| 是否能被Spring AOP增强 | 不能 | 能 |
| 底层原理 | 依赖Servlet容器回调 | 依赖Spring MVC的DispatcherServlet分发机制 |
| 拦截粒度 | 请求级别 | 方法级别 |
在实际项目里,Filter和Interceptor并没有绝对的替代关系。如果你需要做全局的字符编码、CORS跨域、请求日志记录,用Filter更合适,因为它最早接触请求,最“外围”。如果你要做登录鉴权、权限控制、方法级检查,用Interceptor更顺手,因为可以精确到某个Controller的某个方法。
还有一点容易被忽视:Filter里抛出的异常不会走Spring的@RestControllerAdvice全局异常处理器。因为Filter执行时Spring MVC的异常解析链路还没准备好。但Interceptor的preHandle里抛出的异常可以被全局异常处理器捕获。这也是很多人把登录校验放在Interceptor而不是Filter的一个原因——异常处理更统一。
6. 关于 JWT 的局限性,以及我推荐的补救方案
6.1 无状态是一把双刃剑
JWT把状态存在客户端,服务端不持久化,这在带来扩展性优势的同时,也带来了一个让人头疼的问题:Token在过期之前始终有效。
举几个实际场景:
用户修改密码后,旧Token依然有效,因为服务端不知道密码已经变了。管理员把某个账号封禁了,但这个用户手里已有的Token在过期之前还是能用。用户在APP上退出登录,Token放在本地没有清除,下次装回来发现状态还是登录的。
这些场景本质上都违反了直觉——用户以为“退出就是失效”,但JWT做不到真正的即时失效。
6.2 我的常用补救策略
第一个策略是缩短Token有效期 + Refresh Token。访问Token的有效期设短一些,比如30分钟到2小时,后端返回一个短期Token的同时,再返回一个长期Refresh Token(有效期7~30天)。当访问Token过期时,客户端拿着Refresh Token去请求一个新的访问Token。这个方案广泛应用于移动端和前后端分离项目。
第二个策略是白名单或黑名单。在Redis里维护一个Token黑名单,把需要立即失效的Token的jti(JWT ID)存进去,校验时先查一下黑名单。这个方案适合用户量不大,但安全要求高的场景。
第三个策略是把用户状态的关键信息做成可查询的动态字段。比如在Payload里不存角色,而是存一个ver版本号字段,用户修改密码或封禁时版本号加1,服务端验签时去查当前用户的版本号和Token里的版本号是否一致。
具体用哪种,取决于业务需求。如果是内部管理系统,直接用短Token + Redis黑名单就够了;如果是To C的APP,Refresh Token是刚需。
6.3 Token存储位置的选择
还有一个很现实的问题:Token到底让前端存在哪里?
存在localStorage里,优点是JS可以轻松读取,适合单页应用。缺点是容易受到XSS攻击——如果页面里被注入了恶意脚本,Token会被直接偷走。
存在Cookie里,带上HttpOnly属性后,JS无法访问,能有效防御XSS,但需要处理CSRF(跨站请求伪造)问题。
我个人建议是:如果项目里有XSS防护措施,可以存在localStorage,开发成本低;如果安全要求高,Token放HttpOnly Cookie里,同时加CSRF Token防护。这个点也算面试加分项,能体现你对前端的了解不是停留在表面。
7. 面试考点:这一节内容,背熟就能扛住大部分追问
7.1 高频八股文题目整理
整理一下这个主题下经常被问到的几类问题:
- 说一下JWT由哪几部分组成,各自的作用是什么?
- JWT和Session有什么区别?为什么现在更多用JWT?
- Filter和Interceptor的区别是什么?
- JWT的Token能不能主动失效?如果不能,项目里怎么处理?
- JWT放在请求头里还是Cookie里?为什么?
- 怎么处理JWT的过期时间?
- JWT的Payload里可以放密码吗?
- 如果JWT被泄露了怎么办?
这些问题看上去都有标准答案,但考官往往会追问“为什么”、“还有没有别的方案”。所以在回答时不要只背结论,要把前后因果讲清楚。比如问“为什么JWT无状态性好”,你可以从水平扩展、服务器集群、分布式Session共享这几个方面来展开。
7.2 我总结的答题套路
回答这类问题的核心是三步:说清楚是什么 → 对比你之前用什么方案 → 解释你为什么选择它、放弃了什么。
举个例子。面试官问“Session 和 JWT 你怎么选?”
你可以这样答:
Session方案的优点是服务端可以随时让某个会话失效,数据存储在服务端也相对安全;缺点是分布式环境下需要处理Session共享,前后端分离场景跨域麻烦。JWT的优点是服务端无状态、天然支持分布式,缺点是失效困难。如果是传统服务端渲染项目,我会优先选Session,省事又直观;如果是前后端分离并且有多个后端实例,我会用JWT,配一个短时效加黑名单的方案来弥补主动失效的问题。
这个答案既展示了你的知识广度,也让面试官知道你面对具体业务时会权衡而不是死记硬背。
7.3 实战面试中容易被追问的细节
有些面试官会考得很细,比如:
jjwt0.11.x版本里的setSubject和claim有什么区别?setSubject是标准字段,已经定义在Payload规范里,claim是自定义字段。两者最终都以JSON字段的形式存放在Payload中。
- 解析JWT时可能出现哪些异常?
- 主要有
ExpiredJwtException(过期)、UnsupportedJwtException(不支持的Token格式)、MalformedJwtException(Token格式错误)、SignatureException(签名不匹配)、IllegalArgumentException(Token为空或空白)。实战中需要逐一捕获并给出不同的响应。
- 主要有
- 密钥放在代码里吗?
- 正规做法是放到环境变量、配置中心或KMS密钥管理服务中。硬编码在代码里是严重的安全隐患。
8. 调试记录:第一次跑通时我踩过的几个坑
8.1 Token每次都不一样,导致本地无法“永久登录”
这是我最早用JWT时最困惑的点。明明用户信息没变,为什么每次登录生成的Token都不一样?
原因很简单:Payload里我放了iat(签发时间)和exp(过期时间)这两个时间字段,每次登录都会变化,所以最终生成的Token字符串自然不同。这不是Bug,这是正常现象。只要验签能通过,Token不同完全无所谓。
8.2 传递密钥到 jjwt 时提示 WeakKeyException
写成Keys.hmacShaKeyFor("my-secret".getBytes())直接报了WeakKeyException。原因我前面提到过,HS256要求密钥至少256位(32字节),不够就报错。这就是为什么我在示例里把密钥写成了那么长一串。
8.3 Spring Boot 未授权时返回的默认 401 结构不是 JSON
当Filter里没有手动写JSON响应体时,前端拿到的错误信息是Spring Boot默认的Basic Auth错误页面,而不是统一的接口返回结构。所以一定要在Filter里设置response.setContentType("application/json;charset=UTF-8")并自己写入JSON字符串,否则前后端联调时又是一堆吐槽。
8.4 前端跨域导致 Authorization 请求头丢失
现在的开发基本是前后端分离,前端端口和后端端口不同,天然跨域。如果你在后端只配置了允许来源(allowedOrigins),没有暴露请求头,前端自定义的Authorization头可能会在预检请求阶段被浏览器挡下来。解决方法是允许Authorization这个请求头。
8.5 过滤器放行路径记得把错误页也带上
Spring Boot的/error路径也要考虑,如果Filter把所有请求都拦了,遇到404或500这类错误跳转时,可能因为拿不到Token直接又变成401,前端排查问题时一脸懵。所以我在Interceptor示例里把/error也放进了excludePathPatterns。
9. 附录:演示用完整代码片段汇总
为了方便你copy下来直接跑,我把核心代码集中放一下。
JwtLoginApplication入口类:
@SpringBootApplication public class JwtLoginApplication { public static void main(String[] args) { SpringApplication.run(JwtLoginApplication.class, args); } }配置类Config:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/error"); } }统一返回结果封装Result:
public class Result { private int code; private String msg; private Object data; public static Result ok(Object data) { Result r = new Result(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static Result error(String msg) { Result r = new Result(); r.code = 500; r.msg = msg; return r; } // getter/setter 略 }这只是一个最简版,真实项目中建议加上数据校验、全局异常处理、请求日志等。
10. 关于登录校验后续可以怎么扩展
我前面一直在强调一个观点:登录校验不是孤立的一步,它是整个安全体系的大门。前面这套JWT + Filter/Interceptor的Demo只能算是骨架,实际项目里通常还需要在上面叠加很多东西。
比如内置一个@RequireRole("admin")注解,配合Interceptor里的HandlerMethod做细粒度的RBAC权限控制。比如加入登录失败次数的限制,防止暴力破解。比如接入Spring Security的SecurityContext,把JWT解析出来的用户信息无缝对接到Spring Security的权限体系中。再比如引入Redis做Token刷新和注销管理。
我个人在实际项目中的体会是:能把Session和JWT的底层原理讲透,能把Filter和Interceptor的边界理清楚,就算不使用任何一个安全框架,你也能搭建出一个看得懂、能维护、能追踪问题的登录系统。框架永远是工具,真正值钱的是对工具底层机制的理解。
这套内容本身,就是Java高级工程师和初级工程师的一个分水岭。