Spring Boot + JWT 实现无状态认证
1. JWT
JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络应用环境间安全地将信息作为 JSON 对象传输。JWT 是目前最流行的跨域认证解决方案,特别适合前后端分离的架构。
1.1 JWT 的结构
JWT 由三部分组成,用点号(.)分隔:
xxxxx.yyyyy.zzzzz │ │ │ │ │ └── Signature(签名) │ └───────── Payload(负载) └──────────────── Header(头部)Header:包含令牌类型和签名算法
{"alg":"HS256","typ":"JWT"}Payload:包含声明(claims),如用户信息、过期时间等
{"username":"zhangsan","iat":1516239022,"exp":1516242622}Signature:使用密钥对前两部分进行签名,确保令牌不被篡改
1.2 JWT 的优势
| 优势 | 说明 |
|---|---|
| 无状态 | 服务器不需要保存会话信息,令牌自包含用户信息 |
| 跨域支持 | 天然支持 CORS,适合前后端分离架构 |
| 扩展性强 | 令牌中可以携带自定义信息 |
| 安全性高 | 使用签名防止篡改,支持过期时间 |
2. 项目架构
┌─────────────────────────────────────────────────────────────┐ │ 客户端 (Vue 3) │ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │ │ │ 登录请求 │ │ 存储 Token │ │ 请求携带 Token │ │ │ └──────────────┘ └──────────────┘ └────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ │ HTTP/REST ▼ ┌─────────────────────────────────────────────────────────────┐ │ 服务端 (Spring Boot) │ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │ │ │ 生成 Token │ │ 验证 Token │ │ 授权访问资源 │ │ │ │ (JwtUtil) │ │ (JwtFilter) │ │ (SecurityConfig) │ │ │ └──────────────┘ └──────────────┘ └────────────────────┘ │ └─────────────────────────────────────────────────────────────┘3. 核心组件实现
3.1 JwtUtil - JWT 工具类
负责生成、解析和验证 JWT 令牌。
@ComponentpublicclassJwtUtil{// 使用 HS256 算法生成密钥privatefinalKeykey=Keys.secretKeyFor(SignatureAlgorithm.HS256);@AutowiredprivateAppConfigappConfig;/** * 生成 JWT 令牌 * @param username 用户名 * @return JWT 令牌字符串 */publicStringgenerateToken(Stringusername){Map<String,Object>claims=newHashMap<>();claims.put("username",username);returnJwts.builder().setClaims(claims)// 设置自定义声明.setSubject(username)// 设置主题(用户名).setIssuedAt(newDate())// 设置签发时间.setExpiration(newDate(System.currentTimeMillis()+getExpirationTime()))// 设置过期时间.signWith(key)// 使用密钥签名.compact();// 生成令牌}/** * 从令牌中提取用户名 * @param token JWT 令牌 * @return 用户名 */publicStringextractUsername(Stringtoken){Claimsclaims=Jwts.parserBuilder().setSigningKey(key)// 设置验证密钥.build().parseClaimsJws(token)// 解析令牌.getBody();// 获取声明体returnclaims.getSubject();}/** * 验证令牌是否有效 * @param token JWT 令牌 * @return 是否有效 */publicbooleanvalidateToken(Stringtoken){try{Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);returntrue;// 解析成功,令牌有效}catch(Exceptione){returnfalse;// 解析失败,令牌无效或过期}}}关键点说明:
- 使用
Keys.secretKeyFor(SignatureAlgorithm.HS256)生成安全的密钥 setExpiration()设置令牌过期时间,增强安全性validateToken()捕获所有异常,确保令牌验证的健壮性
3.2 JwtFilter - JWT 过滤器
拦截请求,验证 JWT 令牌的有效性。
@ComponentpublicclassJwtFilterextendsOncePerRequestFilter{privatestaticfinalLoggerlogger=LoggerFactory.getLogger(JwtFilter.class);@AutowiredprivateJwtUtiljwtUtil;@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{// 从请求头中获取令牌StringauthorizationHeader=request.getHeader("Authorization");Stringtoken=null;Stringusername=null;// 检查授权头,提取 Bearer Tokenif(authorizationHeader!=null&&authorizationHeader.startsWith("Bearer ")){token=authorizationHeader.substring(7);// 去掉 "Bearer " 前缀try{username=jwtUtil.extractUsername(token);}catch(Exceptione){logger.error("Error extracting username from token: {}",e.getMessage());}}// 验证令牌并设置认证上下文if(username!=null&&SecurityContextHolder.getContext().getAuthentication()==null){if(jwtUtil.validateToken(token)){// 创建认证令牌UsernamePasswordAuthenticationTokenauthToken=newUsernamePasswordAuthenticationToken(username,null,null);authToken.setDetails(newWebAuthenticationDetailsSource().buildDetails(request));// 设置安全上下文SecurityContextHolder.getContext().setAuthentication(authToken);}}chain.doFilter(request,response);}}关键点说明:
- 继承
OncePerRequestFilter确保每个请求只过滤一次 - 从
Authorization头中提取Bearer Token - 使用
SecurityContextHolder设置认证信息,供后续业务使用
3.3 SecurityConfig - 安全配置
配置 Spring Security,定义安全规则和认证方式。
@Configuration@EnableWebSecuritypublicclassSecurityConfig{@AutowiredprivateJwtFilterjwtFilter;@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurityhttp)throwsException{http// 配置 CORS.cors(cors->cors.configurationSource(corsConfigurationSource()))// 禁用 CSRF(前后端分离不需要).csrf(csrf->csrf.disable())// 配置请求授权.authorizeHttpRequests(authorize->authorize// 允许匿名访问的路径.requestMatchers("/api/v1/users/register","/api/v1/users/login","/api/v1/users/security-question","/api/v1/users/reset-password","/api/v1/captcha/**").permitAll()// AI 相关接口允许匿名访问.requestMatchers("/api/v1/ai-analysis/**","/api/v1/ai-assistant/**").permitAll()// Swagger 文档允许访问.requestMatchers("/swagger-ui.html","/swagger-ui/**","/v3/api-docs/**").permitAll()// 其他请求需要认证.anyRequest().authenticated())// 添加 JWT 过滤器.addFilterBefore(jwtFilter,UsernamePasswordAuthenticationFilter.class);returnhttp.build();}@BeanpublicCorsConfigurationSourcecorsConfigurationSource(){CorsConfigurationconfiguration=newCorsConfiguration();configuration.setAllowedOrigins(Arrays.asList("http://localhost:5173"));configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTIONS"));configuration.setAllowedHeaders(Arrays.asList("Content-Type","Authorization"));configuration.setAllowCredentials(true);UrlBasedCorsConfigurationSourcesource=newUrlBasedCorsConfigurationSource();source.registerCorsConfiguration("/**",configuration);returnsource;}}关键点说明:
- 禁用 CSRF 保护(前后端分离架构不需要)
- 配置白名单路径,允许匿名访问登录、注册等接口
- 使用
addFilterBefore()将 JWT 过滤器添加到 Spring Security 过滤器链中
4. 认证流程
4.1 登录流程
1. 用户发送登录请求 POST /api/v1/users/login { "username": "zhangsan", "password": "password123", "captcha": "a3f8" } 2. UserController 接收请求,验证用户名密码 3. 验证成功,调用 JwtUtil.generateToken(username) 生成 JWT 4. 返回响应,包含 JWT 令牌 { "success": true, "data": { "token": "eyJhbGciOiJIUzI1NiIs...", "username": "zhangsan" } } 5. 前端存储 Token(localStorage 或 sessionStorage)4.2 请求认证流程
1. 用户访问受保护资源,请求头携带 Token Authorization: Bearer eyJhbGciOiJIUzI1NiIs... 2. JwtFilter 拦截请求,从 Header 提取 Token 3. 验证 Token 有效性(签名、过期时间) 4. 验证通过,设置 SecurityContext 认证信息 5. 请求到达 Controller,执行业务逻辑 6. 返回业务数据5. 代码示例
5.1 登录接口实现
@RestController@RequestMapping("/api/v1/users")publicclassUserController{@AutowiredprivateUserServiceuserService;@AutowiredprivateJwtUtiljwtUtil;privateBCryptPasswordEncoderpasswordEncoder=newBCryptPasswordEncoder();@PostMapping("/login")publicUserResponselogin(@Valid@RequestBodyUserLoginRequestrequest,HttpServletRequesthttpRequest){// 1. 验证验证码Stringcaptcha=request.getCaptcha();if(!validateCaptcha(captcha,httpRequest.getSession())){thrownewRuntimeException("验证码错误");}// 2. 验证用户名密码Useruser=userService.login(request.getUsername(),request.getPassword());// 3. 生成 JWT TokenStringtoken=jwtUtil.generateToken(user.getUsername());// 4. 构建响应UserResponseresponse=newUserResponse();response.setToken(token);response.setUsername(user.getUsername());response.setEmail(user.getEmail());// ... 其他用户信息returnresponse;}}5.2 受保护接口示例
@RestController@RequestMapping("/api/v1/food-records")publicclassFoodRecordController{@GetMappingpublicList<FoodRecord>getRecords(@RequestParamStringusername){// 由于 JwtFilter 已经验证过 Token// 这里可以直接获取当前认证用户Authenticationauth=SecurityContextHolder.getContext().getAuthentication();StringcurrentUser=auth.getName();// 验证只能查询自己的记录if(!currentUser.equals(username)){thrownewRuntimeException("无权访问其他用户的数据");}returnfoodRecordService.findByUsername(username);}}6. 前端集成
6.1 登录后存储 Token
// 登录成功,保存 Tokenconstlogin=async(credentials)=>{constresponse=awaitapi.post('/api/v1/users/login',credentials);const{token,username}=response.data;// 存储到 localStoragelocalStorage.setItem('token',token);localStorage.setItem('username',username);returnresponse.data;};6.2 请求拦截器添加 Token
// Axios 请求拦截器api.interceptors.request.use(config=>{consttoken=localStorage.getItem('token');if(token){config.headers.Authorization=`Bearer${token}`;}returnconfig;},error=>Promise.reject(error));6.3 响应拦截器处理 401
// Axios 响应拦截器api.interceptors.response.use(response=>response.data,error=>{if(error.response?.status===401){// Token 过期或无效,清除存储并跳转登录页localStorage.removeItem('token');window.location.href='/';}returnPromise.reject(error);});7. 安全注意事项
7.1 密钥管理
- 生产环境:密钥应存储在环境变量或配置中心,不要硬编码
- 密钥轮换:定期更换密钥,旧令牌会自然失效
- 密钥强度:使用足够强度的密钥(HS256 自动生成)
7.2 令牌安全
| 风险 | 防护措施 |
|---|---|
| XSS 攻击 | 使用 httpOnly Cookie 存储令牌,或做好 XSS 防护 |
| CSRF 攻击 | 前后端分离天然免疫 CSRF,但仍需验证 Origin |
| 令牌泄露 | 设置合理的过期时间(通常 1-24 小时) |
| 重放攻击 | 结合时间戳和随机数,或使用短期令牌 |
7.3 传输安全
- 生产环境必须使用 HTTPS
- 敏感接口增加额外的验证(如密码修改需要旧密码)
