اگه از SignalR برای استریم دیتای real-time استفاده میکنی و احراز هویتت stateful و session-based هست، احتمالاً به این مشکل خوردی: کاربر وسط استریم لاگاوت میکنه، ولی connection همچنان باز میمونه و دیتا کماکان push میشه. این مقاله دلیل این اتفاق و راهحل عملیش رو با کد C# نشون میده.
TL;DR
- SignalR توکن یا session رو فقط یک بار، موقع handshake، چک میکنه. بعدش دیگه دوباره validate نمیکنه.
- Revoke کردن session توی Redis لازمه ولی کافی نیست؛ باید یه مکانیزم فعال هم برای بستن connection داشته باشی.
- راهحل کامل از سه بخش تشکیل میشه: نگاشت userId به connectionId، پیام فعال قطع اتصال موقع logout، و یه لایهی دفاع در عمق با
IHubFilter.
چرا فقط revoke کردن کافی نیست
SignalR روی یه persistent connection کار میکنه. ClaimsPrincipal یه بار توی OnConnectedAsync ساخته میشه و تا وقتی connection زندهست همونجا میمونه. یعنی اگه یه فلگ توی Redis بذاری که session باطل شده، خود SignalR از وجود این فلگ خبر نداره؛ کسی باید صراحتاً بره چک کنه.
برای همین دو تا مسیر لازمه:
- چک فلگ revoke قبل از هر invoke، برای متدهایی که کلاینت صداشون میزنه.
- یه سیگنال فعال که connection رو از سمت سرور ببنده، برای جایی که سرور داره مداوم push میکنه و منتظر invoke نیست.
گام اول: نگاشت userId به connectionId
اولین چیزی که لازم داری اینه که بدونی هر کاربر الان کدوم connectionها رو باز داره. این نگاشت باید توی Redis باشه، نه memory محلی سرور، چون اگه چند instance از اپلیکیشن داشته باشی (scale-out)، هر connection ممکنه روی سرور دیگهای باز شده باشه.
public class ChatHub : Hub
{
private readonly IConnectionMultiplexer _redis;
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
var db = _redis.GetDatabase();
await db.SetAddAsync($"user-connections:{userId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier;
var db = _redis.GetDatabase();
await db.SetRemoveAsync($"user-connections:{userId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
گام دوم: قطع فعال connection موقع logout
توی endpoint لاگاوت، بعد از باطل کردن session توی Redis، connectionهای فعال همون کاربر رو پیدا کن و بهشون پیام بده که قطع بشن.
public async Task LogoutAsync(string userId, string sessionId)
{
var db = _redis.GetDatabase();
await db.KeyDeleteAsync($"session:{sessionId}");
var connectionIds = await db.SetMembersAsync($"user-connections:{userId}");
if (connectionIds.Length > 0)
{
await _hubContext.Clients.Clients(
connectionIds.Select(c => c.ToString())
).SendAsync("ForceLogout");
}
}
اگه از Redis backplane برای SignalR استفاده میکنی (AddStackExchangeRedis روی AddSignalR)، همین SendAsync خودش بین همهی instanceها پخش میشه و نیازی به pub/sub جدا نیست.
سمت کلاینت هم باید گوش بده و خودش connection رو ببنده:
connection.on("ForceLogout", () => {
connection.stop();
// ریدایرکت به صفحهی لاگین
});
گام سوم: دفاع در عمق با IHubFilter
روش بالا به رفتار درست کلاینت متکیه. اگه کلاینت پیام رو نادیده بگیره یا دستکاری شده باشه، connection باز میمونه. برای پوشش این حالت، از IHubFilter (از NET 7 به بعد) استفاده کن تا قبل از هر invoke، اعتبار session چک بشه.
public class SessionRevocationFilter : IHubFilter
{
private readonly IConnectionMultiplexer _redis;
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext invocationContext,
Func<HubInvocationContext, ValueTask<object?>> next)
{
var sessionId = invocationContext.Context.User?
.FindFirst("sid")?.Value;
var db = _redis.GetDatabase();
var isValid = await db.KeyExistsAsync($"session:{sessionId}");
if (!isValid)
{
invocationContext.Context.Abort();
throw new HubException("Session revoked");
}
return await next(invocationContext);
}
}
ثبتش توی startup:
services.AddSignalR(options =>
{
options.AddFilter<SessionRevocationFilter>();
});
حالت خاص: استریم سروری با IAsyncEnumerable
اگه سرور مداوم دیتا push میکنه بدون اینکه منتظر invoke از کلاینت باشه، فیلتر بالا اصلاً اجرا نمیشه چون invoke جدیدی رخ نمیده. اینجا باید خود لوپ استریم، فلگ revoke رو دورهای چک کنه.
public async IAsyncEnumerable<DataPoint> StreamData(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var sessionId = Context.User?.FindFirst("sid")?.Value;
var db = _redis.GetDatabase();
while (!cancellationToken.IsCancellationRequested)
{
if (!await db.KeyExistsAsync($"session:{sessionId}"))
{
Context.Abort();
yield break;
}
yield return GetNextDataPoint();
await Task.Delay(1000, cancellationToken);
}
}
اگه فرکانس push خیلی بالاست و زدن Redis روی هر iteration هزینهبره، یه IMemoryCache محلی با TTL چند ثانیهای جلوی Redis بذار تا فشار درخواست کم بشه.
سوالات پرتکرار
آیا revoke کردن token توی Redis بهتنهایی SignalR رو از لاگاوت باخبر میکنه؟
نه. SignalR فقط موقع handshake اعتبار session رو چک میکنه. برای قطع فوری باید علاوه بر revoke، یا فعالانه connection رو ببندی یا قبل از هر invoke اعتبارش رو دوباره چک کنی.
برای اپلیکیشن تکسروری هم به backplane نیاز دارم؟
نه. backplane فقط وقتی لازمه که چند instance از اپلیکیشن داری و connection کاربر ممکنه روی سرور دیگهای باز شده باشه.
چک کردن Redis روی هر invoke عملکرد رو کند نمیکنه؟
اگه فرکانس بالا باشه، بله محسوس میشه. راهحلش گذاشتن یه memory cache محلی با TTL کوتاه جلوی Redisه تا اکثر چکها بدون رفتن به شبکه انجام بشن.
جمعبندی
Revoke کردن session توی Redis قدم درست و لازمیه، ولی تنها راهحل نیست. برای قطع واقعی و فوری connection باید نگاشت userId به connectionId نگه داری، موقع logout فعالانه به کلاینت پیام بدی، و برای پوشش کلاینتهای غیرقابلاعتماد یه لایهی دفاعی با IHubFilter یا چک دورهای داخل خود استریم اضافه کنی.