安全机制对照:state / 防伪 token / PKCE
这三个参数经常被放在一起问"到底防什么",但它们保护的目标、校验的时机、校验的人完全不同。 把三者并排放在本项目这条登录链路上:
| 机制 | 出现在哪 | 谁校验 | 一句话职责 |
|---|---|---|---|
state | ① 授权请求 → 回调 | 客户端自己 | 请求-响应配对 + 传递应用状态;防 CSRF 角色已弱化 |
防伪 token(__RequestVerificationToken) | ② 登录表单 POST(同意 / 登出同款) | OP | 防"伪造登录 → 在受害者浏览器里种下攻击者会话" |
PKCE(code_challenge / code_verifier) | ① 授权请求 + ④ 换 token | OP | 兜底:换 code 的人 == 发起 authorize 的人 |
1. state:客户端侧的"请求-响应配对 + 传递应用状态"
① 请求-响应配对。 并发发起多次登录时,回调必须和"当初那次请求"一一对应。 客户端拿回调里的 state 反查自己保存的请求上下文(PKCE code_verifier、returnUrl、nonce 等),查不到就丢弃。 本项目 demo 客户端用 oidc-client-ts:UserManager 封装了 state 管理,回调按 state 取回中间态。
② 传递应用状态。 /connect/authorize 与回调是两次独立请求,中间隔着登录页 / 同意页。 客户端想记住的东西(如"登录完回哪个页面")靠 state 这一程带过去、再带回来。
③ 防 CSRF 已弱化。 state 最初还负责防"伪造回调":攻击者把自己的授权码塞进受害者的回调, 诱导受害者客户端拿它换 token → 受害者以攻击者账号登录(一种登录 CSRF)。 在纯授权码流 + 强制 PKCE 下,这个攻击面被 PKCE 覆盖——攻击者没有受害者客户端的 code_verifier,code 换不出 token。 所以 state 的防 CSRF 职责不再重要,OAuth 2.0 的现行建议也把它的定位回到"传递应用状态"。
谁校验:客户端自己。OP 只把 state 原样回传(见 ① 授权请求 的实测 302),不检查、不解释。
2. 防伪 token:保护"种 cookie"这一刻
先完整走一遍攻击(登录 CSRF):
- 攻击者在恶意页面放一个自动提交的表单(受害者一访问该页就
submit()):action 指向POST /Account/Login,字段填攻击者自己的用户名密码; - 受害者的浏览器把这个表单跨站提交过去,凭据校验通过 → 在受害者浏览器里种下攻击者的会话 cookie;
- 攻击在这一刻已经成功——不需要受害者再点任何"登录"按钮,也不走 OAuth 前端通道;
- 受害者之后继续用这个浏览器(下单、填资料……),请求带的全是攻击者的 cookie,所有操作落在攻击者账号下。
防伪 token 堵住的正是第 2 步:服务端要求表单携带只有同源页面才拿得到的令牌 (隐藏字段 + HttpOnly cookie 配对,见 ② 登录),跨站表单拿不到 → 请求被拒。
谁校验:OP。本项目在 AccountController 的表单 POST 端点上标注 [ValidateAntiForgeryToken], 失败是 400 空响应体([ValidateAntiForgeryToken],与 Identity UI 一致);同意 / 登出端点同样用该 attribute。
它不属于 OAuth 规范
防伪 token 是"表单 POST 端点"自身的 CSRF 防御——保护的是**"种 cookie"这个动作**,而不是授权码流程本身。
3. PKCE:OP 在换 token 时兜底"换 code 的人 == 发起 authorize 的人"
① 兜底。 在纯授权码流 + 强制 PKCE 场景下,它挡住"code 被偷(或被塞)后拿去换 token", 也覆盖了 state 原本的防 CSRF 功能(见上一节)。
② OP 校验。 校验发生在 POST /connect/token,由 OP(本项目是 OpenIddict)完成,客户端不做判断。
③ 核心机制。 authorize 时客户端只给出哈希 code_challenge;换 token 时才给出原文 code_verifier; OP 比对 BASE64URL(SHA256(code_verifier)) == code_challenge。 关键点:code_verifier 从不经过 redirect_uri——攻击者能偷到 code(它走 URL、浏览器历史和 Referer), 但偷不到 verifier(它只出现在"客户端 → token 端点"的 POST 里)→ 偷来的 code 换不出 token。
谁校验:OP。本项目注册客户端时用 Requirements.Features.ProofKeyForCodeExchange 强制 PKCE; 失败见 坑速查:ID2054(没带 PKCE)/ ID2052(verifier 与 challenge 不匹配)。
校验方速查
| 机制 | 校验方 | 校验时机 | 失败表现 |
|---|---|---|---|
state | 客户端 | 回调返回后 | 客户端丢弃该次回调(配对失败,重新登录) |
| 防伪 token | OP | 处理表单 POST 时 | 400 空响应体([ValidateAntiForgeryToken];登录页表单不再回 302 error=) |
| PKCE | OP | POST /connect/token | invalid_grant(ID2054 / ID2052) |
常见理解偏差(本页的由来)
| # | 容易这样理解 | 更准确的说法 |
|---|---|---|
| 1 | state"保证并发一致性" | 拆成两件事:请求-响应配对 + 传递应用状态;防 CSRF 已被 PKCE 弱化 |
| 2 | 防伪 token 的攻击要在"诱导用户点击登录"之后才完成 | 不需要后续点击——种下 cookie 的那一刻攻击已经成功,且不走 OAuth |
| 3 | PKCE 只是"兜底" | 保留"兜底"定位,但要具体:兜的是 code 被偷换 token,并覆盖 state 的防 CSRF;code_verifier 从不经过 redirect_uri |