AuthenticationController 走读
文件:backend/Yagang.Identity/Yagang.Identity.Api/Controllers/AuthenticationController.cs (ControllerBase + 特性路由;一个 action 处理全部外部提供方)
它解决什么问题
当用户点「使用 GitHub 登录」时,流程跨了两个"授权服务器":
浏览器 ──▶ 本进程(OP)──▶ GitHub(外部 IdP)
│ code
浏览器 ◀── /callback/login/github ◀─┘/callback/login/{provider} 就是**本进程作为"客户端"**接收 GitHub 回调的地方:
[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。
校验与"不能用于认证"的判断
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("代表用户做动作"而不是"登录用户")。
构造要写进 cookie 的身份
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.ProviderName | OpenIddict 私有 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 存储
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 的差异(关键教学点)
文件里那段长注释值得完整读一遍,它讲了三件事:
- Identity 默认 UI 时:这里种下的身份不会直接进"应用 cookie",而是先进external cookie, 之后由
ExternalLogin.cshtml.cs调SignInManager.ExternalLoginSignInAsync()换成应用 cookie; - 这条路会丢掉这里添加的 claim(默认 UI 的换 cookie 过程不保留它们); 想保留只能
UserManager.AddClaimAsync()存库,或去脚手架化ExternalLogin页; - 本项目没有默认 UI → 换 cookie 那一步由
/Account/ExternalLoginCallback自己做 (已绑定 →ExternalLoginSignInAsync直接登录;未绑定 → React 确认页/Account/ExternalLoginConfirm由用户显式确认后建号并绑定), 见 ⑥ GitHub 外部登录。
依赖的前置条件
| 前置 | 在哪配置 |
|---|---|
OpenIddict Client + Web provider(AddGitHub) | Program.cs 的 AddClient(配置驱动,未配置则该提供方不存在) |
重定向端点 URI callback/login/github | AddClient 的 SetRedirectionEndpointUris + AddGitHub 的 SetRedirectUri |
转发 scheme(名如 GitHub) | OpenIddict Client 的 ASP.NET Core 集成自动注册;AccountController 用它发起 Challenge |
客户端 Challenge 的 RedirectUri | AccountController 的 /Account/ExternalLoginCallback?returnUrl=...(登录页的 POST /Account/ExternalLogin 只是发起方) |