only(['email', 'password']); $throttleKey = $this->loginThrottleKey($credentials['email'], $request->ip()); $ip = $request->ip(); if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { return $this->lockoutResponse($throttleKey); } // ТЗ §22.4.4 п.2: IP-lockout — 10 неудач/час с одного IP → блок IP на 1 час. if ($this->isIpLockedOut($ip)) { return response()->json([ 'message' => 'Слишком много неудачных попыток с этого IP. Попробуйте через час.', 'retry_after' => 3600, ], 429)->header('Retry-After', '3600'); } $user = User::where('email', $credentials['email'])->first(); if (! $user || ! Hash::check($credentials['password'], $user->password_hash)) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); $this->logAuthEvent('login_failed', $user?->id, $user?->tenant_id, $credentials['email'], $ip, $request->userAgent(), $user ? 'invalid_password' : 'unknown_email'); $this->maybeNotifySuspiciousLogin($user, $ip); return response()->json([ 'message' => 'Неверный email или пароль.', 'errors' => ['email' => ['Неверный email или пароль.']], ], 422); } if (! $user->is_active) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); $this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(), 'account_locked'); return response()->json([ 'message' => 'Аккаунт заблокирован.', 'errors' => ['email' => ['Аккаунт заблокирован.']], ], 422); } // Email+пароль приняты → счётчик чистим, чтобы 2FA-фаза не зависела от login-fails. RateLimiter::clear($throttleKey); // Если включена 2FA — НЕ делаем Auth::login сразу. Сохраняем // pending_user_id в session, ждём POST /api/auth/2fa/verify. if ($user->totp_enabled) { $request->session()->put('auth.pending_user_id', $user->id); $request->session()->put('auth.pending_remember', $request->boolean('remember', false)); return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => true, ]); } $remember = $request->boolean('remember', false); Auth::login($user, $remember); $request->session()->regenerate(); $user->update(['last_login_at' => now()]); $this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null); return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => false, ]); } public function register(RegisterRequest $request): JsonResponse { // На MVP — attach нового user'а к первому tenant'у (для UI-разводки). // Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли. $tenant = Tenant::first(); if (! $tenant) { return response()->json([ 'message' => 'Tenants не настроены. Обратитесь к администратору.', ], 503); } $user = User::create([ 'tenant_id' => $tenant->id, 'email' => $request->string('email')->toString(), 'password_hash' => Hash::make($request->string('password')->toString()), 'first_name' => 'Новый', 'last_name' => 'Пользователь', 'is_active' => true, 'totp_enabled' => false, ]); Auth::login($user); $request->session()->regenerate(); $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null); return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => false, ], 201); } public function me(Request $request): JsonResponse { /** @var User $user */ $user = $request->user(); return response()->json([ 'user' => $this->userResource($user), ]); } public function logout(Request $request): JsonResponse { $userId = $request->user()?->id; $tenantId = $request->user()?->tenant_id; $email = $request->user()?->email; Auth::guard('web')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); $this->logAuthEvent('logout', $userId, $tenantId, $email, $request->ip(), $request->userAgent(), null); return response()->json(['message' => 'Вы вышли из системы.']); } /** * 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()), ]); } /** * PATCH /api/auth/me — обновление профиля текущего пользователя * (имя, фамилия, телефон, тайм-зона). Email менять нельзя (через support). * * Audit J6/D1 (ProfileTab). Зеркалит updateNotificationPreferences: * та же группа auth:sanctum, тот же inline-validate, тот же userResource. */ public function updateProfile(Request $request): JsonResponse { $validated = $request->validate([ 'first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'phone' => ['nullable', 'string', 'max:20'], 'timezone' => ['required', 'timezone'], ]); /** @var User $user */ $user = $request->user(); $user->update($validated); return response()->json([ 'user' => $this->userResource($user->fresh()), ]); } /** * Ключ throttle для login: email|ip — защищает email от брутфорса даже * за NAT'ом, и IP от перебора емейлов с одного источника. */ private function loginThrottleKey(string $email, ?string $ip): string { return 'auth:login:'.mb_strtolower($email).'|'.($ip ?? 'unknown'); } /** * IP-lockout по ТЗ §22.4.4 п.2: 10+ login_failed с одного IP за последний час. * * Считаем через `auth_log` (event=login_failed) — это даёт защиту от * перебора email'ов с одного IP даже если каждый email используется * <5 раз и не триггерит email-lockout. */ private function isIpLockedOut(?string $ip): bool { if ($ip === null || $ip === '') { return false; } $count = DB::table('auth_log') ->where('event', 'login_failed') ->where('ip_address', $ip) ->where('created_at', '>=', now()->subHour()) ->count(); return $count >= self::IP_LOCKOUT_THRESHOLD; } /** * ТЗ §22.4.4 п.3: при превышении 3 неудачных попыток входа на email * пользователю отправляется email-уведомление о подозрительной активности. * * Триггерим РОВНО на 3-й неудаче (не на каждой) — иначе спам уведомлений. * Для unknown email user=null → ничего не отправляем (некому). * * NB: считаем уже после INSERT'а login_failed события — поэтому именно * 3 (не 2): на 3-й попытке count = 3. */ private function maybeNotifySuspiciousLogin(?User $user, ?string $ip): void { if ($user === null) { return; } $count = DB::table('auth_log') ->where('event', 'login_failed') ->where('user_id', $user->id) ->where('created_at', '>=', now()->subHour()) ->count(); if ($count === 3) { Mail::to($user->email)->send(new SuspiciousLoginNotification($user, $ip, $count)); } } /** 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 */ private function userResource(User $user): array { return [ 'id' => $user->id, 'email' => $user->email, 'first_name' => $user->first_name, 'last_name' => $user->last_name, 'phone' => $user->phone, 'timezone' => $user->timezone, '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, ]; } }