数据、种子与 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 token | OpenIddict 签发/撤销 |
oi_scopes | scope → 资源映射 | 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两个必须记住的顺序
- 删库文件前必须
CloseConnectionAsync()+SqliteConnection.ClearAllPools(), 否则 SQLite 连接池握着句柄 → "文件正被另一进程使用"; - 清表必须从子表开始(
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 = true | demo 无邮件确认环节(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。