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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 01:12:07