Skip to content

AuthenticationController 走读 ​

文件:backend/Yagang.Identity/Yagang.Identity.Api/Controllers/AuthenticationController.cs (ControllerBase + 特性路由;一个 action 处理全部外部提供方)

它解决什么问题 ​

当用户点「使用 GitHub 登录」时,流程跨了两个"授权服务器":

浏览器 ──▶ 本进程(OP)──▶ GitHub(外部 IdP)
                              │ code
浏览器 ◀── /callback/login/github ◀─┘

/callback/login/{provider} 就是**本进程作为"客户端"**接收 GitHub 回调的地方:

csharp
[HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken]
public async Task<ActionResult> LogInCallback()
{
    var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
    ...
}
  • 一个 action 处理所有提供方(想每个提供方一个 action 也可以自行拆分)。
  • IgnoreAntiforgeryToken:这是外部提供方发起的 GET/POST 回调,不可能带本站防伪 token。
  • AuthenticateAsync(OpenIddictClient...):由 OpenIddict Client 完成 state 校验、code 换 token、 拉 userinfo(如需要),最终给出一个外部身份 principal。

校验与"不能用于认证"的判断 ​

csharp
if (result is not { Succeeded: true, Principal.Identity.IsAuthenticated: true })
{
    throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}

注释解释了原因:如果提供方既不支持 OpenID Connect 也不提供 userinfo, result.Principal 会是一个未认证的身份(没有任何用户 claim)。 这种身份不能直接拿去建 cookie —— 因为 ASP.NET Core 的防伪栈要求身份里至少有 name claim 才能把 CSRF cookie 绑到用户上。 此时可以改用 result.Properties.GetTokens() 拿到 access/refresh token 直接调 API("代表用户做动作"而不是"登录用户")。

csharp
var identity = new ClaimsIdentity(
    authenticationType: "ExternalLogin",
    nameType: ClaimTypes.Name,
    roleType: ClaimTypes.Role);

identity.SetClaim(ClaimTypes.Email, result.Principal.GetClaim(ClaimTypes.Email))
        .SetClaim(ClaimTypes.Name, result.Principal.GetClaim(ClaimTypes.Name))
        .SetClaim(ClaimTypes.NameIdentifier, result.Principal.GetClaim(ClaimTypes.NameIdentifier));

// Preserve the registration details to be able to resolve them later.
identity.SetClaim(Claims.Private.RegistrationId, result.Principal.GetClaim(Claims.Private.RegistrationId))
        .SetClaim(Claims.Private.ProviderName, result.Principal.GetClaim(Claims.Private.ProviderName));
claim用途
ClaimTypes.Email确认页预填 + 用户确认后作为新建本地账号的邮箱(不用它自动关联已有账号)
ClaimTypes.Name显示名;也是防伪栈要求的 name claim
ClaimTypes.NameIdentifier提供方侧的稳定用户 Id(GitHub user id)→ 与 ProviderName 组成外部登录键
Claims.Private.RegistrationId / Claims.Private.ProviderNameOpenIddict 私有 claim,记住"哪份注册/哪个提供方";落地端点用 ProviderName 找 scheme

为什么用 ClaimTypes.* 而不是 Claims.*

这里刻意使用 .NET 的 ClaimTypes URI 常量:因为这份身份最终要交给 ASP.NET Core Identity, Identity 的 UserManager / 防伪栈默认认的是 ClaimTypes 那一套。 而 OpenIddict 内部用短名(sub/email/name)——result.Principal.GetClaim(ClaimTypes.Email) 能取到, 是因为 OpenIddict Client 做了映射。

认证属性与 token 存储 ​

csharp
var properties = new AuthenticationProperties(result.Properties.Items)
{
    RedirectUri = result.Properties.RedirectUri ?? "/",
    IssuedUtc = null,
    ExpiresUtc = null,
    IsPersistent = false   // 只活在 5 分钟的 external cookie 里
};

properties.StoreTokens(result.Properties.GetTokens().Where(token => token.Name is
    OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or
    OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken));

// 显式写入 Identity 的 external cookie scheme(不是应用 cookie):
// 这张票是"外部身份已验证、还没落地成本地账号"的中间状态。
return SignIn(new ClaimsPrincipal(identity), properties, IdentityConstants.ExternalScheme);
项说明
RedirectUri = result.Properties.RedirectUri ?? "/"就是 ② 里 Challenge 时写的 /Account/ExternalLoginCallback?returnUrl=...
IssuedUtc = null / ExpiresUtc = null解除"外部身份 cookie 生命周期跟着 id_token 走"的绑定,交给 cookie handler 按配置算
StoreTokens(...Where(...))过滤后只存 backchannel access token 与 refresh token(cookie 有 4KB 限制,别把全部 token 塞进去)
IsPersistent = false这张票只活在 external cookie(默认 5 分钟)里,浏览器关掉即忘
SignIn(..., IdentityConstants.ExternalScheme)显式指定 scheme:不依赖"默认 SignIn 方案",保证和落地端点 GetExternalLoginInfoAsync() 读的是同一个 cookie

与 Identity 默认 UI 的差异(关键教学点) ​

文件里那段长注释值得完整读一遍,它讲了三件事:

  1. Identity 默认 UI 时:这里种下的身份不会直接进"应用 cookie",而是先进external cookie, 之后由 ExternalLogin.cshtml.cs 调 SignInManager.ExternalLoginSignInAsync() 换成应用 cookie;
  2. 这条路会丢掉这里添加的 claim(默认 UI 的换 cookie 过程不保留它们); 想保留只能 UserManager.AddClaimAsync() 存库,或去脚手架化 ExternalLogin 页;
  3. 本项目没有默认 UI → 换 cookie 那一步由 /Account/ExternalLoginCallback 自己做 (已绑定 → ExternalLoginSignInAsync 直接登录;未绑定 → React 确认页 /Account/ExternalLoginConfirm 由用户显式确认后建号并绑定), 见 ⑥ GitHub 外部登录。

依赖的前置条件 ​

前置在哪配置
OpenIddict Client + Web provider(AddGitHub)Program.cs 的 AddClient(配置驱动,未配置则该提供方不存在)
重定向端点 URI callback/login/githubAddClient 的 SetRedirectionEndpointUris + AddGitHub 的 SetRedirectUri
转发 scheme(名如 GitHub)OpenIddict Client 的 ASP.NET Core 集成自动注册;AccountController 用它发起 Challenge
客户端 Challenge 的 RedirectUriAccountController 的 /Account/ExternalLoginCallback?returnUrl=...(登录页的 POST /Account/ExternalLogin 只是发起方)

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