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

478 lines
19 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: [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 攻防