AccountController(React 页面的后端)
文件:backend/Yagang.Identity/Yagang.Identity.Api/Controllers/AccountController.cs
为什么路由是 /Account/...(而不是 Identity UI 的 /Identity/Account/...)
Identity 自带的默认 UI 是一套 Razor Pages,URL 前缀是 /Identity/Account/...。 本项目没有视图、没有区域(纯 Web API + React 静态页):控制器统一放 Controllers/,路由直接用默认的 [Route("[controller]")] → /Account/...。
| 端点 | 对应 Identity 默认 UI 的什么 |
|---|---|
POST /Account/Login | Identity 默认 UI 的登录页(Account/Login.cshtml.cs) |
GET /Account/Antiforgery | Razor 表单里的 @Html.AntiForgeryToken() |
GET /Account/ExternalProviders | 默认 UI 登录页上枚举外部提供方按钮的逻辑 |
POST /Account/ExternalLogin(表单字段 provider,returnUrl 在 query) | 默认 UI ExternalLogin 页的 OnPost(发 Challenge);GET /Account/ExternalLogin → 302 回登录页(= 默认 UI 的 OnGet) |
GET /Account/ExternalLoginCallback | 默认 UI ExternalLogin 页的 OnGetCallbackAsync(先 FindByLoginAsync 精确判"未绑定"→ 确认页;已绑定 → ExternalLoginSignInAsync,未知结果兜底 external_login_failed) |
GET /Account/ExternalLoginPending | 确认页读取 external cookie 里的外部身份(provider / 邮箱) |
POST /Account/ExternalLoginRegister | 默认 UI ExternalLogin 页的 OnPostConfirmationAsync(用户确认后创建本地账号并绑定) |
前端四个页面与之同址:登录页 GET /Account/Login 与上面的 POST /Account/Login 同名; 同意页 GET /Account/Consent、登出确认页 GET /Account/Logout、外部账号确认页 GET /Account/ExternalLoginConfirm 由 React Router 渲染, 提交仍分别回 POST /connect/authorize、POST /connect/logout、POST /Account/ExternalLoginRegister。
0. 为什么这几个端点要"自己写"?
因为它们不是 OAuth/OIDC 协议端点,而是"登录页面的后端"。在"直接用 ASP.NET Core Identity 默认 UI"的项目里, 这些逻辑由框架的 Razor Pages 提供,不需要自己写:
| Identity 默认 UI 里由谁提供 | 具体位置 | 干了什么 |
|---|---|---|
| Identity 默认 UI 登录页 | Areas/Identity/Pages/Account/Login.cshtml(.cs) | 渲染用户名/密码表单 + 调 SignInManager.PasswordSignInAsync;同时枚举外部提供方渲染"用 GitHub 登录"按钮 |
| Identity 默认 UI 外部登录页 | Account/ExternalLogin.cshtml(.cs) | 按钮发 Challenge(ExternalLogin POST)、回调后落地用户(ExternalLoginSignInAsync + 建/关联用户) |
| Razor 表单机制 | @Html.AntiForgeryToken() | 自动生成防伪隐藏字段与 cookie |
本项目的 UI 是 React 静态页面(没有 Razor Pages、没有 Identity 默认 UI),所以上面三件事必须自己实现成 HTTP 端点:
浏览器(React 页面) ──► 这三个自定义端点(页面后端,返回 JSON / 302)
│
└─ 真正属于协议的端点仍然由框架/控制器处理:
/connect/authorize、/connect/token、/callback/login/{provider} …为什么不再模拟 Identity 默认 UI 的 URL?(2026-09-29 调整) Areas/ 目录、[Area] 特性与 [area] 令牌只对 MVC 的视图查找 / 区域约定路由有意义;本项目没有视图、只用特性路由(app.MapControllers()), 继续挂着 Identity 区域属于"为对齐而对齐"。现在改为常规做法:文件放 Controllers/,路由用默认的 [controller](→ /Account/Login 等)。
顺带回答"为什么不用 .AddDefaultUI()"
.AddDefaultUI() 能换来"零代码的登录页",代价是 UI 被 Razor Pages 绑死。 本项目的界面要由 React 承担,就必须把默认 UI 做的三件事(登录提交、外部登录入口、防伪令牌)自己接管—— 也就是上面这张表里的端点。
附:.AddDefaultUI() 到底给你了什么(实测)
它是 NuGet 包 Microsoft.AspNetCore.Identity.UI 里的一个 Razor 类库(RCL)—— 页面代码不在你的仓库里,是包内预编译好的 Razor Pages,挂在 Identity 区域下:
| 实测项 | 结果 |
|---|---|
| 页面 URL(路由模板) | Identity/Account/Login、Register、Logout、ExternalLogin、ForgotPassword… Identity/Account/Manage/*、Identity/Error |
| 页面里有没有防伪字段 | 有:/Identity/Account/Login 的 HTML 里带 1 个 __RequestVerificationToken(就是 @Html.AntiForgeryToken() 的产物) |
| 未认证时 challenge 落点 | /Identity/Account/Login?ReturnUrl=... |
不用 AddDefaultUI() 时 | challenge 落点是 Identity 的内置默认值 /Account/Login(那个路径没有页面 → 404) |
也就是说:AddDefaultUI() 不只是"加页面",它还把 Identity 应用 cookie 的 LoginPath / LogoutPath / AccessDeniedPath 改写成了 RCL 里的实际页面地址( IdentityDefaultUIConfigureOptions 干的)。这也解释了为什么官方模板里还额外写了一遍 options.LoginPath = "/Identity/Account/Login"——如果你脚手架/自定义了页面路径,就得自己对上。
默认 UI 也不是"纯零代码"
它还要求你的应用提供 Pages/Shared/_LoginPartial.cshtml(或 Views/Shared/_LoginPartial.cshtml), 否则访问页面会直接 500:
The default Identity UI layout requires a partial view '_LoginPartial' usually located at '/Pages/_LoginPartial' or at '/Views/Shared/_LoginPartial' to work.
项目模板里那个 _LoginPartial.cshtml 就是给它用的(导航栏里的登录/注册/退出链接)。
1. POST /Account/Login(表单 POST,与 Identity 默认 UI 的 Login 页同构)
// 防伪令牌用 [ValidateAntiForgeryToken] 标注:失败 → 400 空响应体(与 Identity UI 的 Razor Page 一致)。
// 表单值直接绑到参数上(MVC 自动绑定;字段名大小写不敏感,ReturnUrl 对应框架默认参数名)。
[HttpPost("Login"), ValidateAntiForgeryToken]
public async Task<IActionResult> Login(
[FromForm] string? returnUrl, [FromForm] string? username,
[FromForm] string? password, [FromForm] string? rememberMe)
{
// 空值 → Url.Content("~/")(站点根);成功时交给 LocalRedirect(内部 Url.IsLocalUrl 防开放重定向,
// 非站内 URL 抛异常 → 500,fail-fast,与 Identity UI 完全一致)。
returnUrl ??= "/";
var persistent = request.RememberMe; // 登录页"记住我"复选框(前端固定提交 "true")
var result = await signInManager.PasswordSignInAsync(
username ?? string.Empty, password ?? string.Empty,
isPersistent: persistent, lockoutOnFailure: true); // 勾选"记住我" → 持久 cookie
return result.Succeeded
? LocalRedirect(returnUrl) // 成功:回原授权请求(内部校验防开放重定向)
: Redirect(ErrorRedirect(oauthOptions.Value.LoginPageUrl, returnUrl, "invalid_credentials",
username, persistent)); // 失败:回登录页 + 预填用户名/记住我
}请求 / 响应契约:
GET /Account/Antiforgery # 先取令牌(同时种下 HttpOnly 的 cookie 令牌)
→ {"token":"CfDJ8...","formFieldName":"__RequestVerificationToken"}
POST /Account/Login
Content-Type: application/x-www-form-urlencoded
ReturnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dclient-demo
&username=alice&password=<演示密码>
&__RequestVerificationToken=CfDJ8... # ← 隐藏字段(HTML 表单没法自定义请求头)302 Location: /connect/authorize?client_id=client-demo # 成功(+ Set-Cookie: .AspNetCore.Identity.Application=...)
500(无 Location) # 成功校验通过后 ReturnUrl 非站内 → LocalRedirect 抛异常(同 Identity UI)
302 Location: /Account/Login?ReturnUrl=...&error=invalid_credentials&username=alice
400(空响应体) # 缺令牌 / 与 cookie 令牌不配对 / 令牌过期([ValidateAntiForgeryToken],同 Identity UI)ErrorRedirect(loginPage, returnUrl, error, username?) 的第 4 个参数是预填用的: 失败回登录页时把用户已输入的用户名带回去(等价 Identity 默认 UI 用 ModelState 回显 Input.Email), 它只用于页面预填(React 渲染时转义),服务端不拿它做任何判断。
失败也是 302 而不是 4xx:请求是表单导航,服务端必须告诉浏览器"下一步去哪"。 Identity 默认 UI 的做法是"回登录页 + ModelState 错误",本项目没有服务端渲染 → 等价成"回登录页 + ?error=", 由 React 页面把 error 映射成中文提示、并把 username 预填回去。 页面上"提交中"的按钮 loading + 全屏遮罩见 表单登录与 loading(业界做法)。
2. GET /Account/Antiforgery(防伪请求令牌)
[HttpGet("Antiforgery")]
public IActionResult Antiforgery()
{
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
return Ok(new
{
token = tokens.RequestToken,
// 名字直接读框架配置(Program.cs 的 AddAntiforgery),避免前后端写死不一致。
formFieldName = antiforgeryOptions.Value.FormFieldName // __RequestVerificationToken
});
}{"token": "CfDJ8...(150 字符左右)", "formFieldName": "__RequestVerificationToken"}它做的事等价于 Razor 里的 @Html.AntiForgeryToken():生成一对令牌并只把"请求令牌"交给页面。
| 令牌 | 存在哪 | JS 能读到吗 | 作用 |
|---|---|---|---|
| cookie 令牌 | 响应 Set-Cookie: .AspNetCore.Antiforgery.*(HttpOnly,框架自动写) | 不能 | 校验时的"另一半",绑定这次会话 |
| 请求令牌 | 本端点的 JSON(页面放进隐藏字段) | 能 | 随表单一起提交,和 cookie 令牌配对 |
所以攻击者光有"跨站发请求的能力"不够:它既拿不到 cookie 令牌(HttpOnly),也读不到请求令牌(同源端点 + 浏览器同源策略)。
为什么要"下发"而不是像 Razor 那样直接渲染
Razor 视图里 @Html.AntiForgeryToken() 是服务端渲染时把隐藏字段写进 HTML 的;React 页面是静态文件, 没有服务端渲染 → 只能多一次 GET 把令牌拿回来,再由 JS 塞进表单/请求头。这也是"页面后端要自己写"的典型例子。
3. 防伪令牌怎么提交:本项目统一走"表单字段"
| 页面 | 提交方式 | 令牌放哪 | 名字 |
|---|---|---|---|
| 登录页 | 原生表单 POST | 隐藏字段 | __RequestVerificationToken(AntiforgeryOptions.FormFieldName,框架默认) |
| 同意页 | 原生表单 POST | 隐藏字段 | 同上 |
| 登出页 | 原生表单 POST | 隐藏字段 | 同上 |
| 外部账号确认页 | 原生表单 POST | 隐藏字段 | 同上 |
框架另外还支持"请求头"这条通道(AntiforgeryOptions.HeaderName,默认名 RequestVerificationToken), 它是给 fetch/XHR 场景用的——HTML 表单无法自定义请求头,所以本项目四个页面都用不上它, Program.cs 里也就只需要默认的:
builder.Services.AddAntiforgery();框架怎么决定"查头还是查字段"
[ValidateAntiForgeryToken](MVC 过滤器)和 IAntiforgery.ValidateRequestAsync(手写校验)都会 先查请求头、再查表单字段;官方文档补充说明:两者同时提供时,只校验请求头里的那个。 所以只有"用头"的调用方需要注意别同时带一个过期的字段。
4. 外部登录:三个入口 + 一个确认页 + 一个注册端点
| 端点 | 作用 |
|---|---|
GET /Account/ExternalProviders | SignInManager.GetExternalAuthenticationSchemesAsync() 列出带 DisplayName 的认证 scheme(配置了 GitHub 才会有),登录页据此决定是否显示"使用 GitHub 登录"按钮 |
POST /Account/ExternalLogin?returnUrl=...(表单字段 provider=GitHub,Identity UI 同款按钮) | 按名字找到 scheme → Challenge(scheme) → 302 GitHub 授权页(PKCE / state 由 OpenIddict Client 处理);未知 provider → 404;GET 同路径 → 302 回登录页 |
GET /Account/ExternalLoginCallback?returnUrl=... | 回调落地:GetExternalLoginInfoAsync() 读 Identity external cookie(5 分钟);已绑定 → ExternalLoginSignInAsync 直接登录;未绑定 → 302 /Account/ExternalLoginConfirm;远端错误 / cookie 过期 / 账号锁定分别回 ?error= |
GET /Account/ExternalLoginPending | 确认页展示用:从 external cookie 读 provider / 显示名 / 邮箱(不把邮箱写进 URL);无 cookie → 404 |
POST /Account/ExternalLoginRegister | 用户在确认页显式确认邮箱后:创建本地账号(无本地密码)→ AddLoginAsync 绑定 → SignInAsync → LocalRedirect(returnUrl);邮箱重名 / 已被绑定 → 回确认页带 ?error= |
核心安全取舍:绝不按邮箱自动关联已有账号(那等于把"外部邮箱相同"当成身份证明,是账号接管的典型成因)。 只有用户在这个确认页显式点击"创建账号并绑定",才会建立本地账号与外部身份的关联。
5. IsLocalUrl:防开放重定向
/// <summary>只允许站内相对地址(防开放重定向)。</summary>
private static bool IsLocalUrl(string? url)
=> !string.IsNullOrEmpty(url) && url[0] is '/' && (url.Length is 1 || url[1] is not '/' and not '\\');- 允许
/connect/authorize?...; - 拒绝
//evil.com、/\evil.com、https://evil.com(后两者在第 2 个字符或第 1 个字符就被挡下)。 - 用在哪:外部登录端点(
ExternalLogin/ExternalLoginCallback/ExternalLoginRegister的returnUrl,统一经SafeReturnUrl)。POST /Account/Login现在不用它了——成功重定向走LocalRedirect(框架内联同样的校验), 非站内 URL 会抛异常 → 500,与 Identity 默认 UI 完全一致(2026-10-02 起,见 ② 登录页)。
为什么必须挡
returnUrl 来自用户可改的 URL。若不校验,攻击者可以构造 /Account/ExternalLoginCallback?returnUrl=https://evil.com,在用户登录完成后把浏览器送到钓鱼站。
小结:七个端点的共同点
| 共同点 | 原因 |
|---|---|
都是 ControllerBase 的 action([HttpGet] / [HttpPost] 特性路由),不是 OpenIddict 协议端点 | 它们不是 OAuth 协议的一部分,只是"页面后端" |
| 都不需要 CORS | 登录页与 OP 同源 |
只返回 JSON / Redirect / LocalRedirect,不渲染任何视图 | 页面在 React 侧 |