Files
Gnuboard7/resources/js/core/identity/IdentityGuardInterceptor.ts
HeuJung 239bdffa10 docs(release): 7.0.1 출시일 7/3 조정 + 엔진 CHANGELOG 버전 병합·정합 정리
7.0.1 릴리즈 산출물 날짜를 2026-07-03 으로 조정하고, 엔진
CHANGELOG 의 engine-v1.52.0(DevTools 번들 분리) 과 engine-v1.51.0
(레이아웃 편집기 번들 분리) 을 단일 engine-v1.51.0 섹션으로 병합.

병합에 따라 DevTools 관련 코드 @since 를 v1.51.0 으로 정정하고,
그 과정에서 발견한 CHANGELOG 부재 유령 버전(v1.52.0/v1.52.2/
v1.52.4) 을 CHANGELOG 정본(engine-v1.50.0) 에 맞춰 정정.

주석/CHANGELOG 문자열 정정만으로 런타임 로직 변경 없음.
2026-07-03 10:02:38 +09:00

382 lines
15 KiB
TypeScript

/**
* IDV Guard Interceptor — 프론트엔드 전역 428 인터셉트.
*
* 서버가 HTTP 428 + error_code='identity_verification_required' 을 반환하면,
* 이 모듈이 Challenge 모달 launcher 를 호출하고 verify 성공 후 원 요청을 자동 재실행합니다.
*
* 사용 방식:
* - 부트스트랩 시 템플릿이 `setLauncher()` 로 모달 launcher 를 등록합니다.
* launcher 의 반환 타입은 `Promise<VerificationResult>` 로, 모달이 verify 성공/취소/실패를
* `resolveIdentityChallenge` 핸들러로 통보하면 인터셉터가 deferred resolver 를 통해 resolve 합니다.
* - launcher 미등록 외부 템플릿은 `defaultLauncher` 로 폴백 — 토스트 발행 + `/identity/challenge` navigate.
* - `render_hint='external_redirect'` 또는 `redirect_url` 존재 시 launcher 가 sessionStorage stash 후
* `window.location.href` 로 이동(헬퍼 `redirectExternally` 제공).
*
* `ActionDispatcher.handleApiCall` 응답 후처리 한 곳에서 `isIdentityRequired()` / `handle()`
* 정적 메서드를 직접 호출 — 별도 디스패처 인프라 없이 모든 apiCall 의 choke point 가 됨.
*
* @since engine-v1.44.0
*/
import { createLogger } from '../utils/Logger';
import {
IDENTITY_REDIRECT_STASH_KEY,
type IdentityResponse428,
type IdentityRedirectStash,
type ModalLauncher,
type VerificationPayload,
type VerificationResult,
} from './types';
const logger = createLogger('IdentityGuardInterceptor');
export type {
IdentityResponse428,
IdentityRedirectStash,
IdentityVerificationTarget,
ModalLauncher,
VerificationPayload,
VerificationResult,
} from './types';
export { IDENTITY_REDIRECT_STASH_KEY } from './types';
/**
* 428 응답을 감지해 인증 모달 launcher 를 실행한 뒤 return_request 를 재실행합니다.
*
* 실제 모달 UI 는 템플릿(공통 모달 파셜) + Extension Point 슬롯이 제공합니다.
* 이 인터셉터는 launcher 를 주입받아 호출하고, deferred resolver 슬롯을 통해
* 모달과 launcher Promise 를 잇는 글로벌 choke point 역할만 합니다.
*/
export class IdentityGuardInterceptor {
private static launcher: ModalLauncher | null = null;
private static deferredResolver:
| ((result: VerificationResult) => void)
| null = null;
/**
* challenge 레이어(모달/launcher)가 이번 IDV 사이클에서 자기 고유의 도메인 안내를
* 사용자에게 이미 표출했음을 표시하는 1회성 플래그.
*
* 배경: 본인확인은 성공했으나 부가 목적(성인인증 등)을 충족하지 못해 challenge 가
* 실패로 종료되면, provider 가 "성인 인증이 필요합니다" 같은 고유 사유를 토스트로 표출한다.
* 그 직후 코어가 원 428 을 onError 로 흘려보내면 원 요청의 generic 가드 토스트
* ("본인 확인이 필요합니다")가 중복으로 뜬다. 이 플래그가 set 되어 있으면 코어 toast 핸들러가
* 동일 사이클의 generic IDV 가드 토스트 1건을 skip 한다.
*
* 일반 본인인증 실패(본인확인 자체 실패/취소)는 이 플래그를 set 하지 않으므로
* generic 가드 토스트가 그대로 유지된다 — provider 무지식의 범용 신호다.
*
* @since engine-v1.50.0
*/
private static domainNoticeShown = false;
/**
* 모달 launcher 를 등록합니다.
*
* launcher 는 모달 open / 풀페이지 navigate / 외부 SDK 호출 등 UI 진입을 책임지며,
* 사용자 액션의 결과를 `VerificationResult` 로 resolve 합니다.
* 모달이 verify 성공 / 취소 / 실패를 알리려면 `resolveIdentityChallenge` 핸들러를 호출하세요.
*
* @param launcher 모달 launcher 함수
*/
static setLauncher(launcher: ModalLauncher): void {
this.launcher = launcher;
}
/**
* 등록된 launcher 가 있는지 여부.
*/
static hasLauncher(): boolean {
return this.launcher !== null;
}
/**
* challenge 레이어가 자기 고유의 도메인 안내(성인인증 실패 등)를 사용자에게 표출했음을 표시한다.
*
* provider(예: 이니시스 플러그인)가 "본인확인은 성공했으나 부가 목적 미달" 실패를 사용자에게
* 안내한 직후 호출한다. 코어 toast 핸들러가 동일 사이클의 generic IDV 가드 토스트를 1회 skip 한다.
* 호출 시점은 항상 코어가 원 428 을 onError 로 흘려보내기 직전(resolveIdentityChallenge 통보 전)이어야 한다.
*
* @since engine-v1.50.0
*/
static markDomainNoticeShown(): void {
this.domainNoticeShown = true;
}
/**
* 도메인 안내 표출 플래그를 읽고 즉시 소비(해제)한다.
*
* 코어 toast 핸들러가 generic IDV 가드 토스트를 띄우기 직전 1회 호출한다.
* true 면 그 토스트를 skip 하고, 플래그는 소비되어 다음 토스트부터는 정상 표출된다.
*
* @returns 직전 challenge 가 도메인 안내를 표출했으면 true (그리고 플래그 해제)
* @since engine-v1.50.0
*/
static consumeDomainNoticeShown(): boolean {
const shown = this.domainNoticeShown;
this.domainNoticeShown = false;
return shown;
}
/**
* 외부 호출(주로 `resolveIdentityChallenge` 핸들러)이 사용하는 진입점.
*
* launcher 진입 시 deferred resolver 가 등록되어 있어야 호출이 의미를 가집니다.
* launcher 가 없는데 호출되면 경고만 남기고 무시.
*/
static resolveDeferred(result: VerificationResult): void {
const resolver = this.deferredResolver;
if (!resolver) {
logger.warn(
'IdentityGuardInterceptor.resolveDeferred 호출됐지만 대기 중인 launcher 가 없습니다. 모달이 launcher 외부에서 열렸는지 확인하세요.'
);
return;
}
this.deferredResolver = null;
resolver(result);
}
/**
* launcher 가 deferred Promise 를 만들 때 사용하는 helper.
*
* 동시에 두 launcher 가 활성화되면 이전 resolver 는 `cancelled` 로 강제 종료됩니다.
*/
static createDeferred(): Promise<VerificationResult> {
if (this.deferredResolver) {
// 이전 launcher 가 아직 살아있다면 cancelled 처리하고 새 launcher 진입 허용
const stale = this.deferredResolver;
this.deferredResolver = null;
stale({ status: 'cancelled' });
}
return new Promise<VerificationResult>((resolve) => {
this.deferredResolver = resolve;
});
}
/**
* API 응답이 IDV 요구 응답인지 판별합니다.
*
* @param status HTTP 상태 코드
* @param body JSON 응답 본문
*/
static isIdentityRequired(status: number, body: unknown): body is IdentityResponse428 {
return (
status === 428 &&
typeof body === 'object' &&
body !== null &&
(body as any).error_code === 'identity_verification_required'
);
}
/**
* 428 응답을 처리합니다.
*
* launcher 가 등록되어 있으면 그 launcher 를, 아니면 `defaultLauncher` 를 호출합니다.
* verify 성공 시 `verification_token` 을 `return_request.url` 에 query 로 부착해 재실행합니다.
*
* 두 번째 인자 `originalRequest` 는 caller(주로 ActionDispatcher.handleApiCall) 가 만든
* 원 요청의 RequestInit 을 전달받습니다. retry fetch 가 원 요청의 body / headers / credentials
* 를 그대로 재사용해야 백엔드가 빈 body 로 422 를 던지지 않습니다 (회원가입 등 모든 POST 흐름).
*
* @param response 428 JSON 본문
* @param originalRequest 원 요청 RequestInit (body, headers, credentials) — 미전달 시 빈 body 로 fallback
* @param target 흐름이 선언한 인증 대상(이메일·전화) — apiCall `identity_target` 에서 평가된 값.
* launcher 가 challenge 시작 시 사용한다. 비로그인 게스트 흐름의 핵심 (서버는 화면 입력값을 모름).
* @returns 재실행 응답 또는 null (사용자 취소 / 실패 / return_request 없음)
*/
static async handle(
response: IdentityResponse428,
originalRequest?: Pick<RequestInit, 'body' | 'headers' | 'credentials'>,
target?: { email?: string; phone?: string },
): Promise<Response | null> {
// 새 challenge 진입 — 이전 사이클의 미소비 도메인 안내 플래그를 정리한다.
// (이전 사이클에서 onError 가드 토스트가 없어 consume 되지 않은 stale 플래그가
// 이번 사이클의 가드 토스트를 잘못 skip 하는 것을 차단)
this.domainNoticeShown = false;
const launcher = this.launcher ?? defaultLauncher;
// 흐름이 선언한 target 을 payload 에 병합 — launcher 는 payload.target 한 곳에서 읽는다.
// email/phone 둘 다 빈 값이면 병합하지 않음(로그인 사용자는 서버 세션 도출).
const hasTarget = !!(target && (target.email || target.phone));
const verification: VerificationPayload = hasTarget
? { ...response.verification, target }
: response.verification;
const result = await launcher(verification);
if (result.status !== 'verified') {
return null;
}
const returnReq = verification.return_request;
if (!returnReq) {
return null;
}
const replayUrl = appendVerificationToken(returnReq.url, result.token);
return fetch(replayUrl, {
method: returnReq.method,
headers: originalRequest?.headers ?? {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: originalRequest?.body,
credentials: originalRequest?.credentials ?? 'same-origin',
});
}
/**
* external_redirect 분기 — sessionStorage 에 stash 후 `window.location.href` 로 이동.
*
* launcher 안에서 `render_hint === 'external_redirect'` 또는 `verification.redirect_url`
* 존재 시 호출하세요. 콜백 후 페이지가 stash 를 복원해 원 요청을 재실행하는 책임은 호출자에게 있습니다.
*
* 반환값은 `Promise<VerificationResult>` 이지만 redirect 가 일어나면 페이지가 언로드되므로
* 사실상 resolve 되지 않습니다. 단위 테스트에서는 `pending` 으로 모킹.
*/
static redirectExternally(payload: VerificationPayload): Promise<VerificationResult> {
const redirectUrl = payload.redirect_url;
if (!redirectUrl) {
logger.error(
'redirectExternally 호출 시 verification.redirect_url 이 없습니다. payload:',
payload
);
return Promise.resolve({
status: 'failed',
failureCode: 'MISSING_REDIRECT_URL',
reason: 'verification.redirect_url is required for external_redirect flow',
});
}
if (typeof window !== 'undefined') {
const stash: IdentityRedirectStash = {
return_url: window.location.href,
payload,
stashed_at: Date.now(),
};
try {
window.sessionStorage?.setItem(IDENTITY_REDIRECT_STASH_KEY, JSON.stringify(stash));
} catch (e) {
logger.warn('sessionStorage 접근 실패 — stash 없이 redirect 합니다.', e);
}
window.location.href = redirectUrl;
}
// 페이지 언로드 — 절대 resolve 되지 않음. pending 으로 표기해 caller 가 polling 으로 전환할 수 있게 함.
return new Promise<VerificationResult>(() => {
/* never resolves */
});
}
/**
* 테스트 목적으로 launcher 와 deferred resolver 를 해제합니다.
*/
static reset(): void {
if (this.deferredResolver) {
const stale = this.deferredResolver;
this.deferredResolver = null;
try {
stale({ status: 'cancelled' });
} catch {
/* ignore */
}
}
this.launcher = null;
this.domainNoticeShown = false;
}
}
/**
* `return_request.url` 에 verification_token 을 query 로 부착합니다.
*
* 이미 query string 이 있으면 `&` 로, 없으면 `?` 로 시작하며,
* 같은 키가 이미 있으면 덮어씁니다.
*/
function appendVerificationToken(url: string, token: string): string {
if (!token) return url;
try {
// 절대 URL / 상대 URL 모두 안전하게 처리하기 위해 base 사용
const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const u = new URL(url, base);
u.searchParams.set('verification_token', token);
// 입력이 상대 경로였다면 상대 경로로 복원
if (!/^https?:\/\//i.test(url)) {
return `${u.pathname}${u.search}${u.hash}`;
}
return u.toString();
} catch {
// URL 파싱 실패 시 단순 부착
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}verification_token=${encodeURIComponent(token)}`;
}
}
/**
* launcher 미등록 외부 템플릿용 폴백 launcher.
*
* `/identity/challenge?return=...` 풀페이지로 navigate 하면서 토스트로 안내합니다.
* 풀페이지가 verify 후 `resolveIdentityChallenge` 를 호출해 결과를 돌려보냅니다.
*
* AuthManager / G7Core 가 미초기화이면 `console.error` 만 남기고 `cancelled` 로 강등.
*/
export const defaultLauncher: ModalLauncher = async (payload) => {
if (typeof window === 'undefined') {
return { status: 'cancelled' };
}
// external_redirect 는 폴백에서도 redirect 로 분기 — 풀페이지 진입조차 무의미하기 때문.
if (payload.render_hint === 'external_redirect' || payload.redirect_url) {
return IdentityGuardInterceptor.redirectExternally(payload);
}
const G7Core = (window as any).G7Core;
if (!G7Core?.dispatch) {
// G7Core 미초기화 — 진입 자체가 불가
// eslint-disable-next-line no-console
console.error(
'[IdentityGuardInterceptor] defaultLauncher: G7Core 가 초기화되지 않아 본인인증 흐름을 시작할 수 없습니다.'
);
return { status: 'failed', failureCode: 'G7_NOT_READY' };
}
// 토스트 안내 (best-effort)
try {
await G7Core.dispatch({
handler: 'toast',
params: {
message: '본인 확인이 필요합니다.',
variant: 'warning',
},
});
} catch {
/* ignore — 토스트가 없는 환경도 폴백 자체는 진행 */
}
// sessionStorage 에 stash 후 풀페이지로 이동
const returnUrl = window.location.href;
const stash: IdentityRedirectStash = {
return_url: returnUrl,
payload,
stashed_at: Date.now(),
};
try {
window.sessionStorage?.setItem(IDENTITY_REDIRECT_STASH_KEY, JSON.stringify(stash));
} catch {
/* ignore */
}
const challengePath = `/identity/challenge?return=${encodeURIComponent(returnUrl)}`;
try {
await G7Core.dispatch({
handler: 'navigate',
params: { path: challengePath },
});
} catch (e) {
logger.warn('navigate 실패 — window.location 으로 폴백', e);
window.location.href = challengePath;
}
// navigate 가 일어나면 페이지가 unmount — 이 Promise 는 사실상 resolve 되지 않음.
return new Promise<VerificationResult>(() => {
/* never resolves */
});
};