CSDN多平台一键发布功能开通链接
https://mp.csdn.net/vip?utm_source=weitingfu
“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_type=password &username=user@example.com &password=your_password &client_id=your_client_id &client_secret=your_client_secret1.4 客户端模式——“企业团购卡”
当两个服务之间需要通信时,不需要用户参与,直接用自己的"企业资质"获取Token。这就像公司统一办的健身卡,不需要每个员工单独申请。
POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id=service_a &client_secret=service_a_secret &scope=read write二、授权码模式完整流程:庖丁解牛
让我们深入授权码模式的每一个细节,看看这套"前台办卡"系统是如何工作的。
2.1 第一步:申请授权
用户点击"使用微信登录"按钮时,应用会重定向到授权服务器:
https://authorization-server.com/oauth/authorize? response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https://your-app.com/callback &scope=openid profile email &state=xyz123🔑参数详解:
response_type=code:告诉服务器我要授权码client_id:你的应用ID,就像健身房的合作商户编号redirect_uri:授权成功后跳回的地址scope:申请的权限范围state:防CSRF攻击的随机字符串
2.2 第二步:用户授权
授权服务器展示登录页面,用户输入凭据并同意授权。这一步在授权服务器端完成,你的应用永远接触不到用户的密码。
2.3 第三步:获取授权码
用户同意后,授权服务器重定向回你的应用,带上授权码:
https://your-app.com/callback? code=AUTH_CODE_HERE &state=xyz1232.4 第四步:换取Token
这是最关键的一步——必须由服务器端完成,因为需要用到client_secret:
POST /oauth/token HTTP/1.1 Host: authorization-server.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=AUTH_CODE_HERE &redirect_uri=https://your-app.com/callback &client_id=YOUR_CLIENT_ID &client_secret=YOUR_CLIENT_SECRET授权服务器验证通过后,返回Token:
{ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "scope": "openid profile email" }三、PKCE扩展:给授权码加把锁
PKCE(Proof 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_challenge=xxx& │ │ │ │ │ code_challenge_method=S256 │ │ │ │─────────────────────────▶│ │ │ │ │ │ │ │ │ │ 4. 返回 code │ │ │ │ │◀─────────────────────────│ │ │ │ │ │ │ │ │ │ 5. /token │ │ │ │ │ code=xxx& │ │ │ │ │ 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结构与签名验证:解密"会员卡"
JWT(JSON 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 Token | Refresh 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_type=refresh_token &refresh_token=tGzv3JOkF0XG5Qx2TlKWIA &client_id=YOUR_CLIENT_ID &client_secret=YOUR_CLIENT_SECRET⚠️安全提示:
- Refresh Token 应该存储在HttpOnly Cookie中,防止XSS攻击
- 使用Refresh Token轮换机制:每次使用Refresh Token时,同时颁发新的Access Token和新的Refresh Token,旧的Refresh Token作废
- 支持Refresh Token黑名单,用户登出时立即失效
六、JWT安全最佳实践:别把会员卡借给别人
6.1 密钥管理
# ❌ 错误:把密钥写在代码里 jwt.secret=my-secret-key-123 # ✅ 正确:使用环境变量或密钥管理服务 jwt.secret=${JWT_SECRET_FROM_VAULT}- 密钥长度:HS256至少256位,RS256至少2048位
- 密钥轮换:定期更换密钥,支持平滑过渡
- 密钥存储:使用AWS KMS、Azure Key Vault或HashiCorp Vault
6.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 RedisTemplate<String, 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- Spring Security OAuth2 Resource Server (JWT验证) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <!-- Spring Security OAuth2 Client (作为客户端) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <!-- JJWT 库 (生成和验证JWT) --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.3</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.3</version> <scope>runtime</scope> </dependency> <!-- Redis (Token黑名单) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>7.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, List<String> 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(); List<String> 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"); List<String> 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..."📦 源码获取
本文完整示例代码已上传至GitHub:
https://github.com/yourusername/spring-security-oauth2-jwt-demo
包含:
- 完整的Spring Boot项目结构
- OAuth2 + JWT实现
- Token黑名单(Redis)
- Refresh 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 Boot
CSDN多平台一键发布功能开通链接
https://mp.csdn.net/vip?utm_source=weitingfu