Blazor WASM集成Keycloak后,如何动态映射门店角色至Claim?
解决Blazor WASM中登录后选择门店的角色Claim映射问题
针对你遇到的「登录后选择门店,需将对应门店角色映射为Claim以支持[Authorize(Roles)]」的问题,以下是可行的解决方案,避开你之前遇到的依赖注入循环、加载卡死等问题:
核心思路
通过内存状态服务存储用户的门店选择和Keycloak返回的权限数据,结合自定义AuthenticationStateProvider动态更新用户Claim。流程如下:
- 登录完成后,从JWT Token中解析
resource_access数据存入状态服务 - 用户选择门店后,调用后端校验接口并更新状态服务的选中门店ID
- 自定义
AuthenticationStateProvider监听状态变化,动态将选中门店的角色添加到用户Claim中
具体实现步骤
1. 创建门店选择状态服务
用于存储用户的权限数据和选中门店,同时提供状态变更通知:
public interface IStoreSelectionState { string? SelectedStoreId { get; set; } Dictionary<string, StoreRoles>? ResourceAccess { get; set; } event Action? OnStateChanged; } public class StoreSelectionState : IStoreSelectionState { private string? _selectedStoreId; private Dictionary<string, StoreRoles>? _resourceAccess; public string? SelectedStoreId { get => _selectedStoreId; set { _selectedStoreId = value; OnStateChanged?.Invoke(); } } public Dictionary<string, StoreRoles>? ResourceAccess { get => _resourceAccess; set { _resourceAccess = value; OnStateChanged?.Invoke(); } } public event Action? OnStateChanged; } public class StoreRoles { public List<string>? Roles { get; set; } }
2. 实现自定义AuthenticationStateProvider
依赖基础OIDC认证提供器和状态服务,动态构建包含门店角色的ClaimsPrincipal:
public class CustomAuthenticationProvider : AuthenticationStateProvider { private readonly AuthenticationStateProvider _baseOidcProvider; private readonly IStoreSelectionState _storeState; public CustomAuthenticationProvider(AuthenticationStateProvider baseOidcProvider, IStoreSelectionState storeState) { _baseOidcProvider = baseOidcProvider; _storeState = storeState; _storeState.OnStateChanged += () => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } public override async Task<AuthenticationState> GetAuthenticationStateAsync() { var baseAuthState = await _baseOidcProvider.GetAuthenticationStateAsync(); var user = baseAuthState.User; if (user.Identity?.IsAuthenticated == true && !string.IsNullOrEmpty(_storeState.SelectedStoreId) && _storeState.ResourceAccess != null) { // 复制原身份信息,避免修改原始对象 var updatedIdentity = new ClaimsIdentity(user.Identity); // 清除已存在的角色Claim,防止重复添加 var existingRoleClaims = updatedIdentity.FindAll(ClaimTypes.Role).ToList(); foreach (var claim in existingRoleClaims) { updatedIdentity.RemoveClaim(claim); } // 添加选中门店的角色到Claim if (_storeState.ResourceAccess.TryGetValue(_storeState.SelectedStoreId, out var storeRoles) && storeRoles.Roles != null) { foreach (var role in storeRoles.Roles) { updatedIdentity.AddClaim(new Claim(ClaimTypes.Role, role)); } } return new AuthenticationState(new ClaimsPrincipal(updatedIdentity)); } return baseAuthState; } }
3. 登录后解析Token获取权限数据
在登录成功后的页面(如MainLayout或专门的登录回调组件)中,从Token中解析resource_access并存入状态服务:
@inject IAccessTokenProvider TokenProvider @inject IStoreSelectionState StoreState protected override async Task OnInitializedAsync() { var tokenResult = await TokenProvider.RequestAccessToken(); if (tokenResult.TryGetToken(out var token)) { var payload = ParseJwtPayload(token.Value); if (payload.TryGetValue("resource_access", out var resourceAccessObj)) { var resourceAccess = JsonSerializer.Deserialize<Dictionary<string, StoreRoles>>(resourceAccessObj.ToString()!); StoreState.ResourceAccess = resourceAccess; } } } // JWT Payload解析工具方法 private Dictionary<string, object> ParseJwtPayload(string token) { var parts = token.Split('.'); var payloadJson = Base64UrlDecode(parts[1]); return JsonSerializer.Deserialize<Dictionary<string, object>>(payloadJson)!; } private string Base64UrlDecode(string input) { input = input.Replace('-', '+').Replace('_', '/'); switch (input.Length % 4) { case 2: input += "=="; break; case 3: input += "="; break; } return Encoding.UTF8.GetString(Convert.FromBase64String(input)); }
4. 门店选择组件实现
用户选择门店后,调用后端校验接口并更新状态服务,触发身份状态刷新:
@inject IStoreSelectionState StoreState @inject AuthenticationStateProvider AuthStateProvider @inject IHttpClientFactory HttpClientFactory <select @bind="SelectedStoreId" @onchange="HandleStoreSelection"> @foreach (var store in AvailableStores) { <option value="@store.Id">@store.Name</option> } </select> @code { private List<Store> AvailableStores { get; set; } = new(); private string? SelectedStoreId { get; set; } protected override async Task OnInitializedAsync() { // 调用后端接口获取用户可访问的门店列表 var httpClient = HttpClientFactory.CreateClient("myRealm"); AvailableStores = await httpClient.GetFromJsonAsync<List<Store>>("/api/stores/available") ?? new(); } private async Task HandleStoreSelection() { if (SelectedStoreId == null) return; // 调用后端接口校验门店选择并设置Cookie var httpClient = HttpClientFactory.CreateClient("myRealm"); await httpClient.PostAsJsonAsync("/api/stores/select", new { StoreId = SelectedStoreId }); // 更新状态服务,触发Claim刷新 StoreState.SelectedStoreId = SelectedStoreId; } public class Store { public string Id { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; } }
5. 配置Program.cs
注册状态服务和自定义认证提供器,避免循环依赖:
// 注册门店状态服务 builder.Services.AddScoped<IStoreSelectionState, StoreSelectionState>(); // OIDC认证配置 builder.Services.AddOidcAuthentication<RemoteAuthenticationState, RemoteUserAccount>(options => { options.ProviderOptions.MetadataUrl = "http://localhost:8080/realms/myRealm/.well-known/openid-configuration"; options.ProviderOptions.Authority = "http://localhost:8080/realms/myRealm"; options.ProviderOptions.ClientId = builder.Configuration["keycloakClient"]; options.ProviderOptions.ResponseType = "code"; }).AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, RemoteUserAccount, CustomAccountFactory>(); // 注册自定义AuthenticationStateProvider(避免直接注入导致的循环依赖) builder.Services.AddScoped<AuthenticationStateProvider>(sp => { var baseOidcProvider = sp.GetRequiredService<RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>>(); var storeState = sp.GetRequiredService<IStoreSelectionState>(); return new CustomAuthenticationProvider(baseOidcProvider, storeState); }); // 保留原有HttpClient配置 builder.Services.AddHttpClient( "myRealm", opt => opt.BaseAddress = new Uri(builder.Configuration["BackendUrl"]!)) .AddHttpMessageHandler(sp => { var handler = sp.GetService<AuthorizationMessageHandler>(); handler.ConfigureHandler(authorizedUrls: [builder.Configuration["BackendUrl"]!]); return handler; });
为什么之前的方案失败?
- 注入IAccessTokenProvider到CustomAuthenticationProvider:AuthenticationStateProvider是DI容器早期初始化的服务,而IAccessTokenProvider依赖的OIDC服务尚未就绪,导致启动卡死。
- IHttpClientFactory循环依赖:AuthorizationMessageHandler依赖AuthenticationStateProvider,若在CustomAuthenticationProvider中注入HttpClientFactory,会形成循环依赖。
- IHttpContextAccessor在WASM中不可用:Blazor WASM运行在浏览器环境,没有服务器端的HttpContext,该服务默认未注册。
内容的提问来源于stack exchange,提问作者Andrew Shaw
相关产品推荐
相关产品推荐

