ABP vNext + OpenIddict:自定义 OAuth2/OpenID Connect 认证策略

ABP vNext + OpenIddict:自定义 OAuth2/OpenID Connect 认证策略


目录

  • ABP vNext + OpenIddict:自定义 OAuth2/OpenID Connect 认证策略
    • 背景与核心设计思路
    • 依赖注入与启动配置
    • 系统配置:注册 Token 授权管道
    • 自定义授权处理器:ApiKeyGrantHandler
    • 租户解析与多租户 SSO
      • Contributor 实现
      • 注入配置
      • 上下文切换
        • 多租户解析流程
    • 接口定义:IApiKeyValidator
    • Scope & Client 动态管理
        • Client 管理流程
    • 接口调用示例
      • 1. API Key 授权成功
      • 2. 刷新令牌示例
        • 刷新流程图
    • 安全加固建议
    • 项目结构推荐


背景与核心设计思路

大型 SaaS 系统常见需求:

  1. 自定义身份来源(API Key、Device Flow)
  2. 多租户隔离与 SSO
  3. 精细化 Scope/资源管理

ABP 的 OpenIddict 模块提供 Handler 模型 插槽,轻松插入自定义授权逻辑。


依赖注入与启动配置

public class AuthServerModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        var services = context.Services;

        // ➤ 注册 OpenIddict 核心 + Server + Validation
        services.AddOpenIddict()
            .AddCore(options => { /* 实体存储等 */ })
            .AddServer(options => { /* 稍后配置 */ })
            .AddValidation(options => { /* 稍后配置 */ });

        // ➤ 多租户解析
        services.Configure<AbpTenantResolveOptions>(opts =>
        {
            opts.Resolvers.Insert(0, new HeaderTenantResolveContributor());
            opts.Resolvers.Insert(1, new DomainTenantResolveContributor());
        });
    }
}

系统配置:注册 Token 授权管道

PreConfigure<OpenIddictBuilder>(builder =>
{
    builder.AddServer(options =>
    {
        // —— 端点 ——  
        options.SetTokenEndpointUris("/connect/token")
               .SetAuthorizationEndpointUris("/connect/authorize")
               .SetDeviceEndpointUris("/connect/device");

        // —— Grant & Scope ——  
        options.RegisterGrantType("api_key_grant")
               .AllowPasswordFlow()
               .AllowClientCredentialsFlow()
               .AllowRefreshTokenFlow()
               .AllowExtensionGrantType("api_key_grant")
               .SetDefaultScopes("api", "profile");

        // —— 有效期 ——  
        options.SetAccessTokenLifetime(TimeSpan.FromHours(2))
               .SetRefreshTokenLifetime(TimeSpan.FromDays(7));

        // —— ASP.NET Core 集成 ——  
        options.UseAspNetCore()
               .EnableTokenEndpointPassthrough();

        // —— 自定义 Handler ——  
        options.AddEventHandler<HandleTokenRequestContext>(cfg =>
            cfg.UseScopedHandler<ApiKeyGrantHandler>()
               .SetOrder(OpenIddictServerHandlers.Authentication.ValidateTokenRequest.Descriptor.Order + 1)
               .SetFilter(ctx => ctx.Request.GrantType == "api_key_grant"));
    });

    builder.AddValidation(options =>
    {
        options.UseLocalServer();
        options.UseAspNetCore();
    });
});

自定义授权处理器:ApiKeyGrantHandler

public class ApiKeyGrantHandler : IOpenIddictServerHandler<HandleTokenRequestContext>
{
    private readonly IApiKeyValidator _apiKeyValidator;
    private readonly ICurrentTenant   _currentTenant;
    private readonly ILogger<ApiKeyGrantHandler> _logger;

    public ApiKeyGrantHandler(
        IApiKeyValidator apiKeyValidator,
        ICurrentTenant   currentTenant,
        ILogger<ApiKeyGrantHandler> logger)
    {
        _apiKeyValidator = apiKeyValidator;
        _currentTenant   = currentTenant;
        _logger          = logger;
    }

    public async ValueTask HandleAsync(HandleTokenRequestContext context)
    {
        using var scope = _logger.BeginScope(new { GrantType = "api_key" });
        try
        {
            var apiKey = context.Request.GetParameter("api_key")?.ToString();
            if (string.IsNullOrWhiteSpace(apiKey))
            {
                context.Reject(Errors.InvalidRequest, "Missing API Key");
                return;
            }

            var userId = await _apiKeyValidator.ValidateAsync(apiKey);
            if (string.IsNullOrEmpty(userId))
            {
                context.Reject(Errors.InvalidGrant, "Invalid API Key");
                return;
            }

            var tenantId = _currentTenant.Id?.ToString() ?? "default";

            var claims = new[]
            {
                new Claim(Claims.Subject,  userId),
                new Claim("tenant_id",    tenantId),
                new Claim(Claims.Name,     "API Key User")
            };
            var identity  = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
            var principal = new ClaimsPrincipal(identity);

            // 设置 Scopes & Resource  
            principal.SetScopes(context.Request.GetScopes());
            principal.SetResources("api");

            // 顶层返回 tenant_id  
            context.AddParameter("tenant_id", tenantId);

            context.Validate(principal);
            context.HandleRequest();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "API Key grant failed.");
            context.Reject(Errors.ServerError, "Internal error.");
        }
    }
}

租户解析与多租户 SSO

Contributor 实现

public class DomainTenantResolveContributor : HttpTenantResolveContributorBase
{
    public override Task<string> ResolveAsync(HttpContext context)
    {
        var sub = context.Request.Host.Host.Split('.').FirstOrDefault();
        return Task.FromResult(string.IsNullOrWhiteSpace(sub) ? "default" : sub);
    }
}

public class HeaderTenantResolveContributor : HttpTenantResolveContributorBase
{
    public override Task<string> ResolveAsync(HttpContext context)
    {
        var header = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
        return Task.FromResult(string.IsNullOrWhiteSpace(header) ? "default" : header);
    }
}

注入配置

services.Configure<AbpTenantResolveOptions>(opts =>
{
    opts.Resolvers.Insert(0, new HeaderTenantResolveContributor());
    opts.Resolvers.Insert(1, new DomainTenantResolveContributor());
});

上下文切换

using (_currentTenant.Change(tenantId))
{
    // 此作用域内,TenantId 生效  
}
多租户解析流程
Yes
No
Yes
No
Incoming HTTP Request
Has X-Tenant-Id Header?
Use HeaderTenantResolveContributor
Host Subdomain Exists?
Use DomainTenantResolveContributor
Fallback to default
Set CurrentTenant

接口定义:IApiKeyValidator

public interface IApiKeyValidator
{
    /// 
    /// 校验 API Key 并返回对应用户ID;失败返回 null/empty
    /// 实现可结合 IMemoryCache/IDistributedCache 缓存
    /// 
    Task<string> ValidateAsync(string apiKey);
}

Scope & Client 动态管理

[Authorize(Roles = "Admin")]
public class ScopeAppService : ApplicationService
{
    private readonly IOpenIddictScopeManager _scopeManager;
    public async Task<List<string>> GetScopesAsync()
    {
        var list = new List<string>();
        await foreach (var s in _scopeManager.ListAsync())
            list.Add(s.Name);
        return list;
    }
}

[Authorize(Roles = "Admin")]
public class ClientAppService : ApplicationService
{
    private readonly IOpenIddictApplicationManager _appManager;

    public async Task CreateMobileClientAsync()
    {
        var desc = new OpenIddictApplicationDescriptor
        {
            ClientId    = "mobile_app",
            DisplayName = "Mobile App",
            Permissions =
            {
                Permissions.Endpoints.Token,
                Permissions.GrantTypes.Password
            }
        };
        await _appManager.CreateAsync(desc);
    }

    public async Task DeleteClientAsync(string clientId)
    {
        var app = await _appManager.FindByClientIdAsync(clientId);
        if (app != null) await _appManager.DeleteAsync(app);
    }
}
Client 管理流程
Admin API Manager POST /api/clients (Create) CreateAsync(descriptor) App Created 201 Created DELETE /api/clients/{id} DeleteAsync(app) App Deleted 204 No Content Admin API Manager

接口调用示例

1. API Key 授权成功

POST /connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=api_key_grant
api_key=valid-api-key
client_id=default
client_secret=secret
scope=api

成功响应:

{
  "access_token":"eyJhbGciOiJSUzI1NiIs...",
  "token_type":"Bearer",
  "expires_in":7200,
  "refresh_token":"eyJhbGciOiJIUzI1NiIs...",
  "tenant_id":"tenant1"
}

错误示例:

{
  "error":"invalid_grant",
  "error_description":"Invalid API Key"
}

2. 刷新令牌示例

POST /connect/token
grant_type=refresh_token
refresh_token={your_refresh_token}
client_id=default
client_secret=secret

刷新成功:

{
  "access_token":"…",
  "token_type":"Bearer",
  "expires_in":7200,
  "refresh_token":"…"
}

刷新失败:

{
  "error":"invalid_grant",
  "error_description":"Refresh token is expired."
}
刷新流程图
Valid
Invalid
Client sends refresh_token
Server Validate Refresh Token
Issue new access_token & refresh_token
Return invalid_grant error

安全加固建议

类型 ️ 实践建议
API Key 哈希存储 + 过期 + 重放防护
限流 使用 AspNetCoreRateLimit 保护 /connect/token
审计日志 所有 Reject() 写入审计表,方便追踪
签名密钥 DataProtection/RSA 证书 + 定期轮换
监控指标 OpenTelemetry Meter/Counter 统计授权成功/失败

项目结构推荐

AuthServer.Host
├── CustomGrants/
│   └── ApiKeyGrantHandler.cs
├── Tenanting/
│   ├── DomainTenantResolveContributor.cs
│   └── HeaderTenantResolveContributor.cs
├── Scopes/
│   └── ScopeAppService.cs
├── Clients/
│   └── ClientAppService.cs
├── Validation/
│   └── IApiKeyValidator.cs
├── OpenIddict/
│   └── PreConfiguration.cs
└── Program.cs

你可能感兴趣的:(Abp,vNext,.net,ABP,vNext,.net,后端,c#)