ABP vNext + OpenIddict:多租户授权中心
ABP vNext + OpenIddict:多租户授权中心 🚀
📚 目录
- ABP vNext + OpenIddict:多租户授权中心 🚀
- TL;DR 🎯
- 1. 环境与依赖 🛠️
- 2. 系统架构概览 🏗️
- 3. 模块依赖配置 🔧
- 4. Program.cs 配置 ⚙️
- 中间件管道流程图 🛣️
- 5. CustomOpenIddictDbContext 与多租户隔离 🌐
- 6. Migration:添加 TenantId 列 🗄️
- 7. 客户端与范围管理 📦
- 8. 外部登录整合与用户映射 🔄
- 9. 细粒度访问控制 🔒
- 10. 后台 Token 清理服务 🧹
- 11. 集成测试与 CI/CD 🔍
- 11.1 集成测试示例
- 11.2 Pipeline 示例
- 12. ReadCertificateFromStore 示例 📜
TL;DR 🎯
- ✅ 多租户隔离:EF Core “影子属性” + 全局查询过滤 + SaveChanges 钩子,保障所有 OpenIddict 实体按当前租户过滤
- 🔑 OpenIddict 集成:
AbpOpenIddictModule
一行启用 Core/Server/Validation,并整合 ASP.NET Identity - 🔒 安全加固:Azure Key Vault 签名证书、严格 CORS/CSP/HSTS、生产环境证书示例
- 🚀 高性能可复现:Redis 分布式缓存、刷新令牌滚动策略、后台 Token 清理 Hosted Service
- ✅ 测试 & CI/CD:集成测试覆盖租户隔离与授权流程,GitHub Actions 自动化 Migrations → 测试 → 部署
1. 环境与依赖 🛠️
-
目标框架:.NET 8.0 +
-
ABP 版本:8.x +
-
核心 NuGet 包
dotnet add package Volo.Abp.OpenIddict --version 8.* dotnet add package Volo.Abp.OpenIddict.EntityFrameworkCore --version 8.* dotnet add package Volo.Abp.TenantManagement.Domain --version 8.* dotnet add package Microsoft.AspNetCore.Authentication.Google dotnet add package Microsoft.Identity.Web dotnet add package StackExchange.Redis dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
-
数据库:EF Core + SQL Server / PostgreSQL
-
缓存:Redis
-
示例 ConnectionString
"ConnectionStrings": {"Default": "Server=.;Database=AuthCenter;User Id=sa;Password=YourPwd;Max Pool Size=200;Command Timeout=60;" }, "Redis": "localhost:6379"
2. 系统架构概览 🏗️
- Tenant Resolver:Host/Path/Header 多策略解析,注入
TenantId
- Configuration Store:Clients、Scopes 存于 EF Core 表,结合“影子属性”按租户过滤
- Redis 缓存:授权配置缓存、验证密钥缓存、Token 缓存
3. 模块依赖配置 🔧
using Volo.Abp;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.OpenIddict;
using Volo.Abp.OpenIddict.EntityFrameworkCore;
using Volo.Abp.TenantManagement.Domain;namespace AuthCenter
{[DependsOn(typeof(AbpOpenIddictModule),typeof(AbpOpenIddictEntityFrameworkCoreModule),typeof(AbpTenantManagementDomainModule))]public class AuthCenterModule : AbpModule{public override void ConfigureServices(ServiceConfigurationContext context){// 配置全局 EF Core 提供者(SQL Server)Configure<AbpDbContextOptions>(opts =>{opts.UseSqlServer();});}}
}
4. Program.cs 配置 ⚙️
var builder = WebApplication.CreateBuilder(args);// 1. Key Vault 集成(可选)🔐
var vaultUrl = builder.Configuration["KeyVault:Url"];
if (!string.IsNullOrEmpty(vaultUrl))
{builder.Configuration.AddAzureKeyVault(new Uri(vaultUrl), new DefaultAzureCredential());
}// 2. CORS 策略 🛡️
builder.Services.AddCors(opts =>
{opts.AddPolicy("AllowTenantApps", p =>p.WithOrigins("https://*.yourtenantdomain.com").AllowAnyHeader().AllowAnyMethod().AllowCredentials());
});// 3. EF Core + OpenIddict 实体 注册 🗄️
var connString = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<CustomOpenIddictDbContext>(options =>
{options.UseSqlServer(connString,sql => sql.MigrationsAssembly(typeof(AuthCenterModule).Assembly.FullName));options.UseOpenIddict(); // 确保 OpenIddict 实体映射
});
builder.Services.AddAbpDbContext<CustomOpenIddictDbContext>(opts =>
{opts.AddDefaultRepositories<OpenIddictEntityFrameworkCoreApplication>();
});// 4. OpenIddict 服务注册 🔑
builder.Services.AddAbpOpenIddict().AddCore(options =>{options.UseEntityFrameworkCore().UseDbContext<CustomOpenIddictDbContext>();}).AddServer(options =>{// Endpointsoptions.SetAuthorizationEndpointUris("/connect/authorize").SetTokenEndpointUris("/connect/token").SetLogoutEndpointUris("/connect/logout");// Flowsoptions.AllowAuthorizationCodeFlow().AllowRefreshTokenFlow();// 刷新令牌:30 天 + 滚动刷新options.SetRefreshTokenLifetime(TimeSpan.FromDays(30));options.UseRollingRefreshTokens();// 开发/生产证书 🎫if (builder.Environment.IsDevelopment()){options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate();}else{var thumb = builder.Configuration["SigningCertificateThumbprint"];options.AddSigningCertificate(ReadCertificateFromStore(thumb));}// ASP.NET Core 集成options.UseAspNetCore().EnableAuthorizationEndpointPassthrough().EnableTokenEndpointPassthrough().EnableLogoutEndpointPassthrough();// TenantId 写入:Authorization + Token 🌐options.AddEventHandler<OpenIddictServerEvents.HandleAuthorizationRequestContext>(builder =>builder.UseInlineHandler(ctx =>{var db = ctx.Transaction.GetDbContext<CustomOpenIddictDbContext>();var tenantId = ctx.HttpContext.GetMultiTenantContext().TenantId;db.Entry(ctx.Authorization!).Property("TenantId").CurrentValue = tenantId;return default;}));options.AddEventHandler<OpenIddictServerEvents.HandleTokenRequestContext>(builder =>builder.UseInlineHandler(ctx =>{var db = ctx.Transaction.GetDbContext<CustomOpenIddictDbContext>();var tenantId = ctx.HttpContext.GetMultiTenantContext().TenantId;db.Entry(ctx.Token!).Property("TenantId").CurrentValue = tenantId;return default;}));}).AddValidation(options =>{options.UseLocalServer();options.UseAspNetCore();});// 5. ASP.NET Identity 集成 👤
builder.Services.AddAbpIdentity<ApplicationUser, IdentityRole>().AddEntityFrameworkStores<CustomOpenIddictDbContext>();// 6. Redis 缓存 & 多租户解析 🔄
builder.Services.AddStackExchangeRedisCache(opts =>
{opts.Configuration = builder.Configuration["Redis"];opts.InstanceName = "AuthCenter:";
});
builder.Services.AddAbpMultiTenancy(opts =>
{opts.Resolvers.Add<HostTenantResolveContributor>();opts.Resolvers.Add<PathTenantResolveContributor>();opts.Resolvers.Add<HeaderTenantResolveContributor>();
});// 7. Token Cleanup 后台服务 🧹
builder.Services.AddHostedService<TokenCleanupService>();// 8. 认证与授权 🔒
builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
builder.Services.AddAuthorization();// 9. Controllers 🚀
builder.Services.AddControllers();var app = builder.Build();
中间件管道流程图 🛣️
app.UseHttpsRedirection();
app.UseMultiTenancy();
app.UseRouting();
app.UseCors("AllowTenantApps");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
5. CustomOpenIddictDbContext 与多租户隔离 🌐
using Microsoft.EntityFrameworkCore;
using OpenIddict.EntityFrameworkCore.Models;
using OpenIddict.EntityFrameworkCore;
using Volo.Abp.MultiTenancy;public class CustomOpenIddictDbContext :OpenIddictEntityFrameworkCoreDbContext<CustomOpenIddictDbContext,OpenIddictEntityFrameworkCoreApplication,OpenIddictEntityFrameworkCoreAuthorization,OpenIddictEntityFrameworkCoreScope,OpenIddictEntityFrameworkCoreToken>,IMultiTenant
{public Guid? TenantId { get; set; }public CustomOpenIddictDbContext(DbContextOptions<CustomOpenIddictDbContext> options): base(options) { }protected override void OnModelCreating(ModelBuilder builder){base.OnModelCreating(builder);// 影子属性 + 全局过滤void Configure<TEntity>() where TEntity : class{builder.Entity<TEntity>().Property<Guid?>("TenantId").HasColumnType("uniqueidentifier");builder.Entity<TEntity>().HasQueryFilter(e => EF.Property<Guid?>(e, "TenantId") == TenantId);}Configure<OpenIddictEntityFrameworkCoreApplication>();Configure<OpenIddictEntityFrameworkCoreAuthorization>();Configure<OpenIddictEntityFrameworkCoreScope>();Configure<OpenIddictEntityFrameworkCoreToken>();}public override int SaveChanges(bool acceptAllChangesOnSuccess){SetTenantId();return base.SaveChanges(acceptAllChangesOnSuccess);}public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess,CancellationToken cancellationToken = default){SetTenantId();return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);}private void SetTenantId(){foreach (var entry in ChangeTracker.Entries()){if (entry.State != EntityState.Added) continue;var type = entry.Entity.GetType();if (type == typeof(OpenIddictEntityFrameworkCoreApplication) ||type == typeof(OpenIddictEntityFrameworkCoreAuthorization) ||type == typeof(OpenIddictEntityFrameworkCoreScope) ||type == typeof(OpenIddictEntityFrameworkCoreToken)){entry.Property("TenantId").CurrentValue = TenantId;}}}
}
6. Migration:添加 TenantId 列 🗄️
public partial class AddTenantIdToOpenIddict : Migration
{protected override void Up(MigrationBuilder mb){mb.AddColumn<Guid>(name: "TenantId", table: "OpenIddictApplications", type: "uniqueidentifier", nullable: true);mb.AddColumn<Guid>(name: "TenantId", table: "OpenIddictAuthorizations", type: "uniqueidentifier", nullable: true);mb.AddColumn<Guid>(name: "TenantId", table: "OpenIddictScopes", type: "uniqueidentifier", nullable: true);mb.AddColumn<Guid>(name: "TenantId", table: "OpenIddictTokens", type: "uniqueidentifier", nullable: true);}protected override void Down(MigrationBuilder mb){mb.DropColumn("TenantId", "OpenIddictApplications");mb.DropColumn("TenantId", "OpenIddictAuthorizations");mb.DropColumn("TenantId", "OpenIddictScopes");mb.DropColumn("TenantId", "OpenIddictTokens");}
}
执行:
dotnet ef migrations add AddTenantId -c CustomOpenIddictDbContext -o Migrations/OpenIddictDb
dotnet ef database update --context CustomOpenIddictDbContext
7. 客户端与范围管理 📦
public async Task RegisterApplicationAsync(Guid tenantId, string clientUri)
{using var scope = _serviceProvider.CreateScope();var db = scope.ServiceProvider.GetRequiredService<CustomOpenIddictDbContext>();db.TenantId = tenantId;var manager = scope.ServiceProvider.GetRequiredService<OpenIddictApplicationManager<OpenIddictEntityFrameworkCoreApplication>>();var descriptor = new OpenIddictApplicationDescriptor{ClientId = $"{tenantId}_web",DisplayName = "Tenant Web App",RedirectUris = { new Uri($"{clientUri}/signin-oidc") },PostLogoutRedirectUris = { new Uri($"{clientUri}/signout-callback-oidc") },Permissions ={OpenIddictConstants.Permissions.Endpoints.Authorization,OpenIddictConstants.Permissions.Endpoints.Token,OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,OpenIddictConstants.Permissions.GrantTypes.RefreshToken,OpenIddictConstants.Permissions.Scopes.Profile,OpenIddictConstants.Permissions.Scopes.Email,OpenIddictConstants.Permissions.Scopes.OfflineAccess}};await manager.CreateAsync(descriptor);// 写入 TenantIdvar entity = await manager.FindByClientIdAsync(descriptor.ClientId);db.Entry(entity!).Property<Guid?>("TenantId").CurrentValue = tenantId;await db.SaveChangesAsync();
}
8. 外部登录整合与用户映射 🔄
builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme).AddGoogle("Google", opts =>{opts.ClientId = builder.Configuration["Authentication:Google:ClientId"];opts.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];opts.SignInScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme;opts.Events.OnTicketReceived = ctx =>ctx.HttpContext.RequestServices.GetRequiredService<ExternalUserMapper>().MapAsync(ctx);}).AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));builder.Services.AddScoped<ExternalUserMapper>();public class ExternalUserMapper
{private readonly UserManager<ApplicationUser> _um;public ExternalUserMapper(UserManager<ApplicationUser> um) => _um = um;public async Task MapAsync(TicketReceivedContext ctx){var principal = ctx.Principal;var email = principal.FindFirstValue(ClaimTypes.Email);var tenantId = ctx.HttpContext.GetMultiTenantContext().TenantId;var user = await _um.FindByEmailAsync(email)?? new ApplicationUser { TenantId = tenantId, UserName = email, Email = email };if (user.Id == default) await _um.CreateAsync(user);}
}
9. 细粒度访问控制 🔒
builder.Services.AddAuthorization(opts =>
{opts.AddPolicy("TenantAdmin", policy =>policy.RequireClaim("tenant_id").RequireRole("Admin"));
});// 在 AccessToken 生成前注入自定义 Claim
builder.Services.AddOpenIddict().AddServer(options =>{options.AddEventHandler<OpenIddictServerEvents.SerializeAccessTokenContext>(builder =>builder.UseInlineHandler(ctx =>{var db = ctx.Transaction.GetDbContext<CustomOpenIddictDbContext>();var userId = ctx.Principal.GetClaim(OpenIddictConstants.Claims.Subject);var user = db.Set<ApplicationUser>().Find(Guid.Parse(userId));ctx.Principal.SetClaim("roles", string.Join(",", user?.Roles ?? Array.Empty<string>()));return default;}));});
10. 后台 Token 清理服务 🧹
public class TokenCleanupService : IHostedService, IDisposable
{private readonly IServiceProvider _sp;private Timer? _timer;public TokenCleanupService(IServiceProvider sp) => _sp = sp;public Task StartAsync(CancellationToken _) {_timer = new Timer(Cleanup, null, TimeSpan.Zero, TimeSpan.FromHours(1));return Task.CompletedTask;}private async void Cleanup(object? _) {using var scope = _sp.CreateScope();var db = scope.ServiceProvider.GetRequiredService<CustomOpenIddictDbContext>();var expired = db.Set<OpenIddictEntityFrameworkCoreToken>().Where(t => t.Status != OpenIddictConstants.Statuses.Valid ||t.Revoked || t.CreationDate < DateTimeOffset.UtcNow.AddDays(-30));db.RemoveRange(expired);await db.SaveChangesAsync();}public Task StopAsync(CancellationToken _) {_timer?.Change(Timeout.Infinite, 0);return Task.CompletedTask;}public void Dispose() => _timer?.Dispose();
}
11. 集成测试与 CI/CD 🔍
11.1 集成测试示例
public class AuthCenterTests : IClassFixture<WebApplicationFactory<Program>>
{private readonly HttpClient _client;public AuthCenterTests(WebApplicationFactory<Program> f) => _client = f.CreateClient();[Fact]public async Task TenantA_CannotUse_TenantB_AuthorizationCode(){_client.DefaultRequestHeaders.Add("X-Tenant-ID","tenantA");var codeResp = await _client.PostAsync("/connect/authorize?client_id=tenantB_web&response_type=code&scope=openid", null);Assert.Equal(HttpStatusCode.BadRequest, codeResp.StatusCode);}
}
11.2 Pipeline 示例
name: CIon: [push,pull_request]jobs:build-and-test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v3- name: Setup .NETuses: actions/setup-dotnet@v2with: dotnet-version: '8.0.x'- name: Restore & Buildrun: dotnet restore && dotnet build --no-restore- name: EF Migrationsrun: dotnet ef database update -c CustomOpenIddictDbContext- name: Validate Certificate/Licenserun: dotnet run --project src/AuthCenter -- --validate-license- name: Run Testsrun: dotnet test --no-build --verbosity normal
12. ReadCertificateFromStore 示例 📜
static X509Certificate2 ReadCertificateFromStore(string thumbprint)
{using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);store.Open(OpenFlags.ReadOnly);var certs = store.Certificates.Find(X509FindType.FindByThumbprint,thumbprint,validOnly: false);if (certs.Count == 0)throw new InvalidOperationException($"Certificate with thumbprint {thumbprint} not found");return certs[0];
}