AuthorizationController 走读
文件:backend/Yagang.Identity/Yagang.Identity.Api/Controllers/AuthorizationController.cs (ControllerBase + 主构造函数注入;TempData 由 ITempDataDictionaryFactory 提供)
Action 一览
| Action | 路由 | 过滤器 | 干什么 |
|---|---|---|---|
Authorize | GET/POST ~/connect/authorize | [IgnoreAntiforgeryToken] | 认证判定 → 同意判定 → 免同意直接签 code / 跳同意页 / 报错误 |
Accept | POST ~/connect/authorize | [Authorize] + FormValueRequired("submit.Accept") + [ValidateAntiForgeryToken] | 用户点"同意" → 写永久授权 → 签 code |
Deny | POST ~/connect/authorize | [Authorize] + FormValueRequired("submit.Deny") + [ValidateAntiForgeryToken] | 用户点"拒绝" → Forbid → error=access_denied |
Logout | GET ~/connect/logout | — | 302 到 React 登出确认页 |
LogoutPost | POST ~/connect/logout | [ValidateAntiForgeryToken] | 清 Identity cookie + OpenIddict SignOut → 回 post_logout_redirect_uri |
Exchange | POST ~/connect/token | [IgnoreAntiforgeryToken, Produces("application/json")] | 授权码/刷新令牌 → 重建 claims → 签发 token |
同一个路由 3 个 Action 怎么区分?
Accept / Deny 都挂在 POST ~/connect/authorize,靠自定义过滤器 FormValueRequiredAttribute 分流: 请求体里有 submit.Accept 才进 Accept,有 submit.Deny 才进 Deny。 这就是 React 同意页上两个 <button name="submit.Accept" value="Yes"> 存在的原因——按钮名本身就是路由条件。
1. 认证判定(Authorize 前半段)
见 ① 授权请求,要点复述:
var result = await HttpContext.AuthenticateAsync(); // 默认 scheme = Identity cookie
if (result is not { Succeeded: true } ||
((request.HasPromptValue(PromptValues.Login) || request.MaxAge is 0 ||
(request.MaxAge is not null && result.Properties?.IssuedUtc is not null &&
TimeProvider.System.GetUtcNow() - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) &&
TempData["IgnoreAuthenticationChallenge"] is null or false))
{
if (request.HasPromptValue(PromptValues.None)) { /* Forbid(login_required) */ }
TempData["IgnoreAuthenticationChallenge"] = true; // 防死循环
return Challenge(new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
Request.HasFormContentType ? Request.Form : Request.Query)
});
}2. 用户 / 应用 / 授权
// Authorize 与 Accept 共用同一个私有方法(返回三元组):
// Authorize 传 result.Principal,Accept 传 User([Authorize] 过滤器填充的 principal)
var (user, application, authorizations) =
await LoadAuthorizationContextAsync(result.Principal, request);LoadAuthorizationContextAsync 内部依次做三件事:GetUserAsync(principal) → FindByClientIdAsync(request.ClientId) → authorizationManager.FindAsync(...)(查永久授权), 失败时的两处 InvalidOperationException 文案与 Balosar 原版一致。
注意 FindAsync 的参数类型都是内部 Id(不是 client_id 字符串)——client 要传 applicationManager.GetIdAsync(application) 的结果。
3. 同意分支(switch)
代码结构:
switch (await applicationManager.GetConsentTypeAsync(application))
{
case ConsentTypes.External when authorizations.Count is 0:
return Forbid(... Errors.ConsentRequired ...);
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):
/* 免同意:构造 identity → SetScopes/SetResources → 拿/建永久授权 → SetAuthorizationId
→ SetDestinations(GetDestinations) → SignIn(...) */
break;
case ConsentTypes.Explicit when request.HasPromptValue(PromptValues.None):
case ConsentTypes.Systematic when request.HasPromptValue(PromptValues.None):
return Forbid(... Errors.ConsentRequired ...);
default:
/* 302 到 React 同意页 /Account/Consent?... */
break;
}详细解释见 ③ 同意流程。
4. GetDestinations
private IEnumerable<string> GetDestinations(Claim claim)
{
switch (claim.Type)
{
case Claims.Name or Claims.PreferredUsername: yield return Destinations.AccessToken;
if (claim.Subject!.HasScope(Scopes.Profile)) yield return Destinations.IdentityToken;
yield break;
case Claims.Email: /* email scope 控制 id token */
case Claims.Role: /* roles scope 控制 id token */
// Never include the security stamp in the access and identity tokens, as it's a secret value.
// Note: the claim type is an IdentityOptions value resolved at runtime, so it cannot be
// a constant case label — the `when` guard keeps the check inside this switch.
case var claimType when claimType == identityOptions.Value.ClaimsIdentity.SecurityStampClaimType:
yield break;
default: yield return Destinations.AccessToken; yield break;
}
}为什么不再是 static:安全戳判断要用构造函数注入的 IOptions<IdentityOptions>(claim 类型来自 ClaimsIdentity.SecurityStampClaimType)。 SetDestinations(GetDestinations) 传的仍是方法组,OpenIddict 会在签发时逐个 claim 回调它。
为什么安全戳那条写成 case var claimType when ...:C# 的 case 标签必须是编译期常量, 而 IdentityOptions 的值是运行时读取的,不能直接当标签;用 pattern + when 守卫就能把这条判断留在 switch 里, 形状与官方 Balosar 的 case "AspNet.Identity.SecurityStamp": yield break; 保持一致。
5. Accept / Deny 的安全细节
[Authorize, FormValueRequired("submit.Accept")]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
public async Task<IActionResult> Accept()[Authorize]:必须是已登录用户;FormValueRequired:区分同一路由的两个 action;ValidateAntiForgeryToken:防"用户没点同意但被第三方站点替他点了";Accept里重复做了Externalconsent 检查,注释写明原因:防止有人直接 POST 这个端点, 绕过"外部授权必须预授予"的约束。
6. Exchange
见 ④ 换 token。这里只强调"交换"二字的含义: AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) 把授权码(或 refresh token)解开,还原成当初那份 principal——这就是为什么授权码必须由 OpenIddict 签发。
7. 两处页面跳转(React 页面的接入点)
| 位置 | 代码 | 说明 |
|---|---|---|
Authorize 的 default 分支 | return Redirect(QueryHelpers.AddQueryString(oauthOptions.Value.ConsentPageUrl, <原授权参数> + app_name)) | 需要用户同意时 302 到 React 同意页;参数取 Request.HasFormContentType ? Form : Query(兼容 GET 与"回车提交"的 POST),并排除防伪字段(AntiforgeryOptions.FormFieldName,默认 __RequestVerificationToken;React 页会自己重新取令牌) |
Logout | Redirect(QueryHelpers.AddQueryString(oauthOptions.Value.LogoutPageUrl, <原参数>)) | GET /connect/logout 不直接清会话,先跳到 React 登出确认页;用户确认后 POST 回 LogoutPost 才真正登出 |
两个页面地址来自 appsettings(OAuth:ConsentPageUrl → /Account/Consent、OAuth:LogoutPageUrl → /Account/Logout),换页面地址不需要改代码。