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

MFA: TOTP, Recovery Code'lar, Step-up Authentication ve SMS'in Zayıflığı

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
MFA: TOTP, Recovery Code'lar, Step-up Authentication ve SMS'in Zayıflığı

ASP.NET Core Identity'nin varsayılan authenticator provider'ı bir TOTP kodunu ±2 zaman adımı (beş kod) içinde ve tekrar tekrar kabul ediyor; NIST ise kodun bir kez kabul edilmesini istiyor. TOTP'yi RFC 6238 test vektörüyle kendimiz hesaplıyor, kurulum, lockout, replay korumalı provider, recovery code'lar, amr claim'iyle step-up policy'si ve SMS ile 'bu cihazı hatırla'nın riskini gerçek çıktılarla gösteriyoruz.

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

Önce Tahmin Et

Ada, Bookstore hesabında authenticator uygulamasıyla MFA'yı (Multi-Factor Authentication) açtı. Telefonundaki 6 haneli kodu laptop'ta girdi ve giriş yaptı. Aynı 30 saniye içinde, Ada'nın parolasını bilen biri aynı kodu (omzunun üstünden bakarak ya da sahte bir giriş sayfasından aktararak) başka bir makinede giriyor. ASP.NET Core Identity'nin varsayılan ayarlarıyla ikinci giriş başarılı olur mu?

== right code on device B
{"status":"signed-in"}
== same code on device C
{"status":"signed-in"}

Olur. Identity'nin varsayılan authenticator provider'ı bir kodu geçerlilik süresi boyunca istediğin kadar kabul ediyor. NIST'in (National Institute of Standards and Technology) SP (Special Publication) 800-63B-4 rehberi ise açık: verifier bir OTP'yi (One-Time Password) geçerli olduğu sürede yalnız bir kez kabul etmeli. Bu yazıda TOTP'nin (Time-based One-Time Password) nasıl çalıştığını byte byte açıyor, Identity'de authenticator kurulumunu ve iki adımlı girişi kuruyor, kabul penceresini ölçüyor, replay'i kapatıyor, recovery code'ları ve hassas işlemler için step-up authentication'ı ekliyoruz. Lockout ve brute force temeli Web Güvenliği (OWASP Top 10) serisinin 12. yazısında anlatıldı.

TOTP Nasıl Çalışır?

TOTP, RFC (Request for Comments) 4226'daki HOTP'nin (HMAC-based One-Time Password) sayaç yerine zamanı kullanan hâlidir ve RFC 6238'de tanımlanır. Sunucu ile authenticator uygulaması bir kez, QR (Quick Response) kodla bir shared secret paylaşır. Sonrasında ikisi de aynı hesabı bağımsız yapar: Unix zamanını 30'a böl, bu zaman adımını 8 byte'lık big-endian bir sayaç yap, secret'la HMAC-SHA1 (Hash-based Message Authentication Code, Secure Hash Algorithm 1) al, sonucu dynamic truncation ile 31 bitlik bir sayıya indir ve son 6 haneyi al. Ağ yok, senkronizasyon yok; yalnız ortak bir secret ve kabaca aynı saat.

05-totp-generation-tr.png

Lab'da authenticator uygulamasını taklit etmek için algoritmayı kendim yazdım:

public static class Totp
{
    // RFC 4226 üzerine RFC 6238: zaman adımı sayacı üzerinde HMAC-SHA1, dynamic truncation, 6 hane
    public static string Compute(byte[] secret, long timeStep, int digits = 6)
    {
        Span<byte> counter = stackalloc byte[8];
        BinaryPrimitives.WriteInt64BigEndian(counter, timeStep);
        Span<byte> hash = stackalloc byte[HMACSHA1.HashSizeInBytes];
        HMACSHA1.HashData(secret, counter, hash);
        int offset = hash[^1] & 0x0F;
        int binary = BinaryPrimitives.ReadInt32BigEndian(hash[offset..]) & 0x7FFFFFFF;
        return (binary % (int)Math.Pow(10, digits)).ToString(new string('0', digits), CultureInfo.InvariantCulture);
    }
}

Kendi yazdığın bir kriptografik kodu bir test vektörüyle doğrulamadan kullanma. RFC 6238'in ekindeki ilk vektör, ASCII (American Standard Code for Information Interchange) 12345678901234567890 secret'ı ve 59. saniye için 8 haneli 94287082:

$ dotnet Lab05.dll rfc6238
94287082

Tutuyor. Dynamic truncation'ın neden var olduğu figürde görünüyor: HMAC'in son nibble'ı hangi 4 byte'ın alınacağını seçiyor, böylece kod hash'in sabit bir yerinden değil, her seferinde farklı bir yerinden çıkıyor. En üst bitin maskelenmesi ise işaretli/işaretsiz tamsayı farklarından kaçınmak için.

Identity'de Authenticator Kurulumu ve Saklama

Kurulum iki adım: önce bir key üret ve kullanıcıya göster, sonra kullanıcı uygulamasındaki ilk kodu girince 2FA'yı (Two-Factor Authentication) aç.

manage.MapPost("/2fa/setup", async (HttpContext context, UserManager<BookstoreUser> users) =>
{
    BookstoreUser user = (await users.GetUserAsync(context.User))!;
    string? key = await users.GetAuthenticatorKeyAsync(user);
    if (string.IsNullOrEmpty(key))
    {
        await users.ResetAuthenticatorKeyAsync(user);
        key = await users.GetAuthenticatorKeyAsync(user);
    }
    string label = Uri.EscapeDataString($"Bookstore:{user.Email}");
    return TypedResults.Ok(new
    {
        sharedKey = key,
        authenticatorUri = $"otpauth://totp/{label}?secret={key}&issuer=Bookstore&digits=6"
    });
});

manage.MapPost("/2fa/enable", async (CodeRequest request, HttpContext context, UserManager<BookstoreUser> users) =>
{
    BookstoreUser user = (await users.GetUserAsync(context.User))!;
    bool valid = await users.VerifyTwoFactorTokenAsync(user, users.Options.Tokens.AuthenticatorTokenProvider, request.Code);
    if (!valid)
    {
        return Results.BadRequest(new[] { "InvalidCode" });
    }
    await users.SetTwoFactorEnabledAsync(user, true);
    IEnumerable<string>? codes = await users.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
    return TypedResults.Ok(new { recoveryCodes = codes });
});

Angular tarafı authenticatorUri'yi bir QR koda çevirir; kamerası olmayanlar için sharedKey'i de dörder karakterlik gruplar hâlinde gösterir. Lab'daki sahte kullanıcı için çıktı:

{"sharedKey":"43ZQISQ66FZBGUZEIVYJN2IBYSKYBL33","authenticatorUri":"otpauth://totp/Bookstore%3Aada%40bookstore.test?secret=43ZQISQ66FZBGUZEIVYJN2IBYSKYBL33&issuer=Bookstore&digits=6"}
{"recoveryCodes":["KVQVD-H8T7R","KD2QB-W42KR","3QBW9-4G8H7","Y56GD-NP465","D7VM9-MNQXW","GFPFC-B2KGC","BF28P-MVQJN","QX93X-VVK4P","DMTQF-NHF6J","VW73J-D9HDM"]}

Key 32 karakterlik Base32, yani 160 bit; RFC 4226'nın HMAC-SHA1 için önerdiği uzunluk. 2FA'yı ilk koddan önce açmamak önemli: kullanıcı QR'ı okutmadan sayfayı kapatırsa bir sonraki girişte kodu olmayan bir ikinci adımla karşılaşır ve hesabına giremez. SetTwoFactorEnabledAsync security stamp'i de yeniler; yani 2FA açılınca diğer cihazlardaki oturumlar bir sonraki stamp kontrolünde düşer.

Peki key ve recovery code'lar nerede?

$ sqlite3 bookstore-identity.db "select LoginProvider, Name, Value from AspNetUserTokens;"
[AspNetUserStore]|AuthenticatorKey|43ZQISQ66FZBGUZEIVYJN2IBYSKYBL33
[AspNetUserStore]|RecoveryCodes|KVQVD-H8T7R;KD2QB-W42KR;3QBW9-4G8H7;Y56GD-NP465;D7VM9-MNQXW;GFPFC-B2KGC;BF28P-MVQJN;QX93X-VVK4P;DMTQF-NHF6J;VW73J-D9HDM

İkisi de düz metin. Shared secret'ın doğası gereği sunucu onu geri okuyabilmeli (parola gibi hash'lenemez), ama bu satıra erişen biri tüm kullanıcıların kodlarını üretebilir. Recovery code'lar ise hash'lenebilirdi ama Identity'nin varsayılan store'u hash'lemiyor. Veritabanı yedeklerini, okuma replikalarını ve bu tabloya erişimi parola hash'lerinden daha sıkı koru; gerekirse UserTokens'ı sütun düzeyinde şifrele.

Giriş Akışı ve Lockout

2FA açık bir kullanıcı doğru parolayı girince PasswordSignInAsync RequiresTwoFactor döner ve kullanıcının kimliğini Identity.TwoFactorUserId adlı geçici bir cookie'ye yazar. Asıl oturum cookie'si ikinci adım tamamlanınca gelir:

account.MapPost("/login/2fa", async (TwoFactorRequest request, SignInManager<BookstoreUser> signIn) =>
{
    SignInResult result = await signIn.TwoFactorAuthenticatorSignInAsync(request.Code, isPersistent: false, rememberClient: request.RememberMachine);
    return result.Succeeded
        ? TypedResults.Ok(new { status = "signed-in" })
        : Results.Json(new { status = result.ToString() }, statusCode: StatusCodes.Status401Unauthorized);
});
== password step
HTTP/1.1 401 Unauthorized
Set-Cookie: Identity.TwoFactorUserId=CfDJ8EINq_wm4TFCv7guzy6SHCftuaQUX4DyHuViBcO_UexYdNSJq...
{"status":"RequiresTwoFactor"}
== lockout
#1 {"status":"Failed"}
#2 {"status":"Failed"}
#3 {"status":"Failed"}
#4 {"status":"Failed"}
#5 {"status":"LockedOut"}
#6 {"status":"LockedOut"}
$ sqlite3 bookstore-identity.db "select AccessFailedCount, LockoutEnd from AspNetUsers;"
0|2026-11-08 05:56:32.783333+00:00

Yanlış kodlar parola denemeleriyle aynı sayaca yazılıyor ve beşinci hatada hesap varsayılan 5 dakikalığına kilitleniyor. Bu önemli: 6 haneli bir kodun milyonda bir tahmin şansı var ve kabul penceresinde birden çok kod geçerli olduğu için şans biraz daha yüksek. Lockout olmasa bir saldırgan parolayı bildikten sonra ikinci adımı tahminle geçebilirdi. NIST de 64 bitten kısa çıktılı authenticator'lar için rate limiting'i zorunlu tutuyor.

Kabul Penceresi ve Replay

Telefonun saati sunucudan birkaç saniye farklı olabilir, kullanıcı kodu okuyup yazana kadar adım değişebilir. Bu yüzden verifier'lar birkaç komşu adımı da kabul eder. Identity'nin kaç adım kabul ettiğini dokümanda aramak yerine ölçtüm: şimdiki adımın -4 ile +4 arasındaki komşuları için kod üretip VerifyTwoFactorTokenAsync'e sordum:

offset -4 (-120s) 296541 -> {"valid":false}
offset -3 ( -90s) 814908 -> {"valid":false}
offset -2 ( -60s) 490716 -> {"valid":true}
offset -1 ( -30s) 673681 -> {"valid":true}
offset +0 (  +0s) 498268 -> {"valid":true}
offset +1 ( +30s) 996559 -> {"valid":true}
offset +2 ( +60s) 397021 -> {"valid":true}
offset +3 ( +90s) 391809 -> {"valid":false}
offset +4 (+120s) 744076 -> {"valid":false}
== replay
{"valid":true}
{"valid":true}

Beş adım, yani her an beş farklı kod geçerli ve her biri tekrar tekrar kullanılabiliyor. Baştaki iki cihaz senaryosu bunun sonucuydu. Gerçek zamanlı bir phishing proxy'si kullanıcının girdiği kodu hemen kendi oturumunda kullanır; replay koruması onu durdurmaz ama aynı kodun ikinci kez, örneğin omuz üstünden görülerek ya da loglanmış bir istekten kullanılmasını engeller. Identity'nin IUserTwoFactorTokenProvider<TUser> interface'i bunu eklemeyi kolaylaştırıyor:

public sealed class ReplayProtectedAuthenticatorTokenProvider<TUser>(TimeProvider time) : IUserTwoFactorTokenProvider<TUser>
    where TUser : class
{
    public const string ProviderName = "ReplayProtectedAuthenticator";
    private const string LastStepName = "LastTotpStep";

    public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUser> manager, TUser user) =>
        !string.IsNullOrEmpty(await manager.GetAuthenticatorKeyAsync(user));

    // Kod kullanıcının authenticator uygulamasından gelir; sunucu kod üretmez
    public Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user) => Task.FromResult(string.Empty);

    public async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
    {
        string? key = await manager.GetAuthenticatorKeyAsync(user);
        if (string.IsNullOrEmpty(key) || token.Length != 6 || !token.All(char.IsAsciiDigit))
        {
            return false;
        }

        byte[] secret = Base32.Decode(key);
        long current = time.GetUtcNow().ToUnixTimeSeconds() / 30;
        string? stored = await manager.GetAuthenticationTokenAsync(user, ProviderName, LastStepName);
        long lastUsed = long.TryParse(stored, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed) ? parsed : 0;

        // Identity'nin iki adımı yerine her yönde bir adım (yaklaşık 30 sn) sapma
        for (long step = current - 1; step <= current + 1; step++)
        {
            byte[] expected = Encoding.ASCII.GetBytes(Totp.Compute(secret, step));
            if (step > lastUsed && CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(token)))
            {
                // Bir kod bir kez kabul edilir: adımı sakla ki aynı ya da daha eski kod reddedilsin
                IdentityResult saved = await manager.SetAuthenticationTokenAsync(
                    user, ProviderName, LastStepName, step.ToString(CultureInfo.InvariantCulture));
                return saved.Succeeded;
            }
        }
        return false;
    }
}

Kaydı ve Identity'ye "authenticator için bunu kullan" demek:

builder.Services.AddIdentity<BookstoreUser, IdentityRole<Guid>>(options =>
    {
        options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
        options.Password.RequiredLength = 12;
        if (builder.Configuration.GetValue("Lab:ReplayProtection", true))
        {
            options.Tokens.AuthenticatorTokenProvider = ReplayProtectedAuthenticatorTokenProvider<BookstoreUser>.ProviderName;
        }
    })
    .AddEntityFrameworkStores<BookstoreIdentityDbContext>()
    .AddDefaultTokenProviders()
    .AddTokenProvider<ReplayProtectedAuthenticatorTokenProvider<BookstoreUser>>(ReplayProtectedAuthenticatorTokenProvider<BookstoreUser>.ProviderName);

TwoFactorAuthenticatorSignInAsync ve VerifyTwoFactorTokenAsync provider'ı Options.Tokens.AuthenticatorTokenProvider'dan okuduğu için başka bir şey değiştirmeye gerek yok. Aynı senaryo, yeni provider'la:

== same code on two devices
device b: {"status":"signed-in"}
device c: {"status":"Failed"}

Kodun karşılaştırmasında CryptographicOperations.FixedTimeEquals kullanıyorum; karşılaştırma süresi eşleşen karakter sayısını sızdırmasın. Son kullanılan adımı yazan SetAuthenticationTokenAsync, kullanıcıyı ConcurrencyStamp kontrolüyle günceller ve başarısız olursa provider false döner. Dört cihazdan aynı kodla aynı anda gönderdiğim isteklerden yalnız biri geçti; yine de birden çok instance'lı ve farklı bir veritabanıyla çalışan kurulumda bu yarışı ayrıca test et. Pencereyi ±1 adıma indirmenin bedeli, saati bir dakikadan fazla kaymış telefonlarda kodun reddedilmesi; bunu destek kayıtlarından izlemek gerekir.

Recovery Code'lar

Telefon kaybolursa kullanıcının hesabına dönebilmesi için recovery code'lar var. Her biri bir kez kullanılır:

account.MapPost("/login/recovery", async (TwoFactorRequest request, SignInManager<BookstoreUser> signIn) =>
{
    SignInResult result = await signIn.TwoFactorRecoveryCodeSignInAsync(request.Code);
    return result.Succeeded
        ? TypedResults.Ok(new { status = "signed-in" })
        : Results.Json(new { status = result.ToString() }, statusCode: StatusCodes.Status401Unauthorized);
});
== recovery code on device D
{"status":"signed-in"}
{"name":"ada@bookstore.test","amr":["mfa"],"stepUpAt":null}
{"status":"Failed"}
$ sqlite3 bookstore-identity.db "select Name, length(Value) - length(replace(Value, ';', '')) + 1 from AspNetUserTokens where Name='RecoveryCodes';"
RecoveryCodes|9

Kod tüketildi, ikinci kullanım reddedildi, dokuz kod kaldı. Dikkat edilecek nokta amr claim'i: recovery code ile giriş de mfa olarak işaretleniyor. Bir recovery code, telefonun kaybolduğu ya da hesabın ele geçirildiği bir anın işareti olabilir; bu girişten sonra kullanıcıya e-posta gönder, kalan kod sayısını göster ve azaldığında yenilerini üretmesini öner. Recovery code'ları kullanıcıya bir kez göster ve yeniden üretmeyi parola ya da mevcut bir 2FA koduyla koru.

Step-up Authentication

Ada sabah MFA ile giriş yaptı, öğleden sonra teslimat adresini değiştirmek istiyor. Oturum açık, ama adres değişikliği (ve ödeme, e-posta değişikliği, 2FA'yı kapatma) bir saldırganın en çok isteyeceği işlemler. Step-up, hassas işlemden hemen önce taze bir doğrulama istemektir. Önce oturumun nasıl doğrulandığını görelim:

{"name":"ada@bookstore.test","amr":["pwd"],"stepUpAt":null}
{"name":"ada@bookstore.test","amr":["mfa"],"stepUpAt":null}

İlki 2FA açılmadan önceki parola girişi, ikincisi iki adımlı giriş. SignInManager, RFC 8176'nın (Authentication Method Reference Values) değerlerini amr claim'ine yazıyor. "Ne zaman" bilgisi ise yok; o yüzden step-up anında kendi claim'imizi ekliyoruz:

manage.MapPost("/step-up", async (CodeRequest request, HttpContext context, UserManager<BookstoreUser> users, SignInManager<BookstoreUser> signIn, TimeProvider time) =>
{
    BookstoreUser user = (await users.GetUserAsync(context.User))!;
    if (!await users.VerifyTwoFactorTokenAsync(user, users.Options.Tokens.AuthenticatorTokenProvider, request.Code))
    {
        await users.AccessFailedAsync(user);
        return Results.BadRequest(new[] { "InvalidCode" });
    }
    await users.ResetAccessFailedCountAsync(user);
    // Cookie'yi taze doğrulama zamanı claim'leriyle yeniden yaz
    await signIn.SignInWithClaimsAsync(user, isPersistent: false,
    [
        new Claim("amr", "mfa"),
        new Claim("step_up_at", time.GetUtcNow().ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))
    ]);
    return TypedResults.NoContent();
});
public sealed record RecentStepUpRequirement(TimeSpan MaxAge) : IAuthorizationRequirement;

public sealed class RecentStepUpHandler(TimeProvider time) : AuthorizationHandler<RecentStepUpRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RecentStepUpRequirement requirement)
    {
        string? raw = context.User.FindFirstValue("step_up_at");
        if (raw is not null
            && long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long seconds)
            && time.GetUtcNow() - DateTimeOffset.FromUnixTimeSeconds(seconds) <= requirement.MaxAge)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

builder.Services.AddSingleton<IAuthorizationHandler, RecentStepUpHandler>();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("RecentStepUp", policy => policy
        .RequireAuthenticatedUser()
        .AddRequirements(new RecentStepUpRequirement(TimeSpan.FromMinutes(5))));

app.MapPut("/account/shipping-address", (AddressRequest request) => TypedResults.Ok(new { saved = request.City }))
    .RequireAuthorization("RecentStepUp");
== step-up
HTTP/1.1 403 Forbidden
step-up 204
{"saved":"Izmir"} 200
{"name":"ada@bookstore.test","amr":["mfa"],"stepUpAt":"1794116960"}

İlk istek 403: oturum var ama son beş dakikada step-up yok. Angular istemcisi bu endpoint'ten 403 alınca kod soran bir dialog açar, /manage/step-up'a gider ve isteği tekrarlar. Policy'nin ve handler'ın nasıl değerlendirildiği ilerideki ASP.NET Core Güvenliği serisinin konusu; burada tasarım önemli: step-up'ı rol değil zaman bilgisine bağlamak. OAuth ile korunan bir API (Application Programming Interface)'de aynı fikrin standart hâli RFC 9470 (OAuth 2.0 Step Up Authentication Challenge Protocol): resource server insufficient_user_authentication hatası döner, istemci authorization server'a acr_values ya da max_age ile yeniden gider. Bunu serinin OAuth ve OIDC (OpenID Connect) yazılarında göreceğiz.

SMS, "Bu Cihazı Hatırla" ve MFA Fatigue

SMS (Short Message Service) ile kod göndermek kullanıcı için kolay, ama NIST SP 800-63B-4 telefon şebekesi üzerinden out-of-band doğrulamayı "restricted" sınıfına koyuyor: SIM (Subscriber Identity Module) swap, numara taşıma ve şebeke saldırıları yüzünden. Restricted bir authenticator'ı kullanan verifier, tüm kullanıcılara alternatif bir authenticator türü sunmak ve kullanıcıyı bu zayıflık konusunda uyarmak zorunda. Aynı rehber AAL2 (Authentication Assurance Level 2) için en az bir phishing'e dayanıklı seçenek sunulmasını da şart koşuyor. TOTP phishing'e dayanıklı değil: sahte bir sayfa kodu alıp anında gerçek siteye aktarabilir. Phishing'e dayanıklı seçenek passkey; serinin sonraki iki yazısı onun hakkında.

"Bu cihazı hatırla" (rememberClient: true) Identity.TwoFactorRememberMe cookie'sini yazar ve o tarayıcıda ikinci adımı atlatır:

== remember machine: password-only login again on device b
{"status":"signed-in"}
{"name":"ada@bookstore.test","amr":["pwd"],"stepUpAt":null}

Parolayı bilen ve o tarayıcıya erişen biri artık tek factor'le giriyor. İyi tarafı, amr dürüstçe pwd diyor; step-up policy'n bu oturumu MFA'lı saymaz. Hatırlama süresini kısa tut ve paylaşılan bilgisayarlarda bu seçeneği sunma.

MFA fatigue, push bildirimi ile onay isteyen sistemlere karşı kullanılır: saldırgan parolayı bilir ve kullanıcı yanlışlıkla "onayla"ya basana kadar bildirim gönderir. TOTP bu saldırıya doğrudan açık değil, çünkü kullanıcı bir şeyi onaylamıyor, kod yazıyor. Push tabanlı bir yöntem eklersen ekranda gösterilen bir sayıyı uygulamada seçtirmek (number matching), bildirim sayısını sınırlamak ve isteğin konumunu göstermek temel savunmalar.

Üretimde Dikkat

Sunucu saatini NTP (Network Time Protocol) ile senkron tut; kayan bir sunucu saati herkesin kodunu reddeder ve bunu ilk fark eden destek ekibi olur. Container'larda saat host'tan gelir, ama sanal makinelerde uyku sonrası kayma görülebilir.

2FA'yı kapatma, authenticator'ı sıfırlama ve recovery code'ları yeniden üretme işlemlerini step-up arkasına koy ve her birinde kullanıcıya e-posta gönder. Bunlar hesabı ele geçiren birinin kalıcı olmak için ilk yapacakları. Identity'nin .NET 10 metriklerinden aspnetcore.identity.sign_in.two_factor_clients_remembered ve aspnetcore.identity.user.verify_token_attempts bu akışları izlemek için doğrudan kullanılabilir.

Ölçek tarafında replay koruması her başarılı doğrulamada bir yazma ekler. Giriş trafiği yüksek değilse fark edilmez; çok yüksekse son adımı Redis gibi hızlı bir yerde, kullanıcı başına kısa TTL (Time To Live) ile tutmak bir seçenek.

Sık Düşülen Tuzaklar

Birincisi, Identity'nin varsayılan authenticator provider'ının replay'i engellediğini varsaymak. Yukarıda gördüğün gibi engellemiyor.

İkincisi, 2FA'yı kullanıcının ilk kodu doğrulanmadan açmak. Üçüncüsü, authenticator key'in ve recovery code'ların veritabanında düz metin durduğunu unutup bu tabloyu her raporlama aracına açmak.

Dördüncüsü, 2FA kodu denemelerini lockout'a bağlamamak. TwoFactorAuthenticatorSignInAsync bunu kendisi yapıyor; ama kendi step-up endpoint'ini yazarken AccessFailedAsync'i çağırmazsan kod tahminine açık bir kapı bırakırsın.

Beşincisi, step-up'ı yalnız UI'da (User Interface) uygulamak. Angular dialog'u bir kolaylık; asıl kontrol API'deki policy.

Küçük Bir Deneme

★☆☆ Lab'ı --Lab:ReplayProtection=false ile çalıştır ve kabul penceresi ölçümünü tekrarla. Sonra replay korumalı hâlde aynı ölçümü yap ve hangi adımların neden reddedildiğini açıkla (ipucu: ölçümün kendisi de adımları tüketiyor).

★★☆ Recovery code'ları düz metin yerine Secure Hash Algorithm 256 (SHA-256) hash'iyle saklayan bir IUserTwoFactorRecoveryCodeStore<TUser> implementasyonu yaz. Kodun tüketildiğini ve veritabanında artık okunamadığını sqlite3 ile göster.

★★★ Step-up'ı 403 yerine standart bir cevapla bildir: endpoint'ten dönen 403'e WWW-Authenticate header'ı ile insufficient_user_authentication ekle ve Angular'da bir interceptor yaz: bu header'ı görünce kod dialog'unu açsın, step-up'tan sonra isteği bir kez tekrarlasın.

TOTP güçlü bir ikinci adım, ama phishing'e dayanıklı değil. Bir sonraki yazıda bunu yapısal olarak çözen yönteme geçiyoruz: passkey'ler, WebAuthn ve FIDO2 (Fast IDentity Online 2); RP (Relying Party) ID'nin, challenge'ın, attestation'ın ve assertion'ın ne işe yaradığı.