ABP vNext + OpenIddict:多租户授权中心 🚀
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 =>
{
// Endpoints
options.SetAuthorizationEndpointUris("/connect/authorize")
.SetTokenEndpointUris("/connect/token")
.SetLogoutEndpointUris("/connect/logout");
// Flows
options.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);
// 写入 TenantId
var 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: CI
on: [push,pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v2
with: dotnet-version: '8.0.x'
- name: Restore & Build
run: dotnet restore && dotnet build --no-restore
- name: EF Migrations
run: dotnet ef database update -c CustomOpenIddictDbContext
- name: Validate Certificate/License
run: dotnet run --project src/AuthCenter -- --validate-license
- name: Run Tests
run: 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];
}