채널 키 배열 */ public function getChannels(): array { return ['email']; } /** * 채널 키 → 다국어 표시 라벨 맵을 반환합니다. * * @return array 채널 키 → 라벨 맵 */ public function getChannelLabels(): array { return [ 'email' => __('identity.channels.email'), ]; } /** * 기본 렌더 힌트를 반환합니다. * * @return string 렌더 힌트 (text_code, link 등) */ public function getRenderHint(): string { // 기본값 — purpose 별 분기는 requestChallenge 내부에서 수행 return 'text_code'; } /** * 지정된 purpose 를 지원하는지 반환합니다. * * @param string $purpose 본인인증 목적 * @return bool 지원 여부 */ public function supportsPurpose(string $purpose): bool { // 메일 프로바이더는 모든 코어·플러그인 purpose 를 범용 지원 return true; } /** * 프로바이더 사용 가능 여부를 반환합니다. * * @return bool 메일러 설정 존재 시 true */ public function isAvailable(): bool { $mailer = (string) config('mail.default', ''); return $mailer !== ''; } /** * 인증 챌린지를 발급하고 메일을 발송합니다. * * @param User|array $target 대상 사용자 또는 식별 정보 * @param array $context 요청 컨텍스트 (purpose, ip_address 등) * @return VerificationChallenge 발급된 챌린지 * * @throws \InvalidArgumentException 이메일이 비어있는 경우 */ public function requestChallenge(User|array $target, array $context = []): VerificationChallenge { $email = $target instanceof User ? $target->email : (string) ($target['email'] ?? ''); if ($email === '') { throw new \InvalidArgumentException('MailIdentityProvider requires an email target.'); } $purpose = (string) ($context['purpose'] ?? 'sensitive_action'); $renderHint = $this->resolveRenderHint($purpose); $ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15); $maxAttempts = (int) config('settings.identity.max_attempts', 5); $targetHash = hash('sha256', mb_strtolower($email)); $metadata = []; $publicPayload = []; $code = null; $linkToken = null; if ($renderHint === 'text_code') { $code = $this->generateNumericCode((int) ($this->config['code_length'] ?? 6)); $metadata['code_hash'] = Hash::make($code); $publicPayload['code_length'] = strlen($code); } else { // link 흐름 — 수신자에게는 서명 링크 전달, 서버는 해시만 저장 $linkToken = Str::random(64); $metadata['link_token_hash'] = Hash::make($linkToken); $publicPayload['link_hint'] = 'email_link'; } $expiresAt = Carbon::now()->add(CarbonInterval::minutes($ttlMinutes)); $log = $this->logRepository->create([ 'provider_id' => self::ID, 'purpose' => $purpose, 'channel' => 'email', 'user_id' => $target instanceof User ? $target->id : null, 'target_hash' => $targetHash, 'status' => IdentityVerificationStatus::Requested->value, 'render_hint' => $renderHint, 'attempts' => 0, 'max_attempts' => $maxAttempts, 'ip_address' => $context['ip_address'] ?? null, 'user_agent' => $context['user_agent'] ?? null, 'origin_type' => $context['origin_type'] ?? null, 'origin_identifier' => $context['origin_identifier'] ?? null, 'origin_policy_key' => $context['origin_policy_key'] ?? null, 'properties' => $context['properties'] ?? null, 'metadata' => $metadata, 'expires_at' => $expiresAt, ]); $sent = $this->dispatchMessage( email: $email, purpose: $purpose, renderHint: $renderHint, challengeId: $log->id, policyKey: $context['origin_policy_key'] ?? null, code: $code, linkToken: $linkToken, ttlMinutes: $ttlMinutes, expiresAt: $expiresAt, ); $this->logRepository->updateById($log->id, [ 'status' => $sent ? IdentityVerificationStatus::Sent->value : IdentityVerificationStatus::Failed->value, ]); return new VerificationChallenge( id: $log->id, providerId: self::ID, purpose: $purpose, channel: 'email', targetHash: $targetHash, expiresAt: $expiresAt, renderHint: $renderHint, publicPayload: $publicPayload, metadata: [], maxAttempts: $maxAttempts, ); } /** * 사용자가 제출한 코드/토큰을 검증합니다. * * @param string $challengeId 챌린지 ID * @param array $input 사용자 입력 (code 또는 token) * @param array $context 검증 컨텍스트 * @return VerificationResult 검증 결과 */ public function verify(string $challengeId, array $input, array $context = []): VerificationResult { $log = $this->logRepository->findById($challengeId); if (! $log) { return VerificationResult::failure($challengeId, self::ID, 'NOT_FOUND', 'identity.errors.challenge_not_found'); } if ($log->provider_id !== self::ID) { return VerificationResult::failure($challengeId, self::ID, 'WRONG_PROVIDER', 'identity.errors.wrong_provider'); } if (in_array($log->status, [ IdentityVerificationStatus::Verified->value, IdentityVerificationStatus::Expired->value, IdentityVerificationStatus::Cancelled->value, ], true)) { return VerificationResult::failure($challengeId, self::ID, 'INVALID_STATE', 'identity.errors.invalid_state'); } if ($log->isExpired()) { $this->logRepository->updateById($log->id, [ 'status' => IdentityVerificationStatus::Expired->value, ]); return VerificationResult::failure($challengeId, self::ID, 'EXPIRED', 'identity.errors.expired'); } if ($log->attempts >= $log->max_attempts) { return VerificationResult::failure($challengeId, self::ID, 'MAX_ATTEMPTS', 'identity.errors.max_attempts'); } $storedHash = $log->metadata['code_hash'] ?? $log->metadata['link_token_hash'] ?? null; $provided = (string) ($input['code'] ?? $input['token'] ?? ''); $this->logRepository->updateById($log->id, [ 'attempts' => $log->attempts + 1, ]); if ($storedHash === null || ! Hash::check($provided, $storedHash)) { // 이번 오답으로 max_attempts 에 도달하면 잠금(Failed)으로 전환하고, 사용자에게 // 일반 오답 안내 대신 "최대 시도 초과 + 재요청" 안내(MAX_ATTEMPTS)를 반환한다. // 프론트 모달은 도달 즉시 확인 버튼을 비활성화하므로 추가 시도를 기대할 수 없어, // 막 소진된 이 응답에서 안내하지 않으면 max_attempts 문구가 사용자에게 영영 도달하지 못한다. if (($log->attempts + 1) >= $log->max_attempts) { $this->logRepository->updateById($log->id, [ 'status' => IdentityVerificationStatus::Failed->value, ]); return VerificationResult::failure($challengeId, self::ID, 'MAX_ATTEMPTS', 'identity.errors.max_attempts'); } return VerificationResult::failure($challengeId, self::ID, 'INVALID_CODE', 'identity.errors.invalid_code'); } $verifiedAt = Carbon::now(); $verificationToken = $this->generateVerificationToken($log->purpose, $log->target_hash); $this->logRepository->updateById($log->id, [ 'status' => IdentityVerificationStatus::Verified->value, 'verified_at' => $verifiedAt, 'verification_token' => $verificationToken, ]); return VerificationResult::success( challengeId: $challengeId, providerId: self::ID, verifiedAt: $verifiedAt, identityHash: null, // 메일 프로바이더는 PII 정규화 식별자 없음 (KCP/이니시스에서만 반환) claims: ['verification_token' => $verificationToken], ); } /** * 챌린지를 취소 상태로 전환합니다. * * @param string $challengeId 챌린지 ID * @return bool 성공 여부 */ public function cancel(string $challengeId): bool { return $this->logRepository->updateById($challengeId, [ 'status' => IdentityVerificationStatus::Cancelled->value, ]); } /** * 프로바이더 설정 스키마를 반환합니다. * * @return array 설정 필드 정의 배열 */ public function getSettingsSchema(): array { return [ 'code_length' => [ 'label' => __('identity.providers.mail.settings.code_length'), 'type' => 'integer', 'default' => self::DEFAULT_CODE_LENGTH, // 경계는 스키마가 선언한다 — 서비스가 자체 상수로 조용히 클램프하면 // 관리자가 설정한 값이 화면 안내와 다르게 동작한다. 'min' => self::MIN_CODE_LENGTH, 'max' => self::MAX_CODE_LENGTH, 'help' => __('identity.providers.mail.settings.code_length_help'), ], 'from_address' => [ 'label' => __('identity.providers.mail.settings.from_address'), 'type' => 'string', 'default' => null, 'help' => __('identity.providers.mail.settings.from_address_help'), ], ]; } /** * 런타임 설정을 병합한 프로바이더 인스턴스를 반환합니다. * * @param array $config 병합할 설정 배열 * @return static 설정이 병합된 새 인스턴스 */ public function withConfig(array $config): static { $clone = clone $this; $clone->config = array_merge($this->config, $config); return $clone; } protected function resolveRenderHint(string $purpose): string { return $purpose === 'password_reset' ? 'link' : 'text_code'; } /** * 지정 길이의 숫자 인증코드를 생성합니다. * * 경계를 벗어난 값은 조용히 클램프하지 않습니다 — 클램프는 관리자가 설정한 값과 * 실제 동작을 어긋나게 만듭니다. 다만 런타임 인증 흐름을 중단시키는 것은 위험하므로 * 예외 대신 경고 로그 + 스키마 기본값 폴백으로 처리합니다. * * @param int $length 요청 코드 길이 * @return string 생성된 숫자 코드 */ protected function generateNumericCode(int $length): string { if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) { Log::warning('IDV 인증코드 길이가 허용 범위를 벗어나 기본값으로 대체됨', [ 'provider_id' => self::ID, 'requested' => $length, 'min' => self::MIN_CODE_LENGTH, 'max' => self::MAX_CODE_LENGTH, 'applied' => self::DEFAULT_CODE_LENGTH, ]); $length = self::DEFAULT_CODE_LENGTH; } $code = ''; for ($i = 0; $i < $length; $i++) { $code .= (string) random_int(0, 9); } return $code; } protected function generateVerificationToken(string $purpose, string $targetHash): string { return hash_hmac( 'sha256', $purpose.'|'.$targetHash.'|'.Str::uuid()->toString(), (string) config('app.key', 'fallback-secret') ); } /** * IDV 전용 메시지 디스패처를 통해 메일을 발송합니다. * * @param string $renderHint text_code | link * @param string|null $code text_code 흐름 시 평문 인증 코드 * @param string|null $linkToken link 흐름 시 서명 링크용 raw 토큰 * @return bool 발송 성공 여부 */ protected function dispatchMessage( string $email, string $purpose, string $renderHint, string $challengeId, ?string $policyKey, ?string $code, ?string $linkToken, int $ttlMinutes, Carbon $expiresAt, ): bool { try { $actionUrl = $linkToken !== null ? $this->buildSignedLink($challengeId, $linkToken, $expiresAt) : null; return app(IdentityMessageDispatcher::class)->dispatch( providerId: self::ID, purpose: $purpose, policyKey: $policyKey, renderHint: $renderHint, channel: 'mail', target: $email, data: [ 'code' => $code, 'action_url' => $actionUrl, 'expire_minutes' => $ttlMinutes, 'purpose_label' => $this->resolvePurposeLabel($purpose), 'app_name' => (string) config('app.name'), 'site_url' => (string) config('app.url'), 'recipient_email' => $email, ], context: [ 'challenge_id' => $challengeId, 'render_hint' => $renderHint, ], ); } catch (\Throwable $e) { Log::warning('[IDV] Mail dispatch failed', [ 'email' => $email, 'purpose' => $purpose, 'policy_key' => $policyKey, 'message' => $e->getMessage(), ]); return false; } } /** * link 흐름용 서명 링크를 생성합니다. */ protected function buildSignedLink(string $challengeId, string $linkToken, Carbon $expiresAt): string { try { return URL::temporarySignedRoute( 'api.identity.challenges.verify', $expiresAt, ['challenge_id' => $challengeId, 'token' => $linkToken], ); } catch (\Throwable) { // 라우트 미존재 환경(테스트 등)에서는 경로 + query 로 fallback return rtrim((string) config('app.url'), '/').'/identity/verify?challenge='.$challengeId.'&token='.$linkToken; } } /** * purpose 라벨(다국어)을 현재 로케일 문자열로 해석합니다. */ protected function resolvePurposeLabel(string $purpose): string { $key = 'identity.purposes.'.$purpose.'.label'; $translated = __($key); return is_string($translated) && $translated !== $key ? $translated : $purpose; } }