Files
Gnuboard7/docs/frontend/identity-verification-ui.md
T
HeuJung 50007d5cc6 fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현
2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.

원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.

이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.

## 구현

- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
 `ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
 「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
 수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
 코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
 뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
 사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
 못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
 문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.

## 이번 점검에서 함께 고친 것

- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
 그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
 한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
 성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
 같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.

## 버전

코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
2026-09-07 17:08:14 +09:00

336 lines
18 KiB
Markdown
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.
# 본인인증(IDV) 공통 UI 가이드
운영자가 활성화한 IDV 정책 N개에 대해 사용자/관리자 화면에서 동일한 모달 UX 로 본인 확인 흐름을 진행하기 위한 프론트엔드 표준입니다.
## TL;DR (5초 요약)
```text
1. 모든 IDV 강제 지점은 동일한 428 응답 형식을 공유 (코어 9 + 게시판 4 + 이커머스 4 + N)
2. 코어 IdentityGuardInterceptor 가 428 가로채 → 템플릿이 등록한 launcher 호출
3. launcher 가 challenge 시작 + 모달 open + deferred Promise 반환
4. 모달은 _global.identityChallenge 네임스페이스로 상태 일원화 — verify onSuccess 가 resolveIdentityChallenge 핸들러로 통보
5. render_hint(text_code/link/external_redirect) + provider_id 로 Extension Point 슬롯 분기 — 외부 IDV 플러그인은 동일 슬롯에 자기 SDK 주입
```
## 흐름도 — 428 → launcher → 모달 → return_request 재실행
```text
[사용자 액션] POST /api/auth/register
↓
[백엔드] EnforceIdentityPolicy 미들웨어 → 428 + verification payload
↓
[코어 ActionDispatcher.handleApiCall] IdentityGuardInterceptor.isIdentityRequired 감지
↓
[코어 IdentityGuardInterceptor.handle] launcher 호출 (템플릿 등록)
↓
[템플릿 launcher] POST /api/identity/challenges → challenge_id, expires_at, render_hint
↓
[템플릿 launcher] G7Core.state.set({ identityChallenge: {...} })
↓
[템플릿 launcher] dispatch(openModal: identity-challenge-modal)
↓
[모달 파셜] render_hint 별 UI 표시 (Extension Point 슬롯으로 플러그인 override 가능)
↓
[사용자 입력] 코드 입력 + 확인 클릭
↓
[모달] POST /api/identity/challenges/{id}/verify → 200 + verification_token
↓
[모달 onSuccess] resolveIdentityChallenge { result: 'verified', token } + closeModal
↓
[코어 IdentityGuardInterceptor.handle] return_request.url 에 ?verification_token=... 부착 후 fetch
↓
[백엔드] IdvTokenRule 검증 통과 → 가입 완료
```
## render_hint 별 UI 표준
| render_hint | 모달 표시 내용 | 폴백 동작 |
| --- | --- | --- |
| `text_code` | 6자리 코드 입력 + 카운트다운 + 재전송 버튼 | 코어 default UI (모달 파셜 fallback content) |
| `link` | "메일/SMS 링크 클릭" 안내 + 폴링 또는 재전송 | 코어 default UI |
| `external_redirect` | 모달 안 띄움. launcher 가 sessionStorage stash 후 redirect_url 로 navigate | 풀페이지 `/identity/challenge` |
| 플러그인 정의 (예: `plugin:kcp`) | 플러그인이 슬롯에 자기 SDK 주입 | provider 슬롯 비어있으면 무시 |
## Extension Point 슬롯 명명 규칙
코어 모달 파셜(`_identity_challenge_modal.json`)에 외부 IDV provider 플러그인이 자기 UI 와 SDK 를 주입할 수 있는 **단일 슬롯**이 박혀 있습니다:
| 슬롯 이름 | 용도 | if 가드 |
| --- | --- | --- |
| `identity_provider_ui:provider` | 외부 IDV provider UI 주입 단일 슬롯 | `provider_id` 가 truthy 이면서 코어 mail 이 아닐 때만 마운트 |
**코어 mail / provider 미지정 케이스의 OTP 입력 / 링크 안내 UI 는 모달 파셜의 plain Div** (Extension Point 아님) 로 정의되어 있습니다. 즉 외부 plugin 이 `mode: replace` 를 써도 코어 default UI 가 사라지지 않습니다.
### 외부 plugin 가 슬롯에 콘텐츠 주입
플러그인은 다음 우편번호 / CKEditor5 와 동일한 G7 표준 패턴(`scripts` + `extension_point` + `callExternalEmbed`)으로 자기 콘텐츠를 주입합니다. **반드시 `mode: append`** 를 사용하여 다른 IDV plugin 과 공존 가능하게 합니다.
```json
{
"extension_point": "identity_provider_ui:provider",
"mode": "append",
"priority": 100,
"scripts": [
{ "src": "https://kcp-cdn.example.com/sdk.js", "id": "kcp_sdk" }
],
"components": [
{
"name": "Button",
"if": "{{_global.identityChallenge?.provider_id === 'plugin.kcp'}}",
"events": {
"onClick": {
"actions": [
{
"handler": "callExternalEmbed",
"params": {
"constructor": "KCP.IdentitySDK",
"config": { "siteCode": "...", "purpose": "{{_global.identityChallenge.purpose}}" },
"callbackAction": [
{ "handler": "resolveIdentityChallenge", "params": { "result": "verified", "token": "{{result.token}}" } }
]
}
}
]
}
}
}
]
}
```
### 코어 mail 케이스 가드 패턴 (모달 / 풀페이지 공통)
모달의 코어 OTP UI · 재전송 버튼 · 확인 버튼은 다음 가드 패턴으로 노출 분기:
```
if: "(!_global.identityChallenge?.provider_id || _global.identityChallenge?.provider_id === 'g7:core.mail')"
```
코어 default provider (`g7:core.mail`) 가 환경설정의 default_provider 로 자동 채워질 수 있으므로 (정책 NULL provider_id fallback), `!provider_id` 만으로 가드하면 mail 인증 시 버튼/UI 가 사라지는 회귀가 발생합니다. 반드시 `|| === 'g7:core.mail'` 을 함께 검사할 것.
### 잘못된 패턴 (DO NOT)
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| `extension_point: "identity_provider_ui:text_code"` | `extension_point: "identity_provider_ui:provider"` — text_code/link 슬롯은 plain Div 로 이동했으므로 외부 plugin 은 provider 슬롯만 사용 |
| `mode: "replace"` | `mode: "append"` — replace 는 다른 plugin 의 UI 까지 잠식하여 공존 불가 |
| `if: "{{!_global.identityChallenge?.provider_id}}"` (재전송/확인 버튼) | `if: "{{!provider_id || provider_id === 'g7:core.mail'}}"` — default fallback 으로 mail 이 채워지는 케이스 대비 |
## `_global.identityChallenge` 네임스페이스 스키마 (CONTRACT)
본인인증 흐름의 모든 상태는 `_global.identityChallenge.*` 한 네임스페이스로 일원화합니다. launcher 가 모달 open 직전에 이 객체를 set 하고, 모달의 모든 액션이 이 경로를 읽고/씁니다. 외부 IDV 프로바이더 플러그인이 자기 launcher 를 작성하는 경우에도 **이 스키마를 그대로 준수**해야 모달 / 풀페이지 / 재전송 / 카운트다운이 일관되게 동작합니다.
| 키 | 타입 | 출처 | 용도 |
| --- | --- | --- | --- |
| `policy_key` | string | 428 verification payload | 디버깅 / 모달 분기 |
| `purpose` | string | 428 verification payload | 재전송 시 동일 purpose 로 challenge 재요청 |
| `provider_id` | string \| null | 428 verification payload | provider 별 슬롯 매칭 (`identity_provider_ui:provider`) |
| `render_hint` | string | challenge 응답 (또는 payload fallback) | 모달 슬롯 분기 — `text_code` / `link` / `external_redirect` / 플러그인 정의 |
| `challenge_id` | string | challenge 응답 | verify / cancel API 의 path param |
| `expires_at` | ISO8601 string | challenge 응답 | 카운트다운 만료 기준 |
| `public_payload` | object | challenge 응답 | 코드 길이 / 링크 힌트 등 provider 가 공개한 메타 |
| **`target`** | **`{ email?, phone? } \| null`** | **흐름이 apiCall `identity_target` 으로 선언 → launcher 가 payload.target 에서 사용 (없으면 로그인 세션 폴백)** | **모달 재전송 시 동일 target 으로 challenge 재요청 — 누락 시 백엔드 422 `missing_target`** |
| `code` | string | 모달 입력 | text_code 모드 OTP 입력값 |
| `error` | string \| null | 모달 onError | 사용자에게 보일 에러 메시지 |
| `attempts` / `maxAttempts` | number | 모달 onError / launcher 초기값 | 시도 횟수 표시 + verify 버튼 비활성화 조건 |
| `remainingSeconds` | number | launcher 카운트다운 | 분/초 표시 + verify 버튼 비활성화 조건 |
| `resendCooldown` | number | 모달 재전송 onSuccess / launcher 카운트다운 | 재전송 버튼 비활성화 조건 (30초) |
> 🛑 `target` 필드 누락은 흔한 회귀입니다. 비로그인(게스트) 흐름은 428 을 유발하는 apiCall 에 `identity_target` 을 선언해야 합니다(engine-v1.51.0+) — 서버 428 payload 에는 target 이 없고(서버는 화면 입력값을 모름) 흐름이 선언해야 launcher 가 받습니다. launcher 는 첫 challenge 시작에 사용한 target 을 같은 객체(`identityChallenge.target`)에 저장하세요 — 모달 재전송 액션이 다른 곳에서 폼 값을 읽을 수 없습니다(모달 컨텍스트는 페이지 _local 과 분리). 로그인 사용자는 선언이 없어도 서버 세션이 도출합니다.
### launcher 가 채워야 하는 필드 vs 모달이 갱신하는 필드
```text
[launcher 책임 — 모달 open 전 G7Core.state.set]
policy_key, purpose, provider_id, render_hint
challenge_id, expires_at, public_payload
target ← 외부 provider 플러그인도 동일 의무
code='', error=null, attempts=0, maxAttempts=5
remainingSeconds=초기값, resendCooldown=0
[모달 액션 책임 — 사용자 인터랙션에 따라 갱신]
code (Input onChange)
error / attempts (verify onError)
challenge_id / expires_at / render_hint / attempts=0 / code='' (재전송 onSuccess)
resendCooldown=30 (재전송 onClick 직후)
[launcher 자체 setInterval 책임 — 매 초 갱신]
remainingSeconds (expires_at 기반 재계산)
resendCooldown (1씩 감소)
```
> ℹ️ launcher 는 코어 `startInterval` 액션 핸들러를 사용하지 않고 직접 `window.setInterval` 으로 카운트다운을 돌리는 것을 권장합니다 — `startInterval` 은 등록 시점의 dispatch context 를 클로저로 캡처하기 때문에 직전의 `G7Core.state.set` (React 비동기 setState) 이 아직 commit 되지 않은 stale 컨텍스트로 매 tick 평가될 위험이 있습니다.
## 모달 상태 머신 (`_global.identityChallenge`)
상태는 `_global.identityChallenge.*` 네임스페이스로 일원화. launcher 가 모달 open 직전에 채우고, 모달이 사용자 액션에 따라 갱신.
```text
[idle]
↓ launcher 진입 + POST /challenges 성공
[challenge_requested] — challenge_id, expires_at, render_hint, public_payload 채워짐
↓ openModal
[awaiting_input] — 사용자 코드 입력 대기, 카운트다운 진행
↓ 확인 버튼 → POST /verify
[verifying]
├─ 200 → resolveIdentityChallenge { verified, token } → [verified] → closeModal
├─ 422 INVALID_CODE → setState error + attempts++ → [awaiting_input]
├─ 422 EXPIRED / MAX_ATTEMPTS → setState error → 사용자에게 재전송 권유
└─ network error → setState error → 재시도
↓ 카운트다운 0 도달
[expired] — 사용자가 재전송 버튼으로 [challenge_requested] 회귀
↓ 사용자 cancel
[cancelled] — resolveIdentityChallenge { cancelled } → 원 요청 폐기
```
## i18n 키 컨벤션
`identity.challenge.*` namespace 를 user/admin 양쪽에서 사용:
| 키 | 용도 |
| --- | --- |
| `title` | 모달 / 풀페이지 제목 |
| `code_title`, `code_subtitle`, `code_placeholder` | text_code 모드 헤더 / 입력 안내 |
| `verify` | 확인 버튼 |
| `resend`, `resend_link`, `resend_cooldown`, `resend_success` | 재전송 버튼 / 쿨다운 표시 / 성공 토스트 |
| `remaining_time`, `remaining_attempts`, `expired` | 카운트다운 / 만료 메시지 |
| `link_title`, `link_subtitle`, `check_spam` | link 모드 안내 |
| `external_title`, `external_subtitle`, `manual_redirect` | external_redirect 모드 안내 |
| `error_generic` | 알 수 없는 verify 실패 메시지 |
파라미터 형식: `$t:user.identity.challenge.remaining_time|minutes={{value}}|seconds={{value}}`.
## 422 failure_code 매핑
| failure_code | 의미 | UI 처리 |
| --- | --- | --- |
| `INVALID_CODE` | 코드 해시 불일치 | 에러 표시 + attempts++ |
| `EXPIRED` | TTL 초과 | 에러 표시 + 재전송 권유 |
| `MAX_ATTEMPTS` | 시도 횟수 초과 | 에러 표시 + 재전송 권유 (challenge 새로 발급 필요) |
| `NOT_FOUND` | 알 수 없는 challenge_id | 에러 표시 + 모달 닫고 재시도 권유 |
| `WRONG_PROVIDER` | provider_id 불일치 | 에러 표시 + 처음부터 재시도 |
| `INVALID_STATE` | 이미 처리된 challenge | 에러 표시 + 모달 닫기 |
## resolveIdentityChallenge 핸들러 사용법
코어가 등록한 표준 핸들러 — 모달 / 풀페이지 / 외부 SDK callback 모두 동일 이름으로 호출.
```json
// verify 성공
{ "handler": "resolveIdentityChallenge", "params": { "result": "verified", "token": "{{response.data.verification_token}}" } }
// 사용자 취소
{ "handler": "resolveIdentityChallenge", "params": { "result": "cancelled" } }
// verify 실패 (422 onError 에서)
{ "handler": "resolveIdentityChallenge", "params": { "result": "failed", "failureCode": "INVALID_CODE" } }
// 비동기 검증 — Stripe Identity / 토스인증 push 등 (인터페이스 예약)
{ "handler": "resolveIdentityChallenge", "params": { "result": "pending", "pollUrl": "/api/identity/challenges/{{id}}", "pollIntervalMs": 2000, "expiresAt": "{{...}}" } }
```
상세 스펙: [identity-guard-interceptor.md](identity-guard-interceptor.md).
## VerificationResult 4-상태 머신
코어 `IdentityGuardInterceptor` 의 launcher 반환 타입(engine-v1.46.0+):
```typescript
type VerificationResult =
| { status: 'verified'; token: string; providerData?: Record<string, unknown> }
| { status: 'pending'; pollUrl: string; pollIntervalMs?: number; expiresAt: string }
| { status: 'cancelled' }
| { status: 'failed'; failureCode: string; reason?: string };
```
- `verified` → return_request 재실행 + verification_token query 자동 부착
- `pending` → 1차 구현은 `failed` 로 강등. 향후 폴링 루프 도입 시 활용
- `cancelled` / `failed` → 원 요청 폐기
## 모달 파셜 작성 시 필수 규칙 (회귀 방지)
IDV 모달 파셜 (`_identity_challenge_modal.json`) 및 동일 패턴을 따르는 OTP/코드 입력 모달 작성 시 아래 규칙을 위반하면 **확인 버튼이 영구 비활성** 또는 **재전송 시 입력값이 초기화되지 않는** 회귀가 발생합니다.
### 1. Input 이벤트는 표준 actions 패턴만 사용
```text
✅ 사용: actions: [{ event: "onChange", handler: "setState", params: {...} }]
❌ 금지: events: { onChange: { actions: [...] } } ← 엔진이 인식하지 않음
```
엔진은 컴포넌트 노드의 `actions[]` 배열에서 `event` 필드로 이벤트 종류를 분기합니다. `events: {}` 래퍼는 어디에서도 처리되지 않으므로 onChange 가 발생하지 않고, controlled Input 의 `value` 바인딩이 갱신되지 않아 disabled 조건이 영구 true 가 됩니다.
### 2. controlled Input 은 `value` + `onChange` 한 쌍을 반드시 함께 정의
`name="code"` 만으로는 `_global.identityChallenge.code` 같은 외부 네임스페이스에 자동 바인딩되지 않습니다. 모달 내부 (`_global` 사용 강제 컨텍스트)에서는 다음을 모두 명시해야 합니다.
```json
{
"name": "Input",
"props": {
"name": "code",
"value": "{{_global.identityChallenge?.code ?? ''}}"
},
"actions": [
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"identityChallenge.code": "{{$event.target.value}}"
}
}
]
}
```
### 3. 재전송 등 재발급 액션은 즉시 입력값 초기화 (apiCall 응답 대기 금지)
재전송 클릭 시 `code` 초기화를 `apiCall.onSuccess` 안에만 넣으면, 네트워크 지연 동안 사용자가 이전 코드를 그대로 보면서 재입력 상황을 인지하지 못합니다. sequence 의 첫 setState (resendCooldown 30 설정과 함께) 에서 `identityChallenge.code: ""` 도 같이 넣어야 합니다.
```json
{
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"identityChallenge.resendCooldown": 30,
"identityChallenge.error": null,
"identityChallenge.code": "" // ← 즉시 초기화
}
},
{ "handler": "apiCall", "...": "..." }
]
}
}
```
### 4. 회귀 테스트 의무
`templates/_bundled/{template}/__tests__/layouts/identity-challenge-modal.test.tsx` 에 아래 3개 케이스 필수:
- code Input 에 `events` 키가 존재하지 않을 것 (비표준 래퍼 차단)
- code Input 의 `actions[]` 에 `event:"onChange"` + `handler:"setState"` + `target:"global"` 항목이 존재할 것
- 재전송 setState (resendCooldown=30) 의 params 에 `identityChallenge.code: ""` 가 포함될 것
로그인 2단계 인증 화면도 같은 규칙을 따르며, 회귀 테스트는
`templates/_bundled/{template}/__tests__/layouts/{admin-,}login-two-factor-step.test.tsx` 가 담당합니다.
### 5. 로그인 challenge 는 이 화면으로 처리하지 않는다
`purpose = login` challenge 는 로그인 전용 엔드포인트(`POST /api/auth/login/two-factor`,
`.../resend`)만 사용합니다. 공개 본인인증 엔드포인트(`POST /api/identity/challenges/{id}/verify`,
`.../cancel`)는 이 목적을 `403 PURPOSE_NOT_ALLOWED` 로 거부합니다 — 여기서 검증·취소되면 그
challenge 로는 더 이상 로그인을 마칠 수 없게 되고(자기 DoS), 이 화면은 로그인 흐름을 모르므로
되돌릴 방법도 없기 때문입니다.
## 관련 문서
- [identity-guard-interceptor.md](identity-guard-interceptor.md) — 코어 인터셉터 API 레퍼런스
- [../extension/template-idv-bootstrap.md](../extension/template-idv-bootstrap.md) — 외부 템플릿 개발자용 launcher 등록 가이드
- [../backend/identity-policies.md](../backend/identity-policies.md) — 백엔드 정책 시스템 + 비동기 인프라
- [../extension/module-identity-settings.md](../extension/module-identity-settings.md) — 모듈/플러그인 IDV 정책/목적 등록