Identity: Двофакторна Аутентифікація (2FA)
Identity: Двофакторна Аутентифікація
1. Навіщо 2FA і що це таке?
Фактори аутентифікації
Теорія безпеки визначає три категорії факторів:
🧠 Щось, що ви знаєте
📱 Щось, що ви маєте
👁️ Щось, що ви є
2FA (Two-Factor Authentication) означає вимогу двох факторів із різних категорій. Комбінація «пароль + TOTP-код» — це перша і третя категорія разом. Навіть якщо пароль вкрадено — без доступу до телефону зловмисник зупинений.
NIST рекомендації (SP 800-63B)
Американський Національний інститут стандартів і технологій має чіткі рекомендації:
| Метод 2FA | Рівень надійності | NIST статус |
|---|---|---|
| TOTP (Authenticator) | Високий | ✅ Рекомендовано |
| Hardware Key (FIDO2) | Найвищий | ✅ Рекомендовано |
| Email OTP | Середній | ⚠️ Допустимо |
| SMS OTP | Середній | ⚠️ Обмежено (SIM-swap ризик) |
| Секретні питання | Низький | ❌ НЕ рекомендовано |
2. Архітектура 2FA в ASP.NET Core Identity
Як Identity реалізує 2FA?
2FA в Identity — це, по суті, дворівневий логін:
- Перший рівень: перевірка пароля → якщо 2FA увімкнена, не повертаємо успіх, а встановлюємо тимчасовий cookie.
- Другий рівень: перевірка другого фактора → тепер повертаємо повноцінний аутентифікований стан.
Ключові методи
SignInResult, де RequiresTwoFactor == true якщо потрібен другий фактор.TwoFactorUserId cookie.TwoFactorSignInAsync("Authenticator", ...).3. TOTP: Часові одноразові паролі
Що таке TOTP?
TOTP (Time-based One-Time Password) — стандарт RFC 6238, який генерує 6-значний код на основі двох елементів:
- Секретний ключ — унікальна рядок (Base32), що зберігається у додатку аутентифікатора.
- Поточний час — округлений до 30-секундного інтервалу.
TOTP = HOTP(secret, floor(unixTime / 30))
= HMAC-SHA1(secret, counter)[-6:]
Код дійсний 30 секунд. Identity за замовчуванням допускає похибку в ±1 інтервал (до 90 секунд), щоб врахувати розсинхронізацію годинників.
Ключове: сервер і додаток аутентифікатора незалежно обчислюють один і той самий код. Жодних даних між ними не передається в момент входу — лише при початковому налаштуванні (QR-код).
Flow налаштування TOTP
Крок 1: Завантаження сторінки налаштування 2FA
Перший запит отримує або генерує ключ аутентифікатора:
app.MapGet("/account/2fa/setup",
async (HttpContext ctx,
UserManager<AppUser> userManager) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
// Отримуємо (або генеруємо) секретний ключ
// Якщо ключ вже є в AspNetUserTokens — повертає існуючий
// Якщо немає — генерує новий і зберігає у tokens
var unformattedKey = await userManager
.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(unformattedKey))
{
// Явна генерація нового ключа
await userManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await userManager
.GetAuthenticatorKeyAsync(user);
}
// Форматуємо ключ для зручності введення вручну: XXXX XXXX XXXX
var formattedKey = FormatKey(unformattedKey!);
// Будуємо URI для QR-коду (стандарт otpauth://)
var email = await userManager.GetEmailAsync(user);
var authenticatorUri = GenerateQrCodeUri(
email!, unformattedKey!);
return Results.Ok(new
{
sharedKey = formattedKey,
authenticatorUri, // Передати на фронт для генерації QR
qrCodeImageUrl = $"/account/2fa/qr?uri={Uri.EscapeDataString(authenticatorUri)}"
});
}).RequireAuthorization();
// Форматування ключа: "abcdefghijklmnop" → "ABCD EFGH IJKL MNOP"
static string FormatKey(string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(
unformattedKey.AsSpan(currentPosition, 4));
result.Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
result.Append(unformattedKey.AsSpan(currentPosition));
return result.ToString().ToUpperInvariant();
}
// URI для QR-коду у форматі otpauth://
static string GenerateQrCodeUri(string email, string unformattedKey)
{
const string AuthenticatorUriFormat =
"otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
return string.Format(
AuthenticatorUriFormat,
Uri.EscapeDataString("MyApp"), // Назва додатку
Uri.EscapeDataString(email), // Email уkористувача
unformattedKey); // Secret key (Base32)
}
Крок 2: Відображення QR-коду
Фронтенд отримує authenticatorUri і генерує QR-код. На сторінці також показується sharedKey для ручного введення (якщо QR не сканується).
Для генерації QR-коду на сервері можна використати пакет QRCoder:
app.MapGet("/account/2fa/qr",
(string uri) =>
{
// QRCoder генерує PNG зображення QR-коду
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(
uri, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrCodeData);
var qrCodeBytes = qrCode.GetGraphic(10);
return Results.File(qrCodeBytes, "image/png");
}).RequireAuthorization();
Крок 3: Верифікація та увімкнення 2FA
Користувач сканував QR-код та ввів перший код — перевіряємо:
app.MapPost("/account/2fa/enable",
async (Enable2FaRequest req,
HttpContext ctx,
UserManager<AppUser> userManager) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
// Нормалізуємо код: прибираємо пробіли та дефіси
var verificationCode = req.Code
.Replace(" ", "")
.Replace("-", "");
// Перевіряємо TOTP-код за допомогою поточного secret
// Це НЕ вмикає 2FA — лише перевіряє, що код правильний
var is2FaTokenValid = await userManager
.VerifyTwoFactorTokenAsync(
user,
userManager.Options.Tokens.AuthenticatorTokenProvider,
verificationCode);
if (!is2FaTokenValid)
return Results.BadRequest(new
{
error = "Invalid verification code. " +
"Please check your authenticator app."
});
// Вмикаємо 2FA для акаунту
await userManager.SetTwoFactorEnabledAsync(user, true);
// Генеруємо резервні коди (обов'язково показати користувачу!)
var recoveryCodes = await userManager
.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
return Results.Ok(new
{
message = "Two-factor authentication has been enabled.",
recoveryCodes = recoveryCodes, // ⚠️ Показати ОДИН РАЗ!
message2 = "Save these recovery codes in a safe place. " +
"You won't be able to see them again."
});
}).RequireAuthorization();
record Enable2FaRequest(string Code);
Login з TOTP
Після того як 2FA налаштовано, логін потребує двох кроків:
app.MapPost("/auth/login",
async (LoginRequest req,
SignInManager<AppUser> signInManager,
UserManager<AppUser> userManager,
TokenService tokenService) =>
{
var user = await userManager.FindByEmailAsync(req.Email);
if (user is null)
return Results.Json(
new { error = "Invalid credentials" }, statusCode: 401);
// CheckPasswordSignInAsync — перевіряє пароль БЕЗ встановлення cookie
// lockoutOnFailure: true — рахує невдалі спроби
var result = await signInManager.CheckPasswordSignInAsync(
user, req.Password, lockoutOnFailure: true);
if (result.IsLockedOut)
return Results.Json(
new { error = "Account locked." }, statusCode: 423);
if (!result.Succeeded)
return Results.Json(
new { error = "Invalid credentials" }, statusCode: 401);
// 2FA потрібна — повертаємо спеціальний статус
if (result.RequiresTwoFactor)
{
// Зберігаємо userId у тимчасовому коді (або можемо повернути userId)
return Results.Ok(new
{
requiresTwoFactor = true,
// Безпечно повертати userId — для 2FA ще потрібен другий фактор
userId = user.Id
});
}
// 2FA не потрібна — видаємо токен
var roles = await userManager.GetRolesAsync(user);
var token = tokenService.GenerateToken(user, roles);
return Results.Ok(new { access_token = token });
});
app.MapPost("/auth/login/2fa",
async (TwoFaLoginRequest req,
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
TokenService tokenService) =>
{
var user = await userManager.FindByIdAsync(req.UserId);
if (user is null)
return Results.Json(
new { error = "Invalid session" }, statusCode: 401);
var code = req.Code.Replace(" ", "").Replace("-", "");
// Перевіряємо TOTP-код
var isValid = await userManager.VerifyTwoFactorTokenAsync(
user,
userManager.Options.Tokens.AuthenticatorTokenProvider,
code);
if (!isValid)
{
// Рахуємо невдалі спроби (захист від брутфорсу 2FA)
await userManager.AccessFailedAsync(user);
return Results.Json(
new { error = "Invalid 2FA code" }, statusCode: 401);
}
// Скидаємо лічильник невдалих спроб
await userManager.ResetAccessFailedCountAsync(user);
// Видаємо токен доступу
var roles = await userManager.GetRolesAsync(user);
var token = tokenService.GenerateToken(user, roles);
return Results.Ok(new
{
access_token = token,
expires_in = 900
});
});
record TwoFaLoginRequest(string UserId, string Code);
Вимикання TOTP
app.MapPost("/account/2fa/disable",
async (Disable2FaRequest req,
HttpContext ctx,
UserManager<AppUser> userManager) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
// Перевіряємо пароль перед вимиканням 2FA (додатковий захист)
var passwordValid = await userManager.CheckPasswordAsync(
user, req.Password);
if (!passwordValid)
return Results.BadRequest(new { error = "Invalid password." });
await userManager.SetTwoFactorEnabledAsync(user, false);
// Скидаємо ключ аутентифікатора — при повторному увімкненні
// буде згенеровано новий QR-код
await userManager.ResetAuthenticatorKeyAsync(user);
return Results.Ok(new
{
message = "Two-factor authentication has been disabled."
});
}).RequireAuthorization();
record Disable2FaRequest(string Password);
4. Email 2FA
Email 2FA — простіший у налаштуванні за TOTP, але менш безпечний (злам email-акаунту руйнує захист). Проте це значне покращення порівняно з відсутністю 2FA.
Як Email 2FA відрізняється від Email Confirmation?
Важливо не плутати:
| Email Confirmation | Email 2FA | |
|---|---|---|
| Мета | Підтвердити адресу при реєстрації | Другий фактор при кожному вході |
| Провайдер | DataProtectionTokenProvider | EmailTokenProvider |
| TTL токена | 1-3 дні | 10-15 хвилин |
| Формат коду | Довгий зашифрований рядок | Короткий числовий код (6 цифр) |
| Частота | Один раз | При кожному Auth |
Налаштування Email Token Provider
builder.Services
.AddIdentity<AppUser, IdentityRole>(options =>
{
// Вказуємо Email як провайдер 2FA
options.Tokens.EmailConfirmationTokenProvider =
TokenOptions.DefaultEmailProvider;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders(); // Реєструє EmailTokenProvider
AddDefaultTokenProviders() реєструє чотири провайдери:
DataProtectorTokenProvider— для підтвердження email, скидання пароляPhoneNumberTokenProvider— для SMS-кодівAuthenticatorTokenProvider— для TOTPEmailTokenProvider— для Email OTP коду
Реалізація Email 2FA
app.MapPost("/account/2fa/email/enable",
async (HttpContext ctx,
UserManager<AppUser> userManager,
IEmailSender emailSender) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
if (!user.EmailConfirmed)
return Results.BadRequest(new
{
error = "Please confirm your email before enabling Email 2FA."
});
// Генеруємо код підтвердження (числовий, 6 символів)
// Провайдер "Email" — це EmailTokenProvider
var code = await userManager.GenerateTwoFactorTokenAsync(
user, "Email");
await emailSender.SendEmailAsync(
user.Email!,
"Enable Two-Factor Authentication",
$"Your verification code: <strong>{code}</strong>. " +
$"Expires in 10 minutes.");
return Results.Ok(new
{
message = "Verification code sent to your email."
});
}).RequireAuthorization();
// Оновлення TTL для Email токенів
builder.Services
.Configure<EmailTokenProviderOptions>(options =>
{
options.TokenLifespan = TimeSpan.FromMinutes(10);
});
app.MapPost("/auth/login/2fa/email",
async (EmailTwoFaRequest req,
UserManager<AppUser> userManager,
TokenService tokenService) =>
{
var user = await userManager.FindByIdAsync(req.UserId);
if (user is null)
return Results.Json(
new { error = "Invalid session" }, statusCode: 401);
// Перевіряємо код через EmailTokenProvider
var isValid = await userManager.VerifyTwoFactorTokenAsync(
user,
TokenOptions.DefaultEmailProvider,
req.Code);
if (!isValid)
return Results.Json(
new { error = "Invalid or expired code." },
statusCode: 401);
var roles = await userManager.GetRolesAsync(user);
var token = tokenService.GenerateToken(user, roles);
return Results.Ok(new { access_token = token });
});
record EmailTwoFaRequest(string UserId, string Code);
5. SMS 2FA (Twilio)
SMS — найпопулярніший вид 2FA для масових споживчих продуктів через простоту для кінцевого користувача. Але він має відому вразливість: SIM-swapping — атака, де зловмисник переконує оператора перенести номер телефону на свою SIM-карту.
ISmsSender — інтерфейс
public interface ISmsSender
{
Task SendSmsAsync(string number, string message);
}
Реалізація з Twilio
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
public class TwilioSmsSender : ISmsSender
{
private readonly IConfiguration _config;
private readonly ILogger<TwilioSmsSender> _logger;
public TwilioSmsSender(
IConfiguration config,
ILogger<TwilioSmsSender> logger)
{
_config = config;
_logger = logger;
// Ініціалізуємо Twilio клієнт
TwilioClient.Init(
_config["Twilio:AccountSid"],
_config["Twilio:AuthToken"]);
}
public async Task SendSmsAsync(string number, string message)
{
try
{
var msg = await MessageResource.CreateAsync(
body: message,
from: new PhoneNumber(_config["Twilio:From"]),
to: new PhoneNumber(number));
_logger.LogInformation(
"SMS sent. SID: {Sid}", msg.Sid);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send SMS to {Number}", number);
throw;
}
}
}
{
"Twilio": {
"AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"AuthToken": "your_auth_token",
"From": "+15551234567"
}
}
Верифікація телефону перед увімкненням SMS 2FA
Перед тим як увімкнути SMS 2FA, потрібно переконатися, що номер телефону підтверджений:
app.MapPost("/account/2fa/sms/setup",
async (SmsTwoFaSetupRequest req,
HttpContext ctx,
UserManager<AppUser> userManager,
ISmsSender smsSender) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
// Генеруємо код для верифікації номера
// ChangePhoneNumberTokenProvider — окремий провайдер
var token = await userManager
.GenerateChangePhoneNumberTokenAsync(user, req.PhoneNumber);
await smsSender.SendSmsAsync(
req.PhoneNumber,
$"Your MyApp verification code: {token}");
return Results.Ok(new
{
message = $"Verification code sent to {req.PhoneNumber}."
});
}).RequireAuthorization();
record SmsTwoFaSetupRequest(string PhoneNumber);
app.MapPost("/account/2fa/sms/enable",
async (SmsTwoFaEnableRequest req,
HttpContext ctx,
UserManager<AppUser> userManager) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
// Підтверджуємо номер та встановлюємо як верифікований
var result = await userManager.ChangePhoneNumberAsync(
user, req.PhoneNumber, req.Code);
if (!result.Succeeded)
return Results.BadRequest(new
{
errors = result.Errors.Select(e => e.Description)
});
// Встановлюємо провайдер 2FA на Phone
await userManager.SetTwoFactorEnabledAsync(user, true);
// Генеруємо резервні коди
var recoveryCodes = await userManager
.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
return Results.Ok(new
{
message = "SMS two-factor authentication enabled.",
recoveryCodes = recoveryCodes // ⚠️ Показати один раз!
});
}).RequireAuthorization();
record SmsTwoFaEnableRequest(string PhoneNumber, string Code);
app.MapPost("/auth/login/2fa/sms",
async (SmsTwoFaLoginRequest req,
UserManager<AppUser> userManager,
ISmsSender smsSender,
TokenService tokenService) =>
{
var user = await userManager.FindByIdAsync(req.UserId);
if (user is null)
return Results.Json(
new { error = "Invalid session" }, statusCode: 401);
// Спочатку генеруємо і надсилаємо SMS
// (якщо це перший запит до 2FA SMS ендпоінту)
if (req.SendCode == true)
{
var code = await userManager.GenerateTwoFactorTokenAsync(
user, TokenOptions.DefaultPhoneProvider);
await smsSender.SendSmsAsync(user.PhoneNumber!, code);
return Results.Ok(new
{
message = $"Code sent to {user.PhoneNumber!.Substring(0, 4)}***"
});
}
// Перевіряємо введений код
var isValid = await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultPhoneProvider, req.Code!);
if (!isValid)
return Results.Json(
new { error = "Invalid or expired code." }, statusCode: 401);
var roles = await userManager.GetRolesAsync(user);
var token = tokenService.GenerateToken(user, roles);
return Results.Ok(new { access_token = token });
});
record SmsTwoFaLoginRequest(
string UserId, string? Code, bool? SendCode);
6. Recovery Codes: резервні коди
Навіщо резервні коди?
Уявіть: ви увімкнули TOTP 2FA. Через рік ваш телефон зламався — і разом із ним ваш додаток аутентифікатора. Без резервних кодів ви назавжди втратили доступ до свого акаунту. Резервні коди — це страхова сітка від втрати другого фактора.
Типова схема: 10 одноразових кодів вигляду XXXX-XXXXXXXX. Кожен код — окрема рядок у базі даних, яка видаляється після використання.
Як зберігаються Recovery Codes?
Identity зберігає резервні коди у таблиці AspNetUserTokens, але не у відкритому вигляді — лише їх хеші. Це важливо: навіть якщо база даних буде скомпрометована, зловмисник не зможе використати вкрадені хеші для входу.
LoginProvider: "[AspNetUserStore]"
Name: "RecoveryCodes"
Value: "hash1;hash2;hash3;..." (хеші через крапку з комою)
Генерація та відображення
app.MapPost("/account/2fa/recovery-codes/generate",
async (HttpContext ctx,
UserManager<AppUser> userManager) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
if (!await userManager.GetTwoFactorEnabledAsync(user))
return Results.BadRequest(new
{
error = "Two-factor authentication must be enabled first."
});
// Генеруємо 10 нових кодів
// Старі коди при цьому АНУЛЮЮТЬСЯ — повернення до старих неможливе
var recoveryCodes = await userManager
.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
// Підраховуємо скільки кодів залишилось (для попередження UI)
var remainingCount = await userManager
.CountRecoveryCodesAsync(user);
return Results.Ok(new
{
recoveryCodes = recoveryCodes, // ПОКАЗАТИ ОДИН РАЗ!
count = recoveryCodes!.Count(),
remainingAfter = remainingCount,
warning = "These codes will not be shown again. " +
"Store them in a secure location."
});
}).RequireAuthorization();
app.MapGet("/account/2fa/recovery-codes/count",
async (HttpContext ctx,
UserManager<AppUser> userManager) =>
{
var user = await userManager.GetUserAsync(ctx.User);
if (user is null) return Results.Unauthorized();
var count = await userManager.CountRecoveryCodesAsync(user);
return Results.Ok(new
{
remaining = count,
// Попередження при малій кількості кодів
warning = count < 3
? "You have very few recovery codes left. Generate new ones!"
: null
});
}).RequireAuthorization();
Вхід за допомогою Recovery Code
app.MapPost("/auth/login/2fa/recovery-code",
async (RecoveryCodeLoginRequest req,
UserManager<AppUser> userManager,
TokenService tokenService) =>
{
var user = await userManager.FindByIdAsync(req.UserId);
if (user is null)
return Results.Json(
new { error = "Invalid session" }, statusCode: 401);
// Нормалізуємо код: прибираємо дефіси та пробіли
var recoveryCode = req.RecoveryCode.Replace("-", "").Replace(" ", "");
// RedeemTwoFactorRecoveryCodeAsync:
// 1. Знаходить хеш коду у AspNetUserTokens
// 2. При успіху — ВИДАЛЯЄ використаний код
// 3. Код більше не дійсний після першого використання!
var result = await userManager
.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode);
if (!result.Succeeded)
return Results.Json(
new
{
error = "Invalid recovery code. " +
"Each code can only be used once."
},
statusCode: 401);
// Перевіряємо скільки кодів залишилось — попереджаємо якщо мало
var remainingCodes = await userManager
.CountRecoveryCodesAsync(user);
var roles = await userManager.GetRolesAsync(user);
var token = tokenService.GenerateToken(user, roles);
return Results.Ok(new
{
access_token = token,
remainingRecoveryCodes = remainingCodes,
warning = remainingCodes < 3
? "You are running low on recovery codes. Please generate new ones."
: null
});
});
record RecoveryCodeLoginRequest(string UserId, string RecoveryCode);
7. Порівняльна таблиця методів 2FA
| Метод | Безпека | Зручність | Потрібне обладнання | Вразливість |
|---|---|---|---|---|
| TOTP (Authenticator) | ⭐⭐⭐⭐ | ⭐⭐⭐ | Смартфон + додаток | Malware на телефоні |
| Hardware Key (FIDO2) | ⭐⭐⭐⭐⭐ | ⭐⭐ | YubiKey (~$30-50) | Фізична втрата ключа |
| Email OTP | ⭐⭐⭐ | ⭐⭐⭐⭐ | Email доступ | Злам email-акаунту |
| SMS OTP | ⭐⭐ | ⭐⭐⭐⭐⭐ | Телефон | SIM-swap, SS7 атаки |
| Recovery Codes | ⭐⭐⭐ | N/A | Безпечне місце зберігання | Фізична крадіжка |
8. Remember Machine: «Не питати 2FA 30 днів»
Вимагати 2FA при кожному вході — безпечно, але незручно на пристроях, якими користувач довіряє. Функція «Remember this machine» дозволяє пропустити 2FA на впізнаному пристрої.
Як це працює?
Identity зберігає у браузері відокремлений cookie (не основний auth cookie), що свідчить: «цей пристрій вже пройшов 2FA». При наступному вході перевіряється наявність цього cookie.
app.MapPost("/auth/login/2fa",
async (TwoFaLoginV2Request req,
HttpContext ctx,
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
TokenService tokenService) =>
{
var user = await userManager.FindByIdAsync(req.UserId);
if (user is null)
return Results.Json(
new { error = "Invalid session" }, statusCode: 401);
// Перевіряємо TOTP-код
var code = req.Code.Replace(" ", "").Replace("-", "");
var isValid = await userManager.VerifyTwoFactorTokenAsync(
user,
userManager.Options.Tokens.AuthenticatorTokenProvider,
code);
if (!isValid)
return Results.Json(
new { error = "Invalid 2FA code" }, statusCode: 401);
// Якщо користувач обрав "Remember this device"
if (req.RememberDevice == true)
{
// Зберігаємо cookie-маркер для цього пристрою
await signInManager.RememberTwoFactorClientAsync(user);
}
var roles = await userManager.GetRolesAsync(user);
var token = tokenService.GenerateToken(user, roles);
return Results.Ok(new { access_token = token });
});
record TwoFaLoginV2Request(
string UserId,
string Code,
bool? RememberDevice);
app.MapPost("/auth/login",
async (LoginRequest req,
HttpContext ctx,
SignInManager<AppUser> signInManager,
UserManager<AppUser> userManager,
TokenService tokenService) =>
{
var user = await userManager.FindByEmailAsync(req.Email);
if (user is null) return Results.Unauthorized();
var passwordResult = await signInManager
.CheckPasswordSignInAsync(user, req.Password, true);
if (!passwordResult.Succeeded) return Results.Unauthorized();
// Чи потрібна 2FA?
var twoFactorEnabled = await userManager
.GetTwoFactorEnabledAsync(user);
if (twoFactorEnabled)
{
// Перевіряємо, чи пристрій вже запам'ятований
var isClientRemembered = await signInManager
.IsTwoFactorClientRememberedAsync(user);
if (!isClientRemembered)
{
// 2FA потрібна
return Results.Ok(new
{
requiresTwoFactor = true,
userId = user.Id
});
}
// Пристрій запам'ятований — пропускаємо 2FA
}
var roles = await userManager.GetRolesAsync(user);
var token = tokenService.GenerateToken(user, roles);
return Results.Ok(new { access_token = token });
});
9. Практичні завдання
Рівень 1: Базовий
Реалізуйте повний setup TOTP:
GET /account/2fa/setup— повертаєsharedKeyтаauthenticatorUri- Скопіюйте
authenticatorUriта перейдіть на https://stefansundin.github.io/2fa-qr/ щоб побачити QR-код - Відскануйте у Google Authenticator або Microsoft Authenticator
POST /account/2fa/enable {code}— підтвердіть перший код із додатка- Перевірте, що
AspNetUsers.TwoFactorEnabledсталоtrueу БД
Реалізуйте двокроковий логін:
POST /auth/login— перевіряє пароль; якщо 2FA увімкнена — повертає{requiresTwoFactor: true, userId}POST /auth/login/2fa {userId, code}— перевіряє TOTP-код і повертає JWT- Протестуйте обидва ендпоінти послідовно через curl або bruno
- Що відбувається, якщо ввести невірний TOTP-код 5 разів? (lockout)
Рівень 2: Проєктування
Реалізуйте управління резервними кодами:
POST /account/2fa/recovery-codes/generate— 10 нових кодівGET /account/2fa/recovery-codes/count— скільки залишилосьPOST /auth/login/2fa/recovery-code {userId, code}— вхід з резервним кодом- Переконайтеся, що код стає недійсним після використання
- Виведіть попередження у відповіді, якщо залишилось менше 3 кодів
Реалізуйте Email OTP як другий фактор:
- Налаштуйте
EmailTokenProviderз TTL = 10 хвилин - При першому кроці (
POST /auth/login) — якщо 2FA-Email — генеруйте та надсилайте код POST /auth/login/2fa/email {userId, code}— перевірка- Порівняйте: чи можна використати один Email код двічі? Чому?
Рівень 3: Архітектура
Побудуйте повноцінну систему 2FA з вибором методу:
- Додайте до
AppUserполеTwoFactorMethod(Totp,Email,Sms,None) - При вході — логіка вибору правильного провайдера на основі методу користувача
- Реалізуйте у
AppUserможливість мати кілька методів 2FA одночасно (наприклад, TOTP основний + Email резервний) - UI-подібне endpoint
GET /account/2fa/status— повертає статус кожного методу (enabled/disabled) та кількість резервних кодів - Напишіть unit-тести для логіки вибору методу
10. Резюме
TOTP — найнадійніший
GetAuthenticatorKeyAsync + VerifyTwoFactorTokenAsync.Recovery Codes — страховка
GenerateNewTwoFactorRecoveryCodesAsync анулює старі.Email/SMS — простіше, але менш безпечно
EmailTokenProvider, SMS: PhoneNumberTokenProvider. Вразливі до злому email/SIM-swap.Remember Machine — зручність vs безпека
RememberTwoFactorClientAsync зберігає cookie-маркер пристрою. Розумний компроміс: 30 днів для особистих пристроїв.Далі: у наступній статті ми зануримось у внутрішню архітектуру Identity — SecurityStamp, кастомізацію Claims через IUserClaimsPrincipalFactory, провайдери токенів та обробку помилок.
Identity: Підтвердження Email та Скидання Пароля
Детальний розбір Email Confirmation та Password Reset в ASP.NET Core Identity: токени, AspNetUserTokens, IEmailSender, timing attack prevention, зміна email та телефону.
Identity: Внутрішня Архітектура та Кастомізація
SecurityStamp та примусовий вихід, ConcurrencyStamp, AspNetUserTokens, кастомні провайдери токенів, IUserClaimsPrincipalFactory, Account Lockout, IdentityOptions та локалізація помилок.