Blazor WebApp Server&Cookie登出跨Tab下SignalR连接残留

目录

框架说明

在使用Blazor WebApp Server模式下,设定以Cookie为鉴权机制,且所有页面都设定了Authorize特性。

问题陈述

当多开Tab页,其中一个Tab页执行退出操作,Cookie在浏览器中清除,可是其他Tab页仍然可以切换页面,执行请求。与传统Web程序工作机制不太相同。因其在首次访问页面后建立了SignalR信道连接,其通信过程中并不使用浏览器上存储的Cookie。

200458030_b198fb96-3b63-4a0a-8018-6430b8cb88bf 而当重新刷新Tab2或Tab3,则又立马跳转到登录页,因此时是走Http请求发起页面获取,没有Cookie。

200500290_a2d9d826-e2f7-43d7-acfb-9d27a86fa494 总而言之便是,退出时无法跨Tab将其他页面一并退出,当前页面无法操作,其他页面仍然可以操作,其根源为无法清除SignalR信道内的Cookie信息。从通信方式上描述为如下场景,退出后只断掉了Http请求的鉴权检查,无法消除SignalR的通道。

200502176_22e55dac-91c5-4be5-95d8-bd2517670a91

解决方案

官方文档中提及了这种场景,也提供了方案来解决,需要自定义RevalidatingServerAuthenticationStateProvider类,这个类目的为服务端会按照其中属性值RevalidationInterval定期检查SignalR信道。

https://source.dot.net/#Microsoft.AspNetCore.Components.Server/Circuits/RevalidatingServerAuthenticationStateProvider.cs,14

其检查方法中,则可以根据当前信道内的信息去获取当前用户是否实际退出了,从而根据结果是断开SignalR信道还是仍然保持。

200503886_c1bc4c2a-0f2f-4eed-a9c0-49b260cbe2c8 如下具体展开实现自定义类及应用。

新建项目

此处快速演示下其具体过程,搭建一个Server模式项目。启用全局交互模式

dotnet new blazor -n BlazorServerLogoutDemo -f net10.0 --interactivity Server --all-interactive true

修改App.razor,更换使用判断确定是哪种交互方式,以便于Login页使用表单发起Http请求。

...
<HeadOutlet @rendermode="PageRenderMode" />
...
<Routes @rendermode="PageRenderMode" />
...
@code {
    [CascadingParameter]
    private HttpContext HttpContext { get; set; } = default!;

    private IComponentRenderMode? PageRenderMode =>
        HttpContext.AcceptsInteractiveRouting() ? InteractiveServer : null;
}

设置鉴权授权

修改Program.cs,注册鉴权授权服务,增加鉴权授权中间件。

using BlazorServerLogoutDemo.Components;
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "auth_token";
        options.Cookie.MaxAge = TimeSpan.FromMinutes(30);
        options.LoginPath = "/login";
        options.AccessDeniedPath = "/access-denied";
    });
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();

var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();
app.Run();

设置服务端模拟数据

本文简化登录页和退出操作,关注于同浏览器跨Tab访问操作,新增模拟数据页面。

@page "/counter"
@using BlazorServerLogoutDemo.Services
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@rendermode InteractiveServer
@inject CounterService CounterService

<PageTitle>Counter</PageTitle>

<AuthorizeView>
    <Authorized>
        <p>Hello, @context.User.Identity?.Name!</p>
    </Authorized>
</AuthorizeView>

<h1>Counter</h1>

<p role="status">Current count: @CounterService.Value</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private void IncrementCount()
    {
        CounterService.Value++;
    }
}

其中CounterServer来模拟服务端状态保留

namespace BlazorServerLogoutDemo.Services;

public class CounterService
{
    public int Value { get; set; }
}

服务注册为单例

builder.Services.AddSingleton<BlazorServerLogoutDemo.Services.CounterService>();

登录完成后,请求页面,可以看到数值不断递增,并且退出后,切换到其他Tab页仍然可以点击递增值。 200506415_5f8205d0-897a-44ec-9c8e-c8f8b1788aef

信道检测机制

增加自定义RevalidatingServerAuthenticationStateProvider类, 此处假设User Claims中有一个过期标记,便于理解服务端检测过程。

using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;

namespace BlazorServerLogoutDemo.Services;

public class CustomRevalidatingServerAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
{
    private readonly ILogger<CustomRevalidatingServerAuthenticationStateProvider> _logger;

    public CustomRevalidatingServerAuthenticationStateProvider(ILoggerFactory loggerFactory) : base(loggerFactory)
    {
        _logger = loggerFactory.CreateLogger<CustomRevalidatingServerAuthenticationStateProvider>();
    }

    protected override TimeSpan RevalidationInterval => TimeSpan.FromSeconds(3);// 实际使用时可以设置长一点

    protected override Task<bool> ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken)
    {
        _logger.LogInformation($"{DateTime.Now:hh:mm:ss}-Checking:");
        var expirationDate = authenticationState.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Expiration)?.Value;
        if (expirationDate != null)
        {
            var expirationDateClaim = DateTime.Parse(expirationDate);
            if (expirationDateClaim < DateTime.UtcNow)
            {
                _logger.LogInformation($"false");
                return Task.FromResult(false);
            }
        }

        _logger.LogInformation($"true");
        return Task.FromResult(true);
    }
}

将其服务注册,

builder.Services.AddScoped<AuthenticationStateProvider, BlazorServerLogoutDemo.Services.CustomRevalidatingServerAuthenticationStateProvider>();

在登录时,额外写入一个ClaimType,以模拟信道内达到过期条件。此处只是为了模拟检查用户退出这个过程而设定的一个字段,实际应结合用户退出时本身的信息做判断。

new Claim(ClaimTypes.Expiration, DateTime.UtcNow.AddSeconds(15).ToString("yyyy-MM-dd HH:mm:ss")),

路由处替换为AuthorizeView配合AuthStateProvider使用。

<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

拦截效果

200509165_3db4dca9-7c75-4329-92ad-4798f8319db5 当检查周期达到,校验当前用户已经退出了,则返回false,断掉当前信道连接。默认是提示Not authorize!,这是框架层面写好的,源码如下:

https://source.dot.net/#Microsoft.AspNetCore.Components.Authorization/AuthorizeRouteView.cs,23

重定向登录页

为了达到没有鉴权时跳转到登录页,可以在没有授权时写一个重定向替换默认的行为。从源码上看就是替换掉默认的defaultNotAuthorizedContent(即Not authorize!)

https://source.dot.net/#Microsoft.AspNetCore.Components.Authorization/AuthorizeRouteView.cs,114

具体替换方式为

@using BlazorServerLogoutDemo.Components.Pages

<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" >
            <NotAuthorized>
                <RedirectToLogin />
            </NotAuthorized>
        </AuthorizeRouteView>
        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

重定向组件内容如下,最终效果不演示了,等同于跳转页面到登录页。

@inject NavigationManager NavigationManager

@code {
    protected override void OnInitialized()
    {
        NavigationManager.NavigateTo($"Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
    }
}

方案扩展

如果仅使用默认的RouteView,则意味着信道内缺少授权检查。

<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
    <Found Context="routeData">
        <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

用户身份信息消失,授权检查无效,仍然可用看到页面及点击功能,只是缺少了用户信息,变为匿名执行了。效果如下 200513312_8f0d9707-256b-4787-b8e0-ec0a87999a1f

内容总结

整个授权检查就是两个部分

  • 在常规http请求页面上时,使用传统Web程序,走鉴权中间件并使用设定的cookie schema检查cookie有效性。当cookie过期或者页面上点击退出请求到服务端则重定向到特定地址。
AuthenticationService+Cookie Scheme+AuthorizeAttribute/GlobalRequireAuthorization
  • 在建立了SignalR通信后,浏览器中的Cookie已经不再起作用了。刷新页面重新使用http请求,走鉴权流程,而不刷新页面下,只要服务端不断开总是保持连接,因此服务端加上周期检查机制,在用户真实退出后断开信道。
RevalidatingServerAuthenticationStateProvider+AuthorizeRouteView+AuthorizeAttribute

参考文档

2025-11-25,望技术有成后能回来看见自己的脚步。