Blazor'da Authentication ve Authorization: AuthenticationStateProvider, AuthorizeView, Render Mode'a Göre Auth ve Passkey'ler
Blazor'da kullanıcı AuthenticationStateProvider'dan gelir: InteractiveServer'da circuit kurulurken cookie'den alınır ve sabit kalır, WebAssembly'ye ise yalnız name ve role claim'leri serileştirilir; bu yüzden AuthorizeView bir UI kararıdır, güvenlik endpoint'te ve API'de sağlanır. Bookstore panelinde Identity ile login, fallback policy, rol bazlı fiyat düzenleme, panelin imzaladığı token'larla korunan API ve .NET 10 template'indeki passkey desteği.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ile derlenip denenmiştir; Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.15, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.15 ve Microsoft.IdentityModel.JsonWebTokens 8.23.0 kullanılmış, testlerde bUnit 2.15.2, tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim paneli on yazıdır herkese açık. Stok sayımı, yeni yazar formu, panelin WebAssembly tarafının çağırdığı katalog endpoint'i ve Bookstore.Api'nin back office endpoint'leri adresi bilen herkes için çalışıyor. Bu yazıda panele personel girişi ekliyoruz: depo personeli Linus stok sayabilecek, mağaza personeli Grace fiyat da değiştirebilecek. Önce üç soru. Login sayfası dışındaki her şeyi kilitleyen bir fallback policy koyduğunda login sayfasının CSS'i yüklenir mi? Aynı kullanıcı InteractiveServer ve InteractiveWebAssembly component'lerinde kaç claim görür? Fiyat formunu AuthorizeView ile Linus'tan gizledin ama bir gün bir geliştirici bunu unuttu; Linus fiyatı değiştirebilir mi?
Identity, Cookie ve Login Sayfası
Blazor Web App'te authentication (kimlik doğrulama) sıradan bir ASP.NET Core uygulamasındakiyle aynı çalışıyor: kullanıcı bir kez giriş yapıyor, tarayıcı bir cookie taşıyor, sunucu her istekte HttpContext.User'ı bu cookie'den kuruyor. Panelin kullanıcıları personel; hesapları yönetici açıyor, kendi kendine kayıt yok. Bu yüzden template'in Identity arayüzünün tamamı yerine ASP.NET Core Identity'nin user/role store'unu, SignInManager'ı ve kendi login sayfamızı kullanıyoruz:
public static WebApplicationBuilder AddPanelIdentity(this WebApplicationBuilder builder, string connectionString)
{
builder.Services.AddDbContext<AdminIdentityContext>(options => options.UseSqlServer(
connectionString, sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "identity")));
builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme).AddIdentityCookies();
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/account/login";
options.AccessDeniedPath = "/account/access-denied";
});
builder.Services.AddIdentityCore<IdentityUser>(options => options.User.RequireUniqueEmail = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AdminIdentityContext>()
.AddSignInManager();
// [AllowAnonymous] demeyen her endpoint bir personel ister.
builder.Services.AddAuthorization(options =>
{
options.AddPanelPolicies();
options.FallbackPolicy = options.GetPolicy(PanelPolicies.Staff);
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddOptions<PanelTokenOptions>()
.BindConfiguration("PanelTokens")
.Validate(o => o.SigningKey.Length >= 32, "PanelTokens:SigningKey must be at least 32 characters (same value as Bookstore.Api).")
.ValidateOnStart();
builder.Services.AddSingleton<PanelTokenIssuer>();
return builder;
}
Identity tabloları BookstoreDb'de duruyor ama kendi identity schema'sında ve kendi migration geçmişi tablosuyla; panelin AdminIdentityContext'i Bookstore.Data'daki BookstoreContext'e dokunmuyor. Migration'ı panel projesinde ürettik:
$ dotnet ef migrations add CreateIdentitySchema --project src/Bookstore.Admin --context AdminIdentityContext --output-dir Identity/Migrations
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
Development'ta panel açılışta bu migration'ı uyguluyor ve iki personel hesabı açıyor: grace@example.com (StoreStaff) ve linus@example.com (WarehouseStaff). Parola appsettings.Development.json'daki Identity:SeedPassword değeri. Login sayfası 05'teki Static SSR (Static Server-Side Rendering; sayfanın her istekte sunucuda render edilmesi) form'larından biri:
private string? error;
[SupplyParameterFromForm(FormName = "login")]
private LoginForm? Input { get; set; }
[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }
protected override void OnInitialized() => Input ??= new();
private async Task LogInAsync()
{
SignInResult result = await SignInManager.PasswordSignInAsync(
Input!.Email, Input.Password, isPersistent: false, lockoutOnFailure: true);
if (result.Succeeded)
{
Logger.LogInformation("{User} logged in", Input.Email);
Navigation.NavigateTo(LocalReturnUrl);
return;
}
error = result.IsLockedOut ? "Too many failed attempts. Try again later." : "Invalid email or password.";
}
// Yalnız bu sitedeki yollar: "//other.example" ya da "https://..." login sayfasını open redirect'e çevirirdi.
private string LocalReturnUrl => ReturnUrl is "/" or ['/', not ('/' or '\\'), ..] ? ReturnUrl : "/";
PasswordSignInAsync cookie'yi response'a yazıyor ve bunun için sayfanın bir HTTP isteğinin içinde, yani Static SSR'da çalışması gerekiyor. InteractiveServer bir component'te response çoktan gönderilmiş durumda, cookie yazılacak bir istek yok; template'teki bütün Account sayfalarının static olmasının sebebi bu. Logout da bir link değil, antiforgery token'lı bir POST form'u; GET ile çıkış yapılabilseydi başka bir site kullanıcıyı bir resim etiketiyle çıkış yaptırabilirdi. ReturnUrl kontrolü kolay unutulan bir ayrıntı: login sayfası gelen adrese kontrolsüz yönlendirirse open redirect (kullanıcıyı güvendiği siteden kötü niyetli bir siteye taşımak için kullanılabilen yönlendirme) açığı olur. List pattern yalnız / ile başlayan ve // ile başlamayan yolları kabul ediyor. lockoutOnFailure: true de Identity'nin varsayılan lockout ayarlarını devreye sokuyor (beş başarısız deneme, beş dakika):
attempt 1 (wrong password): Invalid email or password.
attempt 2 (wrong password): Invalid email or password.
attempt 3 (wrong password): Invalid email or password.
attempt 4 (wrong password): Invalid email or password.
attempt 5 (wrong password): Too many failed attempts. Try again later.
attempt 6 (correct password): Too many failed attempts. Try again later.
Fallback Policy ve Statik Dosyalar
Korunacak sayfaları tek tek [Authorize] ile işaretlemek yerine panelde tersini yaptık. FallbackPolicy, kendi authorization metadata'sı olmayan her endpoint'e uygulanıyor ve personel rollerinden birini istiyor. Yeni bir sayfa eklendiğinde korumayı unutmak mümkün değil; açık olması gereken login sayfası [AllowAnonymous] ile kendini açıyor. İlk soru şu satırla ilgili:
// CSS, script'ler ve WebAssembly dosyaları login sayfasında da yüklenmeli.
app.MapStaticAssets().AllowAnonymous();
app.MapAccountEndpoints();
app.MapPanelCatalogEndpoints();
AllowAnonymous() olmadan fallback policy CSS ve JS dosyalarına da uygulanıyor:
$ curl -i http://localhost:5037/app.khy4lop6wu.css
HTTP/1.1 302 Found
Location: http://localhost:5037/account/login?ReturnUrl=%2Fapp.khy4lop6wu.css
$ curl -i http://localhost:5037/_framework/blazor.web.js
HTTP/1.1 302 Found
Location: http://localhost:5037/account/login?ReturnUrl=%2F_framework%2Fblazor.web.js
Giriş yapmamış kullanıcı için stil dosyası ve blazor.web.js de login'e yönlendiriliyor; login sayfası stilsiz ve enhanced navigation'sız geliyor. Statik dosyalarında gizli bir şey olmayan bir panelde çözüm MapStaticAssets().AllowAnonymous(). Doğru ayarla giriş yapmamış bir istemcinin gördüğü:
$ curl -i http://localhost:5037/inventory
HTTP/1.1 302 Found
Location: http://localhost:5037/account/login?ReturnUrl=%2Finventory
$ curl -i http://localhost:5037/api/catalog/books?search=Dune
HTTP/1.1 401 Unauthorized
Location: http://localhost:5037/account/login?ReturnUrl=%2Fapi%2Fcatalog%2Fbooks%3Fsearch%3DDune
$ curl -i http://localhost:5080/api/inventory
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Sayfa isteği 302 ile login'e gidiyor, katalog endpoint'i ise 401 dönüyor. Bu .NET 10'daki bir davranış değişikliği: cookie authentication, JSON okuyan ya da yazan minimal API'ler, [ApiController]'lar ve TypedResults dönen endpoint'ler gibi "bilinen API endpoint'lerinde" login sayfasına yönlendirmek yerine 401 ve 403 dönüyor; Location header'ı yine de yerinde. Eski davranışı geri getiren IgnoreRedirectMetadata switch'iyle aynı istek:
$ curl -i http://localhost:5037/api/catalog/books?search=Dune
HTTP/1.1 302 Found
Location: http://localhost:5037/account/login?ReturnUrl=%2Fapi%2Fcatalog%2Fbooks%3Fsearch%3DDune
WebAssembly'deki HttpBookCatalog bu 302'yi takip edip login sayfasının HTML'ini JSON diye okumaya çalışırdı; 401 ise koddaki hata yolunu doğrudan tetikliyor.
AuthenticationStateProvider, AuthorizeView ve [Authorize]
Component'ler kullanıcıyı HttpContext'ten değil AuthenticationStateProvider'dan alıyor. AddCascadingAuthenticationState() bu provider'ın verdiği Task<AuthenticationState>'ı, 08'deki PanelOptions gibi root seviyesinde bir cascading value olarak bütün component ağacına veriyor; AuthorizeView ve AuthorizeRouteView onu okuyor. Kitap sayfasındaki fiyat formu:
@using System.ComponentModel.DataAnnotations
@inject BookstoreApiClient Api
@inject NavigationManager Navigation
<AuthorizeView Policy="@PanelPolicies.ManageCatalog">
<Authorized>
<h2>Price</h2>
@if (error is not null)
{
<p class="alert alert-danger">@error</p>
}
<EditForm Model="Form" FormName="price" OnValidSubmit="SaveAsync" Enhance Context="editContext">
<DataAnnotationsValidator />
<label>
New price
<InputNumber @bind-Value="Form!.Price" class="form-control w-auto" />
</label>
<ValidationMessage For="() => Form!.Price" />
<button type="submit" class="btn btn-primary">Change price</button>
</EditForm>
</Authorized>
<NotAuthorized>
<p class="text-muted">Only store staff can change prices.</p>
</NotAuthorized>
</AuthorizeView>
Authorized bloğu bir RenderFragment<AuthenticationState> ve parametresinin adı varsayılan olarak context; EditForm'un içeriğinin de öyle. İkisi iç içe girince build duruyor:
CSC : error RZ9999: The child content element 'ChildContent' of component 'EditForm' uses the same parameter name ('context') as enclosing child content element 'Authorized' of component 'AuthorizeView'. Specify the parameter name like: '<ChildContent Context="another_name"> to resolve the ambiguity
Bu yüzden EditForm'a Context="editContext" verdik. Sayfa seviyesinde aynı kural bir attribute: yeni yazar sayfasının başında @attribute [Authorize(Policy = PanelPolicies.ManageCatalog)] var, yazar listesindeki "New author" düğmesi de aynı policy'yle bir AuthorizeView'ın içinde. Policy'ler .Client projesinde tanımlı, çünkü WebAssembly tarafı da aynı isimleri kullanacak:
using Microsoft.AspNetCore.Authorization;
namespace Bookstore.Admin.Client.Auth;
// Panelin rolleri ve policy'leri; sunucu ve WebAssembly tarafı ortak kullanır.
// UI'da neyin görüneceğine bunlar karar verir; Bookstore.Api her yazmada aynı rolleri yeniden kontrol eder.
public static class PanelPolicies
{
public const string StoreStaff = "StoreStaff";
public const string WarehouseStaff = "WarehouseStaff";
public const string Admin = "Admin";
public const string Staff = "Staff";
public const string ManageCatalog = "ManageCatalog";
public static void AddPanelPolicies(this AuthorizationOptions options)
{
options.AddPolicy(Staff, policy => policy.RequireRole(StoreStaff, WarehouseStaff, Admin));
options.AddPolicy(ManageCatalog, policy => policy.RequireRole(StoreStaff, Admin));
}
}
Component'in testi tarayıcısız. bUnit'in AddAuthorization() yardımcısı policy'leri yalnız isimleriyle taklit ediyor; rol eşlemesini de test etmek için gerçek DefaultAuthorizationService'i kaydettim. Kaydetmeyince bUnit'in yer tutucusu testi düşürüyor:
failed Bookstore.Admin.Tests.PriceEditorTests.Store_staff_see_the_price_form
Xunit.MicrosoftTestingPlatform.XunitException: Bunit.TestDoubles.MissingBunitAuthorizationException : This test requires IAuthorizationService to be supplied, because the component under test uses authentication/authorization during the test. You can fix this by calling BunitContext.Services.AddAuthorization with appropriate values. More information can be found in the documentation.
Playwright ile önce Linus, sonra Grace olarak paneli gezdim:
anonymous GET /inventory -> /account/login?ReturnUrl=%2Finventory
wrong password: Invalid email or password.
linus logged in -> /inventory, nav shows: linus@example.com
book page as linus: price forms 0, note: "Only store staff can change prices."
authors page as linus: "New author" links 0
GET /authors/new as linus -> /account/access-denied?ReturnUrl=%2Fauthors%2Fnew: Access denied
fetch('/api/catalog/books?search=Dune') from the page as linus -> 200 Dune
Server linus@example.com has 6 claims: nameidentifier, name, emailaddress, AspNet.Identity.SecurityStamp, role, amr | Policy ManageCatalog: denied
WebAssembly linus@example.com has 2 claims: name, role | Policy ManageCatalog: denied
after log out -> /account/login?returnUrl=%2Fdiagnostics%2Fclaims
grace logged in -> /books/9780141439587, price: 249.90 TRY
after "Change price" -> /books/9780141439587, price: 289.50 TRY
Linus fiyat formunu görmüyor, "New author" düğmesi yok, /authors/new adresini elle yazınca access denied sayfasına düşüyor; Grace fiyatı değiştiriyor ve sayfa yeni fiyatla tekrar yükleniyor. Static SSR ve prerender edilen sayfalarda [Authorize] endpoint metadata'sı olarak authorization middleware'inde kontrol ediliyor, Linus'un yönlendirilmesi bu yüzden. AuthorizeRouteView'ın NotAuthorized içeriği routing'in kendisi interaktif olduğunda, sayfa geçişi circuit içinde yapılırken devreye giriyor. Gizlenen bir Static SSR form'unun POST'u da işlenmiyor. Linus DevTools'ta stok formunu fiyat formuymuş gibi gönderdiğinde:
forged POST _handler=price -> 400 Cannot submit the form 'price' because no form on the page currently has that name.
Blazor bir POST'u yalnız sayfada o isimle render edilmiş bir form varsa işliyor. Bu işe yarayan bir yan etki ama güvenliği onun üstüne kurmamak gerekiyor; nedenini API bölümünde göreceğiz.
Render Mode'a Göre Auth: Circuit ve WebAssembly

Aynı kullanıcıyı farklı render mode'larda gösteren küçük bir diagnostics component'i:
@using System.Security.Claims
<div class="probe">
<h3>@RendererInfo.Name</h3>
<AuthorizeView>
<Authorized>
<p>@context.User.Identity?.Name has @context.User.Claims.Count() claims: <code>@ClaimTypes(context.User)</code></p>
<AuthorizeView Policy="@PanelPolicies.ManageCatalog" Context="catalog">
<Authorized><p>Policy ManageCatalog: allowed</p></Authorized>
<NotAuthorized><p>Policy ManageCatalog: denied</p></NotAuthorized>
</AuthorizeView>
</Authorized>
</AuthorizeView>
</div>
@code {
// Kısa adlar: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" "name" olur.
private static string ClaimTypes(ClaimsPrincipal user) =>
string.Join(", ", user.Claims.Select(c => c.Type[(c.Type.LastIndexOf('/') + 1)..]));
}
Sayfada biri InteractiveServer, biri InteractiveWebAssembly iki kopyası var; Linus olarak açtığımda yukarıdaki çıktının ortasındaki iki satır geldi. İkinci sorunun cevabı: Server'da altı claim, WebAssembly'de iki. InteractiveServer'da kullanıcı, circuit kurulurken SignalR bağlantısını açan istekteki cookie'den alınıyor ve bağlantı boyunca sabit kalıyor; doküman yeniden bağlanmada tekrar değerlendirildiğini söylüyor. Sayfa geçişlerinde cookie hiç gönderilmiyor, çünkü yeni bir HTTP isteği yok. Yani Grace'in rolü veritabanında değişse açık sekmesi eski rolle çalışmaya devam ediyor. Template bunun için RevalidatingServerAuthenticationStateProvider'dan türeyen ve security stamp'i otuz dakikada bir kontrol eden bir provider ekliyor; parola değişikliği gibi security stamp'i yenileyen bir işlemden sonra circuit'teki kullanıcı en geç o kontrolde düşüyor.
WebAssembly'de kullanıcı bilgisini verecek bir sunucu bağlantısı yok; bilgi prerender sırasında sayfaya gömülüyor. Sunucuda AddAuthenticationStateSerialization(), .Client'ta deserialization:
// Kullanıcı sunucudan gelir: orada AddAuthenticationStateSerialization, burada deserialization.
builder.Services.AddAuthorizationCore(options => options.AddPanelPolicies());
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthenticationStateDeserialization();
Serileştirme 06'daki persistent component state mekanizmasını kullanıyor ve varsayılan olarak yalnız name ve role claim'lerini taşıyor; kullanıcı ID'si, e-posta ve security stamp tarayıcıya gitmiyor. Hepsi gerekiyorsa SerializeAllClaims = true var ama o zaman bu bilgiler sayfanın HTML'inde açık durur. WebAssembly tarafındaki kullanıcı sayfa yüklenirken sabitleniyor; login ve logout tam sayfa yenilemesiyle yansıyor.
Asıl önemli fark şu: WebAssembly'deki AuthorizeView kullanıcının kendi tarayıcısında çalışıyor. Policy'ler ve assembly'ler kullanıcının tarayıcısına iniyor ve değiştirilebiliyor; orada gizlenen bir düğme yalnız kolaylık. Korunan şey her zaman sunucudaki endpoint. HttpBookCatalog'un çağırdığı endpoint bu yüzden kendi policy'sini taşıyor:
// HttpBookCatalog'un WebAssembly'den çağırdığı endpoint. Tarayıcı panelin cookie'sini de gönderir (aynı origin),
// bu yüzden sayfalardaki personel policy'si burada da geçerli; UI'da gizlenen bir düğme hiçbir şeyi korumazdı.
public static class CatalogEndpoints
{
public static IEndpointRouteBuilder MapPanelCatalogEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/catalog/books", (IBookCatalog catalog, string? search, CancellationToken ct, int page = 1, int pageSize = 10)
=> catalog.SearchAsync(search, page, pageSize, ct))
.RequireAuthorization(PanelPolicies.Staff);
return app;
}
09'da WebAssembly için panelin kendi origin'inde bir endpoint seçmiştik; faydasını şimdi görüyoruz. Aynı origin olduğu için tarayıcı panelin cookie'sini isteğe kendisi ekliyor, WebAssembly kodunun bir token saklaması gerekmiyor. Bu, Authentication ve Authorization serisinin BFF (Backend for Frontend; tarayıcıdaki uygulamanın token görmeden kendi sunucusu üzerinden API'ye gitmesi) yazısındaki yaklaşımın Blazor'daki karşılığı. Angular'da da guard ve @if bir UI kararı, güvenlik sunucuda.
API'yi Ayrıca Korumak
Panelin yazdığı her şey Bookstore.Api'den geçiyor (09'daki karar) ve 07'den beri kuralların sahibi API. Authorization için de öyle olmalı: panelde bir sayfa hatalı olsa bile API kimin ne yapabileceğini kendisi bilmeli. Ama API panelin kullanıcısını tanımıyor ve bunun için iki yol var.
Birinci yol: panel, giriş yapmış kullanıcı için kısa ömürlü bir JWT (JSON Web Token; claim taşıyan, imzalı token) imzalıyor, API aynı anahtarla doğruluyor. Ek altyapı gerekmiyor; buna karşılık iki servis aynı gizli anahtarı paylaşıyor ve anahtarı bilen token üretebiliyor. İkinci yol: bir identity provider (Microsoft Entra ID, Keycloak) kullanıcıyı OIDC (OpenID Connect; OAuth 2.0 üzerine kurulu kimlik protokolü) ile doğruluyor ve access token'ı o veriyor; panel BFF olarak token'ı sunucuda saklıyor ve API çağrılarına ekliyor. Standart ve merkezi yol bu, ama ayrı bir sunucu, uygulama kaydı ve token yenileme gerektiriyor. Seri için birinciyi seçtik. API tarafındaki kod ikinci yolda da büyük ölçüde aynı kalıyor; imza anahtarının yerine provider'ın adresi (Authority) geliyor:
public static class PanelAuthentication
{
// Bookstore.Admin giriş yapmış personel için kısa ömürlü bir token imzalar; API onu doğrular.
public static WebApplicationBuilder AddPanelAuthentication(this WebApplicationBuilder builder)
{
IConfigurationSection tokens = builder.Configuration.GetSection("PanelTokens");
string signingKey = tokens["SigningKey"] is { Length: >= 32 } key
? key
: throw new InvalidOperationException(
"PanelTokens:SigningKey is missing or shorter than 32 characters. It must match Bookstore.Admin's value.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = tokens["Issuer"],
ValidAudience = tokens["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
NameClaimType = "name",
RoleClaimType = "role"
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy(ApiPolicies.Staff, policy => policy
.RequireRole(StaffRoles.StoreStaff, StaffRoles.WarehouseStaff, StaffRoles.Admin))
.AddPolicy(ApiPolicies.ManageCatalog, policy => policy
.RequireRole(StaffRoles.StoreStaff, StaffRoles.Admin));
return builder;
}
}
MapInboundClaims = false claim isimlerini token'daki gibi (name, role) bırakıyor; NameClaimType ve RoleClaimType de RequireRole'un hangi claim'e bakacağını söylüyor. İkisi tutarsız olursa token geçerli olduğu hâlde her istek 403 alır. Endpoint'ler policy'lerini kendileri söylüyor:
public static IEndpointRouteBuilder MapPriceEndpoints(this IEndpointRouteBuilder app)
{
app.MapPut("/api/books/{isbn}/price", SetPrice)
.WithTags("Catalog")
.RequireAuthorization(ApiPolicies.ManageCatalog);
return app;
}
Stok endpoint'leri Staff, yazar ekleme ve fiyat değiştirme ManageCatalog istiyor; vitrinin kullandığı katalog GET'leri herkese açık kalıyor. Testler aynı anahtarla token imzalayan küçük bir yardımcıyla API'yi farklı rollerle çağırıyor:
passed Bookstore.Api.Tests.AuthorizationTests.A_token_signed_with_another_key_is_rejected
passed Bookstore.Api.Tests.AuthorizationTests.The_storefront_catalog_stays_public
passed Bookstore.Api.Tests.AuthorizationTests.Back_office_endpoints_reject_anonymous_calls(method: "GET", uri: "/api/inventory")
passed Bookstore.Api.Tests.AuthorizationTests.Back_office_endpoints_reject_anonymous_calls(method: "PUT", uri: "/api/books/9780141439587/price")
passed Bookstore.Api.Tests.AuthorizationTests.Back_office_endpoints_reject_anonymous_calls(method: "POST", uri: "/api/authors")
passed Bookstore.Api.Tests.AuthorizationTests.Back_office_endpoints_reject_anonymous_calls(method: "GET", uri: "/api/authors")
passed Bookstore.Api.Tests.AuthorizationTests.Store_staff_change_prices
passed Bookstore.Api.Tests.AuthorizationTests.Warehouse_staff_count_stock_but_cannot_change_prices
Test run summary: Passed!
total: 8
failed: 0
succeeded: 8
İkinci yolu, yani Entra ID ya da Keycloak ile AddOpenIdConnect, token saklama ve yenilemeyi bu yazıda çalıştırmadık; ayrıntısı Authentication ve Authorization serisinin OpenID Connect ve BFF yazılarında.
Token'ı Kim Ekler: Typed Client mı, DelegatingHandler mı?
Her API çağrısına bir header eklemenin bilinen yolu bir DelegatingHandler. Önce onu denedim:
using System.Net.Http.Headers;
using Bookstore.Admin.Identity;
using Microsoft.AspNetCore.Components.Authorization;
namespace Bookstore.Admin.Services;
public sealed class BearerTokenHandler(AuthenticationStateProvider authState, PanelTokenIssuer tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AuthenticationState state = await authState.GetAuthenticationStateAsync();
if (state.User.Identity?.IsAuthenticated == true)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.CreateToken(state.User));
}
return await base.SendAsync(request, cancellationToken);
}
}
GET /books/9780141439587 -> 500
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
An unhandled exception has occurred while executing the request.
System.InvalidOperationException: Do not call GetAuthenticationStateAsync outside of the DI scope for a Razor component. Typically, this means you can call it only within a Razor component or inside another DI service that is resolved for a Razor component.
at Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider.GetAuthenticationStateAsync()
at Bookstore.Admin.Services.BearerTokenHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) in Services/BearerTokenHandler.cs:line 11
Hata mesajı sebebi söylüyor. IHttpClientFactory handler'ları kendi DI (Dependency Injection; bağımlılıkların dışarıdan verilmesi) scope'unda oluşturuyor, Razor component'in scope'unda değil; oradaki AuthenticationStateProvider başka bir instance ve kimseyi tanımıyor. Doküman bunun için keyed service'le kurulan bir "application scope handler" anlatıyor. Daha basit bir yol var: typed client'ın kendisi onu isteyenin scope'unda oluşuyor; circuit'te circuit'in, Static SSR'da isteğin scope'unda. Token'ı bu yüzden BookstoreApiClient ekliyor:
// Bookstore.Api için typed HttpClient; base address configuration'dan gelir (BookstoreApi:BaseUrl).
// Typed client onu isteyenin DI scope'unda oluşur (circuit ya da Static SSR'da istek), bu yüzden
// buradaki AuthenticationStateProvider o anki kullanıcınındır. Message handler'lar kendi scope'larında yaşar.
public sealed class BookstoreApiClient(HttpClient http, AuthenticationStateProvider authState, PanelTokenIssuer tokens)
{
// Her çağrı giriş yapmış kullanıcı için yeni bir token taşır; kullanıcının ne yapabileceğine API karar verir.
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string uri, HttpContent? content, CancellationToken ct)
{
AuthenticationState state = await authState.GetAuthenticationStateAsync();
using HttpRequestMessage request = new(method, uri) { Content = content };
if (state.User.Identity?.IsAuthenticated == true)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.CreateToken(state.User));
}
return await http.SendAsync(request, ct);
}
}
Şimdi üçüncü soru. Geliştiricinin PriceEditor'daki AuthorizeView'ı unuttuğu bir kopyada Linus formu görüyor ve fiyatı 1 lira yapmaya çalışıyor:
GET /books/9780141439587 -> 200 | h1: Emma
after "Change price": Bookstore.Api refused the change: your account cannot change prices. | price: 289.50 TRY
Form göründü, gönderildi, panelin sunucu kodu çalıştı; API token'daki WarehouseStaff rolüne baktı, 403 döndü ve fiyat değişmedi. Linus'un API'ye panelin dışından gitme yolu da yok, çünkü imza anahtarı yalnız iki sunucuda duruyor.
Passkey'ler: .NET 10 Template'i
.NET 10'un Blazor Web App template'i Individual Accounts ile oluşturulduğunda (dotnet new blazor -au Individual) passkey desteğiyle geliyor. Passkey, parola yerine cihazdaki bir anahtar çiftiyle giriş; tarayıcı tarafı WebAuthn API'si. Identity schema'sının 3. sürümü passkey'ler için AspNetUserPasskeys tablosunu ekliyor:
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
Login sayfasında parola form'unun yanında bir PasskeySubmit component'i var. Collocated JS module'ü tarayıcının WebAuthn API'sini çağırıyor ve sonucu form'a JSON olarak koyuyor; sunucuda iş tek bir method'da:
SignInResult result;
if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson))
{
// Passkey ile girişte form doğrulaması yapılmaz.
result = await SignInManager.PasskeySignInAsync(Input.Passkey.CredentialJson);
}
Template'i ayrı bir projede, Chromium'un CDP (Chrome DevTools Protocol; tarayıcıyı programdan yönetme arayüzü) üzerinden sunduğu ve Windows Hello'nun yerini tutan sanal authenticator'la denedim:
registered ada@example.com: Thank you for confirming your email.
after "Add a new passkey" -> /Account/Manage/RenamePasskey/{id}
virtual authenticator holds 1 credential, rpId=localhost, resident=true
passkeys page lists: Test laptop
GET /Account/Login, no password typed -> /, nav: ada@example.com is logged in
Login sayfası passkey'i mediation "conditional" ile, yani tarayıcının otomatik doldurma önerileri arasında istiyor. Sanal authenticator kullanıcı onayını hemen verdiği için parola yazmadan giriş yapıldı; gerçek bir tarayıcıda kullanıcı öneriden passkey'i seçip parmak izi ya da cihazın kilit koduyla onaylar. Panele passkey eklemek, template'in passkey dosyalarını kopyalamak, schema'yı 3. sürüme yükseltmek ve bir migration eklemek demek. WebAuthn'ün kendisi Authentication ve Authorization serisinin iki passkey yazısında anlatılıyor.
Üretimde Dikkat
İmza anahtarı gerçek bir secret. appsettings.Development.json'daki değer yalnız geliştirme için ve bilerek okunaklı; appsettings.json'da boş duruyor ve iki uygulama da anahtar yoksa başlamıyor. Production'da anahtar bir secret store'dan (Azure Key Vault gibi) ortam değişkeni olarak geliyor ve iki serviste aynı oluyor; anahtarı değiştirirken bir süre eskisini de kabul etmek gerekiyor. Anahtar yönetiminin ayrıntısı Authentication ve Authorization serisinin JWT yazısında.
Token ömrü beş dakika ve her çağrıda yenisi imzalanıyor; HMAC (Hash-based Message Authentication Code; paylaşılan anahtarla üretilen imza) imzası mikrosaniyeler sürdüğü için cache'lemeye gerek yok. Rol değiştiğinde API bunu bir sonraki çağrıda görüyor; circuit ise revalidation yoksa sekme kapanana kadar eski kullanıcıyla kalıyor. Panelde henüz revalidation yok, template'teki provider eklenmeli.
Identity tabloları API ile aynı veritabanında. 12'de panelin veritabanı kullanıcısını katalog tablolarında salt okunur yapacağız, identity schema'sında ise yazma yetkisi gerekecek. Lockout tek bir hesabı koruyor; birçok hesabı deneyen bir saldırgana karşı login endpoint'ine rate limiting de gerekiyor.
Sık Düşülen Tuzaklar
Fallback policy'yi açıp statik dosyaları unutmak. Login sayfası stilsiz gelir, blazor.web.js yüklenmez.
WebAssembly'deki AuthorizeView'ı güvenlik sanmak. Tarayıcıdaki kod kullanıcının elinde; endpoint'i sunucuda koru.
Login'i InteractiveServer bir component'te yapmaya çalışmak. Cookie bir HTTP response'una yazılır; circuit'te ortada response yok.
DelegatingHandler'a AuthenticationStateProvider inject etmek. Handler'ın scope'u component'in scope'u değil; typed client'ı ya da dokümandaki application scope handler'ı kullan.
ReturnUrl'e kontrolsüz yönlendirmek. Login sayfası open redirect açığına dönüşür.
JWT'de claim tiplerini eşleştirmemek. RoleClaimType token'daki claim adıyla aynı değilse RequireRole hiç geçmez ve her istek 403 alır.
Küçük Bir Deneme
★☆☆ Seed'e üçüncü bir personel ekle: ada@example.com, rolü Admin. Fiyat formunu görüyor mu? API'de hangi policy onu kabul ediyor?
★★☆ 09'da tarayıcı storage'ındaki stok filtresinin ilk render'da bir an boş göründüğünü söylemiştik. Artık kullanıcının bir ID'si var: filtreyi sunucuda kullanıcı başına sakla (StaffNoteStore gibi bir Singleton ya da bir tablo) ve prerender sırasında oku. Titreme gidiyor mu? Circuit'teki component kullanıcıyı nereden alıyor?
★★★ Template'in passkey dosyalarını (PasskeySubmit, Passkeys sayfası ve iki options endpoint'i) panele taşı, SchemaVersion'ı 3 yap ve migration ekle. Sanal authenticator'lı Playwright testiyle panelde passkey'le giriş yap.
Panel artık kimin kim olduğunu biliyor. Bir sonraki yazıda production'a geçiyoruz: WebAssembly indirme boyutu, trimming ve AOT (Ahead-of-Time; kodun tarayıcıya gitmeden önce WebAssembly'ye derlenmesi), circuit başına bellek ve bağlantı ayarları, ReconnectModal ve reverse proxy arkasında hosting.