Skip to content

AuthorizationController 走读 ​

文件:backend/Yagang.Identity/Yagang.Identity.Api/Controllers/AuthorizationController.cs (ControllerBase + 主构造函数注入;TempData 由 ITempDataDictionaryFactory 提供)

Action 一览 ​

Action路由过滤器干什么
AuthorizeGET/POST ~/connect/authorize[IgnoreAntiforgeryToken]认证判定 → 同意判定 → 免同意直接签 code / 跳同意页 / 报错误
AcceptPOST ~/connect/authorize[Authorize] + FormValueRequired("submit.Accept") + [ValidateAntiForgeryToken]用户点"同意" → 写永久授权 → 签 code
DenyPOST ~/connect/authorize[Authorize] + FormValueRequired("submit.Deny") + [ValidateAntiForgeryToken]用户点"拒绝" → Forbid → error=access_denied
LogoutGET ~/connect/logout—302 到 React 登出确认页
LogoutPostPOST ~/connect/logout[ValidateAntiForgeryToken]清 Identity cookie + OpenIddict SignOut → 回 post_logout_redirect_uri
ExchangePOST ~/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 前半段) ​

见 ① 授权请求,要点复述:

csharp
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. 用户 / 应用 / 授权 ​

csharp
// 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) ​

代码结构:

csharp
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 ​

csharp
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 的安全细节 ​

csharp
[Authorize, FormValueRequired("submit.Accept")]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
public async Task<IActionResult> Accept()
  • [Authorize]:必须是已登录用户;
  • FormValueRequired:区分同一路由的两个 action;
  • ValidateAntiForgeryToken:防"用户没点同意但被第三方站点替他点了";
  • Accept 里重复做了 External consent 检查,注释写明原因:防止有人直接 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 页会自己重新取令牌)
LogoutRedirect(QueryHelpers.AddQueryString(oauthOptions.Value.LogoutPageUrl, <原参数>))GET /connect/logout 不直接清会话,先跳到 React 登出确认页;用户确认后 POST 回 LogoutPost 才真正登出

两个页面地址来自 appsettings(OAuth:ConsentPageUrl → /Account/Consent、OAuth:LogoutPageUrl → /Account/Logout),换页面地址不需要改代码。

OpenIddict + ASP.NET Core Identity 授权服务器,React 登录页,逐步拆解登录链路