119 lines
4.1 KiB
PHP
119 lines
4.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Facades\Audit;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Services\Auth\TwoFactorService;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* Многофакторная аутентификация по TOTP (мера ИАФ.4).
|
|
*
|
|
* Поток:
|
|
* setup -> показать секрет/QR (сохраняется во временной сессии);
|
|
* enable -> подтвердить кодом, включить MFA, выдать резервные коды;
|
|
* challenge/verify -> ввод кода при каждом входе;
|
|
* disable -> отключение (требует подтверждения паролем — см. маршрут).
|
|
*/
|
|
class TwoFactorController extends Controller
|
|
{
|
|
public function __construct(private readonly TwoFactorService $twoFactor) {}
|
|
|
|
public function setup(Request $request): Response
|
|
{
|
|
$secret = $request->session()->get('mfa.pending_secret')
|
|
?? $this->twoFactor->generateSecret();
|
|
|
|
$request->session()->put('mfa.pending_secret', $secret);
|
|
|
|
return Inertia::render('auth/TwoFactorSetup', [
|
|
'secret' => $secret,
|
|
'qr' => $this->twoFactor->provisioningUri($request->user(), $secret),
|
|
]);
|
|
}
|
|
|
|
public function enable(Request $request): RedirectResponse
|
|
{
|
|
$request->validate(['code' => ['required', 'string']]);
|
|
|
|
$secret = (string) $request->session()->get('mfa.pending_secret');
|
|
|
|
if ($secret === '' || ! $this->twoFactor->verify($secret, $request->string('code'))) {
|
|
throw ValidationException::withMessages(['code' => 'Неверный код подтверждения.']);
|
|
}
|
|
|
|
$recoveryCodes = $this->twoFactor->generateRecoveryCodes();
|
|
|
|
$user = $request->user();
|
|
$user->mfa_secret = $secret;
|
|
$user->setRecoveryCodes($this->twoFactor->hashRecoveryCodes($recoveryCodes));
|
|
$user->mfa_confirmed_at = now();
|
|
$user->save();
|
|
|
|
$request->session()->forget('mfa.pending_secret');
|
|
$request->session()->put('mfa.verified', true);
|
|
|
|
Audit::log('auth.mfa.enabled', 'enable', 'User:'.$user->id);
|
|
|
|
// Резервные коды показываем один раз — пользователь обязан их сохранить.
|
|
return redirect()->route('dashboard')->with('recoveryCodes', $recoveryCodes);
|
|
}
|
|
|
|
public function challenge(): Response
|
|
{
|
|
return Inertia::render('auth/TwoFactorChallenge');
|
|
}
|
|
|
|
public function verify(Request $request): RedirectResponse
|
|
{
|
|
$request->validate(['code' => ['required', 'string']]);
|
|
|
|
$user = $request->user();
|
|
$code = $request->string('code');
|
|
|
|
if ($this->twoFactor->verify((string) $user->mfa_secret, $code)) {
|
|
$request->session()->put('mfa.verified', true);
|
|
|
|
return redirect()->intended(route('dashboard', absolute: false));
|
|
}
|
|
|
|
// Попытка по резервному коду.
|
|
$remaining = $this->twoFactor->consumeRecoveryCode($user->recoveryCodes(), $code);
|
|
|
|
if ($remaining !== null) {
|
|
$user->setRecoveryCodes($remaining);
|
|
$user->save();
|
|
$request->session()->put('mfa.verified', true);
|
|
|
|
return redirect()->intended(route('dashboard', absolute: false));
|
|
}
|
|
|
|
Audit::log('auth.mfa.failed', 'verify', 'User:'.$user->id, 'failure');
|
|
|
|
throw ValidationException::withMessages(['code' => 'Неверный код.']);
|
|
}
|
|
|
|
public function disable(Request $request): RedirectResponse
|
|
{
|
|
$request->validate(['password' => ['required', 'current_password']]);
|
|
|
|
$user = $request->user();
|
|
$user->forceFill([
|
|
'mfa_secret' => null,
|
|
'mfa_recovery_codes' => null,
|
|
'mfa_confirmed_at' => null,
|
|
])->save();
|
|
|
|
$request->session()->forget('mfa.verified');
|
|
|
|
Audit::log('auth.mfa.disabled', 'disable', 'User:'.$user->id);
|
|
|
|
return back();
|
|
}
|
|
}
|