当前位置: 首页 > news >正文

搞懂Spring Boot登录认证:从UUID到JWT,一次完整的架构推演

文章目录

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

对比项FilterInterceptor
所处层次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)去除
FilterServlet层拦截,手动response.getWriter(),异常无法被Spring全局捕获
InterceptorSpring MVC层拦截,可抛出异常交@RestControllerAdvice处理
统一异常处理@RestControllerAdvice+@ExceptionHandler定义统一JSON错误响应
包版本适配Spring Boot 3.x 用jakarta.servlet.*,2.x 用javax.servlet.*

最后的话:

我们从一段简单的登录接口出发,经历了UUID的临时方案,演化到JWT无状态认证,再通过Filter和Interceptor的对比实践,最终用全局异常处理收尾。现在你不但会写登录,更理解了背后分层与拦截器的设计思想。建议你把代码自己敲一遍,改一改白名单,尝试加入密码加密(BCrypt),这会是你成为后端熟手的重要一步。欢迎在评论区分享你的练习心得!

http://www.rkmt.cn/news/1451334.html

相关文章:

  • MATLAB四阶矩可靠度计算工具:含熵辅助、偏导数值求解与改进算法
  • 大语言模型(LLM,Large Language Model)是一类基于深度学习、参数量通常达数十亿至数万亿级别的神经网络模型
  • PDF补丁丁:重新定义PDF文档处理的免费开源解决方案
  • 别再为个人网站收款发愁了!实测三款免签支付平台(蓝鲸/V云/云免签)的保姆级避坑指南
  • 复杂调查设计与机器学习融合:SDRF算法解析与应用
  • 开发者必备:手把手教你用Tiny11 Builder定制纯净Win11开发环境镜像
  • 现在不整合AI与开发工具,半年后将丧失交付竞争力:2024Q2 DevOps Survey揭示的3个临界阈值与紧急应对清单
  • 别再手动同步数据了!用Maxwell 1.29.2实时捕获MySQL变更,5分钟搞定CDC入门
  • 告别拥堵!用Python+SUMO+TraCI手把手教你打造一个会‘自学’的智能交通体(附完整代码)
  • 粒球计算与骨架聚类技术在大数据中的应用
  • CW32量产效率翻倍秘籍:CW-Programmer自动编号与工程文件实战
  • 跨镜无缝轨迹续联高密度多目标透明化人防监测预警及AI预案
  • 避开CANoe以太网诊断的‘大坑’:TCP/IP Stack选错,你的数据可能就‘丢’了
  • QMT数据获取避坑指南:你的`get_market_data`和`get_local_data`用对了吗?
  • 在Tina5.0系统里,如何一步步验证RTL8188FU USB WiFi驱动是否正常工作?
  • 别再被坑了!Vue3 + Element Plus里el-tabs切换导致ECharts图表变形,这几种修复方案实测有效
  • 用手机APP验证MFRC522读写结果:NFC Writer工具在STM32项目调试中的妙用
  • ROS机器人开发避坑指南:搞不清map、odom、base_link坐标系?这篇帮你理清关系
  • HS2-HF补丁终极指南:3步解锁《Honey Select 2》完整游戏体验的最佳方案
  • ENVI处理GF2数据时,为什么你的融合结果总发黑?聊聊辐射定标与背景值那些坑
  • 从标准库到HAL库混用也没问题?手把手验证STM32F4 Bootloader与App的库兼容性
  • 从DirectX原理到实战:一次搞懂d3dx9_43.dll丢失的根源与终极修复方案
  • 【AI电商整合实战指南】:2024年最全7大落地场景+3套避坑清单,头部平台已验证
  • 开源PLM实战:我们如何用Odoo+3D CAD集成,把产品研发周期缩短了30%
  • 危机公关的蝴蝶效应防控策略
  • Ansaldo pcbb p319控制器模块
  • 【万字文档+源码】基于springBoot+vue水果蔬菜商城管理系统-项目分享学习
  • 洛雪音乐音源配置指南:3步构建你的免费音乐库
  • 2026年国内研发费用补贴申报服务机构TOP5排行:成都高企代办机构、政府补贴申请流程、政府资金申报代办、政策申报代理服务选择指南 - 优质品牌商家
  • 从仿真波形反推设计:用Modelsim/Vivado深入理解奇数与偶数分频的时序逻辑