Blazor WebApp Server&Cookie登出跨Tab下SignalR连接残留
目录
框架说明
在使用Blazor WebApp Server模式下,设定以Cookie为鉴权机制,且所有页面都设定了Authorize特性。
问题陈述
当多开Tab页,其中一个Tab页执行退出操作,Cookie在浏览器中清除,可是其他Tab页仍然可以切换页面,执行请求。与传统Web程序工作机制不太相同。因其在首次访问页面后建立了SignalR信道连接,其通信过程中并不使用浏览器上存储的Cookie。
而当重新刷新Tab2或Tab3,则又立马跳转到登录页,因此时是走Http请求发起页面获取,没有Cookie。
总而言之便是,退出时无法跨Tab将其他页面一并退出,当前页面无法操作,其他页面仍然可以操作,其根源为无法清除SignalR信道内的Cookie信息。从通信方式上描述为如下场景,退出后只断掉了Http请求的鉴权检查,无法消除SignalR的通道。

解决方案
官方文档中提及了这种场景,也提供了方案来解决,需要自定义RevalidatingServerAuthenticationStateProvider类,这个类目的为服务端会按照其中属性值RevalidationInterval定期检查SignalR信道。
其检查方法中,则可以根据当前信道内的信息去获取当前用户是否实际退出了,从而根据结果是断开SignalR信道还是仍然保持。
如下具体展开实现自定义类及应用。
新建项目
此处快速演示下其具体过程,搭建一个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页仍然可以点击递增值。

信道检测机制
增加自定义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>
拦截效果
当检查周期达到,校验当前用户已经退出了,则返回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>
用户身份信息消失,授权检查无效,仍然可用看到页面及点击功能,只是缺少了用户信息,变为匿名执行了。效果如下

内容总结
整个授权检查就是两个部分
- 在常规http请求页面上时,使用传统Web程序,走鉴权中间件并使用设定的cookie schema检查cookie有效性。当cookie过期或者页面上点击退出请求到服务端则重定向到特定地址。
AuthenticationService+Cookie Scheme+AuthorizeAttribute/GlobalRequireAuthorization
- 在建立了SignalR通信后,浏览器中的Cookie已经不再起作用了。刷新页面重新使用http请求,走鉴权流程,而不刷新页面下,只要服务端不断开总是保持连接,因此服务端加上周期检查机制,在用户真实退出后断开信道。
RevalidatingServerAuthenticationStateProvider+AuthorizeRouteView+AuthorizeAttribute
参考文档
2025-11-25,望技术有成后能回来看见自己的脚步。