333 lines
13 KiB
PHP
333 lines
13 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\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,
|
||
];
|
||
}
|
||
}
|