Реалізація двофакторної аутентифікації (2FA/TOTP) на веб-сайті
Двофакторна аутентифікація через TOTP (Time-based One-Time Password) — другий фактор у вигляді 6-значного коду з додатка-аутентифікатора (Google Authenticator, Authy, 1Password тощо). Коди генеруються за алгоритмом RFC 6238: HMAC-SHA1 від поточного часу та таємного ключа, оновлюються кожні 30 секунд.
Технологічний стек
- Алгоритм: TOTP (RFC 6238), HOTP (RFC 4226)
- PHP бібліотека:
pragmarx/google2faабоsonata-project/google-authenticator - QR-код:
bacon/bacon-qr-code - Зберігання: зашифрований таємний ключ у БД
Схема включення 2FA
1. Користувач натискає "Включити 2FA"
2. Сервер генерує секрет (base32, 160 бітів)
3. Сервер повертає QR-код та резервні коди
4. Користувач сканує QR у додатку-аутентифікаторі
5. Користувач вводить перший код для підтвердження
6. Сервер зберігає секрет як активний
Генерація секрету та QR-коду
composer require pragmarx/google2fa bacon/bacon-qr-code
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class TwoFactorSetupController extends Controller
{
public function setup(Request $request): JsonResponse
{
$google2fa = new Google2FA();
$secret = $google2fa->generateSecretKey(32);
// Тимчасово зберігати в сесії до підтвердження
session(['2fa_pending_secret' => $secret]);
$otpauthUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$request->user()->email,
$secret
);
$renderer = new ImageRenderer(
new RendererStyle(200),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qrSvg = $writer->writeString($otpauthUrl);
return response()->json([
'secret' => $secret,
'qr_code' => base64_encode($qrSvg),
'qr_uri' => $otpauthUrl,
]);
}
}
Підтвердження та активація
public function confirm(Request $request): JsonResponse
{
$request->validate(['code' => 'required|digits:6']);
$secret = session('2fa_pending_secret');
$google2fa = new Google2FA();
if (!$google2fa->verifyKey($secret, $request->code)) {
return response()->json(['message' => 'Невірний код'], 422);
}
// Зберегти зашифрований секрет
$request->user()->update([
'two_factor_secret' => encrypt($secret),
'two_factor_enabled_at' => now(),
]);
// Згенерувати резервні коди
$recoveryCodes = $this->generateRecoveryCodes();
$request->user()->update([
'two_factor_recovery_codes' => encrypt(json_encode($recoveryCodes)),
]);
session()->forget('2fa_pending_secret');
return response()->json(['recovery_codes' => $recoveryCodes]);
}
private function generateRecoveryCodes(): array
{
return collect(range(1, 8))->map(fn() =>
Str::random(5) . '-' . Str::random(5)
)->toArray();
}
Middleware для перевірки 2FA
Після базової аутентифікації користувач з увімкненою 2FA повинен пройти другий фактор:
class TwoFactorMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user && $user->two_factor_enabled_at && !session('2fa_verified')) {
return redirect()->route('2fa.challenge');
}
return $next($request);
}
}
class TwoFactorChallengeController extends Controller
{
public function verify(Request $request): RedirectResponse
{
$request->validate(['code' => 'required|string']);
$user = $request->user();
$google2fa = new Google2FA();
$secret = decrypt($user->two_factor_secret);
$code = $request->code;
// Перевірити TOTP-код
$validTotp = $google2fa->verifyKey($secret, $code, 1); // window=1 (±30 сек)
// Якщо не TOTP — перевірити резервний код
if (!$validTotp) {
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
if (!in_array($code, $recoveryCodes, true)) {
return back()->withErrors(['code' => 'Невірний код']);
}
// Видалити використаний резервний код
$remaining = array_filter($recoveryCodes, fn($c) => $c !== $code);
$user->update([
'two_factor_recovery_codes' => encrypt(json_encode(array_values($remaining))),
]);
}
session(['2fa_verified' => true]);
return redirect()->intended('/dashboard');
}
}
Захист від повторного використання коду
TOTP-коди діють 30 секунд. Щоб виключити повторне використання одного коду:
// Зберігати хеш останнього використаного коду
$lastUsed = Cache::get("2fa_last_used:{$user->id}");
$currentHash = hash('sha256', $secret . $code . floor(time() / 30));
if ($lastUsed === $currentHash) {
return response()->json(['message' => 'Код уже був використаний'], 422);
}
Cache::put("2fa_last_used:{$user->id}", $currentHash, 60);
Резервні коди
8 одноразових кодів у форматі xxxxx-xxxxx. Показуються один раз під час налаштування — користувач повинен їх зберегти. При використанні резервного коду видаліти його зі списку. Коли залишається менше 2 кодів — повідомити користувача про необхідність створити нові.
Тимчасовість роботи
| Етап | Час |
|---|---|
| Генерація секрету + QR-код | 1 день |
| Підтвердження + збереження | 0,5 дня |
| Middleware + сторінка виклику | 1 день |
| Резервні коди + захист від повторення | 0,5 дня |
| UI (React/Vue) + тести | 1,5 дня |
Всього: 4–5 робочих днів.







