Files
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

341 lines
13 KiB
PHP

<?php
namespace App\Extension\IdentityVerification;
use App\Contracts\Extension\IdentityVerificationInterface;
use App\Enums\IdentityPolicySourceType;
use App\Enums\IdentityVerificationChannel;
use App\Enums\IdentityVerificationPurpose;
use App\Extension\HookManager;
use InvalidArgumentException;
/**
* 본인인증 프로바이더 레지스트리 / 매니저.
*
* 기존 드라이버 레시피(Interface + CoreServiceProvider 바인딩 + 필터 훅 등록)를 그대로 따릅니다.
* 플러그인은 `core.identity.registered_providers` 필터 훅으로 프로바이더를 등록합니다.
*
* @since 7.0.0-beta.4
*/
class IdentityVerificationManager
{
/**
* @var array<string, IdentityVerificationInterface>
*/
protected array $providers = [];
/**
* 확장(모듈/플러그인) 이 선언한 purpose 레지스트리.
*
* `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
* 반환값을 `ModuleManager` / `PluginManager` 가 부팅 시 여기에 병합합니다.
* DB 에 저장되지 않는 **코드 계약** 입니다.
*
* @var array<string, array<string, mixed>>
*/
protected array $declaredPurposes = [];
/**
* 코어 기본 purpose 목록.
*
* `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` — 이 5종은
* 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다.
*
* `IdentityVerificationPurpose` 에 case 를 추가하면 이 레지스트리에도 반드시 등록해야
* 합니다. 등록하지 않으면 `getAllPurposes()` 에서 빠져 관리자가 그 목적의 메시지 템플릿·
* 정책을 만들 수 없고, `hasPurpose()` 도 false 가 됩니다.
*
* @var array<string, array<string, mixed>>
*/
protected array $corePurposes = [
IdentityVerificationPurpose::Signup->value => [
'label' => 'identity.purposes.signup.label',
'description' => 'identity.purposes.signup.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::PasswordReset->value => [
'label' => 'identity.purposes.password_reset.label',
'description' => 'identity.purposes.password_reset.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::SelfUpdate->value => [
'label' => 'identity.purposes.self_update.label',
'description' => 'identity.purposes.self_update.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::SensitiveAction->value => [
'label' => 'identity.purposes.sensitive_action.label',
'description' => 'identity.purposes.sensitive_action.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::Login->value => [
'label' => 'identity.purposes.login.label',
'description' => 'identity.purposes.login.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
];
/**
* 기본 프로바이더 id (설정에 의해 덮어쓰기 가능).
*/
protected string $defaultId = 'g7:core.mail';
/**
* 프로바이더를 등록합니다.
*
* @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스
*/
public function register(IdentityVerificationInterface $provider): void
{
$this->providers[$provider->getId()] = $provider;
}
/**
* 프로바이더 등록을 해제합니다.
*
* @param string $id 프로바이더 식별자 (예: g7:core.mail)
*/
public function unregister(string $id): void
{
unset($this->providers[$id]);
}
/**
* 특정 id 의 프로바이더가 등록되어 있는지 확인합니다.
*
* @param string $id 프로바이더 식별자
* @return bool 등록 여부
*/
public function has(string $id): bool
{
return isset($this->all()[$id]);
}
/**
* 특정 id 의 프로바이더를 반환합니다.
*
* @param string $id 프로바이더 식별자
* @return IdentityVerificationInterface 등록된 provider
*
* @throws InvalidArgumentException 프로바이더 미등록 시
*/
public function get(string $id): IdentityVerificationInterface
{
$providers = $this->all();
if (! isset($providers[$id])) {
throw new InvalidArgumentException("Identity verification provider not found: {$id}");
}
return $providers[$id];
}
/**
* 등록된 전체 프로바이더 목록 (필터 훅 통과 후).
*
* @return array<string, IdentityVerificationInterface>
*/
public function all(): array
{
$merged = HookManager::applyFilters('core.identity.registered_providers', $this->providers);
if (! is_array($merged)) {
return $this->providers;
}
$valid = [];
foreach ($merged as $key => $provider) {
if ($provider instanceof IdentityVerificationInterface) {
$valid[$provider->getId()] = $provider;
}
}
return $valid;
}
/**
* 기본 프로바이더를 반환합니다.
*
* 우선순위: settings.identity.default_provider → 코어 기본 (g7:core.mail) → 등록된 첫 provider.
*
* @return IdentityVerificationInterface 기본 provider
*
* @throws InvalidArgumentException 등록된 provider 가 하나도 없을 때
*/
public function default(): IdentityVerificationInterface
{
$configured = (string) config('settings.identity.default_provider', $this->defaultId);
$providers = $this->all();
if (isset($providers[$configured])) {
return $providers[$configured];
}
if (isset($providers[$this->defaultId])) {
return $providers[$this->defaultId];
}
if (empty($providers)) {
throw new InvalidArgumentException('No identity verification provider is registered.');
}
return $providers[array_key_first($providers)];
}
/**
* 특정 purpose 에 사용할 프로바이더를 해석합니다.
*
* 해석 순서:
* 0. 명시 $providerId 가 등록 + supportsPurpose 통과 → 사용 (호출자 명시 우선)
* 1. `settings.identity.purpose_providers.{purpose}` 에 명시적 지정 + 해당 프로바이더가 purpose 지원 → 사용
* 2. 기본 프로바이더가 purpose 지원 → 사용
* 3. 등록된 프로바이더 중 purpose 지원하는 첫 번째 → 사용
* 4. 없으면 mail (코어 기본) 반환 — mail 은 모든 purpose 지원 계약
*
* 정책의 provider_id 가 우선되므로 (IdentityPolicyService::resolveRenderHint 참조), 본 메서드는
* 정책에 provider_id 가 명시되지 않은 경우의 fallback 으로 사용됩니다.
*
* $providerId 가 등록되지 않았거나 purpose 미지원이면 silent fallback (1~4 단계 진행).
* 운영자가 정책에 명시한 provider 가 비활성화/미설치된 상태에서도 가입 흐름이 중단되지 않도록 한다.
*
* @param string $purpose IDV 목적 (signup, password_reset, sensitive_action 등)
* @param string|null $providerId 호출자 명시 provider id (Mode A controller 요청 / Mode B 정책)
* @return IdentityVerificationInterface 해석된 provider
*/
public function resolveForPurpose(string $purpose, ?string $providerId = null): IdentityVerificationInterface
{
$providers = $this->all();
if ($providerId !== null && $providerId !== '' && isset($providers[$providerId]) && $providers[$providerId]->supportsPurpose($purpose)) {
return $providers[$providerId];
}
$explicitId = (string) config("settings.identity.purpose_providers.{$purpose}", '');
if ($explicitId !== '' && isset($providers[$explicitId]) && $providers[$explicitId]->supportsPurpose($purpose)) {
return $providers[$explicitId];
}
$default = $this->default();
if ($default->supportsPurpose($purpose)) {
return $default;
}
foreach ($providers as $provider) {
if ($provider->supportsPurpose($purpose)) {
return $provider;
}
}
if (isset($providers[$this->defaultId])) {
return $providers[$this->defaultId];
}
throw new InvalidArgumentException("No identity verification provider supports purpose: {$purpose}");
}
/**
* 확장(모듈/플러그인) 이 선언한 purpose 들을 레지스트리에 등록합니다.
*
* `ModuleManager::bootModules()` / `PluginManager::bootPlugins()` 가
* 활성화된 확장의 `getIdentityPurposes()` 결과를 순회하며 호출합니다.
*
* 같은 key 로 중복 등록되면 나중 호출이 이전 값을 덮어씁니다
* (확장 로드 순서 결정성 보장은 ExtensionManager 책임).
*
* @param array<string, array<string, mixed>> $purposes key => metadata 매핑
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹)
* @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin')
*/
public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void
{
$resolvedType = $sourceType ?? 'admin';
$resolvedIdentifier = $sourceIdentifier ?? 'admin';
foreach ($purposes as $key => $meta) {
if (! is_string($key) || $key === '' || ! is_array($meta)) {
continue;
}
// legacy `label_key` / `description_key` 명명을 표준 `label` / `description` 으로 정규화.
// controller 의 resolvePurposeText 는 `label` / `description` 만 인식하므로, 미정규화
// meta 가 등록되면 응답에서 라벨이 raw 키로 노출되는 회귀 발생.
if (! isset($meta['label']) && isset($meta['label_key'])) {
$meta['label'] = $meta['label_key'];
}
if (! isset($meta['description']) && isset($meta['description_key'])) {
$meta['description'] = $meta['description_key'];
}
$meta['source_type'] = $resolvedType;
$meta['source_identifier'] = $resolvedIdentifier;
$this->declaredPurposes[$key] = $meta;
}
}
/**
* 확장이 선언한 purpose 를 한 개 등록합니다.
*
* @param string $key purpose 식별자
* @param array<string, mixed> $meta label/description/allowed_channels 등 메타데이터
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin')
* @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin')
*/
public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void
{
$meta['source_type'] = $sourceType ?? 'admin';
$meta['source_identifier'] = $sourceIdentifier ?? 'admin';
$this->declaredPurposes[$key] = $meta;
}
/**
* 등록된 전체 purpose 목록을 반환합니다.
*
* 병합 순서: 코어 기본 4종 → 확장 getter 선언분 → `core.identity.purposes` filter 훅.
* 같은 key 가 충돌하면 나중 소스가 이전을 덮어씁니다 (filter 훅이 최종 결정권).
*
* @return array<string, array<string, mixed>>
*/
public function getAllPurposes(): array
{
$merged = $this->corePurposes;
foreach ($this->declaredPurposes as $key => $meta) {
$merged[$key] = $meta;
}
$filtered = HookManager::applyFilters('core.identity.purposes', $merged);
if (! is_array($filtered)) {
return $merged;
}
return $filtered;
}
/**
* 특정 purpose 가 등록되어 있는지 확인합니다 (코어·확장·filter 훅 모두 포함).
*
* @param string $key purpose 식별자
* @return bool 존재 여부
*/
public function hasPurpose(string $key): bool
{
$all = $this->getAllPurposes();
return isset($all[$key]);
}
}