seed(IdentityPolicySeeder::class); $this->middleware = $this->app->make(EnforceIdentityPolicy::class); } /** * @return array [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 provider id 매트릭스 — 인프라 규약을 따르는 한 provider 무관. */ public static function providerMatrix(): array { return [ 'core.mail' => ['g7:core.mail'], 'hypothetical.sms' => ['g7:plugin.sms'], 'hypothetical.kcp' => ['g7:plugin.kcp'], ]; } #[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 \Illuminate\Http\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 \Illuminate\Http\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 \Illuminate\Http\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 \Illuminate\Http\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 \Illuminate\Http\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 \Illuminate\Http\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 \Illuminate\Http\Response('ok'); }, 'core.auth.signup_before_submit'); $this->assertTrue($passed, "provider={$providerId} 토큰 우회가 동작하지 않음"); } }