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 系统常见需求:
- 自定义身份来源(API Key、Device Flow)
- 多租户隔离与 SSO
- 精细化 Scope/资源管理
ABP 的 OpenIddict 模块提供 Handler 模型 插槽,轻松插入自定义授权逻辑。
🛠 依赖注入与启动配置
public class AuthServerModule : AbpModule
{public override void ConfigureServices(ServiceConfigurationContext context){var services = context.Services;// ➤ 注册 OpenIddict 核心 + Server + Validationservices.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 生效
}
🌀 多租户解析流程
📋 接口定义:IApiKeyValidator
public interface IApiKeyValidator
{/// <summary>/// 校验 API Key 并返回对应用户ID;失败返回 null/empty/// 实现可结合 IMemoryCache/IDistributedCache 缓存/// </summary>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 管理流程
🧪 接口调用示例
1. API Key 授权成功
POST /connect/token
Content-Type: application/x-www-form-urlencodedgrant_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."
}
🔄 刷新流程图
🔐 安全加固建议
🔒 类型 | 🛡️ 实践建议 |
---|---|
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