Files
Gnuboard7/docs/backend/identity-policies.md
T
HeuJung 7b56d5f23a feat(core,board,ecommerce,page,ckeditor5,kginicis): 업로드 이미지 축소·로그인 2단계 인증 도입 + 파생 결함 수정
인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서
발견한 결함 2건을 함께 고쳤다.

기능 도입:
- 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality`
 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지
 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호).
 PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다.
- 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면
 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호
 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor`
 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의
 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이
 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다.

결함 수정:
- 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과
 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고
 있어 같은 요청이 화면에 따라 다르게 동작했다.
- 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드
 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃
 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다.
- 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도
 매번 404 요청이 나갔다.
- 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다.

커밋 전 점검에서 발견:
- 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose
 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose`
 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지
 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀
 테스트를 함께 둔다.
- 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다.
 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지
 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다.

재발 방지:
- audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출)
- 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차)
- `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존)

공개 이슈: gnuboard/g7
2026-07-30 07:57:06 +09:00

340 lines
20 KiB
Markdown

# 본인인증 정책 시스템 (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` / `login` 등 |
| `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 자동 동봉)
```