ASP.NET Core Identity ile Hesap Yaşam Döngüsü: Kayıt, E-posta Onayı, Parola Sıfırlama, External Login ve Hesap Silme
Identity'nin sıfırlama token'ı tek kullanımlık olarak işaretlenmez; içinde security stamp taşıdığı için parola değişince ölür, varsayılan ömrü de bir gündür. Kayıt, e-posta onayı, ayrı ömürlü parola sıfırlama provider'ı, diğer session'ları düşürme, e-posta değişikliği, Google ile external login'de otomatik bağlamanın riski ve KVKK/GDPR için kişisel veri export'u ile hesap silmeyi Angular ekranıyla kuruyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.14, Microsoft.AspNetCore.Authentication.Google 10.0.14 ve Angular 22.2 ile derlenip denenmiştir.
Önce Tahmin Et
Ada parolasını unuttu, Bookstore ona bir sıfırlama link'i gönderdi, Ada link'le yeni parolasını belirledi. Ertesi gün aynı e-postayı tekrar açıp aynı link'e tıklıyor. Ne olur?
reset 204
["InvalidToken"] reuse 400
İkinci deneme reddediliyor. Çoğu kişi bunun sebebini "Identity token'ı kullanıldı diye işaretliyor" sanıyor. Öyle bir işaret yok; token veritabanında hiç saklanmıyor. Reddin sebebi, parola değiştiğinde kullanıcının security stamp'inin yenilenmesi ve token'ın içinde eski stamp'in durması. Bu ayrıntı küçük görünüyor ama hesap yaşam döngüsünün tamamını belirliyor: hangi token ne zaman ölür, parola sıfırlanınca diğer cihazlara ne olur, e-posta değişince açık session'lar ne yapar. Bu yazıda bir Bookstore hesabının hayatını baştan sona kuruyoruz: kayıt, e-posta onayı, parola sıfırlama, e-posta değişikliği, external login ve hesap silme. Ön yüz Angular olduğu için Identity'nin hazır Razor sayfalarını değil, UserManager ve SignInManager üzerinde kendi endpoint'lerimizi kullanıyoruz.
Token Provider'lar: Token'ın İçinde Ne Var?
E-posta onayı, parola sıfırlama ve e-posta değişikliği token'larını varsayılan olarak DataProtectorTokenProvider<TUser> üretir. Token, Data Protection ile şifrelenmiş küçük bir paket: oluşturulma zamanı, kullanıcının id'si, amaç (ResetPassword, EmailConfirmation, ChangeEmail:yeni@adres) ve kullanıcının o anki security stamp'i. Doğrulama dört şeye bakar: süre dolmamış mı, id aynı mı, amaç aynı mı, stamp hâlâ aynı mı. Stamp'in token'ın içinde olması güçlü bir sonuç doğurur: stamp'i değiştiren her işlem (parola sıfırlama, parola değişikliği, e-posta değişikliği, 2FA ayarı) o kullanıcıya verilmiş bütün açık token'ları geçersiz kılar.
Varsayılan ömür hafızadan değil, koşudan:
DataProtectionTokenProviderOptions.TokenLifespan=1.00:00:00
SignInOptions.RequireConfirmedEmail=False RequireConfirmedAccount=False
Bir gün. E-posta onayı için makul, parola sıfırlama için uzun: link bir gün boyunca gelen kutusunda, iletilmiş bir e-postada ya da bir log dosyasında duran bir giriş anahtarı. Sorun şu ki DataProtectionTokenProviderOptions tektir; ömrü kısaltırsan e-posta onayı da kısalır. Çözüm, parola sıfırlama için ayrı options'ı olan ayrı bir provider:
public sealed class PasswordResetTokenProviderOptions : DataProtectionTokenProviderOptions
{
public const string ProviderName = "PasswordReset";
}
public sealed class PasswordResetTokenProvider<TUser>(
IDataProtectionProvider dataProtectionProvider,
IOptions<PasswordResetTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<TUser>> logger)
: DataProtectorTokenProvider<TUser>(dataProtectionProvider, options, logger)
where TUser : class;
builder.Services.AddIdentity<BookstoreUser, BookstoreRole>(options =>
{
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
options.SignIn.RequireConfirmedEmail = true;
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = 12;
options.Tokens.PasswordResetTokenProvider = PasswordResetTokenProviderOptions.ProviderName;
})
.AddEntityFrameworkStores<BookstoreIdentityDbContext>()
.AddDefaultTokenProviders()
.AddTokenProvider<PasswordResetTokenProvider<BookstoreUser>>(PasswordResetTokenProviderOptions.ProviderName);
builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
options.TokenLifespan = TimeSpan.FromHours(24));
builder.Services.Configure<PasswordResetTokenProviderOptions>(options =>
{
options.Name = PasswordResetTokenProviderOptions.ProviderName;
options.TokenLifespan = builder.Configuration.GetValue("Lab:ResetTokenLifespan", TimeSpan.FromMinutes(15));
});
builder.Services.Configure<SecurityStampValidatorOptions>(options =>
options.ValidationInterval = builder.Configuration.GetValue("Lab:StampInterval", TimeSpan.FromMinutes(1)));
options.Name'i farklı vermek önemli: Data Protection'ın purpose'u bu isimden türetilir, böylece bir provider'ın ürettiği token diğerinde çözülmez. Parola sıfırlama 15 dakika, e-posta onayı 24 saat. SecurityStampValidator'ın aralığını da 30 dakikadan 1 dakikaya indirdim; neden olduğu birazdan çıktıda görünecek. RequireConfirmedEmail'in varsayılanı false; yani onaylanmamış bir e-postayla giriş yapılabiliyor. Bookstore'da bunu açıyoruz.
Kayıt ve E-posta Onayı
account.MapPost("/register", async (RegisterRequest request, UserManager<BookstoreUser> users, OutboxEmailSender email) =>
{
if (await users.FindByEmailAsync(request.Email) is not null)
{
// Yeni kayıtla aynı cevap; hesabın sahibine e-postayla haber verilir
await email.SendAsync(request.Email, "You already have an account", $"{WebOrigin}/forgot-password");
return TypedResults.Accepted((string?)null);
}
var user = new BookstoreUser { UserName = request.Email, Email = request.Email, DisplayName = request.DisplayName };
IdentityResult result = await users.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
return Results.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
}
string token = await users.GenerateEmailConfirmationTokenAsync(user);
string code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
await email.SendAsync(user.Email, "Confirm your e-mail", $"{WebOrigin}/confirm-email?userId={user.Id}&code={code}");
return TypedResults.Accepted((string?)null);
});
İki karar var. Birincisi, kayıtlı bir e-postayla gelen istek de 202 alıyor. "Bu e-posta zaten kayıtlı" diye 400 dönmek, kimin Bookstore müşterisi olduğunu herkese sorgulatmak demek (account enumeration). Kayıt formunda bunu tamamen saklamak zordur, ama cevap kodunu ve süresini aynı tutmak kolay kazanımdır. İkincisi, token URL (Uniform Resource Locator)'de taşınacağı için WebEncoders.Base64UrlEncode ile kodlanıyor. Lab'da e-posta göndermek yerine bir outbox dosyasına yazıyorum:
HTTP/1.1 202 Accepted
{"status":"NotAllowed"} 401
HTTP/1.1 202 Accepted
{"to":"ada@bookstore.test","subject":"Confirm your e-mail","link":"https://localhost:4200/confirm-email?userId=1e213a7a-cb16-4a7e-b5c9-f158a06a297c\u0...
{"to":"ada@bookstore.test","subject":"You already have an account","link":"https://localhost:4200/forgot-password"}
Sırayla: kayıt 202, onaydan önce giriş denemesi 401 ve SignInResult NotAllowed, aynı e-postayla ikinci kayıt yine 202, ama outbox'a "zaten hesabın var" e-postası düşüyor. Onay endpoint'i token'ı geri çözüp ConfirmEmailAsync'i çağırıyor. Burada lab'ın ilk sürümünde bir hata yakaladım: bozuk bir userId ile gelen istek 500 döndü, çünkü Guid key'li store'da FindByIdAsync geçersiz bir string'i parse ederken FormatException fırlatıyor. Düzeltilmiş hâli:
account.MapPost("/confirm-email", async (ConfirmEmailRequest request, UserManager<BookstoreUser> users) =>
{
// FindByIdAsync bozuk bir Guid için FormatException fırlatır; bunu bad request olarak reddet
if (!Guid.TryParse(request.UserId, out _) || await users.FindByIdAsync(request.UserId) is not { } user)
{
return Results.BadRequest();
}
string token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Code));
IdentityResult result = await users.ConfirmEmailAsync(user, token);
return result.Succeeded ? TypedResults.NoContent() : Results.BadRequest(result.Errors.Select(e => e.Code));
});
confirm bad id 400
confirm 204
{"status":"signed-in"} 200
Parola Sıfırlama ve Diğer Session'ları Düşürmek
account.MapPost("/forgot-password", async (ForgotPasswordRequest request, UserManager<BookstoreUser> users, OutboxEmailSender email) =>
{
BookstoreUser? user = await users.FindByEmailAsync(request.Email);
if (user is not null && await users.IsEmailConfirmedAsync(user))
{
string token = await users.GeneratePasswordResetTokenAsync(user);
string code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
await email.SendAsync(request.Email, "Reset your password", $"{WebOrigin}/reset-password?email={Uri.EscapeDataString(request.Email)}&code={code}");
}
// Her zaman aynı cevap: endpoint hesabın var olup olmadığını açık etmemeli
return TypedResults.Accepted((string?)null);
});
account.MapPost("/reset-password", async (ResetPasswordRequest request, UserManager<BookstoreUser> users) =>
{
BookstoreUser? user = await users.FindByEmailAsync(request.Email);
if (user is null)
{
return Results.BadRequest(new[] { "InvalidToken" });
}
string token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Code));
IdentityResult result = await users.ResetPasswordAsync(user, token, request.NewPassword);
return result.Succeeded ? TypedResults.NoContent() : Results.BadRequest(result.Errors.Select(e => e.Code));
});
Senaryo: Ada iki cihazdan (A ve B) giriş yapmış. Var olan ve olmayan bir e-postayla sıfırlama istiyorum, sonra A'dan sıfırlıyorum, aynı link'i tekrar deniyorum ve B'nin cookie'siyle istek atıyorum. En sonda da lab'ı 10 saniyelik reset ömrüyle çalıştırıp yeni bir link'i 12 saniye bekleterek deniyorum:
HTTP/1.1 202 Accepted
HTTP/1.1 202 Accepted
reset 204
["InvalidToken"] reuse 400
HTTP/1.1 401 Unauthorized
Set-Cookie: bookstore.auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly
Set-Cookie: Identity.External=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponl...
Set-Cookie: Identity.TwoFactorUserId=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; ...
Set-Cookie: Identity.TwoFactorRememberMe=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=l...
["InvalidToken"] expired 400
Var olan ve olmayan e-postaya aynı 202. Sıfırlama başarılı, aynı link ikinci kez InvalidToken. B cihazı bir sonraki isteğinde 401 alıyor ve Identity onun bütün cookie'lerini temizliyor; "bu cihazı hatırla" işareti de dahil. Bunu sağlayan ResetPasswordAsync'in security stamp'i yenilemesi ve SecurityStampValidator'ın B'nin cookie'sindeki eski stamp'i görmesi. Lab'ı aralık sıfırken çalıştırdım; varsayılan 30 dakikada B'nin düşmesi 30 dakikaya kadar sürebilirdi. Hesabı ele geçirildiğini düşünüp parolasını sıfırlayan bir kullanıcı için 30 dakika uzun. Bookstore'da 1 dakika seçtim: her dakika bir sorgu, karşılığında bir dakikalık pencere. Süresi dolmuş link de beklendiği gibi reddedildi.
Hata mesajları kasıtlı olarak sade: süresi dolmuş, kullanılmış ya da başka bir kullanıcının token'ı aynı InvalidToken'ı döndürüyor. Kullanıcıya "link geçersiz ya da süresi dolmuş, yenisini iste" demek yeterli.
Angular Tarafı: Sıfırlama Ekranı
Link Angular uygulamasına email ve code query parametreleriyle geliyor. withComponentInputBinding() açıkken bunlar doğrudan component input'larına bağlanır:
export class ResetPassword {
readonly email = input.required<string>();
readonly code = input.required<string>();
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
readonly status = signal<ResetStatus>('idle');
readonly form = this.fb.nonNullable.group({
newPassword: ['', [Validators.required, Validators.minLength(12)]],
});
async submit(): Promise<void> {
this.status.set('saving');
try {
await firstValueFrom(
this.http.post('/account/reset-password', {
email: this.email(),
code: this.code(),
newPassword: this.form.getRawValue().newPassword,
}),
);
this.status.set('done');
// Tek kullanımlık kodu adres çubuğundan ve tarayıcı geçmişinden kaldır
await this.router.navigate([], { queryParams: {}, replaceUrl: true });
} catch (error) {
const invalid = error instanceof HttpErrorResponse && error.status === 400
&& Array.isArray(error.error) && error.error.includes('InvalidToken');
this.status.set(invalid ? 'invalid-link' : 'error');
}
}
}
Template'te autocomplete="new-password" kullanıyorum; parola yöneticisi yeni parolayı önerip kaydedebilsin. InvalidToken geldiğinde form yerine "yeni link iste" mesajı gösteriliyor. Başarıdan sonra replaceUrl: true ile query string'i silmek, kodun tarayıcı geçmişinde kalmamasını sağlıyor. Aynı sebeple bu sayfada üçüncü taraf script ya da görsel olmamalı ve Referrer-Policy header'ı en az strict-origin-when-cross-origin olmalı; aksi hâlde kod tam URL'yle birlikte Referer header'ında başka bir siteye gidebilir. Component'i ng build ile derledim; tarayıcıda uçtan uca tıklama testini bu yazı için yapmadım.
E-posta Değişikliği
account.MapPost("/change-email", async (ChangeEmailRequest request, HttpContext context, UserManager<BookstoreUser> users, OutboxEmailSender email) =>
{
BookstoreUser user = (await users.GetUserAsync(context.User))!;
string token = await users.GenerateChangeEmailTokenAsync(user, request.NewEmail);
string code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
// Link YENİ adrese gider: onaylayan kişi o posta kutusunun sahibi olmalı
await email.SendAsync(request.NewEmail, "Confirm your new e-mail", $"{WebOrigin}/confirm-email-change?userId={user.Id}&email={Uri.EscapeDataString(request.NewEmail)}&code={code}");
return TypedResults.Accepted((string?)null);
}).RequireAuthorization();
GenerateChangeEmailTokenAsync yeni adresi token'ın amacına gömüyor (ChangeEmail:yeni@adres). Biri onay isteğinde adresi değiştirmeye çalışırsa token tutmuyor. Sonra gerçek değişiklik, giriş yeniden ve /me:
["InvalidToken"] tampered email 400
confirm change 204
401
{"status":"signed-in"} 200
Değişiklikten sonraki 401 sürpriz değil: ChangeEmailAsync de security stamp'i yeniliyor, yani e-posta değişince tüm cihazlar yeniden giriş yapıyor. Kullanıcı adı e-postayla aynıysa SetUserNameAsync'i de çağırmayı unutma; yoksa kullanıcı yeni adresle giriş yapamaz. Bir de eski adrese "e-posta adresin değiştirildi" bilgilendirmesi gönder: hesabı ele geçiren birinin ilk yaptığı şey genelde e-postayı değiştirmektir ve eski adresin sahibi bunu bu e-postadan öğrenir.
External Login: Google'ı Bağlamak ve Aynı E-postayla İki Hesap
Google ile giriş, Identity'nin Identity.External cookie'si üzerinden çalışır: Google handler'ı dönen kimliği önce bu geçici cookie'ye yazar, sonra senin callback'in onu bir yerel hesaba bağlar.
account.MapGet("/external/callback", async (SignInManager<BookstoreUser> signIn, UserManager<BookstoreUser> users) =>
{
ExternalLoginInfo? info = await signIn.GetExternalLoginInfoAsync();
if (info is null)
{
return Results.Redirect($"{WebOrigin}/login?error=external");
}
SignInResult result = await signIn.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: false);
if (result.Succeeded)
{
return Results.Redirect(WebOrigin);
}
// E-postaya göre otomatik bağlama yok: var olan yerel hesap önce giriş yapıp açıkça bağlamalı
string? externalEmail = info.Principal.FindFirstValue(ClaimTypes.Email);
if (externalEmail is not null && await users.FindByEmailAsync(externalEmail) is not null)
{
return Results.Redirect($"{WebOrigin}/login?linkRequired={info.LoginProvider}");
}
return Results.Redirect($"{WebOrigin}/register/external");
});
Kritik karar ortadaki yorumda. Google'dan ada@bookstore.test e-postasıyla biri geldiğinde ve bu e-postayla bir yerel hesap varsa, "aynı kişidir" deyip otomatik bağlamak cazip. Ama bu, e-postayı doğrulamayan ya da kendi alan adını kontrol edemediğin bir sağlayıcıda hesabı ele geçirmenin kısa yolu: saldırgan sağlayıcıda kurbanın e-postasıyla bir hesap açar ve Bookstore onu kurbanın hesabına bağlar. Güvenli yol, kullanıcıdan önce kendi parolasıyla (ya da passkey'iyle) giriş yapmasını istemek ve bağlamayı oturum açıkken AddLoginAsync ile yapmak. Bağlantıyı koparmak için RemoveLoginAsync var; ama kullanıcının başka bir giriş yolu (parola ya da başka bir sağlayıcı) kalmıyorsa koparmaya izin verme, yoksa hesap kilitlenir.
Bu bölümün kodu derleniyor, ama Google'a karşı çalıştırmadım: lab yalnız yerel makinede koşuyor ve üçüncü taraf bir sağlayıcıya istek atmıyor. AddGoogle kaydı da yalnız configuration'da bir client id varsa yapılıyor. OAuth ve OIDC'nin (OpenID Connect) yerel bir Keycloak ile uçtan uca trafiği serinin 11. ve 12. yazılarında.
Hesap Silme ve Kişisel Veri
KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) kullanıcıya verisini alma ve sildirme hakkı tanır. Identity bunun için [PersonalData] attribute'unu kullanır; IdentityUser'ın e-posta, telefon gibi property'leri zaten işaretli, kendi eklediklerini sen işaretlersin:
public sealed class BookstoreUser : IdentityUser<Guid>
{
[PersonalData]
public string DisplayName { get; set; } = "";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=PersonalData.json
{"DisplayName":"Ada","Id":"1e213a7a-cb16-4a7e-b5c9-f158a06a297c","UserName":"ada@bookstore.test","Email":"ada@bookstore.test","EmailConfirmed":"True","PhoneNumber":null,"PhoneNumberConfirmed":"False","TwoFactorEnabled":"False"}
Export endpoint'i [PersonalData] taşıyan property'leri reflection ile topluyor ve bağlı external login'leri ekliyor. Silme ise parolanın yeniden girilmesini istiyor; açık kalmış bir oturumdan tek tıkla hesap silinmemeli:
["PasswordMismatch"] 400
delete 204
$ sqlite3 bookstore-identity.db "select count(*) from AspNetUsers; select count(*) from AspNetUserTokens;"
0
0
DeleteAsync, Identity tablolarındaki kullanıcı satırını ve cascade ile bağlı claim, login, token ve passkey satırlarını siler. Ama Bookstore'un siparişleri, faturaları ve logları Identity'nin dışında. Bunlar için ayrı bir karar gerekir: yasal saklama yükümlülüğü olan fatura silinmez, anonimleştirilir; sipariş geçmişindeki müşteri adı kaldırılır; loglarda e-posta yerine kullanıcı id'si tutulmuşsa iş kolaylaşır. Hassas alanları veritabanında şifreli tutmak için [ProtectedPersonalData] ve options.Stores.ProtectPersonalData da var; bunun için bir ILookupProtector ve anahtar yönetimi yazman gerekir.
Üretimde Dikkat
E-posta gönderimini request'in içinde senkron yapma; SMTP (Simple Mail Transfer Protocol) sunucusu yavaşsa kayıt ve şifre sıfırlama da yavaşlar, üstelik var olan ve olmayan hesaplar için cevap süresi farklılaşır ve enumeration geri gelir. E-postayı bir outbox tablosuna yaz, bir background job göndersin. Lab'daki outbox dosyası bunun en basit hâli.
/forgot-password ve /register rate limiting olmadan bırakılırsa birinin gelen kutusunu doldurmak için kullanılabilir. IP (Internet Protocol) ve hedef e-posta başına sınır koy; rate limiting'in kendisi Web Güvenliği serisinin 11. yazısında anlatıldı. Identity'nin .NET 10 metriklerinden aspnetcore.identity.user.generated_tokens ve aspnetcore.identity.user.verify_token_attempts bu endpoint'lerin kötüye kullanımını izlemek için doğrudan işe yarar.
Birden fazla instance'ta Data Protection key ring'ini paylaşmayı unutma: bir instance'ın ürettiği sıfırlama token'ını diğeri çözemezse kullanıcı "link geçersiz" görür ve sebebini kimse bulamaz.
Sık Düşülen Tuzaklar
Birincisi, parola sıfırlama token'ını varsayılan bir günlük ömürde bırakmak ve DataProtectionTokenProviderOptions'ı kısaltınca e-posta onayının da kısaldığını fark etmemek. Ayrı provider kullan.
İkincisi, sıfırlamadan sonra açık session'ları unutmak. Stamp yenileniyor ama SecurityStampValidator varsayılan 30 dakikada bir bakıyor; aralığı ihtiyacına göre ayarla.
Üçüncüsü, kayıtta ve sıfırlamada farklı cevaplarla hesabın varlığını açık etmek. Dördüncüsü, external login'i e-postaya göre otomatik bağlamak. Beşincisi, FindByIdAsync'e doğrulanmamış bir string vermek; Guid key'de bu 500 demek ve log'u gereksiz exception'larla doldurur.
Altıncısı, sıfırlama link'ini loglamak. Lab'daki outbox gibi bir dosya ya da "debug için" yazılmış bir log satırı, geçerli bir giriş anahtarını herkesin okuyabileceği bir yere koyar. Production'da link'in kendisini değil, yalnız "kime, hangi amaçla, ne zaman" bilgisini logla.
Küçük Bir Deneme
★☆☆ Lab'da e-posta onay token'ını iki kez kullan. İkinci denemenin sonucunu parola sıfırlama token'ıyla karşılaştır ve farkı security stamp üzerinden açıkla.
★★☆ Ada için bir e-posta onay token'ı üret, sonra parolasını değiştir ve aynı onay token'ını dene. Neden InvalidToken döndüğünü token'ın içeriğiyle açıkla.
★★★ /forgot-password için iki ayrı rate limiter policy'si yaz: IP başına dakikada 5 istek ve hedef e-posta başına saatte 3 istek. Var olan ve olmayan bir e-posta için cevap süresini 50'şer istekle ölç ve farkın neden kapandığını göster.
Hesap artık doğuyor, değişiyor ve ölüyor. Bir sonraki yazıda girişin kendisini güçlendiriyoruz: TOTP (Time-based One-Time Password) ile MFA (Multi-Factor Authentication), recovery code'lar, hassas işlemler için step-up authentication ve SMS (Short Message Service)'in neden zayıf bir ikinci factor olduğu.