fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Repositories;
|
||||
|
||||
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* IdentityVerificationLogRepository 의 검증 토큰 조회 만료 검증.
|
||||
*
|
||||
* 회귀 배경(KVE-2026-2029): findVerifiedForToken() 이 status/consumed_at 만 보고
|
||||
* expires_at 술어를 갖지 않아, 만료된 본인인증 토큰이 무기한 유효하게 취급됐다.
|
||||
* 이 저장소를 공유하는 소비자(정책 미들웨어·정책 서비스·회원가입/비밀번호재설정
|
||||
* 리스너·IdvTokenRule)가 전부 같은 결함을 물려받았으므로 저장소 단일 지점에서 닫는다.
|
||||
*
|
||||
* @group identity
|
||||
* @group unit
|
||||
*/
|
||||
class IdentityVerificationLogRepositoryTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private IdentityVerificationLogRepositoryInterface $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->repository = app(IdentityVerificationLogRepositoryInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 완료 로그를 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $overrides 덮어쓸 속성
|
||||
*/
|
||||
private function makeVerifiedLog(array $overrides = []): IdentityVerificationLog
|
||||
{
|
||||
return IdentityVerificationLog::create(array_merge([
|
||||
'provider_id' => 'test-provider',
|
||||
'purpose' => 'sensitive_action',
|
||||
'channel' => 'email',
|
||||
'user_id' => null,
|
||||
'target_hash' => hash('sha256', 'kve2029-'.Str::random(8)),
|
||||
'status' => IdentityVerificationStatus::Verified->value,
|
||||
'attempts' => 1,
|
||||
'max_attempts' => 5,
|
||||
'verification_token' => 'kve2029-'.Str::random(32),
|
||||
'expires_at' => Carbon::now()->addMinutes(10),
|
||||
'verified_at' => Carbon::now()->subMinute(),
|
||||
'consumed_at' => null,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* 미만료 검증 토큰은 조회된다 (정상 흐름 불변).
|
||||
*/
|
||||
public function test_unexpired_verified_token_is_returned(): void
|
||||
{
|
||||
$log = $this->makeVerifiedLog();
|
||||
|
||||
$found = $this->repository->findVerifiedForToken(
|
||||
$log->verification_token,
|
||||
'sensitive_action'
|
||||
);
|
||||
|
||||
$this->assertNotNull($found);
|
||||
$this->assertSame($log->id, $found->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 검증 토큰은 조회되지 않는다 (KVE-2026-2029).
|
||||
*/
|
||||
public function test_expired_verified_token_is_not_returned(): void
|
||||
{
|
||||
$log = $this->makeVerifiedLog([
|
||||
'expires_at' => Carbon::now()->subMinute(),
|
||||
]);
|
||||
|
||||
$found = $this->repository->findVerifiedForToken(
|
||||
$log->verification_token,
|
||||
'sensitive_action'
|
||||
);
|
||||
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 경계: 1초 전은 통과, 1초 후는 차단.
|
||||
*/
|
||||
public function test_expiry_boundary_is_closed_strictly(): void
|
||||
{
|
||||
$valid = $this->makeVerifiedLog([
|
||||
'expires_at' => Carbon::now()->addSecond(),
|
||||
]);
|
||||
$expired = $this->makeVerifiedLog([
|
||||
'expires_at' => Carbon::now()->subSecond(),
|
||||
]);
|
||||
|
||||
$this->assertNotNull(
|
||||
$this->repository->findVerifiedForToken($valid->verification_token, 'sensitive_action')
|
||||
);
|
||||
$this->assertNull(
|
||||
$this->repository->findVerifiedForToken($expired->verification_token, 'sensitive_action')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* expires_at 이 비어 있는 토큰은 조회되지 않는다 (엄격 차단).
|
||||
*
|
||||
* 보안 토큰은 모든 provider 가 challenge 생성 시 expires_at 을 세팅한다.
|
||||
* NULL 은 정상 발급 산물이 아니므로 허용 분기를 두지 않는다.
|
||||
*/
|
||||
public function test_token_without_expiry_is_not_returned(): void
|
||||
{
|
||||
$log = $this->makeVerifiedLog([
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
$found = $this->repository->findVerifiedForToken(
|
||||
$log->verification_token,
|
||||
'sensitive_action'
|
||||
);
|
||||
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 소비된 토큰은 조회되지 않는다 (기존 동작 보존).
|
||||
*/
|
||||
public function test_consumed_token_is_not_returned(): void
|
||||
{
|
||||
$log = $this->makeVerifiedLog([
|
||||
'consumed_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$found = $this->repository->findVerifiedForToken(
|
||||
$log->verification_token,
|
||||
'sensitive_action'
|
||||
);
|
||||
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
/**
|
||||
* 목적(purpose)이 다르면 조회되지 않는다 (기존 동작 보존).
|
||||
*/
|
||||
public function test_token_with_other_purpose_is_not_returned(): void
|
||||
{
|
||||
$log = $this->makeVerifiedLog();
|
||||
|
||||
$found = $this->repository->findVerifiedForToken(
|
||||
$log->verification_token,
|
||||
'signup'
|
||||
);
|
||||
|
||||
$this->assertNull($found);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user