diff --git a/hhs/DEV/XSS与CSRF攻击/XSS与CSRF攻击.md b/hhs/DEV/XSS与CSRF攻击/XSS与CSRF攻击.md index 64c00b6..868338c 100644 --- a/hhs/DEV/XSS与CSRF攻击/XSS与CSRF攻击.md +++ b/hhs/DEV/XSS与CSRF攻击/XSS与CSRF攻击.md @@ -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]] diff --git a/hhs/DEV/鉴权策略/Cookie/Cookie.md b/hhs/DEV/鉴权策略/Cookie/Cookie.md new file mode 100644 index 0000000..0dae8e4 --- /dev/null +++ b/hhs/DEV/鉴权策略/Cookie/Cookie.md @@ -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 年全面落地。它直接影响嵌入式场景:支付回调、第三方登录、`