인터뷰에서 방향만 정하고 남겨 뒀던 후속 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
21 KiB
모듈/플러그인 본인인증(IDV) 설정 통합 가이드
TL;DR (5초 요약)
1. 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Messages}() 로 declarative 선언 — Manager 가 install/update 시 자동 동기화
2. UI 탭: 모듈 환경설정에 _tab_identity_policies.json partial + 코어 API source_type=module&source_identifier=... 호출
3. 권한: {vendor-module}.identity.policies.{read,update} 모듈 자체 네임스페이스 신설 (최소 권한 원칙)
4. 편집 권한: 모듈 source 정책은 enabled/grace_minutes/provider_id/fail_mode/conditions 5 필드만 (코어가 강제), admin source 는 자유
5. 결제 등 도메인 액션 가드: scope=hook 정책 + EnforceIdentityPolicyListener 가 자동 구독 → IdentityVerificationRequiredException → HTTP 428
6. 다국어: purpose label/description, 메시지 정의 등은 모듈 i18n 표준(`{vendor-module}::identity.purposes.*`) lang 키 사용 — `__()` 자동 해석
개요
모듈/플러그인이 자기 컨텍스트의 본인인증 정책과 목적(purpose) 을 코어 IDV 인프라에 등록하고, 모듈 자체 환경설정 페이지에 정책 관리 탭을 노출하는 방법을 설명합니다. 이슈 #297 후속 작업으로 도입되었습니다.
코어 IDV 인프라의 전체 설계는 docs/backend/identity-policies.md 를 참조하세요.
백엔드 — 정책 / 목적 선언
정책 declarative 등록
module.php 또는 plugin.php 의 getIdentityPolicies() 메서드에서 정책 배열을 반환합니다. ModuleManager::installModule / updateModule(--force) 트랜잭션 내부에서 IdentityPolicySyncHelper::syncPolicy() 가 자동 호출되어 identity_policies 테이블에 upsert 됩니다.
public function getIdentityPolicies(): array
{
return [
[
'key' => 'sirsoft-board.post.delete',
'scope' => 'hook',
'target' => 'sirsoft-board.post.before_delete',
'purpose' => 'sensitive_action',
'grace_minutes' => 5,
'enabled' => false,
'applies_to' => 'admin',
'fail_mode' => 'block',
],
];
}
| 필드 | 설명 |
|---|---|
key |
정책 식별자(고유). 권장 형식 {vendor-module}.{도메인}.{액션} |
scope |
route / hook / custom 중 하나. hook 이 가장 일반적 |
target |
scope 별 대상 (라우트명, 훅 이름, 커스텀 키) |
purpose |
IDV purpose. 코어 4 종 또는 모듈/플러그인이 등록한 신규 purpose |
grace_minutes |
최근 N 분 이내 verified 재사용 허용. 0 = 매번 요구 |
enabled |
기본값. 모듈 정책은 보수적으로 false 권장 |
applies_to |
self / admin / both |
fail_mode |
block (HTTP 428) / log_only (감사 기록만, 요청 통과) |
source_type / source_identifier 는 Manager 가 자동 주입하므로 선언 불필요.
user_overrides 보존
운영자가 환경설정에서 enabled / grace_minutes / provider_id / fail_mode / conditions 를 수정하면 user_overrides JSON 컬럼에 필드명이 기록됩니다. 모듈 재설치/업데이트 시 declarative 기본값이 다시 들어와도 user_overrides 에 등록된 필드는 덮어쓰지 않습니다 — 알림 시스템의 HasUserOverrides 와 동형 패턴.
신규 purpose 등록
코어가 제공하는 5 종(signup / password_reset / self_update / sensitive_action / login) 외에 도메인 특화 purpose 를 도입하려면 getIdentityPurposes() 를 오버라이드합니다.
public function getIdentityPurposes(): array
{
return [
'checkout_verification' => [
// 모듈 i18n 표준 (docs/extension/module-i18n.md) 준수
// — IdentityVerificationController::resolvePurposeText() 가 __() 로 자동 해석
'label' => 'sirsoft-ecommerce::identity.purposes.checkout_verification.label',
'description' => 'sirsoft-ecommerce::identity.purposes.checkout_verification.description',
'default_provider' => null,
'allowed_channels' => ['email', 'sms', 'ipin'],
],
];
}
대응되는 lang 파일: modules/_bundled/{vendor-module}/src/lang/{ko,en}/identity.php
return [
'purposes' => [
'checkout_verification' => [
'label' => '결제 시 본인 확인',
'description' => '결제 진행 전 성인/본인 확인이 필요한 경우 사용됩니다.',
],
],
];
CoreServiceProvider 부팅 시 활성 모듈/플러그인의 결과가 IdentityVerificationManager::registerDeclaredPurposes() 로 병합되어 런타임 레지스트리에 등록됩니다 (DB 저장 X — 매 부팅 새로 등록되는 코드 계약).
다국어 키 형식 권장: 코어 4 종이 'identity.purposes.signup.label' 같은 lang 키 문자열을 사용하므로, 모듈도 '{vendor-module}::identity.purposes.{key}.label' 형태로 일관되게 작성하세요. 인라인 ['ko' => ..., 'en' => ...] 배열도 동작하지만 번역가가 lang 파일만으로 라벨을 수정할 수 없어 비권장입니다.
필드 명명 — label / description 만 인식: meta 의 키는 반드시 'label' / 'description' 이어야 합니다. 'label_key' / 'description_key' 같은 변형 명명은 controller 의 resolvePurposeText 가 인식하지 못해 응답에서 라벨이 raw 키로 노출되는 결함이 발생합니다. IdentityVerificationManager::registerDeclaredPurposes 가 legacy label_key / description_key 입력을 자동 정규화하지만 안전망일 뿐 — 신규 작성 시 표준 명명을 사용하세요.
IDV 메시지 정의 declarative 등록 (engine-v1.46+)
정책 트리거 시 발송되는 본인인증 메일 문구를 모듈이 자기 도메인에 맞춰 등록할 수 있습니다. getIdentityMessages() 메서드를 오버라이드하면 ModuleManager / PluginManager 가 install/update 시 IdentityMessageSyncHelper 를 통해 identity_message_definitions / identity_message_templates 테이블에 자동 동기화합니다 (uninstall + deleteData=true 시 자동 정리).
public function getIdentityMessages(): array
{
return [
[
'provider_id' => 'g7:core.mail',
'scope_type' => \App\Models\IdentityMessageDefinition::SCOPE_PURPOSE,
'scope_value' => 'checkout_verification',
'name' => ['ko' => '결제 시 본인 확인', 'en' => 'Checkout Verification'],
'description' => ['ko' => '결제 진행 전 본인/성인 확인 인증 코드 메일', 'en' => 'Identity/adult verification code mail before checkout'],
'channels' => ['mail'],
'variables' => [
['key' => 'code', 'description' => '인증 코드'],
['key' => 'expire_minutes', 'description' => '만료까지 남은 분'],
['key' => 'app_name', 'description' => '사이트명'],
],
'templates' => [
[
'channel' => 'mail',
'subject' => ['ko' => '[{app_name}] 결제 본인 확인', 'en' => '[{app_name}] Checkout Verification'],
'body' => ['ko' => '<p>인증 코드: {code}</p>', 'en' => '<p>Code: {code}</p>'],
],
],
],
];
}
| scope_type | 사용 시점 |
|---|---|
SCOPE_PURPOSE |
purpose 단위 메시지. 신규 purpose(checkout_verification 등) 도입 시 필수 — 코어에 해당 purpose fallback 이 없으므로 provider_default 로 떨어지는 것을 방지 |
SCOPE_POLICY |
특정 policy_key 전용 메시지. purpose 보다 우선 — 같은 purpose 라도 정책별 차별화된 문구가 필요할 때 |
SCOPE_PROVIDER_DEFAULT |
provider 기본 fallback. 외부 IDV provider 플러그인이 자기 default 를 등록할 때 사용 |
언제 등록해야 하나:
- 신규 purpose 를 도입했다면 반드시 그 purpose 의 메시지 정의 1건 이상 등록 (안 하면 결제 등 도메인 정보 없는 일반 fallback 발송)
- 기존 purpose(
sensitive_action등) 만 사용한다면 빈 배열 반환 + 의도 주석 권장 (코어 fallback 그대로 사용) - 같은 purpose 라도 정책별로 도메인 특화 문구가 필요하면
SCOPE_POLICY로 정책별 메시지 추가
extension_type='module', extension_identifier=$this->getIdentifier() 는 Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다. 운영자가 관리자 UI(환경설정 → 본인인증 → 메시지 템플릿) 에서 편집한 필드는 user_overrides JSON 으로 보존되어 모듈 update 재시딩 시에도 덮어쓰이지 않습니다.
도메인 액션 가드 (결제·삭제 등)
scope='hook' 정책의 target 으로 지정된 훅이 발동되면 코어 EnforceIdentityPolicyListener 가 자동 구독해 IdentityPolicyService::enforce() 를 호출합니다. 미인증 + 정책 활성 시 IdentityVerificationRequiredException 이 throw 되며 코어 Handler 가 HTTP 428 + verification payload 응답으로 변환합니다 — 프론트 IdentityGuardInterceptor 가 자동으로 모달을 열어 verify 후 원 요청을 재실행합니다.
확장 개발자가 추가 작업할 것은 자기 Service 의 결제/삭제 등 진입부에 HookManager::doAction({target_hook_name}, ...) 한 줄을 두는 것 뿐입니다.
public function requestPayment(Order $order, array $paymentData): array
{
HookManager::doAction('sirsoft-ecommerce.checkout.before_payment', $order, $paymentData);
// ... 결제 처리 ...
}
Listener 의 모듈 hook 동적 구독은 app/Listeners/Identity/EnforceIdentityPolicyListener.php 의 loadDynamicHookTargets() 가 부팅 시 identity_policies 테이블에서 scope=hook 정책의 distinct target 을 읽어 자동 처리합니다.
프론트 — 환경설정 탭 추가
탭 추가 (admin layout)
modules/_bundled/{vendor-module}/resources/layouts/admin/admin_*_settings.json 에 다음을 추가:
data_sources배열에{vendor}IdentityPolicies추가 — 코어 API/api/admin/identity/policies를source_type=module+source_identifier={vendor-module}로 호출TabNavigation.tabs배열에{ "id": "identity_policies", "label": "$t:..." }추가partials배열에{ "partial": "partials/.../_tab_identity_policies.json" }추가
partial 골격
게시판/이커머스의 _tab_identity_policies.json 파일이 참고용 레퍼런스 — 안내 카드 + Desktop/Tablet 테이블(responsive.mobile.if = false) + Mobile 카드(responsive.mobile.if = true) + 인라인 enabled 토글 + [+ 정책 추가] 버튼(코어 페이지로 navigate)으로 구성됩니다.
인라인 토글 동작
코어 정책 관리 UI 와 동일한 패턴(commit 97655c50c):
- 컴포넌트:
Toggle(composite,size="sm") - API:
PUT /api/admin/identity/policies/{id}body{ enabled } onSuccess:parallel(toast + refetchDataSource)onError:parallel(toast + refetchDataSource)(refetch 가 실패 시 토글 원복 역할)
정책 추가 / 편집
운영자 자유 정책의 신규 추가/편집은 모듈 탭에서 직접 폼 모달을 열지 않고, 코어 환경설정 페이지(/admin/settings?tab=identity&sub_tab=policies)로 navigate 하여 코어 모달을 사용합니다. [+ 정책 추가] 버튼은 query 에 source_identifier=module:{vendor-module} 를 포함시켜 모듈 컨텍스트 귀속을 보장합니다.
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/settings",
"query": {
"tab": "identity",
"sub_tab": "policies",
"source_identifier": "module:sirsoft-board",
"action": "add"
}
}
}
권한
모듈 자체 권한 네임스페이스에 IDV 정책 관리 권한 2 종 신설 — 알림 시스템 패턴({vendor}.settings.read|update) 동형.
// module.php::getPermissions() 의 categories 배열에 추가
[
'identifier' => 'identity.policies',
'name' => ['ko' => '... 본인인증 정책', 'en' => '... Identity Policies'],
'permissions' => [
['action' => 'read', 'type' => 'admin', 'roles' => ['admin'], ...],
['action' => 'update', 'type' => 'admin', 'roles' => ['admin'], ...],
],
],
광역 권한 core.identity.policies.manage 재사용은 비권장 — 게시판 운영자에게 부여하면 다른 모듈 정책까지 만질 수 있어 최소 권한 원칙 위배.
i18n
각 모듈의 resources/lang/partial/{ko,en}/admin/settings.json 의 tabs 에 identity_policies 키와, root 에 identity 섹션을 추가합니다. 키 구성은 게시판/이커머스 partial 을 참고하세요 — intro_title, intro, list_title, add_policy, empty, edit, grace_minutes, enable_success, disable_success, toggle_failed, col.*, scope.*, source.* (이커머스만 purposes_section_title, allowed_channels 추가).
테스트
백엔드 — 정책 declaration 검증
tests/Feature/Identity/{Vendor}IdentityPolicyDeclarationTest.php:
module.php::getIdentityPolicies()결과가IdentityPolicySyncHelper를 통해 source_type=module 컨텍스트로 적재- 운영자가 enabled 토글 후 재동기화해도 user_overrides 가 보존
cleanupStalePolicies()가 제거된 정책을 정리
백엔드 — 결제 등 가드 검증 (해당 시)
- Service 진입 시 정책 hook 이 발동
IdentityPolicyService::enforce()가 정책 활성+미인증 시IdentityVerificationRequiredExceptionthrowEnforceIdentityPolicyListener::getSubscribedHooks()가 모듈 hook target 을 동적 구독
외부 IDV provider 플러그인 — 메시지 정의 기여
외부 IDV provider 플러그인(KCP / PortOne / 토스인증 / 자체 메일 provider 등)이 자기 메일/SMS 문구의 기본값을 코어에 기여하는 패턴.
Filter 훅: core.identity.filter_default_message_definitions
코어 reset 로직이 운영자가 "기본값으로 복원" 클릭 시 시드 데이터를 모으는데, 플러그인이 자기 정의를 추가할 수 있도록 노출됩니다. 알림 시스템의 core.notification.filter_default_definitions 와 동형 패턴.
// 플러그인 Listener
public static function getSubscribedHooks(): array
{
return [
'core.identity.filter_default_message_definitions' => [
'method' => 'contributeDefinitions',
'priority' => 20,
'type' => 'filter',
],
];
}
public function contributeDefinitions(array $definitions, array $context = []): array
{
return array_merge($definitions, [
[
'provider_id' => 'kcp',
'scope_type' => \App\Models\IdentityMessageDefinition::SCOPE_PROVIDER_DEFAULT,
'scope_value' => '',
'extension_type' => 'plugin',
'extension_identifier' => 'sirsoft-kcp',
'name' => ['ko' => 'KCP 본인 확인 (기본)', 'en' => 'KCP Verification (default)'],
'channels' => ['mail'],
'variables' => [
['key' => 'code', 'description' => '인증 코드'],
['key' => 'expire_minutes', 'description' => '만료 분'],
],
'templates' => [
[
'channel' => 'mail',
'subject' => ['ko' => '[KCP] 본인 확인', 'en' => '[KCP] Verification'],
'body' => ['ko' => '<p>코드: {code}</p>', 'en' => '<p>Code: {code}</p>'],
],
],
],
]);
}
플러그인 자체 시더로도 동일 구조를 등록하면 설치 직후 즉시 발송 가능. 운영자는 환경설정 → 본인인증 → 메시지 템플릿 서브탭에서 다국어 제목/본문을 편집할 수 있습니다.
상세: docs/backend/identity-messages.md
외부 IDV provider 플러그인 — Extension Point 사용 (engine-v1.46.0+)
KCP / PortOne / 토스인증 / Stripe Identity 등 외부 IDV provider 를 G7 에 붙일 때 — 프론트엔드 코어 변경 없이 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입하면 됩니다. 다음 우편번호 (sirsoft-daum_postcode) / CKEditor5 (sirsoft-ckeditor5) 와 동일한 방식.
모듈/플러그인이 새 정책을 추가할 때 프론트엔드 작업이 필요 없음
코어 모달(_identity_challenge_modal.json) 이 text_code / link 두 render_hint 를 기본 처리하므로, 새 정책의 purpose 가 기존 render_hint 중 하나로 매핑되면 프론트엔드 추가 작업 없이 동일한 모달이 자동 적용됩니다.
외부 provider 가 자기 launcher 를 작성하는 경우 — SSoT 스키마 준수 의무
provider 가 자체 launcher / 모달 파셜을 추가하는 경우 (PortOne / KCP / 토스인증 등 SDK 가 모달이 아닌 자기 팝업/리다이렉트로 동작하는 케이스), _global.identityChallenge 네임스페이스 스키마를 그대로 준수해야 코어 모달 / 풀페이지 / 재전송 / 카운트다운과 호환됩니다.
특히 다음 필드는 launcher 가 반드시 채워야 합니다:
target({ email?, phone? } | null) — 첫 challenge 시작 body 에 동봉한 그 target 을 SSoT 에 저장. 누락 시 모달의 재전송 액션이 백엔드에서 422missing_target반환.expires_at(ISO8601) — 카운트다운 기준. launcher 가window.setInterval으로 매 초remainingSeconds를 직접 갱신 권장 (코어startInterval핸들러는 stale closure 위험).
상세 SSoT 표 + launcher 작성 체크리스트:
- identity-verification-ui.md "_global.identityChallenge 네임스페이스 스키마 (CONTRACT)" 섹션
- template-idv-bootstrap.md "launcher 작성 필수 항목 체크리스트"
새 정책 추가 → getIdentityPolicies() 선언 → DB 동기화 → 모달 자동 적용
새 render_hint / 외부 SDK 가 필요한 경우 — Extension Point 슬롯 사용
코어 모달은 다음 슬롯을 노출합니다:
identity_provider_ui:text_code— OTP 코드 입력 슬롯 (코어 default)identity_provider_ui:link— 링크 안내 슬롯 (코어 default)identity_provider_ui:provider— provider 별 SDK 주입 슬롯 (비어있음)
플러그인 extension JSON 예시 (plugins/{id}/resources/extensions/identity-provider.json):
{
"extension_point": "identity_provider_ui:provider",
"scripts": [
{ "src": "https://sdk.example.com/v2.js", "id": "vendor_sdk_v2" }
],
"components": [
{
"name": "Button",
"if": "{{_global.identityChallenge?.provider_id === 'vendor.method'}}",
"events": {
"onClick": {
"actions": [
{
"handler": "callExternalEmbed",
"params": {
"constructor": "VendorSdk.IdentityVerification",
"config": { "channelKey": "..." },
"callbackAction": [
{
"handler": "resolveIdentityChallenge",
"params": { "result": "verified", "token": "{{result.verification_token}}" }
}
]
}
}
]
}
}
}
]
}
| 인프라 | 위치 | 용도 |
|---|---|---|
레이아웃 scripts 필드 |
LayoutScript |
외부 SDK URL 자동 로드 (id 기반 dedupe) |
extension_point + scripts 병합 |
LayoutExtensionService |
슬롯에 컴포넌트 + scripts 동시 주입 |
callExternalEmbed 핸들러 |
ActionDispatcher.handleCallExternalEmbed |
SDK 인스턴스 layer/popup + callbackAction/callbackSetState |
resolveIdentityChallenge 핸들러 |
ActionDispatcher (engine-v1.46.0+) |
모달/풀페이지/SDK callback 이 launcher 에 결과 통보 |
비동기/외부 redirect provider 통합
provider 가 webhook 또는 redirect 콜백으로 결과를 보내는 경우 코어 비동기 인프라 활용:
GET /api/identity/challenges/{id}폴링 (Processing 상태 추적)POST /api/identity/callback/{providerId}외부 redirect 콜백 수신- 상세: docs/backend/identity-policies.md 11. 비동기·외부 redirect 플러그인 통합
참고 파일
- 게시판 모듈 구현 예: modules/_bundled/sirsoft-board/module.php, _tab_identity_policies.json
- 이커머스 모듈 구현 예 (purpose 등록 + 결제 가드): modules/_bundled/sirsoft-ecommerce/module.php
- 코어 IDV 인프라 가이드: docs/backend/identity-policies.md
- 코어 정책 관리 partial 레퍼런스: _tab_identity_policies.json (코어)
- 모달 UI 표준 + Extension Point 슬롯: ../frontend/identity-verification-ui.md
- 외부 템플릿 launcher 등록 가이드: template-idv-bootstrap.md
- 외부 SDK 통합 사례:
sirsoft-daum_postcode/resources/extensions/,sirsoft-ckeditor5/resources/extensions/