用户认证与 JWT 实现
学习目标理解 JWTJSON Web Token的工作原理和优势掌握 JWT 的生成、验证和解析方法实现 BCrypt 密码加密和校验使用 ThreadLocal 实现在任意位置获取当前登录用户实现自定义认证拦截器和角色权限注解完成用户登录、注册和获取用户信息接口一、JWT 原理什么是 JWTJWTJSON Web Token是一种无状态的认证方案。传统的 Session 认证中用户信息存储在服务器内存或 Redis 中每次请求服务器都要查询用户状态。而 JWT 把用户信息编码在 Token 本身服务器只需要验证签名即可不需要存储任何会话信息。JWT 的结构一个 JWT Token 由三部分组成用点号连接抬头.内容.签名 eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.4fZ8zF6k...这三部分分别是Header头部声明 Token 的类型和签名算法{alg:HS256,typ:JWT}经过 Base64 编码后变成eyJhbGciOiJIUzI1NiJ9Payload载荷存放实际数据比如 userId、role、过期时间等{userId:1,role:ADMIN,exp:1700000000,iat:1699913600}注意Payload 虽然经过 Base64 编码但 Base64 是可逆的token 在客户端可见所以永远不要把密码这样的敏感信息放到 JWT 中。Signature签名对 Header 和 Payload 的签名使用服务端密钥计算HMACSHA256( base64UrlEncode(header) . base64UrlEncode(payload), secret )签名确保了 Token 的完整性——如果有人篡改 Header 或 Payload签名验证就会失败因为攻击者不知道服务端的密钥。为什么选 JWT 而不是 Session对比项SessionJWT存储位置服务器内存/Redis客户端浏览器 LocalStorage扩展性多服务器需要共享 Session天然支持分布式任何服务器都能验证服务器压力每个请求都要查 Session只需计算签名无需存储查询安全性依赖 Cookie易受 CSRF签名防篡改适合场景传统单体应用前后端分离、微服务、移动端二、项目中的认证体系架构用户请求 认证流程 ───────── ────────── ┌──────────────────┐ 1. POST /api/auth/login │ AuthController │ ← 校验用户名密码生成 JWT ↓ └──────────────────┘ 2. 返回 Token ↓ ↓ ┌──────────────────┐ 3. 前端保存 Token │ 前端 localStorage │ ← 每次请求携带 ↓ └──────────────────┘ 4. GET /api/students ↓ Authorization: ┌──────────────────┐ Bearer xxx │ JwtAuthInterceptor│ ← 拦截器验证 Token └──────────────────┘ ↓ ┌──────────────────┐ │ UserContext │ ← 存入 ThreadLocal └──────────────────┘ ↓ ┌──────────────────┐ │ Controller/Service│ ← 直接使用 UserContext └──────────────────┘三、JWT 工具类使用 JJWT 库Java JWT实现 Token 的生成、验证和解析ComponentpublicclassJwtUtil{Value(${jwt.secret})// 从配置文件读取签名密钥privateStringsecret;Value(${jwt.expiration})// 过期时间秒privateLongexpiration;/** * 生成 JWT Token * * param userId 用户 ID * param username 用户名 * param role 用户角色ADMIN/TEACHER/STUDENT * return JWT 字符串 */publicStringgenerateToken(LonguserId,Stringusername,Stringrole){DatenownewDate();returnJwts.builder().setSubject(username)// 主题通常存用户名.claim(userId,userId)// 自定义声明用户 ID.claim(role,role)// 自定义声明角色.setIssuedAt(now)// 签发时间.setExpiration(newDate(now.getTime()expiration*1000))// 过期时间.signWith(SignatureAlgorithm.HS256,secret.getBytes(StandardCharsets.UTF_8)).compact();}/** * 验证 Token 是否有效 * 返回 true Token 有效false Token 无效或已过期 */publicbooleanvalidateToken(Stringtoken){try{Jwts.parser().setSigningKey(secret.getBytes(StandardCharsets.UTF_8)).parseClaimsJws(token);returntrue;}catch(ExpiredJwtExceptione){returnfalse;// Token 过期}catch(JwtExceptione){returnfalse;// Token 无效签名不匹配等}}/** * 从 Token 中解析出 Claims包含所有声明信息 */publicClaimsgetClaimsFromToken(Stringtoken){returnJwts.parser().setSigningKey(secret.getBytes(StandardCharsets.UTF_8)).parseClaimsJws(token).getBody();}// 便捷方法直接获取各个字段publicLonggetUserIdFromToken(Stringtoken){returngetClaimsFromToken(token).get(userId,Long.class);}publicStringgetUsernameFromToken(Stringtoken){returngetClaimsFromToken(token).getSubject();}publicStringgetRoleFromToken(Stringtoken){returngetClaimsFromToken(token).get(role,String.class);}}JWT 配置application.ymljwt:secret:my-secret-key-change-in-production# 生产环境不要硬编码用环境变量expiration:86400# Token 有效期 86400 秒 24 小时关于密钥的安全性这里的secret硬编码在配置文件中仅用于教学演示。生产环境中应该通过环境变量JWT_SECRET注入或者使用密钥管理服务。密钥泄露意味着任何人都可以伪造 Token 登录你的系统。四、密码加密为什么不能用 MD5 加密密码因为 MD5 是可查表破解的。BCrypt 通过以下机制大幅提升安全性不可逆从密文无法反推明文加盐每次加密使用随机盐值相同的明文每次加密结果都不同可调慢可以通过调整强度参数让加密变慢默认 10 次迭代暴力破解成本剧增ComponentpublicclassPasswordEncoder{privatefinalBCryptPasswordEncoderencodernewBCryptPasswordEncoder();/** * 加密密码 * 每次加密结果都不同内部使用了随机盐值 * 例如$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy */publicStringencode(StringrawPassword){returnencoder.encode(rawPassword);}/** * 校验密码 * BCrypt 从密文中提取出盐值然后用它对明文加密比较结果 */publicbooleanmatches(StringrawPassword,StringencodedPassword){returnencoder.matches(rawPassword,encodedPassword);}}数据库中的密码示例原始密码admin123 加密后$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy ├───┬──┘ ├──────────────┬──────────────────────────────┘ 算法 强度 盐值22 字符随机 哈希值31 字符五、ThreadLocal 用户上下文为什么需要 UserContext在 Controller 中获取当前用户后如果想要在 Service 层使用通常有两种方式方法参数传递每个 Service 方法都加上LoginUser参数 —— 侵入性强代码耦合度高ThreadLocal推荐将当前用户存入线程局部变量任何地方都可以取 —— 零侵入实现publicclassUserContext{/** * ThreadLocal 是 Java 提供的线程局部变量 * 每个请求在 Tomcat 中由一个线程处理 * 所以在同一个请求的任意位置都能拿到 User */privatestaticfinalThreadLocalLoginUserthreadLocalnewThreadLocal();publicstaticvoidset(LoginUseruser){threadLocal.set(user);}publicstaticLoginUserget(){returnthreadLocal.get();}publicstaticvoidclear(){threadLocal.remove();// 必须清理否则 Tomcat 线程池复用会导致信息泄露}DataAllArgsConstructorpublicstaticclassLoginUser{privateLonguserId;privateStringusername;privateStringrole;}}为什么必须调用clear()Tomcat 使用线程池处理请求请求处理完后线程不会销毁而是归还给线程池。如果不清理 ThreadLocal下一个请求复用该线程时还能取到上一个用户的登录信息这是严重的安全漏洞。六、认证拦截器拦截器是 Spring MVC 提供的一种机制在请求到达 Controller 之前执行预处理逻辑。ComponentSlf4jpublicclassJwtAuthInterceptorimplementsHandlerInterceptor{AutowiredprivateJwtUtiljwtUtil;OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{// 如果不是映射到方法比如静态资源直接放行if(!(handlerinstanceofHandlerMethod)){returntrue;}HandlerMethodhandlerMethod(HandlerMethod)handler;// 1. 从请求头提取 Token格式Authorization: Bearer xxxStringauthHeaderrequest.getHeader(Authorization);if(authHeadernull||!authHeader.startsWith(Bearer )){thrownewUnauthorizedException(未登录请先登录);}StringtokenauthHeader.substring(7);// 去掉 Bearer 前缀// 2. 验证 Tokenif(!jwtUtil.validateToken(token)){thrownewUnauthorizedException(Token 已过期或无效请重新登录);}// 3. 解析用户信息存入 ThreadLocalLonguserIdjwtUtil.getUserIdFromToken(token);StringusernamejwtUtil.getUsernameFromToken(token);StringrolejwtUtil.getRoleFromToken(token);UserContext.set(newLoginUser(userId,username,role));// 4. 检查 RequireRole 权限注解RequireRolerequireRolehandlerMethod.getMethodAnnotation(RequireRole.class);if(requireRole!null){StringuserRoleUserContext.get().getRole();booleanhasRoleArrays.asList(requireRole.value()).contains(userRole);if(!hasRole){thrownewForbiddenException(权限不足需要角色Arrays.toString(requireRole.value()));}}returntrue;}OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{// 请求结束后清理 ThreadLocal防止内存泄漏UserContext.clear();}}注册拦截器ConfigurationpublicclassWebMvcConfigimplementsWebMvcConfigurer{AutowiredprivateJwtAuthInterceptorjwtAuthInterceptor;OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){registry.addInterceptor(jwtAuthInterceptor).addPathPatterns(/api/**)// 拦截所有 API 请求.excludePathPatterns(// 放行以下路径/api/auth/login,/api/auth/register,/doc.html,// Knife4j 文档/webjars/**,/swagger-resources/**,/v2/api-docs);}}拦截器执行流程HTTP 请求到达 ↓ WebMvcConfig 判断是否匹配拦截路径 ↓ 匹配 ↓ 不匹配 拦截器 preHandle 直接放行 ↓ 验证 Token → 失败 → 抛出 UnauthorizedException → 全局异常处理器返回 401 ↓ 成功 检查 RequireRole → 不满足 → 抛出 ForbiddenException → 返回 403 ↓ 满足 存入 UserContextThreadLocal ↓ Controller 方法执行 ↓ afterCompletion清理 UserContext七、角色权限注解使用自定义注解RequireRole实现声明式权限控制比在代码中硬编码 if-else 更优雅/** * 角色权限注解 * * 使用方式 * RequireRole(ADMIN) // 仅管理员 * RequireRole({ADMIN, TEACHER}) // 管理员或教师 * * 原理拦截器在请求处理前读取此注解判断当前用户角色是否匹配 */Target(ElementType.METHOD)// 只能标注在方法上Retention(RetentionPolicy.RUNTIME)// 运行时保留反射可读publicinterfaceRequireRole{String[]value();// 允许访问的角色列表}在 Controller 中使用GetMapping(/page)RequireRole({ADMIN,TEACHER})// 管理员和教师都可以查看列表publicResultIPageStudentVOpage(...){...}PostMappingRequireRole({ADMIN})// 只有管理员可以新增学生publicResultVoidadd(...){...}DeleteMapping(/{id})RequireRole({ADMIN})// 只有管理员可以删除publicResultVoiddelete(PathVariableLongid){...}八、认证控制器RestControllerRequestMapping(/api/auth)publicclassAuthController{PostMapping(/login)publicResultLoginResponselogin(ValidRequestBodyLoginRequestrequest){// 1. 校验用户名是否存在UseruseruserMapper.selectByUsername(request.getUsername());if(usernull){thrownewBusinessException(用户名或密码错误);// 故意模糊提示防止攻击者探测用户是否存在}// 2. 校验密码if(!passwordEncoder.matches(request.getPassword(),user.getPassword())){thrownewBusinessException(用户名或密码错误);}// 3. 检查用户状态if(user.getStatus()0){thrownewBusinessException(账号已被禁用请联系管理员);}// 4. 生成 JWT TokenStringtokenjwtUtil.generateToken(user.getId(),user.getUsername(),user.getRole());// 5. 返回登录结果LoginResponserespnewLoginResponse(token,user.getId(),user.getUsername(),user.getRole(),user.getRealName());returnResult.success(resp);}PostMapping(/register)publicResultVoidregister(ValidRequestBodyRegisterRequestrequest){// 检查用户名是否已被注册if(userMapper.selectByUsername(request.getUsername())!null){thrownewBusinessException(用户名已存在);}// 密码加密后保存userService.createUser(request);returnResult.success();}GetMapping(/info)publicResultLoginUserinfo(){// 从 UserContext 获取当前登录用户信息LoginUserloginUserUserContext.get();returnResult.success(loginUser);}}九、Postman 测试流程注册账号POST /api/auth/register{username:student1,password:123456,realName:测试学生,role:STUDENT}登录获取 TokenPOST /api/auth/login{username:admin,password:admin123}返回结果中会包含token字段复制它。访问受保护接口GET /api/students/page在 Postman 的 Headers 中添加Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...应返回学生列表数据测试无 Token 访问直接请求GET /api/students/page不加 Authorization 头→ 返回{ code: 401, message: 未登录请先登录 }测试角色不匹配使用 STUDENT 角色的 Token 访问需要 ADMIN 权限的接口→ 返回{ code: 403, message: 权限不足 }常见问题1. Token 验证失败io.jsonwebtoken.SignatureException: JWT signature does not match检查jwt.secret配置是否正确生成和验证是否使用了相同的密钥。如果修改了密钥已签发的 Token 会全部失效。2. javax.xml.bind 不存在JDK 9java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter原因JDK 9 移除了 JAXB 模块。JJWT 0.9.x 依赖它。解决方案在 pom.xml 中添加 jaxb-api 依赖或升级到 JJWT 0.12使用新的 API。3. 拦截器不生效检查WebMvcConfig类是否有Configuration注解addInterceptor中addPathPatterns的路径是否匹配到了 API 请求。4. 请求结束后仍在 ThreadLocal 中拿到用户信息原因afterCompletion中没有调用UserContext.clear()。Tomcat 线程池复用线程导致信息泄露。小结本篇文章我们实现了完整的用户认证和权限控制体系JWT 原理无状态认证三部分结构Header.Payload.Signature签名防篡改JwtUtil生成 Token含 userId、role、验证签名、解析 ClaimsBCrypt 密码加密不可逆 随机盐值比 MD5 安全得多UserContextThreadLocal当前线程共享用户信息业务代码零侵入JwtAuthInterceptor拦截所有 API 请求验证 Token检查权限注解RequireRole 注解声明式权限控制比硬编码 if-else 更优雅认证控制器登录、注册、获取用户信息这套认证体系的特点是代码量少不到 200 行、每一步都可见。下一篇文章我们将搭建前端页面把登录界面和后台管理界面做出来。下篇预告《Vue 3 Element Plus 搭建管理界面》