⑥ GitHub 外部登录
这一节是"React 前端 + 外部提供方"带来的额外工作量:Identity 默认 UI 自带"用 GitHub 登录"按钮和落地逻辑, 本项目没有默认 UI,所以要用一组端点把这条链补齐。
涉及的三段
① React 登录页 → GET /Account/ExternalProviders (有配置才显示按钮)
② 点按钮 → POST /Account/ExternalLogin?returnUrl=...(表单字段 provider=GitHub,Identity UI 同款)
└─ Challenge 转发 scheme → 302 github.com/login/oauth/authorize
③ GitHub 回调 → GET /callback/login/github(AuthenticationController)
└─ 写 Identity 的 external cookie(中间态,5 分钟)→ 302 /Account/ExternalLoginCallback?returnUrl=...
④ 落地 → GET /Account/ExternalLoginCallback(对齐 Identity UI 的 ExternalLogin 页)
├─ 已绑定过 → ExternalLoginSignInAsync 直接登录 → 302 回原授权请求(然后正常签 code)
└─ 未绑定 → 302 /Account/ExternalLoginConfirm(React 确认页)→ 用户确认邮箱 → 创建账号并绑定启用条件(默认关闭)
Program.cs 里是配置驱动:
var githubClientId = builder.Configuration["OAuth:External:GitHub:ClientId"];
var githubClientSecret = builder.Configuration["OAuth:External:GitHub:ClientSecret"];
if (!string.IsNullOrEmpty(githubClientId))
{
options.UseWebProviders()
.AddGitHub(provider => provider
.SetClientId(githubClientId)
.SetClientSecret(githubClientSecret ?? string.Empty)
.SetRedirectUri("callback/login/github"));
}| 配置 | 值 |
|---|---|
OAuth:External:GitHub:ClientId | GitHub OAuth App 的 Client ID |
OAuth:External:GitHub:ClientSecret | Client Secret |
| 环境变量写法(临时测试不落盘) | OAuth__External__GitHub__ClientId / ...__ClientSecret |
| GitHub OAuth App 的回调地址 | https://localhost:7016/callback/login/github |
未配置时:
/Account/ExternalProviders返回[]→ 登录页不显示按钮;POST /Account/ExternalLogin(表单字段provider=GitHub)返回 404;- 其余流程(本地登录/同意/换 token/登出)完全不受影响。
凭据只从配置读
GitHub OAuth App 的 client_id / secret 不写进代码,统一从 appsettings / 环境变量读取(仓库里不放任何密钥)。
① 列出提供方
[HttpGet("ExternalProviders")]
public async Task<IActionResult> ExternalProviders()
{
// 与 Identity 默认 UI 的 Login.cshtml.cs 同构:外部提供方 = 注册时带 DisplayName 的认证 scheme
var schemes = await signInManager.GetExternalAuthenticationSchemesAsync();
return Ok(schemes.Select(scheme => new { name = scheme.Name, displayName = scheme.DisplayName }));
}与 Identity UI 源码逐点对齐(2026-10-05)
Identity UI 的 Login.cshtml 里,"用其它服务登录"就是每个提供方一个 POST 表单: <form asp-page="./ExternalLogin" asp-route-returnUrl=... method="post"> + <button type="submit" name="provider" value="@provider.Name">; ExternalLogin.cshtml.cs 的 OnGet 只是 RedirectToPage("./Login"),OnPost(provider, returnUrl) 才 Challenge。 本项目 1:1 搬了这两点(去掉 /Identity 前缀,SPA 需要额外的 GET /Account/ExternalProviders JSON 接口来渲染按钮), 并保留一处更严的校验:未知 provider 返回 404(Identity UI 会直接交给 Challenge 抛错)。
SignInManager.GetExternalAuthenticationSchemesAsync() 是 Identity 的标准入口(Identity UI 的 Login.cshtml.cs 用的就是它)—— 实现就是 Authentication.GetSchemesAsync() 再过滤"有 DisplayName"的 scheme,它并不认识 OpenIddict。
那 OpenIddict 的 GitHub 为什么能被它看见?因为 OpenIddict Client 的 ASP.NET Core 集成会自动为每个提供方注册一个同名的转发 scheme, 并且带上 displayName(openiddict-core OpenIddictClientAspNetCoreConfiguration.PostConfigure: options.AddScheme<OpenIddictClientAspNetCoreForwarder>(scheme.Name, scheme.DisplayName)): AddGitHub(...) → 得到一个名为 GitHub、带显示名的 scheme → Identity 这个方法就能发现它。 (这正是 Identity UI 在 OpenIddict 4.7+ 下"零配置出现 GitHub 按钮"的全部机制。)
语义上也比直接读 IOptionsMonitor<OpenIddictClientAspNetCoreOptions>.ForwardedAuthenticationSchemes 更宽: 将来若直接 AddGoogle() 注册一个非 OpenIddict 的外部认证,这里会自动列出,与 Identity UI 的行为一致 (这正是本项目从"读 OpenIddict 配置"改成"调 SignInManager 方法"的原因)。
实测(配了 dummy 凭据时):
[{ "name": "GitHub", "displayName": "GitHub" }]② 发起外部登录
[HttpGet("ExternalLogin")]
public async Task<IActionResult> ExternalLogin(string? provider, string? returnUrl)
{
// 与 ① 用同一个来源查(避免"列表里有、点击 404"的不一致)
var scheme = (await signInManager.GetExternalAuthenticationSchemesAsync())
.FirstOrDefault(candidate => string.Equals(candidate.Name, provider, StringComparison.OrdinalIgnoreCase));
if (scheme is null)
{
return NotFound(new { error = "unknown_provider" });
}
return Challenge(
new AuthenticationProperties
{
RedirectUri = QueryHelpers.AddQueryString(
"/Account/ExternalLoginCallback", "returnUrl", IsLocalUrl(returnUrl) ? returnUrl! : "/")
},
scheme.Name);
}Challenge这个转发 scheme → OpenIddict Client 开始它自己的授权码流程:生成 state/PKCE、302 到 GitHub。RedirectUri写的是拿回外部身份之后去哪(这里是本项目自定义的落地端点), 注意它和 GitHub OAuth App 上配置的回调地址callback/login/github不是一回事。
实测:
HTTP/1.1 302 Found
Location: https://github.com/login/oauth/authorize
?client_id=dummy-client-id
&redirect_uri=https%3A%2F%2Flocalhost%3A7016%2Fcallback%2Flogin%2Fgithub
&response_type=code&code_challenge=...&code_challenge_method=S256&state=...React 登录页上的按钮就是这个 POST 表单(Identity UI 同款:provider 作为按钮的 name/value,returnUrl 走 query; 只有 returnUrl 以 / 开头才带过去):
<form method="post" action={`${AUTHORITY}/Account/ExternalLogin?returnUrl=${encodeURIComponent(target)}`}>
<input type="hidden" name={formFieldName} value={token} />
<Button htmlType="submit" name="provider" value={provider.name} disabled={!token}>使用 GitHub 登录</Button>
</form>③ 回调:AuthenticationController
[HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken]
public async Task<ActionResult> LogInCallback()
{
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
if (result is not { Succeeded: true, Principal.Identity.IsAuthenticated: true })
{
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}
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));
...
// 显式写进 Identity 的 external cookie(中间态),而不是应用 cookie
return SignIn(new ClaimsPrincipal(identity), properties, IdentityConstants.ExternalScheme);
}要点:
| 点 | 说明 |
|---|---|
AuthenticateAsync(OpenIddictClient...) | 校验 state、用 code 换 token(OpenIddict Client 干的事) |
断言 Principal.Identity.IsAuthenticated | 若提供方不返回任何用户 claim(无 userinfo),身份是"未认证"的,不能拿去种 cookie |
ClaimTypes.NameIdentifier | 提供方内侧的用户 Id(GitHub 的用户 Id)——后面拿来匹配 AspNetUserLogins |
Claims.Private.ProviderName / RegistrationId | 记住"是哪个提供方/哪份注册",落地端点要用 |
properties.StoreTokens(...) | 把 backchannel access token / refresh token 存进 cookie(过滤掉其它 token,避免 cookie 过大) |
IssuedUtc = null / ExpiresUtc = null | 让 cookie 生命周期按 cookie handler 的配置算,而不是跟着 id_token 走 |
IsPersistent = false + SignIn(..., IdentityConstants.ExternalScheme) | 这张票只活在 5 分钟的 external cookie 里,浏览器关掉即忘;显式指定 scheme 保证与落地端点读的是同一个 cookie |
这条 cookie 是"外部身份",不是最终登录
这里种下的是 Identity 的 external cookie(scheme Identity.External,5 分钟)——中间态,不是应用会话。 落地由 ④ 的 /Account/ExternalLoginCallback 完成:已绑定 → 换成应用 cookie;未绑定 → 交给 React 确认页。 (ExternalLoginSignInAsync / SignInAsync 成功后会顺带清掉这个 external cookie。)
④ 落地:外部身份 → Identity 用户(生产级语义)
[HttpGet("ExternalLoginCallback")]
public async Task<IActionResult> ExternalLoginCallback(string? returnUrl, string? error = null)
{
var safeReturnUrl = SafeReturnUrl(returnUrl);
// AuthenticationController 按 ?error= 契约把提供方错误带过来(OpenIddict 把错误放在
// 认证属性 .error 里,不是 query 上的 remoteError)
if (!string.IsNullOrEmpty(error))
return Redirect(ErrorRedirect(loginPage, safeReturnUrl, "external_provider_error"));
var info = await signInManager.GetExternalLoginInfoAsync(); // 读 external cookie(5 分钟)
if (!IsUsableExternalLogin(info)) // 缺失/过期,或提供方没给出可用的用户标识
{
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
return Redirect(ErrorRedirect(loginPage, safeReturnUrl, "external_login_unavailable"));
}
// = Identity UI 的 OnGetCallbackAsync:只看这次登录结果
var result = await signInManager.ExternalLoginSignInAsync(
info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
if (result.Succeeded) return LocalRedirect(safeReturnUrl); // 已绑定过 → 直接登录
if (result.IsLockedOut) return Redirect(... "account_locked"); // 已锁定(Identity UI 跳 Lockout 页)
if (result.IsNotAllowed) return Redirect(... "account_not_allowed"); // 本项目额外保留
// 独立确认(在 Identity UI 的 else 之上加的一步):只有"确实未绑定"才去确认页
if (await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey) is null)
return Redirect($"{ConfirmPageUrl}?returnUrl=...");
logger.LogWarning("Unexpected external sign-in result ...");
return Redirect(... "external_login_failed"); // 已绑定却失败 → 记日志 + 兜底
}与 Identity UI 逐行对齐(2026-10-05 重写)
- 判定逻辑:Identity UI 的
OnGetCallbackAsync只依赖ExternalLoginSignInAsync的结果 ——Succeeded→LocalRedirect;IsLockedOut→ Lockout 页;else → 渲染确认页(我们 302 到 React 确认页)。 - 比 Identity UI 多两步:①
IsNotAllowed(邮箱未确认/被禁用)→account_not_allowed(Identity UI 会当成"未绑定"去弹确认页); ② 去确认页之前先用FindByLoginAsync独立确认"确实未绑定" —— 已绑定但登录异常的用户不会被推去建号(否则会在建号时撞重名邮箱卡死), 而是记LogWarning+ 回登录页error=external_login_failed。 - 只判
info is null:不检查LoginProvider/ProviderKey是否为空(cookie 由我们自己签发,值来自已校验的提供方响应)。 - 异常不捕获:解析/登录过程中抛出的异常(DB 故障等)直接 500,让故障可见、不吞错。
确认页与辅助端点:
| 端点 | 作用 |
|---|---|
GET /Account/ExternalLoginConfirm | React 确认页(SPA 路由) |
GET /Account/ExternalLoginPending | 页面读外部身份(provider / 显示名 / 邮箱);无 external cookie → 404(不把邮箱写进 URL) |
POST /Account/ExternalLoginRegister | 用户确认后在同一个 EF Core 事务里 CreateAsync(user) → AddLoginAsync → Commit → SignInAsync(user, false, info.LoginProvider) → LocalRedirect(returnUrl)。邮箱用模型校验([Required] / [EmailAddress] → ModelState),校验放在最前面(普通 MVC 控制器没有自动 400,不判 ModelState 就丢错误)→ 非法时 302 回确认页 error=email_invalid(verify-flow 已覆盖);随后才读 external cookie。幂等:该外部身份已绑定则直接登录既有账号;任一步失败整体回滚(刷新可重试,不留孤儿账号) |
GitHub → /callback/login/github(写 external cookie,5 分钟)
→ /Account/ExternalLoginCallback
├─ 已绑定 → ExternalLoginSignInAsync → 应用 cookie → 302 returnUrl
└─ 未绑定 → /Account/ExternalLoginConfirm(React)
↓ 用户确认邮箱 → 创建账号并绑定
→ SignInAsync(user, false, provider) → 302 returnUrl为什么不再按邮箱自动关联(本次生产级改造的核心)
- 业界共识(Ory / AWS Cognito / Auth0 / Keycloak)与多个 CVE(Zammad、Nhost 等账号接管)指向同一条规则: 仅凭 provider 返回的邮箱相等就自动关联 = 账户接管风险——攻击者把自己外部账号的邮箱改成受害者邮箱,就能登录成受害者。
- Identity UI 的默认行为同样是"不自动关联":未绑定 → 弹确认页,用户显式确认后创建新账号。
- 改造前那段"
FindByLoginAsync→FindByEmailAsync→ 直接关联/建号"是教学简化,已删除。
与 Identity UI 的差异(只在表现层)
- 确认页:Identity UI 是 Razor(
ExternalLogin.cshtml),本项目换成 React 页/Account/ExternalLoginConfirm,行为同构(确认邮箱 → 创建账号并绑定)。 - 邮箱确认:Identity UI 建号后会生成 token 并发确认邮件;本项目未接邮件发送器 →
EmailConfirmed保持false(没有因为外部提供了邮箱就当作已验证),接入邮件服务后补发即可。 - 已登录用户"再绑定一个外部账号"(Identity UI 的 Manage → External logins)不在本次范围。
最后 SignInManager.SignInAsync(user, isPersistent: false, authenticationMethod: info.LoginProvider) 把 external cookie 换成真正的应用 cookie (同时把 amr 记成 provider 名),之后授权流程与本地登录完全同一条路。
无外部身份 / 提供方报错时的行为(实测)
GET /Account/ExternalLoginCallback?returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dclient-demo
→ 302 /Account/Login?ReturnUrl=...&error=external_login_unavailable
POST /Account/ExternalLoginRegister(带防伪令牌、无 external cookie)
→ 302 /Account/Login?ReturnUrl=%2F&error=external_login_unavailable
GET /Account/ExternalLoginPending(无 external cookie)
→ 404(确认页据此提示"外部登录已过期,请重新登录")
GET /Account/ExternalLoginCallback?returnUrl=%2F&error=access_denied
→ 302 /Account/Login?ReturnUrl=%2F&error=external_provider_errorGET /callback/login/github?code=x&state=y
→ 400 Bad Request (路由已被 AuthenticationController 接管;state 校验失败属预期,不是 404)本地自测(没有真实 OAuth App 也能测一半)
$env:ASPNETCORE_ENVIRONMENT='Development'
$env:OAuth__External__GitHub__ClientId='dummy-client-id'
$env:OAuth__External__GitHub__ClientSecret='dummy-client-secret'
dotnet run --no-launch-profile --urls "https://localhost:7016"然后:
GET /Account/ExternalProviders→[{"name":"GitHub","displayName":"GitHub"}]- 登录页出现「使用 GitHub 登录」按钮,点击会跳到 GitHub 授权页(dummy client_id 到这一步就结束)
- 真实兑换需要自建 GitHub OAuth App(本项目未端到端验证过真实兑换)
🎓 读到这里,一条完整的登录链路你就走完了(① 授权请求 → ② 登录 → ③ 同意 → ④ 换 token → ⑤ 登出 → ⑥ 外部登录)。
下一步建议看 学完之后:下一步学什么:官方 16 个示例分别在讲什么、推荐按什么顺序继续学、示例故意没讲的生产化清单,以及一份"10 个问题"的自测表——能答上来就说明这条链路真的学透了。