2225a8487e
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>
264 lines
10 KiB
PHP
264 lines
10 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|