Program.cs 逐段讲解
backend/Yagang.Identity/Yagang.Identity.Api/Program.cs 是整个系统的装配图。 下面按"分组"讲,每组都说明为什么需要它(很多行如果不写,会以奇怪的报错出现,见 坑速查)。
1. MVC / 防伪服务
// AddControllersWithViews:AuthorizationController 用到 TempData(防登录↔授权死循环的标记)
// 与 [ValidateAntiForgeryToken](同意 / 登出 POST),这些由"视图特性"相关的服务提供;
// 本项目不渲染任何 Razor 视图(前端是 React),只是需要这些服务,所以不需要 AddRazorPages。
builder.Services.AddControllersWithViews();
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery();这里有坑
用 AddControllers() 启动后,访问 /connect/authorize 会报 Unable to resolve service for type 'ITempDataDictionaryFactory' (以及防伪过滤器相关服务)。因为 TempData 与 [ValidateAntiForgeryToken] 依赖"视图特性"服务。 本项目不渲染任何 Razor,但也需要这两个服务,所以用 AddControllersWithViews() 而不是 AddControllers()。
2. ASP.NET Core Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.SignIn.RequireConfirmedAccount = false; // demo 无邮件确认环节
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();本项目不调 .AddDefaultUI()——那是 Identity 自带的 Razor 登录/注册页,本项目的界面全部由 React 承担。
本项目不配置 ConfigureApplicationCookie——应用 cookie 全部使用 Identity / Cookie 认证的默认值 (ExpireTimeSpan = 14 天滑动、cookie 名 .AspNetCore.Identity.Application、LoginPath=/Account/Login 等)。
| 配置 | 影响 |
|---|---|
ExpireTimeSpan(默认 14 天) | 未显式配置,走 CookieAuthenticationOptions 默认值;SlidingExpiration 默认 true(有活动就续期) |
LoginPath(默认 /Account/Login) | ① 授权请求 未认证时 302 的目标(Challenge 的落点)——默认值恰好就是 React 登录页;ReturnUrlParameter 默认 "ReturnUrl",登录页读的就是它 |
cookie 名(默认 .AspNetCore.Identity.Application) | HttpOnly=true / SameSite=Lax(免二次登录依赖它)/ SecurePolicy=SameAsRequest 也全是默认值 |
改 OAuth:LoginPageUrl 时要留意
LoginPath 现在走的是 Cookie 认证的默认值 /Account/Login,不再绑定 OAuth:LoginPageUrl。 如果哪天把 appsettings 里的登录页地址改到别处(比如 /auth/login),要重新显式写 options.LoginPath = oauthOptions.LoginPageUrl;,否则 Challenge 仍会跳到 /Account/Login(而 AccountController 的失败回跳仍用配置值,两边就不一致了)。
3. OpenAPI + Swagger(本项目原有)
AddOpenApi 里挂了一个 document transformer,声明 Bearer(http + bearer) 安全方案:
var scheme = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http, // HTTP 认证方案(Bearer 的标准写法)
Scheme = "bearer",
BearerFormat = "JWT",
Description = "粘贴 access_token(可直接调 /connect/token 换取)…"
};
document.AddComponent("Bearer", scheme); // 注册进 components.securitySchemes
document.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", document)] = new List<string>()
});三个易错点(都真实踩过):
- 方案类型用
Http+Scheme = "bearer"(不是ApiKey):Swagger UI 的 Authorize 弹窗只让你填 token, 自动补Bearer前缀;用ApiKey的话得自己把Bearer连带 token 一起粘进去; - 方案用
document.AddComponent("Bearer", scheme)注册(直接往document.Components.SecuritySchemes字典里塞也能跑,但AddComponent是组件注册的标准入口,能确保引用解析器找得到它); OpenApiSecuritySchemeReference("Bearer", document)的第二个参数必须传 hostDocument, 否则 reference 序列化成{},SwaggerUI 认不出安全方案;- Microsoft.OpenApi 2.x 里
SecurityRequirements→Security。
4. 数据库与配置绑定
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlite("Data Source=database.db");
options.UseOpenIddict(); // 注册 OpenIddict 的四张表
});
var oauthOptions = builder.Configuration.GetSection(OAuthOptions.SectionName).Get<OAuthOptions>()
?? throw new InvalidOperationException("appsettings.json 缺少 OAuth 配置节");ApplicationDbContext : IdentityDbContext<ApplicationUser>→ Identity 表 + OpenIddict 表在同一个库里。OAuthOptions是业务配置(客户端列表、demo_api scope、登录页地址、演示用户)——不写死在代码里。
5. Quartz(定时清理)
builder.Services.AddQuartz(options =>
{
options.UseSimpleTypeLoader();
options.UseInMemoryStore();
});
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);配合 OpenIddict core 的 options.UseQuartz():OpenIddict 会用 Quartz 定时清理孤儿授权/过期 token。 没有它也不会立刻报错,但长期运行数据库里会堆废弃记录。
6. OpenIddict Client(外部登录)
.AddClient(options =>
{
options.AllowAuthorizationCodeFlow();
options.SetRedirectionEndpointUris("callback/login/github");
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options.UseAspNetCore()
.EnableRedirectionEndpointPassthrough();
options.UseSystemNetHttp().SetProductInformation(typeof(Program).Assembly);
var githubClientId = builder.Configuration["OAuth:External:GitHub:ClientId"];
...
if (!string.IsNullOrEmpty(githubClientId))
{
options.UseWebProviders().AddGitHub(provider => provider
.SetClientId(githubClientId)
.SetClientSecret(githubClientSecret ?? string.Empty)
.SetRedirectUri("callback/login/github"));
}
})SetRedirectionEndpointUris 不能省
OpenIddict 的选项校验规则:启用了某条流程(这里是授权码流程)⇒ 必须注册重定向端点。 本项目的 GitHub 是"条件注册"(没配置凭据就不注册提供方),"没有 GitHub 配置"时就没有重定向端点 → 启动直接抛选项校验异常。所以在 AddClient 里显式声明一次即可。
7. OpenIddict Server(本进程 = 授权服务器)
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("connect/authorize")
.SetEndSessionEndpointUris("connect/logout")
.SetTokenEndpointUris("connect/token")
.SetRevocationEndpointUris("connect/revocation")
.SetUserInfoEndpointUris("connect/userinfo");
options.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles, oauthOptions.DemoApiScope);
options.AllowAuthorizationCodeFlow().AllowRefreshTokenFlow();
options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate();
options.DisableAccessTokenEncryption(); // access token 用 JWS(可解码调试)
options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableEndSessionEndpointPassthrough()
.EnableTokenEndpointPassthrough();
})为什么没有 EnableStatusCodePagesIntegration()
该选项只在应用注册了 UseStatusCodePages* 中间件时才有意义——OpenIddict 运行时会检查 IStatusCodePagesFeature.Enabled,没有中间件就直接退回本地错误响应(见 OpenIddictServerAspNetCoreHandlers.cs 的 ProcessStatusCodePagesErrorResponse)。 本项目没有注册 status code pages 中间件,所以它是悬空配置,已删除(Balosar 里这行也只是从官方示例带过来的样板)。
实测的错误响应契约(verify-flow.mjs 已加断言锁定):/connect/authorize、/callback/* 是 400 + text/plain 的 error:...;/connect/token 是 401 + JSON(RFC 6749 §5.2)。
| 项 | 说明 |
|---|---|
| 端点 URI | connect/* 是 OpenIddict 的惯例;~/callback/login/{provider}(客户端重定向端点)由控制器声明 |
RegisterScopes | 声明支持哪些 scope(openid 默认支持,不需要注册) |
DisableAccessTokenEncryption() | 默认 access token 是 JWE(加密),关掉之后是 JWS —— 调试神器(能直接解出 claims),但要意识到生产常开着 |
AddDevelopment*Certificate() | 本地自动生成并复用的开发证书(重启同钥,旧 refresh token 仍能解密) |
Enable*Passthrough() | 把端点交给 MVC 控制器处理(否则 OpenIddict 自己处理) |
为什么 refresh token 是加密的
DisableAccessTokenEncryption 只影响 access token。refresh token 仍然是 JWE(默认行为), 因为它携带的信息更敏感、生命周期更长。
8. OpenIddict Validation(本进程 = 资源服务器)
.AddValidation(options =>
{
options.UseSystemNetHttp();
options.SetIssuer("https://localhost:7016/");
options.UseAspNetCore();
})本进程既签发又校验,但故意走 discovery + jwks_uri 拉公钥验签(更贴近真实"独立资源服务器"的姿势)。 WeatherForecastController 上的 [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] 就是用这里的配置验 token。
9. CORS(只为 SPA 客户端)
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
policy.AllowAnyHeader().AllowAnyMethod()
.WithOrigins("http://localhost:5173")
.AllowCredentials()));- 需要 CORS 的只有 5173 的 demo 客户端(跨源 XHR 拉 discovery / 换 token / 调 API)。
- 登录页是同源托管的,不需要 CORS。
- 不能用
AllowAnyOrigin():和AllowCredentials()冲突,浏览器会直接拒收响应。
10. 中间件顺序与 SPA 回落
app.UseCors();
if (app.Environment.IsDevelopment()) { app.MapOpenApi(); app.UseSwaggerUI(...); }
app.UseHttpsRedirection();
app.UseStaticFiles(); // 只托管构建产物(/assets/*、/favicon.svg)
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// SPA 回落:未匹配路径返回 index.html;但 /api、/connect、/callback 前缀保持真 404
string[] apiPrefixes = ["/api", "/connect", "/callback"];
var spaIndexFile = Path.Combine(app.Environment.WebRootPath ?? app.Environment.ContentRootPath, "index.html");
app.MapFallback(async context =>
{
// 未知 API/协议路径、缺少 SPA 构建产物 → 一律 404(绝不返回 HTML 壳)
if (apiPrefixes.Any(prefix => context.Request.Path.StartsWithSegments(prefix)) || !File.Exists(spaIndexFile))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(spaIndexFile);
});顺序要点:
| 顺序 | 为什么 |
|---|---|
UseCors() 在最前 | 预检/响应头要盖住后面所有端点 |
| 静态文件在认证之前 | React 页面无需登录即可加载(否则登录页自己都被拦) |
app.MapFallback(...)(不是 MapFallbackToFile) | React 是 history 模式,页面 URL 在磁盘上没有文件 → 未匹配路径返回 wwwroot/index.html(前端自己的 404 路由可以生效);/api、/connect、/callback 前缀保持真 404(区分 API 404 与页面 404)。构建产物缺失时同样返回 404(与"未知 API 路径"合并为同一个条件:` |
app.UseStaticFiles() | 只服务构建产物(/assets/*.js|css、/favicon.svg)——它们是文件不是页面路由,仍需要静态文件中间件;UseDefaultFiles 不需要(/ 由 fallback 匹配) |
11. 启动时建库 / 清表 / 种子
await using (var scope = app.Services.CreateAsyncScope())
{
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.EnsureCreatedAsync();
// 旧库缺 Identity 表 → 整库重建(EnsureCreated 对已存在的库不会补表)
if (!await TableExistsAsync(context, "AspNetUsers"))
{
await context.Database.CloseConnectionAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
}
// 清空四张 OpenIddict 表(顺序:Tokens → Authorizations → Applications → Scopes)
await context.Set<OpenIddictEntityFrameworkCoreToken>().ExecuteDeleteAsync();
...
// 种子:角色 admin + 用户 alice + 客户端 client-demo
}三个"教学点":
EnsureCreated不会给已存在的库补表 → 旧database.db(改造前没有AspNetUsers)必须整库重建。- 删库前必须
CloseConnectionAsync()+SqliteConnection.ClearAllPools(), 否则 SQLite 连接池握着文件句柄,Windows 上会报"文件正被另一进程使用"。 - 清表顺序受外键约束:
oi_tok引用oi_ath,oi_ath引用oi_app→ 必须从子表删起。
TableExistsAsync 里还有一个小细节:它自己开的连接自己关(finally 中 CloseAsync),不关同样删不掉库文件。
12. KeyLoader(备用签名方式)
static class KeyLoader
{
public static RsaSecurityKey FromPem(string pem) { var rsa = RSA.Create(); rsa.ImportFromPem(pem); return new RsaSecurityKey(rsa); }
public static ECDsaSecurityKey FromEcPem(string pem) { ... }
}AddSigningKey / AddEncryptionKey 收的是 SecurityKey,直接传 RSA/ECDsa 会编译报 CS1503 —— 必须包一层。 当前默认用开发证书,这两个方法是给"PEM 私钥签名 / 对称 AES 加密"实验准备的(见 OAuthConstants,现为遗留备选)。