Files
Gnuboard7/tests/Feature/Auth/LoginThrottleTest.php
T
HeuJung 50007d5cc6 fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현
2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.

원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.

이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.

## 구현

- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
 `ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
 「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
 수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
 코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
 뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
 사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
 못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
 문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.

## 이번 점검에서 함께 고친 것

- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
 그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
 한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
 성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
 같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.

## 버전

코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
2026-09-07 17:08:14 +09:00

246 lines
7.9 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
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
/**
* 로그인 시도 제한 / 계정 잠금 통합 테스트.
*
* 보안 환경설정의 `login_attempt_enabled / max_login_attempts /
* login_lockout_time` 키가 실제 인증 흐름에 적용되는지 검증합니다.
*
* 시나리오 매트릭스 (axis × axis):
* - login_attempt_enabled ∈ {true, false}
* - 잠금 상태 ∈ {none, active, expired}
* - 자격 증명 ∈ {valid, invalid}
*/
class LoginThrottleTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// 환경설정을 테스트 친화적 값으로 강제
$this->setSecuritySettings([
'login_attempt_enabled' => true,
'max_login_attempts' => 3,
'login_lockout_time' => 5,
'auth_token_lifetime' => 30,
]);
// RateLimiter 캐시 초기화 — 다른 테스트 잔여 카운트 영향 방지
Cache::flush();
}
/**
* @test
*/
public function 비밀번호_3회_연속_실패_후_4회째_시도_시_423_으로_차단된다(): void
{
$user = User::factory()->create([
'email' => 'lockme@example.com',
'password' => Hash::make('correct-password'),
'status' => 'active',
]);
for ($i = 1; $i <= 3; $i++) {
$response = $this->postJson('/api/auth/login', [
'email' => 'lockme@example.com',
'password' => 'wrong-password',
]);
$response->assertStatus(401);
}
// 4회째 — 잠금 상태로 423 Locked 응답
$response = $this->postJson('/api/auth/login', [
'email' => 'lockme@example.com',
'password' => 'correct-password',
]);
$response->assertStatus(423);
$response->assertJsonPath('success', false);
$user->refresh();
$this->assertNotNull($user->locked_until);
$this->assertTrue($user->locked_until->isFuture());
}
/**
* @test
*/
public function 잠금_해제_시각이_지나면_정상_로그인_가능하다(): void
{
$user = User::factory()->create([
'email' => 'expired@example.com',
'password' => Hash::make('correct-password'),
'status' => 'active',
'locked_until' => now()->subMinutes(1), // 이미 만료
'failed_login_attempts' => 0,
]);
$response = $this->postJson('/api/auth/login', [
'email' => 'expired@example.com',
'password' => 'correct-password',
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$user->refresh();
$this->assertNull($user->locked_until);
$this->assertSame(0, (int) $user->failed_login_attempts);
}
/**
* @test
*/
public function 정상_로그인_성공_시_실패_카운터가_리셋된다(): void
{
$user = User::factory()->create([
'email' => 'reset@example.com',
'password' => Hash::make('correct-password'),
'status' => 'active',
'failed_login_attempts' => 2,
'last_failed_login_at' => now()->subMinute(),
]);
$response = $this->postJson('/api/auth/login', [
'email' => 'reset@example.com',
'password' => 'correct-password',
]);
$response->assertStatus(200);
$user->refresh();
$this->assertSame(0, (int) $user->failed_login_attempts);
$this->assertNull($user->locked_until);
$this->assertNull($user->last_failed_login_at);
}
/**
* @test
*/
public function login_attempt_enabled_가_off_이면_무제한_시도_허용된다(): void
{
$this->setSecuritySettings([
'login_attempt_enabled' => false,
'max_login_attempts' => 3,
'login_lockout_time' => 5,
'auth_token_lifetime' => 30,
]);
$user = User::factory()->create([
'email' => 'unlimited@example.com',
'password' => Hash::make('correct-password'),
'status' => 'active',
]);
// 5번 실패해도 잠금 안 됨
for ($i = 1; $i <= 5; $i++) {
$response = $this->postJson('/api/auth/login', [
'email' => 'unlimited@example.com',
'password' => 'wrong-password',
]);
$response->assertStatus(401);
}
// 정상 비밀번호로 로그인 가능
$response = $this->postJson('/api/auth/login', [
'email' => 'unlimited@example.com',
'password' => 'correct-password',
]);
$response->assertStatus(200);
$user->refresh();
$this->assertNull($user->locked_until);
}
/**
* @test
*/
public function 카운터가_임계값_도달하면_잠금_시각과_함께_카운터가_0으로_리셋된다(): void
{
$user = User::factory()->create([
'email' => 'threshold@example.com',
'password' => Hash::make('correct-password'),
'status' => 'active',
]);
for ($i = 1; $i <= 3; $i++) {
$this->postJson('/api/auth/login', [
'email' => 'threshold@example.com',
'password' => 'wrong-password',
]);
}
$user->refresh();
// 잠금 처리 시 카운터 0 으로 리셋 (다음 윈도우 시작점)
$this->assertSame(0, (int) $user->failed_login_attempts);
$this->assertNotNull($user->locked_until);
$this->assertTrue($user->locked_until->isFuture());
}
/**
* @test
*/
public function 존재하지_않는_이메일은_db에_카운트_저장되지_않는다(): void
{
$response = $this->postJson('/api/auth/login', [
'email' => 'ghost@example.com',
'password' => 'wrong-password',
]);
$response->assertStatus(401);
// 존재하지 않는 이메일에 대해서는 user 가 없으므로 DB 변경 없음 — IP throttle 이 백업
$this->assertDatabaseMissing('users', ['email' => 'ghost@example.com']);
}
/**
* @test
*
* @scenario two_factor=off, controller=user, delivery=sent
*
* @effects too_many_attempts_message_translated
*/
public function 분당_요청_상한을_넘으면_다국어_문구로_응답한다(): void
{
// 기본 응답은 영문 "Too Many Attempts." 이다 — 로그인 화면이 그 문구를 그대로
// 노출하므로, 한국어 사이트에서 영문 원문이 오류 박스에 뜬다.
$limit = max(30, 3 * 6);
for ($i = 0; $i <= $limit; $i++) {
$response = $this->postJson('/api/auth/login', [
'email' => 'ghost@example.com',
'password' => 'wrong-password',
]);
if ($response->getStatusCode() === 429) {
break;
}
}
$response->assertStatus(429);
$this->assertNotSame('Too Many Attempts.', $response->json('message'));
$this->assertStringNotContainsString(':seconds', (string) $response->json('message'));
$this->assertSame(
__('auth.too_many_attempts', ['seconds' => (int) $response->headers->get('Retry-After')]),
$response->json('message')
);
// 재시도 시점 안내는 그대로 유지되어야 한다.
$this->assertNotNull($response->headers->get('Retry-After'));
}
private function setSecuritySettings(array $values): void
{
$existing = (array) config('g7_settings.core.security', []);
config(['g7_settings.core.security' => array_merge($existing, $values)]);
}
}