메일 인증 대신 휴대폰 본인확인을 쓸 수 있게 하는 플러그인을 추가한다. 가입·비밀번호 재설정·성인인증 등 코어 IDV 가 강제하는 모든 지점에서 동작하며, 테스트 모드로 계약 없이 전 흐름을 확인할 수 있다. 구현·검수 과정에서 드러난 저장소 전역 결함을 함께 처리했다. - 외래키 컬럼의 한국어 comment 가 방출되지 않던 문제 (소스 38건 교정 + 기설치본 백필 업그레이드 스텝 7종). ->comment 를 ->constrained 뒤에 체인하면 예외 없이 통과하면서 comment 가 조용히 사라진다 - 로케일 전환 후 확장 액션 핸들러가 소실되던 문제 (플러그인 3종에 재등록 진입점 노출) - 본인인증 상태 폴링 응답이 누적 시도 횟수를 함께 내보내던 문제 - 인증 안내 문구와 실제 진행 방식의 폭 경계 불일치 (768~1023px). 엔진과 같은 값을 같은 방법으로 읽도록 교정 - transition_overlay_target 이 replace 없이 무효이던 관리자 목록 40곳. 로딩 표시가 나오지 않아도 경고가 남지 않아 화면을 봐야만 알 수 있었다 두 인증 플러그인의 코어 최소 요구 버전을 7.0.6 으로 올린다. 운영 모드 자격증명 필수 검사가 7.0.6 의 설정 저장 검증 표면에 의존하며, 그보다 낮은 코어에서는 그 검사가 조용히 통과해 빈 자격증명으로 저장할 수 있었다. 재발 차단으로 정적 검사 룰 4종을 신설하고, 검사 도구가 미커밋 신규 확장을 "검사 파일 0" 으로 통과시키던 사각을 함께 막았다.
266 lines
10 KiB
PHP
266 lines
10 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Identity;
|
||
|
||
use App\Enums\IdentityVerificationStatus;
|
||
use App\Exceptions\IdentityVerificationRequiredException;
|
||
use App\Http\Middleware\EnforceIdentityPolicy;
|
||
use App\Models\IdentityPolicy;
|
||
use App\Models\IdentityVerificationLog;
|
||
use Database\Seeders\IdentityPolicySeeder;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Str;
|
||
use PHPUnit\Framework\Attributes\DataProvider;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* EnforceIdentityPolicy 미들웨어의 verification_token 우회 매트릭스 회귀.
|
||
*
|
||
* 검증 차원:
|
||
* - purpose × token 상태 (verified+미소비, verified+소비됨, status!=verified, 다른 purpose, 위조)
|
||
* - target 매칭 (일치 / 불일치 / 인증 사용자 컨텍스트 / 식별자 미존재)
|
||
* - provider 비종속성 — provider_id 가 무엇이든 IdentityVerificationLog 인프라만 따르면 동작
|
||
*
|
||
* 새 IDV provider 추가 시 이 매트릭스가 통과해야 token-based 재시도 흐름이 깨지지 않음.
|
||
*
|
||
* @since 7.0.0-beta.4
|
||
*/
|
||
class EnforceIdentityPolicyTokenBypassTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private EnforceIdentityPolicy $middleware;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->seed(IdentityPolicySeeder::class);
|
||
$this->middleware = $this->app->make(EnforceIdentityPolicy::class);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, array{0: string, 1: string}> [policy_key, purpose]
|
||
*/
|
||
public static function enforcedPolicyMatrix(): array
|
||
{
|
||
return [
|
||
'signup_before_submit' => ['core.auth.signup_before_submit', 'signup'],
|
||
'password_reset' => ['core.auth.password_reset', 'password_reset'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string, array{0: string}> provider id 매트릭스 — 인프라 규약을 따르는 한 provider 무관.
|
||
*/
|
||
public static function providerMatrix(): array
|
||
{
|
||
return [
|
||
'core.mail' => ['g7:core.mail'],
|
||
'hypothetical.sms' => ['g7:plugin.sms'],
|
||
'hypothetical.kcp' => ['g7:plugin.kcp'],
|
||
'plugin.nhnkcp' => ['nhnkcp'],
|
||
];
|
||
}
|
||
|
||
#[DataProvider('enforcedPolicyMatrix')]
|
||
public function test_valid_token_bypasses_for_each_policy(string $policyKey, string $purpose): void
|
||
{
|
||
IdentityPolicy::where('key', $policyKey)->update(['enabled' => true]);
|
||
$email = 'matrix-'.$purpose.'@example.com';
|
||
$token = 'tok-'.bin2hex(random_bytes(8));
|
||
|
||
IdentityVerificationLog::create([
|
||
'id' => (string) Str::uuid(),
|
||
'provider_id' => 'g7:core.mail',
|
||
'purpose' => $purpose,
|
||
'channel' => 'email',
|
||
'target_hash' => hash('sha256', $email),
|
||
'status' => IdentityVerificationStatus::Verified->value,
|
||
'verification_token' => $token,
|
||
'verified_at' => now()->subSeconds(2),
|
||
'expires_at' => now()->addMinutes(15),
|
||
]);
|
||
|
||
$request = Request::create('/api/test', 'POST', [
|
||
'email' => $email,
|
||
'verification_token' => $token,
|
||
]);
|
||
|
||
$passed = false;
|
||
$this->middleware->handle($request, function () use (&$passed) {
|
||
$passed = true;
|
||
|
||
return new Response('ok');
|
||
}, $policyKey);
|
||
|
||
$this->assertTrue($passed, "policy={$policyKey} 가 token 으로 통과하지 못함");
|
||
}
|
||
|
||
#[DataProvider('enforcedPolicyMatrix')]
|
||
public function test_consumed_token_does_not_bypass(string $policyKey, string $purpose): void
|
||
{
|
||
IdentityPolicy::where('key', $policyKey)->update(['enabled' => true]);
|
||
$email = 'consumed-'.$purpose.'@example.com';
|
||
$token = 'tok-'.bin2hex(random_bytes(8));
|
||
|
||
IdentityVerificationLog::create([
|
||
'id' => (string) Str::uuid(),
|
||
'provider_id' => 'g7:core.mail',
|
||
'purpose' => $purpose,
|
||
'channel' => 'email',
|
||
'target_hash' => hash('sha256', $email),
|
||
'status' => IdentityVerificationStatus::Verified->value,
|
||
'verification_token' => $token,
|
||
'verified_at' => now()->subSeconds(2),
|
||
'consumed_at' => now()->subSecond(),
|
||
'expires_at' => now()->addMinutes(15),
|
||
]);
|
||
|
||
$request = Request::create('/api/test', 'POST', [
|
||
'email' => $email,
|
||
'verification_token' => $token,
|
||
]);
|
||
|
||
$this->expectException(IdentityVerificationRequiredException::class);
|
||
$this->middleware->handle($request, fn () => new Response('ok'), $policyKey);
|
||
}
|
||
|
||
#[DataProvider('enforcedPolicyMatrix')]
|
||
public function test_token_with_wrong_purpose_does_not_bypass(string $policyKey, string $purpose): void
|
||
{
|
||
IdentityPolicy::where('key', $policyKey)->update(['enabled' => true]);
|
||
$email = 'wrongp-'.$purpose.'@example.com';
|
||
$token = 'tok-'.bin2hex(random_bytes(8));
|
||
|
||
// 토큰은 sensitive_action 으로 발급됨 — 정책의 purpose 와 다름
|
||
IdentityVerificationLog::create([
|
||
'id' => (string) Str::uuid(),
|
||
'provider_id' => 'g7:core.mail',
|
||
'purpose' => 'sensitive_action',
|
||
'channel' => 'email',
|
||
'target_hash' => hash('sha256', $email),
|
||
'status' => IdentityVerificationStatus::Verified->value,
|
||
'verification_token' => $token,
|
||
'verified_at' => now()->subSeconds(2),
|
||
'expires_at' => now()->addMinutes(15),
|
||
]);
|
||
|
||
$request = Request::create('/api/test', 'POST', [
|
||
'email' => $email,
|
||
'verification_token' => $token,
|
||
]);
|
||
|
||
$this->expectException(IdentityVerificationRequiredException::class);
|
||
$this->middleware->handle($request, fn () => new Response('ok'), $policyKey);
|
||
}
|
||
|
||
#[DataProvider('enforcedPolicyMatrix')]
|
||
public function test_unverified_status_does_not_bypass(string $policyKey, string $purpose): void
|
||
{
|
||
IdentityPolicy::where('key', $policyKey)->update(['enabled' => true]);
|
||
$email = 'unver-'.$purpose.'@example.com';
|
||
$token = 'tok-'.bin2hex(random_bytes(8));
|
||
|
||
IdentityVerificationLog::create([
|
||
'id' => (string) Str::uuid(),
|
||
'provider_id' => 'g7:core.mail',
|
||
'purpose' => $purpose,
|
||
'channel' => 'email',
|
||
'target_hash' => hash('sha256', $email),
|
||
// verified 이전 단계 — verify 미완료
|
||
'status' => IdentityVerificationStatus::Sent->value,
|
||
'verification_token' => $token,
|
||
'expires_at' => now()->addMinutes(15),
|
||
]);
|
||
|
||
$request = Request::create('/api/test', 'POST', [
|
||
'email' => $email,
|
||
'verification_token' => $token,
|
||
]);
|
||
|
||
$this->expectException(IdentityVerificationRequiredException::class);
|
||
$this->middleware->handle($request, fn () => new Response('ok'), $policyKey);
|
||
}
|
||
|
||
public function test_forged_token_not_in_db_does_not_bypass(): void
|
||
{
|
||
IdentityPolicy::where('key', 'core.auth.signup_before_submit')->update(['enabled' => true]);
|
||
|
||
$request = Request::create('/api/test', 'POST', [
|
||
'email' => 'victim@example.com',
|
||
'verification_token' => 'forged-'.bin2hex(random_bytes(16)),
|
||
]);
|
||
|
||
$this->expectException(IdentityVerificationRequiredException::class);
|
||
$this->middleware->handle($request, fn () => new Response('ok'), 'core.auth.signup_before_submit');
|
||
}
|
||
|
||
public function test_token_target_mismatch_does_not_bypass(): void
|
||
{
|
||
IdentityPolicy::where('key', 'core.auth.signup_before_submit')->update(['enabled' => true]);
|
||
$token = 'tok-'.bin2hex(random_bytes(8));
|
||
|
||
// 토큰은 a@example.com 으로 발급
|
||
IdentityVerificationLog::create([
|
||
'id' => (string) Str::uuid(),
|
||
'provider_id' => 'g7:core.mail',
|
||
'purpose' => 'signup',
|
||
'channel' => 'email',
|
||
'target_hash' => hash('sha256', 'a@example.com'),
|
||
'status' => IdentityVerificationStatus::Verified->value,
|
||
'verification_token' => $token,
|
||
'verified_at' => now()->subSeconds(2),
|
||
'expires_at' => now()->addMinutes(15),
|
||
]);
|
||
|
||
// 공격자가 b@example.com 로 hijack 시도
|
||
$request = Request::create('/api/test', 'POST', [
|
||
'email' => 'b@example.com',
|
||
'verification_token' => $token,
|
||
]);
|
||
|
||
$this->expectException(IdentityVerificationRequiredException::class);
|
||
$this->middleware->handle($request, fn () => new Response('ok'), 'core.auth.signup_before_submit');
|
||
}
|
||
|
||
/**
|
||
* provider 비종속성 — provider_id 가 무엇이든 IdentityVerificationLog 인프라(verified/token/target_hash)만
|
||
* 따르면 동일하게 통과. 새 provider(SMS/KCP/외부 SDK 등) 추가 시 이 동작이 자동 보장됨.
|
||
*/
|
||
#[DataProvider('providerMatrix')]
|
||
public function test_token_bypass_is_provider_agnostic(string $providerId): void
|
||
{
|
||
IdentityPolicy::where('key', 'core.auth.signup_before_submit')->update(['enabled' => true]);
|
||
$email = 'p-'.md5($providerId).'@example.com';
|
||
$token = 'tok-'.bin2hex(random_bytes(8));
|
||
|
||
IdentityVerificationLog::create([
|
||
'id' => (string) Str::uuid(),
|
||
'provider_id' => $providerId,
|
||
'purpose' => 'signup',
|
||
'channel' => 'sms',
|
||
'target_hash' => hash('sha256', $email),
|
||
'status' => IdentityVerificationStatus::Verified->value,
|
||
'verification_token' => $token,
|
||
'verified_at' => now()->subSeconds(2),
|
||
'expires_at' => now()->addMinutes(15),
|
||
]);
|
||
|
||
$request = Request::create('/api/test', 'POST', [
|
||
'email' => $email,
|
||
'verification_token' => $token,
|
||
]);
|
||
|
||
$passed = false;
|
||
$this->middleware->handle($request, function () use (&$passed) {
|
||
$passed = true;
|
||
|
||
return new Response('ok');
|
||
}, 'core.auth.signup_before_submit');
|
||
|
||
$this->assertTrue($passed, "provider={$providerId} 토큰 우회가 동작하지 않음");
|
||
}
|
||
}
|