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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user