朋友老周被一个需求卡住了:新系统要访问老系统的用户数据,两边的鉴权怎么搞?"总不能把老系统密码直接发给新系统吧?"我说你这场景有现成的实现方案------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 这一步?
这是面试中的高频追问。答案是:安全------确保令牌只发给真正的后端服务器,而不是浏览器。
具体来说:
code是通过浏览器重定向传递的,会暴露在 URL 中- 如果用
code直接当令牌用,任何一个看到 URL 的人都能拿它访问数据 - 但
code是一次性的,而且换 token 时需要带上client_secret(客户端密钥) client_secret只存在后端服务器上,浏览器里没有- 所以即使别人截获了
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 授权码模式的隐患
即使是授权码模式,也存在一个攻击场景:
- 攻击者在自己设备上发起授权流程,拿到 code
- 攻击者在受害者的应用回调 URL 中拦截到 code(比如通过恶意 App 注册了相同的回调 scheme)
- 攻击者用自己的 code 替换掉受害者的 code
- 受害者登录后,攻击者能通过自己的 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_challenge是code_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:
XML
<!-- 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 授权服务器配置
java
@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 资源服务器配置
java
@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 资源接口
java
@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
java
server:
port: 8080
spring:
security:
user:
name: zhangsan
password: 123456 # 演示用,生产环境绝不这样写
logging:
level:
org.springframework.security: DEBUG # 调试时可以打开
6.6 验证流程
启动项目后,在浏览器里访问:
java
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
你会依次看到:
- Spring Security 默认的登录页面(输入 zhangsan / 123456)
- 授权确认页面("咕咚笔记申请获取你的 read 权限")
- 点击同意后,浏览器重定向到
http://localhost:8081/...?code=xxxx
拿到了 code,后端就可以用 code + client_secret + code_verifier 去换 Access Token 了:
bash
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)
java
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"); // 角色
});
}
};
}
资源服务器侧读取:
java
@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 参数,并在回调时验证。这可以防止攻击者诱导用户点击一个已预授权的链接。
java
// 发起授权时
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,透传或由网关统一处理:
java
用户 → 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 + Session | JWT Token |
|---|---|---|
| 状态 | 服务端有状态(存 session) | 服务端无状态(token 自包含) |
| 扩展性 | 需要共享 session(如 Redis) | 天然支持分布式 |
| 注销 | 简单(删 session) | 复杂(token 未过期仍有效,需黑名单) |
| 移动端 | 不友好 | 友好(HTTP Header) |
| 安全性 | 需防 CSRF | 需防 XSS(若存前端) |
写在最后
OAuth2 不是一个能"一眼看懂"的协议。它的规范 RFC 6749 是一份篇幅不小的文档,而 OAuth 2.1 还在持续演进中。但好消息是:对于绝大多数 Java 开发者来说,你不需要通读 RFC,你只需要搞懂本文覆盖的这些核心概念和原则。
回顾一下你的学习路径:
- 理解问题:OAuth2 是为了让第三方在不拿密码的情况下访问用户数据
- 记住角色:四个角色、四种模式
- 吃透流程:授权码模式为什么分两步、PKCE 做了什么
- 动手实践:Spring Security 搭一套授权/资源服务器
- 守住安全:七大实践,条条都是真金白银的经验