Files
cs-note/hhs/DEV/鉴权策略/JWT/JWT.md
T
2026-05-24 11:42:38 +08:00

447 lines
16 KiB
Markdown
Raw 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: [JWT, token, authentication, security, backend]
create time: 2026-05-02 16:30
---
# JWT — JSON Web Token
## 概述
JWT(JSON Web Token, RFC 7519)是一种**开放标准**,用于在各方之间安全地传输声明(claims)。在 Web 开发中,它最常被用作无状态身份认证的载体——服务器签发一个签名的令牌,客户端在后续请求中携带该令牌来证明自己的身份。
> [!tip] 为什么选 JWT 而不是 Session?
> Session 需要服务端存储用户状态,在分布式或微服务架构中必须借助 Redis 等共享存储;JWT 将所有信息打包在令牌本身,服务端只需验签即可,天然适合横向扩展的无状态场景。
> [!question] 思考
> 如果 JWT 把所有信息(包括敏感数据)都打包在令牌里,那和明文 Cookie 有什么区别?
> —— 关键差异在于 **签名机制**。JWT 附带数字签名,服务端可以验证令牌是否被篡改。但注意:默认情况下 Payload 只是 Base64 编码,并非加密!
## 核心结构
JWT 由三段 Base64Url 编码的字符串用 `.` 连接而成:
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. header (头部)
eyJpc3MiOiJleGFtcGxlLmNvbSIsInVzZXJJZCI6NDJ9. payload (载荷)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c signature (签名)
```
### Header — 元信息
声明签名算法和令牌类型:
```json
{
"alg": "HS256",
"typ": "JWT"
}
```
常用算法对比:
| 算法 | 底层机制 | 密钥类型 | 典型场景 |
| ----------------- | ------------------ | -------------- | -------------- |
| `HS256` / `HS512` | HMAC + SHA-256/512 | 对称(同一密钥签名+验签) | 小型项目、快速原型 |
| `RS256` | RSA + SHA-256 | 非对称(私钥签名,公钥验签) | 生产环境、多服务验签 |
| `ES256` | ECDSA + P-256 | 非对称(密钥更短,性能更好) | 移动端、IoT 资源受限场景 |
### Payload — 声明数据
承载要传递的信息,分为三类标准字段和自定义字段:
```json
{
"iss": "auth.example.com", // Issuer — 签发者
"iat": 1714627200, // Issued At — 签发时间(Unix 时间戳)
"exp": 1714713600, // Expiration — 过期时间
"sub": "user_42", // Subject — 主题(通常是用户 ID)
"jti": "550e8400-e29b-41d4", // JWT ID — 唯一标识,用于防重放和黑名单
"role": "admin", // 自定义字段
"permissions": ["read", "write"] // 自定义字段
}
```
> [!tip] Payload 到底存什么?
> 存的是**身份标识 + 授权上下文**——让服务端拿到令牌后能快速判断"你是谁、你能干什么",而不用每次都查库。典型内容:
>
> | 类别 | 字段示例 | 说明 |
> |------|----------|------|
> | 身份标识 | `sub`, `iss` | 谁签发的、令牌属于谁 |
> | 时间控制 | `iat`, `exp`, `nbf` | 签发 / 过期 / 生效时间 |
> | 授权信息 | `role`, `permissions` | 角色与权限,减少查库次数 |
> | 防重放 | `jti` | 唯一标识,配合黑名单做一次性校验 |
> | 业务元数据 | `tenantId`, `orgId` | 多租户场景的轻量上下文 |
>
> **核心原则**:存标识符和元数据,不是数据本身。密码、手机号、身份证号——通过 `sub` 对应的用户 ID 去数据库查。
> [!warning] 安全红线
> **Payload 绝对不要存放密码、身份证号等敏感信息!** Base64 编码不等于加密,任何人拿到令牌都可以解码看到内容。如需保密,应使用 JWE(JWT 加密格式)或在服务端查库获取。
### Signature — 签名验证
签名确保令牌在传输过程中未被篡改:
```
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
```
对于非对称算法(RS256),则使用私钥签名、公钥验签。这也是微服务架构中的推荐方案——只有鉴权服务持有私钥,其他服务用公钥验签即可。
```mermaid
flowchart LR
subgraph Sign["签名阶段"]
A[Header + Payload] --> B["HMAC(Raw string, Secret)"]
B --> C[Signature]
A -.-> D["最终令牌 = Header.Payload.Signature"]
end
subgraph Verify["验签阶段"]
E["收到的令牌"] --> F["提取前两段拼接"]
F --> G["HMAC(Raw string, Secret)"]
G --> H["本地签名"]
I["原始签名段"] --> J{"一致?"}
H --> J
J -->|"是"| K["令牌合法"]
J -->|"否"| L["被篡改"]
end
Sign --> |"下发完整令牌"| Client["Client"]
Client --> |"每次请求携带"| Verify
```
## 认证流程
典型的 JWT 认证过程涉及四个角色间的交互:
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: 1. POST /login (username + password)
S->>DB: 2. 校验凭据
DB-->>S: 3. 用户信息
alt 凭据正确
S->>S: 4. 生成 JWT (带 exp, iss, sub)
S-->>C: 5. 返回 { accessToken, expiresIn }
C->>S: 6. 后续请求带 Authorization: Bearer <token>
S->>S: 7. 验签 + 检查 exp
alt 令牌有效
S-->>C: 8. 业务数据
else 令牌无效
S-->>C: 9. 401 Unauthorized
end
else 凭据错误
S-->>C: 10. 401 Unauthorized
end
```
> [!note] Access Token vs Refresh Token
> 实践中通常采用双令牌策略:
> - **Access Token**:生命周期短(如 15 分钟),放在请求头中,用于访问 API
> - **Refresh Token**:生命周期长(如 7 天),存储在 HTTP-only Cookie 中,用于换取新的 Access Token
>
> 这样即使 Access Token 泄露,影响范围也有限;而 Refresh Token 存放在 Cookie 中不受 XSS 直接窃取。
## 代码示例
### Go:签发与验证 JWT
使用 `golang-jwt/jwt/v5`:
```go
import "github.com/golang-jwt/jwt/v5"
// 签发令牌
func GenerateToken(userID uint, role string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"role": role,
"iat": time.Now().Unix(),
"exp": time.Now().Add(15 * time.Minute).Unix(), // 15 分钟过期
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte("your-secret-key"))
}
// 验证令牌
func ValidateToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 确保算法符合预期,防止算法替换攻击
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte("your-secret-key"), nil
})
}
```
### Gin 中间件:JWT 认证
结合之前 `[[hzh/GIN/3-middleware/jwt-auth-qa]]` 讨论的模式:
```go
func JWTAuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
// 提取 Bearer prefix
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid format"})
return
}
token, err := ValidateToken(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// 存入 Context,供下游 handler 使用
c.Set("userID", token.Claims.(jwt.MapClaims)["sub"])
c.Next()
}
}
```
### React:前端使用 JWT
```tsx
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
// 自动在请求头注入 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 统一处理 401,触发刷新或登出
api.interceptors.response.use(
(res) => res,
async (err) => {
if (err.response?.status === 401) {
// 尝试刷新 Token
const newToken = await refreshAccessToken();
if (newToken) {
localStorage.setItem('accessToken', newToken);
err.config.headers.Authorization = `Bearer ${newToken}`;
return api.request(err.config);
}
window.location.href = '/login'; // 重定向到登录页
}
return Promise.reject(err);
},
);
```
> [!example] Token 存储方式对比
>
> | 存储位置 | XSS 防护 | CSRF 防护 | 后端可读 | 适用场景 |
> |----------|----------|-----------|----------|----------|
> | **localStorage** | ❌ 易被 XSS 读取 | ✅ 不自动发送 | ❌ 需手动设置 | SPA + 有 XSRF 防护 |
> | **Memory** | ✅ 不被 XSS 直接读 | ❌ — | ❌ 需手动设置 | 高安全要求(刷新即丢失) |
> | **HTTP-only Cookie** | ✅ 不被 JS 访问 | ⚠️ 需额外防护 | ✅ 自动发送 | 服务端渲染 / 传统 Web |
## 常见安全问题与防御
### 1. 算法降级攻击(Alg: None)
攻击者将 `alg` 改为 `none`,试图绕过验签:
```json
{"alg": "none", "typ": "JWT"}
```
**防御**:始终在白名单中指定允许的算法:
```go
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
```
### 2. 密钥泄露与弱密钥
使用硬编码或弱密钥会让攻击者轻易伪造令牌。
**防御**:
- 密钥长度 ≥ 256 bit(HS256 要求至少 32 字节)
- 通过环境变量或密钥管理服务(Vault、AWS KMS)注入
- 定期轮换密钥
### 3. 过期时间设计不合理
过长的 `exp` 增加令牌泄露后的风险窗口;过短的 `exp` 又导致频繁刷新。
**建议**:
- Access Token:5 ~ 30 分钟
- Refresh Token:7 ~ 30 天
- 配合黑名单机制实现主动注销(见下文)
### 4. 重放攻击
攻击者截取合法的 JWT 令牌后反复使用。
**防御**:
- 短生命周期 Access Token
- 添加 `jti`(JWT ID)唯一标识,配合黑名单做一次性校验
- 在高价值操作中引入二次确认(如修改密码、支付)
## 进阶:Token 黑名单与撤销
JWT 的一个固有缺陷:**一旦签发,无法主动作废**(除非查询数据库)。在生产环境中,这意味著用户登出或密码修改后,已泄露的令牌仍可在过期前使用。
```mermaid
flowchart TD
subgraph Issue["签发"]
A["登录成功"] --> B["生成 JWT jti + exp + signed"]
B --> C["返回给客户端"]
end
subgraph Use["正常使用"]
D["请求携带 JWT"] --> E["验签 + 查黑名单"]
E -->|"未在册"| F["放行"]
E -->|"已在册"| G["拒绝"]
end
subgraph Revoke["撤销操作"]
H["用户登出或改密"] --> I["将jti写入黑名单Redis"]
I --> J["Set TTL = exp - iat"]
end
C --> D
H --> I
```
Go 实现黑名单校验的关键逻辑:
```go
func Blacklisted(ctx context.Context, jti string) bool {
key := "jwt:blacklist:" + jti
exists, _ := redis.Get(ctx, key).Result()
return exists == "true"
}
func InvalidateToken(ctx context.Context, jti string, ttl time.Duration) {
redis.Set(ctx, "jwt:blacklist:"+jti, "true", ttl)
}
```
> [!tip] 替代方案:短期 Token + 版本控制
> 为简化实现,可以给每个用户分配一个 `tokenVersion`(存在数据库中)。签发令牌时将版本号写入 `sub` 或自定义字段。用户登出时只递增版本号,所有旧版令牌在验签通过后对比数据库版本号即可识别为失效。无需维护黑名单集合。
## 什么是 Session?
理解 JWT 之前,有必要先搞清楚它要"替代"的对象——Session。
Session(会话)是一种**有状态**的认证机制。核心思路很朴素:客户端只存一把"钥匙"(`sessionId`),用户是谁、什么角色、登录了多久……**所有状态全部存在服务端**。
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant Store as Session Store
C->>S: 1. POST /login (username + password)
S->>S: "2. 校验通过 生成 sessionId"
S->>Store: "3. 存储 sessionId 到用户数据的映射"
S-->>C: 4. Set-Cookie: sessionId=abc123
Note over C: "浏览器自动保存这个 Cookie"
C->>S: "5. 后续请求自动带 Cookie: sessionId=abc123"
S->>Store: "6. 查 Store: abc123 对应谁"
Store-->>S: "7. 返回用户信息"
S-->>C: "8. 返回业务数据"
```
> [!question] Session 每次请求都要查 Store,性能会不会很差?
> 单次查询开销很小(尤其是 Redis 这类内存数据库,微秒级)。真正的瓶颈在于**水平扩展**——当服务器从 1 台扩到 10 台,每台都要能访问同一份 Session 数据,这就必须引入 Redis 等共享存储,架构复杂度随之上升。这也正是 JWT "无状态"优势凸显的地方。
**三个关键特征**:
| 特征 | 说明 |
|------|------|
| **客户端只存 ID** | 浏览器通过 Cookie 自动携带 `sessionId`,它本身不含任何业务信息,只是一把钥匙 |
| **服务端存全部状态** | 用户身份、角色、权限等全部保存在服务端的 Session Store(内存 / Redis / 数据库) |
| **每次请求需查库** | 服务端收到 `sessionId` 后,必须去 Store 查询,才能识别"这个请求是谁发的" |
> [!tip] 一句话类比
> Session 像**存包柜**——你拿着号牌走人,东西存在柜子里;JWT 像**护照**——所有信息都写在证件本身。前者靠柜子(服务端存储)管状态,后者靠证件本身(令牌签名)证明身份。
### Session vs JWT:选型决策
| 维度 | Session | JWT |
|------|---------|-----|
| 服务端存储 | 需要(内存/Redis) | 不需要(仅验签) |
| 实时注销 | ✅ 立即可效 | ❌ 需黑名单或短轮询 |
| 跨域支持 | 依赖 Cookie,需处理 CORS | 任意头,天然跨域 |
| 可扩展性 | 受限于共享存储 | 天然无状态,易于水平扩展 |
| 令牌体积 | 极小(仅 sessionId) | 较大(含 payload) |
| 客户端操控 | ❌ 不可见 | ✅ 可解码(但不可篡改) |
> [!info] 如何选型?
>
> - **单体应用 / 传统 MVC** → Session(简单可靠,原生支持好)
> - **微服务 / 前后端分离 / 移动端** → JWT(无状态,多服务共享密钥即可)
> - **混合模式** → JWT 作为内部服务间通信凭证,外部入口仍可搭配 Session
## JWT vs Cookie:常见误区
> [!question] JWT 和 Cookie 是对立的吗?
> 不是!它们根本不在同一层——**JWT 是令牌格式(数据结构),Cookie 是存储/传输机制(浏览器行为)**。两者完全可以组合使用。
| 维度 | JWT | Cookie |
|------|-----|--------|
| 本质 | 令牌格式(数据载体) | 存储与传输机制(浏览器行为) |
| 存储位置 | localStorage / Memory / Cookie 均可 | 浏览器自动管理 |
| 自动发送 | ❌ 需手动设置 `Authorization` 头 | ✅ 浏览器每次请求自动携带 |
| 跨域 | ✅ 天然支持(手动设头即可) | ⚠️ 受 SameSite / Domain 策略限制 |
| XSS 防护 | 存 localStorage 时易被 JS 读取 | HTTP-only Cookie 无法被 JS 访问 |
| CSRF 防护 | ✅ 不自动发送,天然免疫 | ⚠️ 自动发送,需额外 CSRF Token |
| 大小限制 | 无硬限制(建议精简) | 单个约 4KB |
| 数据可见性 | Base64 可解码(但签名防篡改) | 明文存储(除非加密) |
```mermaid
flowchart LR
A["JWT 是什么?"] --> B["令牌格式 — 数据结构"]
C["Cookie 是什么?"] --> D["传输机制 — 浏览器行为"]
B --> E["存在哪里?"]
E --> F["localStorage"]
E --> G["Cookie (HTTP-only)"]
E --> H["Memory (JS 变量)"]
D --> I["自动随请求发送"]
I --> J["SameSite 限制"]
I --> K["Domain/Path 限制"]
style A fill:#e8f4f8
style C fill:#fde8e8
```
> [!tip] 生产环境的常见组合策略
> - **SPA 前后端分离** → JWT 存 localStorage,手动注入 `Authorization` 头
> - **需要兼顾安全** → JWT 存 HTTP-only Cookie,既防 XSS 又自动传输
> - **最佳实践** → Access Token 用 JWT + `Authorization` 头(短期),Refresh Token 用 HTTP-only Cookie(长期)
## 关联笔记
- [[JWT vs Cookie]] — JWT 与 Cookie 认证方案全景对比
- [[hzh/GIN/3-middleware/jwt-auth-qa]] — JWT 中间件中 Context 数据安全分析
- [[hzh/GIN/3-middleware]] — Gin 中间件完整机制
- [[hzh/DEV/跨域问题调试]] — CORS 配置与跨域场景