Skip to content

Program.cs 逐段讲解 ​

backend/Yagang.Identity/Yagang.Identity.Api/Program.cs 是整个系统的装配图。 下面按"分组"讲,每组都说明为什么需要它(很多行如果不写,会以奇怪的报错出现,见 坑速查)。

1. MVC / 防伪服务 ​

csharp
// 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 ​

csharp
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) 安全方案:

csharp
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. 数据库与配置绑定 ​

csharp
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(定时清理) ​

csharp
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(外部登录) ​

csharp
.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(本进程 = 授权服务器) ​

csharp
.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)。

项说明
端点 URIconnect/* 是 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(本进程 = 资源服务器) ​

csharp
.AddValidation(options =>
{
    options.UseSystemNetHttp();
    options.SetIssuer("https://localhost:7016/");
    options.UseAspNetCore();
})

本进程既签发又校验,但故意走 discovery + jwks_uri 拉公钥验签(更贴近真实"独立资源服务器"的姿势)。 WeatherForecastController 上的 [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] 就是用这里的配置验 token。

9. CORS(只为 SPA 客户端) ​

csharp
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 回落 ​

csharp
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. 启动时建库 / 清表 / 种子 ​

csharp
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
}

三个"教学点":

  1. EnsureCreated 不会给已存在的库补表 → 旧 database.db(改造前没有 AspNetUsers)必须整库重建。
  2. 删库前必须 CloseConnectionAsync() + SqliteConnection.ClearAllPools(), 否则 SQLite 连接池握着文件句柄,Windows 上会报"文件正被另一进程使用"。
  3. 清表顺序受外键约束:oi_tok 引用 oi_ath,oi_ath 引用 oi_app → 必须从子表删起。

TableExistsAsync 里还有一个小细节:它自己开的连接自己关(finally 中 CloseAsync),不关同样删不掉库文件。

12. KeyLoader(备用签名方式) ​

csharp
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,现为遗留备选)。

OpenIddict + ASP.NET Core Identity 授权服务器,React 登录页,逐步拆解登录链路