Blazor静态SSR与WASM认证状态不同步问题排查
问题概述
开发了采用静态SSR和WASM(非交互式服务器模式)的Blazor Web应用,包含服务端与WASM客户端项目,基于Appwrite实现自定义认证提供者。认证初期运行正常,但会话持续数天后出现不一致问题:
- 服务端判定用户仍处于登录状态,预渲染的登录组件显示“管理个人资料”按钮;但WASM完成hydration后切换为“登录”按钮,客户端显示用户已登出
- 使用同一认证逻辑的极简API仍允许登录用户执行操作
- 调试页面显示:预渲染页面展示正常登录状态和Claims,WASM页面显示用户已登出
目前无法定位会话持续数天后,认证状态在服务端到客户端同步时序列化失败的原因,请求排查。
服务端认证配置
public static IHostApplicationBuilder ConfigureAuthentication(this IHostApplicationBuilder builder) { builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(x => { x.ExpireTimeSpan = TimeSpan.FromDays(30); x.SlidingExpiration = true; x.LoginPath = "/login"; x.LogoutPath = "/logout"; x.AccessDeniedPath = "/access-denied"; x.Events.OnRedirectToLogin = context => { // Check if it's an API request (you can adjust this logic) if (context.Request.Path.StartsWithSegments("/api") || context.Request.Headers.Accept.ToString().Contains("application/json")) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } // For regular Blazor pages, keep the redirect behavior context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; x.Events.OnRedirectToLogout = context => { // Check if it's an API request if (context.Request.Path.StartsWithSegments("/api") || context.Request.Headers.Accept.ToString().Contains("application/json")) { // For API requests, return 200 OK but don't redirect context.Response.StatusCode = StatusCodes.Status200OK; return Task.CompletedTask; } // For regular requests, keep the redirect to the logout page // This will hit your Logout.razor WASM page context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; }); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>(); return builder; }
服务端认证中间件配置
public static WebApplication ConfigureAuthentication(this WebApplication app) { app.UseAuthentication(); app.UseAuthorization(); return app; }
客户端WASM认证配置
public static WebAssemblyHostBuilder ConfigureAuthentication(this WebAssemblyHostBuilder builder) { builder.Services.AddAuthorizationCore(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthenticationStateDeserialization(); return builder; }
服务端自定义认证状态提供者
using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; using PinguApps.Appwrite.Server.Clients; using PinguApps.Appwrite.Shared.Requests.Users; using PinguApps.Appwrite.Shared.Responses; using Wrevo.Common; using Wrevo.UI.Client.Features.Extensions; using ZiggyCreatures.Caching.Fusion; namespace Wrevo.UI.Features.Pages.Account; public class CustomAuthStateProvider : ServerAuthenticationStateProvider { private readonly IServerAppwriteClient _appwriteClient; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IFusionCache _cache; public CustomAuthStateProvider(IServerAppwriteClient appwriteClient, IHttpContextAccessor httpContextAccessor, IFusionCache cache) { _appwriteClient = appwriteClient; _httpContextAccessor = httpContextAccessor; _cache = cache; } public override async Task<AuthenticationState> GetAuthenticationStateAsync() { var httpContext = _httpContextAccessor.HttpContext; if (httpContext is null) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } // Try to get the current user from cookie authentication var authenticateResult = await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); if (!authenticateResult.Succeeded) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } // Check for both session cookies var sessionId = authenticateResult.Principal.FindFirst(ClaimTypes.PrimarySid)?.Value; if (string.IsNullOrEmpty(sessionId)) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } var authState = await _cache.GetOrSetAsync<List<SerializableClaim>?>( CacheKeys.Auth.SessionState(sessionId), async (context, _) => { context.Options.IsFailSafeEnabled = false; var isSignedOut = await _cache.GetOrDefaultAsync( CacheKeys.Auth.SignedOutSession(sessionId), (string?)null); if (!string.IsNullOrEmpty(isSignedOut)) { context.Options.SetDurationZero(); return null; } var userId = authenticateResult.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId)) { await SignOutAsync(sessionId); context.Options.SetDurationZero(); return null; } // Validate user session var sessionsRequest = new ListUserSessionsRequest { UserId = userId }; var sessionsResult = await _appwriteClient.Users.ListUserSessions(sessionsRequest); if (!sessionsResult.Result.TryParseResult(out var sessionsList) || !sessionsList.Sessions.Any(x => x.Id == sessionId)) { await SignOutAsync(sessionId); context.Options.SetDurationZero(); return null; } // Retrieve and validate existence of session cookie here var sessionCookie = httpContext.Request.Cookies["wrevo.session"]; if (string.IsNullOrEmpty(sessionCookie)) { await SignOutAsync(sessionId); context.Options.SetDurationZero(); return null; } var userRequest = new GetUserRequest { UserId = userId }; var userResult = await _appwriteClient.Users.GetUser(userRequest); if (!userResult.Result.TryParseResult(out var user) || !user.Status) { await SignOutAsync(sessionId); context.Options.SetDurationZero(); return null; } var claimsIdentity = GetClaimsForUser(user, sessionId, sessionCookie); var currentClaims = authenticateResult.Principal.Claims; var newClaims = claimsIdentity.Claims; var claimsMatch = currentClaims.Select(x => new { x.Type, x.Value }).SequenceEqual(newClaims.Select(x => new { x.Type, x.Value })); if (!claimsMatch) { await SignIn(httpContext, claimsIdentity); } context.Options.SetDuration(CacheOptions.Auth.Session); return claimsIdentity.Claims .Select(x => new SerializableClaim { Type = x.Type, Value = x.Value }) .ToList(); }, tags: CacheTags.Auth.SessionState); // Convert the result to AuthenticationState if (authState is not null) { var identity = new ClaimsIdentity( authState.Select(x => new Claim(x.Type, x.Value)), CookieAuthenticationDefaults.AuthenticationScheme); return new AuthenticationState(new ClaimsPrincipal(identity)); } return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } private async Task SignOutAsync(string sessionId) { if (_httpContextAccessor.HttpContext is not null) { await _cache.SetAsync( CacheKeys.Auth.SignedOutSession(sessionId), "true", CacheOptions.Auth.Signout, CacheTags.Auth.SignedOutSession); await _cache.RemoveAsync(CacheKeys.Auth.SessionState(sessionId)); var httpContext = _httpContextAccessor.HttpContext; if (httpContext is not null && !httpContext.Response.HasStarted) { await _httpContextAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); _httpContextAccessor.HttpContext.Response.Cookies.Delete("wrevo.session"); _httpContextAccessor.HttpContext.Response.Cookies.Delete("wrevo.sessionId"); } } } public static ClaimsIdentity GetClaimsForUser(User user, string sessionId, string sessionSecret) { var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id), new(ClaimTypes.Name, user.Name), new(ClaimTypes.Email, user.Email), new(ClaimTypes.PrimarySid, sessionId), new(Claims.CreatedAt, user.CreatedAt.ToString("O")), new(Claims.SessionSecret, sessionSecret) }; if (user.EmailVerification) { claims.Add(new(ClaimTypes.Role, "emailVerified")); } else { claims.Add(new(ClaimTypes.Role, "anonymous")); } foreach (var label in user.Labels) { claims.Add(new(ClaimTypes.Role, label)); } return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); } public static Task SignIn(HttpContext httpContext, ClaimsIdentity claimsIdentity) { var authProperties = new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.Add(TimeSpan.FromDays(30)) }; return httpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), authProperties); } public async Task InvalidateCache(string sessionId) { await _cache.RemoveAsync(CacheKeys.Auth.SessionState(sessionId)); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } private class SerializableClaim { public string Type { get; set; } = string.Empty; public string Value { get; set; } = string.Empty; } }
内容的提问来源于stack exchange,提问作者pingu2k4
相关产品推荐
相关产品推荐

