170382878b
- AuthController::forgotPassword использует Password::sendResetLink (anti-enumeration: всегда 200)
- AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets — указывает на нашу таблицу из schema v8.7
- Rate-limit 5/15мин по auth:forgot:{email}|{ip} — hit ставится ДО sendResetLink (защита перебора через unknown email)
- Frontend: authApi.forgotPassword, auth-store.requestPasswordReset, ForgotPasswordView success-state
- Pest +6 в ForgotPasswordTest (79/79 за 10.55с, 273 assertions)
- Vitest +4 (153/153 за 11.11с)
- TODO: POST /api/auth/reset-password + UI-форма new_password (deep-link)
- Регресс: lint+type+format OK; build 862ms; story:build 21/28 за 32с; Pint+Stan passed
- CLAUDE.md v1.36→v1.37, реестр v1.45→v1.46
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
12 KiB
PHP
291 lines
12 KiB
PHP
<?php
|
||
|
||
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\VerifyTwoFactorRequest;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Illuminate\Support\Facades\Password;
|
||
use Illuminate\Support\Facades\RateLimiter;
|
||
use PragmaRX\Google2FA\Google2FA;
|
||
|
||
/**
|
||
* Аутентификация через Sanctum SPA mode (cookie-based session) + 2FA TOTP.
|
||
*
|
||
* 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} → проверяет TOTP против users.totp_secret,
|
||
* окно ±1 (30 сек до/после). Если ок — Auth::login + clear pending_user_id.
|
||
*
|
||
* Rate-limit (ТЗ §22.4.4):
|
||
* - Login: 5 попыток / 15 мин по ключу email|ip. Превышение → 429 + Retry-After.
|
||
* - 2FA verify: 5 попыток / 15 мин по pending_user_id из session.
|
||
* - Email-уведомление при 3 неудачах + IP-lockout 10/час — TODO (требует MailService + auth_log).
|
||
*
|
||
* Не входит:
|
||
* - Recovery codes (XXXX-XXXX) — отдельный endpoint /2fa/recovery-use.
|
||
* - Yandex 360 SSO.
|
||
* - Tenant-wizard при register (на MVP — first tenant attach).
|
||
*/
|
||
class AuthController extends Controller
|
||
{
|
||
/** Лимит попыток входа в окне (ТЗ §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;
|
||
|
||
public function login(LoginRequest $request): JsonResponse
|
||
{
|
||
$credentials = $request->only(['email', 'password']);
|
||
$throttleKey = $this->loginThrottleKey($credentials['email'], $request->ip());
|
||
|
||
if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) {
|
||
return $this->lockoutResponse($throttleKey);
|
||
}
|
||
|
||
$user = User::where('email', $credentials['email'])->first();
|
||
|
||
if (! $user || ! Hash::check($credentials['password'], $user->password_hash)) {
|
||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||
|
||
return response()->json([
|
||
'message' => 'Неверный email или пароль.',
|
||
'errors' => ['email' => ['Неверный email или пароль.']],
|
||
], 422);
|
||
}
|
||
|
||
if (! $user->is_active) {
|
||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||
|
||
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()]);
|
||
|
||
return response()->json([
|
||
'user' => $this->userResource($user),
|
||
'requires_2fa' => false,
|
||
]);
|
||
}
|
||
|
||
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-разводки).
|
||
// 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();
|
||
|
||
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
|
||
{
|
||
Auth::guard('web')->logout();
|
||
|
||
$request->session()->invalidate();
|
||
$request->session()->regenerateToken();
|
||
|
||
return response()->json(['message' => 'Вы вышли из системы.']);
|
||
}
|
||
|
||
/**
|
||
* 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 зарегистрирован — мы отправили ссылку для сброса пароля.',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Ключ throttle для login: email|ip — защищает email от брутфорса даже
|
||
* за NAT'ом, и IP от перебора емейлов с одного источника.
|
||
*/
|
||
private function loginThrottleKey(string $email, ?string $ip): string
|
||
{
|
||
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');
|
||
}
|
||
|
||
/** 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,
|
||
'tenant_id' => $user->tenant_id,
|
||
'totp_enabled' => $user->totp_enabled,
|
||
'last_login_at' => $user->last_login_at,
|
||
];
|
||
}
|
||
}
|