# 본인인증 정책 시스템 (Identity Policies)

> **버전**: 7.0.0-beta.4 신설
> **위치**: `app/Services/IdentityPolicyService.php`, `app/Http/Middleware/EnforceIdentityPolicy.php`, `app/Listeners/Identity/EnforceIdentityPolicyListener.php`, `config/core.php` (`identity_policies` 블록)

## TL;DR (5초 요약)

```text
회원가입·비밀번호 재설정·민감 작업 등 모든 IDV 강제 시점은 IdentityPolicy 단일 진실로 통합
정책 = (scope, target, purpose, applies_to, conditions, enabled, grace, fail_mode) 9개 필드
미들웨어 `identity.policy:KEY` 부착 또는 `EnforceIdentityPolicyListener` 의 hook scope 매칭으로 자동 enforce
applies_to 는 permission 기반 admin 판정 (User::isAdmin() — type='admin' 권한 보유 역할 검사)
회원가입 단계 분기는 `conditions.signup_stage` 키로 표현 (before_submit / after_create)
```

## 1. 정책의 구성

`identity_policies` 테이블의 한 행 = 한 정책. 핵심 필드:

| 필드 | 의미 | 값 |
|------|------|---|
| `key` | 고유 식별자 (시드 기준) | `core.auth.signup_before_submit` |
| `scope` | 매칭 단위 | `route` (라우트 미들웨어) / `hook` (action 훅) |
| `target` | scope 별 대상 식별자 | route name 또는 hook name |
| `purpose` | IDV 목적 | `signup` / `password_reset` / `self_update` / `sensitive_action` 등 |
| `provider_id` | 강제할 IDV provider | `null` 이면 매니저 default |
| `applies_to` | 강제 대상 사용자군 | `self` (관리자 제외) / `admin` (관리자만) / `both` |
| `enabled` | 활성 여부 | bool |
| `grace_minutes` | 통과 유예 시간 | 0 = 매번 요구, n = n분 내 verified 있으면 통과 |
| `fail_mode` | 실패 시 동작 | `block` (예외) / `log_only` (감사 로그만) |
| `priority` | 매칭 우선순위 | 높을수록 먼저 평가. 동률이면 id 오름차순(먼저 생성된 정책 우선)으로 결정적 정렬. 운영자 UI 에서 같은 scope+target 에 동률 활성 정책 저장은 차단 — §1.1 |
| `conditions` | 추가 매칭 조건 (JSON) | `http_method` / `changed_fields` / `user_role` / `signup_stage` |

### 1.1 priority 동률 처리

같은 `scope`+`target` 에 여러 정책이 걸릴 수 있고, enforce 는 `priority` 내림차순으로 순회해 가장 먼저 차단(428)하는 정책을 적용한다. `priority` 가 같으면 적용 순서가 비결정적이 되어 "운영자가 의도한 정책(예: 성인 인증)이 무시되고 임의 정책이 먼저 적용"되는 결함이 발생했다. 두 층위로 차단한다:

1. **결정적 정렬** — `IdentityPolicyRepository::resolveByScopeTarget()` / `getRouteScopeIndex()` 가 `orderByDesc('priority')->orderBy('id')` 로 정렬. 동률이어도 항상 id 오름차순(먼저 생성된 정책 우선)이라 enforce 순서가 캐시 재빌드/실행계획과 무관하게 고정된다.

2. **동률 저장 차단** — 운영자가 관리자 UI 에서 정책을 생성/수정할 때, 같은 `scope`+`target` 에 **동일 priority 활성 정책**이 이미 있으면 422 로 거부한다 (`App\Rules\UniquePolicyPriorityPerTarget`, Store/Update FormRequest). 비활성(`enabled=false`) 정책은 enforce 대상이 아니므로 동률이어도 무해 — 저장하려는 정책과 기존 정책이 **둘 다 활성**일 때만 충돌로 본다.

운영자 입력 UI: 정책 폼 모달의 "우선순위" 입력칸(0~65535, 기본 100) + 정책 목록의 "우선순위" 컬럼. 코어(`sirsoft-admin_basic`)·모듈(`sirsoft-board`/`sirsoft-ecommerce`) 환경설정 정책 탭 모두 동일.

## 2. 두 가지 enforce 경로 (모두 동적 — 정책 DB toggle 만으로 즉시 효과)

### 2.1 라우트 자동 매핑 (scope=route)

`EnforceIdentityPolicy` 미들웨어가 [bootstrap/app.php](../../bootstrap/app.php) 에서 `appendToGroup('api', ...)` 로 등록되어 모든 API 요청에 적용됩니다. 매 요청마다 `IdentityPolicyRepository::getRouteScopeIndex()` 가 캐시된 `[route_name => Collection<IdentityPolicy>]` 맵에서 현재 라우트명으로 매칭 정책을 찾아 `enforce()` 호출. 무매칭 라우트는 즉시 통과 (O(1)).

```text
정책 DB 에서 enable 토글 → IdentityPolicy 모델 saved 이벤트 → CacheInterface::flushTags(['identity_policy']) → 다음 요청부터 즉시 반영
```

**라우트 코드 수정 불필요**:

```php
// routes/api.php — 미들웨어 명시 등록 없음
Route::post('register', [UserAuthController::class, 'register'])
    ->name('api.auth.register');  // ← 자동 매핑이 'api.auth.register' → core.auth.signup_before_submit 정책 매칭
```

**명시 형태도 backward compat 으로 보존** — 외부 모듈/플러그인이 자기 정책 키를 강제하고 싶을 때:

```php
Route::put('/sensitive', [...])->middleware('identity.policy:vendor.module.sensitive_action');
```

자동 매핑은 정책의 `target` 컬럼을 라우트 이름과 1:1 매칭. brace expansion 지원: `target='api.admin.{modules,plugins}.uninstall'` → 두 라우트명 모두 매핑.

### 2.2 훅 리스너 (scope=hook)

`EnforceIdentityPolicyListener::loadDynamicHookTargets()` 가 부팅 시 DB 의 `scope='hook'` 정책 target 을 자동 구독. 훅 이름이 매칭하면 `enforce()` 호출. Service/잡/Artisan 등 라우트 외 진입점도 일괄 보호.

scope=route 가 자동 매핑으로 동일한 "DB toggle = 즉시 효과" 모델을 따르므로, 두 경로 모두 운영자가 admin UI 에서 정책을 토글하는 것만으로 즉시 보안 정책이 변경됩니다 — 라우트/Service 코드 수정 불필요.

### 2.3 catch-all 안전망 — IdentityVerificationRequiredException 의 \Error 상속

코어/모듈/플러그인 컨트롤러 다수가 `try { ... } catch (\Exception $e) { ... }` 로 자체 응답 변환을 합니다. IDV 예외가 `\Exception` 자식이면 그 catch-all 에 포획되어 422 일반 에러로 강등 → 프론트가 모달을 띄우지 못합니다.

[IdentityVerificationRequiredException](../../app/Exceptions/IdentityVerificationRequiredException.php) 은 의도적으로 `\Error` 를 상속하여 `catch (\Exception)` 을 우회합니다. PHP 의 `\Error` 와 `\Exception` 은 별도 계층이며 `\Throwable` 만 공통 부모. Laravel 글로벌 핸들러의 `render(Throwable)` 콜백은 정상 매칭되어 428 응답 발급.

**라우트 작성 시 별도 안전망 코드 작성 불필요** — 어떤 catch-all 패턴을 쓰더라도 IDV 흐름은 항상 글로벌 핸들러까지 도달.

## 3. `applies_to` 분기 (permission 기반)

`IdentityPolicyService::isAdminContext()` 가 admin 여부 판정:

```text
1. context['user_is_admin'] 가 명시되어 있으면 그 값 (미들웨어 fast path)
2. User::isAdmin() — type='admin' 권한을 보유한 역할이 1개라도 있으면 true
```

- `applies_to='self'` + admin → enforce **하지 않음** (관리자 제외)
- `applies_to='admin'` + 일반 사용자 → enforce **하지 않음** (관리자만 강제)
- `applies_to='both'` → 모두 enforce

> ⚠️ `context['user_roles']` 는 `conditions.user_role` 매칭 전용이며 admin 판정 입력으로는 사용하지 않습니다 (role identifier 와 권한 보유는 별개의 개념).

## 4. `conditions` 매칭 키

`policyMatchesContext()` 가 검사하는 4종 키 — 모두 명시된 조건이 통과해야 정책이 매칭됨:

| 키 | 컨텍스트 키 | 예시 |
|---|------------|------|
| `http_method` | `context['http_method']` | `['POST']` |
| `changed_fields` | `context['changed_fields']` | `['email', 'phone']` (교집합 1개 이상) |
| `user_role` | `context['user_roles']` | `['admin']` (교집합 1개 이상) |
| `signup_stage` | `context['signup_stage']` | `'before_submit'` 또는 `'after_create'` |

### 4.1 `signup_stage` 사용 사례 — 회원가입 단계 분기

| 단계 | 정책 KEY | scope/target | enabled 시 동작 |
|------|----------|--------------|---------------|
| 가입 제출 전 | `core.auth.signup_before_submit` | `route` / `api.auth.register` | `verification_token` 검증 룰 자동 주입 + 미들웨어 enforce |
| 가입 후 활성화 전 | `core.auth.signup_after_create` | `hook` / `core.auth.after_register` | 사용자를 `PendingVerification` 으로 생성 + challenge 자동 발행 |

두 정책 모두 비활성이면 일반 가입 흐름 (Active 사용자 즉시 생성, IDV 없음).

## 5. filter 훅 — `core.identity.resolve_policy`

플러그인이 정책 해석을 가로채기 위한 훅:

```php
HookManager::addFilter(
    'core.identity.resolve_policy',
    fn (?IdentityPolicy $policy, string $scope, string $target, array $context): ?IdentityPolicy
        => /* 다른 IdentityPolicy 인스턴스 또는 그대로 반환 */,
);
```

**보안**: 훅이 `IdentityPolicy` 외 타입(`null` 등)을 반환하면 원본 정책이 그대로 유지됩니다 (silent retention). 의도적으로 정책을 변경하려면 반드시 `IdentityPolicy` 인스턴스를 반환할 것.

## 5.1 정책 provider_id 해석 — fallback 체인

`IdentityPolicyService::enforce()` 는 정책 위반 시 `IdentityVerificationRequiredException` 을 던지는데, 정책에 `provider_id` 가 명시되지 않은 경우 `resolveProviderId()` 가 환경설정의 기본 프로바이더로 자동 fallback 합니다. 운영자가 정책마다 provider 를 일일이 지정하지 않아도 환경설정 default 가 일관 적용되도록 보장합니다.

해석 순서:

1. 정책 `provider_id` 가 등록된 provider 면 그대로 사용
2. 정책 미명시 또는 미등록 provider → `IdentityVerificationManager::resolveForPurpose()` 호출 (purpose 기반 fallback 체인 — 환경설정 `purpose_providers.{purpose}` → `default_provider` → 등록된 첫 provider)
3. Manager 예외 시 정책의 원본 provider_id 그대로 반환 (정책 정보 보존)

`resolveRenderHint()` 와 동일한 우선순위 체인을 적용하여 정책에 provider 가 지정되지 않아도 환경설정 default 가 모달 launcher payload 의 `provider_id` 로 정확히 전달됩니다.

## 5.2 Service 라이프사이클 훅 (cancel / consume_token)

`IdentityVerificationService` 는 challenge 라이프사이클 각 단계마다 `before_*` / `after_*` 페어로 hook 을 발행합니다. 외부 plugin 이 자기 record 정리·후속 작업을 listener 로 분리할 수 있습니다.

| 단계 | before hook | after hook |
| --- | --- | --- |
| Challenge 발급 | `core.identity.before_request` | `core.identity.after_request` |
| Verify | `core.identity.before_verify` | `core.identity.after_verify` |
| Cancel | `core.identity.before_cancel` | `core.identity.after_cancel` |
| Consume Token | `core.identity.before_consume_token` | `core.identity.after_consume_token` |

`after_cancel` / `after_consume_token` 의 세 번째 인자는 작업 성공 여부 (`bool`). 대상 log 가 없는 경우 두 번째 인자(`$log`) 는 `null` 이고 성공 여부는 `false`. 모든 경우에 before/after 둘 다 발행됩니다.

```php
// 예: 이니시스 challenge cancel 시 자기 mapping row 정리
class CleanInicisMappingOnCancel implements HookListenerInterface
{
    public static function getSubscribedHooks(): array
    {
        return [
            'core.identity.after_cancel' => ['method' => 'handle', 'priority' => 20],
        ];
    }

    public function handle(string $challengeId, ?IdentityVerificationLog $log, bool $success): void
    {
        if ($success && $log?->provider_id === 'inicis') {
            $this->mappingRepository->deleteByChallengeId($challengeId);
        }
    }
}
```

## 6. 시드 정책 (코어 기본 9종, 모두 `core.*` namespace)

`config/core.php` `identity_policies` 블록에 선언. `IdentityPolicySeeder` 가 동기화하며 `HasUserOverrides` 트레이트가 운영자 수정값을 보존합니다.

| 정책 KEY | 기본 enabled | 용도 |
|----------|--------------|------|
| `core.auth.signup_before_submit` | `false` | 가입 제출 전 IDV (운영자 opt-in) |
| `core.auth.signup_after_create` | `false` | 가입 후 활성화 전 IDV (운영자 opt-in) |
| `core.auth.password_reset` | `false` | 비밀번호 재설정 IDV (운영자 opt-in) |
| `core.profile.password_change` | `true` | 로그인 상태 비밀번호 변경 IDV |
| `core.profile.contact_change` | `true` | 이메일/전화 변경 IDV (`changed_fields` 매칭) |
| `core.account.withdraw` | `true` | 계정 탈퇴 IDV |
| `core.admin.app_key_regenerate` | `false` | App Key 재생성 (관리자 한정) |
| `core.admin.user_delete` | `false` | 사용자 삭제 (관리자 한정) |
| `core.admin.extension_uninstall` | `false` | 모듈/플러그인 제거 (관리자 한정) |

## 7. 모듈/플러그인이 정책 추가하는 방법

```php
// modules/{vendor}-{name}/module.php
public function getIdentityPolicies(): array
{
    return [
        [
            'key' => 'sirsoft-ecommerce.checkout.high_value',
            'scope' => 'hook',
            'target' => 'sirsoft-ecommerce.order.before_create',
            'purpose' => 'sensitive_action',
            'enabled' => false,
            'applies_to' => 'self',
            'fail_mode' => 'block',
            'grace_minutes' => 5,
        ],
    ];
}
```

확장 활성화 시 `IdentityPolicySyncHelper` 가 자동으로 DB 에 동기화. `cleanupStalePolicies` 가 미선언 정책을 자동 제거합니다 (운영자가 만든 `source_type='admin'` 정책은 영향 없음).

## 8. 잘못된 패턴 (DO NOT)

```text
❌ config('settings.identity.enabled') / 'signup.mode' 직접 read — 정책 시스템으로 통합되어 더 이상 존재하지 않음
❌ 'admin' role identifier 직접 가정한 admin 판정 — User::isAdmin() / context['user_is_admin'] 사용
❌ filter 훅 'core.identity.resolve_policy' 에서 null 반환으로 정책 우회 — 차단됨, 원본 정책 유지
❌ 정책 KEY 를 코드에 하드코딩 — config/core.php 또는 확장의 getIdentityPolicies() 에 선언
❌ DB 시뱀 직접 변경으로 정책 추가 — Seeder 재실행 시 cleanup 으로 삭제됨
```

## 8.1 언어팩 적용 비대상 (의도된 설계)

`identity_policies` 는 **lang pack seed 대상이 아니다**.

근거:
- `IdentityPolicy` 모델 fillable 에 `name` / `description` 등 **다국어 필드 부재** (key/scope/target/purpose 등 시스템 식별자만 보유)
- config/core.php 의 각 정책 entry 도 다국어 데이터 직접 보유하지 않음 (정책 키 자체가 식별자)
- 운영자 가시 라벨은 `templates/_bundled/sirsoft-admin_basic/lang/partial/{locale}/admin.json::identity.policy.*` i18n 키로 처리 → 언어팩 활성화 시 템플릿 lang pack 으로 자동 ja 표시

따라서 별도 lang pack seed 인프라(`seed/identity_policies.json` 등) 는 추가하지 않는다 (config 자체가 식별자 SSoT 이므로 번역 대상 없음).

## 9. 관련 코드 진입점

- `app/Services/IdentityPolicyService.php` — `resolve()` / `enforce()` / `isAdminContext()` / `policyMatchesContext()`
- `app/Http/Middleware/EnforceIdentityPolicy.php` — 라우트 단계 enforce + `user_is_admin` / `target_email` 컨텍스트 주입
- `app/Listeners/Identity/EnforceIdentityPolicyListener.php` — 훅 단계 enforce
- `app/Extension/Helpers/IdentityPolicySyncHelper.php` — Seeder 가 사용하는 upsert + cleanup
- `app/Models/IdentityPolicy.php` — `HasUserOverrides` 트레이트로 운영자 수정값 보존
- `config/core.php` `identity_policies` — 코어 기본 정책 선언

## 9.1 정책 ↔ 메시지 템플릿 연계

정책이 트리거하는 본인인증 메일 문구는 별도의 IDV 메시지 템플릿 시스템에서 관리합니다 (알림 시스템과 분리). `IdentityMessageDispatcher` 가 정책 컨텍스트를 받아 가장 구체적인 템플릿을 fallback 체인으로 해석합니다.

```text
policy:{policy_key}  →  purpose:{purpose}  →  provider_default
```

- 정책별 커스텀 문구가 필요하면 `(provider_id, scope_type='policy', scope_value=$policy->key)` 정의를 추가
- 그 외에는 기본 5종(provider_default + 4 purposes)이 자동 적용
- 운영자 편집 UI: 환경설정 → 본인인증 → "메시지 템플릿" 서브탭

상세: [identity-messages.md](identity-messages.md)

## 10. 프론트엔드 통합 (engine-v1.46.0+)

백엔드 정책 enforce → 428 응답을 프론트엔드가 가로채 모달/풀페이지로 본인 확인 흐름을 진입시키는 인프라:

- 코어 인터셉터: [`resources/js/core/identity/IdentityGuardInterceptor.ts`](../frontend/identity-guard-interceptor.md)
- 모달 UI 표준 + Extension Point 슬롯: [identity-verification-ui.md](../frontend/identity-verification-ui.md)
- 외부 템플릿 launcher 등록 가이드: [template-idv-bootstrap.md](../extension/template-idv-bootstrap.md)
- 모듈/플러그인 IDV 정책/목적 등록 + 외부 provider 통합: [module-identity-settings.md](../extension/module-identity-settings.md)

핵심 흐름:

1. 백엔드 미들웨어/리스너가 정책 위반 감지 시 `IdentityVerificationRequiredException` throw
2. `Handler` → `ResponseHelper::identityRequired()` 가 HTTP 428 + `verification` payload 응답
3. 프론트엔드 `IdentityGuardInterceptor.handle` 이 launcher 호출 → 모달 → verify → return_request 재실행
4. 재실행 요청은 `?verification_token=...` query 자동 부착 → `IdvTokenRule` 통과

## 11. 비동기·외부 redirect 플러그인 통합 (engine-v1.46.0+)

Stripe Identity / 토스인증 push / 외부 redirect provider 등 클라이언트가 verify 즉시 응답을 받지 못하는 흐름을 위한 백엔드 인프라:

### 11.1 `IdentityVerificationStatus.Processing` 상태값

비동기 검증 진행 중 상태. `Sent` → `Processing` → `Verified|Failed|Expired` 전이.

### 11.2 `GET /api/identity/challenges/{id}` — 폴링 엔드포인트

클라이언트가 challenge 의 공개 상태를 조회. 시도 횟수·코드 본체·내부 metadata 는 노출하지 않고 다음 필드만:

```json
{
  "success": true,
  "data": {
    "id": "uuid",
    "status": "pending|processing|verified|failed|expired|cancelled",
    "render_hint": "text_code|link|external_redirect|...",
    "expires_at": "2026-04-27T10:15:00+00:00",
    "public_payload": { "code_length": 6 }
  }
}
```

- 라우트: `api.identity.challenges.show`
- throttle: 30/1min
- optional.sanctum (비로그인 가입 흐름도 폴링 가능)

### 11.3 `POST /api/identity/callback/{providerId}` — 외부 redirect 콜백 수신

외부 IDV provider 가 사용자 브라우저를 우리 서버로 다시 보내는 진입점. 처리 흐름:

1. body/query 에서 `challenge_id` 추출 (FormRequest `IdentityCallbackRequest`)
2. `IdentityVerificationService::handleProviderCallback($providerId, $challengeId, $input)` 위임
3. provider 식별자 일치 검증 — 불일치 시 `WRONG_PROVIDER` failure
4. provider 의 `verify($challengeId, $input, $context)` 위임 — 일반 verify 와 동일 경로 (after_verify 훅 발화 등)
5. 성공 + `?return=` 안전한 same-origin URL 있음 → 302 → `{return}?verification_token=...`
6. 성공 + return 없음 → 200 JSON `{ verification_token }`
7. 실패 + return 안전 → 302 → `{return}?identity_error={failure_code}`
8. 실패 + return 없음 → 422 JSON

Open redirect 차단: `isSafeReturnUrl()` 가 절대 URL 의 host 가 앱 host 와 일치하는지 검증. 프로토콜 상대 URL(`//evil.example.com`) 도 차단.

- 라우트: `api.identity.callback`
- throttle: 30/1min
- optional.sanctum (외부 redirect 시 세션이 손실되었을 수 있음)

### 11.4 외부 IDV provider 가 비동기 인프라를 사용하는 시퀀스

```text
[클라이언트] launcher 호출 → POST /api/identity/challenges
  ↓ 응답 redirect_url 포함
[클라이언트] sessionStorage stash + window.location = redirect_url
  ↓
[provider 도메인] 사용자 인증 진행
  ↓
[provider → 우리 서버] POST /api/identity/callback/{providerId}?return=https://...
  ↓
[handleProviderCallback] verify 위임 → verification_token 발급
  ↓ 302 redirect to return URL with verification_token query
[클라이언트 원 페이지] sessionStorage stash 복원 + 원 요청 재실행 (token 자동 동봉)
```
