Mert Özen Her Satırda Daha İleri
Güvenlik Authentication ve Authorization — #03

ASP.NET Core Identity'nin Mimarisi: UserManager, SignInManager, Store'lar ve Özelleştirme

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
ASP.NET Core Identity'nin Mimarisi: UserManager, SignInManager, Store'lar ve Özelleştirme

ASP.NET Core Identity tek parça bir giriş sistemi değil; UserManager kullanıcı kurallarını, SignInManager HTTP oturumunu, store interface'leri de veri erişimini ayrı katmanlarda yürütür. Guid key'li kendi user entity'mizi ve tablo isimlerimizi kuruyor, schema version'ları, dört kayıt yöntemini ve MapIdentityApi'nin JWT olmayan token'ını ve tekrar kullanılabilen refresh token'ını gerçek çıktılarla inceliyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.14 ve Microsoft.EntityFrameworkCore.Sqlite 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore API'ye (Application Programming Interface) ASP.NET Core Identity'yi şu satırla ekledin: builder.Services.AddIdentityCore<BookstoreUser>().AddEntityFrameworkStores<BookstoreIdentityDbContext>(). Uygulamada kaç authentication scheme kayıtlı ve SignInManager'ı inject edebilir misin?

[full] schemes: Identity.Application, Identity.External, Identity.TwoFactorRememberMe, Identity.TwoFactorUserId
[full] SignInManager registered: True
[core] schemes: (no authentication services)
[core] SignInManager registered: False
[default] schemes: Identity.Application, Identity.External, Identity.TwoFactorRememberMe, Identity.TwoFactorUserId
[default] SignInManager registered: True
[api] schemes: Identity.BearerAndApplication, Identity.Bearer, Identity.Application, Identity.External, Identity.TwoFactorRememberMe, Identity.TwoFactorUserId
[api] SignInManager registered: True

Sıfır. AddIdentityCore authentication servislerini hiç kaydetmiyor; IAuthenticationSchemeProvider bile yok, SignInManager da yok. Yukarıdaki dört satır grubu aynı uygulamanın dört farklı Identity kaydıyla çalıştırılmış hâli. Identity'yi "giriş sistemi" diye tek parça düşünenler genelde burada şaşırıyor. Identity aslında katmanlı bir library: kullanıcı yönetimi bir katman, oturum açma başka bir katman, veri erişimi üçüncü bir katman. Bu yazıda o katmanları açıyor, kendi user entity'mizi tasarlıyor ve .NET 8 ile gelen MapIdentityApi'nin nerede yettiğini, nerede yetmediğini ölçüyoruz.

Katmanlar: Manager, Store Interface'i, EF Core Store'u

En üstte manager'lar var. UserManager<TUser> kullanıcıyla ilgili iş kurallarını yürütür: oluşturma, parola doğrulama ve hash'leme, e-posta ve kullanıcı adını normalize etme, security stamp'i yenileme, token üretme. RoleManager<TRole> rolleri yönetir. SignInManager<TUser> ise HTTP (Hypertext Transfer Protocol) tarafıdır: parola kontrolü, lockout, iki adımlı doğrulama akışı ve en sonunda cookie authentication'a SignInAsync çağrısı. UserManager HTTP bilmez; bir console uygulamasında da çalışır. SignInManager ise HttpContext'e ve authentication scheme'lerine bağımlıdır.

Manager'lar veriye doğrudan dokunmaz, store interface'leri üzerinden konuşur. Temel interface IUserStore<TUser> yalnız oluşturma, bulma, güncelleme ve silmeyi tanımlar. Gerisi isteğe bağlı interface'ler: IUserPasswordStore, IUserEmailStore, IUserSecurityStampStore, IUserTwoFactorStore, IUserRoleStore ve .NET 10 ile gelen IUserPasskeyStore. UserManager elindeki store'un hangilerini implement ettiğine bakar ve bunu Supports* property'leriyle söyler:

[api] store: UserStore`10
[api] SupportsUserPassword=True SupportsUserSecurityStamp=True SupportsUserTwoFactor=True SupportsUserPasskey=True SupportsUserRole=True
[default] store: UserOnlyStore`6
[default] SupportsUserPassword=True SupportsUserSecurityStamp=True SupportsUserTwoFactor=True SupportsUserPasskey=True SupportsUserRole=False

AddEntityFrameworkStores, rol type'ı kayıtlıysa on generic parametreli UserStore'u, değilse altı parametreli UserOnlyStore'u seçiyor. AddDefaultIdentity rol eklemediği için SupportsUserRole=False; bu durumda AddToRoleAsync çağırırsan NotSupportedException alırsın. Kendi store'unu yazmak (örneğin kullanıcılar zaten Dapper ile okunan eski bir tabloda duruyorsa) yalnız ihtiyacın olan interface'leri implement etmek demek; manager desteklenmeyen özelliği kullanmaya çalıştığında açık bir hata verir.

Kendi User Entity'n: Guid Key, Tablo İsimleri ve Schema Version

Varsayılan IdentityUser'ın key'i string'tir ve tablolar AspNetUsers, AspNetRoles gibi isimler alır. Bookstore'da key Guid, tablolar da projenin isimlendirmesine uysun:

namespace Bookstore.Identity
{
    public sealed class BookstoreUser : IdentityUser<Guid>
    {
        public string DisplayName { get; set; } = "";
        public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    }

    public sealed class BookstoreRole : IdentityRole<Guid>;

    public sealed class BookstoreIdentityDbContext(DbContextOptions<BookstoreIdentityDbContext> options)
        : IdentityDbContext<BookstoreUser, BookstoreRole, Guid>(options)
    {
        protected override void OnModelCreating(ModelBuilder builder)
        {
            // Önce base: Identity'nin key, index ve ilişki configuration'ı burada kurulur
            base.OnModelCreating(builder);
            builder.Entity<BookstoreUser>(user =>
            {
                user.ToTable("Users");
                user.Property(u => u.DisplayName).HasMaxLength(100);
            });
            builder.Entity<BookstoreRole>().ToTable("Roles");
            builder.Entity<IdentityUserRole<Guid>>().ToTable("UserRoles");
            builder.Entity<IdentityUserClaim<Guid>>().ToTable("UserClaims");
            builder.Entity<IdentityUserLogin<Guid>>().ToTable("UserLogins");
            builder.Entity<IdentityUserToken<Guid>>().ToTable("UserTokens");
            builder.Entity<IdentityRoleClaim<Guid>>().ToTable("RoleClaims");
            builder.Entity<IdentityUserPasskey<Guid>>().ToTable("UserPasskeys");
        }
    }
}

base.OnModelCreating'i en başta çağırmak şart; sonra çağırırsan Identity kendi tablo isimlerini geri yazar. User entity'ye eklediğin her property (DisplayName, CreatedAt) aynı tabloya kolon olur. Kimlikle ilgisi olmayan profil verisini (adres, tercih edilen kitap türleri) buraya doldurmak yerine ayrı bir Customer entity'sinde tutmak, Identity tablosunu küçük ve hassas veriyi ayrık tutar.

Bir de görünmeyen bir ayar var: schema version. Identity'nin Entity Framework Core (EF Core) modeli sürümlüdür ve hangi sürümün kurulacağını IdentityOptions.Stores.SchemaVersion belirler:

builder.Services.AddIdentityApiEndpoints<BookstoreUser>(options =>
    {
        options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
        options.Password.RequiredLength = 12;
        options.User.RequireUniqueEmail = true;
    })
    .AddRoles<BookstoreRole>()
    .AddEntityFrameworkStores<BookstoreIdentityDbContext>();
StoreOptions default SchemaVersion=0.0 Version1=1.0 Version2=2.0 Version3=3.0

Ayarlamazsan değer 0.0 kalır ve IdentityUserContext'in kaynak kodundaki karşılaştırma onu Version1 gibi işler. Version 2, key kolonlarına varsayılan 128 karakter sınırı ve PhoneNumber'a 256 karakter sınırı ekler. Version 3 bunların üzerine passkey tablosunu getirir. Version3 ile oluşan veritabanı:

$ sqlite3 bookstore-identity.db .tables
RoleClaims     UserClaims     UserPasskeys     UserTokens
Roles          UserLogins     UserRoles        Users
$ sqlite3 bookstore-identity.db ".schema UserPasskeys"
CREATE TABLE "UserPasskeys" (
    "CredentialId" BLOB NOT NULL CONSTRAINT "PK_UserPasskeys" PRIMARY KEY,
    "UserId" TEXT NOT NULL,
    "Data" TEXT NOT NULL,
    CONSTRAINT "FK_UserPasskeys_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);

.NET 10 şablonları Version3'ü açıkça yazar. Eski bir projeyi .NET 10'a yükseltip passkey eklemek istiyorsan sürümü yükseltmen ve bir migration üretmen gerekir; sürümü değiştirmeden IUserPasskeyStore metodlarını çağırmak tablo olmadığı için çalışmaz. Passkey'lerin kendisi 6. ve 7. yazılarda. Lab'da şemayı göstermek için EnsureCreatedAsync kullandım; gerçek projede migration kullan (EF Core serisinin 7. yazısı: EF Core Migrations).

AddIdentity, AddIdentityCore, AddDefaultIdentity ve AddIdentityApiEndpoints

Dört kayıt yöntemi, baştaki çıktıda gördüğün farkları üretiyor:

YöntemAuthentication scheme'leriSignInManagerRollerNe zaman
AddIdentity<TUser, TRole>4 cookie scheme'iVarVarMVC (Model-View-Controller) ya da Razor Pages ile kendi sayfaların
AddIdentityCore<TUser>YokYok (AddSignInManager ile eklenir)AddRoles ileYalnız kullanıcı yönetimi, kendi authentication'ın
AddDefaultIdentity<TUser>4 cookie scheme'iVarAddRoles ileHazır Identity UI (Razor Class Library)
AddIdentityApiEndpoints<TUser>Cookie'ler + Identity.BearerVarAddRoles ileMapIdentityApi ile SPA (Single-Page Application) ya da mobil

Dört cookie scheme'inin her birinin bir işi var. Identity.Application asıl oturum cookie'si. Identity.External, Google gibi bir dış sağlayıcıdan dönen kimliği hesap bağlanana kadar geçici olarak tutar. Identity.TwoFactorUserId, parolayı doğru giren ama ikinci adımı henüz tamamlamamış kullanıcının kimliğini tutar. Identity.TwoFactorRememberMe ise "bu cihazı hatırla" işaretidir. AddIdentity ayrıca DefaultAuthenticateScheme ve DefaultChallengeScheme'i Identity.Application'a ayarlar; serinin 1. yazısında konuştuğumuz "JWT (JSON Web Token) ekledim ama login'e yönlendiriyor" sorununun kaynağı çoğunlukla bu satırdır.

Identity UI'ı kullanmak mı, kendi endpoint'lerini yazmak mı? Hazır UI, Razor sayfalarıyla gelir; sunucu tarafında render edilen bir uygulamada hızlı başlangıç sağlar ve dotnet aspnet-codegenerator identity ile sayfaları projeye kopyalayıp özelleştirebilirsin. Bookstore'un ön yüzü Angular olduğu için UI'ın sayfaları işimize yaramıyor; ya MapIdentityApi'yi kullanacağız ya da UserManager ve SignInManager üzerinde kendi endpoint'lerimizi yazacağız. Bir sonraki yazıdaki hesap yaşam döngüsü ikinci yolu izliyor.

MapIdentityApi: Ne Veriyor, Neyi Vermiyor?

ASP.NET Core Web API serisinin 34. yazısında .NET 8 ile gelen MapIdentityApi'yi tanıtmıştık. Bookstore'da şöyle bağlı:

app.MapGroup("/account").MapIdentityApi<BookstoreUser>();

Bu tek satır /register, /login, /refresh, /confirmEmail, /resendConfirmationEmail, /forgotPassword, /resetPassword, /manage/2fa ve /manage/info endpoint'lerini ekliyor. Kayıt, parola kurallarını ProblemDetails ile dönüyor; giriş bir token çifti veriyor:

$ curl -s -i -X POST http://localhost:19130/account/register -H "Content-Type: application/json" -d '{"email":"ada@bookstore.test","password":"short"}'
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"PasswordTooShort":["Passwords must be at least 12 characters."],"PasswordRequiresNonAlphanumeric":["Passwords must have at least one non alphanumeric character."],"PasswordRequiresDigit":["Passwords must have at least one digit ('0'-'9')."],"PasswordRequiresUpper":["Passwords must have at least one uppercase ('A'-'Z')."]}}

$ curl -s -X POST http://localhost:19130/account/login -H "Content-Type: application/json" -d '{"email":"ada@bookstore.test","password":"..."}' > login.json
$ python3 -c "import json;t=json.load(open('login.json'))['accessToken'];print('accessToken length', len(t), 'dots', t.count('.'))"
accessToken length 731 dots 0

Cevabın kendisi (değerleri kısalttım):

{"tokenType":"Bearer","accessToken":"CfDJ8EINq_wm4TFCv7gu...","expiresIn":3600,"refreshToken":"CfDJ8EINq_wm4TFCv7gu..."}

Token'da hiç nokta yok, yani JWT değil. Önündeki CfDJ8'i bir önceki yazıdan tanıyorsun: bu, Data Protection ile şifrelenmiş bir AuthenticationTicket. Çözülmüş ilk byte'lar da Data Protection'ın magic header'ı:

00000000: 09f0 c9f0 420d abfc 26e1 3142 bfb8 2ecf  ....B...&.1B....

Bunun pratik sonucu: bu token'ı yalnız aynı Data Protection key ring'ine sahip uygulama doğrulayabilir. Başka bir API, bir API gateway ya da başka dilde yazılmış bir servis onu okuyamaz; JWKS (JSON Web Key Set) yok, issuer yok, audience yok. Varsayılan ömürler de BearerTokenOptions'tan geliyor:

BearerTokenExpiration=01:00:00 RefreshTokenExpiration=14.00:00:00

Refresh tarafında daha önemli bir gözlem var. Aynı refresh token'ı iki kez kullandım:

$ curl -s -X POST http://localhost:19130/account/refresh -H "Content-Type: application/json" -d '{"refreshToken":"CfDJ8EINq_wm4TFC..."}'
{"tokenType":"Bearer","accessToken":"CfDJ8EINq_wm4TFCv7guzy6SHCcQ-om9nuuqUZ5o3t3NbUAivYyasdXVjE1Mmj-Q_nZgE6X9j0RQsxDDvXJ
second refresh with same token: 200

İkincisi de 200 döndü. Refresh token sunucuda saklanmıyor, kendisi de şifreli bir ticket; dolayısıyla "bu token bir kez kullanıldı" bilgisini tutacak bir yer yok. Rotation var (her refresh yeni bir refresh token veriyor), ama eskisi de 14 gün boyunca geçerli kalıyor. Çalınan bir refresh token'ın kullanımını fark etmenin yolu olan reuse detection'ı 9. yazıda kendimiz kuracağız. Endpoint security stamp'i kontrol ettiği için parola değişikliği eski refresh token'ları geçersiz kılıyor, ama access token'lara dokunmuyor:

change password: 200
refresh after password change: 401
old access token after password change: 200

Bearer handler her istekte security stamp'e bakmaz; parolası değişen kullanıcının elindeki access token bir saatlik ömrü bitene kadar çalışmaya devam eder. Access token'ı kısa tutmanın sebebi tam olarak bu.

MapIdentityApi ne zaman yeter? Tek bir API'n ve ona bağlanan kendi SPA'n ya da mobil uygulaman varsa, başka bir servisin bu token'ı doğrulaması gerekmiyorsa ve üçüncü taraf uygulamalara giriş vermiyorsan. Aynı origin'deki bir SPA için ?useCookies=true ile token yerine cookie almak da mümkün:

$ curl -s -i -X POST "http://localhost:19130/account/login?useCookies=true" -H "Content-Type: application/json" -d '{"email":"ada@bookstore.test","password":"..."}'
HTTP/1.1 200 OK
Set-Cookie: .AspNetCore.Identity.Application=CfDJ8EINq_wm4TFCv7guzy6SHCfPdbgjK37XQJ3zD0NtqGVnUc9MFjWZFOjEgbsOz...

Ne zaman yetmez? Birden çok API aynı kimliği kabul edecekse, single sign-on (SSO) gerekiyorsa, bir partnerin uygulaması kullanıcı adına Bookstore'a erişecekse ya da standart bir protokol (OAuth 2.0, OIDC (OpenID Connect)) bekleyen bir istemcin varsa. O noktada bir authorization server gerekir; serinin 10. yazısından itibaren o tarafa geçiyoruz.

Üretimde Dikkat

Identity'nin varsayılanlarını bilerek kullan. SecurityStampValidator 30 dakikada bir kontrol eder (bir önceki yazıda yazdırdık); parola değişikliğinin diğer cihazlara yansıması bu kadar gecikebilir. Lockout varsayılan olarak açık ama brute force ve lockout ayarları Web Güvenliği serisinin 12. yazısında, parolanın nasıl hash'lendiği ise 8. yazısında anlatıldı. Yukarıdaki kayıt sırasında hash'in AQAAAAIAAYagAAAAE... ile başladığını göreceksin; bu ön ek format sürümünü, PRF'i (Pseudo-Random Function) ve iterasyon sayısını taşır.

.NET 10, Microsoft.AspNetCore.Identity meter'ı altında Identity metrikleri getirdi: aspnetcore.identity.user.create.duration, aspnetcore.identity.sign_in.check_password_attempts, aspnetcore.identity.sign_in.sign_ins gibi. Başarısız parola denemelerindeki ani artış credential stuffing'in en erken sinyallerinden biri; bu metriği bir alarma bağlamak birkaç satırlık iş.

Ölçek tarafında: FindByEmailAsync NormalizedEmail kolonu üzerinden çalışır ve Identity bu kolona index koyar (EmailIndex). Ama RequireUniqueEmail = true verdiğinde bile bu index unique değildir; benzersizliği UserValidator uygulama tarafında kontrol eder. Aynı anda gelen iki kayıt isteği bu kontrolü birlikte geçebilir. Gerçek garanti istiyorsan NormalizedEmail üzerine veritabanında unique index ekle.

Sık Düşülen Tuzaklar

Birincisi, Identity'yi bir authorization framework'ü sanmak. Identity kullanıcıları, parolaları, rolleri ve token'ları yönetir; "Ada bu siparişi iade edebilir mi?" sorusunu cevaplamaz. Roller Identity'de durur ama karar ASP.NET Core'un authorization katmanında, policy'lerde ve handler'larda verilir. Bunun mekaniği ilerideki ASP.NET Core Güvenliği serisinin ilk yazısında, yetkilendirme modelleri de bu serinin 16. yazısında.

İkincisi, user entity'sini API'den olduğu gibi döndürmek. Lab'a bilerek kötü bir endpoint koydum:

// Kötü örnek: entity'yi asla doğrudan döndürme
app.MapGet("/debug/user-entity", async (HttpContext context, UserManager<BookstoreUser> users) =>
    TypedResults.Ok(await users.GetUserAsync(context.User)))
    .RequireAuthorization();
{
    "displayName": "",
    "createdAt": "2026-11-08T05:35:34.166991+00:00",
    "id": "f57fcedf-35a2-477e-bb64-d144dd711f4a",
    "userName": "ada@bookstore.test",
    "normalizedUserName": "ADA@BOOKSTORE.TEST",
    "email": "ada@bookstore.test",
    "normalizedEmail": "ADA@BOOKSTORE.TEST",
    "emailConfirmed": false,
    "passwordHash": "AQAAAAIAAYagAAAAEJkY2iZIsMS4rOvaKLfy5Ytlo3ApWOhD...",
    "securityStamp": "IHZ6I25DJPP7FF3GXV5OQCT6RO6U4LNP",
    "concurrencyStamp": "c4a27ff7-7375-4eb7-aeba-f8002cce43c9",
    "phoneNumber": null,
    "phoneNumberConfirmed": false,
    "twoFactorEnabled": false,
    "lockoutEnd": null,
    "lockoutEnabled": true,
    "accessFailedCount": 0
}

Parola hash'i ve security stamp cevapta. Hash offline kırma denemesine açılır; security stamp ise iptal mekanizmasının sırrıdır. Çözüm her zaman bir DTO (Data Transfer Object):

app.MapGet("/me", async (HttpContext context, UserManager<BookstoreUser> users) =>
{
    BookstoreUser? user = await users.GetUserAsync(context.User);
    return user is null
        ? Results.NotFound()
        : TypedResults.Ok(new MeResponse(user.Id, user.Email!, user.DisplayName, await users.GetRolesAsync(user)));
}).RequireAuthorization();

public sealed record MeResponse(Guid Id, string Email, string DisplayName, IList<string> Roles);
{"id":"f57fcedf-35a2-477e-bb64-d144dd711f4a","email":"ada@bookstore.test","displayName":"","roles":[]}

Üçüncüsü, base.OnModelCreating'i sona koymak ve tablo isimlerinin neden değişmediğini aramak. Dördüncüsü, SchemaVersion'ı ayarlamadan passkey kodu yazmak. Beşincisi, AddIdentityCore kullanıp SignInManager inject etmeye çalışmak; baştaki çıktıda gördüğün gibi kayıtlı değil, AddSignInManager() ve authentication configuration'ını kendin eklemen gerekiyor.

Küçük Bir Deneme

★☆☆ Lab'da SchemaVersion satırını silip veritabanını yeniden oluştur. .tables çıktısında UserPasskeys'in kaybolduğunu ve UserLogins tablosundaki LoginProvider kolonunun uzunluk sınırının nasıl değiştiğini .schema ile karşılaştır.

★★☆ AddIdentityCore ile başlayıp yalnız AddSignInManager() ve AddAuthentication().AddIdentityCookies() ekleyerek çalışan bir parola girişi kur. Hangi scheme'lerin kaydolduğunu yazdır ve AddIdentity'nin çıktısıyla karşılaştır.

★★★ NormalizedEmail üzerine unique index ekle. Ardından aynı e-postayla 20 kayıt isteğini paralel gönderen küçük bir script yaz; index'li ve index'siz durumda veritabanında kaç kayıt oluştuğunu karşılaştır.

Identity'nin iskeletini gördük. Bir sonraki yazıda bu iskeletin üzerinde bir hesabın tüm hayatını kuruyoruz: kayıt, e-posta onayı, parola sıfırlama, external login ve hesap silme; token provider'ların ömrü ve sıfırlamadan sonra diğer session'ları düşürmek de dahil.