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

Blazor Server(.NET 10)中ProtectedLocalStorage引发JS互操作异常求助

解决Blazor Server中JWT认证的JS互操作错误

问题根源

AuthenticationStateProvider.GetAuthenticationStateAsync会在应用启动早期(比如路由授权检查阶段)被调用,此时Blazor的JS运行时还未完全初始化——哪怕禁用预渲染,这个时机也早于JS互操作可用的阶段,直接调用ProtectedLocalStorage就会触发InvalidOperationException。

解决方案核心

先返回一个未认证的初始状态,等到JS运行时就绪后(组件首次渲染完成),再异步加载令牌并更新认证状态。

具体修改步骤

1. 更新AuthStateProvider

修改为延迟加载认证状态,避免在早期调用JS互操作:

public class AuthStateProvider : AuthenticationStateProvider
{
    private readonly TokenProvider _tokenService;
    private AuthenticationState _cachedState;

    public AuthStateProvider(TokenProvider tokenService)
    {
        _tokenService = tokenService;
        // 初始化未认证状态
        _cachedState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
    }

    public override Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        // 先返回缓存的初始状态,绕过早期JS互操作限制
        return Task.FromResult(_cachedState);
    }

    // 用于异步加载并更新认证状态的方法
    public async Task LoadAuthenticationStateAsync()
    {
        var token = await _tokenService.GetTokenAsync();
        AuthenticationState newState;

        if (string.IsNullOrWhiteSpace(token))
        {
            newState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }
        else
        {
            var handler = new JwtSecurityTokenHandler();
            var jwt = handler.ReadJwtToken(token);
            var identity = new ClaimsIdentity(jwt.Claims, "jwt");
            newState = new AuthenticationState(new ClaimsPrincipal(identity));
        }

        _cachedState = newState;
        // 通知Blazor认证状态已变更,触发组件重新渲染
        NotifyAuthenticationStateChanged(Task.FromResult(newState));
    }
}

2. 完善TokenProvider

添加令牌保存和清除方法:

public class TokenProvider
{
    private const string TokenKey = "authToken";
    private readonly ProtectedLocalStorage _storage;

    public TokenProvider(ProtectedLocalStorage storage) => _storage = storage;

    public async Task<string?> GetTokenAsync()
    {
        try
        {
            var result = await _storage.GetAsync<string>(TokenKey);
            return result.Success ? result.Value : null;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"GetTokenAsync failed: {ex.GetType().Name} - {ex.Message}");
            return null;
        }
    }

    // 添加保存令牌方法
    public async Task SetTokenAsync(string token)
    {
        await _storage.SetAsync(TokenKey, token);
    }

    // 添加清除令牌方法
    public async Task ClearTokenAsync()
    {
        await _storage.DeleteAsync(TokenKey);
    }
}

3. 在根组件触发状态加载

修改MainLayout.razor,在首次渲染完成后调用状态加载方法:

@inherits LayoutComponentBase
@inject AuthStateProvider AuthStateProvider

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <LoginDisplay />
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // 首次渲染后JS运行时已就绪,安全加载认证状态
            await AuthStateProvider.LoadAuthenticationStateAsync();
        }
        await base.OnAfterRenderAsync(firstRender);
    }
}

4. 处理登录/登出逻辑

登录组件示例
@inject TokenProvider TokenProvider
@inject AuthStateProvider AuthStateProvider
@inject NavigationManager NavManager
@inject HttpClient HttpClient

<div class="login-container">
    <input @bind="Username" placeholder="用户名" class="form-control mb-2" />
    <input @bind="Password" type="password" placeholder="密码" class="form-control mb-2" />
    <button @onclick="LoginAsync" class="btn btn-primary">登录</button>
</div>

@code {
    private string Username { get; set; }
    private string Password { get; set; }

    private async Task LoginAsync()
    {
        var loginModel = new { Username, Password };
        var response = await HttpClient.PostAsJsonAsync("/api/auth/login", loginModel);
        
        if (response.IsSuccessStatusCode)
        {
            var token = await response.Content.ReadAsStringAsync();
            await TokenProvider.SetTokenAsync(token);
            // 更新认证状态
            await AuthStateProvider.LoadAuthenticationStateAsync();
            NavManager.NavigateTo("/");
        }
        else
        {
            // 处理登录失败逻辑
        }
    }
}
登出逻辑示例(LoginDisplay.razor)
@inject TokenProvider TokenProvider
@inject AuthStateProvider AuthStateProvider
@inject NavigationManager NavManager
@inject HttpClient HttpClient

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity.Name!
        <button @onclick="LogoutAsync" class="btn btn-link">登出</button>
    </Authorized>
    <NotAuthorized>
        <a href="/login">登录</a>
    </NotAuthorized>
</AuthorizeView>

@code {
    private async Task LogoutAsync()
    {
        var token = await TokenProvider.GetTokenAsync();
        // 调用后端登出接口(如果需要)
        if (!string.IsNullOrWhiteSpace(token))
        {
            HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
            await HttpClient.PostAsync("/api/auth/logout", null);
        }
        // 清除本地令牌
        await TokenProvider.ClearTokenAsync();
        // 更新认证状态
        await AuthStateProvider.LoadAuthenticationStateAsync();
        NavManager.NavigateTo("/login");
    }
}

5. 服务注册与配置

在Program.cs中确保正确注册服务:

var builder = WebApplication.CreateBuilder(args);

// 添加Blazor服务
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

// 注册自定义服务
builder.Services.AddScoped<TokenProvider>();
builder.Services.AddScoped<AuthenticationStateProvider, AuthStateProvider>();

// JWT认证配置
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
    };
});

builder.Services.AddAuthorization();
builder.Services.AddHttpClient();

var app = builder.Build();

// 中间件配置
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();

// 启用认证和授权中间件
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode(options => options.Prerender = false);

app.Run();

6. 确保App.razor禁用预渲染

<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />

关键说明

  • 初始的未认证状态会让授权组件(如[Authorize]属性)暂时认为用户未登录,直到状态加载完成后自动更新。
  • NotifyAuthenticationStateChanged会触发所有依赖认证状态的组件重新渲染,比如LoginDisplay和带有[Authorize]的页面。
  • 登出时先尝试获取令牌调用后端接口,再清除本地存储并更新状态,避免JS互操作错误。

内容的提问来源于stack exchange,提问作者Prashil Shah

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.11 11:14:51