ABP vNext + OpenIddict:多租户授权中心

发布于:2025-07-14 ⋅ 阅读:(10) ⋅ 点赞:(0)

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. 系统架构概览 🏗️

External
AuthCenter
JWT/OAuth2
OIDC
OIDC
Tenants
T1
T2
Google
AzureAD
OpenIddict Server
Host/Path/Header Resolver
受保护 API
Configuration Store
Cache
  • 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();

中间件管道流程图 🛣️

UseHttpsRedirection
UseMultiTenancy
UseRouting
UseCors(AllowTenantApps)
UseAuthentication
UseAuthorization
MapControllers
(OpenIddict endpoints)
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);
    }
}
Client App AuthCenter Google/Azure Database /connect/authorize Redirect for login Callback with ticket ExternalUserMapper.MapAsync Persist user Return tokens Client App AuthCenter Google/Azure Database

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];
}


网站公告

今日签到

点亮在社区的每一天
去签到