② 登录页与 POST /Account/Login
与 Identity 默认 UI 同构:表单 POST + LocalRedirect
Identity 默认 UI 的登录页是"表单 POST → SignInManager.PasswordSignInAsync → LocalRedirect(ReturnUrl)(302)"。 本项目的前端是 React(静态页面、没有服务器端渲染),但保持了同一套语义:
React 登录页(原生 <form>) ──表单 POST──▶ POST /Account/Login(自定义端点)
成功 → 302 回表单里的 ReturnUrl(原授权请求) 失败 → 302 回登录页并附 ?error=...为什么用表单而不是 fetch
- 表单提交是浏览器顶层导航,服务端返回的 302 由浏览器自己跟随,地址栏/页面都会正确变化;
- 这也让四个 React 页面(登录 / 同意 / 登出 / 外部账号确认)用同一种机制提交:原生
<form>+ 隐藏字段__RequestVerificationToken; - 对比之下,
fetch只能"拿到 JSON 再自己导航"(旧实现),而且没法带表单隐藏字段、也没法让服务端直接 302。
React 登录页(src/pages/Login.tsx)
读 ReturnUrl 与展示授权参数
const { returnUrl, authParams } = (() => {
const search = new URLSearchParams(window.location.search)
const url = search.get('ReturnUrl') || search.get('returnUrl') || ''
const query = url.includes('?') ? url.slice(url.indexOf('?')) : ''
return { returnUrl: url, authParams: readAuthParams(query) }
})()
const loginReturnUrl = returnUrl.startsWith('/') ? returnUrl : '/' // 表单隐藏字段的值ReturnUrl来自 OP 的 Challenge(就是 ① 里的原始授权请求)。- 页面把里面的参数解析出来展示("查看本次授权请求参数");直接打开登录页(没有 ReturnUrl)时回首页
/。 - 2026-10-02 删除:原先还有一段"没有 ReturnUrl 时按当前 URL 的 query 重建 authorize 地址并删
prompt"的兜底。 它是 2026-09-27 表单 POST 改造前的残留(那之前 Challenge 直接把授权参数裸放在登录页 URL 上,读window.location.search是主路径); 当前架构下/Account/Login的四个入口(Challenge / 登录失败 / 外部登录回调 / 直接打开)全部带ReturnUrl或无参数,没有任何路径会产生"裸授权参数"的登录页 URL,属死代码。
提交:一个原生表单,三个隐藏字段
{/* 原生表单 POST(不是 fetch):服务端的 302 由浏览器跟随,客户端地址栏/页面才会正确变化 */}
<form method="post" action={`${AUTHORITY}/Account/Login`} onSubmit={handleSubmit}>
<input type="hidden" name="ReturnUrl" value={loginReturnUrl} />
<input type="hidden" name={formFieldName} value={token} /> {/* __RequestVerificationToken */}
<Input id="username" name="username" autoComplete="username" defaultValue={prefilledUsername} required ... />
<Input.Password id="password" name="password" autoComplete="current-password" required ... />
<Checkbox name="rememberMe" defaultChecked={rememberMePrefill}>记住我</Checkbox> {/* 未勾选不提交该字段 */}
<Button type="primary" htmlType="submit" disabled={!token} loading={submitting}>登录</Button>
<Spin spinning={submitting} fullscreen description="正在登录…" />
</form>| 字段 | 从哪来 | 干什么 |
|---|---|---|
username / password | 用户输入(antd Input 的 name 会透传到原生 <input>) | 交给 SignInManager.PasswordSignInAsync |
ReturnUrl | OP 的 Challenge(= 原始授权请求;直接打开登录页时没有 → 用 /) | 成功后 302 回哪去 |
__RequestVerificationToken | GET /Account/Antiforgery(页面挂载时取,取不到就禁用提交按钮) | 防伪令牌(与 HttpOnly 的 cookie 令牌配对) |
rememberMe(可选) | 登录页"记住我"复选框(未勾选时浏览器不提交该字段) | 传给 PasswordSignInAsync(isPersistent: ...):勾选 → 持久 cookie(带 expires),不勾 → 会话 cookie |
两个要点:
prompt=login的防死循环由服务端负责:正常路径把含prompt的ReturnUrl原样回传, "刚从登录页回来"这一次由 ① 的TempData["IgnoreAuthenticationChallenge"]跳过 Challenge(前端不再删prompt)。- 服务端由
LocalRedirect再校验一次ReturnUrl(内部Url.IsLocalUrl,防开放重定向)——客户端那层只是让正常流程更顺。 与 Identity 默认 UI 完全一致:非站内 URL 会抛异常 → 500(fail-fast),而不是静默回退到登录页 (2026-10-02 起由"前置IsLocalUrl+ 回退"改成完全对齐 Identity UI;脚本断言同步为 500)。
提交中的 loading(渐进增强)
表单提交是顶层导航,但页面不会在点下按钮的瞬间消失:浏览器要等新文档开始接管才卸载旧页面。 所以 onSubmit 里把 submitting 置为 true → 按钮 loading + <Spin fullscreen description="正在登录…">, "服务端校验 + 网络往返"期间用户看到的就是转圈(MSA / Google 登录页同款效果)。
// 不调用 preventDefault:让浏览器按原生流程提交;只在"已经在提交中"时拦截重复提交
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
if (submitting) { event.preventDefault(); return }
setSubmitting(true)
}细节与三条硬规则(别给带 name/value 的提交按钮加 disabled、别 preventDefault、用 SubmitEvent 的 nativeEvent.submitter 区分按钮)见 表单登录与 loading(业界做法)。
OP 端:POST /Account/Login
Controllers/AccountController.cs(2026-09-29 起与其他控制器统一放 Controllers/、路由改用默认的 [controller]):
app.MapPost("/Account/Login", async (
HttpContext context, IAntiforgery antiforgery, SignInManager<ApplicationUser> signInManager) =>
{
var form = await context.Request.ReadFormAsync();
// ReturnUrl = 表单隐藏字段(= 原 /connect/authorize 请求;字段名引用框架默认值 CookieAuthenticationDefaults.ReturnUrlParameter)。与 Identity 默认 UI 的 Login 页同构:
// 空值 → Url.Content("~/")(站点根);成功时交给 LocalRedirect——它内部用 Url.IsLocalUrl 校验,
// 非站内 URL 抛异常(500)。这是 Identity UI 的 fail-fast 语义,本项目完全对齐(不做静默回退)。
var returnUrl = form[CookieAuthenticationDefaults.ReturnUrlParameter].ToString();
if (string.IsNullOrEmpty(returnUrl))
{
returnUrl = "/"; // = Url.Content("~/")
}
// ① 防伪令牌:用 [ValidateAntiForgeryToken] 标注(隐藏字段 __RequestVerificationToken + HttpOnly cookie 配对),
// 失败是 400 空响应体 —— 与 Identity UI(Razor Pages 自动防伪校验)完全一致,不再回 302。
// ② 登录(rememberMe 来自"记住我"复选框)
var result = await signInManager.PasswordSignInAsync(
username, password, isPersistent: rememberMe, lockoutOnFailure: true);
// 成功 → 302 回原授权请求(接着走同意页 / 签 code);失败 → 302 回登录页(React 页面从 ?error= 显示提示)
return result.Succeeded
? LocalRedirect(returnUrl) // 内部 Url.IsLocalUrl:非站内 URL → 抛异常(500,同 Identity UI)
: Results.Redirect(ErrorRedirect("/Account/Login", returnUrl, "invalid_credentials"));
});逐个参数解释:
| 参数 | 值 | 含义 |
|---|---|---|
isPersistent | 登录页"记住我"复选框 | 勾选 → 持久 cookie(带 expires,关浏览器仍在,票据仍受 14 天滑动过期约束);不勾(默认)→ 会话 cookie(关浏览器即失效)。与 Identity 默认 UI 的 Input.RememberMe 同构 |
lockoutOnFailure | true | 连续失败会锁定账户(SignInManager 维护 AccessFailedCount) |
| 返回 | 302 ReturnUrl(成功,经 LocalRedirect)/ 302 /Account/Login?ReturnUrl=...&error=...(失败) | 与 Identity 默认 UI 的 LocalRedirect + "回登录页带错误"同构;非站内 ReturnUrl 由 LocalRedirect 抛异常 → 500(fail-fast,与 Identity UI 一致);响应不再是 JSON |
关于登录 CSRF:唯一闸门是防伪令牌(与 Identity UI 一致)
登录是"跨站也能种 cookie"的端点,存在登录 CSRF(攻击者把受害者登进攻击者账户)。本项目按 Identity UI 只保留一层:
- 防伪令牌:页面先
GET /Account/Antiforgery拿 request token(同源,攻击者读不到), 放进隐藏字段__RequestVerificationToken,与框架写成 HttpOnly 的 cookie 令牌配对校验 ([ValidateAntiForgeryToken])→ 缺令牌即 400 空响应体(Identity UI 同款); - 原来的"同源 Origin 校验"(302
error=invalid_origin)已删除(2026-10-05,用户要求与 Identity UI 对齐)—— Identity UI 不做 Origin 校验;并且实测本项目里框架的"自动 CSRF 中间件"并没有兜住跨站表单 POST (有效令牌 +Origin: https://evil.example仍会登录成功),CORS 也只管 fetch/XHR、不保护表单导航。 所以攻击者拿不到令牌就过不了第 1 层,这就是与 Identity UI 相同的安全模型。
为什么四个页面都用"表单字段"而不是请求头
登录 / 同意 / 登出 / 外部账号确认都是原生表单 POST(登录页要服务端 LocalRedirect;同意、登出页要浏览器自己跟随 302 回客户端), 而 HTML 表单无法自定义请求头 → 统一用隐藏字段 __RequestVerificationToken(框架默认 FormFieldName)。 请求头通道(AntiforgeryOptions.HeaderName,默认名 RequestVerificationToken)是给"fetch/XHR 提交"的场景用的,本项目用不到—— 所以 Program.cs 里只需要默认的 AddAntiforgery()。 详见 AccountController 走读。
那登录端点"必须"校验令牌吗?(诚实的取舍)
从规范上说不是必须。官方 CSRF 文档说:"从跨源 SPA 调用的 API 可以结合 CORS 允许列表使用自动 CSRF 中间件; 如果 API 从不提供 HTML 表单,则可以完全跳过令牌系统。" 但本项目提供 HTML 表单(浏览器顶层导航 POST), 且实测自动中间件不生效 → 令牌就是实际生效的那道闸门(Identity UI 的 Razor Pages 也是靠自动令牌校验)。
本项目校验令牌的理由:① 服务器签发、攻击者跨站读不到,是可靠的独立证据;② 一致性(四个页面一套机制)。 代价:客户端要多一次 GET /Account/Antiforgery;curl/脚本必须先取令牌并保持 cookie 会话。
登录成功后发生了什么
SignInManager用 Identity 的 token provider 生成认证票据 → 写入.AspNetCore.Identity.Application(默认名,HttpOnly,14 天滑动——Identity / Cookie 认证默认值)。- 浏览器顶层导航回
ReturnUrl= ① 的/connect/authorize?...。 - 这次
AuthenticateAsync()成功 → 进入授权判定(③ 同意)。
登录失败的错误形态
| 场景 | 响应 | 页面表现 |
|---|---|---|
| 密码错 / 用户不存在 | 302 /Account/Login?ReturnUrl=...&error=invalid_credentials&username=alice | "用户名或密码错误" + 用户名预填 |
| 缺 / 错防伪令牌 | 400(空响应体,[ValidateAntiForgeryToken]) | 浏览器停在空白 400 页 —— 与 Identity UI 一致(Razor Pages 自动防伪校验同样是空白 400) |
| 账户被锁 | 302 ...&error=invalid_credentials | 同上(demo 未细分 IsLockedOut) |
为什么失败还要带 username 回来
等价 Identity 默认 UI 用 ModelState 回显 Input.Email——失败了不该让用户重打账号。 ErrorRedirect(...) 的第 4 个参数就是它;它是用户输入,只用于页面预填(React 渲染时转义),不做任何判断。
为什么用 302 + ?error= 而不是 400/401 JSON
因为请求是表单提交(浏览器顶层导航),服务端必须给出"下一步去哪"。 Identity 默认 UI 的做法是"回登录页并显示 ModelState 错误",本项目没法服务端渲染, 所以等价成"302 回登录页带 error 查询参数",由 React 页面把 error 映射成中文提示。
这条路径的关键取舍
服务端只负责"校验 + 302":成功回 ReturnUrl,失败回登录页带 error / username; 登录页的文案与交互全部由 React 决定(错误码到中文提示的映射在 Login.tsx)。