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

18 KiB
Raw Blame History

본인인증(IDV) 공통 UI 가이드

운영자가 활성화한 IDV 정책 N개에 대해 사용자/관리자 화면에서 동일한 모달 UX 로 본인 확인 흐름을 진행하기 위한 프론트엔드 표준입니다.

TL;DR (5초 요약)

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 재실행

[사용자 액션] 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 과 공존 가능하게 합니다.

{
  "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

_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 모달이 갱신하는 필드

[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 직전에 채우고, 모달이 사용자 액션에 따라 갱신.

[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 모두 동일 이름으로 호출.

// 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.

VerificationResult 4-상태 머신

코어 IdentityGuardInterceptor 의 launcher 반환 타입(engine-v1.46.0+):

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 패턴만 사용

✅ 사용: 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 사용 강제 컨텍스트)에서는 다음을 모두 명시해야 합니다.

{
  "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: "" 도 같이 넣어야 합니다.

{
  "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), 이 화면은 로그인 흐름을 모르므로 되돌릴 방법도 없기 때문입니다.

관련 문서