① 授权请求与未认证 Challenge
谁发起
客户端 userManager.signinRedirect() → 顶层导航:
GET https://localhost:7016/connect/authorize
?client_id=client-demo
&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fsignin-callback
&response_type=code
&scope=openid%20offline_access%20demo_api
&state=<客户端随机串>
&code_challenge=<base64url(SHA256(verifier))>
&code_challenge_method=S256
&nonce=<随机串>为什么必须是顶层导航
响应是 302(跳登录页 / 同意页 / 回调地址)。fetch 拿不到跨源重定向的 Location,也没法让浏览器种 cookie。
OP 端:AuthorizationController.Authorize
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
var result = await HttpContext.AuthenticateAsync();
if (result is not { Succeeded: true } ||
((request.HasPromptValue(PromptValues.Login) || request.MaxAge is 0 ||
(request.MaxAge is not null && result.Properties?.IssuedUtc is not null &&
TimeProvider.System.GetUtcNow() - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) &&
TempData["IgnoreAuthenticationChallenge"] is null or false))
{
...
}
...
}这段 if 是整个登录流程的分岔口,满足其中任一条件就要去登录页:
| 条件 | 含义 |
|---|---|
AuthenticateAsync() 不成功 | 没有 Identity 应用 cookie(没登录 / cookie 过期) |
prompt=login | 客户端要求"必须重新输密码"(例如刚登出,或点了"切换账号") |
max_age=0 | 等价 prompt=login |
max_age=N 且 cookie 的 IssuedUtc 已超过 N 秒 | 会话不够"新鲜"(prompt=none 之外也常见) |
TempData["IgnoreAuthenticationChallenge"] is null or false | 防死循环的开关(见下) |
HttpContext.AuthenticateAsync() 不带 scheme = 默认 scheme(本项目里是 Identity 应用 cookie)。 注意这里断言的是 Succeeded: true——AuthenticateAsync 对"没有 cookie"不会抛异常,只会返回未成功。
防无限跳转的 TempData 开关
TempData["IgnoreAuthenticationChallenge"] = true;
return Challenge(new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
Request.HasFormContentType ? Request.Form : Request.Query)
});Challenge(...)会交给 Identity cookie 处理器处理 → 302 到LoginPath(本项目/Account/Login)并附?ReturnUrl=<被拦截的地址>。- 但"登录成功后顶层导航回 ReturnUrl"会让浏览器又一次打到
/connect/authorize。 如果此时prompt=login还在(或 cookie 的max_age判定仍不满足),就会又 Challenge 回登录页 → 无限循环。 - 所以先写
TempData["IgnoreAuthenticationChallenge"] = true:TempData 是一次性会话数据(读过即删), 下一次请求里它还在 →if条件里的... && TempData[...] is null or false为假 → 跳过整个分支 → 直接按"已认证"继续走。 - 换句话说:"刚从登录页回来"这一次,即使
prompt=login也不再强制重登,避免死循环。
TempData 不是"用户真的重新认证过"的证明
官方注释特意说明:这个标记不保证用户真的重新认证了。如果业务要求"必须重新认证", 正确做法是删掉旧 cookie 并撤销(比如用 Identity 的 security stamp + 极短的重验证间隔),再发起 Challenge。
prompt=none 的特殊处理(不能跳登录页)
if (request.HasPromptValue(PromptValues.None))
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in."
}));
}prompt=none 的语义是"别弹界面,能授权就直接给,不能就报错"(静默刷新常用)。 所以这里必须返回错误而不是跳登录页:Forbid(OpenIddict scheme) 会让 OpenIddict 按 response_mode 把 error=login_required 回给客户端。
实际响应(本项目实测)
HTTP/1.1 302 Found
Location: https://localhost:7016/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dclient-demo%26...Location 的参数顺序就是客户端发来的顺序,ReturnUrl 是被 URL 编码后的原始授权请求。
谁决定 302 到哪
不是控制器写死的,是 Identity cookie handler 用的默认落点: LoginPath = /Account/Login(CookieAuthenticationDefaults.LoginPath)、附带的参数名 ReturnUrl (CookieAuthenticationDefaults.ReturnUrlParameter)。 本项目 Program.cs 没有配置 ConfigureApplicationCookie,这两项(以及过期时间 14 天)都走 Cookie 认证的默认值; appsettings 里的 OAuth:LoginPageUrl 供 AccountController 的失败回跳等页面端点使用。
下一步
浏览器落在 /Account/Login(React 登录页)→ ② 登录