Files
Gnuboard7/plugins/_bundled/sirsoft-verification_kginicis/resources/js/index.ts
T
HeuJung bd215bc586 feat(identity): NHN KCP 휴대폰 본인확인 플러그인 추가
메일 인증 대신 휴대폰 본인확인을 쓸 수 있게 하는 플러그인을 추가한다.
가입·비밀번호 재설정·성인인증 등 코어 IDV 가 강제하는 모든 지점에서 동작하며,
테스트 모드로 계약 없이 전 흐름을 확인할 수 있다.

구현·검수 과정에서 드러난 저장소 전역 결함을 함께 처리했다.

- 외래키 컬럼의 한국어 comment 가 방출되지 않던 문제 (소스 38건 교정 +
 기설치본 백필 업그레이드 스텝 7종). ->comment 를 ->constrained 뒤에
 체인하면 예외 없이 통과하면서 comment 가 조용히 사라진다
- 로케일 전환 후 확장 액션 핸들러가 소실되던 문제 (플러그인 3종에
 재등록 진입점 노출)
- 본인인증 상태 폴링 응답이 누적 시도 횟수를 함께 내보내던 문제
- 인증 안내 문구와 실제 진행 방식의 폭 경계 불일치 (768~1023px).
 엔진과 같은 값을 같은 방법으로 읽도록 교정
- transition_overlay_target 이 replace 없이 무효이던 관리자 목록 40곳.
 로딩 표시가 나오지 않아도 경고가 남지 않아 화면을 봐야만 알 수 있었다

두 인증 플러그인의 코어 최소 요구 버전을 7.0.6 으로 올린다. 운영 모드
자격증명 필수 검사가 7.0.6 의 설정 저장 검증 표면에 의존하며, 그보다 낮은
코어에서는 그 검사가 조용히 통과해 빈 자격증명으로 저장할 수 있었다.

재발 차단으로 정적 검사 룰 4종을 신설하고, 검사 도구가 미커밋 신규 확장을
"검사 파일 0" 으로 통과시키던 사각을 함께 막았다.
2026-07-31 00:32:46 +09:00

386 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* KG이니시스 본인인증 plugin — 코어 IDV 모달 슬롯 기반 통합.
*
* 설계 (Phase E′ 전환):
* - 코어 IDV 흐름 (428 → 템플릿 launcher → POST /api/identity/challenges → 모달 open) 을 그대로 사용
* - 코어 모달의 `identity_provider_ui:text_code` Extension Point 슬롯을 본 plugin 의
* `resources/extensions/identity_provider_inicis.json` (mode: 'replace') 로 교체
* - 슬롯 안의 "이니시스 본인인증 시작" 버튼이 본 파일의 핸들러 `startAuth` 를 호출
* - 사용자 클릭 직접 호출 → window.open 이 사용자 제스처 컨텍스트 안에서 실행 →
* Chrome popup blocker 회피 (자동 호출은 차단됨)
* - 인증 완료 시 bridge 페이지가 부모창에 postMessage → resolveIdentityChallenge 핸들러 →
* 코어 모달이 닫히고 return_request 재실행
*
* 이전 setLauncher 덮어쓰기 방식은 popup blocker 차단으로 폐기 (Phase E′-revert).
*
* @since 1.0.0-beta.1
*/
const PLUGIN_IDENTIFIER = 'sirsoft-verification_kginicis';
const PROVIDER_ID = 'inicis';
const POPUP_FEATURES = 'width=400,height=640,scrollbars=yes,resizable=yes';
const INICIS_AUTH_URL = 'https://sa.inicis.com/auth';
const POPUP_CLOSED_POLL_MS = 500;
const MODAL_ID = 'identity-challenge-modal';
interface InicisChallengePayload {
mid: string;
mtxid: string;
reqSvcCd: string;
flgFixedUser: string;
reservedMsg: string;
authHash: string;
}
interface BridgeResult {
type: 'identity_result';
verification_token?: string;
challenge_id?: string;
identity_error?: string;
}
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
};
function getG7Core(): Record<string, any> | null {
return ((window as any).G7Core as Record<string, any> | undefined) ?? null;
}
function buildSuccessUrl(): string {
return `${window.location.origin}/plugins/${PLUGIN_IDENTIFIER}/plugin/inicis/callback`;
}
/**
* 이니시스 본인인증 form 을 동적 생성하여 지정 target 에 POST.
*
* KISA 샘플 (sirsoft-verification/inicis/PHP/request.php) 와 동일한 form 구조 + setAttribute 패턴.
*/
function submitInicisForm(payload: InicisChallengePayload, popupName: string): void {
const form = document.createElement('form');
form.setAttribute('name', 'saForm');
form.setAttribute('method', 'POST');
form.setAttribute('action', INICIS_AUTH_URL);
form.setAttribute('target', popupName);
form.setAttribute('accept-charset', 'UTF-8');
form.style.display = 'none';
const fields: Record<string, string> = {
mid: payload.mid,
reqSvcCd: payload.reqSvcCd,
mTxId: payload.mtxid,
authHash: payload.authHash,
flgFixedUser: payload.flgFixedUser,
successUrl: buildSuccessUrl(),
failUrl: buildSuccessUrl(),
reservedMsg: payload.reservedMsg,
};
for (const [name, value] of Object.entries(fields)) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
// form 은 자동 정리되지 않으므로 다음 tick 에 제거
window.setTimeout(() => { try { form.remove(); } catch { /* noop */ } }, 0);
}
/**
* _global.identityChallenge.providerInProgress 플래그를 set 한다.
*
* 모달 슬롯의 if 분기 — true 면 진행 중 안내 (스피너 + "팝업창에서 진행 중") 노출,
* false 면 사전 안내 카드 + 시작 버튼 노출.
*/
function setProviderInProgress(value: boolean): void {
const G7Core = getG7Core();
const set = G7Core?.state?.set;
if (typeof set !== 'function') return;
set({ identityChallenge: { providerInProgress: value } });
}
/**
* verify 실패 코드 → 사용자 안내 메시지 i18n 키 매핑.
*
* 브리지 query 에는 failureCode 만 실려오므로(다국어 메시지 미전달), 프론트에서 코드를
* `$t:` 다국어 키로 매핑한다. 코어 toast 핸들러가 `$t:` prefix 를 자동 번역하므로
* 하드코딩 없이 로케일별 메시지가 표시된다.
*
* 미성년자 차단(NOT_ADULT) 안내 + 기존 미노출 실패 다건 안내를 일괄 처리한다.
*/
const FAILURE_MESSAGE_KEYS: Record<string, string> = {
NOT_ADULT: '$t:sirsoft-verification_kginicis.errors.not_adult',
INVALID_AUTH_URL: '$t:sirsoft-verification_kginicis.errors.invalid_auth_url',
DECRYPT_FAILED: '$t:sirsoft-verification_kginicis.errors.decrypt_failed',
REMOTE_CALL_FAILED: '$t:sirsoft-verification_kginicis.errors.remote_call_failed',
NOT_FOUND: '$t:sirsoft-verification_kginicis.errors.not_found',
ALREADY_CONSUMED: '$t:sirsoft-verification_kginicis.errors.already_consumed',
IDENTITY_BINDING_MISMATCH: '$t:sirsoft-verification_kginicis.errors.binding_mismatch',
INCOMPLETE_IDENTITY: '$t:sirsoft-verification_kginicis.errors.incomplete_identity',
STORAGE_FAILED: '$t:sirsoft-verification_kginicis.errors.storage_failed',
};
/** 매핑되지 않은 코드용 일반 실패 안내 키 */
const FAILURE_MESSAGE_FALLBACK = '$t:sirsoft-verification_kginicis.errors.verify_failed';
/**
* "본인확인 자체는 성공했으나 부가 목적(성년 등)을 충족하지 못해" 실패한 코드 집합.
*
* 이 부류의 실패는 사용자에게 고유 사유(예: 성인 인증 안내)를 토스트로 표출하므로, 코어가
* 원 요청의 generic 가드 토스트("본인 확인이 필요합니다")를 중복 발화하지 않도록
* `G7Core.identity.markDomainNoticeShown()` 신호를 남긴다.
*
* 일반 본인인증 실패(NOT_FOUND/INCOMPLETE_IDENTITY 등 본인확인 자체 실패)는 여기에 넣지 않는다 —
* 그 경우 코어 generic 가드 토스트가 유일한 안내이므로 유지되어야 한다.
*
* 부수 목적이 추가되면(예: 실명 1급 인증 등) 그 failureCode 를 이 집합에 추가하기만 하면
* 코어/타 확장 수정 없이 동일하게 중복 억제가 동작한다.
*/
const SUPPLEMENTARY_PURPOSE_FAILURE_CODES = new Set<string>([
'NOT_ADULT',
]);
/**
* failureCode 를 사용자 안내용 `$t:` 다국어 키로 변환한다.
*
* @param failureCode 브리지에서 전달된 실패 코드 (빈 값이면 fallback)
* @returns `$t:` 다국어 키
*/
function resolveFailureMessageKey(failureCode: string): string {
return FAILURE_MESSAGE_KEYS[failureCode] ?? FAILURE_MESSAGE_FALLBACK;
}
/**
* 코어에 "이번 IDV 사이클에서 도메인 안내(성인인증 실패 등)를 사용자에게 표출했다"는 신호를 남긴다.
*
* 코어 toast 핸들러가 동일 사이클의 generic IDV 가드 토스트("본인 확인이 필요합니다")를 1회 skip 한다.
* G7Core.identity 미초기화(구버전 코어)면 no-op — 이 경우 가드 토스트가 그대로 떠도 기능엔 영향 없음.
*/
function markDomainNoticeShown(): void {
const mark = getG7Core()?.identity?.markDomainNoticeShown;
if (typeof mark === 'function') {
try { mark(); } catch { /* noop */ }
}
}
/**
* dispatch 가 가능하면 toast 발행 (best-effort, 실패해도 흐름 유지).
*/
async function safeToast(message: string, type: 'error' | 'warning' | 'success' = 'error'): Promise<void> {
const dispatch = getG7Core()?.dispatch;
if (typeof dispatch !== 'function') return;
try {
await dispatch({ handler: 'toast', params: { type, message } });
} catch { /* noop */ }
}
/**
* postMessage 결과 또는 popup.closed 감지 시 호출 — resolveIdentityChallenge + closeModal 을
* sequence 로 함께 dispatch 하여 모달이 자동으로 닫히도록 한다.
*
* resolveIdentityChallenge 만 단독 dispatch 하면 코어 IdentityGuardInterceptor 의 deferred resolver
* 만 호출되어 모달이 잔존하는 결함 (Phase E′-revert 이전 상태) 을 방지.
*/
function dispatchResolveAndClose(params: Record<string, unknown>): Promise<unknown> {
const dispatch = getG7Core()?.dispatch;
if (typeof dispatch !== 'function') return Promise.resolve();
return dispatch({
handler: 'sequence',
params: {
actions: [
{ handler: 'resolveIdentityChallenge', params },
{ handler: 'closeModal', target: MODAL_ID },
],
},
}).catch(() => { /* noop */ });
}
/**
* 코어 모달의 "이니시스 본인인증 시작" 버튼이 호출하는 핸들러.
*
* 코어 challenge 시작 응답의 `public_payload` 에 mid/mtxid/authHash 가 채워져 있다고 가정.
* 사용자 직접 클릭 컨텍스트에서 window.open 호출 → Chrome popup blocker 회피.
*
* 흐름:
* 1. _global.identityChallenge.public_payload 에서 페이로드 추출
* 2. window.open 으로 빈 팝업 생성 (KISA 샘플과 동일 — 빈 URL + 고정 name 'sa_popup')
* 3. providerInProgress=true 로 set → 모달 슬롯이 진행 중 안내로 전환
* 4. 동적 form 생성 + form.target=팝업 + POST
* 5. bridge postMessage 수신 또는 popup.closed 감지 시:
* - postMessage 수신 → resolveIdentityChallenge(verified|failed|cancelled) + closeModal sequence
* - 사용자 팝업 직접 닫음 → providerInProgress=false 로 set, 모달은 열린 채 유지 (재시도 가능)
*/
async function startAuthHandler(): Promise<void> {
const G7Core = getG7Core();
const challenge = G7Core?.state?.getGlobal?.()?.identityChallenge ?? G7Core?.state?.get?.()?.identityChallenge;
if (!challenge) {
logger.error('_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요');
return;
}
const payload = challenge.public_payload as InicisChallengePayload | undefined;
if (!payload?.mid || !payload?.mtxid || !payload?.authHash) {
logger.error('public_payload 의 mid/mtxid/authHash 부재 — Provider.requestChallenge 응답 확인 필요', payload);
await safeToast('본인인증 페이로드가 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.');
return;
}
// 사용자 제스처 컨텍스트 안에서 popup 열기
const popupName = 'sa_popup';
const popup = window.open('', popupName, POPUP_FEATURES);
if (!popup) {
logger.error('window.open null — 팝업 차단됨');
await safeToast('팝업이 차단되었습니다. 브라우저 팝업 허용 설정 후 다시 시도해 주세요.');
return;
}
// 진행 중 상태 진입 — 모달 슬롯이 스피너 + 안내로 전환
setProviderInProgress(true);
submitInicisForm(payload, popupName);
// listener 와 polling 은 둘 중 먼저 발생한 분기에서만 cleanup 수행 (이중 dispatch 방지)
let settled = false;
let pollHandle: number | null = null;
let messageHandler: ((ev: MessageEvent<unknown>) => void) | null = null;
const cleanup = (): void => {
if (messageHandler) {
window.removeEventListener('message', messageHandler);
messageHandler = null;
}
if (pollHandle !== null) {
window.clearInterval(pollHandle);
pollHandle = null;
}
};
messageHandler = (ev: MessageEvent<unknown>) => {
if (ev.origin !== window.location.origin) return;
const data = ev.data as BridgeResult | null;
if (!data || data.type !== 'identity_result') return;
if (settled) return;
settled = true;
cleanup();
try { popup.close(); } catch { /* noop */ }
setProviderInProgress(false);
const params = data.verification_token
? { result: 'verified', token: data.verification_token }
: data.identity_error
? { result: 'failed', failureCode: data.identity_error }
: { result: 'cancelled' };
// verify 실패 시 사용자에게 사유 안내 toast 발행.
// 차단/실패는 동작하나 안내가 누락되던 결함을 NOT_ADULT 포함 전체 실패 코드에 대해 보강.
if (data.identity_error) {
// 부가 목적 미달류(성인인증 실패 등)는 고유 사유를 여기서 표출하므로, 코어가 원 요청의
// generic 가드 토스트("본인 확인이 필요합니다")를 중복 발화하지 않도록 신호를 남긴다.
// dispatchResolveAndClose → 코어 onError 가 가드 토스트를 띄우기 전에 set 되어야 한다.
if (SUPPLEMENTARY_PURPOSE_FAILURE_CODES.has(data.identity_error)) {
markDomainNoticeShown();
}
void safeToast(resolveFailureMessageKey(data.identity_error), 'error');
}
void dispatchResolveAndClose(params);
};
window.addEventListener('message', messageHandler);
// popup.closed 폴링 — 사용자가 인증 안 끝내고 팝업 X 닫음 감지
// postMessage 가 먼저 도착하면 settled=true 로 폴링이 무시됨.
pollHandle = window.setInterval(() => {
if (settled) {
cleanup();
return;
}
if (popup.closed) {
settled = true;
cleanup();
// 사용자 직접 닫음 — resolveIdentityChallenge 는 dispatch 안 함.
// 모달은 열린 채 유지하여 시작 버튼 재클릭으로 재시도 가능.
setProviderInProgress(false);
}
}, POPUP_CLOSED_POLL_MS);
}
/**
* Plugin 핸들러를 코어 ActionDispatcher 에 등록.
*
* 등록되면 extension JSON 의 actions 에서 `handler: "sirsoft-verification_kginicis.startAuth"`
* 식별자로 호출 가능.
*/
function registerHandlers(): boolean {
const G7Core = getG7Core();
const getDispatcher = G7Core?.getActionDispatcher;
if (typeof getDispatcher !== 'function') return false;
const dispatcher = getDispatcher();
if (!dispatcher || typeof dispatcher.registerHandler !== 'function') return false;
dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.startAuth`, startAuthHandler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
logger.info('startAuth handler registered');
return true;
}
/**
* ActionDispatcher 가 준비될 때까지 재시도하며 핸들러를 등록한다.
*/
function registerHandlersWithRetry(): void {
if (registerHandlers()) return;
let retries = 0;
const interval = window.setInterval(() => {
retries++;
if (registerHandlers() || retries >= 50) {
window.clearInterval(interval);
if (retries >= 50) {
logger.warn('G7Core ActionDispatcher 미준비 — handler 등록 실패');
}
}
}, 100);
}
/**
* 코어 재초기화 시 호출되는 진입점.
*
* 로케일 전환 등으로 TemplateApp 이 ActionDispatcher 를 새로 만들면
* `TemplateApp.reinitializePluginHandlers()` 가 `window.__[Plugin].initPlugin()` 을 호출한다.
* 이 이름으로 노출하지 않으면 재초기화 후 startAuth 핸들러가 소실되어
* 본인확인 시작 버튼이 무반응이 된다.
*/
function initPlugin(): void {
registerHandlersWithRetry();
}
function init(): void {
registerHandlersWithRetry();
}
// 테스트 환경에서는 vitest 가 jsdom 으로 window 를 제공하지만 G7Core 를 직접 mock 하므로
// 자동 init 을 건너뛰도록 한다 (`import.meta.env.MODE === 'test'` 시).
if (typeof import.meta === 'undefined' || (import.meta as any).env?.MODE !== 'test') {
init();
}
(window as any).__SirsoftVerificationKginicis = {
identifier: PLUGIN_IDENTIFIER,
init,
// 코어 재초기화 시 핸들러 재등록 진입점 — 이름 고정 (TemplateApp.reinitializePluginHandlers)
initPlugin,
startAuthHandler,
};
// 테스트 / 외부 도구가 import 로 직접 호출할 수 있도록 named export 도 노출
export { startAuthHandler, init, initPlugin, PLUGIN_IDENTIFIER, PROVIDER_ID, MODAL_ID };