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:
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user