③ 同意流程与 Accept / Deny
登录之后浏览器回到 /connect/authorize,这次已认证 → 进入同意判定。这是整条链路里分支最多、最值得细读的一段。
先查"有没有永久授权"
var authorizations = await authorizationManager.FindAsync(
subject: await userManager.GetUserIdAsync(user),
client : await applicationManager.GetIdAsync(application),
status : Statuses.Valid,
type : AuthorizationTypes.Permanent,
scopes : request.GetScopes()).ToListAsync();subject= Identity 用户 Id(AspNetUsers.Id)client=oi_app里该客户端的内部 Idtype = permanent+status = valid+scopes必须覆盖本次请求的全部 scope
命中的就是 oi_ath 表里"用户对这次请求的这些 scope 已经同意过"的记录。
分支矩阵(switch (ConsentType))
| ConsentType | 有永久授权? | prompt=consent? | 结果 |
|---|---|---|---|
external | 否 | — | Forbid → consent_required(外部授权必须由管理员预先授予) |
implicit | 无所谓 | — | 直接签 code |
external | 是 | — | 直接签 code |
explicit | 是 | 否 | 直接签 code(这就是"第二次登录不再弹同意页") |
explicit | 是 | 是 | 弹同意页(客户端强制再确认) |
explicit / systematic | 否 | prompt=none | Forbid → consent_required(静默模式不能弹界面) |
| 其他 | — | — | 弹同意页 ← 本项目的默认路径(首次授权) |
对照代码:
case ConsentTypes.External when authorizations.Count is 0: // 报错
case ConsentTypes.Implicit:
case ConsentTypes.External when authorizations.Count is not 0:
case ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPromptValue(PromptValues.Consent):
// ↑ 免同意:构造 claims → SetScopes/SetResources → 拿/建永久授权 → 签 code
case ConsentTypes.Explicit when request.HasPromptValue(PromptValues.None):
case ConsentTypes.Systematic when request.HasPromptValue(PromptValues.None):
// ↑ 报错 consent_required
default:
// ↑ React 同意页本项目客户端 client-demo 的 ConsentType = Explicit → 首次走 default 分支。
免同意分支:构造身份并签 code
该分支(Authorize 的免同意 case 与 Accept 的同意提交)共用私有方法 CreateUserPrincipalAsync(...)(用户 / 客户端 / 永久授权由 LoadAuthorizationContextAsync(...) 一次取好),核心步骤如下:
// CreateUserPrincipalAsync:claims → scopes/resources → 复用/新建永久授权 → SetDestinations
var identity = new ClaimsIdentity(
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name, // OpenIddict 的 "name"
roleType: Claims.Role); // OpenIddict 的 "role"
identity.SetClaim(Claims.Subject, await userManager.GetUserIdAsync(user))
.SetClaim(Claims.Email, await userManager.GetEmailAsync(user))
.SetClaim(Claims.Name, await userManager.GetUserNameAsync(user))
.SetClaim(Claims.PreferredUsername, await userManager.GetUserNameAsync(user))
.SetClaims(Claims.Role, [.. await userManager.GetRolesAsync(user)]);
identity.SetScopes(request.GetScopes());
identity.SetResources(await scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
var authorization = authorizations.LastOrDefault();
authorization ??= await authorizationManager.CreateAsync(
identity: identity, subject: ..., client: ..., type: AuthorizationTypes.Permanent,
scopes: identity.GetScopes());
identity.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization));
identity.SetDestinations(GetDestinations);
return new ClaimsPrincipal(identity); // 调用方:SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)四个关键点:
- claims 从 Identity 数据库实时取(不是从 cookie 抄)——改了用户名/角色立刻生效。
SetScopes/SetResources决定 token 里出现哪些 scope;ListResourcesAsync按 scope 查oi_sco得到 audience(本项目 scope 未注册 resource,返回空)。SetAuthorizationId把这次签发与oi_ath记录关联起来,之后可以按授权撤销。SetDestinations(GetDestinations)决定每个 claim 进 access token 还是 id token(见 ④ 换 token)。
需要同意时:302 到 React 同意页
default:
// 参数可能来自 query(普通 GET)也可能来自 form(POST)——见下面 ⚠️
var parameters = (Request.HasFormContentType ? Request.Form.AsEnumerable() : Request.Query.AsEnumerable())
.Where(p => !string.Equals(p.Key, antiforgeryOptions.Value.FormFieldName, StringComparison.Ordinal))
.Select(p => new KeyValuePair<string, string?>(p.Key, p.Value.ToString()));
return Redirect(QueryHelpers.AddQueryString(
oauthOptions.Value.ConsentPageUrl,
parameters.Append(new KeyValuePair<string, string?>("app_name",
await applicationManager.GetLocalizedDisplayNameAsync(application)))));为什么是"302 + 原样带回参数"
本项目 302 到 React 同意页,把原授权参数原样带过去(再加一个 app_name 供展示)。 React 页面把这些参数作为隐藏字段原样 POST 回来,所以控制器侧完全不需要为 React 保存任何状态。
实测跳转:
HTTP/1.1 302 Found
Location: /Account/Consent?client_id=client-demo&...&code_challenge=...&app_name=SPA+client+application+(client-demo)为什么参数要从 Form 或 Query 里取(不能只看 Query)
Authorize 同时挂了 [HttpGet] 和 [HttpPost]。当同意页的表单没有submit.Accept / submit.Deny 时(例如用户在同意页按了回车),两个 FormValueRequired action 都不匹配 → MVC 把 POST 回落到 Authorize → 又走到 default 分支。此时参数在表单里,不在 query 里: 只读 Request.Query 会重定向出一个"没有参数"的同意页,用户再点"同意"就报 error:invalid_request … The mandatory 'client_id' parameter is missing(ID2029)。
服务端渲染的同意页用的是同一个判断 (Context.Request.HasFormContentType ? Context.Request.Form : Context.Request.Query)。 唯一例外:不放行 __RequestVerificationToken —— React 同意页会自己重新取请求令牌,重复的隐藏字段会让防伪校验失败。
实测(2026-09-27):回车提交只会回到同意页且参数不丢,既不会创建永久授权、也不会签 code。
React 同意页(src/pages/Consent.tsx)
const params: Record<string, string> = {}
new URLSearchParams(window.location.search).forEach((value, key) => { params[key] = value })
useEffect(() => {
fetch(`${AUTHORITY}/Account/Antiforgery`, { credentials: 'include' })
.then(res => res.ok ? res.json() : Promise.reject(new Error(String(res.status))))
.then((data: { token?: string }) => setToken(data.token ?? ''))
.catch(() => setError('无法获取防伪令牌,请刷新页面重试'))
}, [])
<form method="post" action={`${AUTHORITY}/connect/authorize`}>
{Object.entries(params).map(([key, value]) => <input key={key} type="hidden" name={key} value={value} />)}
<input type="hidden" name="__RequestVerificationToken" value={token} />
<div className="actions">
<button type="submit" name="submit.Accept" value="Yes" disabled={!token}>同意</button>
<button type="submit" name="submit.Deny" value="No" className="secondary" disabled={!token}>拒绝</button>
</div>
</form>为什么要 GET /Account/Antiforgery:服务端渲染的表单由 @Html.AntiForgeryToken() 直接生成隐藏字段, React 页面没有这个能力 → 用一个小端点下发请求令牌(官方文档的 SPA 写法), 字段名用框架配置的 AntiforgeryOptions.FormFieldName(默认 __RequestVerificationToken)。 注意表单用普通 POST 而不是 fetch,这样 302 回客户端由浏览器自己处理。
Accept:同意
[Authorize, FormValueRequired("submit.Accept")]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
public async Task<IActionResult> Accept()
{
...
// Note: the same check is already made in the other action but is repeated
// here to ensure a malicious user can't abuse this POST-only endpoint and
// force it to return a valid response without the external authorization.
if (authorizations.Count is 0 && await applicationManager.HasConsentTypeAsync(application, ConsentTypes.External))
{
return Forbid(... Errors.ConsentRequired ...);
}
// 与免同意分支相同的 claims 构造 → CreateAsync 写永久授权 → SignIn 签 code
}三个安全细节:
| 机制 | 作用 |
|---|---|
[Authorize] | 必须是已登录用户(Identity 应用 cookie) |
[FormValueRequired("submit.Accept")] | 自定义过滤器:请求体里没有 submit.Accept 就不进这个方法(否则同一个 POST 路由无法区分"同意/拒绝"两个 action) |
[ValidateAntiForgeryToken] | 表单必须带 __RequestVerificationToken |
重复检查 External | 防止有人直接 POST 这个端点绕过"外部授权"限制(注释里写明的攻击场景) |
实测响应(同意后):
HTTP/1.1 302 Found
Location: http://localhost:5173/signin-callback?code=<授权码>&state=<原样回传>&iss=https%3A%2F%2Flocalhost%3A7016%2FDeny:拒绝
[Authorize, FormValueRequired("submit.Deny")]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
public IActionResult Deny() => Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);用 OpenIddict 的 scheme 去 Forbid:OpenIddict 会按 response_mode 生成错误响应:
HTTP/1.1 302 Found
Location: http://localhost:5173/signin-callback?error=access_denied
&error_description=The+resource+owner+rejected+the+authorization+request.
&state=...&iss=...(错误码 ID2015,见 坑速查。)
同意状态怎么看
| 现象 | 原因 |
|---|---|
| 每次点登录都弹同意页 | ① 客户端 ConsentType 不是 explicit?② 每次重启清了 oi_ath?③ 请求的 scope 变了(scope 是匹配条件之一)④ prompt=consent |
| 第二次不再弹 | oi_ath 里已有覆盖本次全部 scope 的永久授权 → 走免同意分支 |
想手动清掉"已同意"?
删 backend/Yagang.Identity/Yagang.Identity.Api/database.db 里的 oi_ath 记录(或直接重启, 因为启动会 ExecuteDeleteAsync 清空四张表)。