Реализация двухфакторной аутентификации (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 + challenge-страница | 1 день |
| Резервные коды + повторное использование | 0.5 дня |
| UI (React/Vue) + тесты | 1.5 дня |
Итого: 4–5 рабочих дней.







