Files
portal/app/app/Http/Controllers/Api/PasswordResetController.php
T
Дмитрий cd13e6c8bb refactor(backend): Sprint 3 Phase B — AuthController split (3 classes by analogy)
Sprint 3 Phase B. Закрытие audit O-refactor-02:

- O-refactor-02: AuthController (595 строк) → split на 3 класса (по аналогии с
  Sprint 3 Phase A DealController split):
  * AuthController (343) — core auth: login/register/logout/me +
    updateNotificationPreferences + helpers (loginThrottleKey, isIpLockedOut,
    maybeNotifySuspiciousLogin, logAuthEvent, lockoutResponse, userResource)
  * TwoFactorController (217, новый) — 2FA verify + recovery codes use
    (login-flow продолжение). Setup/enable/disable остались в
    TwoFactorSetupController (с Sprint 1).
  * PasswordResetController (146, новый) — forgot/reset password

API endpoints без изменений (только routing — controller@method обновлён в web.php):
- POST /api/auth/2fa/verify         → TwoFactorController@verifyTwoFactor
- POST /api/auth/2fa/recovery-use   → TwoFactorController@useRecoveryCode
- POST /api/auth/forgot             → PasswordResetController@forgotPassword
- POST /api/auth/reset-password     → PasswordResetController@resetPassword

Helpers lockoutResponse и userResource дублируются между классами (1:1) — по
принципу Phase A: «копируй методы 1:1, не оптимизировать на ходу». Будущая
итерация может вынести в trait/base controller, если появится 4-й класс.

Pest: 416/416 PASS + 2 skipped.
Larastan: 0 errors.
Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:14:33 +03:00

147 lines
7.0 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\ResetPasswordRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
/**
* Password reset flow — forgot, reset.
* Извлечено из AuthController (Sprint 3 Phase B, audit O-refactor-02).
*
* Оба endpoint'а публичные (без auth-middleware) — иначе сброс пароля
* был бы недоступен для забывших пароль пользователей.
*
* Anti-enumeration (forgot): unified-ответ независимо от существования email'а.
* Anti-bruteforce (reset): rate-limit по token-хэшу + ip.
*
* Token-провайдер — стандартный Laravel `Password` facade (под капотом
* `password_resets` table, env AUTH_PASSWORD_RESET_TOKEN_TABLE; TTL 60 мин
* config/auth.php).
*/
class PasswordResetController 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;
/**
* 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 зарегистрирован — мы отправили ссылку для сброса пароля.',
]);
}
/**
* POST /api/auth/reset-password — установка нового пароля по токену.
*
* Token приходит из email-ссылки (формируется Laravel ResetPassword Notification).
* `Password::reset()` под капотом:
* 1. Ищет user по email.
* 2. Проверяет hashed token из password_resets (TTL 60 мин по config/auth.php).
* 3. Вызывает callback с (user, password) → пишем `password_hash` (наша колонка).
* 4. Удаляет row из password_resets.
*
* После успешного reset — invalidate всех существующих сессий через
* `Auth::logoutOtherDevices` (требовало бы пароль) — для MVP опускаем,
* но регенерируем remember_token (если есть).
*
* Reset rate-limit (anti-token-brute) — 5 попыток / 15 мин по token-prefix + ip.
*/
public function resetPassword(ResetPasswordRequest $request): JsonResponse
{
$email = mb_strtolower($request->string('email')->toString());
$token = $request->string('token')->toString();
// Throttle key — token (полный, не префикс) + ip. Анти-перебор: попытки
// менее чем за 15 мин по одному и тому же token блокируются.
$throttleKey = 'auth:reset:'.substr(hash('sha256', $token), 0, 16).'|'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) {
return $this->lockoutResponse($throttleKey);
}
$status = Password::reset(
[
'email' => $email,
'password' => $request->string('password')->toString(),
'password_confirmation' => $request->string('password_confirmation')->toString(),
'token' => $token,
],
function (User $user, string $password): void {
$user->forceFill([
'password_hash' => Hash::make($password),
])->save();
}
);
if ($status !== Password::PASSWORD_RESET) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
return response()->json([
'message' => 'Ссылка для сброса недействительна или истекла. Запросите новую.',
'errors' => ['email' => ['Ссылка для сброса недействительна или истекла.']],
], 422);
}
RateLimiter::clear($throttleKey);
return response()->json([
'message' => 'Пароль успешно изменён. Войдите с новым паролем.',
]);
}
/** 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);
}
}