Files
Gnuboard7/tests/Feature/Auth/TwoFactorAccountLockTest.php
HeuJung 21fc114f37 fix(core,extensions): 보안 결함 3건과 이중저장소·레이아웃 중복키 결함군 폐쇄
KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고,
그 과정에서 드러난 두 결함군을 함께 닫는다.

- 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다
- 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다
- 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고,
 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는
 결제창 미완료 주문은 만료 자동취소가 거둔다
- 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다
 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다
- 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
2026-09-02 17:36:11 +09:00

327 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Auth;
use App\Enums\UserStatus;
use App\Models\IdentityVerificationLog;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Database\Seeders\IdentityMessageDefinitionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 2단계 인증 완료 지점의 계정 잠금 재검사 테스트.
*
* 계정 잠금은 비밀번호 확인 단계(`login`)에서만 검사되고 있었습니다. 그런데 2단계 인증이
* 켜져 있으면 `login` 은 토큰 대신 challenge 만 돌려주고, 실제 세션은 `login/two-factor`
* 가 발급합니다. 그 두 번째 지점에 잠금 검사가 없으면 **잠기기 전에 발급받은 challenge 를
* 잠긴 뒤에 완료**하는 것만으로 잠금이 통째로 우회됩니다.
*
* 게다가 로그인 완료 훅(`core.auth.after_login`)이 실패 횟수·잠금 시각을 초기화하므로,
* 우회에 성공하면 잠금 자체가 해제되어 흔적도 남지 않습니다.
*
* 불변조건: **세션(토큰)을 발급하는 모든 지점은 잠금을 재검사한다.**
*/
class TwoFactorAccountLockTest extends TestCase
{
use RefreshDatabase;
private const EMAIL = 'two-factor-lock@test.com';
private const PASSWORD = 'Passw0rd!2fa';
private User $user;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
// 인증번호 메일 구성에 본인인증 메시지 정의가 필요하다. 없으면 challenge 가 발송
// 실패로 표시되어, 잠금 게이트가 아니라 시드 부재를 측정하게 된다.
$this->seed(IdentityMessageDefinitionSeeder::class);
$this->setSecuritySettings([
'two_factor_auth' => true,
'login_attempt_enabled' => true,
'max_login_attempts' => 3,
'login_lockout_time' => 30,
]);
Cache::flush();
$this->user = User::factory()->create([
'email' => self::EMAIL,
'password' => Hash::make(self::PASSWORD),
'status' => UserStatus::Active->value,
]);
}
/**
* 잠금 전에 발급된 2단계 인증 challenge 로는 잠긴 계정에 로그인할 수 없다.
*/
#[Test]
public function a_challenge_issued_before_the_lock_cannot_complete_login_after_the_lock(): void
{
// 1. 정상 비밀번호로 challenge 를 확보한다 (아직 잠기지 않은 상태).
$challengeId = $this->login()->json('data.challenge_id');
$this->assertNotEmpty($challengeId, '2단계 인증 challenge 가 발급되지 않았습니다.');
$code = $this->issuedCode($challengeId);
// 2. 임계값까지 실패시켜 계정을 잠근다.
$this->lockAccount();
// 3. 통제군 — 정상 로그인 경로는 잠금을 인지해 423 을 돌려준다.
$this->login()->assertStatus(423);
// 4. 잠금 전 challenge 로 2단계 인증을 완료하려 하면 같은 판정을 받아야 한다.
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => $code,
]);
$response->assertStatus(423);
$this->assertNull(
$response->json('data.token'),
'잠긴 계정에 토큰이 발급되면 계정 잠금이 통째로 우회됩니다.'
);
}
/**
* 우회 시도는 잠금 상태를 초기화하지 않는다.
*
* 로그인 완료 훅이 실패 횟수·잠금 시각을 리셋하므로, 우회가 성공하면 공격의 흔적까지
* 사라진다. 차단이 훅 이전에 일어나는지를 사후 상태로 확인한다.
*/
#[Test]
public function a_blocked_two_factor_attempt_does_not_reset_the_lock_state(): void
{
$challengeId = $this->login()->json('data.challenge_id');
$code = $this->issuedCode($challengeId);
$this->lockAccount();
$locked = $this->user->fresh();
$attemptsBefore = $locked->failed_login_attempts;
$lockedUntilBefore = $locked->locked_until;
$permanentBefore = $locked->locked_permanently;
$this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => $code,
])->assertStatus(423);
$after = $this->user->fresh();
$this->assertSame($attemptsBefore, $after->failed_login_attempts, '실패 횟수가 초기화되었습니다.');
$this->assertEquals($lockedUntilBefore, $after->locked_until, '잠금 해제 시각이 변경되었습니다.');
$this->assertEquals($permanentBefore, $after->locked_permanently, '영구 잠금 플래그가 변경되었습니다.');
$this->assertSame(0, $after->tokens()->count(), '차단된 요청이 토큰을 남겼습니다.');
}
/**
* 423 응답은 정상 로그인 경로와 같은 계약(해제 시각·잔여 시간·영구 여부)을 따른다.
*
* 화면은 두 경로를 구분하지 않으므로, 한쪽만 다른 모양이면 안내가 깨진다.
*/
#[Test]
public function the_locked_response_matches_the_login_endpoint_contract(): void
{
$challengeId = $this->login()->json('data.challenge_id');
$code = $this->issuedCode($challengeId);
$this->lockAccount();
$loginResponse = $this->login()->assertStatus(423);
$twoFactorResponse = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => $code,
])->assertStatus(423);
$this->assertSame(
array_keys((array) $loginResponse->json('errors')),
array_keys((array) $twoFactorResponse->json('errors')),
'두 경로의 423 응답 형태가 다릅니다.'
);
$this->assertFalse((bool) $twoFactorResponse->json('errors.permanent'));
$this->assertNotNull($twoFactorResponse->json('errors.locked_until'));
}
/**
* 잠기지 않은 계정의 2단계 인증은 그대로 완료된다 (정상 흐름 회귀 방지).
*/
#[Test]
public function an_unlocked_account_still_completes_two_factor_normally(): void
{
$challengeId = $this->login()->json('data.challenge_id');
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => $this->issuedCode($challengeId),
]);
$response->assertStatus(200);
$this->assertNotEmpty($response->json('data.token'), '정상 계정의 2단계 인증이 차단되었습니다.');
}
/**
* 잠금 검사 설정이 꺼져 있으면 2단계 인증도 잠금을 보지 않는다 (설정 계약 일치).
*/
#[Test]
public function the_lock_recheck_follows_the_login_attempt_setting(): void
{
$challengeId = $this->login()->json('data.challenge_id');
$code = $this->issuedCode($challengeId);
$this->lockAccount();
// 운영자가 잠금 기능을 끄면 정상 로그인이 통과하듯 2단계 인증도 통과해야 한다.
$this->setSecuritySettings(['login_attempt_enabled' => false]);
$this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => $code,
])->assertStatus(200);
}
/**
* 잠긴 계정은 기존 세션으로도 토큰을 재발급받지 못한다.
*
* 형제 결함 — `refreshToken` 은 유효한 세션이 전제라 신규 로그인 우회는 아니지만,
* 관리자가 계정을 잠근 뒤에도 그 세션이 무기한 연장되면 잠금이 실효를 잃는다.
*/
#[Test]
public function a_locked_account_cannot_refresh_its_token(): void
{
$this->grantRefreshPermission();
$token = $this->user->createToken('test-token')->plainTextToken;
$this->lockAccount();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/user/auth/refresh')
->assertStatus(423);
}
/**
* 잠기지 않은 계정의 토큰 재발급은 그대로 동작한다 (정상 흐름 회귀 방지).
*/
#[Test]
public function an_unlocked_account_can_still_refresh_its_token(): void
{
$this->grantRefreshPermission();
$token = $this->user->createToken('test-token')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/user/auth/refresh');
$response->assertStatus(200);
$this->assertNotEmpty($response->json('data.token'));
}
/**
* 테스트 사용자에게 토큰 재발급 권한을 부여합니다.
*
* 재발급 라우트는 `permission:user,core.auth.refresh` 미들웨어를 거치므로, 권한이 없으면
* 잠금 게이트에 닿기 전에 403 이 되어 측정 대상이 바뀐다.
*/
private function grantRefreshPermission(): void
{
$permission = Permission::firstOrCreate(
['identifier' => 'core.auth.refresh'],
[
'name' => json_encode(['ko' => '토큰 재발급', 'en' => 'Refresh Token']),
'description' => json_encode(['ko' => '토큰 재발급', 'en' => 'Refresh Token']),
'extension_type' => 'core',
'extension_identifier' => 'core',
'type' => 'user',
]
);
$role = Role::create([
'identifier' => 'two_factor_lock_test_'.uniqid(),
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
'is_active' => true,
]);
$role->permissions()->sync([$permission->id]);
$this->user->roles()->attach($role->id, ['assigned_at' => now()]);
$this->user = $this->user->fresh();
}
/**
* 비밀번호 확인 단계(1단계)를 수행합니다.
*
* @return TestResponse 로그인 응답
*/
private function login()
{
return $this->postJson('/api/auth/login', [
'email' => self::EMAIL,
'password' => self::PASSWORD,
]);
}
/**
* 임계값까지 로그인을 실패시켜 계정을 잠급니다.
*/
private function lockAccount(): void
{
for ($i = 0; $i < 3; $i++) {
$this->postJson('/api/auth/login', [
'email' => self::EMAIL,
'password' => 'wrong-password',
]);
}
$this->assertTrue(
$this->user->fresh()->locked_until !== null || (bool) $this->user->fresh()->locked_permanently,
'전제 조건 실패 — 계정이 잠기지 않았습니다.'
);
}
/**
* challenge 에 알려진 인증 코드를 심고 그 값을 돌려줍니다.
*
* 발급된 코드는 해시로만 저장되어 되읽을 수 없으므로, 기존 2단계 인증 테스트와 같은
* 방식으로 알려진 값의 해시를 심어 "코드가 맞을 때" 를 재현합니다.
*
* @param string $challengeId challenge UUID
* @return string 심어 둔 인증 코드
*/
private function issuedCode(string $challengeId): string
{
$code = '135790';
$log = IdentityVerificationLog::find($challengeId);
$metadata = $log->metadata ?? [];
$metadata['code_hash'] = Hash::make($code);
$log->metadata = $metadata;
$log->save();
return $code;
}
/**
* 코어 보안 설정을 덮어씁니다.
*
* @param array<string, mixed> $values 덮어쓸 설정 값
*/
private function setSecuritySettings(array $values): void
{
$existing = (array) config('g7_settings.core.security', []);
config(['g7_settings.core.security' => array_merge($existing, $values)]);
}
}