SignalR'da Authentication, Authorization ve Güvenlik: JWT, access_token Query String ve Hub Method Policy'leri
SignalR'da kullanıcı bağlanırken bir kez doğrulanır; token süresi dolsa da rol değişse de bağlantı varsayılan olarak açık kalır. OnMessageReceived ile access_token, token'ın log'lara düşmesi, accessTokenFactory, hub ve method policy'leri, HubInvocationContext ile resource-based kontrol, IHubFilter ile rate limiting, mesaj boyutu, Origin ve CloseOnAuthenticationExpiration.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14, Angular 22.2 ve @microsoft/signalr 10.0.11 ile derlenip denenmiştir.
Önce Tahmin Et
Kullanıcı 5 dakika geçerli bir JWT (JSON Web Token) ile SignalR'a bağlanıyor ve sekmeyi iki saat açık bırakıyor. Altıncı dakikada bir hub method'u çağırıyor. Çağrı 401 ile mi reddedilir, bağlantı kopar mı, yoksa hiçbir şey olmamış gibi çalışır mı? Bu arada yönetici kullanıcının rolünü elinden almış olsun; o rolün gerektirdiği method'u hâlâ çağırabilir mi?
HTTP (Hypertext Transfer Protocol) API'lerinde (Application Programming Interface) her istek token'ı yeniden gösteriyor, o yüzden bu sorular orada hiç akla gelmez. Uzun ömürlü bir bağlantıda ise authentication bir kez, bağlanırken yapılıyor. Bu yazıda bunun bütün sonuçlarını gerçek denemelerle görüyoruz.
JWT ile Hub'ı Korumak
// demo: her süreç başlangıcında rastgele bir imza anahtarı (gerçekte key vault'tan gelir)
SymmetricSecurityKey signingKey = new(RandomNumberGenerator.GetBytes(32));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "bookstore-dev",
ValidAudience = "bookstore-api",
IssuerSigningKey = signingKey,
ClockSkew = TimeSpan.Zero
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// tarayıcı WebSocket ve SSE isteklerine header ekleyemez; token query string'de gelir
string? accessToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
Token üretimi ve doğrulamanın kendisi ASP.NET Core Web API serisinin "JWT ile Authentication" yazısıyla aynı; tek fark OnMessageReceived. JWT bearer handler normalde token'ı yalnızca Authorization header'ında arıyor. Bu event'te "path /hubs ile başlıyorsa ve query string'de access_token varsa onu kullan" diyoruz. Path kontrolü önemli: koymazsan uygulamanın bütün endpoint'leri query string'den token kabul eder ve token'ların URL'lere (Uniform Resource Locator), tarayıcı geçmişine, log'lara yayılması kolaylaşır.
ClockSkew = TimeSpan.Zero demo için: varsayılan 5 dakikalık tolerans, aşağıdaki süre dolumu testlerini 5 dakika geciktirirdi. Hub'ı [Authorize] ile işaretlediğimizde token göndermeyen bir istemci daha negotiate aşamasında reddediliyor:
anonymous start failed: Failed to complete negotiation with the server: Error: Unauthorized: Status code '401'
Neden Query String? Tarayıcı ve Token Log'ları
Tarayıcının WebSocket ve EventSource API'leri özel header eklemeye izin vermiyor. Bu SignalR'ın ya da WebSocket protokolünün değil, tarayıcı API'sinin bir kısıtı. Bu yüzden SignalR'ın JavaScript client'ı tarayıcıda WebSocket ve SSE (Server-Sent Events) transport'ları için token'ı URL'e ekliyor. Aynı uygulamaya biri tarayıcıdan, biri Node.js üzerindeki aynı @microsoft/signalr paketiyle bağlandığında sunucunun request log'u:
Request starting HTTP/1.1 GET http://localhost:17606/hubs/orders?id=fWuk-GSN_kJMiLOlWysGdQ&access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJib29rc3RvcmUtYXBpIiwiaXNzIjoiYm9...
Request starting HTTP/1.1 GET http://localhost:17606/hubs/orders?id=BgWsww_AIGw0uiUtU-VCXA - - -
İlk satır tarayıcıdan: token, ASP.NET Core'un varsayılan request log'una olduğu gibi düştü (satırı ben kısalttım; log'da token'ın tamamı var). İkinci satır Node'dan: tarayıcı dışı istemciler token'ı Authorization header'ında gönderdiği için URL'de yok. HTTPS (HTTP Secure) query string'i ağda şifreliyor, ama log'lar şifrelenmiyor; log toplayan herkes, geçerlilik süresi boyunca o kullanıcı gibi davranabilir.
Üç önlem var. Birincisi request log'unu kapatmak ya da seviyesini düşürmek: appsettings.json'da "Microsoft.AspNetCore.Hosting.Diagnostics": "Warning". Aynı uygulamayı bu ayarla çalıştırdığımda Request starting satırları hiç yazılmadı. Request log'u istiyorsan access_token parametresini maskeleyen kendi middleware'ini yaz. İkincisi reverse proxy ve load balancer'ların access log'ları: Nginx'in varsayılan log formatı da tam URL'i yazıyor, oraya da aynı önlemi al. Üçüncüsü token ömrünü kısa tutmak; sızan bir token'ın değeri ömrüyle orantılı.
Angular: accessTokenFactory
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthTokenService {
private token: string | null = null;
private expiresAt = 0;
// gerçek uygulamada burası OIDC library'sinin "geçerli token ver, gerekirse yenile" çağrısıdır
async getAccessToken(): Promise<string> {
if (!this.token || Date.now() > this.expiresAt - 30_000) {
const response = await fetch('http://localhost:17606/dev/token?user=ada&role=customer&seconds=300', {
method: 'POST',
});
this.token = ((await response.json()) as { token: string }).token;
this.expiresAt = Date.now() + 300_000;
}
return this.token;
}
}
private readonly connection: HubConnection = new HubConnectionBuilder()
.withUrl('http://localhost:17606/hubs/orders', {
// negotiate'te ve her yeniden bağlanmada çağrılır; açık bağlantı boyunca tekrar çağrılmaz
accessTokenFactory: () => this.auth.getAccessToken(),
})
.configureLogging(LogLevel.Warning)
.build();
accessTokenFactory'ye sabit bir string değil, her çağrıldığında geçerli bir token döndüren bir fonksiyon veriyoruz. Fonksiyon negotiate'te ve her yeniden bağlanma denemesinde çağrılıyor; bağlantı açıkken çağrılmıyor. Yani token'ı yenilemek yalnızca bir sonraki bağlantıyı etkiliyor, açık olanı değil. OIDC (OpenID Connect) library'n zaten "süresi dolmak üzereyse yenile" mantığını içeriyorsa burada onu çağırman yeterli; HTTP interceptor'ı SignalR'ın WebSocket isteklerini görmediği için orada token eklemek işe yaramaz.
Tarayıcıda, Ada olarak bağlanan component'in ekranı (sipariş 42 Ada'nın, 43 Alan'ın):
ada (customer), token expires at 02:44:18
watching order 42
order 43: Failed to invoke 'WatchOrder' because user is unauthorized
order 42: Packed
Son satır, depo çalışanı Grace'in başka bir istemciden 42'yi paketlemesinin sonucu. 43'ün neden reddedildiğine birazdan geleceğiz.
Hub ve Method Seviyesinde Policy'ler
[Authorize]
public sealed class OrderHub : Hub<IOrderClient>
{
public string WhoAmI() =>
$"{Context.UserIdentifier} ({Context.User!.FindFirstValue(ClaimTypes.Role)}), " +
$"token expires at {DateTimeOffset.FromUnixTimeSeconds(long.Parse(Context.User!.FindFirstValue("exp")!)):HH:mm:ss}";
[Authorize(Policy = "OrderOwner")]
public Task WatchOrder(int orderId) =>
Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
[Authorize(Policy = "Warehouse")]
public Task MarkPacked(int orderId) =>
Clients.Group($"order-{orderId}").OrderStatusChanged(orderId, "Packed");
public int Echo(string text) => text.Length;
}
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Warehouse", policy => policy.RequireRole("warehouse"));
options.AddPolicy("OrderOwner", policy => policy.AddRequirements(new OrderOwnerRequirement()));
});
Class üzerindeki [Authorize] bağlantının kendisini koruyor: token'sız negotiate 401. Method üzerindeki [Authorize(Policy = ...)] ise her çağrıda değerlendiriliyor. Policy'ler Web API serisinin Authorization yazısındakilerle birebir aynı; SignalR onları HTTP endpoint'leri gibi kullanıyor. Node istemcisinden müşteri Ada ve depo çalışanı Grace ile:
WhoAmI() -> "ada (customer), token expires at 02:42:13"
WatchOrder(42) -> null
WatchOrder(43) failed: Failed to invoke 'WatchOrder' because user is unauthorized
MarkPacked(42) failed: Failed to invoke 'MarkPacked' because user is unauthorized
ada <- OrderStatusChanged(42, Packed)
MarkPacked(42) -> null
Ada MarkPacked çağıramadı; Grace çağırınca 42'yi izleyen Ada güncellemeyi aldı. Yetkisiz çağrı bağlantıyı kapatmıyor, yalnızca o çağrı hata dönüyor.
Resource-Based Authorization: Yalnızca Kendi Siparişinin Grubu
Bir önceki yazının açık kapısı buydu: grup adlarını biz üretiyoruz ve WatchOrder(43) diyen herkes 43'ün grubuna katılabiliyordu. Rol kontrolü burada yetmez; Ada da Alan da "customer". Karar, çağrının argümanına bağlı:
public sealed class OrderOwnerRequirement : IAuthorizationRequirement;
public sealed class OrderOwnerHandler(OrderOwnership ownership)
: AuthorizationHandler<OrderOwnerRequirement, HubInvocationContext>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, OrderOwnerRequirement requirement, HubInvocationContext resource)
{
// method'un ilk argümanı sipariş numarası; depo çalışanı hepsini, müşteri yalnız kendisininkini izleyebilir
if (resource.HubMethodArguments is [int orderId, ..] &&
(context.User.IsInRole("warehouse") || ownership.IsOwner(orderId, context.User.FindFirstValue(ClaimTypes.NameIdentifier))))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
SignalR, hub method'u için authorization yaparken handler'a resource olarak bir HubInvocationContext veriyor: çağıranın bağlantısı, method adı ve argümanlar. List pattern ([int orderId, ..]) ile ilk argümanı alıp sahiplik kontrolü yapıyoruz. OrderOwnership burada sabit bir sözlük (42 → ada, 43 → alan); gerçek uygulamada bir veritabanı sorgusu olur ve her WatchOrder çağrısında çalışır, dolayısıyla hızlı olmalı. Handler singleton kayıtlı; scoped bir DbContext gerekiyorsa handler'ı scoped kaydet.
IHubFilter ve Limitler: Rate Limiting, Mesaj Boyutu, Origin
Her hub çağrısında çalışması gereken ortak kontroller (audit log, rate limiting, doğrulama) için IHubFilter var; MVC'deki (Model-View-Controller) action filter'ın SignalR karşılığı:
public sealed class RateLimitHubFilter(ILogger<RateLimitHubFilter> logger) : IHubFilter
{
private const string LimiterKey = "rate-limiter";
public Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> next)
{
// bağlantı başına 10 saniyede 5 çağrı
context.Context.Items[LimiterKey] = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
TokenLimit = 5,
TokensPerPeriod = 5,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 0
});
return next(context);
}
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object?>> next)
{
TokenBucketRateLimiter limiter = (TokenBucketRateLimiter)invocationContext.Context.Items[LimiterKey]!;
using RateLimitLease lease = limiter.AttemptAcquire();
if (!lease.IsAcquired)
{
logger.LogWarning("{User} rate limited on {Method}",
invocationContext.Context.UserIdentifier, invocationContext.HubMethodName);
throw new HubException("Too many requests, slow down.");
}
long started = Stopwatch.GetTimestamp();
object? result = await next(invocationContext);
logger.LogInformation("{User} called {Method} in {Elapsed:F1} ms",
invocationContext.Context.UserIdentifier, invocationContext.HubMethodName,
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return result;
}
public Task OnDisconnectedAsync(HubLifetimeContext context, Exception? exception, Func<HubLifetimeContext, Exception?, Task> next)
{
(context.Context.Items[LimiterKey] as IDisposable)?.Dispose();
return next(context, exception);
}
}
builder.Services.AddSignalR(options =>
{
options.MaximumReceiveMessageSize = 32 * 1024;
options.AddFilter<RateLimitHubFilter>();
});
ASP.NET Core'un rate limiting middleware'i HTTP isteği başına çalışıyor; WebSocket bağlantısı tek bir HTTP isteği olduğu için içindeki yüzlerce hub çağrısını göremiyor. Bu yüzden limiti filter'da, bağlantı başına bir TokenBucketRateLimiter ile koyuyoruz ve Context.Items'ta saklıyoruz. Grace'in token'ı bir MarkPacked ile başlamıştı; ardından gelen altı Echo:
Echo(hello) -> 5
Echo(hello) -> 5
Echo(hello) -> 5
Echo(hello) -> 5
Echo(hello) failed: An unexpected error occurred invoking 'Echo' on the server. HubException: Too many requests, slow down.
Echo(hello) failed: An unexpected error occurred invoking 'Echo' on the server. HubException: Too many requests, slow down.
Bir ayrıntı: yetkisiz çağrılar (Ada'nın MarkPacked'i) filter'a hiç ulaşmadı, audit log'unda görünmediler; authorization filter'dan önce çalışıyor. Yetkisiz çağrı denemelerini de saymak istiyorsan bunu authorization handler'da ya da log'larda yapman gerekiyor.
Mesaj boyutu: Ada 40.000 karakterlik bir Echo gönderdiğinde istemci tarafında yalnızca "Connection closed with an error." gördüm; sunucu log'unu Debug seviyesine çekince sebep ortaya çıktı:
System.IO.InvalidDataException: The maximum message size of 32768B was exceeded. The message size can be configured in AddHubOptions.
MaximumReceiveMessageSize'ı aşan bir mesaj yalnızca o çağrıyı değil bütün bağlantıyı kapatıyor. Limiti büyütmek yerine büyük veriyi normal bir HTTP upload ile göndermek çoğu zaman doğru çözüm.
Son olarak Origin. SignalR'ın negotiate isteği CORS (Cross-Origin Resource Sharing) ile korunuyor, ama serinin ikinci yazısında gördüğümüz gibi tarayıcı WebSocket isteği için CORS uygulamıyor. MapHub'dan önce app.UseWebSockets'e bir AllowedOrigins listesi verdiğimde, SignalR'ın WebSocket isteği de bu kontrolden geçti. Token'ı ve connection token'ı geçerli iki istek, yalnızca Origin header'ı farklı:
Origin: https://evil.example
HTTP/1.1 403 Forbidden
Origin: http://localhost:4200
HTTP/1.1 101 Switching Protocols
Token Süresi Dolunca Ne Olur?
4 saniyelik bir token ile bağlanıp 7 saniye bekledim ve aynı method'u yeniden çağırdım:
connected at 02:37:53
WhoAmI() -> "ada (customer), token expires at 02:37:57"
after 7 s at 02:38:00
WhoAmI() -> "ada (customer), token expires at 02:37:57"
Baştaki sorunun cevabı: hiçbir şey olmadı. SignalR, bağlantı kurulurken doğrulanan kullanıcıyı (Context.User) bağlantı boyunca önbellekte tutuyor ve süresini, iptalini ya da rol değişikliğini yeniden kontrol etmiyor. Method seviyesindeki [Authorize(Roles = ...)] da bu önbellekteki kullanıcıya bakıyor; yani rolü elinden alınan kullanıcı, bağlantısı kopana kadar o method'u çağırmaya devam edebilir. Microsoft'un dokümantasyonu da bunu açıkça yazıyor: bu davranış cookie dahil bütün authentication şemaları için geçerli.
Bunu değiştirmenin .NET 10'daki yolu CloseOnAuthenticationExpiration:
app.MapHub<OrderHub>("/hubs/orders", options =>
options.CloseOnAuthenticationExpiration = args.Contains("--close-on-expiry"));
Sunucuyu --close-on-expiry ile başlatıp aynı testi tekrarladım:
connected at 02:38:06
WhoAmI() -> "ada (customer), token expires at 02:38:10"
ada closed: no error
after 7 s at 02:38:13
WhoAmI(undefined) failed: Cannot send data if the connection is not in the 'Connected' State.
Connection FqQFMEoU7m-vulpTraBB3Q closing because the authentication token has expired.
Sunucu bağlantıyı token'ın süresi dolduğu an kapattı. İstemci tarafında bu bir kopuş: withAutomaticReconnect varsa yeniden bağlanır, accessTokenFactory yeni bir token alır ve iş devam eder. Bedeli, yeniden bağlanma süresince kaçan mesajlar ve kaybolan grup üyelikleri. .NET 11 bunu bağlantıyı koparmadan token yenileyen "authentication refresh" ile çözüyor; serinin son yazısında anlatıyoruz.
Üretimde Dikkat
Yetki değişikliklerini bağlantıya yansıtacak bir yolun olsun. Kullanıcının rolü değiştiğinde ya da hesabı kapatıldığında, bağlantılarını kapatmak için onları bulabilmelisin; Clients.User(id)'ye "çıkış yap" mesajı göndermek ve istemcinin stop() çağırması en basit yol. Kritik işlemlerde hub method'u içinde güncel yetkiyi veritabanından yeniden kontrol etmek, önbellekteki Context.User'a güvenmekten daha güvenli.
Token ömrü ile bağlantı ömrünü birlikte düşün: kısa ömürlü token ve CloseOnAuthenticationExpiration, kullanıcı başına belirli aralıklarla bir yeniden bağlanma demek. 10.000 kullanıcı ve 5 dakikalık token, saniyede ortalama 33 yeniden bağlanma; negotiate, authentication ve grup katılımları dahil.
Rate limiting ve audit filter'larının her çağrıda çalıştığını unutma; içlerinde veritabanına gitme, bellek içi ve hızlı tut.
Sık Düşülen Tuzaklar
OnMessageReceived'da path kontrolü yapmamak. Bütün API query string'den token kabul eder hâle gelir.
Request log'larını ve proxy access log'larını unutmak. Tarayıcıdan gelen her WebSocket bağlantısı token'ı URL'de taşıyor.
Token'ı Angular HTTP interceptor'ı ile eklemeye çalışmak. SignalR'ın WebSocket ve SSE istekleri HttpClient'tan geçmiyor; accessTokenFactory kullan.
Token süresi dolunca bağlantının kapandığını varsaymak. Varsayılan davranış açık kalmak; istiyorsan CloseOnAuthenticationExpiration'ı aç.
Grup katılımını yalnızca rol ile korumak. Aynı roldeki herkes birbirinin grubuna katılır; argümana bakan resource-based bir handler yaz.
HTTP rate limiting middleware'inin hub çağrılarını sınırladığını sanmak. WebSocket bağlantısında yalnızca bağlantıyı açan istekte bir kez çalışır; hub çağrıları için filter gerekiyor.
Küçük Bir Deneme
★☆☆ OnMessageReceived'daki path kontrolünü kaldır ve /dev/token dışındaki korumalı bir HTTP endpoint'ine ?access_token=... ile istek at. İsteğin kabul edildiğini gör, sonra kontrolü geri koy.
★★☆ access_token değerini *** ile değiştirerek request path'ini ve süresini loglayan küçük bir middleware yaz, Microsoft.AspNetCore.Hosting.Diagnostics'i Warning'e çek ve tarayıcıdan bağlanınca log'da token'ın görünmediğini doğrula.
★★★ CloseOnAuthenticationExpiration açıkken Angular tarafında withAutomaticReconnect() ekle, token ömrünü 20 saniyeye indir ve iki dakika boyunca bağlantı durumunu ve WatchOrder grubunun kaybolup kaybolmadığını gözle. Grubu onreconnected'da yeniden kur.
Bir sonraki yazıda bağlantının kopmasını bir hata değil olağan bir durum olarak ele alıyoruz: automatic reconnect ve özel retry policy, .NET 8'in stateful reconnect'i, server ve client streaming, MessagePack protocol'ü ve yavaş istemciler.