phase2(ip-lockout): auth_log записи + 10 неудач/час с IP → 429 (ТЗ §22.4.4 п.2)

- AuthController::isIpLockedOut: count login_failed за час с IP, ≥10 → 429 + Retry-After: 3600
- logAuthEvent: 3 ветки failure_reason (invalid_password / unknown_email / account_locked)
- DB::table('auth_log')->insert; hash-chain trigger заполняет log_hash (OPEN-И-15)
- Защита поверх email-rate-limit: один IP не сможет перебирать множество email'ов
- Pest +6 IpLockoutTest (107/107 за 13.86с, 380 assertions)
- Регресс: Pint+Stan passed
- CLAUDE.md v1.40→v1.41, реестр v1.49→v1.50

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-09 04:07:17 +03:00
parent 73e64128dc
commit d1e4237e2f
5 changed files with 258 additions and 2 deletions
@@ -17,6 +17,7 @@ use App\Models\UserRecoveryCode;
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\Password;
use Illuminate\Support\Facades\RateLimiter;
@@ -52,19 +53,33 @@ class AuthController extends Controller
/** Окно блокировки в секундах (ТЗ §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, $credentials['email'], $ip, $request->userAgent(),
$user ? 'invalid_password' : 'unknown_email');
return response()->json([
'message' => 'Неверный email или пароль.',
@@ -74,6 +89,8 @@ class AuthController extends Controller
if (! $user->is_active) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
'account_locked');
return response()->json([
'message' => 'Аккаунт заблокирован.',
@@ -102,6 +119,8 @@ class AuthController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent('login_success', $user, $user->email, $ip, $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
@@ -415,6 +434,56 @@ class AuthController extends Controller
return 'auth:2fa:'.$userId.'|'.($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;
}
/**
* Запись события auth_log.
*
* Через DB::table auth_log имеет hash-chain trigger BEFORE INSERT,
* который заполняет log_hash. Eloquent-модели для этой таблицы нет.
* RLS USING без WITH CHECK INSERT не фильтруется.
*/
private function logAuthEvent(
string $event,
?User $user,
?string $email,
?string $ip,
?string $userAgent,
?string $failureReason,
): void {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'tenant_id' => $user?->tenant_id,
'user_id' => $user?->id,
'email' => $email,
'event' => $event,
'ip_address' => $ip,
'user_agent' => $userAgent,
'failure_reason' => $failureReason,
'created_at' => now(),
]);
}
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
private function lockoutResponse(string $throttleKey): JsonResponse
{