Files

262 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [pattern/auth, oauth2, jwt, token-authentication, refresh-token]
create time: 2026-08-08 16:00
update time: 2026-08-08 16:00
---
# OAuth2 与 JWT
## 概述
OAuth2 是业界标准的授权框架,定义了四种获取访问令牌的流程。JWT(JSON Web Token)则是令牌本身的一种通用格式。二者配合使用构成了现代互联网最常见的认证/授权方案:OAuth2 负责"你是谁、你能做什么",JWT 负责"如何传递这些信息"。
## OAuth2 四种授权模式详解
### Authorization Code — 最安全的服务端模式
**适用场景**:传统 Web 应用、后端服务调用第三方 API。
```mermaid
sequenceDiagram
participant User as 用户浏览器
participant Client as 客户端应用
participant Auth as 授权服务器
participant Resource as 资源服务器
User->>Client: 点击"用 Google 登录"
Client->>User: 重定向到授权端点<br/>?response_type=code&client_id=&redirect_uri=&scope=
User->>Auth: 登录并授权
Auth->>User: 重定向回 redirect_uri?code=XXX
User->>Client: 携带 authorization code
Client->>Auth: POST /token (code + client_secret)
Auth-->>Client: access_token + id_token + refresh_token
Client->>Resource: GET /api/profile (Bearer token)
Resource-->>Client: 用户数据
```
核心要点:
- **code 是一次性的短期凭证**(通常有效期 5 分钟),换取 token 的过程在后端完成,浏览器拿不到 token。
- `client_secret` 只存在于服务端通信中,前端 SPA 不应该持有它。
- 回调时建议校验 `state` 参数防止 CSRF。
> [!NOTE]
> PKCE 扩展:对于无法安全存储 `client_secret` 的场景(移动 App、SPA),RFC 7636 定义的 PKCE(Proof Key for Code Exchange)通过 `code_verifier` / `code_challenge` 对来替代 `client_secret`,使 Authorization Code 对所有客户端类型都是安全的。
### Implicit — 已废弃
曾用于纯前端 SPA 场景,直接返回 access_token:
```
redirect_uri?access_token=XXX&expires_in=3600
```
问题在于 **token 暴露在前端**——可以存入 localStorage、被 XSS 窃取、无法撤销。IETF 已在 RFC 6819 中明确标记为过时,推荐使用 Authorization Code + PKCE 替代。
### Password — 不推荐的第一方应用模式
用户直接向授权服务器提交用户名和密码:
```http
POST /token
grant_type=password&username=user@example.com&password=secret
```
优点是实现简单。但问题是:
- 客户端等同于拿到了用户的密码,**违背了 OAuth 的信任委派原则**。
- 无法在不换密码的前提下撤销特定客户端的访问。
- 仅适用于你完全信任的第一方应用。
### Client Credentials — 机器间通信
没有用户参与,客户端用自己的身份申请令牌:
```http
POST /token
grant_type=client_credentials&client_id=&client_secret=
```
典型场景:后台定时任务、微服务之间的内部调用、CI/CD 流水线拉取制品仓库。
### 四种模式对比
| 维度 | Authorization Code | Implicit | Password | Client Credentials |
|------|-------------------|----------|----------|-------------------|
| 涉及主体 | 用户 + 客户端 | 用户 + 客户端 | 用户 + 客户端 | 客户端 |
| token 暴露风险 | 低(后端交换) | **高**(前端可见) | **极高** | 无(无用户) |
| 是否需要 client_secret | 推荐(PKCE 可选) | 不适用 | 不适用 | **必需** |
| 当前状态 | 标准推荐 | 已废弃 | 受限使用 | 标准推荐 |
| 适用环境 | Web / 移动端 | ~~SPA~~ → 转 Code+PKCE | 第一方 App | 服务端互信 |
## JWT 结构
JWT 由三段 Base64Url 编码的字符串用 `.` 拼接而成:
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. . cCpliwZISlC_ddV2aOKXVuUFtF4QdBSgOzAWo0XihXA
Header Payload Signature
```
### Header
```json
{
"alg": "RS256",
"typ": "JWT"
}
```
指定签名算法和令牌类型。
### Payload(Claim 集)
```json
{
"sub": "user_12345",
"name": "Alice",
"role": "admin",
"permissions": ["order:read", "order:write", "bucket:manage"],
"iat": 1690000000,
"exp": 1690003600,
"jti": "uuid-v4-here"
}
```
标准保留声明:
- `iss`(Issuer)— 签发者
- `sub`(Subject)— 主题/用户标识
- `aud`(Audience)— 接收方
- `exp`(Expiration)— 过期时间
- `iat`(Issued At)— 签发时间
- `jti`(JWT ID)— 唯一标识,支持撤销
> [!WARNING]
> 绝对不要把敏感信息(密码、身份证号)放在 JWT payload 中。JWT 只是 Base64 编码而非加密,任何人都可以解码阅读。需要保密就使用 JWE(加密型 JWT)。
### Header + Payload → Signature
```
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
```
或用 RSA 私钥签名(RS256):
```
RSA-SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), privateKey)
```
## HS256 vs RS256 签名算法选择
| 维度 | HS256(对称) | RS256(非对称) |
|------|--------------|----------------|
| 密钥 | 单一 shared secret | 公钥公开 / 私钥保密 |
| 验证方 | 必须持有密钥 | 只需公钥 |
| 适用场景 | 单体应用、内网服务 | 微服务、第三方集成 |
| 撤销能力 | 几乎不可撤销 | 可通过黑名单或短生命周期缓解 |
| 性能 | 更快(AES 级别运算) | 略慢(RSA 运算) |
| 安全风险 | 密钥泄露 = 所有 token 可伪造 | 仅私钥泄露才有危险 |
> [!TIP]
> 面试常考点:为什么微服务架构推荐 RS256?因为每个微服务只需要验证公钥而不需要持有签发私钥,即使某个服务被攻陷,攻击者也无法伪造其他服务的 token。HS256 要求所有服务共享同一个密钥,任何一台机器的密钥泄漏都会导致全局信任崩溃。
## Refresh Token 轮换机制
Access Token 生命周期短(5~15 分钟),不适合频繁让用户重新登录。Refresh Token 用于无声续期,但必须设计得当以防止重放攻击。
### 旋转流程
```mermaid
sequenceDiagram
participant Client as 客户端
participant Auth as 认证服务器
participant Store as Token Store
Note over Client,Store: 首次登录
Auth->>Store: 存储 refresh_token (RT1) <-> user_id
Auth-->>Client: RT1 + AT1(短效)
Note over Client,Store: Access Token 过期后刷新
Client->>Auth: POST /refresh {refresh_token: RT1}
Auth->>Store: 验证 RT1 有效性且未被吊销
Store-->>Auth: 匹配成功
Auth->>Store: 删除 RT1,插入 RT2
Auth-->>Client: RT2 + AT2
Note over Client,Store: RT2 再次用于刷新(正常)
Client->>Auth: POST /refresh {refresh_token: RT2}
Auth->>Store: 验证 RT2...
Note over Client,Store: RT1 被重放(攻击者窃取了旧 RT1)
Client->>Auth: POST /refresh {refresh_token: RT1}
Auth->>Store: 查找 RT1 → 不存在!
Auth-->>Client: 401 Unauthorized ⚠️ 检测到重放
```
关键设计决策:
1. **每次刷新都生成新的 Refresh Token**(Rotation),旧的立即失效。
2. **如果同一个 Refresh Token 出现两次**:第一次按正常流程处理(生成新 Token),第二次判定为窃听攻击,同时**级联销毁该用户的所有 token 并要求重新登录**。
3. Refresh Token 比 Access Token 更严格地保管——存放在 HttpOnly Cookie 而非 localStorage。
### 代码示例(Go 伪代码)
```go
func (m *AuthManager) RotateRefreshToken(ctx context.Context, rt string) (newAT, newRT string, err error) {
record, err := m.store.GetRefreshToken(ctx, rt)
if err != nil {
return "", "", fmt.Errorf("invalid refresh token")
}
// 幂等检查:如果这个 RT 已经被使用过,触发全量吊销
if record.ConsumedAt != nil {
m.store.RevokeAllUserTokens(ctx, record.UserID)
return "", "", ErrSuspiciousReplay
}
// 标记旧 RT 已消费,创建新 RT
now := time.Now()
store.UpdateRefreshTokenConsumed(ctx, rt, now)
newRT = generateToken(record.UserID, store.RandomNonce())
return m.issueAccessToken(ctx, record.UserID), newRT, nil
}
```
## Token 黑名单 vs Token 白名单
| 方案 | 实现方式 | 优点 | 缺点 |
|------|---------|------|------|
| **黑名单** | 撤销时将 JWT jti 加入 Redis Set | 实现简单,无需改 Token 结构 | 随时间累积占用空间,需定期清理 |
| **白名单** | 服务端保存每条合法会话记录 | 天然支持细粒度控制(设备管理、远程注销) | 每次请求都要查库/缓存,有性能开销 |
实际生产中常用混合策略:
- **短生命周期 Access Token**(5 分钟)+ 本地校验(无需网络查询)
- **Refresh Token 存储在 Redis**,每次刷新做一次性校验
- **特殊场景下的黑名单**用于主动登出、密码修改后的批量踢人
## 实践场景:完整认证流
```mermaid
sequenceDiagram
participant Client as 前端
participant AuthSrv as 认证服务
participant TokenSrv as Token 服务
participant API as 业务 API
Client->>AuthSrv: POST /login (username/password)
AuthSrv->>AuthSrv: 验证凭据
AuthSrv->>TokenSrv: 签发 RS256-signed JWT
TokenSrv-->>AuthSrv: AT + RT
AuthSrv-->>Client: 设置 Cookie(AT, RT) + 302 /dashboard
Note over Client,API: 后续请求
Client->>API: GET /api/orders (Cookie: AT)
API->>TokenSrv: 验签 AT(公钥本地验证,零网络调用)
TokenSrv-->>API: valid {sub: user_123, role: admin}
API-->>Client: JSON response
```
> [!NOTE]
> 一个常见的误解:JWT ≠ 会话管理。JWT 只是一种**自包含的令牌格式**,它本身不提供会话管理能力。是否用数据库存 session、是否支持远程撤销、是否限制单设备登录——这些都是独立的架构决策,与选用 JWT 还是 opaque token 无关。
## 关联笔记
- [[RBAC 权限模型]]