大型 SaaS 系统常见需求:
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());
});
}
}
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();
});
});
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.");
}
}
}
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 生效
}
public interface IApiKeyValidator
{
///
/// 校验 API Key 并返回对应用户ID;失败返回 null/empty
/// 实现可结合 IMemoryCache/IDistributedCache 缓存
///
Task<string> ValidateAsync(string apiKey);
}
[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);
}
}
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"
}
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