Files
Gnuboard7/tests/Feature/Identity/EnforceIdentityPolicyTokenBypassTest.php
T
HeuJung bd215bc586 feat(identity): NHN KCP 휴대폰 본인확인 플러그인 추가
메일 인증 대신 휴대폰 본인확인을 쓸 수 있게 하는 플러그인을 추가한다.
가입·비밀번호 재설정·성인인증 등 코어 IDV 가 강제하는 모든 지점에서 동작하며,
테스트 모드로 계약 없이 전 흐름을 확인할 수 있다.

구현·검수 과정에서 드러난 저장소 전역 결함을 함께 처리했다.

- 외래키 컬럼의 한국어 comment 가 방출되지 않던 문제 (소스 38건 교정 +
 기설치본 백필 업그레이드 스텝 7종). ->comment 를 ->constrained 뒤에
 체인하면 예외 없이 통과하면서 comment 가 조용히 사라진다
- 로케일 전환 후 확장 액션 핸들러가 소실되던 문제 (플러그인 3종에
 재등록 진입점 노출)
- 본인인증 상태 폴링 응답이 누적 시도 횟수를 함께 내보내던 문제
- 인증 안내 문구와 실제 진행 방식의 폭 경계 불일치 (768~1023px).
 엔진과 같은 값을 같은 방법으로 읽도록 교정
- transition_overlay_target 이 replace 없이 무효이던 관리자 목록 40곳.
 로딩 표시가 나오지 않아도 경고가 남지 않아 화면을 봐야만 알 수 있었다

두 인증 플러그인의 코어 최소 요구 버전을 7.0.6 으로 올린다. 운영 모드
자격증명 필수 검사가 7.0.6 의 설정 저장 검증 표면에 의존하며, 그보다 낮은
코어에서는 그 검사가 조용히 통과해 빈 자격증명으로 저장할 수 있었다.

재발 차단으로 정적 검사 룰 4종을 신설하고, 검사 도구가 미커밋 신규 확장을
"검사 파일 0" 으로 통과시키던 사각을 함께 막았다.
2026-07-31 00:32:46 +09:00

266 lines
10 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\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} 토큰 우회가 동작하지 않음");
}
}