You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Blazor WASM集成Keycloak后,如何动态映射门店角色至Claim?

解决Blazor WASM中登录后选择门店的角色Claim映射问题

针对你遇到的「登录后选择门店,需将对应门店角色映射为Claim以支持[Authorize(Roles)]」的问题,以下是可行的解决方案,避开你之前遇到的依赖注入循环、加载卡死等问题:

核心思路

通过内存状态服务存储用户的门店选择和Keycloak返回的权限数据,结合自定义AuthenticationStateProvider动态更新用户Claim。流程如下:

  1. 登录完成后,从JWT Token中解析resource_access数据存入状态服务
  2. 用户选择门店后,调用后端校验接口并更新状态服务的选中门店ID
  3. 自定义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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 15:28:11