262 lines
9.8 KiB
Markdown
262 lines
9.8 KiB
Markdown
|
|
---
|
|||
|
|
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 权限模型]]
|