Files
cs-note/hhs/DEV/鉴权策略/Cookie/Cookie.md
T

478 lines
19 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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 攻防