坑速查
按"症状 → 根因 → 处理"列。全部是这个项目真实踩过的(含改造前的手工会话时代)。
一、启动 / 构建类
1. Unable to resolve service for type 'ITempDataDictionaryFactory'
- 症状:启动后访问
/connect/authorize500。 - 根因:用了
AddControllers(),而AuthorizationController用了TempData(防死循环标记)。 - 处理:改成
AddControllersWithViews()(本项目不渲染视图,只是需要它注册TempData与防伪过滤器相关服务)。
2. 启动抛 OpenIddict 选项校验异常(授权码流程必须有重定向端点)
- 症状:
SetRedirectionEndpointUris缺失时启动失败。 - 根因:启用某流程 ⇒ 必须有对应端点;GitHub 改成条件注册后,"没配凭据"时就没有端点。
- 处理:在
AddClient里显式options.SetRedirectionEndpointUris("callback/login/github")。
3. 旧 database.db 缺 Identity 表 → 运行时 500 "no such table: AspNetUsers"
- 根因:
EnsureCreatedAsync对已存在的库不会补建缺失的表。 - 处理:启动时检测
AspNetUsers是否存在,不存在就整库重建(本项目已实现)。
4. 删库文件报"正被另一进程使用"(Windows)
- 根因:SQLite 连接池持有文件句柄;
DbContext还开着连接。 - 处理:csharp
await context.Database.CloseConnectionAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); await context.Database.EnsureDeletedAsync();
5. 端口/exe 被占用:MSB3021 ... being used by another process
- 根因:上一次
dotnet run的进程还在(或 VS Code 的调试进程)。 - 处理:先停掉进程再 build/run。用
Get-CimInstance Win32_Process找到具体 PID 后Stop-Process -Id <PID>。
6. 重命名 backend/ 子目录 ACCESS DENIED
- 症状:VS Code / .NET 工具链正在使用该子树时,重命名该子树内的既有目录一律失败(新建的空目录可以改)。
- 绕过:改"逐层搬子项 + 删除空目录"(移动子项与删除空目录都正常)。
- 教训:Windows 上搬目录,先关掉可能持有句柄的 IDE 进程,或用"搬内容"而不是"搬目录"。
二、登录 / cookie 类
7. 登录后仍被要求登录(死循环)
- 症状:
/connect/authorize→ 登录页 → 登录成功 → 又回登录页。 - 根因:原请求带
prompt=login(或max_age判定不满足),导航回去又被 Challenge。 - 处理:①
TempData["IgnoreAuthenticationChallenge"]跳过"刚从登录页回来"的那一次——这是唯一防线,在服务端(见 ① 授权请求 的 Challenge 逻辑)。 - 历史注(2026-10-02):原先这里还列了"第②层 = React 登录页兜底重建地址时删掉
prompt",已删除—— 那是 2026-09-27 表单 POST 改造前的残留,正常路径根本不会执行(详见 ② 登录页 的"读 ReturnUrl"一节)。
8. fetch 打 /connect/authorize 拿不到结果
- 根因:响应 302 对
fetch是"不透明重定向",JS 读不到Location,也没有 cookie 语义。 - 处理:登录页用表单 POST 交给浏览器(服务端 302 回 ReturnUrl);同意 / 登出页同理必须用表单,不能用 fetch。 同意页/登出页同理必须用普通表单 POST。
9. 直接访问受保护页面 302 到 404
- 根因:cookie 认证的默认
LoginPath是/Account/Login;如果登录页实际不在这个地址(或该地址没有页面),Challenge 落地就是 404。 - 处理:本项目登录页恰好就是
/Account/Login(React 页面),默认值直接可用; 万一以后改了登录页地址,要显式设置options.LoginPath,并让 appsettings 的OAuth:LoginPageUrl与它一致。
10. 登录 CSRF / Antiforgery 相关结论
- API(
/weatherforecast)走 Bearer,没有 CSRF 攻击面,不需要防伪; POST /Account/Login是"跨站也能种 cookie"的端点 → 用同源 Origin 校验关闭面(本项目做法);- 同意 / 登出 POST 用
[ValidateAntiForgeryToken],React 侧用GET /Account/Antiforgery取令牌(表单无法自定义请求头 → 统一走隐藏字段)。
三、同意流程类
11. 每次登录都弹同意页
按可能性排查:
- 客户端
ConsentType不是Explicit?(本项目是 Explicit,符合预期会弹) - 服务重启过?(清空了
oi_ath) - 请求的 scope 变了?(永久授权按 scope 精确匹配,多一个少一个都不算命中)
- 客户端带了
prompt=consent?(强制再确认)
12. 点"拒绝"后客户端收到 error=access_denied
- 这是预期行为(
ID2015),由Deny()的Forbid(OpenIddict scheme)产生。
13. 同意页 / 登出页 / 登录页表单提交防伪失败
- 根因:没先
GET /Account/Antiforgery,或提交时漏了隐藏字段__RequestVerificationToken(AntiforgeryOptions.FormFieldName,框架默认名)。 - 注意:这个 GET 会种下 HttpOnly 的 cookie 令牌(
.AspNetCore.Antiforgery.*);先清 cookie 再拿令牌是最稳的测试姿势。 - 原生表单 POST 无法自定义请求头,所以四个页面统一用表单字段;请求头通道(
HeaderName,默认RequestVerificationToken)是给 fetch/XHR 场景的,本项目用不到。
13′. 登录后又被送回登录页,URL 上带 ?error=...
- 症状:
POST /Account/Login返回 302,Location 是/Account/Login?ReturnUrl=...&error=invalid_credentials(账号密码错);缺/错防伪令牌则是 400 空响应体([ValidateAntiForgeryToken],与 Identity UI 一致;本项目不再做 Origin 校验)。 - 根因:登录是表单 POST,失败不会返回 JSON,而是"302 回登录页 + error 查询参数", 由 React 页面把
error映射成中文提示。 - 修法:① 缺令牌 → 先
GET /Account/Antiforgery(cookie jar 保存 cookie 令牌)并把token放进隐藏字段; ② 用 curl 测试时别忘-b/-c cookies.txt保持会话(cookie 令牌与请求令牌必须配对)。
13″. 同意页按回车 / 参数丢失 → 再点"同意"报 client_id is missing(ID2029)
- 症状:在同意页按回车(表单里没有
submit.Accept/submit.Deny)后,页面变成一个没有参数的同意页; 再点"同意"直接报error:invalid_request … The mandatory 'client_id' parameter is missing。 - 根因:
Authorize同时接 GET 与 POST。缺submit.*时两个FormValueRequiredaction 都不匹配 → MVC 回落到Authorize→ 又走"弹同意页"分支。此时授权参数在表单里, 而"302 到 React 同意页"这一步只读了Request.Query→ 参数全丢。 - 修法(已改):按
Request.HasFormContentType ? Request.Form : Request.Query取参数(Razor 版同意页也是同一判断; 但要排除__RequestVerificationToken, 见③ 同意流程)。 - 顺带确认:回车提交不会创建永久授权、也不会签 code(
Accept只认submit.Accept)。
四、Token / 校验类
14. 换 token 报 ID2054 / ID2052
ID2054:授权请求没带 PKCE(本项目客户端强制 PKCE)。ID2052:code_verifier与code_challenge不匹配。测试时必须用真配对:code_challenge = base64url(SHA256(ASCII(code_verifier)))。
15. 拿不到 refresh_token
- 根因:OpenIddict 只在令牌请求含
offline_accessscope 时才发 refresh token。 - 处理:客户端 scope 加上
offline_access(且客户端权限里有Permissions.Prefixes.Scope + Scopes.OfflineAccess)。
16. 角色没有出现在 id_token 里
- 根因:
GetDestinations里 role 进 id_token 需要rolesscope:claim.Subject.HasScope(Scopes.Roles)。 - 处理:客户端请求
rolesscope,并把它注册到RegisterScopes+ 客户端权限白名单。
17. Permissions.Scopes.OpenId 不存在
openid由 OpenIddict 默认允许,代码里没有这个常量 → 写了会编译不过。
18. access token 里的 claim 少了几个
- 根因:OpenIddict 默认不把自定义 claim 放进 token,必须
SetDestinations(...)声明目的地。 - 另注意安全戳 claim 被特意排除(敏感值)——类型取自
IdentityOptions.ClaimsIdentity.SecurityStampClaimType(默认AspNet.Identity.SecurityStamp),不硬编码。
五、前端 / SPA 类
19. /Account/Consent 刷新 404
- 根因:React Router history 模式,磁盘上没有该文件。
- 处理:确认 SPA 回落生效(
app.MapFallback返回wwwroot/index.html,且该路径不在/api、/connect、/callback排除前缀内)。
20. 页面白屏 / ErrorBoundary
- 根因:配了 vite
base但没配 React Routerbasename。 - 处理:
{ basename: import.meta.env.BASE_URL.replace(/\/$/, '') }。
21. 目录请求 / 404
- 处理:
app.UseDefaultFiles()(把目录请求解析到index.html,React 产物就在wwwroot根)+app.UseStaticFiles(),且要在认证之前。
22. 跨源调用被浏览器拒绝
- 症状:5173 客户端拉 discovery / 换 token 失败,控制台报 CORS 或 credential 问题。
- 处理:
WithOrigins("http://localhost:5173").AllowCredentials();不要用AllowAnyOrigin()(与 credentials 冲突)。
23. React Compiler 与"提前 return"
- 症状:组件编译失败(本地曾出现
Login.tsx里 try 块内提前return+ 三元表达式)。 - 处理:把提前
return改写成if/else链、把setState移出finally。 排查手段:给 React 插件传logger选项拿到CompileError.reason。
23′. 提交时给按钮加 disabled → 字段丢了,服务端 400
- 症状:同意页点"同意"后 400(或登出页点了没反应),日志里
[FormValueRequired("submit.Accept")]匹配不上。 - 根因:浏览器是在
submit事件之后才收集表单数据的,而 disabled 控件不进入表单数据; 如果在提交时把name="submit.Accept"(或登出页的Confirm)按钮 disable 掉,这个字段就消失了。 - 处理:提交中用 antd
Button loading(只加 class + 拦后续点击,不加disabled属性),别写disabled={submitting}。 同理"提交中把输入框 disable"也会把用户名/密码挤出表单数据。
23″. 以为"用了 form 就没有 loading"
- 症状:想给表单提交加转圈,但觉得 form 会整页跳走所以做不到。
- 事实:浏览器在新文档开始接管前不会卸载旧文档,所以
onSubmit里切submitting→ 按钮loading+<Spin fullscreen>,在"服务端处理 + 网络往返"期间完全可见(业界登录页都这么做)。 - 处理:四页统一
onSubmit={handleSubmit}(不preventDefault,只在重复提交时拦);见 表单登录与 loading(业界做法)。
23‴. 客户端登出后界面仍是"已登录 + 旧 token"(假登录态)
- 症状:从 demo 客户端点"登出"、在 OP 登出页确认、跳回 5173 后,界面还显示 token 面板和"登出"按钮; 刷新也不变。此时 OP 会话其实已经清掉了。
- 根因:客户端只做了
window.location.href = .../connect/logout,没有删 localStorage 里的 user (oidc.user:https://localhost:7016:client-demo)→ 回到页面时getUser()仍能读到旧用户。 - 修法(已改):跳转前
await userManager.removeUser()(清 token 并触发userUnloaded→setUser(null)), 再clearStaleState()、最后才跳 end-session;见客户端说明。
六、外部登录类
24. 登录页没有「使用 GitHub 登录」按钮
- 根因:没配
OAuth:External:GitHub:ClientId(未配置时/Account/ExternalProviders返回[])。 - 处理:填配置(或环境变量)后重启 OP。
25. POST /Account/ExternalLogin(provider=GitHub)404
- 同上:提供方未注册——
SignInManager.GetExternalAuthenticationSchemesAsync()里没有 GitHub(即没有注册带 DisplayName 的转发 scheme)。
26. 回调报 The external authorization data cannot be used for authentication.
- 根因:提供方没返回可用的用户 claim(
Principal.Identity.IsAuthenticated == false)。 - 说明:这时应该改用
result.Properties.GetTokens()拿 access token 代表用户做事,而不是把它当登录。
27. 外部登录后回到登录页
- 根因:
/Account/ExternalLoginCallback拿不到外部身份(cookie 丢了 / 直接手输了该 URL)。 - 预期行为:本项目在这种情况下 302 回
/Account/Login?ReturnUrl=...&error=external_login_unavailable(Identity 默认 UI 的等价做法是"回登录页并提示"), 用户重新点"使用 GitHub 登录"即可。
七、文档站 / CI 类
28. GitHub Pages 子路径资源 404
- 根因:仓库是项目站点(
https://<user>.github.io/<repo>/)但base没配。 - 处理:
docs/.vitepress/config.ts里base: '/yagang-identity/'。
29. 文档站构建失败:Dead link
- 根因:VitePress 会检查站内链接。写到
docs/目录之外的相对链接(如../../backend/...)会被判为死链。 - 处理:文档里提到仓库路径时用
code行内代码,不要做成 Markdown 链接。
30. 部署 job 失败:Pages 未启用
- 症状:
actions/configure-pages报 Pages 未启用 / 或actions/deploy-pages失败。 - 处理:仓库 Settings → Pages → Build and deployment → Source 选 GitHub Actions(只需一次)。