多系统认证授权利器:OAuth2,究竟该如何使用?

📅 2026/7/4 3:33:17 👁️ 阅读次数 📝 编程学习
多系统认证授权利器:OAuth2,究竟该如何使用?

朋友老周被一个需求卡住了:新系统要访问老系统的用户数据,两边的鉴权怎么搞?"总不能把老系统密码直接发给新系统吧?"我说你这场景有现成的实现方案——OAuth2,核心思路就三句话:用户不暴露密码,第三方拿一张有权限、有时效的令牌去访问资源,令牌过期了还能静默续上,不用重新登录。老周一拍大腿:"懂了,就是发张门禁卡!"

门禁卡的比喻很贴切,但除了架构师,恐怕大多数开发者对 OAuth2 的认知,也只是停在了这个比喻上。本文的目标很明确:让你不仅会用,还能把核心原理和最佳实践讲得清清楚楚。


一、从"门禁卡"说起:OAuth2 到底解决了什么问题

1.1 一个你每天都遇到的场景

假设你入住一家酒店,前台给了你一张门禁卡。这张卡有以下几个特点:

  • 只对特定房门有效(你只能进你的房间,不能进别人的)
  • 有时间限制(过了退房时间就打不开了)
  • 能干什么是确定的(开门可以,但不能用它去餐厅签单)
  • 随时可以挂失(前台可以把卡作废)

这张门禁卡,就是一种授权机制。

现在把这个场景搬到互联网世界:

你打开一个叫"咕咚笔记"的第三方应用,它说"可以用微信账号登录"。你点了授权,微信给你弹了个确认框:"咕咚笔记申请获取你的昵称和头像"。你点了同意,然后咕咚笔记就能拿到你的微信昵称和头像了。

这个过程是怎么实现的?答案是:OAuth2

1.2 传统方式的致命缺陷

在没有 OAuth2 的年代,第三方应用想要访问你在另一个服务上的数据,只能这样做:

你 → 告诉咕咚笔记你的微信账号密码 → 咕咚笔记拿着你的密码去微信服务器登录 → 拿到你的数据

问题在哪?你把微信的密码给了咕咚笔记。

  • 咕咚笔记是好人还好说,但如果它把密码存了明文呢?
  • 如果它不止读取了昵称头像,还偷偷翻了你的聊天记录呢?
  • 如果你在 10 个应用里都用了这种"给密码"的方式,改一次密码 = 10 个应用全部失效

OAuth2 的解决思路非常朴素:让用户在不暴露自己密码的情况下,授权第三方应用访问自己在某个服务上的受保护资源。

你 → 在微信授权页点"同意" → 微信发给咕咚笔记一张"临时门禁卡" → 咕咚笔记拿卡访问你的昵称头像

你的密码从头到尾只有你知道,微信服务器知道。咕咚笔记拿到的,只是一个有时效性、有权限范围的令牌

1.3 OAuth2 到底是什么

OAuth2(Open Authorization 2.0)是一个授权框架,它允许第三方应用在资源拥有者的授权下,获取对受保护资源的有限访问权限。

注意这两个关键词——授权而非认证有限而非无限

概念通俗解释
认证(Authentication)"你是谁?"——出示身份证
授权(Authorization)"你能干什么?"——刷门禁卡进房间

OAuth2 主要解决的是授权问题,虽然在实际使用中它经常被用来做认证(也就是"用微信登录"这类场景),但那其实是基于 OAuth2 之上的扩展(OpenID Connect,也就是我们常说的 OIDC)。


二、四个人一台戏:OAuth2 的核心角色

在深入流程之前,你必须先把这四个角色刻在脑子里。整个 OAuth2 就是这四个角色之间的交互。

┌──────────────┐ ┌──────────────────┐ │ │ │ │ │ 资源拥有者 │ │ 授权服务器 │ │ (Resource │ │ (Authorization │ │ Owner) │ │ Server) │ │ │ │ │ │ 就是你,用户 │ │ 比如微信的OAuth │ │ │ │ 授权服务 │ └──────┬───────┘ └────────┬─────────┘ │ ┌──────────────────┐ │ │ │ │ │ └───────┤ 客户端 ├───────┘ │ (Client) │ │ │ │ 咕咚笔记这个应用 │ └────────┬─────────┘ │ ┌────────┴─────────┐ │ │ │ 资源服务器 │ │ (Resource │ │ Server) │ │ │ │ 微信的个人信息API │ │ │ └──────────────────┘
  • 资源拥有者(Resource Owner):用户本人。拥有数据的所有权。
  • 客户端(Client):第三方应用。想要访问用户数据的那一方。
  • 授权服务器(Authorization Server):负责认证用户并颁发令牌的服务。
  • 资源服务器(Resource Server):存放用户资源的服务,验证令牌后提供数据。

在实际的工程实现中,授权服务器和资源服务器经常是同一个系统的不同模块,但它们在 OAuth2 协议中是逻辑上独立的角色。

小贴士:面试的时候面试官经常问"OAuth2 有哪几个角色",这是一道送分题,四个角色一个都不能少。


三、令牌三兄弟:Access Token、Refresh Token 和 JWT

在正式进入授权流程之前,我们先认识 OAuth2 中最核心的三个"令牌"概念。

3.1 Access Token —— 门禁卡

Access Token是 OAuth2 最核心的令牌。客户端拿着它去资源服务器请求数据,资源服务器验证它有效就返回数据。

它的特点:

  • 短期有效:通常是几十分钟到几小时
  • 包含权限信息:能访问什么、不能访问什么
  • 对客户端不透明:客户端不需要(也不应该试图)解析它的内容

3.2 Refresh Token —— 换卡凭证

Refresh Token用于在 Access Token 过期后换取新的 Access Token,而不需要让用户重新授权。

为什么要这样设计?因为 Access Token 是频繁在网络上传输的,泄露风险大,所以设置较短的有效期。Refresh Token 只在与授权服务器通信时才使用,暴露面小得多,所以可以设置较长的有效期(几天到几个月)。

3.3 JWT —— 自包含令牌

JWT(JSON Web Token)不是 OAuth2 协议要求的,但在实际落地中极其常见。它是一种自包含的令牌格式:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIn0.xxxxxxxxxxxxxxxxxx │ │ │ │ │ └── 签名 │ └── Payload(载荷,Base64解码后是JSON) └── Header(头,Base64解码后是JSON)

为什么 JWT 和 OAuth2 常常一起出现?因为传统的 Access Token 可能只是一个随机字符串("不透明令牌"),资源服务器每次收到这种令牌,都需要去授权服务器校验。而 JWT 本身携带了用户信息和签名,资源服务器可以自己验证 JWT 的有效性,不需要每次都远程调用授权服务器——这叫"无状态验证",性能更好。

关键区分:OAuth2 是授权协议,定义的是"怎么发令牌、怎么用令牌";JWT 是令牌格式,定义的是"令牌里写什么、怎么签名"。两者不是同一个层面的东西。


四、四大授权模式:选对武器才能打赢仗

OAuth2 定义了四种授权模式(Grant Type)。这是整个 OAuth2 最核心的知识点,也是面试中必考的。

OAuth2 四种授权模式 │ ├── 授权码模式(Authorization Code)── ⭐ 最安全、最常用 │ 适用:有后端服务器的 Web 应用 │ 比如:咕咚笔记的后端服务器 │ ├── 简化模式(Implicit)── ⚠ OAuth 2.1 已不推荐 │ 适用:纯前端应用(SPA) │ 比如:没有后端、纯 JS 写的单页应用 │ ├── 密码模式(Resource Owner Password)── ⚠ 已废弃 │ 适用:用户完全信任客户端(比如官方App) │ 比如:微信自己的 App │ └── 客户端凭证模式(Client Credentials) 适用:服务端到服务端的调用 比如:后端微服务之间的调用

4.1 授权码模式(Authorization Code)—— 王者

这是最经典、最安全、使用最广泛的模式。如果你只记住一种,就记这一种。

完整流程图
资源拥有者 客户端 授权服务器 资源服务器 (你) (咕咚笔记后端) (微信OAuth) (微信API) │ │ │ │ │1 点击"微信登录" │ │ │ ├───────────────>│ │ │ │ │ 2 重定向到授权页 │ │ │ ├───────────────────>│ │ │ │ │ │ │ 3 显示授权确认页 │ │ │ │<───────────────┼────────────────────┤ │ │ │ │ │ │ 4 用户点击"同意" │ │ │ ├────────────────┼───────────────────>│ │ │ │ │ │ │ │ 5 返回授权码(code) │ │ │ │<───────────────────┤ │ │ │ │ │ │ │ 6 用code换token │ │ │ ├───────────────────>│ │ │ │ │ │ │ │ 7 返回Access Token │ │ │ │ + Refresh Token │ │ │ │<───────────────────┤ │ │ │ │ │ │ │ 8 拿Access Token │ │ │ │ 请求用户数据 │ │ │ ├────────────────────┼─────────────────>│ │ │ │ │ │ │ 9 返回用户数据 │ │ │ │<───────────────────┼──────────────────┤ │ │ │ │ │10 登录成功,展示 │ │ │ │ 用户信息 │ │ │ │<───────────────┤ │ │
关键问题:为什么叫"授权码模式"?为什么要有 code 这一步?

这是面试中的高频追问。答案是:安全——确保令牌只发给真正的后端服务器,而不是浏览器。

具体来说:

  1. code是通过浏览器重定向传递的,会暴露在 URL 中
  2. 如果用code直接当令牌用,任何一个看到 URL 的人都能拿它访问数据
  3. code是一次性的,而且换 token 时需要带上client_secret(客户端密钥)
  4. client_secret只存在后端服务器上,浏览器里没有
  5. 所以即使别人截获了code,没有client_secret也换不到真正的 Access Token

Authorization Code 流程的核心安全逻辑就是:code 通过不安全的渠道(浏览器)传输,但换 token 的操作只通过安全的渠道(后端到后端)完成。

浏览器(不安全通道) 后端服务器 │ │ │ code=xxxx ← 出现在URL里 │ │ │ └──── 后端收到 code ──────────────┘ │ code + client_secret │ → 授权服务器 │ Access Token │ ← 授权服务器

4.2 简化模式(Implicit)—— 被时代抛弃的快枪手

简化模式省略了"用 code 换 token"这一步,授权服务器直接把 Access Token 返回给了浏览器。

为什么被抛弃?因为 Access Token 直接暴露在浏览器端:

  • 浏览器的 URL 片段(#access_token=xxx)虽然不会发给服务器,但可以被 JavaScript 读取
  • 浏览器的历史记录、第三方脚本、浏览器插件都可能泄露这个 token
  • 没有client_secret的保护,任何一个拿到 token 的人都能用

OAuth 2.1 已经明确不推荐使用 Implicit 模式,改为推荐带 PKCE 的授权码模式

4.3 密码模式(Resource Owner Password)—— 已死

用户直接把用户名和密码交给客户端,客户端拿它们去授权服务器换 token。

这又回到了我们最开始说的那个问题:你把密码给了第三方。只有一个场景勉强可以用:客户端和授权服务器是同一家公司的产品(比如微信 App 和微信服务端)。即便如此,OAuth 2.1 也已经将其标记为废弃。

4.4 客户端凭证模式(Client Credentials)—— 机器人的世界

当客户端本身就是资源拥有者,不涉及"用户"的时候使用。比如:

  • 后端微服务 A 调用微服务 B 的 API
  • 定时任务访问自己的数据

这种模式最简单:客户端拿自己的client_id+client_secret直接去授权服务器换一个 Access Token。

4.5 四种模式对比速查表

模式适用场景安全性OAuth 2.1 状态是否涉及用户
授权码模式Web 应用(有后端)⭐⭐⭐⭐⭐✅ 推荐
简化模式纯前端 SPA⭐⭐❌ 不推荐
密码模式高度可信应用❌ 废弃
客户端凭证模式服务间调用⭐⭐⭐⭐✅ 推荐

五、PKCE:给授权码模式加一层装甲

如果你只了解了上面四种模式,面试官很可能追问:"那 PKCE 是什么?"

5.1 授权码模式的隐患

即使是授权码模式,也存在一个攻击场景:

  1. 攻击者在自己设备上发起授权流程,拿到 code
  2. 攻击者在受害者的应用回调 URL 中拦截到 code(比如通过恶意 App 注册了相同的回调 scheme)
  3. 攻击者用自己的 code 替换掉受害者的 code
  4. 受害者登录后,攻击者能通过自己的 code 关联到受害者的 session

这种攻击叫授权码拦截攻击(Authorization Code Interception Attack)

5.2 PKCE 做了什么

PKCE(Proof Key for Code Exchange,发音 "pixy")在授权码流程中增加了两个步骤:

客户端生成 code_verifier(随机字符串) │ ├── 对其做 SHA-256 哈希 → code_challenge │ ├── ① 发起授权时,把 code_challenge 传给授权服务器 │ ├── ② 用户授权后,客户端拿到 code │ └── ③ 用 code 换 token 时,把 code_verifier 也传过去 授权服务器验证:SHA256(code_verifier) == code_challenge ?

关键安全点在于:

  • code_challengecode_verifier的哈希值,无法反推
  • 只有最初生成code_verifier的客户端,才能在最后一步提供正确的code_verifier
  • 任何中间人截获了 code 也没用,因为它没有code_verifier

一句话总结 PKCE:我先把锁给你(code_challenge),等我拿到授权码后来换 token 时,我再证明我有钥匙(code_verifier)。换不了钥匙的人,拿到授权码也没用。

5.3 OAuth 2.1 的规定

在 OAuth 2.1 中,所有使用授权码模式的客户端都必须使用 PKCE。这不是可选增强,而是硬性要求。


六、Spring Security 实战:10 分钟搭一个 OAuth2 授权服务器

理论讲得差不多了,手该上键盘了。我们来搭一个可以跑的 OAuth2 授权服务器。

6.1 项目依赖

Spring Boot 3.x + Spring Security 6.x:

<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

6.2 授权服务器配置

@Configuration public class AuthorizationServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain( HttpSecurity http) throws Exception { // Spring Security 6 的 OAuth2 授权服务器配置 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); return http.formLogin(Customizer.withDefaults()).build(); } @Bean public RegisteredClientRepository registeredClientRepository() { // 注册一个客户端:咕咚笔记 RegisteredClient client = RegisteredClient .withId(UUID.randomUUID().toString()) .clientId("gudong-notes") // 客户端ID .clientSecret("{noop}secret-123456") // 客户端密钥(生产环境必须加密) .clientAuthenticationMethod( ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType( AuthorizationGrantType.AUTHORIZATION_CODE) // 授权码模式 .authorizationGrantType( AuthorizationGrantType.REFRESH_TOKEN) // 支持刷新令牌 .redirectUri("http://localhost:8081/login/oauth2/code/gudong") .scope("read") // 读权限 .scope("write") // 写权限 .clientSettings( ClientSettings.builder() .requireAuthorizationConsent(true) // 需要用户确认 .requireProofKey(true) // 强制PKCE .build()) .build(); return new InMemoryRegisteredClientRepository(client); } @Bean public JWKSource<SecurityContext> jwkSource() { // 生成 RSA 密钥对,用于签名 JWT KeyPair keyPair = generateRsaKey(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } private static KeyPair generateRsaKey() { try { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); return generator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } } }

6.3 资源服务器配置

@Configuration public class ResourceServerConfig { @Bean @Order(2) public SecurityFilterChain resourceServerSecurityFilterChain( HttpSecurity http) throws Exception { http .securityMatcher("/api/**") // 只保护 /api 路径 .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults()) // 使用 JWT 验证 ); return http.build(); } }

6.4 资源接口

@RestController @RequestMapping("/api") public class UserController { @GetMapping("/public/hello") public String publicHello() { return "这是公开接口,不需要 token"; } @GetMapping("/user/me") public Map<String, Object> currentUser( @AuthenticationPrincipal Jwt jwt) { // jwt 里直接可以拿到用户信息和权限 Map<String, Object> result = new HashMap<>(); result.put("subject", jwt.getSubject()); // 用户ID result.put("claims", jwt.getClaims()); // 所有声明 result.put("authorities", jwt.getClaimAsStringList("scope")); return result; } }

6.5 配置 application.yml

server: port: 8080 spring: security: user: name: zhangsan password: 123456 # 演示用,生产环境绝不这样写 logging: level: org.springframework.security: DEBUG # 调试时可以打开

6.6 验证流程

启动项目后,在浏览器里访问:

http://localhost:8080/oauth2/authorize ?response_type=code &client_id=gudong-notes &redirect_uri=http://localhost:8081/login/oauth2/code/gudong &scope=read &code_challenge=<PKCE_challenge> &code_challenge_method=S256

你会依次看到:

  1. Spring Security 默认的登录页面(输入 zhangsan / 123456)
  2. 授权确认页面("咕咚笔记申请获取你的 read 权限")
  3. 点击同意后,浏览器重定向到http://localhost:8081/...?code=xxxx

拿到了code,后端就可以用code+client_secret+code_verifier去换 Access Token 了:

curl -X POST http://localhost:8080/oauth2/token \ -H "Authorization: Basic $(echo -n 'gudong-notes:secret-123456' | base64)" \ -d "grant_type=authorization_code" \ -d "code=<刚才拿到的code>" \ -d "redirect_uri=http://localhost:8081/login/oauth2/code/gudong" \ -d "code_verifier=<PKCE_verifier>"

响应:

{ "access_token": "eyJhbGciOiJSUzI1NiJ9...", "refresh_token": "abc123...", "token_type": "Bearer", "expires_in": 3600, "scope": "read" }

七、细说 Token 安全:Access Token 过期了怎么办

7.1 Token 生命周期的设计哲学

Access Token: 有效期短(15分钟 ~ 2小时) ↓ 过期后 Refresh Token: 有效期长(7天 ~ 30天) ↓ 过期后 重新授权: 用户再次登录确认

为什么要这样分层?最小化暴露面。

  • Access Token 在每次 API 请求中都会发送,暴露在网络上,所以让它短命
  • Refresh Token 只在 token 过期时使用一次,暴露次数极少
  • 即使 Access Token 泄露,攻击者也只有十几分钟的窗口

7.2 Refresh Token 轮转(Rotation)

这是 OAuth 2.1 的最佳实践:每次用 Refresh Token 换新 Access Token 时,同时发放一个新的 Refresh Token,并把旧的 Refresh Token 作废。

请求:refresh_token_123 → 换取新的 access_token 响应:新的 access_token + 新的 refresh_token_456 服务端:refresh_token_123 标记为已使用

好处是:如果攻击者偷到了一个 Refresh Token,但用户使用它会触发轮转,那么当用户也用了一下这个 Refresh Token(或攻击者用完之后用户再用),服务端就会发现"这个 token 已经被用过了"——说明可能被泄露了,可以立刻作废该用户的所有 token。

7.3 Token 存储:前端到底该放哪?

这也是面试和实际开发中都绕不过的问题。

存储方式安全性XSS 风险CSRF 风险推荐度
localStorage❌ 可被 JS 读取不推荐
sessionStorage❌ 可被 JS 读取不推荐
Cookie(HttpOnly)✅ JS 无法读取需防 CSRF⭐ 推荐
内存变量刷新即丢失
BFF(Backend For Frontend)最高⭐⭐ 最推荐

最佳实践:Token 存在后端的 session 中,前端只持有一个 session cookie(HttpOnly + Secure + SameSite=Strict)。这就是 BFF 模式——前端根本不碰 token。


八、OAuth2 + JWT 深度融合:从原理到代码

8.1 JWT 的结构深入

一个 JWT 由三部分组成,用.分隔:

Header.Payload.Signature

Header(算法信息):

{ "alg": "RS256", "kid": "key-id-001", "typ": "JWT" }

Payload(声明信息):

{ "iss": "http://auth-server:8080", // 签发者 "sub": "zhangsan", // 主体(用户ID) "aud": "gudong-notes", // 受众(谁在使用这个token) "exp": 1718000000, // 过期时间 "iat": 1717996400, // 签发时间 "scope": "read write" // 权限范围 }

Signature(签名):

RSASHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), 私钥 )

8.2 资源服务器如何验证 JWT

资源服务器收到请求(Authorization: Bearer <jwt>) │ ├── 1. 解码 JWT Header,获取 kid(key id) │ ├── 2. 从授权服务器的 JWKS 端点获取公钥 │ GET http://auth-server:8080/oauth2/jwks │ ├── 3. 用公钥验证签名 │ 签名有效 → 内容未被篡改 │ ├── 4. 检查 Claims │ exp 是否过期 │ iss 是否是信任的签发者 │ aud 是否包含自己 │ └── 5. 全部通过 → 放行,把 JWT 信息注入 SecurityContext

Spring Security 的oauth2ResourceServer().jwt()配置自动完成了这一切。

8.3 手动解析 JWT(不用 Spring Security)

import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import java.security.PublicKey; public class JwtValidator { public static Claims validateAndParse(String token, PublicKey publicKey) { return Jwts.parser() .verifyWith(publicKey) // 用公钥验证签名 .build() .parseSignedClaims(token) // 解析 .getPayload(); // 获取载荷 } }

8.4 自定义 JWT 中的 Claims

有时候你需要在 JWT 中加入业务相关的信息:

@Bean public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() { return context -> { if (context.getTokenType().getValue().equals( OAuth2TokenType.ACCESS_TOKEN.getValue())) { // 往 JWT 里加自定义字段 context.getClaims().claims(claims -> { claims.put("department", "engineering"); // 部门 claims.put("role", "admin"); // 角色 }); } }; }

资源服务器侧读取:

@GetMapping("/admin/info") public Map<String, Object> adminInfo(@AuthenticationPrincipal Jwt jwt) { String department = jwt.getClaimAsString("department"); // "engineering" String role = jwt.getClaimAsString("role"); // "admin" // ... }

九、生产环境的七大安全最佳实践

这一节不讲代码,讲原则。每一条都是用血泪教训换来的。

9.1 始终使用 HTTPS

全文最重要的一条。如果授权请求走的是 HTTP,中间人可以截获code;如果 token 端点走的是 HTTP,中间人可以截获client_secret。一旦泄漏,全盘皆输。

生产环境强制 HTTPS,没有任何商量的余地。

9.2 Token 不存前端

我们在 7.3 节已经讨论过了。localStorage 存 token 等价于在大马路上贴银行卡密码。用 HttpOnly Cookie + BFF 模式。

9.3 最小权限原则

申请 scope 时只申请你真正需要的。一个只展示用户昵称的应用,不要申请"读取好友列表""发送私信"的权限。

9.4 验证 redirect_uri

授权服务器必须严格校验redirect_uri,只允许预先注册的地址。不校验的话,攻击者可以诱导用户授权后把 code 发到自己的服务器。

9.5 使用 state 参数防 CSRF

在发起 OAuth2 授权请求时,生成一个随机字符串作为state参数,并在回调时验证。这可以防止攻击者诱导用户点击一个已预授权的链接。

// 发起授权时 String state = UUID.randomUUID().toString(); session.setAttribute("oauth2_state", state); // 回调时验证 String returnedState = request.getParameter("state"); if (!returnedState.equals(session.getAttribute("oauth2_state"))) { throw new SecurityException("CSRF attack detected!"); }

9.6 启用 PKCE(即便是机密客户端)

虽然 PKCE 最初是为公开客户端(无法安全存储 client_secret 的应用)设计的,但 OAuth 2.1 要求所有客户端都使用。多一层防护总没错。

9.7 监控异常 Token 使用

  • 同一个 Refresh Token 被使用了两次 → 可能是泄露
  • 同一个 Access Token 在短时间内从不同 IP 使用 → 可能被盗用
  • 大量 token 签发请求 → 可能是暴力攻击

十、面试八股速通:高频问题一网打尽

10.1 OAuth2 的四种角色是什么?

资源拥有者、客户端、授权服务器、资源服务器。四个角色,一个都别少。

10.2 授权码模式为什么要先发 code 再换 token?

因为code通过浏览器传输(不安全),但换token需要client_secret(只存在后端)。这样确保了即使code泄露,没有client_secret也拿不到真正的 Access Token。

10.3 PKCE 解决了什么问题?原理是什么?

解决了授权码拦截攻击。原理是客户端先生成code_verifier和它的哈希值code_challenge,授权时传 challenge,换 token 时传 verifier,授权服务器验证两者匹配。只有最初发起授权请求的客户端才知道code_verifier

10.4 JWT 和 OAuth2 是什么关系?

OAuth2 是授权协议,定义了"怎么发、怎么用";JWT 是令牌格式,定义了"长什么样"。OAuth2 的 Access Token 可以用 JWT 格式,也可以用随机字符串(不透明令牌)。

10.5 Access Token 过期了怎么办?

用 Refresh Token 去换新的 Access Token,不需要用户重新登录。如果 Refresh Token 也过期了,才需要用户重新授权。

10.6 Refresh Token 轮转是什么?

每次使用 Refresh Token 时,授权服务器同时发放一个新的 Refresh Token,旧 token 作废。用于检测 Refresh Token 是否被泄露。

10.7 如何在微服务之间传递 token?

下游服务从请求头中获取 token,透传或由网关统一处理:

用户 → Gateway → 服务A → 服务B │ │ │ │ 验证token │ │ │ │ │ └── 透传 ───────────┘ Authorization Header

或者服务 A 使用客户端凭证模式,以自己的身份去调用服务 B。

10.8 OAuth2 和 SSO(单点登录)是什么关系?

OAuth2 本身是授权协议,不能直接用于认证。OpenID Connect (OIDC)是基于 OAuth2 的身份认证层,可以实现 SSO。简单理解:

  • OAuth2:给你一张门禁卡,你可以进房间
  • OIDC:给你一张门禁卡,卡上还印了你的名字和照片,可以证明你是谁

10.9 Cookie 和 Token 两种认证方式的区别?

维度Cookie + SessionJWT Token
状态服务端有状态(存 session)服务端无状态(token 自包含)
扩展性需要共享 session(如 Redis)天然支持分布式
注销简单(删 session)复杂(token 未过期仍有效,需黑名单)
移动端不友好友好(HTTP Header)
安全性需防 CSRF需防 XSS(若存前端)

写在最后

OAuth2 不是一个能"一眼看懂"的协议。它的规范 RFC 6749 是一份篇幅不小的文档,而 OAuth 2.1 还在持续演进中。但好消息是:对于绝大多数 Java 开发者来说,你不需要通读 RFC,你只需要搞懂本文覆盖的这些核心概念和原则。

回顾一下你的学习路径:

  1. 理解问题:OAuth2 是为了让第三方在不拿密码的情况下访问用户数据
  2. 记住角色:四个角色、四种模式
  3. 吃透流程:授权码模式为什么分两步、PKCE 做了什么
  4. 动手实践:Spring Security 搭一套授权/资源服务器
  5. 守住安全:七大实践,条条都是真金白银的经验