Files
Gnuboard7/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
T
HeuJung 6c8381f164 fix(core,board,ecommerce,plugins): API 문서 미채움 마커 전수 채움 + 미해석 lang 키 정정 + IDV 조회 시도횟수 비노출
이슈 본연: API 레퍼런스의 미채움 마커 5종(실측 제외/TODO/필드 없음/대표 에러 없음)
1,194건을 코드에서 읽어 전수 채우고(0건), api:docgen 재생성이 사람이 채운 내용을
손실·열화시키던 멱등성 결함 4종(CRLF, 표 파이프 이스케이프, 에러 서술 보존, 중복
라우트명 키)을 근본 수정. --check 를 실측 제외와 드리프트가 구분되도록 재정의.
ParameterDescriber/ResourceFieldDescriber 에 leaf 폴백·SEO 스코프·공통 사전 확장.

파생 결함(문서 실측 중 발견): ResponseHelper 기본 메시지 키 6종이 존재하지 않는
messages.* 를 가리켜 응답 message 가 번역문 대신 키 문자열로 노출되던 문제를 common.*
으로 정정하고, 코어 7 + gdpr/marketing/pay_kginicis/verification/ecommerce 확장의
호출부와 누락 lang 키(게시판 10키 + 이커머스 category_images 4키 포함)를 ko/en/ja
전수 정정. 중복 그룹 키 2건은 기존 키로 호출부 통합.

보안: GET /api/identity/challenges/{id} 는 권한 가드 없는 공개 폴링 엔드포인트인데
Service::getStatus 가 attempts/max_attempts 를 응답에 담아 남은 시도 횟수를 추론할 수
있었다. 두 필드를 제거하고, 챌린지 화면(admin_basic/basic)의 서버 attempts 참조를
제거(남은 횟수 UI 는 query fallback + verify 실패 시 로컬 증가로 유지).

부수 정리(변경셋에 들어온 board 컨트롤러의 audit 사전 결함): FormRequest 4개 신설로
base Request 주입 제거, CommentController 의 Board 직접 호출을 BoardService 위임으로
전환, PHPDoc @return 보강. board 스위트에서 발견한 stale test 3건(seed SSoT 불일치,
admin 계정 user 역할 누락)도 같은 세션에서 정정.

버전 정렬(공개 release 기준): 코어 7.0.3→7.0.4, sirsoft-board 1.0.1→1.0.2,
verification_kginicis 1.0.0→1.0.1, admin_basic 1.0.1→1.0.4.
2026-08-06 14:16:40 +09:00

376 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Identity;
use App\Enums\IdentityOriginType;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Identity\CancelChallengeRequest;
use App\Http\Requests\Identity\IdentityCallbackRequest;
use App\Http\Requests\Identity\ProvidersIndexRequest;
use App\Http\Requests\Identity\PurposesIndexRequest;
use App\Http\Requests\Identity\RequestChallengeRequest;
use App\Http\Requests\Identity\ResolvePolicyRequest;
use App\Http\Requests\Identity\ShowChallengeRequest;
use App\Http\Requests\Identity\VerifyChallengeRequest;
use App\Http\Resources\Identity\ChallengeResource;
use App\Http\Resources\Identity\ProviderResource;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Services\IdentityPolicyService;
use App\Services\IdentityVerificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
/**
* 본인인증 공개 API 컨트롤러.
*
* 비로그인 가입 플로우(Mode B) 에서도 접근하므로 PublicBaseController 를 상속합니다.
* 로그인 필요 엔드포인트는 라우트의 permission 미들웨어가 담당합니다.
*/
class IdentityVerificationController extends PublicBaseController
{
/**
* @param IdentityVerificationService $service 본인인증 유스케이스 Service
* @param IdentityVerificationManager $manager 프로바이더 레지스트리 (Service 역할 겸함)
* @param IdentityPolicyService $policyService 정책 해석 Service (프런트 프리페치용)
*/
public function __construct(
protected IdentityVerificationService $service,
protected IdentityVerificationManager $manager,
protected IdentityPolicyService $policyService,
) {
parent::__construct();
}
/**
* Challenge 를 요청합니다. POST /api/identity/challenges
*
* @param RequestChallengeRequest $request 검증된 요청
* @return JsonResponse 생성된 Challenge 리소스 (201)
*/
public function request(RequestChallengeRequest $request): JsonResponse
{
$validated = $request->validated();
$user = $request->user();
$target = $user ?: ($validated['target'] ?? []);
if (! ($user instanceof User) && empty($target['email']) && empty($target['phone'])) {
return $this->error('identity.errors.missing_target', 422);
}
$providerId = $validated['provider_id'] ?? null;
if (is_string($providerId) && $providerId === '') {
$providerId = null;
}
$challenge = $this->service->start(
purpose: (string) $validated['purpose'],
target: $target,
context: [
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'origin_type' => IdentityOriginType::Api->value,
'origin_identifier' => '/api/identity/challenges',
],
providerId: $providerId,
);
return $this->success(
'identity.messages.challenge_requested',
(new ChallengeResource($challenge))->toArray($request),
201,
);
}
/**
* Challenge 를 검증합니다. POST /api/identity/challenges/{challenge}/verify
*
* 라우트는 `permission:user,core.identity.verify` 미들웨어 + Route::model('challenge') 바인딩으로 보호됩니다.
* 로그인 사용자는 PermissionMiddleware 의 scope=self 가드가 challenge.user_id 일치를 자동 검증합니다.
* 비로그인 게스트는 guest 역할 권한만 통과하면 진입합니다 (Mode B 가입 흐름).
*
* @param VerifyChallengeRequest $request 검증된 요청 (code 또는 token 포함)
* @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
* @return JsonResponse 검증 결과 (verification_token 포함)
*/
public function verify(VerifyChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
$result = $this->service->verify(
challengeId: $challenge->id,
input: $request->validated(),
context: [
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 512),
],
);
// verify 실패 시에도 서버 측 시도 횟수를 응답에 포함 — 클라이언트가 자체 카운트를 서버와 동기화하여
// "남은 시도 횟수" UI 가 다른 탭/세션과 불일치하지 않도록.
if (! $result->success) {
$fresh = $challenge->fresh();
return $this->error(
$result->failureReason ?: 'identity.errors.generic',
422,
[
'failure_code' => $result->failureCode,
'attempts' => $fresh ? (int) $fresh->attempts : (int) $challenge->attempts,
'max_attempts' => $fresh ? (int) $fresh->max_attempts : (int) $challenge->max_attempts,
],
);
}
return $this->success('identity.messages.challenge_verified', [
'challenge_id' => $result->challengeId,
'provider_id' => $result->providerId,
'verified_at' => $result->verifiedAt?->toIso8601String(),
'verification_token' => $result->claims['verification_token'] ?? null,
]);
}
/**
* Challenge 를 취소합니다. POST /api/identity/challenges/{challenge}/cancel
*
* 라우트는 `permission:user,core.identity.cancel` 미들웨어 + Route::model('challenge') 바인딩으로 보호됩니다.
* 로그인 사용자는 PermissionMiddleware 의 scope=self 가드가 challenge.user_id 일치를 자동 검증합니다.
* 비로그인 게스트는 guest 역할 권한만 통과하면 진입합니다 (모달 취소 시 audit trail 정합용).
*
* @param CancelChallengeRequest $request 검증된 요청
* @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
* @return JsonResponse 취소 결과
*/
public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
$ok = $this->service->cancel($challenge->id);
if (! $ok) {
return $this->error('identity.errors.challenge_not_found', 404);
}
return $this->success('identity.messages.challenge_cancelled');
}
/**
* Challenge 의 공개 상태를 폴링합니다. GET /api/identity/challenges/{challenge}
*
* 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백 대기) 에서 클라이언트가
* verify 즉시 응답을 받지 못할 때 상태를 추적하기 위한 엔드포인트.
*
* 노출 필드는 공개 안전 항목만 (시도 횟수·코드 본체·metadata 노출 금지) — Service::getStatus 참조.
*
* @param ShowChallengeRequest $request 검증된 요청
* @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
* @return JsonResponse 공개 안전 상태 필드
*
* @since engine-v1.46.0
*/
public function show(ShowChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
$status = $this->service->getStatus($challenge->id);
if ($status === null) {
return $this->error('identity.errors.challenge_not_found', 404);
}
return $this->success('common.success', $status);
}
/**
* 외부 IDV provider 의 redirect 콜백을 수신합니다. POST /api/identity/callback/{providerId}
*
* 외부 본인인증 SDK / OAuth-style provider 가 사용자 브라우저를 우리 서버로 다시 보내는 진입점.
* body/query 에서 challenge_id 를 추출 → Service::handleProviderCallback 위임.
*
* 응답 정책 — 클라이언트가 stash 한 페이지(`return` query) 가 있으면 redirect, 없으면 JSON 응답:
* - 성공 + return 있음: 302 → `{return}?verification_token=...`
* - 성공 + return 없음: 200 JSON `{ verification_token }`
* - 실패 + return 있음: 302 → `{return}?identity_error={failure_code}`
* - 실패 + return 없음: 422 JSON
*
* @param IdentityCallbackRequest $request 검증된 요청
* @param string $providerId 콜백을 보낸 provider 식별자
* @return JsonResponse|RedirectResponse
*
* @since engine-v1.46.0
*/
public function callback(IdentityCallbackRequest $request, string $providerId)
{
$validated = $request->validated();
$challengeId = (string) $validated['challenge_id'];
$returnUrl = (string) $request->query('return', '');
$result = $this->service->handleProviderCallback(
providerId: $providerId,
challengeId: $challengeId,
input: $validated,
context: [
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 512),
],
);
if (! $result->success) {
if ($returnUrl !== '' && $this->isSafeReturnUrl($returnUrl)) {
$sep = str_contains($returnUrl, '?') ? '&' : '?';
return redirect()->away(
$returnUrl.$sep.'identity_error='.urlencode($result->failureCode ?? 'UNKNOWN'),
);
}
return $this->error(
$result->failureReason ?: 'identity.errors.generic',
422,
['failure_code' => $result->failureCode],
);
}
$token = $result->claims['verification_token'] ?? '';
if ($returnUrl !== '' && $this->isSafeReturnUrl($returnUrl)) {
$sep = str_contains($returnUrl, '?') ? '&' : '?';
return redirect()->away(
$returnUrl.$sep.'verification_token='.urlencode((string) $token).'&challenge_id='.urlencode($challengeId),
);
}
return $this->success('identity.messages.challenge_verified', [
'challenge_id' => $result->challengeId,
'provider_id' => $result->providerId,
'verified_at' => $result->verifiedAt?->toIso8601String(),
'verification_token' => $token,
]);
}
/**
* `return` 쿼리 URL 이 같은 origin 인지 검증 — open redirect 차단.
*
* 절대 URL 이면 host 가 현재 앱 host 와 일치해야 통과, 상대 경로(`/...`) 는 통과.
*/
private function isSafeReturnUrl(string $url): bool
{
if ($url === '' || $url[0] === '/') {
return ! str_starts_with($url, '//'); // protocol-relative 차단
}
$appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
$urlHost = parse_url($url, PHP_URL_HOST);
return $appHost !== null && $urlHost !== null && $appHost === $urlHost;
}
/**
* 등록된 프로바이더 목록을 반환합니다. GET /api/identity/providers
*
* @param ProvidersIndexRequest $request 검증된 요청
* @return JsonResponse 프로바이더 공개 메타데이터 목록
*/
public function providers(ProvidersIndexRequest $request): JsonResponse
{
$providers = array_values($this->manager->all());
$data = array_map(
fn ($p) => (new ProviderResource($p))->toArray($request),
$providers,
);
return $this->success('common.success', $data);
}
/**
* 등록된 purpose 목록을 반환합니다 (core.identity.purposes 필터 훅 통과).
* GET /api/identity/purposes
*
* @param PurposesIndexRequest $request 검증된 요청
* @return JsonResponse purpose 키 => 메타 매핑
*/
public function purposes(PurposesIndexRequest $request): JsonResponse
{
// 코어 기본 4종 + 활성 모듈/플러그인 `getIdentityPurposes()` 선언 +
// `core.identity.purposes` filter 훅 (서드파티 동적 확장) 을 모두 병합
$purposes = $this->manager->getAllPurposes();
$data = [];
foreach ($purposes as $key => $meta) {
$data[] = [
'id' => $key,
'label' => $this->resolvePurposeText($meta['label'] ?? $key),
'description' => $this->resolvePurposeText($meta['description'] ?? ''),
'default_provider' => $meta['default_provider'] ?? null,
'allowed_channels' => $meta['allowed_channels'] ?? [],
'source_type' => $meta['source_type'] ?? 'core',
'source_identifier' => $meta['source_identifier'] ?? 'core',
];
}
return $this->success('common.success', $data);
}
/**
* purpose meta 의 label/description 값을 현재 로케일 문자열로 정규화합니다.
*
* 입력 형태 3가지 지원:
* - i18n 키 문자열 (예: `identity.purposes.signup.label`) → `__()` 로 풀이
* - 다국어 배열 (`['ko' => ..., 'en' => ...]`) → 현재 로케일 우선, en 폴백
* - 일반 문자열 → 그대로 반환
*
* @param mixed $value
*/
private function resolvePurposeText($value): string
{
if (is_array($value)) {
$locale = app()->getLocale();
return (string) ($value[$locale] ?? $value['en'] ?? reset($value) ?: '');
}
if (! is_string($value) || $value === '') {
return '';
}
// i18n 키처럼 보이는 경우 (`identity.*` 또는 `*.purposes.*` 등)
if (str_contains($value, '.')) {
$translated = __($value);
return is_string($translated) ? $translated : $value;
}
return $value;
}
/**
* 지정된 scope+target 조합에 대한 정책을 조회합니다 (프론트엔드 프리페치용).
*
* GET /api/identity/policies/resolve?scope=route&target=api.me.password.update
* → 레이아웃 마운트 시 "이 페이지에서 IDV 가 요구될 수 있는 API" 를 미리 파악해
* UI 힌트(버튼 배지 "확인 필요" 등) 를 표시하기 위한 엔드포인트.
*
* @param ResolvePolicyRequest $request 검증된 요청 (scope+target query)
* @return JsonResponse 매칭 정책 요약 또는 null
*/
public function resolvePolicy(ResolvePolicyRequest $request): JsonResponse
{
$validated = $request->validated();
$scope = (string) $validated['scope'];
$target = (string) $validated['target'];
$policy = $this->policyService->resolve($scope, $target);
if (! $policy || ! $policy->enabled) {
return $this->success('common.success', null);
}
// 민감 필드는 노출하지 않고 UI 힌트에 필요한 최소 필드만 반환
return $this->success('common.success', [
'policy_key' => $policy->key,
'scope' => $policy->scope,
'target' => $policy->target,
'purpose' => $policy->purpose,
'provider_id' => $policy->provider_id,
'grace_minutes' => $policy->grace_minutes,
'applies_to' => $policy->applies_to,
'fail_mode' => $policy->fail_mode,
]);
}
}