文章目录
- 从UUID到JWT再到Filter/Interceptor:Spring Boot登录认证进阶之路
- 1. 基础登录:模拟数据 + UUID令牌
- 1.1 项目结构
- 1.2 请求DTO
- 1.3 Service——模拟用户与令牌管理
- 1.4 Controller
- 1.5 测试
- 2. 从UUID到JWT:让令牌自带“身份证”
- 2.1 有状态 vs 无状态对比
- 2.2 添加JWT依赖
- 2.3 编写JwtUtil工具类
- 2.4 精简Service
- 3. 踩坑:Bearer前缀与测试那些事
- 3.1 另一个坑:JWT立即过期
- 4. 过滤器Filter:第一道防线
- 4.1 Filter的作用
- 4.2 创建LoginCheckFilter(Spring Boot 3.x 版本)
- 4.3 Filter的尴尬:异常无法被Spring全局捕获
- 5. Interceptor登场:纳入Spring的异常体系
- 5.1 Filter vs Interceptor
- 5.2 自定义未授权异常
- 5.3 编写LoginCheckInterceptor
- 5.4 配置拦截器白名单
- 6. 统一异常处理:@RestControllerAdvice
- 7. 总结:一张清单回顾所有要点
- 最后的话:
从UUID到JWT再到Filter/Interceptor:Spring Boot登录认证进阶之路
这篇文章要带你从零实现一个Spring Boot登录接口,并一步步将它从“临时UUID令牌”演变成无状态的JWT,再通过Filter → Interceptor → 统一异常处理,最终得到一个规范、可维护的认证架构。我们不依赖前端,只使用IDEA内置的HTTP Client做所有测试。所有代码都会给出,你可以复制即用。
1. 基础登录:模拟数据 + UUID令牌
我们先从最简单的入手:接收用户名密码,验证后返回一个临时令牌。所有用户数据先用HashMap硬编码在内存里,令牌就用UUID随机生成。
1.1 项目结构
src/main/java/com/example/demo ├── DemoApplication.java // 启动类 ├── config │ └── WebConfig.java // 配置拦截器、跨域等 ├── controller │ └── UserController.java // 登录、用户接口 ├── dto │ └── LoginRequest.java // 登录请求体 ├── exception │ ├── GlobalExceptionHandler.java // 全局异常处理 │ └── UnauthorizedException.java // 自定义未授权异常 ├── filter │ └── LoginCheckFilter.java // 登录校验过滤器(可选) ├── interceptor │ └── LoginCheckInterceptor.java // 登录校验拦截器 ├── service │ └── UserService.java // 用户服务(验证逻辑) └── util └── JwtUtil.java // JWT 工具类1.2 请求DTO
// LoginRequest.javapublicclassLoginRequest{privateStringusername;privateStringpassword;// 必须有无参构造,Spring才能把JSON转成对象publicLoginRequest(){}// getter/setter 略}注意:如果只有全参构造而没有无参构造,Spring反序列化时会直接报400,这是一个新手非常容易踩的坑。
1.3 Service——模拟用户与令牌管理
@ServicepublicclassUserService{// 模拟数据库中的用户privatestaticfinalMap<String,String>MOCK_USERS=newHashMap<>();static{MOCK_USERS.put("admin","123456");MOCK_USERS.put("user","password");}// 临时存储已登录的令牌(有状态方案)privatestaticfinalSet<String>TOKEN_STORE=ConcurrentHashMap.newKeySet();publicStringlogin(LoginRequestrequest){Stringpwd=MOCK_USERS.get(request.getUsername());if(pwd!=null&&pwd.equals(request.getPassword())){Stringtoken=UUID.randomUUID().toString();TOKEN_STORE.add(token);// 记住这个令牌returntoken;}returnnull;}publicbooleanisValidToken(Stringtoken){returntoken!=null&&TOKEN_STORE.contains(token);}}1.4 Controller
@RestControllerpublicclassUserController{@AutowiredprivateUserServiceuserService;@PostMapping("/api/login")publicResponseEntity<?>login(@RequestBodyLoginRequestrequest){Stringtoken=userService.login(request);if(token!=null){returnResponseEntity.ok(Map.of("token",token));}else{returnResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("msg","用户名或密码错误"));}}}1.5 测试
POST http://localhost:8080/api/login Content-Type: application/json { "username": "admin", "password": "123456" }成功返回200和一个随机的UUID。虽然跑通了,但这个方案有两大问题:
- 令牌随机,不携带任何用户信息,服务端必须维护一个
TOKEN_STORE才知道谁是谁。 - 有状态:一旦重启应用,所有登录状态全丢,扩展多实例时还需要共享存储。
2. 从UUID到JWT:让令牌自带“身份证”
我们希望令牌自己能“说话”,携带用户名和有效期,服务端不用再记——这就是无状态的JWT(Json Web Token)。
2.1 有状态 vs 无状态对比
| 方案 | 状态 | 存储位置 | 优点 | 缺点 |
|---|---|---|---|---|
| UUID令牌 | 有状态 | 服务器内存/Redis | 实现简单 | 扩展性差,内存占用 |
| JWT | 无状态 | 客户端本地 | 服务端无需存储,自带用户信息,防篡改 | 无法主动注销(需配合黑名单),payload仅Base64不加密 |
2.2 添加JWT依赖
pom.xml中加入:
<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>2.3 编写JwtUtil工具类
publicclassJwtUtil{privatestaticfinalKeyKEY=Keys.secretKeyFor(SignatureAlgorithm.HS256);// 随机密钥privatestaticfinallongEXPIRATION_MS=3600_000;// 1小时publicstaticStringgenerateToken(Stringusername){Datenow=newDate();Dateexpiration=newDate(now.getTime()+EXPIRATION_MS);returnJwts.builder().setSubject(username)// 主题放用户名.setIssuedAt(now).setExpiration(expiration).signWith(KEY).compact();}publicstaticClaimsparseToken(Stringtoken){returnJwts.parserBuilder().setSigningKey(KEY).build().parseClaimsJws(token).getBody();}}2.4 精简Service
@ServicepublicclassUserService{privatestaticfinalMap<String,String>MOCK_USERS=newHashMap<>();static{MOCK_USERS.put("admin","123456");MOCK_USERS.put("user","password");}// 不再需要 TOKEN_STORE !publicStringlogin(LoginRequestrequest){Stringpwd=MOCK_USERS.get(request.getUsername());if(pwd!=null&&pwd.equals(request.getPassword())){returnJwtUtil.generateToken(request.getUsername());}returnnull;}publicbooleanisValidJwt(Stringtoken){try{JwtUtil.parseToken(token);returntrue;}catch(Exceptione){returnfalse;}}}Controller也相应调整:/api/info接口从请求头提取JWT并解析,获取用户名。此时我们会遇到一个重要的HTTP细节:Bearer前缀。
3. 踩坑:Bearer前缀与测试那些事
我们测试/api/info时,要求请求头写:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...如果你只写了Authorization: 你的token,服务器会认为格式错误,返回401。Bearer是一种认证方案标识,告诉服务器“后面跟的是持有者令牌”。解析时我们用substring(7)跳过了“Bearer ”这7个字符。
3.1 另一个坑:JWT立即过期
测试时我们故意把EXPIRATION_MS改成了10秒,想验证过期效果,结果发现怎么快都提示过期。排查后发现是过早复制了错误单位(比如写了1毫秒)。后来改成10_000就正常了。过期时间的单位必须是毫秒。
4. 过滤器Filter:第一道防线
现在我们想统一校验所有需要登录的请求,而不是在每个Controller里重复写解析代码。首先想到的就是Servlet Filter。
4.1 Filter的作用
Filter运行在Servlet容器层,在请求进入Spring MVC的DispatcherServlet之前执行,可以拦截任何资源。
4.2 创建LoginCheckFilter(Spring Boot 3.x 版本)
注意:Spring Boot 3.x 使用jakarta.servlet.*,2.x 是javax.servlet.*,下面的代码基于3.x。
@ComponentpublicclassLoginCheckFilterimplementsFilter{@OverridepublicvoiddoFilter(ServletRequestreq,ServletResponseres,FilterChainchain)throwsIOException,ServletException{HttpServletRequestrequest=(HttpServletRequest)req;HttpServletResponseresponse=(HttpServletResponse)res;Stringurl=request.getRequestURL().toString();if(url.endsWith("/api/login")){chain.doFilter(req,res);// 登录接口直接放行return;}StringauthHeader=request.getHeader("Authorization");if(!StringUtils.hasText(authHeader)||!authHeader.startsWith("Bearer ")){response.setContentType("application/json;charset=UTF-8");response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}");return;}Stringtoken=authHeader.substring(7);try{Claimsclaims=JwtUtil.parseToken(token);request.setAttribute("username",claims.getSubject());chain.doFilter(req,res);// 校验通过放行}catch(Exceptione){response.setContentType("application/json;charset=UTF-8");response.getWriter().write("{\"code\":401,\"msg\":\"Token无效\"}");}}}这样Controller里的校验代码就可以删掉了,直接从request.getAttribute("username")取用户信息。
4.3 Filter的尴尬:异常无法被Spring全局捕获
Filter中一旦校验失败,我们只能手动拼接JSON并用response.getWriter()写回。这样不但繁琐,而且抛出的异常不会被Spring的@RestControllerAdvice捕获,因为Filter在Spring MVC的外层。这就引出了更优雅的方案:拦截器(Interceptor)。
5. Interceptor登场:纳入Spring的异常体系
Interceptor是Spring MVC提供的拦截器,它位于DispatcherServlet之后、Controller之前,所以其抛出的异常可以被Spring的全局异常处理器捕获。
5.1 Filter vs Interceptor
| 对比项 | Filter | Interceptor |
|---|---|---|
| 所处层次 | Servlet容器 | Spring MVC |
| 能否被Spring异常处理 | ❌ | ✅ |
| 适用场景 | 编码过滤、安全过滤 | 登录校验、日志、权限 |
5.2 自定义未授权异常
publicclassUnauthorizedExceptionextendsRuntimeException{privateintcode=401;publicUnauthorizedException(Stringmsg){super(msg);}// getter}5.3 编写LoginCheckInterceptor
@ComponentpublicclassLoginCheckInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){Stringurl=request.getRequestURL().toString();if(url.endsWith("/api/login")){returntrue;// 放行}StringauthHeader=request.getHeader("Authorization");if(!StringUtils.hasText(authHeader)||!authHeader.startsWith("Bearer ")){thrownewUnauthorizedException("未登录或Token格式错误");}Stringtoken=authHeader.substring(7);try{Claimsclaims=JwtUtil.parseToken(token);request.setAttribute("username",claims.getSubject());}catch(Exceptione){thrownewUnauthorizedException("Token无效或已过期");}returntrue;// 放行}}5.4 配置拦截器白名单
@ConfigurationpublicclassWebConfigimplementsWebMvcConfigurer{@AutowiredprivateLoginCheckInterceptorloginCheckInterceptor;@OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**").excludePathPatterns("/api/login");// 登录接口不拦截}}这样我们将LoginCheckFilter注释掉,完全由拦截器接管JWT校验,并且校验失败时抛出的UnauthorizedException会被接下来要写的全局异常处理器兜底。
6. 统一异常处理:@RestControllerAdvice
有了自定义异常,我们就可以集中管理所有错误响应,确保前端收到统一的JSON结构。
@RestControllerAdvicepublicclassGlobalExceptionHandler{@ExceptionHandler(UnauthorizedException.class)publicResponseEntity<Map<String,Object>>handleUnauthorized(UnauthorizedExceptione){Map<String,Object>result=newHashMap<>();result.put("code",e.getCode());result.put("msg",e.getMessage());returnnewResponseEntity<>(result,HttpStatus.UNAUTHORIZED);}@ExceptionHandler(Exception.class)publicResponseEntity<Map<String,Object>>handleOther(Exceptione){Map<String,Object>result=newHashMap<>();result.put("code",500);result.put("msg","服务器内部错误:"+e.getMessage());returnnewResponseEntity<>(result,HttpStatus.INTERNAL_SERVER_ERROR);}}现在再访问不带token的/api/info,你会看到响应状态码是401,而JSON内容也规范了。我们不再需要手动拼接JSON字符串,Interceptor只需抛出异常,一切交给全局处理器。
7. 总结:一张清单回顾所有要点
| 主题 | 关键点 |
|---|---|
| 基础登录 | 接收@RequestBody,用HashMap模拟用户,返回UUID令牌 |
| JWT无状态令牌 | jjwt依赖,生成/解析JWT,setSubject(username)存储用户标识 |
Bearer前缀 | HTTP认证方案标识,提取时需substring(7)去除 |
| Filter | Servlet层拦截,手动response.getWriter(),异常无法被Spring全局捕获 |
| Interceptor | Spring MVC层拦截,可抛出异常交@RestControllerAdvice处理 |
| 统一异常处理 | @RestControllerAdvice+@ExceptionHandler定义统一JSON错误响应 |
| 包版本适配 | Spring Boot 3.x 用jakarta.servlet.*,2.x 用javax.servlet.* |
最后的话:
我们从一段简单的登录接口出发,经历了UUID的临时方案,演化到JWT无状态认证,再通过Filter和Interceptor的对比实践,最终用全局异常处理收尾。现在你不但会写登录,更理解了背后分层与拦截器的设计思想。建议你把代码自己敲一遍,改一改白名单,尝试加入密码加密(BCrypt),这会是你成为后端熟手的重要一步。欢迎在评论区分享你的练习心得!