From fc765ad0071c3a45ed732a8b8242021e8ced4e47 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Thu, 22 Oct 2026 22:40:44 +0700 Subject: [PATCH] feat(auth): lock admin login per account as well as per IP Co-Authored-By: Claude Opus 5.5 --- .../Admin/Auth/AdminLoginBlockController.php | 13 +++-- .../Api/Admin/Auth/AuthController.php | 29 ++++++----- app/Support/Auth/AdminLoginLockout.php | 49 +++++++++++++++---- ...003_lock_admin_login_by_ip_and_account.php | 43 ++++++++++++++++ tests/Feature/Api/Admin/LoginThrottleTest.php | 38 ++++++++++---- 5 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 database/migrations/2026_10_23_000003_lock_admin_login_by_ip_and_account.php diff --git a/app/Http/Controllers/Api/Admin/Auth/AdminLoginBlockController.php b/app/Http/Controllers/Api/Admin/Auth/AdminLoginBlockController.php index 90189d1..3ec7df1 100644 --- a/app/Http/Controllers/Api/Admin/Auth/AdminLoginBlockController.php +++ b/app/Http/Controllers/Api/Admin/Auth/AdminLoginBlockController.php @@ -21,7 +21,9 @@ class AdminLoginBlockController extends Controller ->get() ->map(fn (AdminLoginBlock $block) => [ 'id' => $block->id, - 'ip' => $block->ip, + 'kind' => $block->kind, + 'identifier' => $block->identifier, + 'last_ip' => $block->last_ip, 'failed_attempts' => $block->failed_attempts, 'last_username' => $block->last_username, 'user_agent' => $block->user_agent, @@ -47,9 +49,14 @@ class AdminLoginBlockController extends Controller $this->activityLogger->log( event: 'login_unblocked', logName: 'auth', - description: sprintf('[admin] %s gỡ chặn đăng nhập cho IP %s (tài khoản thử gần nhất: %s)', $request->user('admin')?->username, $block->ip, $block->last_username ?? '—'), + description: sprintf('[admin] %s gỡ khoá đăng nhập %s %s', $request->user('admin')?->username, $this->label($block), $block->identifier), ); - return response()->json(['error' => false, 'message' => 'Đã gỡ chặn IP ' . $block->ip]); + return response()->json(['error' => false, 'message' => sprintf('Đã gỡ khoá %s %s', $this->label($block), $block->identifier)]); + } + + private function label(AdminLoginBlock $block): string + { + return $block->kind === AdminLoginLockout::KIND_ACCOUNT ? 'tài khoản' : 'IP'; } } diff --git a/app/Http/Controllers/Api/Admin/Auth/AuthController.php b/app/Http/Controllers/Api/Admin/Auth/AuthController.php index 0828125..b5dee6d 100644 --- a/app/Http/Controllers/Api/Admin/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Admin/Auth/AuthController.php @@ -7,10 +7,10 @@ use App\Http\Requests\UpdateProfileRequest; use App\Http\Requests\UpdateAdminPasswordRequest; use App\Http\Resources\Api\Admin\AdminUserResource; use App\Services\Api\Admin\AdminRefreshTokenService; +use App\Models\AdminLoginBlock; use App\Services\ActivityLogger; use App\Support\Auth\AdminLoginAlert; use App\Support\Auth\AdminLoginLockout; -use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -39,9 +39,10 @@ class AuthController extends Controller ]); $ip = (string) $request->ip(); + $username = $request->string('username')->toString(); - if ($blockedUntil = AdminLoginLockout::blockedUntil($ip)) { - return $this->blockedResponse($blockedUntil); + if ($block = AdminLoginLockout::activeBlock($ip, $username)) { + return $this->blockedResponse($block); } $guard = Auth::guard('admin'); @@ -50,7 +51,7 @@ class AuthController extends Controller $token = $guard->attempt($credentials); if (!$token) { - $block = AdminLoginLockout::recordFailure($ip, $request->string('username')->toString(), $request->userAgent()); + $blocks = AdminLoginLockout::recordFailure($ip, $username, $request->userAgent()); $this->activityLogger->log( event: 'login_failed', @@ -58,16 +59,18 @@ class AuthController extends Controller description: '[admin] Đăng nhập thất bại với username: ' . $request->username . ' từ IP ' . $ip, ); - if ($block->isBlocked()) { - return $this->blockedResponse($block->blocked_until); + foreach ($blocks as $block) { + if ($block->isBlocked()) { + return $this->blockedResponse($block); + } } - $remaining = AdminLoginLockout::remainingAttempts($block); + $remaining = AdminLoginLockout::remainingAttempts($blocks); return response()->json([ 'error' => true, 'message' => sprintf( - 'Thông tin đăng nhập không chính xác. Còn %d lần thử trước khi IP bị chặn %d phút.', + 'Thông tin đăng nhập không chính xác. Còn %d lần thử trước khi bị khoá %d phút.', $remaining, AdminLoginLockout::BLOCK_MINUTES, ), @@ -103,18 +106,20 @@ class AuthController extends Controller : $response->withCookie($this->forgetRefreshCookie()); } - private function blockedResponse(\DateTimeInterface $blockedUntil): JsonResponse + private function blockedResponse(AdminLoginBlock $block): JsonResponse { - $minutes = max(1, (int) ceil((Carbon::instance($blockedUntil)->getTimestamp() - now()->getTimestamp()) / 60)); + $minutes = max(1, (int) ceil(($block->blocked_until->getTimestamp() - now()->getTimestamp()) / 60)); + $target = $block->kind === AdminLoginLockout::KIND_ACCOUNT ? 'Tài khoản này' : 'IP của bạn'; return response()->json([ 'error' => true, 'message' => sprintf( - 'IP của bạn bị chặn đăng nhập do nhập sai quá %d lần. Thử lại sau %d phút hoặc liên hệ quản trị viên để gỡ chặn.', + '%s bị khoá đăng nhập do nhập sai quá %d lần. Thử lại sau %d phút hoặc liên hệ quản trị viên để gỡ khoá.', + $target, AdminLoginLockout::MAX_FAILURES, $minutes, ), - 'blocked_until' => Carbon::instance($blockedUntil)->toIso8601String(), + 'blocked_until' => $block->blocked_until->toIso8601String(), ], 429); } diff --git a/app/Support/Auth/AdminLoginLockout.php b/app/Support/Auth/AdminLoginLockout.php index 8ee19d0..546b830 100644 --- a/app/Support/Auth/AdminLoginLockout.php +++ b/app/Support/Auth/AdminLoginLockout.php @@ -9,19 +9,47 @@ class AdminLoginLockout { public const MAX_FAILURES = 4; public const BLOCK_MINUTES = 20; + public const KIND_IP = 'ip'; + public const KIND_ACCOUNT = 'account'; - public static function blockedUntil(string $ip): ?\DateTimeInterface + public static function activeBlock(string $ip, string $username): ?AdminLoginBlock { - $block = AdminLoginBlock::where('ip', $ip)->first(); - - return $block?->isBlocked() ? $block->blocked_until : null; + return AdminLoginBlock::active() + ->where(fn ($query) => $query + ->where(['kind' => self::KIND_IP, 'identifier' => $ip]) + ->orWhere(fn ($account) => $account->where(['kind' => self::KIND_ACCOUNT, 'identifier' => self::accountKey($username)]))) + ->orderByDesc('blocked_until') + ->first(); } - public static function recordFailure(string $ip, string $username, ?string $userAgent): AdminLoginBlock + /** + * @return list IP counter first, then the account counter when a username was given + */ + public static function recordFailure(string $ip, string $username, ?string $userAgent): array { - return DB::transaction(function () use ($ip, $username, $userAgent) { - AdminLoginBlock::query()->insertOrIgnore(['ip' => $ip, 'created_at' => now(), 'updated_at' => now()]); - $block = AdminLoginBlock::where('ip', $ip)->lockForUpdate()->firstOrFail(); + $blocks = [self::recordOne(self::KIND_IP, $ip, $ip, $username, $userAgent)]; + if (self::accountKey($username) !== '') { + $blocks[] = self::recordOne(self::KIND_ACCOUNT, self::accountKey($username), $ip, $username, $userAgent); + } + + return $blocks; + } + + /** + * @param list $blocks + */ + public static function remainingAttempts(array $blocks): int + { + return max(0, min(array_map(fn (AdminLoginBlock $block) => self::MAX_FAILURES + 1 - $block->failed_attempts, $blocks))); + } + + private static function recordOne(string $kind, string $identifier, string $ip, string $username, ?string $userAgent): AdminLoginBlock + { + return DB::transaction(function () use ($kind, $identifier, $ip, $username, $userAgent) { + AdminLoginBlock::query()->insertOrIgnore([ + 'kind' => $kind, 'identifier' => $identifier, 'created_at' => now(), 'updated_at' => now(), + ]); + $block = AdminLoginBlock::where(['kind' => $kind, 'identifier' => $identifier])->lockForUpdate()->firstOrFail(); $windowExpired = $block->last_failed_at === null || $block->last_failed_at->lt(now()->subMinutes(self::BLOCK_MINUTES)); @@ -30,6 +58,7 @@ class AdminLoginLockout $block->update([ 'failed_attempts' => $attempts, 'last_username' => mb_substr($username, 0, 191), + 'last_ip' => $ip, 'user_agent' => $userAgent ? mb_substr($userAgent, 0, 500) : null, 'last_failed_at' => now(), 'blocked_until' => $attempts > self::MAX_FAILURES @@ -41,8 +70,8 @@ class AdminLoginLockout }); } - public static function remainingAttempts(AdminLoginBlock $block): int + private static function accountKey(string $username): string { - return max(0, self::MAX_FAILURES + 1 - $block->failed_attempts); + return mb_strtolower(mb_substr(trim($username), 0, 191)); } } diff --git a/database/migrations/2026_10_23_000003_lock_admin_login_by_ip_and_account.php b/database/migrations/2026_10_23_000003_lock_admin_login_by_ip_and_account.php new file mode 100644 index 0000000..d2882bc --- /dev/null +++ b/database/migrations/2026_10_23_000003_lock_admin_login_by_ip_and_account.php @@ -0,0 +1,43 @@ +id(); + $table->string('kind', 16); + $table->string('identifier', 191); + $table->unsignedInteger('failed_attempts')->default(0); + $table->string('last_username', 191)->nullable(); + $table->string('last_ip', 45)->nullable(); + $table->string('user_agent', 500)->nullable(); + $table->timestamp('last_failed_at')->nullable(); + $table->timestamp('blocked_until')->nullable()->index(); + $table->timestamps(); + + $table->unique(['kind', 'identifier']); + }); + } + + public function down(): void + { + Schema::dropIfExists('admin_login_blocks'); + + Schema::create('admin_login_blocks', function (Blueprint $table) { + $table->id(); + $table->string('ip', 45)->unique(); + $table->unsignedInteger('failed_attempts')->default(0); + $table->string('last_username', 191)->nullable(); + $table->string('user_agent', 500)->nullable(); + $table->timestamp('last_failed_at')->nullable(); + $table->timestamp('blocked_until')->nullable()->index(); + $table->timestamps(); + }); + } +}; diff --git a/tests/Feature/Api/Admin/LoginThrottleTest.php b/tests/Feature/Api/Admin/LoginThrottleTest.php index df2ba78..923a536 100644 --- a/tests/Feature/Api/Admin/LoginThrottleTest.php +++ b/tests/Feature/Api/Admin/LoginThrottleTest.php @@ -29,9 +29,10 @@ class LoginThrottleTest extends TestCase (new TemplateSeeder())->seedScenarioIfMissing('admin_login_alert'); } - private function login(string $username, string $password) + private function login(string $username, string $password, string $ip = '127.0.0.1') { - return $this->postJson('/api/v1/administrator/login', ['username' => $username, 'password' => $password]); + return $this->withServerVariables(['REMOTE_ADDR' => $ip]) + ->postJson('/api/v1/administrator/login', ['username' => $username, 'password' => $password]); } private function staff(): User @@ -49,7 +50,7 @@ class LoginThrottleTest extends TestCase $this->login('staff', 'wrong')->assertStatus(429); $this->login('staff', 'correct-password-123')->assertStatus(429); - $this->login('someone-else', 'whatever')->assertStatus(429); + $this->login('someone-else', 'whatever')->assertStatus(429)->assertJsonPath('message', fn ($m) => str_starts_with($m, 'IP của bạn')); $this->travel(21)->minutes(); $this->login('staff', 'correct-password-123')->assertOk(); @@ -67,7 +68,22 @@ class LoginThrottleTest extends TestCase } $this->login('victim', 'guess-final')->assertStatus(429); - $this->assertTrue(AdminLoginBlock::sole()->isBlocked()); + $this->assertTrue(AdminLoginBlock::where('kind', 'ip')->sole()->isBlocked()); + } + + public function test_account_is_locked_after_failures_from_different_ips(): void + { + $this->staff(); + + foreach (['10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4'] as $ip) { + $this->login('staff', 'wrong', $ip)->assertUnauthorized(); + } + $this->login('STAFF', 'wrong', '10.0.0.5')->assertStatus(429); + + $this->login('staff', 'correct-password-123', '10.0.0.9') + ->assertStatus(429) + ->assertJsonPath('message', fn ($m) => str_starts_with($m, 'Tài khoản này')); + $this->login('another', 'whatever', '10.0.0.9')->assertUnauthorized(); } public function test_successful_login_alerts_configured_recipients(): void @@ -102,15 +118,19 @@ class LoginThrottleTest extends TestCase $admin = User::factory()->create(); $admin->assignRole('Full Administrator'); - $id = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/login-blocks') + $items = collect($this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/login-blocks') ->assertOk() - ->assertJsonPath('data.items.0.ip', '127.0.0.1') - ->assertJsonPath('data.items.0.last_username', 'staff') - ->json('data.items.0.id'); + ->assertJsonCount(2, 'data.items') + ->json('data.items'))->keyBy('kind'); + $this->assertSame('127.0.0.1', $items['ip']['identifier']); + $this->assertSame('staff', $items['account']['identifier']); - $this->actingAs($admin, 'admin')->deleteJson("/api/v1/administrator/login-blocks/{$id}")->assertOk(); + $this->actingAs($admin, 'admin')->deleteJson("/api/v1/administrator/login-blocks/{$items['ip']['id']}")->assertOk(); $this->app['auth']->forgetGuards(); + $this->login('staff', 'correct-password-123')->assertStatus(429); + $this->actingAs($admin, 'admin')->deleteJson("/api/v1/administrator/login-blocks/{$items['account']['id']}")->assertOk(); + $this->app['auth']->forgetGuards(); $this->login('staff', 'correct-password-123')->assertOk(); } }