CSDN多平台一键发布功能开通链接https://mp.csdn.net/vip?utm_sourceweitingfu“OAuth2就像去健身房——你先办会员卡授权然后每次去刷会员卡Access Token不用每次都带身份证。JWT就像会员卡上印了你的信息健身房一看就知道你是谁。”想象一下如果你每次去健身房都要出示身份证、户口本、工作证明还要填表申请估计你早就放弃健身了。现代API认证也是这个道理——OAuth2和JWT就是为了让进门这件事变得优雅而安全。今天这篇文章我们就来聊聊这套现代API的通行证系统。不管你是刚入门的小白还是想深入原理的老司机这篇文章都能让你有所收获。一、OAuth2授权流程详解四种进门方式OAuth2定义了四种授权模式就像进健身房可以有四种不同的办卡方式授权模式适用场景类比健身房授权码模式 (Authorization Code)Web应用、移动应用前台办理正式会员卡简化模式 (Implicit)纯前端应用已废弃临时体验卡不推荐密码模式 (Password)第一方应用直接报身份证号办卡客户端模式 (Client Credentials)服务间调用企业团购卡1.1 授权码模式——最安全的前台办卡健身房类比你去前台说我要办卡前台给你一张申请表Authorization Code。 你拿着申请表到后台办公室工作人员验证后给你正式的会员卡Access Token。 关键点申请表是一次性的而且只有你能拿到真正的会员卡。授权码模式是OAuth2中最安全、最常用的模式。它的核心思想是不要把敏感凭证暴露给客户端。┌─────────────┐ ┌─────────────┐ │ 用户浏览器 │ │ 授权服务器 │ │ (User Agent)│ │ (Gym Front) │ └──────┬──────┘ └──────┬──────┘ │ │ │ 1. 申请授权 (带client_id, redirect_uri, scope) │ │─────────────────────────────────────────────────▶│ │ │ │ 2. 用户登录并同意授权 │ │◀─────────────────────────────────────────────────│ │ │ │ 3. 返回授权码 (Authorization Code) │ │─────────────────────────────────────────────────▶│ │ │ └──────────────────────────────────────────────────┘ │ │ 4. 用授权码换取Token │ (后台服务器发起带client_secret) ▼ ┌─────────────┐ │ 应用服务器 │ │(Backend App)│ └──────┬──────┘ │ │ 5. 返回 Access Token Refresh Token ▼1.2 简化模式——“临时体验卡”已废弃⚠️重要提示简化模式在OAuth 2.1中已被废弃因为它把Access Token直接暴露在URL中存在安全隐患。如果你的项目还在用建议尽快迁移到授权码模式PKCE。1.3 密码模式——“直接报身份证号”健身房类比你直接告诉健身房前台你的身份证号前台验证后直接给你会员卡。 这种模式只适用于你完全信任的应用比如官方App因为你要把密码交给它。POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typepassword usernameuserexample.com passwordyour_password client_idyour_client_id client_secretyour_client_secret1.4 客户端模式——“企业团购卡”当两个服务之间需要通信时不需要用户参与直接用自己的企业资质获取Token。这就像公司统一办的健身卡不需要每个员工单独申请。POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typeclient_credentials client_idservice_a client_secretservice_a_secret scoperead write二、授权码模式完整流程庖丁解牛让我们深入授权码模式的每一个细节看看这套前台办卡系统是如何工作的。2.1 第一步申请授权用户点击使用微信登录按钮时应用会重定向到授权服务器https://authorization-server.com/oauth/authorize? response_typecode client_idYOUR_CLIENT_ID redirect_urihttps://your-app.com/callback scopeopenid profile email statexyz123参数详解response_typecode告诉服务器我要授权码client_id你的应用ID就像健身房的合作商户编号redirect_uri授权成功后跳回的地址scope申请的权限范围state防CSRF攻击的随机字符串2.2 第二步用户授权授权服务器展示登录页面用户输入凭据并同意授权。这一步在授权服务器端完成你的应用永远接触不到用户的密码。2.3 第三步获取授权码用户同意后授权服务器重定向回你的应用带上授权码https://your-app.com/callback? codeAUTH_CODE_HERE statexyz1232.4 第四步换取Token这是最关键的一步——必须由服务器端完成因为需要用到client_secretPOST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typeauthorization_code codeAUTH_CODE_HERE redirect_urihttps://your-app.com/callback client_idYOUR_CLIENT_ID client_secretYOUR_CLIENT_SECRET授权服务器验证通过后返回Token{ access_token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..., token_type: Bearer, expires_in: 3600, refresh_token: tGzv3JOkF0XG5Qx2TlKWIA, scope: openid profile email }三、PKCE扩展给授权码加把锁PKCEProof Key for Code Exchange发音同pixy是OAuth2的一个扩展专门为移动应用和SPA设计的安全增强。健身房类比想象有人偷看了你的申请表Authorization Code想冒领你的会员卡。 PKCE就像在申请表上加了一个动态密码——即使别人偷看了申请表没有动态密码也领不了卡。3.1 PKCE工作原理┌─────────────────────────────────────────────────────────────────┐ │ PKCE 流程 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 客户端 授权服务器 资源服务器 │ │ │ │ │ │ │ │ 1. 生成 code_verifier │ │ │ │ │ (随机字符串存好!) │ │ │ │ │ │ │ │ │ │ 2. 计算 code_challenge │ │ │ │ │ BASE64URL(SHA256(code_verifier)) │ │ │ │ │ │ │ │ │ 3. /authorize? │ │ │ │ │ code_challengexxx │ │ │ │ │ code_challenge_methodS256 │ │ │ │─────────────────────────▶│ │ │ │ │ │ │ │ │ │ 4. 返回 code │ │ │ │ │◀─────────────────────────│ │ │ │ │ │ │ │ │ │ 5. /token │ │ │ │ │ codexxx │ │ │ │ │ code_verifier原始值 │ │ │ │ │─────────────────────────▶│ │ │ │ │ │ 6. 验证: │ │ │ │ │ SHA256(verifier) │ │ │ │ │ challenge? │ │ │ │ │ │ │ │ │ 7. 返回 access_token │ │ │ │ │◀─────────────────────────│ │ │ │ │ │ │ │ │ │ 8. 用 access_token 访问资源 │ │ │ │──────────────────────────────────────────────────▶│ │ │ │ └─────────────────────────────────────────────────────────────────┘3.2 为什么需要PKCE在移动应用中应用无法安全地存储client_secret可能被反编译获取。PKCE通过动态密码机制即使授权码被截获攻击者也无法换取Token。// Java 生成 PKCE 参数示例 import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; public class PKCEGenerator { // 生成 code_verifier (43-128字符的随机字符串) public static String generateCodeVerifier() { SecureRandom secureRandom new SecureRandom(); byte[] codeVerifier new byte[32]; secureRandom.nextBytes(codeVerifier); return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier); } // 计算 code_challenge public static String generateCodeChallenge(String codeVerifier) throws Exception { MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] hash digest.digest(codeVerifier.getBytes()); return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } }四、JWT结构与签名验证解密会员卡JWTJSON Web Token就是那张会员卡它里面印着你的信息而且很难伪造。4.1 JWT的结构Header.Payload.Signature┌────────────────────────────────────────────────────────────────────┐ │ JWT 结构解析 │ ├────────────────────────────────────────────────────────────────────┤ │ │ │ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. │ │ │ Header (Base64Url编码) │ │ │ { │ │ │ alg: HS256, ← 签名算法 │ │ │ typ: JWT ← Token类型 │ │ │ } │ │ │ │ │ ▼ │ │ eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. │ │ Payload (Base64Url编码) │ │ │ { │ │ │ sub: 1234567890, ← 主题(用户ID) │ │ │ name: John Doe, ← 用户名 │ │ │ iat: 1516239022, ← 签发时间 │ │ │ exp: 1516242622, ← 过期时间 │ │ │ scope: read write ← 权限范围 │ │ │ } │ │ │ │ │ ▼ │ │ SflKxwRJSMeKKF2QT4fwpMe... │ │ Signature (签名) │ │ HMACSHA256( │ │ base64UrlEncode(header) . │ │ base64UrlEncode(payload), │ │ secret │ │ ) │ │ │ └────────────────────────────────────────────────────────────────────┘4.2 签名验证原理服务器收到JWT后会用同样的算法和密钥重新计算签名然后比对// 伪代码JWT签名验证 String receivedJwt eyJhbGciOiJIUzI1NiIs...; String[] parts receivedJwt.split(\\.); String header parts[0]; String payload parts[1]; String receivedSignature parts[2]; // 重新计算签名 String computedSignature HMACSHA256(header . payload, secret); // 验证 if (computedSignature.equals(receivedSignature)) { // 签名有效Token未被篡改 JSONObject claims Base64UrlDecode(payload); // 检查过期时间 if (claims.exp currentTime()) { // Token有效允许访问 } }为什么JWT难以伪造因为签名需要密钥才能生成。没有密钥攻击者只能伪造Header和Payload但无法生成有效的签名。服务器验证签名时就会发现不匹配。五、Access Token vs Refresh Token双卡双待OAuth2返回两种Token它们分工明确特性Access TokenRefresh Token用途访问受保护资源获取新的Access Token有效期短通常15分钟-2小时长通常7天-30天存储位置内存/短期存储安全存储HttpOnly Cookie泄露风险低有效期短高需重点保护健身房类比Access Token 当日有效的入场券每天去都要新的Refresh Token 你的会员卡用它可以在前台换新的入场券如果入场券丢了泄露坏人只能用一天但如果会员卡丢了坏人可以一直换入场券。所以会员卡要收好5.1 Refresh Token工作流程// Access Token 过期后用 Refresh Token 换取新的 POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typerefresh_token refresh_tokentGzv3JOkF0XG5Qx2TlKWIA client_idYOUR_CLIENT_ID client_secretYOUR_CLIENT_SECRET⚠️安全提示Refresh Token 应该存储在HttpOnly Cookie中防止XSS攻击使用Refresh Token轮换机制每次使用Refresh Token时同时颁发新的Access Token和新的Refresh Token旧的Refresh Token作废支持Refresh Token黑名单用户登出时立即失效六、JWT安全最佳实践别把会员卡借给别人6.1 密钥管理# ❌ 错误把密钥写在代码里 jwt.secretmy-secret-key-123 # ✅ 正确使用环境变量或密钥管理服务 jwt.secret${JWT_SECRET_FROM_VAULT}密钥长度HS256至少256位RS256至少2048位密钥轮换定期更换密钥支持平滑过渡密钥存储使用AWS KMS、Azure Key Vault或HashiCorp Vault6.2 过期策略// 合理的Token过期时间设置 public class TokenConfig { // Access Token: 15分钟-2小时 public static final long ACCESS_TOKEN_EXPIRATION 15 * 60 * 1000; // 15分钟 // Refresh Token: 7天-30天 public static final long REFRESH_TOKEN_EXPIRATION 7 * 24 * 60 * 60 * 1000; // 7天 // 绝对过期时间无论是否活跃强制过期 public static final long MAX_SESSION_DURATION 30 * 24 * 60 * 60 * 1000; // 30天 }6.3 Token黑名单用户登出或发现Token泄露时需要让Token立即失效Service public class TokenBlacklistService { Autowired private RedisTemplateString, String redisTemplate; // 将Token加入黑名单 public void blacklistToken(String jti, long expirationTime) { long ttl expirationTime - System.currentTimeMillis(); if (ttl 0) { redisTemplate.opsForValue().set( blacklist: jti, revoked, ttl, TimeUnit.MILLISECONDS ); } } // 检查Token是否在黑名单中 public boolean isBlacklisted(String jti) { return Boolean.TRUE.equals(redisTemplate.hasKey(blacklist: jti)); } }6.4 其他安全措施HTTPS传输永远使用HTTPS防止中间人攻击Token绑定将Token与设备指纹/IP绑定异常时告警敏感操作二次验证修改密码、转账等操作需要额外验证审计日志记录Token颁发、刷新、吊销日志七、Spring Security OAuth2实战代码理论讲完了我们来写代码。以下是一个完整的Spring Boot Spring Security OAuth2实现。7.1 Maven依赖dependencies !-- Spring Boot Starter -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Security -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- Spring Security OAuth2 Resource Server (JWT验证) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency !-- Spring Security OAuth2 Client (作为客户端) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-client/artifactId /dependency !-- JJWT 库 (生成和验证JWT) -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.3/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.3/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.3/version scoperuntime/scope /dependency !-- Redis (Token黑名单) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies7.2 配置类Configuration EnableWebSecurity public class SecurityConfig { Value(${jwt.secret}) private String jwtSecret; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**).permitAll() .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.decoder(jwtDecoder())) ); return http.build(); } Bean public JwtDecoder jwtDecoder() { SecretKeySpec secretKey new SecretKeySpec( jwtSecret.getBytes(StandardCharsets.UTF_8), HmacSHA256 ); return NimbusJwtDecoder.withSecretKey(secretKey).build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }7.3 JWT工具类Component public class JwtUtil { Value(${jwt.secret}) private String jwtSecret; Value(${jwt.access-token.expiration:900000}) // 默认15分钟 private long accessTokenExpiration; Value(${jwt.refresh-token.expiration:604800000}) // 默认7天 private long refreshTokenExpiration; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); } // 生成Access Token public String generateAccessToken(String userId, String username, ListString roles) { Date now new Date(); Date expiry new Date(now.getTime() accessTokenExpiration); return Jwts.builder() .subject(userId) .claim(username, username) .claim(roles, roles) .claim(type, access) .id(UUID.randomUUID().toString()) // jti用于黑名单 .issuedAt(now) .expiration(expiry) .signWith(getSigningKey()) .compact(); } // 生成Refresh Token public String generateRefreshToken(String userId) { Date now new Date(); Date expiry new Date(now.getTime() refreshTokenExpiration); return Jwts.builder() .subject(userId) .claim(type, refresh) .id(UUID.randomUUID().toString()) .issuedAt(now) .expiration(expiry) .signWith(getSigningKey()) .compact(); } // 解析Token public Claims parseToken(String token) { return Jwts.parser() .verifyWith(getSigningKey()) .build() .parseSignedClaims(token) .getPayload(); } // 验证Token public boolean validateToken(String token) { try { parseToken(token); return true; } catch (Exception e) { return false; } } // 获取过期时间 public Date getExpirationDate(String token) { return parseToken(token).getExpiration(); } // 获取JTI public String getJti(String token) { return parseToken(token).getId(); } }7.4 认证控制器RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtUtil jwtUtil; Autowired private UserService userService; Autowired private TokenBlacklistService blacklistService; // 登录 PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest request) { try { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); UserDetails userDetails (UserDetails) authentication.getPrincipal(); String userId ((CustomUserDetails) userDetails).getUserId(); ListString roles userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .map(role - role.replace(ROLE_, )) .collect(Collectors.toList()); String accessToken jwtUtil.generateAccessToken( userId, userDetails.getUsername(), roles ); String refreshToken jwtUtil.generateRefreshToken(userId); return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken)); } catch (BadCredentialsException e) { return ResponseEntity.status(401).body(用户名或密码错误); } } // 刷新Token PostMapping(/refresh) public ResponseEntity? refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); try { Claims claims jwtUtil.parseToken(refreshToken); // 验证是否是Refresh Token if (!refresh.equals(claims.get(type))) { return ResponseEntity.status(400).body(无效的Token类型); } // 检查黑名单 if (blacklistService.isBlacklisted(claims.getId())) { return ResponseEntity.status(401).body(Token已被吊销); } String userId claims.getSubject(); User user userService.findById(userId); // 生成新的Token对 String newAccessToken jwtUtil.generateAccessToken( userId, user.getUsername(), user.getRoles() ); String newRefreshToken jwtUtil.generateRefreshToken(userId); // 将旧的Refresh Token加入黑名单Token轮换 blacklistService.blacklistToken( claims.getId(), claims.getExpiration().getTime() ); return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken)); } catch (ExpiredJwtException e) { return ResponseEntity.status(401).body(Refresh Token已过期请重新登录); } catch (Exception e) { return ResponseEntity.status(401).body(无效的Token); } } // 登出 PostMapping(/logout) public ResponseEntity? logout(RequestHeader(Authorization) String authHeader) { String token authHeader.replace(Bearer , ); try { Claims claims jwtUtil.parseToken(token); // 将Token加入黑名单 blacklistService.blacklistToken( claims.getId(), claims.getExpiration().getTime() ); return ResponseEntity.ok(登出成功); } catch (Exception e) { return ResponseEntity.status(400).body(无效的Token); } } }7.5 受保护资源示例RestController RequestMapping(/api) public class ResourceController { GetMapping(/public/info) public String publicInfo() { return 这是公开信息任何人都能访问; } GetMapping(/user/profile) PreAuthorize(hasAnyRole(USER, ADMIN)) public ResponseEntity? getProfile(AuthenticationPrincipal Jwt jwt) { String userId jwt.getSubject(); String username jwt.getClaimAsString(username); ListString roles jwt.getClaimAsStringList(roles); return ResponseEntity.ok(Map.of( userId, userId, username, username, roles, roles )); } GetMapping(/admin/users) PreAuthorize(hasRole(ADMIN)) public ResponseEntity? getAllUsers() { // 只有管理员能访问 return ResponseEntity.ok(userService.findAll()); } }7.6 配置文件# application.yml server: port: 8080 spring: redis: host: localhost port: 6379 security: oauth2: resourceserver: jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-here-must-be-at-least-32-characters} jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-here-must-be-at-least-32-characters} access-token: expiration: 900000 # 15分钟 refresh-token: expiration: 604800000 # 7天7.7 测试命令# 1. 登录获取Token curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} # 响应示例 # {accessToken:eyJhbGciOiJIUzI1NiIs...,refreshToken:eyJhbGciOiJIUzI1NiIs...} # 2. 访问受保护资源 curl -X GET http://localhost:8080/api/user/profile \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIs... # 3. 刷新Token curl -X POST http://localhost:8080/api/auth/refresh \ -H Content-Type: application/json \ -d {refreshToken:eyJhbGciOiJIUzI1NiIs...} # 4. 登出 curl -X POST http://localhost:8080/api/auth/logout \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIs... 源码获取本文完整示例代码已上传至GitHubhttps://github.com/yourusername/spring-security-oauth2-jwt-demo包含完整的Spring Boot项目结构OAuth2 JWT实现Token黑名单RedisRefresh Token轮换机制单元测试和集成测试Docker Compose一键启动 思考题为什么OAuth2的授权码模式比简化模式更安全请从Token暴露位置、攻击面等角度分析。PKCE是如何防止授权码被截获攻击的如果攻击者同时截获了code和code_verifier还能防御吗JWT的签名使用对称加密HS256和非对称加密RS256各有什么优缺点在什么场景下应该选择哪种Refresh Token轮换机制有什么作用如果不用轮换会有什么安全风险如何在不增加服务器状态不使用Redis黑名单的情况下实现Token即时失效提示考虑缩短Token有效期 其他机制。 系列文章预告网络协议系列持续更新中敬请期待第19篇《SSO单点登录实战——CAS vs SAML vs OIDC》第20篇《API网关设计与实现——Spring Cloud Gateway深度解析》第21篇《微服务认证鉴权——从JWT到OAuth2的演进之路》第22篇《零信任架构下的身份认证——BeyondCorp实践》如果本文对你有帮助欢迎点赞、收藏、转发有任何问题可以在评论区留言我会一一回复。本文标签OAuth2JWT身份认证API安全Spring BootCSDN多平台一键发布功能开通链接https://mp.csdn.net/vip?utm_sourceweitingfu
网络技术18-OAuth2与JWT认证——现代API的“通行证系统“
发布时间:2026/6/7 4:02:14
CSDN多平台一键发布功能开通链接https://mp.csdn.net/vip?utm_sourceweitingfu“OAuth2就像去健身房——你先办会员卡授权然后每次去刷会员卡Access Token不用每次都带身份证。JWT就像会员卡上印了你的信息健身房一看就知道你是谁。”想象一下如果你每次去健身房都要出示身份证、户口本、工作证明还要填表申请估计你早就放弃健身了。现代API认证也是这个道理——OAuth2和JWT就是为了让进门这件事变得优雅而安全。今天这篇文章我们就来聊聊这套现代API的通行证系统。不管你是刚入门的小白还是想深入原理的老司机这篇文章都能让你有所收获。一、OAuth2授权流程详解四种进门方式OAuth2定义了四种授权模式就像进健身房可以有四种不同的办卡方式授权模式适用场景类比健身房授权码模式 (Authorization Code)Web应用、移动应用前台办理正式会员卡简化模式 (Implicit)纯前端应用已废弃临时体验卡不推荐密码模式 (Password)第一方应用直接报身份证号办卡客户端模式 (Client Credentials)服务间调用企业团购卡1.1 授权码模式——最安全的前台办卡健身房类比你去前台说我要办卡前台给你一张申请表Authorization Code。 你拿着申请表到后台办公室工作人员验证后给你正式的会员卡Access Token。 关键点申请表是一次性的而且只有你能拿到真正的会员卡。授权码模式是OAuth2中最安全、最常用的模式。它的核心思想是不要把敏感凭证暴露给客户端。┌─────────────┐ ┌─────────────┐ │ 用户浏览器 │ │ 授权服务器 │ │ (User Agent)│ │ (Gym Front) │ └──────┬──────┘ └──────┬──────┘ │ │ │ 1. 申请授权 (带client_id, redirect_uri, scope) │ │─────────────────────────────────────────────────▶│ │ │ │ 2. 用户登录并同意授权 │ │◀─────────────────────────────────────────────────│ │ │ │ 3. 返回授权码 (Authorization Code) │ │─────────────────────────────────────────────────▶│ │ │ └──────────────────────────────────────────────────┘ │ │ 4. 用授权码换取Token │ (后台服务器发起带client_secret) ▼ ┌─────────────┐ │ 应用服务器 │ │(Backend App)│ └──────┬──────┘ │ │ 5. 返回 Access Token Refresh Token ▼1.2 简化模式——“临时体验卡”已废弃⚠️重要提示简化模式在OAuth 2.1中已被废弃因为它把Access Token直接暴露在URL中存在安全隐患。如果你的项目还在用建议尽快迁移到授权码模式PKCE。1.3 密码模式——“直接报身份证号”健身房类比你直接告诉健身房前台你的身份证号前台验证后直接给你会员卡。 这种模式只适用于你完全信任的应用比如官方App因为你要把密码交给它。POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typepassword usernameuserexample.com passwordyour_password client_idyour_client_id client_secretyour_client_secret1.4 客户端模式——“企业团购卡”当两个服务之间需要通信时不需要用户参与直接用自己的企业资质获取Token。这就像公司统一办的健身卡不需要每个员工单独申请。POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typeclient_credentials client_idservice_a client_secretservice_a_secret scoperead write二、授权码模式完整流程庖丁解牛让我们深入授权码模式的每一个细节看看这套前台办卡系统是如何工作的。2.1 第一步申请授权用户点击使用微信登录按钮时应用会重定向到授权服务器https://authorization-server.com/oauth/authorize? response_typecode client_idYOUR_CLIENT_ID redirect_urihttps://your-app.com/callback scopeopenid profile email statexyz123参数详解response_typecode告诉服务器我要授权码client_id你的应用ID就像健身房的合作商户编号redirect_uri授权成功后跳回的地址scope申请的权限范围state防CSRF攻击的随机字符串2.2 第二步用户授权授权服务器展示登录页面用户输入凭据并同意授权。这一步在授权服务器端完成你的应用永远接触不到用户的密码。2.3 第三步获取授权码用户同意后授权服务器重定向回你的应用带上授权码https://your-app.com/callback? codeAUTH_CODE_HERE statexyz1232.4 第四步换取Token这是最关键的一步——必须由服务器端完成因为需要用到client_secretPOST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typeauthorization_code codeAUTH_CODE_HERE redirect_urihttps://your-app.com/callback client_idYOUR_CLIENT_ID client_secretYOUR_CLIENT_SECRET授权服务器验证通过后返回Token{ access_token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..., token_type: Bearer, expires_in: 3600, refresh_token: tGzv3JOkF0XG5Qx2TlKWIA, scope: openid profile email }三、PKCE扩展给授权码加把锁PKCEProof Key for Code Exchange发音同pixy是OAuth2的一个扩展专门为移动应用和SPA设计的安全增强。健身房类比想象有人偷看了你的申请表Authorization Code想冒领你的会员卡。 PKCE就像在申请表上加了一个动态密码——即使别人偷看了申请表没有动态密码也领不了卡。3.1 PKCE工作原理┌─────────────────────────────────────────────────────────────────┐ │ PKCE 流程 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 客户端 授权服务器 资源服务器 │ │ │ │ │ │ │ │ 1. 生成 code_verifier │ │ │ │ │ (随机字符串存好!) │ │ │ │ │ │ │ │ │ │ 2. 计算 code_challenge │ │ │ │ │ BASE64URL(SHA256(code_verifier)) │ │ │ │ │ │ │ │ │ 3. /authorize? │ │ │ │ │ code_challengexxx │ │ │ │ │ code_challenge_methodS256 │ │ │ │─────────────────────────▶│ │ │ │ │ │ │ │ │ │ 4. 返回 code │ │ │ │ │◀─────────────────────────│ │ │ │ │ │ │ │ │ │ 5. /token │ │ │ │ │ codexxx │ │ │ │ │ code_verifier原始值 │ │ │ │ │─────────────────────────▶│ │ │ │ │ │ 6. 验证: │ │ │ │ │ SHA256(verifier) │ │ │ │ │ challenge? │ │ │ │ │ │ │ │ │ 7. 返回 access_token │ │ │ │ │◀─────────────────────────│ │ │ │ │ │ │ │ │ │ 8. 用 access_token 访问资源 │ │ │ │──────────────────────────────────────────────────▶│ │ │ │ └─────────────────────────────────────────────────────────────────┘3.2 为什么需要PKCE在移动应用中应用无法安全地存储client_secret可能被反编译获取。PKCE通过动态密码机制即使授权码被截获攻击者也无法换取Token。// Java 生成 PKCE 参数示例 import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; public class PKCEGenerator { // 生成 code_verifier (43-128字符的随机字符串) public static String generateCodeVerifier() { SecureRandom secureRandom new SecureRandom(); byte[] codeVerifier new byte[32]; secureRandom.nextBytes(codeVerifier); return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier); } // 计算 code_challenge public static String generateCodeChallenge(String codeVerifier) throws Exception { MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] hash digest.digest(codeVerifier.getBytes()); return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } }四、JWT结构与签名验证解密会员卡JWTJSON Web Token就是那张会员卡它里面印着你的信息而且很难伪造。4.1 JWT的结构Header.Payload.Signature┌────────────────────────────────────────────────────────────────────┐ │ JWT 结构解析 │ ├────────────────────────────────────────────────────────────────────┤ │ │ │ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. │ │ │ Header (Base64Url编码) │ │ │ { │ │ │ alg: HS256, ← 签名算法 │ │ │ typ: JWT ← Token类型 │ │ │ } │ │ │ │ │ ▼ │ │ eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. │ │ Payload (Base64Url编码) │ │ │ { │ │ │ sub: 1234567890, ← 主题(用户ID) │ │ │ name: John Doe, ← 用户名 │ │ │ iat: 1516239022, ← 签发时间 │ │ │ exp: 1516242622, ← 过期时间 │ │ │ scope: read write ← 权限范围 │ │ │ } │ │ │ │ │ ▼ │ │ SflKxwRJSMeKKF2QT4fwpMe... │ │ Signature (签名) │ │ HMACSHA256( │ │ base64UrlEncode(header) . │ │ base64UrlEncode(payload), │ │ secret │ │ ) │ │ │ └────────────────────────────────────────────────────────────────────┘4.2 签名验证原理服务器收到JWT后会用同样的算法和密钥重新计算签名然后比对// 伪代码JWT签名验证 String receivedJwt eyJhbGciOiJIUzI1NiIs...; String[] parts receivedJwt.split(\\.); String header parts[0]; String payload parts[1]; String receivedSignature parts[2]; // 重新计算签名 String computedSignature HMACSHA256(header . payload, secret); // 验证 if (computedSignature.equals(receivedSignature)) { // 签名有效Token未被篡改 JSONObject claims Base64UrlDecode(payload); // 检查过期时间 if (claims.exp currentTime()) { // Token有效允许访问 } }为什么JWT难以伪造因为签名需要密钥才能生成。没有密钥攻击者只能伪造Header和Payload但无法生成有效的签名。服务器验证签名时就会发现不匹配。五、Access Token vs Refresh Token双卡双待OAuth2返回两种Token它们分工明确特性Access TokenRefresh Token用途访问受保护资源获取新的Access Token有效期短通常15分钟-2小时长通常7天-30天存储位置内存/短期存储安全存储HttpOnly Cookie泄露风险低有效期短高需重点保护健身房类比Access Token 当日有效的入场券每天去都要新的Refresh Token 你的会员卡用它可以在前台换新的入场券如果入场券丢了泄露坏人只能用一天但如果会员卡丢了坏人可以一直换入场券。所以会员卡要收好5.1 Refresh Token工作流程// Access Token 过期后用 Refresh Token 换取新的 POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_typerefresh_token refresh_tokentGzv3JOkF0XG5Qx2TlKWIA client_idYOUR_CLIENT_ID client_secretYOUR_CLIENT_SECRET⚠️安全提示Refresh Token 应该存储在HttpOnly Cookie中防止XSS攻击使用Refresh Token轮换机制每次使用Refresh Token时同时颁发新的Access Token和新的Refresh Token旧的Refresh Token作废支持Refresh Token黑名单用户登出时立即失效六、JWT安全最佳实践别把会员卡借给别人6.1 密钥管理# ❌ 错误把密钥写在代码里 jwt.secretmy-secret-key-123 # ✅ 正确使用环境变量或密钥管理服务 jwt.secret${JWT_SECRET_FROM_VAULT}密钥长度HS256至少256位RS256至少2048位密钥轮换定期更换密钥支持平滑过渡密钥存储使用AWS KMS、Azure Key Vault或HashiCorp Vault6.2 过期策略// 合理的Token过期时间设置 public class TokenConfig { // Access Token: 15分钟-2小时 public static final long ACCESS_TOKEN_EXPIRATION 15 * 60 * 1000; // 15分钟 // Refresh Token: 7天-30天 public static final long REFRESH_TOKEN_EXPIRATION 7 * 24 * 60 * 60 * 1000; // 7天 // 绝对过期时间无论是否活跃强制过期 public static final long MAX_SESSION_DURATION 30 * 24 * 60 * 60 * 1000; // 30天 }6.3 Token黑名单用户登出或发现Token泄露时需要让Token立即失效Service public class TokenBlacklistService { Autowired private RedisTemplateString, String redisTemplate; // 将Token加入黑名单 public void blacklistToken(String jti, long expirationTime) { long ttl expirationTime - System.currentTimeMillis(); if (ttl 0) { redisTemplate.opsForValue().set( blacklist: jti, revoked, ttl, TimeUnit.MILLISECONDS ); } } // 检查Token是否在黑名单中 public boolean isBlacklisted(String jti) { return Boolean.TRUE.equals(redisTemplate.hasKey(blacklist: jti)); } }6.4 其他安全措施HTTPS传输永远使用HTTPS防止中间人攻击Token绑定将Token与设备指纹/IP绑定异常时告警敏感操作二次验证修改密码、转账等操作需要额外验证审计日志记录Token颁发、刷新、吊销日志七、Spring Security OAuth2实战代码理论讲完了我们来写代码。以下是一个完整的Spring Boot Spring Security OAuth2实现。7.1 Maven依赖dependencies !-- Spring Boot Starter -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Security -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- Spring Security OAuth2 Resource Server (JWT验证) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency !-- Spring Security OAuth2 Client (作为客户端) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-client/artifactId /dependency !-- JJWT 库 (生成和验证JWT) -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.3/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.3/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.3/version scoperuntime/scope /dependency !-- Redis (Token黑名单) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies7.2 配置类Configuration EnableWebSecurity public class SecurityConfig { Value(${jwt.secret}) private String jwtSecret; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**).permitAll() .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.decoder(jwtDecoder())) ); return http.build(); } Bean public JwtDecoder jwtDecoder() { SecretKeySpec secretKey new SecretKeySpec( jwtSecret.getBytes(StandardCharsets.UTF_8), HmacSHA256 ); return NimbusJwtDecoder.withSecretKey(secretKey).build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }7.3 JWT工具类Component public class JwtUtil { Value(${jwt.secret}) private String jwtSecret; Value(${jwt.access-token.expiration:900000}) // 默认15分钟 private long accessTokenExpiration; Value(${jwt.refresh-token.expiration:604800000}) // 默认7天 private long refreshTokenExpiration; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); } // 生成Access Token public String generateAccessToken(String userId, String username, ListString roles) { Date now new Date(); Date expiry new Date(now.getTime() accessTokenExpiration); return Jwts.builder() .subject(userId) .claim(username, username) .claim(roles, roles) .claim(type, access) .id(UUID.randomUUID().toString()) // jti用于黑名单 .issuedAt(now) .expiration(expiry) .signWith(getSigningKey()) .compact(); } // 生成Refresh Token public String generateRefreshToken(String userId) { Date now new Date(); Date expiry new Date(now.getTime() refreshTokenExpiration); return Jwts.builder() .subject(userId) .claim(type, refresh) .id(UUID.randomUUID().toString()) .issuedAt(now) .expiration(expiry) .signWith(getSigningKey()) .compact(); } // 解析Token public Claims parseToken(String token) { return Jwts.parser() .verifyWith(getSigningKey()) .build() .parseSignedClaims(token) .getPayload(); } // 验证Token public boolean validateToken(String token) { try { parseToken(token); return true; } catch (Exception e) { return false; } } // 获取过期时间 public Date getExpirationDate(String token) { return parseToken(token).getExpiration(); } // 获取JTI public String getJti(String token) { return parseToken(token).getId(); } }7.4 认证控制器RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtUtil jwtUtil; Autowired private UserService userService; Autowired private TokenBlacklistService blacklistService; // 登录 PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest request) { try { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); UserDetails userDetails (UserDetails) authentication.getPrincipal(); String userId ((CustomUserDetails) userDetails).getUserId(); ListString roles userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .map(role - role.replace(ROLE_, )) .collect(Collectors.toList()); String accessToken jwtUtil.generateAccessToken( userId, userDetails.getUsername(), roles ); String refreshToken jwtUtil.generateRefreshToken(userId); return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken)); } catch (BadCredentialsException e) { return ResponseEntity.status(401).body(用户名或密码错误); } } // 刷新Token PostMapping(/refresh) public ResponseEntity? refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); try { Claims claims jwtUtil.parseToken(refreshToken); // 验证是否是Refresh Token if (!refresh.equals(claims.get(type))) { return ResponseEntity.status(400).body(无效的Token类型); } // 检查黑名单 if (blacklistService.isBlacklisted(claims.getId())) { return ResponseEntity.status(401).body(Token已被吊销); } String userId claims.getSubject(); User user userService.findById(userId); // 生成新的Token对 String newAccessToken jwtUtil.generateAccessToken( userId, user.getUsername(), user.getRoles() ); String newRefreshToken jwtUtil.generateRefreshToken(userId); // 将旧的Refresh Token加入黑名单Token轮换 blacklistService.blacklistToken( claims.getId(), claims.getExpiration().getTime() ); return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken)); } catch (ExpiredJwtException e) { return ResponseEntity.status(401).body(Refresh Token已过期请重新登录); } catch (Exception e) { return ResponseEntity.status(401).body(无效的Token); } } // 登出 PostMapping(/logout) public ResponseEntity? logout(RequestHeader(Authorization) String authHeader) { String token authHeader.replace(Bearer , ); try { Claims claims jwtUtil.parseToken(token); // 将Token加入黑名单 blacklistService.blacklistToken( claims.getId(), claims.getExpiration().getTime() ); return ResponseEntity.ok(登出成功); } catch (Exception e) { return ResponseEntity.status(400).body(无效的Token); } } }7.5 受保护资源示例RestController RequestMapping(/api) public class ResourceController { GetMapping(/public/info) public String publicInfo() { return 这是公开信息任何人都能访问; } GetMapping(/user/profile) PreAuthorize(hasAnyRole(USER, ADMIN)) public ResponseEntity? getProfile(AuthenticationPrincipal Jwt jwt) { String userId jwt.getSubject(); String username jwt.getClaimAsString(username); ListString roles jwt.getClaimAsStringList(roles); return ResponseEntity.ok(Map.of( userId, userId, username, username, roles, roles )); } GetMapping(/admin/users) PreAuthorize(hasRole(ADMIN)) public ResponseEntity? getAllUsers() { // 只有管理员能访问 return ResponseEntity.ok(userService.findAll()); } }7.6 配置文件# application.yml server: port: 8080 spring: redis: host: localhost port: 6379 security: oauth2: resourceserver: jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-here-must-be-at-least-32-characters} jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-here-must-be-at-least-32-characters} access-token: expiration: 900000 # 15分钟 refresh-token: expiration: 604800000 # 7天7.7 测试命令# 1. 登录获取Token curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} # 响应示例 # {accessToken:eyJhbGciOiJIUzI1NiIs...,refreshToken:eyJhbGciOiJIUzI1NiIs...} # 2. 访问受保护资源 curl -X GET http://localhost:8080/api/user/profile \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIs... # 3. 刷新Token curl -X POST http://localhost:8080/api/auth/refresh \ -H Content-Type: application/json \ -d {refreshToken:eyJhbGciOiJIUzI1NiIs...} # 4. 登出 curl -X POST http://localhost:8080/api/auth/logout \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIs... 源码获取本文完整示例代码已上传至GitHubhttps://github.com/yourusername/spring-security-oauth2-jwt-demo包含完整的Spring Boot项目结构OAuth2 JWT实现Token黑名单RedisRefresh Token轮换机制单元测试和集成测试Docker Compose一键启动 思考题为什么OAuth2的授权码模式比简化模式更安全请从Token暴露位置、攻击面等角度分析。PKCE是如何防止授权码被截获攻击的如果攻击者同时截获了code和code_verifier还能防御吗JWT的签名使用对称加密HS256和非对称加密RS256各有什么优缺点在什么场景下应该选择哪种Refresh Token轮换机制有什么作用如果不用轮换会有什么安全风险如何在不增加服务器状态不使用Redis黑名单的情况下实现Token即时失效提示考虑缩短Token有效期 其他机制。 系列文章预告网络协议系列持续更新中敬请期待第19篇《SSO单点登录实战——CAS vs SAML vs OIDC》第20篇《API网关设计与实现——Spring Cloud Gateway深度解析》第21篇《微服务认证鉴权——从JWT到OAuth2的演进之路》第22篇《零信任架构下的身份认证——BeyondCorp实践》如果本文对你有帮助欢迎点赞、收藏、转发有任何问题可以在评论区留言我会一一回复。本文标签OAuth2JWT身份认证API安全Spring BootCSDN多平台一键发布功能开通链接https://mp.csdn.net/vip?utm_sourceweitingfu