$target 로그인 사용자(User) 또는 ['email' => '...'] 배열 * @param array $context origin_type / origin_identifier / origin_policy_key / ip_address / user_agent * @param string|null $providerId Mode A controller 요청 또는 Mode B 정책의 명시 provider id * @return VerificationChallenge 발행된 challenge DTO */ public function start( string $purpose, User|array $target, array $context = [], ?string $providerId = null, ): VerificationChallenge { $provider = $this->manager->resolveForPurpose($purpose, $providerId); $context['purpose'] = $purpose; HookManager::doAction('core.identity.before_request', $purpose, $target, $context); $challenge = $provider->requestChallenge($target, $context); HookManager::doAction('core.identity.after_request', $challenge, $purpose, $target, $context); return $challenge; } /** * Challenge 를 검증합니다. * * @param string $challengeId Challenge UUID * @param array $input 프로바이더별 입력 (코드, 토큰 등) * @param array $context origin 정보 (origin_type/origin_identifier 등) * @return VerificationResult 검증 결과 DTO (success/실패 사유 포함) */ public function verify(string $challengeId, array $input, array $context = []): VerificationResult { $log = $this->logRepository->findById($challengeId); if (! $log) { HookManager::doAction('core.identity.before_verify', $challengeId, null, $context); $result = VerificationResult::failure($challengeId, 'unknown', 'NOT_FOUND', 'identity.errors.challenge_not_found'); HookManager::doAction('core.identity.after_verify', $result, null, $context); return $result; } $provider = $this->manager->get($log->provider_id); HookManager::doAction('core.identity.before_verify', $challengeId, $log, $context); $result = $provider->verify($challengeId, $input, $context); if ($result->success && $log->user_id !== null) { $user = $this->userRepository->findById($log->user_id); if ($user !== null) { $this->userRepository->update($user, [ 'identity_verified_at' => $result->verifiedAt, 'identity_verified_provider' => $result->providerId, 'identity_verified_purpose_last' => $log->purpose, 'identity_hash' => $result->identityHash ?: null, ]); } } HookManager::doAction('core.identity.after_verify', $result, $log, $context); return $result; } /** * Challenge 를 취소합니다. * * before/after_cancel 훅을 발행하여 외부 plugin 이 자기 record (예: 이니시스의 challenge_mapping) * 를 cancel 시점에 정리할 수 있도록 한다. 다른 라이프사이클 이벤트(before/after_request, * before/after_verify) 와 일관된 hook 페어 구조 유지. * * @param string $challengeId Challenge UUID * @return bool 취소 성공 여부 (대상 challenge 가 없으면 false) */ public function cancel(string $challengeId): bool { $log = $this->logRepository->findById($challengeId); HookManager::doAction('core.identity.before_cancel', $challengeId, $log); if (! $log) { HookManager::doAction('core.identity.after_cancel', $challengeId, null, false); return false; } $result = $this->manager->get($log->provider_id)->cancel($challengeId); HookManager::doAction('core.identity.after_cancel', $challengeId, $log, $result); return $result; } /** * verification_token 을 소비(consume)합니다 — signup_before_submit 정책 통과 시 재사용 방지. * * before/after_consume_token 훅을 발행하여 외부 plugin 이 token 소비 시점에 자기 후속 작업 * (예: 이니시스의 가입 완료 후 record 영속화) 을 listener 로 분리할 수 있도록 한다. * * @param string $token IDV 발행 verification_token * @return bool consume 성공 여부 (verified 로그 부재 시 false) */ public function consumeToken(string $token): bool { $log = $this->logRepository->findVerifiedForToken($token, 'signup'); HookManager::doAction('core.identity.before_consume_token', $token, $log); if (! $log) { HookManager::doAction('core.identity.after_consume_token', $token, null, false); return false; } $result = $this->logRepository->updateById($log->id, [ 'consumed_at' => now(), ]); HookManager::doAction('core.identity.after_consume_token', $token, $log, $result); return $result; } /** * Challenge 의 공개 상태를 조회합니다 (폴링용). * * 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백 대기) 에서 클라이언트가 * `GET /api/identity/challenges/{id}` 로 상태를 폴링할 때 사용합니다. * * 반환 필드는 코드 본체·내부 metadata·시도 횟수를 제외한 공개 안전 필드만: * - id / status / provider_id / purpose / render_hint / expires_at / max_attempts / public_payload * * max_attempts 는 정책 상수라 노출해도 무방하며, 프론트 풀페이지 (`auth/identity_challenge.json`) 가 * 시도 한도 표시에 사용한다. 누적 시도 횟수(attempts)는 싣지 않는다 — 사유는 반환 지점 주석 참조. * * @param string $challengeId Challenge UUID * @return array|null 공개 상태 또는 null (없는 경우) * * @since engine-v1.46.0 */ public function getStatus(string $challengeId): ?array { $log = $this->logRepository->findById($challengeId); if (! $log) { return null; } $publicPayload = []; if (is_array($log->metadata) && isset($log->metadata['public_payload'])) { $publicPayload = $log->metadata['public_payload']; } elseif (is_array($log->properties) && isset($log->properties['public_payload'])) { $publicPayload = $log->properties['public_payload']; } // 누적 시도 횟수(attempts) 는 노출하지 않는다. // 이 엔드포인트는 권한 가드 없는 공개 폴링용이라 challenge id 만 알면 누구나 조회할 수 있고, // 남의 인증 시도 실패 횟수가 드러나면 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있다. // 상한(max_attempts) 은 정책 상수라 노출해도 무방하다 — 아래 반환 배열의 주석 참조. return [ 'id' => $log->id, 'status' => $log->status->value, 'provider_id' => $log->provider_id, 'purpose' => $log->purpose, 'render_hint' => $log->render_hint, 'expires_at' => optional($log->expires_at)->toIso8601String(), // 시도 횟수(attempts)는 공개 폴링 응답에 싣지 않는다 — challenge id 만 알면 누구나 // 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 드러나고 잠금 직전까지 // 시도 횟수를 맞춰 보는 데도 쓰일 수 있다. 상한(max_attempts)은 정책 상수라 노출해도 // 무방하며, 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다. 'max_attempts' => (int) $log->max_attempts, 'public_payload' => $publicPayload, ]; } /** * 지정 purpose 로 검증을 마친 challenge 의 대상 사용자를 반환합니다. * * 로그인 2단계 인증처럼 **아직 인증되지 않은 주체**를 challenge 로만 식별해야 하는 흐름에서 * 사용합니다. `getStatus()` 는 challenge id 만 알면 누구나 조회할 수 있는 공개 폴링 경로라 * `user_id` 를 싣지 않으므로, 주체 해석은 이 메서드로 분리합니다. * * purpose 를 인자로 받아 대조하는 이유: 다른 용도(가입·비밀번호 재설정)로 발급된 challenge 를 * 들고 와 로그인하는 것을 막기 위함입니다. * * @param string $challengeId Challenge UUID * @param string $purpose 기대하는 purpose * @return User|null 검증 완료된 대상 사용자 (조건 불일치 시 null) */ public function resolveVerifiedUser(string $challengeId, string $purpose): ?User { $log = $this->logRepository->findById($challengeId); if (! $log || $log->purpose !== $purpose || $log->verified_at === null) { return null; } if ($log->user_id === null) { return null; } return $this->userRepository->findById((int) $log->user_id); } /** * 외부 IDV provider 의 redirect 콜백을 처리합니다. * * `POST /api/identity/callback/{providerId}` 진입 후 컨트롤러가 호출합니다. * provider 의 `verify($challengeId, $input, $context)` 위임 — 이는 Mode B verify 와 동일한 경로. * * @param string $providerId 콜백을 보낸 provider 식별자 * @param string $challengeId body/query 에서 추출한 Challenge UUID * @param array $input provider 가 보낸 페이로드 (code/token/state 등) * @param array $context origin 정보 (origin_type=callback) * @return VerificationResult provider 의 검증 결과 (challenge mismatch 시 failure) * * @since engine-v1.46.0 */ public function handleProviderCallback(string $providerId, string $challengeId, array $input, array $context = []): VerificationResult { $log = $this->logRepository->findById($challengeId); if (! $log) { return VerificationResult::failure($challengeId, $providerId, 'NOT_FOUND', 'identity.errors.challenge_not_found'); } // provider 식별자 불일치 — 다른 provider 의 콜백이 잘못 라우팅된 경우 차단 if ($log->provider_id !== $providerId) { return VerificationResult::failure($challengeId, $providerId, 'WRONG_PROVIDER', 'identity.errors.wrong_provider'); } // 일반 verify 경로와 동일하게 위임 — verifyToken 발행, after_verify 훅 등 일관성 유지 $context['origin_type'] = $context['origin_type'] ?? 'callback'; $context['origin_identifier'] = $context['origin_identifier'] ?? "/api/identity/callback/{$providerId}"; return $this->verify($challengeId, $input, $context); } }