.NET 6基于Keycloak实现无需重启的多租户API授权方案问询
.NET 6 + Keycloak 运行时新增Realm无需重启的多租户授权方案
你之前依赖多JWT Schema的思路走不通,因为.NET的中间件管道和服务注册都是启动时固化的,运行时没法直接新增Schema或修改中间件。换个思路,用动态JWT验证逻辑适配多Realm,完全不用依赖预定义的Schema。
核心思路
Keycloak每个Realm生成的Token里都会带iss声明(格式一般为https://<keycloak-host>/realms/<realm-name>),我们可以从Token里解析出这个Issuer,然后动态获取对应Realm的验证配置(比如JWKS公钥),而非启动时就把所有Realm的Schema配置死。
具体实现步骤
1. 搭建动态Realm配置存储
把Realm的基础配置(Issuer、Audience)从appSettings.json迁移到支持运行时更新的存储(比如数据库、Redis),新增Realm时直接更新存储即可,不用修改配置文件重启应用。
先定义Realm配置模型:
public class KeycloakRealmConfig { public string Issuer { get; set; } public string Audience { get; set; } public string JwksEndpoint => $"{Issuer}/protocol/openid-connect/certs"; }
再实现一个读写配置的服务:
public interface IRealmConfigProvider { Task<KeycloakRealmConfig> GetRealmConfigByIssuer(string issuer); Task AddOrUpdateRealmConfig(KeycloakRealmConfig config); } // 示例:基于数据库的实现 public class DbRealmConfigProvider : IRealmConfigProvider { private readonly IDbConnection _dbConn; public DbRealmConfigProvider(IDbConnection dbConn) { _dbConn = dbConn; } public async Task<KeycloakRealmConfig> GetRealmConfigByIssuer(string issuer) { return await _dbConn.QueryFirstOrDefaultAsync<KeycloakRealmConfig>( "SELECT Issuer, Audience FROM RealmConfigs WHERE Issuer = @Issuer", new { Issuer = issuer }); } public async Task AddOrUpdateRealmConfig(KeycloakRealmConfig config) { await _dbConn.ExecuteAsync( "INSERT INTO RealmConfigs (Issuer, Audience) VALUES (@Issuer, @Audience) " + "ON DUPLICATE KEY UPDATE Audience = @Audience", config); } }
2. 自定义JWT验证逻辑
放弃多Schema,注册一个默认JWT处理程序,通过JwtBearerEvents动态修改验证参数:
在Program.cs中配置服务和中间件:
builder.Services.AddScoped<IRealmConfigProvider, DbRealmConfigProvider>(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, // 不硬编码Issuer和Audience,留到事件中动态验证 }; options.Events = new JwtBearerEvents { OnTokenValidated = async context => { // 从Token中解析Issuer var issuer = context.SecurityToken.Claims.First(c => c.Type == JwtRegisteredClaimNames.Iss).Value; var realmConfigProvider = context.HttpContext.RequestServices.GetRequiredService<IRealmConfigProvider>(); var realmConfig = await realmConfigProvider.GetRealmConfigByIssuer(issuer); if (realmConfig == null) { context.Fail("无效的Realm Issuer"); return; } // 验证Audience if (!context.SecurityToken.Claims.Any(c => c.Type == JwtRegisteredClaimNames.Aud && c.Value == realmConfig.Audience)) { context.Fail("无效的Audience"); return; } } }; // 动态拉取JWKS配置,替换默认硬编码逻辑 options.ConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>( "placeholder", new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever()); options.ConfigurationManager.GetConfigurationAsync = async (cancellationToken) => { var httpContext = options.HttpContextAccessor.HttpContext; if (httpContext == null) throw new InvalidOperationException("HttpContext不可用"); var token = httpContext.Request.Headers.Authorization.FirstOrDefault()?.Split(" ").Last(); if (string.IsNullOrEmpty(token)) throw new UnauthorizedAccessException("未提供Token"); var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); var issuer = jwtToken.Issuer; var realmConfigProvider = httpContext.RequestServices.GetRequiredService<IRealmConfigProvider>(); var realmConfig = await realmConfigProvider.GetRealmConfigByIssuer(issuer); if (realmConfig == null) throw new UnauthorizedAccessException("无效的Realm"); // 拉取对应Realm的JWKS配置 var retriever = new OpenIdConnectConfigurationRetriever(); var docRetriever = new HttpDocumentRetriever(); return await retriever.GetConfigurationAsync(realmConfig.JwksEndpoint, docRetriever, cancellationToken); }; }); builder.Services.AddAuthorization();
3. 缓存JWKS减少重复请求
Keycloak的JWKS不会频繁更新,用IDistributedCache缓存配置,减少对Keycloak的请求:
修改GetConfigurationAsync方法加入缓存逻辑:
options.ConfigurationManager.GetConfigurationAsync = async (cancellationToken) => { var httpContext = options.HttpContextAccessor.HttpContext; if (httpContext == null) throw new InvalidOperationException("HttpContext不可用"); var token = httpContext.Request.Headers.Authorization.FirstOrDefault()?.Split(" ").Last(); if (string.IsNullOrEmpty(token)) throw new UnauthorizedAccessException("未提供Token"); var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); var issuer = jwtToken.Issuer; var cache = httpContext.RequestServices.GetRequiredService<IDistributedCache>(); var cacheKey = $"Keycloak_JWKS_{issuer}"; var cachedConfig = await cache.GetStringAsync(cacheKey, cancellationToken); if (!string.IsNullOrEmpty(cachedConfig)) { return JsonSerializer.Deserialize<OpenIdConnectConfiguration>(cachedConfig); } var realmConfigProvider = httpContext.RequestServices.GetRequiredService<IRealmConfigProvider>(); var realmConfig = await realmConfigProvider.GetRealmConfigByIssuer(issuer); if (realmConfig == null) throw new UnauthorizedAccessException("无效的Realm"); var retriever = new OpenIdConnectConfigurationRetriever(); var docRetriever = new HttpDocumentRetriever(); var config = await retriever.GetConfigurationAsync(realmConfig.JwksEndpoint, docRetriever, cancellationToken); // 缓存1小时,可根据实际调整 await cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(config), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) }, cancellationToken); return config; };
运行时新增Realm流程
- 在Keycloak中创建新Realm,记录Issuer和Audience
- 通过后台管理接口或工具调用
IRealmConfigProvider.AddOrUpdateRealmConfig,将新Realm配置存入数据库 - 新Realm客户端生成的Token可直接被API验证,无需重启应用
注意事项
- 确保
IRealmConfigProvider实现线程安全,避免并发读写问题 - 合理设置JWKS缓存过期时间,避免Keycloak更新公钥后API无法验证新Token
- 处理Token解析失败、Realm配置不存在等异常,返回合适的HTTP状态码
内容的提问来源于stack exchange,提问作者Adeel
相关产品推荐
相关产品推荐

