메일 인증 대신 휴대폰 본인확인을 쓸 수 있게 하는 플러그인을 추가한다. 가입·비밀번호 재설정·성인인증 등 코어 IDV 가 강제하는 모든 지점에서 동작하며, 테스트 모드로 계약 없이 전 흐름을 확인할 수 있다. 구현·검수 과정에서 드러난 저장소 전역 결함을 함께 처리했다. - 외래키 컬럼의 한국어 comment 가 방출되지 않던 문제 (소스 38건 교정 + 기설치본 백필 업그레이드 스텝 7종). ->comment 를 ->constrained 뒤에 체인하면 예외 없이 통과하면서 comment 가 조용히 사라진다 - 로케일 전환 후 확장 액션 핸들러가 소실되던 문제 (플러그인 3종에 재등록 진입점 노출) - 본인인증 상태 폴링 응답이 누적 시도 횟수를 함께 내보내던 문제 - 인증 안내 문구와 실제 진행 방식의 폭 경계 불일치 (768~1023px). 엔진과 같은 값을 같은 방법으로 읽도록 교정 - transition_overlay_target 이 replace 없이 무효이던 관리자 목록 40곳. 로딩 표시가 나오지 않아도 경고가 남지 않아 화면을 봐야만 알 수 있었다 두 인증 플러그인의 코어 최소 요구 버전을 7.0.6 으로 올린다. 운영 모드 자격증명 필수 검사가 7.0.6 의 설정 저장 검증 표면에 의존하며, 그보다 낮은 코어에서는 그 검사가 조용히 통과해 빈 자격증명으로 저장할 수 있었다. 재발 차단으로 정적 검사 룰 4종을 신설하고, 검사 도구가 미커밋 신규 확장을 "검사 파일 0" 으로 통과시키던 사각을 함께 막았다.
576 lines
25 KiB
JSON
576 lines
25 KiB
JSON
{
|
|
"version": "1.0.0",
|
|
"layout_name": "auth/identity_challenge",
|
|
"extends": "_user_base",
|
|
"meta": {
|
|
"title": "$t:user.identity.challenge.title"
|
|
},
|
|
"state": {
|
|
"code": "",
|
|
"resendCooldown": 0,
|
|
"attempts": 0,
|
|
"maxAttempts": 0,
|
|
"expiresAt": null,
|
|
"remainingSeconds": 0,
|
|
"error": null
|
|
},
|
|
"data_sources": [
|
|
{
|
|
"id": "challenge",
|
|
"label_key": "$t:editor.data_source.challenge",
|
|
"type": "api",
|
|
"endpoint": "/api/identity/challenges/{{query.challenge_id}}",
|
|
"method": "GET",
|
|
"auto_fetch": false,
|
|
"fallback": {
|
|
"data": {
|
|
"id": "{{query.challenge_id ?? ''}}",
|
|
"label_key": "$t:editor.data_source.{{query.challenge_id ?? ''}}",
|
|
"render_hint": "{{query.render_hint ?? 'text_code'}}",
|
|
"purpose": "{{query.purpose ?? ''}}",
|
|
"redirect_url": "{{query.redirect_url ?? ''}}",
|
|
"expires_at": "{{query.expires_at ?? ''}}"
|
|
}
|
|
}
|
|
}
|
|
],
|
|
"init_actions": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"challenge_id": "{{query.challenge_id ?? ''}}",
|
|
"render_hint": "{{query.render_hint ?? 'text_code'}}",
|
|
"purpose": "{{query.purpose ?? 'signup'}}",
|
|
"return_url": "{{query.return_url ?? '/'}}",
|
|
"redirect_url": "{{query.redirect_url ?? ''}}",
|
|
"expiresAt": "{{query.expires_at ?? ''}}",
|
|
"remainingSeconds": "{{query.expires_at ? Math.max(0, Math.floor((new Date(query.expires_at).getTime() - Date.now()) / 1000)) : 0}}",
|
|
"attempts": "{{Number(query.attempts ?? 0)}}",
|
|
"maxAttempts": "{{Number(query.max_attempts ?? 0)}}",
|
|
"resendCooldown": 0
|
|
}
|
|
},
|
|
{
|
|
"comment": "challenge_id 가 있으면 서버에서 정확한 max_attempts/expires_at/render_hint 를 받아 query fallback 을 덮어쓴다 — 외부 redirect 콜백 후 진입 시 UI 정합 보장. 누적 시도 횟수(attempts)는 이 응답에 없다 — 인증 없이 challenge id 만으로 조회되는 공개 경로라 서버가 싣지 않는다(IdentityVerificationService::getStatus 주석 참조). 따라서 _local.attempts 는 이 화면에서 시도한 횟수만 세는 자체 카운터이며, 남은 횟수를 화면에 표시하지 않는다.",
|
|
"handler": "apiCall",
|
|
"target": "/api/identity/challenges/{{query.challenge_id}}",
|
|
"if": "{{!!query.challenge_id}}",
|
|
"params": { "method": "GET" },
|
|
"onSuccess": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"render_hint": "{{response.data.render_hint ?? _local.render_hint}}",
|
|
"purpose": "{{response.data.purpose ?? _local.purpose}}",
|
|
"expiresAt": "{{response.data.expires_at ?? _local.expiresAt}}",
|
|
"remainingSeconds": "{{response.data.expires_at ? Math.max(0, Math.floor((new Date(response.data.expires_at).getTime() - Date.now()) / 1000)) : (_local.remainingSeconds ?? 0)}}",
|
|
"maxAttempts": "{{Number(response.data.max_attempts ?? _local.maxAttempts ?? 0)}}"
|
|
}
|
|
},
|
|
{
|
|
"handler": "redirect",
|
|
"if": "{{(response.data.render_hint ?? _local.render_hint) === 'external_redirect' && _local.redirect_url}}",
|
|
"params": {
|
|
"url": "{{_local.redirect_url}}"
|
|
}
|
|
}
|
|
],
|
|
"onError": [{ "handler": "suppress" }]
|
|
},
|
|
{
|
|
"comment": "외부 redirect 콜백 후 challenge_id 없이 진입한 경우 (render_hint=external_redirect 가 query 에서 직접 들어옴) — apiCall 우회 경로 보강.",
|
|
"handler": "redirect",
|
|
"if": "{{!query.challenge_id && _local.render_hint === 'external_redirect' && _local.redirect_url}}",
|
|
"params": {
|
|
"url": "{{_local.redirect_url}}"
|
|
}
|
|
},
|
|
{
|
|
"handler": "startInterval",
|
|
"params": {
|
|
"id": "identity_challenge_tick",
|
|
"intervalMs": 1000,
|
|
"actions": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"remainingSeconds": "{{_local.expiresAt ? Math.max(0, Math.floor((new Date(_local.expiresAt).getTime() - Date.now()) / 1000)) : 0}}",
|
|
"resendCooldown": "{{Math.max(0, (_local.resendCooldown ?? 0) - 1)}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
],
|
|
"slots": {
|
|
"content": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "min-h-[60vh] flex items-center justify-center py-12 px-4"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "w-full max-w-md bg-white dark:bg-gray-800 rounded-lg shadow-md p-8"
|
|
},
|
|
"responsive": {
|
|
"portable": {
|
|
"props": {
|
|
"className": "w-full max-w-md bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"
|
|
}
|
|
}
|
|
},
|
|
"children": [
|
|
{
|
|
"comment": "S4 — render_hint = text_code (OTP 코드 입력)",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{(_local.render_hint ?? 'text_code') === 'text_code'}}",
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "text-center mb-6"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"props": {
|
|
"className": "text-2xl font-bold text-gray-900 dark:text-white mb-2"
|
|
},
|
|
"text": "$t:user.identity.challenge.code_title"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "P",
|
|
"props": {
|
|
"className": "text-sm text-gray-600 dark:text-gray-400"
|
|
},
|
|
"text": "$t:user.identity.challenge.code_subtitle"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{_local.error}}",
|
|
"props": {
|
|
"className": "mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": {
|
|
"className": "text-sm text-red-700 dark:text-red-300"
|
|
},
|
|
"text": "{{_local.error ?? ''}}"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "space-y-4"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Input",
|
|
"props": {
|
|
"type": "text",
|
|
"name": "code",
|
|
"inputMode": "numeric",
|
|
"autoComplete": "one-time-code",
|
|
"placeholder": "$t:user.identity.challenge.code_placeholder",
|
|
"maxLength": 10,
|
|
"className": "w-full px-4 py-3 text-center text-xl tracking-widest font-mono border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
}
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "flex items-center justify-between text-sm",
|
|
"role": "status",
|
|
"aria-live": "polite"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{(_local.remainingSeconds ?? 0) > 0}}",
|
|
"props": {
|
|
"className": "text-gray-600 dark:text-gray-400"
|
|
},
|
|
"text": "$t:user.identity.challenge.remaining_time|minutes={{Math.floor((_local.remainingSeconds ?? 0) / 60)}}|seconds={{String((_local.remainingSeconds ?? 0) % 60).padStart(2, '0')}}"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{(_local.remainingSeconds ?? 0) <= 0 && _local.expiresAt}}",
|
|
"props": {
|
|
"className": "text-red-600 dark:text-red-400"
|
|
},
|
|
"text": "$t:user.identity.challenge.expired"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "button",
|
|
"className": "w-full px-4 py-3 text-sm font-medium bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors",
|
|
"disabled": "{{!_local.code || _local.code.length < 4 || (_local.remainingSeconds ?? 0) <= 0 || ((_local.maxAttempts ?? 0) > 0 && (_local.attempts ?? 0) >= (_local.maxAttempts ?? 0))}}"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "sequence",
|
|
"params": {
|
|
"actions": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"error": null
|
|
}
|
|
},
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/identity/challenges/{{_local.challenge_id}}/verify",
|
|
"params": {
|
|
"method": "POST",
|
|
"body": {
|
|
"code": "{{_local.code ?? ''}}"
|
|
}
|
|
},
|
|
"onSuccess": [
|
|
{
|
|
"comment": "코어 IdentityGuardInterceptor 의 deferred resolver 가 살아있다면(launcher 미등록 폴백으로 풀페이지 진입) verify 결과 통보 — defaultLauncher 폴백 흐름 일관성 확보",
|
|
"handler": "resolveIdentityChallenge",
|
|
"params": {
|
|
"result": "verified",
|
|
"token": "{{response.data.verification_token ?? ''}}"
|
|
}
|
|
},
|
|
{
|
|
"handler": "navigate",
|
|
"params": {
|
|
"path": "{{_local.return_url ?? '/'}}",
|
|
"mergeQuery": true,
|
|
"query": {
|
|
"verification_token": "{{response.data.verification_token ?? ''}}",
|
|
"challenge_id": "{{_local.challenge_id}}"
|
|
}
|
|
}
|
|
}
|
|
],
|
|
"onError": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"error": "{{error.message ?? '$t:user.identity.challenge.error_generic'}}",
|
|
"attempts": "{{(_local.attempts ?? 0) + 1}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
],
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"text": "$t:user.identity.challenge.verify"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "text-center"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "button",
|
|
"disabled": "{{(_local.resendCooldown ?? 0) > 0}}",
|
|
"className": "text-sm text-indigo-600 dark:text-indigo-400 hover:underline disabled:text-gray-400 dark:disabled:text-gray-600 disabled:no-underline disabled:cursor-not-allowed"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "sequence",
|
|
"params": {
|
|
"actions": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"resendCooldown": 30
|
|
}
|
|
},
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/identity/challenges",
|
|
"params": {
|
|
"method": "POST",
|
|
"body": {
|
|
"purpose": "{{_local.purpose ?? 'signup'}}",
|
|
"target": "{{query.target ?? ''}}"
|
|
}
|
|
},
|
|
"onSuccess": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"challenge_id": "{{response.data.id ?? _local.challenge_id}}",
|
|
"expiresAt": "{{response.data.expires_at ?? _local.expiresAt}}",
|
|
"remainingSeconds": "{{response.data.expires_at ? Math.max(0, Math.floor((new Date(response.data.expires_at).getTime() - Date.now()) / 1000)) : (_local.remainingSeconds ?? 0)}}",
|
|
"attempts": 0,
|
|
"error": null
|
|
}
|
|
}
|
|
],
|
|
"onError": [
|
|
{
|
|
"handler": "toast",
|
|
"params": {
|
|
"type": "error",
|
|
"message": "{{error.message}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
],
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{(_local.resendCooldown ?? 0) > 0}}",
|
|
"text": "$t:user.identity.challenge.resend_cooldown|seconds={{_local.resendCooldown ?? 0}}"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{(_local.resendCooldown ?? 0) <= 0}}",
|
|
"text": "$t:user.identity.challenge.resend"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "S5 — render_hint = link (메일 링크 안내)",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{(_local.render_hint ?? '') === 'link'}}",
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "text-center mb-6"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "inline-flex items-center justify-center w-16 h-16 bg-blue-100 dark:bg-blue-900/40 rounded-full mb-4"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"props": {
|
|
"name": "fas fa-envelope",
|
|
"className": "text-3xl text-blue-600 dark:text-blue-400"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"props": {
|
|
"className": "text-2xl font-bold text-gray-900 dark:text-white mb-2"
|
|
},
|
|
"text": "$t:user.identity.challenge.link_title"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "P",
|
|
"props": {
|
|
"className": "text-sm text-gray-600 dark:text-gray-400"
|
|
},
|
|
"text": "$t:user.identity.challenge.link_subtitle"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "space-y-3"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "button",
|
|
"className": "w-full px-4 py-3 text-sm font-medium bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600 text-white rounded-lg transition-colors"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "apiCall",
|
|
"target": "/api/identity/challenges",
|
|
"params": {
|
|
"method": "POST",
|
|
"body": {
|
|
"purpose": "{{_local.purpose ?? 'password_reset'}}",
|
|
"target": "{{query.target ?? ''}}"
|
|
}
|
|
},
|
|
"onSuccess": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"challenge_id": "{{response.data.id ?? _local.challenge_id}}"
|
|
}
|
|
}
|
|
],
|
|
"onError": [
|
|
{
|
|
"handler": "toast",
|
|
"params": {
|
|
"type": "error",
|
|
"message": "{{error.message}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"text": "$t:user.identity.challenge.resend_link"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "P",
|
|
"props": {
|
|
"className": "text-xs text-gray-500 dark:text-gray-400 text-center"
|
|
},
|
|
"text": "$t:user.identity.challenge.check_spam"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "S6 — render_hint = external_redirect (외부 리다이렉트 로딩). redirect 발화는 root init_actions 의 apiCall onSuccess + apiCall 우회 경로 보강에서 처리 (init_actions 는 레이아웃 root 전용).",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{(_local.render_hint ?? '') === 'external_redirect'}}",
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "text-center py-8"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "inline-flex items-center justify-center w-16 h-16 mb-4"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"props": {
|
|
"name": "fas fa-spinner fa-spin",
|
|
"className": "text-4xl text-indigo-600 dark:text-indigo-400"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"props": {
|
|
"className": "text-xl font-bold text-gray-900 dark:text-white mb-2"
|
|
},
|
|
"text": "$t:user.identity.challenge.external_title"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "P",
|
|
"props": {
|
|
"className": "text-sm text-gray-600 dark:text-gray-400 mb-4"
|
|
},
|
|
"text": "$t:user.identity.challenge.external_subtitle"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "A",
|
|
"if": "{{_local.redirect_url}}",
|
|
"props": {
|
|
"href": "{{_local.redirect_url}}",
|
|
"className": "inline-block px-4 py-2 text-sm font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-lg"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"text": "$t:user.identity.challenge.manual_redirect"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|