Files
portal/app/app/Http/Controllers/Api/AuthController.php
T

333 lines
13 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\LoginRequest;
use App\Mail\SuspiciousLoginNotification;
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
/**
* Аутентификация через 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}.
*
* 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} → TwoFactorController, проверяет TOTP
* против users.totp_secret, окно ±1 (30 сек до/после). Если ок — Auth::login.
*
* Rate-limit (ТЗ §22.4.4):
* - Login: 5 попыток / 15 мин по ключу email|ip. Превышение → 429 + Retry-After.
* - IP-lockout: 10 неудач/час с одного IP через auth_log → 429 + Retry-After: 3600.
* - Email-уведомление при ровно 3 неудачах входа на email → SuspiciousLoginNotification.
*
* Не входит:
* - Yandex 360 SSO.
* - Tenant-wizard при register (на MVP — first tenant attach).
*/
class AuthController 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;
/** Лимит неудач входа с одного IP за час (ТЗ §22.4.4 п.2). */
private const IP_LOCKOUT_THRESHOLD = 10;
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->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 me(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$resource = $this->userResource($user);
$marker = $request->hasSession() ? $request->session()->get('impersonation') : null;
if ($marker !== null) {
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id']);
$tenant = $token?->tenant;
$resource['impersonation'] = [
'active' => true,
'tenant_name' => $tenant?->organization_name,
'started_at' => $marker['started_at'] ?? null,
'expires_at' => $token?->sessionExpiresAt()?->toIso8601String(),
];
}
return response()->json(['user' => $resource]);
}
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<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,
'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,
];
}
}