Files
portal/app/app/Http/Controllers/Api/AuthController.php
T
Дмитрий 170382878b phase2(forgot-password): POST /api/auth/forgot + ForgotPasswordView интеграция
- 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>
2026-05-08 21:10:28 +03:00

291 lines
12 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\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,
];
}
}