Files
portal/app/app/Http/Controllers/Api/TwoFactorController.php
T
Дмитрий 2225a8487e
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(security): рабочая «Завершить сессию» — реальный отзыв активных сессий
UI-аудит: вкладка Безопасность показывала фейк-сессии с мёртвой кнопкой.
Теперь — реальные активные сессии + рабочий отзыв.

- UserSessionTracker (новый): запись сессии при входе (login + 2FA verify +
  recovery-use) в существующую таблицу user_sessions; отзыв = удаление строки
  + удаление сессии из Redis по session_id (реальный выход с устройства);
  logout снимает текущую сессию из списка. Best-effort (не ломает вход/выход).
- AccountController: GET /api/account/security отдаёт реальные сессии;
  DELETE /api/account/sessions/{id} — отзыв (только свои; чужая → 404).
- Фронт SessionsTable: список + кнопка «Завершить» (кроме текущей).
- phpstan-baseline обновлён (Pest-$this нового теста).

Pest: 10/10. Верификация: Playwright (2 сессии → «Завершить» → исчезла).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:15:26 +03:00

264 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\UseRecoveryCodeRequest;
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
use App\Models\User;
use App\Models\UserRecoveryCode;
use App\Services\UserSessionTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use PragmaRX\Google2FA\Google2FA;
/**
* 2FA login-flow — TOTP verify + recovery code use.
* Извлечено из AuthController (Sprint 3 Phase B, audit O-refactor-02).
*
* Setup / enable / disable / regenerate-recovery-codes — в TwoFactorSetupController
* (отдельный класс, route prefix /api/2fa, требует auth:sanctum).
*
* Эти endpoint'ы (verify, recovery-use) — публичные (нет полноценной session-auth
* до verify): user проходит email+password в AuthController::login, который
* сохраняет pending_user_id в session, затем сюда — для завершения login.
*
* Rate-limit (ТЗ §22.4.4):
* - 2FA verify: 5 попыток / 15 мин по pending_user_id|ip.
* - Recovery use: 5 попыток / 15 мин по pending_user_id|ip (отдельный scope).
*/
class TwoFactorController extends Controller
{
use WritesAuthLog;
/** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
private const LOGIN_MAX_ATTEMPTS = 5;
/** Окно блокировки в секундах (ТЗ §22.4.4: 15 мин). */
private const LOGIN_DECAY_SECONDS = 900;
public function verifyTwoFactor(VerifyTwoFactorRequest $request): JsonResponse
{
$pendingUserId = $request->session()->get('auth.pending_user_id');
$remember = (bool) $request->session()->get('auth.pending_remember', false);
if (! $pendingUserId) {
return response()->json([
'message' => 'Сессия 2FA истекла. Войдите снова.',
], 422);
}
$throttleKey = $this->twoFactorThrottleKey((int) $pendingUserId, $request->ip());
if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) {
return $this->lockoutResponse($throttleKey);
}
$user = User::find($pendingUserId);
if (! $user || ! $user->totp_enabled || empty($user->totp_secret)) {
return response()->json([
'message' => 'Сессия 2FA недействительна.',
], 422);
}
$google2fa = new Google2FA;
// Окно ±1 (30 сек до/после) — компенсирует clock-skew между сервером и
// приложением аутентификатора. По ТЗ §1.6 / Прил. Г.4.2.
$valid = $google2fa->verifyKey((string) $user->totp_secret, $request->string('code')->toString(), 1);
if (! $valid) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'2fa_verify_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'invalid_code',
);
return response()->json([
'message' => 'Неверный код. Проверьте время на устройстве и попробуйте снова.',
'errors' => ['code' => ['Неверный код.']],
], 422);
}
// 2FA пройдена → чистим throttle + залогиниваем + чистим pending session.
RateLimiter::clear($throttleKey);
Auth::login($user, $remember);
$request->session()->regenerate();
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
$user->update(['last_login_at' => now()]);
app(UserSessionTracker::class)->record($request, $user->id);
$this->logAuthEvent(
'2fa_verify_success',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
]);
}
/**
* POST /api/auth/2fa/recovery-use — вход по резервному коду вместо TOTP.
*
* Flow (продолжение login → pending_user_id в session):
* 1. Из session берём pending_user_id (тот же, что для /2fa/verify).
* 2. Перебираем все НЕиспользованные user_recovery_codes этого user'а,
* для каждого делаем `Hash::check($plainCode, $codeHash)`.
* 3. На совпадении → mark used_at + Auth::login + clear pending.
* 4. Иначе → 422 + RateLimiter::hit.
*
* Rate-limit: 5/15мин по pending_user_id|ip (тот же ключ, что 2fa/verify
* был бы избыточен — но физически разные scope'ы).
*
* Recovery code хранится в bcrypt-хеше (security: даже при утечке БД
* нельзя восстановить plain-код), сравнение через Hash::check.
*/
public function useRecoveryCode(UseRecoveryCodeRequest $request): JsonResponse
{
$pendingUserId = $request->session()->get('auth.pending_user_id');
$remember = (bool) $request->session()->get('auth.pending_remember', false);
if (! $pendingUserId) {
return response()->json([
'message' => 'Сессия 2FA истекла. Войдите снова.',
], 422);
}
$throttleKey = 'auth:recovery:'.$pendingUserId.'|'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) {
return $this->lockoutResponse($throttleKey);
}
$user = User::find($pendingUserId);
if (! $user) {
return response()->json([
'message' => 'Сессия 2FA недействительна.',
], 422);
}
// Нормализуем: убираем дефисы и пробелы, lower-case (генерация в setup
// wizard'е тоже нормализует — единый формат сравнения).
$plainCode = mb_strtolower(preg_replace('/[\s\-]+/', '', $request->string('code')->toString()) ?? '');
$unusedCodes = UserRecoveryCode::query()
->where('user_id', $user->id)
->whereNull('used_at')
->get();
$matched = null;
foreach ($unusedCodes as $row) {
if (Hash::check($plainCode, $row->code_hash)) {
$matched = $row;
break;
}
}
if (! $matched) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'2fa_recovery_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'invalid_or_used',
);
return response()->json([
'message' => 'Резервный код недействителен или уже использован.',
'errors' => ['code' => ['Резервный код недействителен или уже использован.']],
], 422);
}
// Пометить код использованным + завершить login.
$matched->update(['used_at' => now()]);
RateLimiter::clear($throttleKey);
Auth::login($user, $remember);
$request->session()->regenerate();
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
$user->update(['last_login_at' => now()]);
app(UserSessionTracker::class)->record($request, $user->id);
$this->logAuthEvent(
'2fa_recovery_used',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
// Кол-во оставшихся неиспользованных кодов — для UI-warning'а
// ("осталось 3 из 8 — рекомендуем перегенерировать").
$remaining = UserRecoveryCode::query()
->where('user_id', $user->id)
->whereNull('used_at')
->count();
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
'recovery_codes_remaining' => $remaining,
]);
}
/** Ключ throttle для 2FA verify: pending user_id + IP. */
private function twoFactorThrottleKey(int $userId, ?string $ip): string
{
return 'auth:2fa:'.$userId.'|'.($ip ?? 'unknown');
}
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
private function lockoutResponse(string $throttleKey): JsonResponse
{
$retryAfter = RateLimiter::availableIn($throttleKey);
return response()->json([
'message' => "Слишком много попыток. Попробуйте через {$retryAfter} сек.",
'retry_after' => $retryAfter,
], 429)->header('Retry-After', (string) $retryAfter);
}
/** @return array<string, mixed> */
private function userResource(User $user): array
{
return [
'id' => $user->id,
'email' => $user->email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'tenant_id' => $user->tenant_id,
'totp_enabled' => $user->totp_enabled,
'last_login_at' => $user->last_login_at,
'notification_preferences' => $user->notification_preferences,
'sound_enabled' => $user->sound_enabled,
];
}
}