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
相关产品推荐
相关产品推荐

