인터뷰에서 방향만 정하고 남겨 뒀던 후속 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
341 lines
13 KiB
PHP
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]);
|
|
}
|
|
}
|