Skip to content

数据、种子与 Quartz ​

数据模型 ​

csharp
// Models/ApplicationUser.cs
public class ApplicationUser : IdentityUser;

// Data/ApplicationDbContext.cs
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    public ApplicationDbContext(DbContextOptions options) : base(options) { }
}

// Models/WeatherForecast.cs
public class WeatherForecast
{
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    public required string Summary { get; set; }
}

UseOpenIddict() 会把四张 OpenIddict 表加进同一个 DbContext → 一个 SQLite 文件装下所有东西(database.db)。

表清单 ​

表内容谁写
AspNetUsers / AspNetRoles / AspNetUserRoles用户、角色、关联启动种子 / 外部登录落地
AspNetUserLogins外部登录关联(provider + providerKey → user)/Account/ExternalLoginCallback
oi_applications客户端注册(client-demo)启动种子
oi_authorizations永久授权 = 已同意AuthorizationController.Accept / 免同意分支
oi_tokens授权码 / refresh tokenOpenIddict 签发/撤销
oi_scopesscope → 资源映射RegisterScopes + 按需创建

启动时的三步 ​

csharp
await context.Database.EnsureCreatedAsync();

if (!await TableExistsAsync(context, "AspNetUsers"))      // 1) 旧库(改造前没有 Identity 表)→ 整库重建
{
    await context.Database.CloseConnectionAsync();
    Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();
}

await context.Set<OpenIddictEntityFrameworkCoreToken>().ExecuteDeleteAsync();          // 2) 清四表(子 → 父)
await context.Set<OpenIddictEntityFrameworkCoreAuthorization>().ExecuteDeleteAsync();
await context.Set<OpenIddictEntityFrameworkCoreApplication>().ExecuteDeleteAsync();
await context.Set<OpenIddictEntityFrameworkCoreScope>().ExecuteDeleteAsync();

// 3) 种子:admin 角色 + alice + client-demo

两个必须记住的顺序

  1. 删库文件前必须 CloseConnectionAsync() + SqliteConnection.ClearAllPools(), 否则 SQLite 连接池握着句柄 → "文件正被另一进程使用";
  2. 清表必须从子表开始(oi_tokens → oi_authorizations → oi_applications → oi_scopes), 因为外键方向是 Token → Authorization → Application。

TableExistsAsync 的实现(自己开的连接自己关):

csharp
static async Task<bool> TableExistsAsync(ApplicationDbContext context, string table)
{
    var connection = context.Database.GetDbConnection();
    var opened = connection.State is not System.Data.ConnectionState.Open;
    if (opened) await connection.OpenAsync();
    try
    {
        await using var command = connection.CreateCommand();
        command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name";
        var parameter = command.CreateParameter();
        parameter.ParameterName = "$name";
        parameter.Value = table;
        command.Parameters.Add(parameter);
        return Convert.ToInt64(await command.ExecuteScalarAsync()) > 0;
    }
    finally
    {
        if (opened) await connection.CloseAsync();
    }
}

种子内容 ​

csharp
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (await roleManager.FindByNameAsync(DemoRoleName) is null)
{
    await roleManager.CreateAsync(new IdentityRole(DemoRoleName));         // "admin"
}

var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
if (await userManager.FindByNameAsync(oauthOptions.DemoUserName) is null)
{
    var user = new ApplicationUser
    {
        UserName = oauthOptions.DemoUserName,        // alice
        Email = "alice@example.com",
        EmailConfirmed = true
    };
    var created = await userManager.CreateAsync(user, oauthOptions.DemoUserPassword);
    if (!created.Succeeded) throw new InvalidOperationException("演示用户创建失败:" + ...);
    await userManager.AddToRoleAsync(user, DemoRoleName);
}
为什么种子要这么做说明
本项目没有注册页启动时种一个演示用户,保证开箱即可登录
EmailConfirmed = truedemo 无邮件确认环节(RequireConfirmedAccount = false)
加 admin 角色让 GetDestinations 里的 role claim 有内容可看(token 里能验证 roles scope 的效果)

客户端注册(oi_applications) ​

csharp
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
    ClientId = client.ClientId,                      // client-demo
    ClientType = ClientTypes.Public,                 // SPA,无 secret
    ConsentType = ConsentTypes.Explicit,             // 需要用户同意
    DisplayName = $"SPA client application ({client.ClientId})",   // ← 同意页上的 app_name
    RedirectUris = { new Uri(client.RedirectUri) },
    PostLogoutRedirectUris = { new Uri(client.PostLogoutRedirectUri) },
    Permissions = { /* 端点 / grant / response_type / scope 白名单 */ },
    Requirements = { Requirements.Features.ProofKeyForCodeExchange }
});

DisplayName 就是同意页显示的 app_name(来自 GetLocalizedDisplayNameAsync(application))。

权限项不写会怎样
Permissions.Endpoints.*该端点返回"客户端无权使用此端点"
Permissions.GrantTypes.*换 token 时报 unauthorized grant type
Permissions.Prefixes.Scope + "demo_api"请求该 scope 直接报错(scope 未授权给客户端)
Requirements.Features.ProofKeyForCodeExchange不强制 PKCE(公开客户端的安全底线就没了)

openid 是特例

openid 不需要(也没有)对应的权限常量——OpenIddict 默认允许。写 Permissions.Scopes.OpenId 会编译不过。

Quartz 在做什么 ​

csharp
builder.Services.AddQuartz(options => { options.UseSimpleTypeLoader(); options.UseInMemoryStore(); });
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

.AddCore(options => { options.UseEntityFrameworkCore().UseDbContext<ApplicationDbContext>(); options.UseQuartz(); })

OpenIddict 通过 UseQuartz() 注册周期性任务,清理:

  • 过期的 token;
  • 孤儿的授权(用户已删除 / 客户端已删除)。

"内存型调度器"意味着任务定义不持久化(重启即重建),但清理逻辑照常执行——对学习项目足够。

每次启动的数据状态(很重要) ​

数据重启后
用户 / 角色 / 外部登录关联保留(不在清表范围内)
客户端注册清掉后由种子重建(内容一致)
永久授权(同意记录)清掉 → 第一次授权会再次弹同意页
授权码 / refresh token清掉 → 库里是干净起点

这解释了最常见的"奇怪现象"

"我明明同意过了,怎么又弹同意页?" → 重启清空了 oi_ath。 "刚换的 refresh token 不能用了?" → 重启清空了 oi_tok。

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