refactor(backend): Sprint 3 Phase B — AuthController split (3 classes by analogy)

Sprint 3 Phase B. Закрытие audit O-refactor-02:

- O-refactor-02: AuthController (595 строк) → split на 3 класса (по аналогии с
  Sprint 3 Phase A DealController split):
  * AuthController (343) — core auth: login/register/logout/me +
    updateNotificationPreferences + helpers (loginThrottleKey, isIpLockedOut,
    maybeNotifySuspiciousLogin, logAuthEvent, lockoutResponse, userResource)
  * TwoFactorController (217, новый) — 2FA verify + recovery codes use
    (login-flow продолжение). Setup/enable/disable остались в
    TwoFactorSetupController (с Sprint 1).
  * PasswordResetController (146, новый) — forgot/reset password

API endpoints без изменений (только routing — controller@method обновлён в web.php):
- POST /api/auth/2fa/verify         → TwoFactorController@verifyTwoFactor
- POST /api/auth/2fa/recovery-use   → TwoFactorController@useRecoveryCode
- POST /api/auth/forgot             → PasswordResetController@forgotPassword
- POST /api/auth/reset-password     → PasswordResetController@resetPassword

Helpers lockoutResponse и userResource дублируются между классами (1:1) — по
принципу Phase A: «копируй методы 1:1, не оптимизировать на ходу». Будущая
итерация может вынести в trait/base controller, если появится 4-й класс.

Pest: 416/416 PASS + 2 skipped.
Larastan: 0 errors.
Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-09 20:14:33 +03:00
parent 86dc930915
commit cd13e6c8bb
4 changed files with 414 additions and 299 deletions
+43 -295
View File
@@ -5,16 +5,11 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\UseRecoveryCodeRequest;
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
use App\Mail\SuspiciousLoginNotification;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserRecoveryCode;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -22,12 +17,15 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use PragmaRX\Google2FA\Google2FA;
/**
* Аутентификация через Sanctum SPA mode (cookie-based session) + 2FA TOTP.
* Аутентификация через Sanctum SPA mode (cookie-based session).
* Core auth flow: login / register / logout / me + notification preferences.
*
* Sprint 3 Phase B (audit O-refactor-02): 2FA verify/recovery вынесены в
* TwoFactorController, password reset в PasswordResetController.
* Setup 2FA TwoFactorSetupController (был отдельным ещё с Sprint 1).
*
* Flow без 2FA:
* 1. POST /api/auth/login создаёт session, возвращает {user, requires_2fa: false}.
@@ -35,17 +33,15 @@ use PragmaRX\Google2FA\Google2FA;
* Flow с 2FA (totp_enabled=true):
* 1. POST /api/auth/login проверяет email+password, сохраняет pending_user_id
* в session, **НЕ** делает Auth::login. Возвращает {user, requires_2fa: true}.
* 2. POST /api/auth/2fa/verify {code} проверяет TOTP против users.totp_secret,
* окно ±1 (30 сек до/после). Если ок Auth::login + clear pending_user_id.
* 2. POST /api/auth/2fa/verify {code} TwoFactorController, проверяет TOTP
* против users.totp_secret, окно ±1 (30 сек до/после). Если ок Auth::login.
*
* Rate-limit (ТЗ §22.4.4):
* - Login: 5 попыток / 15 мин по ключу email|ip. Превышение 429 + Retry-After.
* - 2FA verify: 5 попыток / 15 мин по pending_user_id из session.
* - IP-lockout: 10 неудач/час с одного IP через auth_log 429 + Retry-After: 3600.
* - Email-уведомление при ровно 3 неудачах входа на email SuspiciousLoginNotification.
*
* Не входит:
* - Recovery codes (XXXX-XXXX) отдельный endpoint /2fa/recovery-use.
* - Yandex 360 SSO.
* - Tenant-wizard при register (на MVP first tenant attach).
*/
@@ -132,59 +128,6 @@ class AuthController extends Controller
]);
}
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);
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()]);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
]);
}
public function register(RegisterRequest $request): JsonResponse
{
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
@@ -236,191 +179,52 @@ class AuthController extends Controller
}
/**
* POST /api/auth/2fa/recovery-use вход по резервному коду вместо TOTP.
* PATCH /api/auth/me/notification-preferences сохранить матрицу
* 8 событий × 3 каналов (inapp/push/email) + sound_enabled.
*
* 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.
* Источник: schema.sql:699 users.notification_preferences JSONB DEFAULT.
* Валидация: события NotificationService::ALL_EVENTS, каналы
* {inapp, push, email}. Незадекларированные ключи отбрасываются.
*/
public function useRecoveryCode(UseRecoveryCodeRequest $request): JsonResponse
public function updateNotificationPreferences(Request $request): JsonResponse
{
$pendingUserId = $request->session()->get('auth.pending_user_id');
$remember = (bool) $request->session()->get('auth.pending_remember', false);
$validated = $request->validate([
'prefs' => 'required|array',
'prefs.*' => 'array',
'sound_enabled' => 'nullable|boolean',
]);
if (! $pendingUserId) {
return response()->json([
'message' => 'Сессия 2FA истекла. Войдите снова.',
], 422);
}
$allEvents = NotificationService::ALL_EVENTS;
$allChannels = [
NotificationService::CHANNEL_INAPP,
NotificationService::CHANNEL_PUSH,
NotificationService::CHANNEL_EMAIL,
];
$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;
// Очищенная матрица (только known events × known channels).
$sanitized = [];
foreach ($validated['prefs'] as $event => $channelPrefs) {
if (! in_array($event, $allEvents, true)) {
continue;
}
$sanitized[$event] = [];
foreach ($allChannels as $channel) {
if (isset($channelPrefs[$channel])) {
$sanitized[$event][$channel] = (bool) $channelPrefs[$channel];
}
}
}
if (! $matched) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
return response()->json([
'message' => 'Резервный код недействителен или уже использован.',
'errors' => ['code' => ['Резервный код недействителен или уже использован.']],
], 422);
/** @var User $user */
$user = $request->user();
$update = ['notification_preferences' => $sanitized];
if (array_key_exists('sound_enabled', $validated)) {
$update['sound_enabled'] = (bool) $validated['sound_enabled'];
}
// Пометить код использованным + завершить 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()]);
// Кол-во оставшихся неиспользованных кодов — для UI-warning'а
// ("осталось 3 из 8 — рекомендуем перегенерировать").
$remaining = UserRecoveryCode::query()
->where('user_id', $user->id)
->whereNull('used_at')
->count();
$user->update($update);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
'recovery_codes_remaining' => $remaining,
]);
}
/**
* POST /api/auth/forgot запрос ссылки на сброс пароля (ТЗ §1.7).
*
* Anti-enumeration: ВСЕГДА возвращаем 200 с unified message, независимо
* от того, существует ли user. Это защищает от перебора email'ов.
*
* Rate-limit (ТЗ §22.4.4 + ТЗ §1.7): 5 попыток / 15 мин по ключу email|ip.
* На превышении 429 + Retry-After.
*
* Laravel `Password::sendResetLink` под капотом:
* 1. Ищет user по email через `users` provider (UserProvider).
* 2. Если найден генерирует token, сохраняет в `password_resets`
* (env AUTH_PASSWORD_RESET_TOKEN_TABLE), отправляет Notification.
* 3. На dev-стеке MAIL_MAILER=log notification пишется в storage/logs/laravel.log.
* 4. Если не найден silently skip (anti-enumeration).
*
* Reset-endpoint POST /api/auth/reset-password отдельный коммит (требует
* UI-формы для new_password + token-валидации из email-ссылки).
*/
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
{
$email = mb_strtolower($request->string('email')->toString());
$throttleKey = 'auth:forgot:'.$email.'|'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) {
return $this->lockoutResponse($throttleKey);
}
// Hit ставится ДО вызова Password::sendResetLink — чтобы счётчик
// увеличивался даже при unknown email (иначе можно перебирать вечно).
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
Password::sendResetLink(['email' => $email]);
// Unified ответ независимо от наличия user'а.
return response()->json([
'message' => 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.',
]);
}
/**
* POST /api/auth/reset-password установка нового пароля по токену.
*
* Token приходит из email-ссылки (формируется Laravel ResetPassword Notification).
* `Password::reset()` под капотом:
* 1. Ищет user по email.
* 2. Проверяет hashed token из password_resets (TTL 60 мин по config/auth.php).
* 3. Вызывает callback с (user, password) пишем `password_hash` (наша колонка).
* 4. Удаляет row из password_resets.
*
* После успешного reset invalidate всех существующих сессий через
* `Auth::logoutOtherDevices` (требовало бы пароль) для MVP опускаем,
* но регенерируем remember_token (если есть).
*
* Reset rate-limit (anti-token-brute) 5 попыток / 15 мин по token-prefix + ip.
*/
public function resetPassword(ResetPasswordRequest $request): JsonResponse
{
$email = mb_strtolower($request->string('email')->toString());
$token = $request->string('token')->toString();
// Throttle key — token (полный, не префикс) + ip. Анти-перебор: попытки
// менее чем за 15 мин по одному и тому же token блокируются.
$throttleKey = 'auth:reset:'.substr(hash('sha256', $token), 0, 16).'|'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) {
return $this->lockoutResponse($throttleKey);
}
$status = Password::reset(
[
'email' => $email,
'password' => $request->string('password')->toString(),
'password_confirmation' => $request->string('password_confirmation')->toString(),
'token' => $token,
],
function (User $user, string $password): void {
$user->forceFill([
'password_hash' => Hash::make($password),
])->save();
}
);
if ($status !== Password::PASSWORD_RESET) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
return response()->json([
'message' => 'Ссылка для сброса недействительна или истекла. Запросите новую.',
'errors' => ['email' => ['Ссылка для сброса недействительна или истекла.']],
], 422);
}
RateLimiter::clear($throttleKey);
return response()->json([
'message' => 'Пароль успешно изменён. Войдите с новым паролем.',
'user' => $this->userResource($user->fresh()),
]);
}
@@ -433,12 +237,6 @@ class AuthController extends Controller
return 'auth:login:'.mb_strtolower($email).'|'.($ip ?? 'unknown');
}
/** Ключ throttle для 2FA verify: pending user_id + IP. */
private function twoFactorThrottleKey(int $userId, ?string $ip): string
{
return 'auth:2fa:'.$userId.'|'.($ip ?? 'unknown');
}
/**
* IP-lockout по ТЗ §22.4.4 п.2: 10+ login_failed с одного IP за последний час.
*
@@ -542,54 +340,4 @@ class AuthController extends Controller
'sound_enabled' => $user->sound_enabled,
];
}
/**
* PATCH /api/auth/me/notification-preferences сохранить матрицу
* 8 событий × 3 каналов (inapp/push/email) + sound_enabled.
*
* Источник: schema.sql:699 users.notification_preferences JSONB DEFAULT.
* Валидация: события NotificationService::ALL_EVENTS, каналы
* {inapp, push, email}. Незадекларированные ключи отбрасываются.
*/
public function updateNotificationPreferences(Request $request): JsonResponse
{
$validated = $request->validate([
'prefs' => 'required|array',
'prefs.*' => 'array',
'sound_enabled' => 'nullable|boolean',
]);
$allEvents = NotificationService::ALL_EVENTS;
$allChannels = [
NotificationService::CHANNEL_INAPP,
NotificationService::CHANNEL_PUSH,
NotificationService::CHANNEL_EMAIL,
];
// Очищенная матрица (только known events × known channels).
$sanitized = [];
foreach ($validated['prefs'] as $event => $channelPrefs) {
if (! in_array($event, $allEvents, true)) {
continue;
}
$sanitized[$event] = [];
foreach ($allChannels as $channel) {
if (isset($channelPrefs[$channel])) {
$sanitized[$event][$channel] = (bool) $channelPrefs[$channel];
}
}
}
/** @var User $user */
$user = $request->user();
$update = ['notification_preferences' => $sanitized];
if (array_key_exists('sound_enabled', $validated)) {
$update['sound_enabled'] = (bool) $validated['sound_enabled'];
}
$user->update($update);
return response()->json([
'user' => $this->userResource($user->fresh()),
]);
}
}