Files
Gnuboard7/app/Services/IdentityVerificationService.php
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

280 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\DTO\VerificationChallenge;
use App\Extension\IdentityVerification\DTO\VerificationResult;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Models\User;
/**
* 본인인증 유스케이스 서비스.
*
* 플로우:
* start() → Manager.resolveForPurpose() → Provider.requestChallenge() → Challenge 반환
* verify() → Provider.verify() → result
* → core.identity.after_verify 훅 → users.identity_verified_at 갱신
*
* 컨트롤러/리스너는 이 서비스를 통해서만 IDV 를 수행 — Provider 를 직접 호출하지 않음.
*
* @since 7.0.0-beta.4
*/
class IdentityVerificationService
{
/**
* @param IdentityVerificationManager $manager 프로바이더 레지스트리
* @param IdentityVerificationLogRepositoryInterface $logRepository 로그 Repository
* @param UserRepositoryInterface $userRepository 사용자 Repository
*/
public function __construct(
protected IdentityVerificationManager $manager,
protected IdentityVerificationLogRepositoryInterface $logRepository,
protected UserRepositoryInterface $userRepository,
) {}
/**
* Challenge 를 시작합니다.
*
* $providerId 가 주어지면 Manager 의 0번째 우선순위로 사용된다 (IdentityVerificationManager::resolveForPurpose).
* 미등록/미지원이면 silent fallback (기존 우선순위 체인 진행).
*
* @param string $purpose signup|password_reset|self_update|sensitive_action|플러그인 등록값
* @param User|array<string, mixed> $target 로그인 사용자(User) 또는 ['email' => '...'] 배열
* @param array<string, mixed> $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<string, mixed> $input 프로바이더별 입력 (코드, 토큰 등)
* @param array<string, mixed> $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 / attempts / max_attempts / public_payload
*
* attempts/max_attempts 는 프론트 풀페이지 (`auth/identity_challenge.json`) 가 "남은 시도 횟수" UI
* 카운트다운에 사용한다. URL 직접 진입(외부 redirect 콜백 후) 흐름에서도 정확한 한도 표시 보장.
*
* @param string $challengeId Challenge UUID
* @return array<string, mixed>|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'];
}
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' => (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<string, mixed> $input provider 가 보낸 페이로드 (code/token/state 등)
* @param array<string, mixed> $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);
}
}