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.
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.10
|
||||
APP_VERSION=7.0.11
|
||||
|
||||
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
|
||||
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.10
|
||||
APP_VERSION=7.0.11
|
||||
|
||||
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
|
||||
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
|
||||
| 대상 | 진입점 | 문서/엔드포인트 |
|
||||
|------|--------|----------------|
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 325 |
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 328 |
|
||||
|
||||
|
||||
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
|
||||
@@ -476,6 +476,24 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진
|
||||
|
||||
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
|
||||
|
||||
### 서버가 조건에 따라 다른 형태의 200 을 돌려주는 엔드포인트
|
||||
|
||||
같은 엔드포인트가 설정·상태에 따라 **다른 형태의 2xx** 를 낸다면, 프론트는 형태를 판별한 뒤에 읽어야 한다. 한 형태만 가정하면 다른 형태에서 필드 접근이 그 자리에서 던지고, 그 원문이 오류 박스에 영문으로 노출된다. 서버는 정상 응답했으므로 **서버 로그에는 흔적이 없다** — 깨진 것은 클라이언트뿐이다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 응답 타입을 한 형태로 고정 선언하고 `response.data.user.*` 를 바로 읽기 | 판별 유니온으로 두 형태를 표현 (`LoginResult` = `{status:'authenticated', user}` \| `{status:'two_factor_required', challenge}`) |
|
||||
| 저장 지점에 형태 가드 없이 `setToken(response.data.token)` | 비어 있지 않은 문자열만 저장 — `localStorage` 는 무엇을 넣든 문자열로 바꾸므로 `undefined` 가 `"undefined"`(truthy)로 남아 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 된다 |
|
||||
| 대체 형태 분기를 사용자 경로에만 두고 관리자 경로는 그대로 | 관리자 경로가 먼저 500 이 되면 설정을 되돌릴 수단까지 사라진다 — 두 경로 동시 적용 |
|
||||
| `onSuccess` 후속 액션에 조건 없이 성공 처리를 나열 | 대체 형태에서 실행되면 안 되는 액션마다 `if:"{{!response.대체형태플래그}}"` |
|
||||
| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 시점 컨텍스트는 아직 갱신 전이다 — `{{response.*}}` 만 읽는다 (`onSuccess` 결과는 `handleSequence` 의 상태 동기화 대상이 아니다) |
|
||||
| 서버가 제공하는 기능의 프론트 화면 부재를 "미사용" 으로 간주 | 토글을 켠 사이트에서만 드러나는 미구현이다 — 서버 토글 ↔ 화면 존재를 전수 대조 |
|
||||
|
||||
착수 전 전수조사 축은 **"서버가 대체 형태 2xx 를 내는 엔드포인트 ↔ 프론트 처리 여부"** 다. 그리고 **"서버 토글 ON 시 프론트 화면 존재 여부"** 를 함께 본다 — 2단계 인증은 도입 후 여러 버전 동안 입력 화면이 없었고, 그 토글을 켠 사이트에서만 전원 로그인 불가로 나타났다(공개 #133).
|
||||
|
||||
> 상세: [auth-system.md "2단계 인증 로그인"](docs/frontend/auth-system.md)
|
||||
> 정적 검사로는 잡히지 않는다 — 응답 변종은 서버 분기의 의미 판정이므로 코드 리뷰에서 확인한다.
|
||||
|
||||
### 제3자 라이브러리는 쓰기 경로를 지정받는다
|
||||
|
||||
제3자 라이브러리는 캐시·임시파일 경로를 설정하지 않으면 **자기 설치 폴더**(vendor 안)나 시스템 temp 에 쓴다. 표준 Laravel 배포는 웹서버에 `storage/` 와 `bootstrap/cache` 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel `HandleExceptions` 가 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 **매 요청이 같은 실패를 반복**한다 — 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다 (공개 #125).
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.11] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 관리자 로그인 화면도 같은 흐름으로 동작합니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- 인증번호를 보내지 못해 로그인을 마칠 수 없을 때의 응답이 「인증에 실패했습니다」(401)에서 「인증번호를 보내지 못했습니다」(503)로 바뀌었습니다. 자격 증명은 올바른데도 로그인 실패로 안내되어 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없었습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 2단계 인증을 켠 사이트에서 로그인이 되지 않던 문제를 수정했습니다. 로그인 화면에 영문 오류(`Cannot read properties of undefined`)가 표시되고 그 뒤로는 진행할 방법이 없었으며, 관리자 로그인은 서버 오류(500)가 되어 설정을 되돌릴 수단까지 사라졌습니다. 이제 인증번호 입력 단계가 표시되고 관리자도 같은 흐름으로 로그인할 수 있습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
- 로그인 응답을 잘못 해석해 사용할 수 없는 인증 정보가 브라우저에 저장되던 문제를 수정했습니다. 그 뒤로는 화면을 새로 열 때마다 「세션이 만료되었습니다」 안내와 함께 로그인 화면으로 되돌아갔습니다.
|
||||
- 본인인증 화면에서 로그인용 인증 요청을 처리할 수 있어, 그 요청으로는 다시 로그인할 수 없게 되던 문제를 수정했습니다. 로그인용 인증 요청은 이제 로그인 화면에서만 처리됩니다.
|
||||
- 로그인 시도가 많아 잠시 차단될 때 영문 안내(`Too Many Attempts.`)가 그대로 표시되던 문제를 수정했습니다. 이제 다시 시도할 수 있는 시각과 함께 사이트 언어로 안내합니다.
|
||||
- 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다.
|
||||
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
|
||||
- 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다.
|
||||
|
||||
## [7.0.10] - 2026-09-06
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -289,7 +289,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.10 g7
|
||||
# (필요 시) mv g7-7.0.11 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
@@ -521,6 +521,7 @@ cp .env.example .env
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
The next generation of Gnuboard — Korea's most widely used open-source CMS
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
@@ -535,6 +535,7 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* Playwright E2E 용 2단계 인증 픽스처 커맨드.
|
||||
*
|
||||
* 두 가지 일을 한다.
|
||||
* - `--ensure-user` : 알려진 비밀번호를 가진 Active 사용자를 만들거나 갱신한다.
|
||||
* (`--admin` 이면 admin 역할을 함께 부여)
|
||||
* - `--plant` : 지정한 challenge 에 알려진 인증 코드의 해시를 심는다.
|
||||
*
|
||||
* 인증번호는 해시로만 저장되므로 브라우저 테스트가 되읽을 수 없다. 메일을 실제로 열어
|
||||
* 보는 대신 알려진 값을 심어 "코드가 맞을 때" 를 재현한다 — 검증 대상은 코드 생성이
|
||||
* 아니라 로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
|
||||
* 심는 방식은 PHPUnit `TwoFactorAuthTest::issuedCode()` 와 동일하다.
|
||||
*
|
||||
* 보안 가드 (3중, `playwright:issue-token` 과 동형):
|
||||
* ① CLI 한정 — `php_sapi_name() === 'cli'`. production 웹 요청에서 도달 불가
|
||||
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 필수
|
||||
* ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작이 가능하도록
|
||||
*
|
||||
* 호출 예시 (PowerShell):
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --ensure-user=user --password='Passw0rd!2fa'
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --plant=<challenge_id> --code=135790
|
||||
*/
|
||||
class PlaywrightSeedTwoFactor extends Command
|
||||
{
|
||||
protected $signature = 'playwright:seed-two-factor
|
||||
{--ensure-user= : 이 접미사로 Active 테스트 사용자를 생성/갱신하고 이메일을 출력한다}
|
||||
{--password= : --ensure-user 가 설정할 비밀번호 (기본값 Passw0rd!2fa)}
|
||||
{--admin : --ensure-user 계정에 admin 역할을 부여한다}
|
||||
{--plant= : 이 challenge 에 알려진 인증 코드의 해시를 심는다 (challenge UUID)}
|
||||
{--code=135790 : --plant 가 심을 인증 코드}
|
||||
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 2FA 테스트 계정을 정리. 0 이면 정리 안 함}
|
||||
{--purge-users : 나이와 무관하게 playwright 2FA 테스트 계정을 전부 제거 (실측 종료 직후 호출)}';
|
||||
|
||||
protected $description = 'Playwright E2E 용 2단계 인증 픽스처 (테스트 계정 준비 / 인증 코드 심기)';
|
||||
|
||||
/** 테스트 전용 계정 이메일 접두사 */
|
||||
private const TEST_EMAIL_PREFIX = 'playwright_2fa_';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단.
|
||||
// 여기의 `env()` 는 `.env` 유래 값이 아니라 호출자가 그 자리에서 넘기는 프로세스
|
||||
// 환경변수이므로 config:cache 의 영향을 받지 않는다.
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작 허용
|
||||
Config::set('app.debug', true);
|
||||
|
||||
$gcHours = (int) $this->option('gc-hours');
|
||||
if ($gcHours > 0) {
|
||||
$this->pruneStaleTestUsers($gcHours);
|
||||
}
|
||||
|
||||
$did = false;
|
||||
|
||||
// 알려진 비밀번호를 가진 계정(관리자 포함)을 실측 뒤에 남기지 않는다 —
|
||||
// 나이 기준 정리(gc-hours)는 그 사이의 창을 닫지 못한다.
|
||||
if ($this->option('purge-users')) {
|
||||
$this->purgeTestUsers();
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if ($suffix = $this->option('ensure-user')) {
|
||||
$this->ensureUser((string) $suffix);
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if ($challengeId = $this->option('plant')) {
|
||||
if (! $this->plantCode((string) $challengeId, (string) $this->option('code'))) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if (! $did) {
|
||||
$this->error('--ensure-user · --plant · --purge-users 중 하나는 지정해야 합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알려진 비밀번호를 가진 Active 테스트 사용자를 만들거나 갱신하고 이메일을 출력합니다.
|
||||
*
|
||||
* @param string $suffix 계정 구분 접미사 (예: user / nonadmin)
|
||||
*/
|
||||
private function ensureUser(string $suffix): void
|
||||
{
|
||||
$email = self::TEST_EMAIL_PREFIX.$suffix.'@example.test';
|
||||
$password = (string) ($this->option('password') ?: 'Passw0rd!2fa');
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if ($user === null) {
|
||||
$user = User::factory()->create([
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
'status' => UserStatus::Active->value,
|
||||
]);
|
||||
} else {
|
||||
// 이전 실행이 남긴 계정을 재사용한다 — 매 실행마다 계정이 늘면 회원 목록이
|
||||
// 테스트 잔재로 뒤덮인다. 잠금·실패 카운트도 함께 초기화해 앞선 spec 의
|
||||
// 잠금 상태가 다음 실행으로 새지 않게 한다.
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'status' => UserStatus::Active->value,
|
||||
'locked_until' => null,
|
||||
'failed_login_attempts' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($this->option('admin')) {
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => ['ko' => '관리자', 'en' => 'Admin'],
|
||||
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $user->roles()->where('roles.id', $adminRole->id)->exists()) {
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
}
|
||||
} else {
|
||||
// 관리자 거절 경로를 재현하려면 관리자 역할이 없어야 한다 — 재사용 계정에
|
||||
// 앞선 실행의 admin 역할이 남아 있으면 403 을 측정할 수 없다.
|
||||
$user->roles()->detach();
|
||||
}
|
||||
|
||||
$this->line($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* challenge 에 알려진 인증 코드의 해시를 심습니다.
|
||||
*
|
||||
* @param string $challengeId challenge UUID
|
||||
* @param string $code 심을 인증 코드
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
private function plantCode(string $challengeId, string $code): bool
|
||||
{
|
||||
$log = IdentityVerificationLog::find($challengeId);
|
||||
|
||||
if ($log === null) {
|
||||
$this->error("challenge 를 찾을 수 없습니다: {$challengeId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $log->metadata ?? [];
|
||||
$metadata['code_hash'] = Hash::make($code);
|
||||
|
||||
$log->metadata = $metadata;
|
||||
$log->save();
|
||||
|
||||
$this->line($code);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* playwright 2FA 테스트 계정을 나이와 무관하게 전부 제거합니다.
|
||||
*
|
||||
* 이 계정들은 알려진 비밀번호를 갖고, `--admin` 으로 만든 것은 관리자 역할까지 갖는다.
|
||||
* 실측이 끝난 뒤에도 남아 있으면 그 자체가 열린 문이므로 즉시 지운다.
|
||||
*/
|
||||
private function purgeTestUsers(): void
|
||||
{
|
||||
$removed = 0;
|
||||
|
||||
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
|
||||
->chunkById(100, function ($chunk) use (&$removed) {
|
||||
foreach ($chunk as $user) {
|
||||
$user->tokens()->delete();
|
||||
$user->roles()->detach();
|
||||
$user->delete();
|
||||
$removed++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("[purge] playwright 2FA 테스트 계정 제거: {$removed}건");
|
||||
}
|
||||
|
||||
/**
|
||||
* 임계 시간보다 오래된 playwright 2FA 테스트 계정을 정리합니다.
|
||||
*
|
||||
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반
|
||||
* chunk()/each() 는 줄어든 결과 집합만큼 커서가 밀려 일부를 건너뛴다.
|
||||
*
|
||||
* @param int $hours 이 시간보다 오래된 계정만 정리
|
||||
*/
|
||||
private function pruneStaleTestUsers(int $hours): void
|
||||
{
|
||||
$threshold = now()->subHours($hours);
|
||||
$removed = 0;
|
||||
|
||||
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
|
||||
->where('created_at', '<', $threshold)
|
||||
->chunkById(100, function ($chunk) use (&$removed) {
|
||||
foreach ($chunk as $user) {
|
||||
$user->tokens()->delete();
|
||||
$user->roles()->detach();
|
||||
$user->delete();
|
||||
$removed++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($removed > 0) {
|
||||
$this->info("[gc] playwright 2FA 테스트 계정 정리: {$removed}건");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Auth;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드 발송 실패 예외 — 비밀번호 확인은 통과했으나 인증번호를 보낼 수 없어
|
||||
* 로그인을 완료할 수단이 없을 때 발생합니다.
|
||||
*
|
||||
* HTTP 503 Service Unavailable 로 매핑됩니다. 자격 증명은 올바르므로 401 로 뭉뚱그리면
|
||||
* 사용자는 비밀번호를 의심하며 같은 실패를 반복하게 되고, 운영자는 메일 설정이 깨진 사실을
|
||||
* 알 방법이 없습니다.
|
||||
*
|
||||
* 메시지 자리에는 번역문이 아니라 다국어 **키**를 보관합니다 (선례: AccountLockedException).
|
||||
*
|
||||
* @since 7.0.11
|
||||
*/
|
||||
class TwoFactorDeliveryFailedException extends HttpException
|
||||
{
|
||||
public function __construct(?string $message = null)
|
||||
{
|
||||
parent::__construct(503, $message ?? 'auth.two_factor_delivery_failed');
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Exceptions\Auth\AccountLockedException;
|
||||
use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
|
||||
use App\Http\Requests\Auth\AuthenticatedRequest;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
|
||||
use App\Http\Requests\Auth\TwoFactorResendRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Services\AuthService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -13,10 +17,21 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends AdminBaseController
|
||||
{
|
||||
use BuildsAuthFailureResponses;
|
||||
|
||||
public function __construct(
|
||||
private AuthService $authService
|
||||
) {
|
||||
parent::__construct();
|
||||
// 부모 생성자 호출하지 않음 - 인증 미들웨어를 수동으로 설정
|
||||
// parent::__construct();
|
||||
|
||||
// 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가
|
||||
// 식별하며, 관리자 여부는 코드 확인에 성공한 뒤 서비스가 돌려준 사용자로 판정한다.
|
||||
$this->middleware(['auth:sanctum', 'admin'])->except([
|
||||
'login',
|
||||
'verifyTwoFactor',
|
||||
'resendTwoFactor',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,10 +48,21 @@ class AuthController extends AdminBaseController
|
||||
$request->validated()['password']
|
||||
);
|
||||
|
||||
// 2단계 인증이 켜져 있으면 아직 토큰도 사용자도 없다 — 관리자 판정은 코드 확인
|
||||
// 뒤로 미룬다. 이 분기가 없으면 $data['user'] 가 없어 500 이 되고, 관리자까지
|
||||
// 로그인할 수 없어 설정을 되돌릴 수단이 사라진다.
|
||||
if ($data['two_factor_required'] ?? false) {
|
||||
return $this->success('auth.two_factor_required', $data);
|
||||
}
|
||||
|
||||
$user = $data['user'];
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (! $user->isAdmin()) {
|
||||
// 이 시점에는 이미 토큰과 web 세션이 발급되어 있다 — 거절하면서 남겨 두면
|
||||
// 관리자가 아닌 사용자가 응답만 403 을 받을 뿐 세션은 그대로 유효해진다.
|
||||
$this->authService->revokeIssuedSession($user, $data['token']);
|
||||
|
||||
return $this->forbidden('auth.admin_required');
|
||||
}
|
||||
|
||||
@@ -45,22 +71,92 @@ class AuthController extends AdminBaseController
|
||||
|
||||
return $this->success('auth.admin_login_success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
);
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.login_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자의 2단계 인증 코드를 확인하고 로그인을 완료합니다.
|
||||
*
|
||||
* 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
|
||||
* 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
|
||||
*
|
||||
* @param TwoFactorChallengeRequest $request challenge 확인 요청
|
||||
* @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답
|
||||
*/
|
||||
public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
$data = $this->authService->completeTwoFactor(
|
||||
$validated['challenge_id'],
|
||||
['code' => $validated['code']]
|
||||
);
|
||||
|
||||
$user = $data['user'];
|
||||
|
||||
if (! $user->isAdmin()) {
|
||||
// completeTwoFactor() 는 코드 확인에 성공한 시점에 토큰을 발급한다.
|
||||
// 관리자 판정으로 거절하면서 그 발급분을 회수하지 않으면 관리자가 아닌
|
||||
// 사용자가 유효한 세션을 손에 쥔 채 응답만 403 을 받는다.
|
||||
$this->authService->revokeIssuedSession($user, $data['token']);
|
||||
|
||||
return $this->forbidden('auth.admin_required');
|
||||
}
|
||||
|
||||
$data['user'] = new UserResource($user);
|
||||
|
||||
return $this->success('auth.admin_login_success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다.
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.two_factor_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자의 2단계 인증 코드를 재발송합니다.
|
||||
*
|
||||
* 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
|
||||
* 더 이상 통하지 않습니다.
|
||||
*
|
||||
* @param TwoFactorResendRequest $request challenge 재발송 요청
|
||||
* @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
|
||||
*/
|
||||
public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$resolvedUser = null;
|
||||
|
||||
$data = $this->authService->resendTwoFactorChallenge(
|
||||
$request->validated()['challenge_id'],
|
||||
$resolvedUser
|
||||
);
|
||||
|
||||
// 이 단계는 토큰을 발급하지 않으므로 회수할 것이 없다 — 다만 완료할 수 없는
|
||||
// 상대에게 새 인증번호를 계속 보내지는 않는다.
|
||||
if ($resolvedUser === null || ! $resolvedUser->isAdmin()) {
|
||||
return $this->forbidden('auth.admin_required');
|
||||
}
|
||||
|
||||
return $this->success('auth.two_factor_required', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자를 로그아웃시킵니다.
|
||||
*
|
||||
@@ -113,16 +209,7 @@ class AuthController extends AdminBaseController
|
||||
} catch (AccountLockedException $e) {
|
||||
// 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다.
|
||||
// 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
);
|
||||
return $this->lockedResponse($e);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.unauthenticated');
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
namespace App\Http\Controllers\Api\Auth;
|
||||
|
||||
use App\Exceptions\Auth\AccountLockedException;
|
||||
use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
|
||||
use App\Http\Controllers\Api\Base\AuthBaseController;
|
||||
use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
|
||||
use App\Http\Requests\Auth\AuthenticatedRequest;
|
||||
use App\Http\Requests\Auth\ForgotPasswordRequest;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\ResetPasswordRequest;
|
||||
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
|
||||
use App\Http\Requests\Auth\TwoFactorResendRequest;
|
||||
use App\Http\Requests\Auth\ValidateResetTokenRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Services\AuthService;
|
||||
@@ -18,6 +21,8 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends AuthBaseController
|
||||
{
|
||||
use BuildsAuthFailureResponses;
|
||||
|
||||
public function __construct(
|
||||
private AuthService $authService
|
||||
) {
|
||||
@@ -27,8 +32,9 @@ class AuthController extends AuthBaseController
|
||||
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
|
||||
$this->middleware('auth:sanctum')->except([
|
||||
'login',
|
||||
// 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
|
||||
// 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
|
||||
'verifyTwoFactor',
|
||||
'resendTwoFactor',
|
||||
'register',
|
||||
'forgotPassword',
|
||||
'resetPassword',
|
||||
@@ -61,6 +67,8 @@ class AuthController extends AuthBaseController
|
||||
return $this->success('auth.login_success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.login_failed');
|
||||
}
|
||||
@@ -92,32 +100,37 @@ class AuthController extends AuthBaseController
|
||||
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다 — 화면은 두 경로를
|
||||
// 구분하지 않으므로 한쪽만 다른 모양이면 잠금 안내가 깨진다.
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.two_factor_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 잠금 응답(423)을 구성합니다.
|
||||
* 2단계 인증 코드를 재발송합니다.
|
||||
*
|
||||
* 세션을 발급하는 모든 엔드포인트가 같은 페이로드를 돌려주도록 단일 지점에서 만든다.
|
||||
* 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
|
||||
* 더 이상 통하지 않습니다.
|
||||
*
|
||||
* @param AccountLockedException $e 잠금 예외
|
||||
* @return JsonResponse 423 응답
|
||||
* @param TwoFactorResendRequest $request challenge 재발송 요청
|
||||
* @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
|
||||
*/
|
||||
private function lockedResponse(AccountLockedException $e): JsonResponse
|
||||
public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
|
||||
{
|
||||
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
try {
|
||||
$data = $this->authService->resendTwoFactorChallenge(
|
||||
$request->validated()['challenge_id']
|
||||
);
|
||||
|
||||
return $this->success('auth.two_factor_required', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Identity;
|
||||
|
||||
use App\Enums\IdentityOriginType;
|
||||
use App\Enums\IdentityVerificationPurpose;
|
||||
use App\Extension\IdentityVerification\IdentityVerificationManager;
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use App\Http\Requests\Identity\CancelChallengeRequest;
|
||||
@@ -96,6 +97,15 @@ class IdentityVerificationController extends PublicBaseController
|
||||
*/
|
||||
public function verify(VerifyChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
|
||||
{
|
||||
// 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
|
||||
// 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
|
||||
// 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
|
||||
if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
|
||||
return $this->error('identity.errors.purpose_not_allowed', 403, [
|
||||
'failure_code' => 'PURPOSE_NOT_ALLOWED',
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->service->verify(
|
||||
challengeId: $challenge->id,
|
||||
input: $request->validated(),
|
||||
@@ -142,6 +152,15 @@ class IdentityVerificationController extends PublicBaseController
|
||||
*/
|
||||
public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
|
||||
{
|
||||
// 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
|
||||
// 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
|
||||
// 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
|
||||
if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
|
||||
return $this->error('identity.errors.purpose_not_allowed', 403, [
|
||||
'failure_code' => 'PURPOSE_NOT_ALLOWED',
|
||||
]);
|
||||
}
|
||||
|
||||
$ok = $this->service->cancel($challenge->id);
|
||||
|
||||
if (! $ok) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Exceptions\Auth\AccountLockedException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 세션을 발급하는 엔드포인트가 공유하는 실패 응답을 구성하는 트레이트.
|
||||
*
|
||||
* 사용자 로그인과 관리자 로그인은 서로 다른 베이스 컨트롤러를 상속하지만, 계정 잠금과
|
||||
* 인증번호 발송 실패는 **같은 사건**이므로 같은 형태로 응답해야 합니다. 두 컨트롤러가
|
||||
* 각자 사본을 들고 있으면 한쪽 페이로드에 필드가 추가될 때 다른 쪽이 조용히 뒤처져,
|
||||
* 같은 실패인데 화면이 다르게 안내하게 됩니다.
|
||||
*
|
||||
* @since 7.0.11
|
||||
*/
|
||||
trait BuildsAuthFailureResponses
|
||||
{
|
||||
/**
|
||||
* 계정 잠금 응답(423)을 구성합니다.
|
||||
*
|
||||
* @param AccountLockedException $e 잠금 예외
|
||||
* @return JsonResponse 423 응답
|
||||
*/
|
||||
protected function lockedResponse(AccountLockedException $e): JsonResponse
|
||||
{
|
||||
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증번호 발송 실패 응답(503)을 구성합니다.
|
||||
*
|
||||
* 자격 증명은 올바르므로 401 로 답하지 않는다 — 사용자는 비밀번호를 의심하며 같은
|
||||
* 실패를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
|
||||
*
|
||||
* @return JsonResponse 503 응답
|
||||
*/
|
||||
protected function deliveryFailedResponse(): JsonResponse
|
||||
{
|
||||
return $this->error('auth.two_factor_delivery_failed', 503);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드 재발송 요청
|
||||
*
|
||||
* 비밀번호 확인 단계가 돌려준 challenge 로만 재발송할 수 있습니다. 이 요청은 아직 로그인 전
|
||||
* 상태에서 호출되므로 인증 미들웨어를 거치지 않으며, 주체 식별은 challenge 에 기록된
|
||||
* 사용자로만 이루어집니다.
|
||||
*/
|
||||
class TwoFactorResendRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
|
||||
*
|
||||
* @return bool 항상 true (주체 식별은 challenge 가 담당)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 검증 규칙
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'challenge_id' => ['required', 'string', 'uuid'],
|
||||
];
|
||||
|
||||
// 확장이 자체 2단계 수단을 붙일 때 재발송 입력을 추가할 수 있도록 개방한다
|
||||
return HookManager::applyFilters('core.auth.two_factor_resend_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 실패 메시지를 반환합니다.
|
||||
*
|
||||
* @return array<string, string> 검증 메시지
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'challenge_id.required' => __('validation.auth.two_factor.challenge_required'),
|
||||
'challenge_id.uuid' => __('validation.auth.two_factor.challenge_invalid'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\View\Composers\TemplateComposer;
|
||||
use App\Http\View\Composers\UserTemplateComposer;
|
||||
use App\Notifications\NotificationChannelManager;
|
||||
@@ -126,7 +127,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
$maxPerMinute = 60;
|
||||
}
|
||||
|
||||
return Limit::perMinute($maxPerMinute)->by($request->ip());
|
||||
// 기본 응답은 영문 "Too Many Attempts." 이다 — 로그인 화면은 이 문구를
|
||||
// 그대로 노출하므로 다국어 키로 갈아끼운다.
|
||||
return Limit::perMinute($maxPerMinute)
|
||||
->by($request->ip())
|
||||
->response(function (Request $request, array $headers) {
|
||||
return ResponseHelper::error(
|
||||
'auth.too_many_attempts',
|
||||
429,
|
||||
null,
|
||||
['seconds' => (int) ($headers['Retry-After'] ?? 60)]
|
||||
)->withHeaders($headers);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
|
||||
use App\Contracts\Repositories\PasswordResetTokenRepositoryInterface;
|
||||
use App\Contracts\Repositories\RoleRepositoryInterface;
|
||||
use App\Contracts\Repositories\UserConsentRepositoryInterface;
|
||||
@@ -11,6 +12,7 @@ use App\Enums\IdentityVerificationPurpose;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Exceptions\Auth\AccountLockedException;
|
||||
use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
@@ -30,6 +32,7 @@ class AuthService
|
||||
private UserConsentRepositoryInterface $userConsentRepository,
|
||||
private PasswordResetTokenRepositoryInterface $passwordResetTokenRepository,
|
||||
private IdentityPolicyService $policyService,
|
||||
private IdentityVerificationLogRepositoryInterface $identityLogRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -228,9 +231,9 @@ class AuthService
|
||||
'provider_id' => $challenge->providerId,
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('auth.two_factor_delivery_failed')],
|
||||
]);
|
||||
// 자격 증명은 올바르다 — 401 로 뭉개면 사용자는 비밀번호를 의심하며 같은 실패를
|
||||
// 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
|
||||
throw new TwoFactorDeliveryFailedException;
|
||||
}
|
||||
|
||||
HookManager::doAction('core.auth.two_factor_requested', $user, [
|
||||
@@ -298,6 +301,83 @@ class AuthService
|
||||
return $this->issueLoginSession($user, (string) $user->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드를 재발송합니다.
|
||||
*
|
||||
* 아직 사용되지 않은 challenge 만 재발송 대상입니다. 기존 challenge 를 취소하고 새로
|
||||
* 발행하므로, 재발송 이후에는 앞서 받은 인증번호가 통하지 않습니다 — 재발송을 남겨 두면
|
||||
* 유효한 코드가 여러 개 동시에 살아 있어 대입 시도의 표적이 넓어집니다.
|
||||
*
|
||||
* 해석된 사용자는 응답 페이로드에 실리지 않고 out 파라미터로만 올린다 — 관리자 경로가
|
||||
* 그 사용자로 등급을 판정해야 하지만, 사용자 경로가 그대로 내보내면 모델이 응답에 새는다.
|
||||
*
|
||||
* @param string $challengeId 비밀번호 확인 단계가 돌려준 challenge UUID
|
||||
* @param User|null $resolvedUser (out) challenge 가 식별한 사용자
|
||||
* @return array{two_factor_required: bool, challenge_id: string, provider_id: string, expires_at: mixed} 새 challenge 정보
|
||||
*
|
||||
* @throws ValidationException challenge 가 재발송 대상이 아닐 때
|
||||
* @throws AccountLockedException 계정이 잠겨 있을 때
|
||||
* @throws TwoFactorDeliveryFailedException 인증번호를 보내지 못했을 때
|
||||
*/
|
||||
public function resendTwoFactorChallenge(string $challengeId, ?User &$resolvedUser = null): array
|
||||
{
|
||||
$log = $this->identityLogRepository->findById($challengeId);
|
||||
|
||||
// 존재하지 않음 / 다른 용도 / 이미 검증·취소·실패·만료 — 전부 같은 응답으로 답한다.
|
||||
// 사유를 구분해 주면 challenge id 를 넣어 보며 상태를 캐낼 수 있다.
|
||||
$resendable = [
|
||||
IdentityVerificationStatus::Requested->value,
|
||||
IdentityVerificationStatus::Sent->value,
|
||||
];
|
||||
|
||||
if ($log === null
|
||||
|| $log->purpose !== IdentityVerificationPurpose::Login->value
|
||||
|| ! in_array($log->status->value, $resendable, true)
|
||||
|| ($log->expires_at !== null && $log->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'challenge_id' => [__('auth.two_factor_invalid_challenge')],
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $log->user_id === null ? null : $this->userRepository->findById((int) $log->user_id);
|
||||
|
||||
if (! $user || $user->status !== UserStatus::Active->value) {
|
||||
throw ValidationException::withMessages([
|
||||
'challenge_id' => [__('auth.two_factor_invalid_challenge')],
|
||||
]);
|
||||
}
|
||||
|
||||
// challenge 발급 이후에 잠겼을 수 있다 — 재발송도 잠긴 계정에는 코드를 보내지 않는다.
|
||||
$this->assertNotLocked($user);
|
||||
|
||||
$resolvedUser = $user;
|
||||
|
||||
app(IdentityVerificationService::class)->cancel($log->id);
|
||||
|
||||
return $this->startTwoFactorChallenge($user, (string) $user->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 발급된 세션(토큰 + web 세션)을 되돌립니다.
|
||||
*
|
||||
* 2단계 인증 완료는 코드 확인 시점에 토큰을 발급하므로, 그 뒤에 권한 검사로 거절하는
|
||||
* 경로는 발급분을 반드시 회수해야 합니다. `logout()` 은 `currentAccessToken()` 에
|
||||
* 의존해 이 시점(요청 자체는 미인증)에는 아무 일도 하지 않습니다.
|
||||
*
|
||||
* @param User $user 대상 사용자
|
||||
* @param string $plainTextToken 방금 발급한 평문 토큰 ({id}|{token} 형식)
|
||||
*/
|
||||
public function revokeIssuedSession(User $user, string $plainTextToken): void
|
||||
{
|
||||
PersonalAccessToken::findToken($plainTextToken)?->delete();
|
||||
|
||||
// completeTwoFactor() 의 Auth::login() 이 연 세션도 함께 닫는다.
|
||||
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
|
||||
Auth::guard('web')->logout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰을 발급하고 로그인 완료 훅을 실행합니다.
|
||||
*
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.10'),
|
||||
'version' => env('APP_VERSION', '7.0.11'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -219,14 +219,14 @@ CSS 가 아닌 자산은 바이트 그대로 서빙됩니다. 정적 게시본(`
|
||||
## 코어 API 레퍼런스
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 36 · **엔드포인트 수**: 325
|
||||
- **문서 수**: 36 · **엔드포인트 수**: 328
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
| [activity-logs.md](activity-logs.md) | `activity-logs` | 3 |
|
||||
| [attachment.md](attachment.md) | `attachment` | 1 |
|
||||
| [attachments.md](attachments.md) | `attachments` | 4 |
|
||||
| [auth.md](auth.md) | `auth` | 15 |
|
||||
| [auth.md](auth.md) | `auth` | 18 |
|
||||
| [avatar.md](avatar.md) | `avatar` | 2 |
|
||||
| [broadcasting.md](broadcasting.md) | `broadcasting` | 1 |
|
||||
| [changelog.md](changelog.md) | `changelog` | 1 |
|
||||
|
||||
+244
-2
@@ -339,6 +339,8 @@ HTTP/1.1 200
|
||||
| 403 | Forbidden | 자격 증명은 맞지만 관리자 역할이 아닌 경우 (`auth.admin_required`) |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우 (`auth.account_locked` — `error.locked_until`, `error.retry_after_seconds` 포함) |
|
||||
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
|
||||
| 503 | Service Unavailable | 2단계 인증이 켜져 있고 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -346,6 +348,169 @@ HTTP/1.1 200
|
||||
|
||||
관리자 로그인. `email`/`password` 검증 후 `AuthService::login()` 이 인증하고, 인증 사용자가 `isAdmin()` 이 아니면 `403 auth.admin_required` 로 거부한다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user`(UserResource) 를 반환한다. 계정 잠금 시 `AccountLockedException`, 자격 불일치 시 `422` 검증 오류를 반환한다. 이후 모든 관리자 API 호출은 이 토큰을 `Authorization: Bearer` 헤더로 실어야 한다.
|
||||
|
||||
**403 거부는 이미 발급된 세션을 회수한다.** `AuthService::login()` 은 관리자 판정보다 먼저 토큰과 web 세션을 발급하므로, 거부하면서 그대로 두면 관리자가 아닌 사용자가 응답만 `403` 을 받을 뿐 유효한 세션을 손에 쥔다. 거부 경로는 `AuthService::revokeIssuedSession()` 으로 그 발급분을 되돌린다.
|
||||
|
||||
**2단계 인증이 켜져 있는 경우**: 사용자 로그인과 동일하게 `200` + `message: auth.two_factor_required` 와 `two_factor_required` / `challenge_id` / `provider_id` / `expires_at` 을 반환한다(필드 정의는 `POST /api/auth/login` 의 같은 절 참조). 이 단계에서는 아직 사용자도 토큰도 없으므로 **관리자 판정을 하지 않는다** — 판정은 `POST /api/auth/admin/login/two-factor` 가 코드 확인에 성공한 뒤에 수행한다.
|
||||
|
||||
|
||||
### POST /api/auth/admin/login/two-factor
|
||||
<!-- @generated:start:api.auth.admin.login.two-factor -->
|
||||
- **라우트명**: `api.auth.admin.login.two-factor`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@verifyTwoFactor`
|
||||
- **인증/권한**: 공개 (인증 불필요 — 주체는 challenge 가 식별한다)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| challenge_id | body | string | 예 | uuid | 관리자 로그인 응답이 돌려준 challenge 식별자 |
|
||||
| code | body | string | 예 | min 4, max 16 | 사용자가 받은 인증 코드 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.two_factor_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/auth/admin/login/two-factor HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40",
|
||||
"code": "135790"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `POST /api/auth/admin/login` 의 성공 페이로드와 동일하다._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| user | object | `{"uuid":"a234c2b1-…","is_admin":true, …}` | 로그인한 관리자 정보 (`UserResource`) |
|
||||
| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 발급된 Sanctum 접근 토큰 평문 |
|
||||
| token_type | string | `Bearer` | 토큰 타입 (항상 `Bearer`) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "관리자 로그인이 성공했습니다.",
|
||||
"data": {
|
||||
"user": {
|
||||
"uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
|
||||
"name": "API 문서 샘플 사용자",
|
||||
"email": "apidoc-sample-user@example.com",
|
||||
"status": "active",
|
||||
"is_admin": true,
|
||||
"is_owner": true
|
||||
},
|
||||
"token": "{MASKED}",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `user` 객체는 지면 절약을 위해 축약했습니다. 실제로는 `UserResource` 필드 전수가 내려옵니다.
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthorized | 코드가 틀렸거나(`auth.two_factor_failed`), challenge 의 `purpose` 가 `login` 이 아니거나, 확인된 사용자가 없거나 `active` 상태가 아닌 경우 |
|
||||
| 403 | Forbidden | 코드 확인은 통과했으나 관리자 역할이 아닌 경우 (`auth.admin_required`) |
|
||||
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
|
||||
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일 |
|
||||
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
관리자 로그인의 인증번호 확인 단계. 사용자 경로(`POST /api/auth/login/two-factor`)와 같은 규칙으로 코드를 확인하고, **확인에 성공한 뒤에** 관리자 여부를 판정한다.
|
||||
|
||||
`AuthService::completeTwoFactor()` 는 코드 확인에 성공한 시점에 토큰을 발급하므로, 관리자 판정으로 `403` 을 돌려줄 때는 반드시 그 발급분을 회수한다(`revokeIssuedSession()`). 회수하지 않으면 관리자가 아닌 사용자가 응답만 `403` 을 받을 뿐 유효한 세션을 손에 쥔다.
|
||||
|
||||
|
||||
### POST /api/auth/admin/login/two-factor/resend
|
||||
<!-- @generated:start:api.auth.admin.login.two-factor.resend -->
|
||||
- **라우트명**: `api.auth.admin.login.two-factor.resend`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@resendTwoFactor`
|
||||
- **인증/권한**: 공개 (인증 불필요 — 주체는 challenge 가 식별한다)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| challenge_id | body | string | 예 | uuid | 관리자 로그인 응답이 돌려준 challenge 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/auth/admin/login/two-factor/resend HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_`POST /api/auth/login/two-factor/resend` 와 동일한 형태다._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| two_factor_required | boolean | `true` | 항상 `true` |
|
||||
| challenge_id | string(uuid) | `9f1c2f2e-0b3a-…` | **새** challenge 식별자 (이전 값은 취소됨) |
|
||||
| provider_id | string | `g7:core.mail` | 코드를 발송한 본인인증 프로바이더 |
|
||||
| expires_at | string(ISO8601)\|null | `2026-09-07T14:03:00+09:00` | 새 challenge 만료 시각 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
|
||||
"data": {
|
||||
"two_factor_required": true,
|
||||
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40",
|
||||
"provider_id": "g7:core.mail",
|
||||
"expires_at": "2026-09-07T14:03:00+09:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | challenge 가 식별한 사용자가 관리자 역할이 아닌 경우 (`auth.admin_required`) |
|
||||
| 422 | Unprocessable Entity | 재발송 대상이 아닌 challenge (`auth.two_factor_invalid_challenge`) — 사유는 구분하지 않는다 |
|
||||
| 423 | Locked | challenge 를 받은 뒤 계정이 잠긴 경우 |
|
||||
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
|
||||
| 503 | Service Unavailable | 새 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
관리자 로그인의 인증번호 재발송. 규칙은 `POST /api/auth/login/two-factor/resend` 와 동일하다 — 기존 challenge 를 취소하고 새로 발행하므로 앞서 받은 인증번호는 통하지 않는다.
|
||||
|
||||
이 단계는 토큰을 발급하지 않으므로 회수할 것이 없다. 다만 완료할 수 없는 상대에게 새 인증번호를 계속 보내지는 않으므로, challenge 가 식별한 사용자가 관리자가 아니면 `403 auth.admin_required` 로 거부한다. 발급 자체를 막는 것이 아니라 **재발송만** 막는 것이며, 비밀번호 확인 단계(`POST /api/auth/admin/login`)는 종전대로 관리자 판정 없이 challenge 를 돌려준다.
|
||||
|
||||
|
||||
### POST /api/auth/forgot-password
|
||||
<!-- @generated:start:api.auth.forgot-password -->
|
||||
@@ -488,6 +653,8 @@ HTTP/1.1 200
|
||||
| 401 | Unauthenticated | 이메일/비밀번호가 일치하지 않거나 계정 상태가 활성이 아닌 경우 (`auth.login_failed`) |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우 (`auth.account_locked` — `error.locked_until`, `error.retry_after_seconds` 포함) |
|
||||
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts` — `Retry-After` 헤더 동반) |
|
||||
| 503 | Service Unavailable | 2단계 인증이 켜져 있고 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -504,7 +671,9 @@ HTTP/1.1 200
|
||||
| `provider_id` | string | 코드를 발송한 본인인증 프로바이더 |
|
||||
| `expires_at` | string(ISO8601)\|null | challenge 만료 시각 |
|
||||
|
||||
이 응답에는 `data.token` 과 `data.user` 가 없다. 토큰 존재 여부로 로그인 완료를 판정하는 클라이언트는 그대로 동작한다.
|
||||
이 응답에는 `data.token` 과 `data.user` 가 없다. **클라이언트는 `two_factor_required` 를 먼저 판정해야 한다** — 응답 형태가 하나라고 가정하고 `data.user.*` 를 읽으면 그 자리에서 예외가 나고, `data.token` 을 그대로 저장하면 `"undefined"` 문자열이 남아 이후 모든 요청이 `401` 로 튕긴다(공개 #133). 코어 클라이언트(`AuthManager.login()`)는 `LoginResult` 판별 유니온으로 두 형태를 구분해 돌려준다.
|
||||
|
||||
**인증번호를 보내지 못한 경우**: 자격 증명은 올바르지만 코드를 전달할 수단이 없으므로 로그인을 완료할 수 없다. 이때는 `401`(자격 증명 오류)이 아니라 `503 auth.two_factor_delivery_failed` 를 반환한다 — `401` 로 뭉뚱그리면 사용자는 비밀번호를 의심하며 같은 실패를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다. 발송에 실패해도 2단계 인증을 건너뛰고 로그인시키지는 않는다.
|
||||
|
||||
|
||||
### POST /api/auth/login/two-factor
|
||||
@@ -590,7 +759,7 @@ HTTP/1.1 200
|
||||
| 401 | Unauthorized | 코드가 틀렸거나(`auth.two_factor_failed`), challenge 의 `purpose` 가 `login` 이 아니거나, 확인된 사용자가 없거나 `active` 상태가 아닌 경우. **세 사유를 같은 응답으로 뭉뚱그린다** — 구분해 내보내면 challenge 유효성 탐색에 쓰인다 |
|
||||
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
|
||||
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일하다 (`auth.account_locked` / 무기한이면 `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`) |
|
||||
| 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다) |
|
||||
| 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다 — `auth.too_many_attempts`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -603,6 +772,79 @@ challenge 의 `purpose` 가 `login` 인지 먼저 대조한다 — 대조하지
|
||||
**계정 잠금은 이 단계에서 다시 검사한다.** 세션을 여는 것은 비밀번호 단계가 아니라 이 엔드포인트이므로, challenge 를 받은 뒤 잠긴 계정은 여기서 `423` 으로 차단된다. 잠기기 전에 발급받은 challenge 를 잠긴 뒤에 완료하는 것만으로 잠금을 우회할 수 없다. 차단은 로그인 완료 훅(`core.auth.after_login`)보다 앞서므로 실패 횟수·잠금 해제 시각도 초기화되지 않는다.
|
||||
|
||||
|
||||
### POST /api/auth/login/two-factor/resend
|
||||
<!-- @generated:start:api.auth.login.two-factor.resend -->
|
||||
- **라우트명**: `api.auth.login.two-factor.resend`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@resendTwoFactor`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| challenge_id | body | string | 예 | uuid | 로그인 응답이 돌려준 challenge 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/auth/login/two-factor/resend HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드 (`AuthService::resendTwoFactorChallenge()` 가 새로 발행한 challenge — `POST /api/auth/login` 의 2단계 인증 응답과 같은 형태)._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| two_factor_required | boolean | `true` | 항상 `true` — 여전히 추가 확인 단계임을 나타낸다 |
|
||||
| challenge_id | string(uuid) | `9f1c2f2e-0b3a-…` | **새** challenge 식별자. 이전 값은 취소되었으므로 반드시 교체해야 한다 |
|
||||
| provider_id | string | `g7:core.mail` | 코드를 발송한 본인인증 프로바이더 |
|
||||
| expires_at | string(ISO8601)\|null | `2026-09-07T14:03:00+09:00` | 새 challenge 만료 시각 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
|
||||
"data": {
|
||||
"two_factor_required": true,
|
||||
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40",
|
||||
"provider_id": "g7:core.mail",
|
||||
"expires_at": "2026-09-07T14:03:00+09:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | `challenge_id` 형식 위반, 또는 재발송 대상이 아닌 challenge (`auth.two_factor_invalid_challenge`) — 존재하지 않음 / `purpose` 가 `login` 이 아님 / 이미 검증·취소·실패 / 만료 / 대상 사용자가 없거나 `active` 가 아님. **사유를 구분하지 않는다** — 구분해 내보내면 challenge 유효성 탐색에 쓰인다 |
|
||||
| 423 | Locked | challenge 를 받은 뒤 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일하다 |
|
||||
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
|
||||
| 503 | Service Unavailable | 새 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증번호를 받지 못했을 때 새 코드를 발행한다. 서버는 **기존 challenge 를 취소하고 새로 발행**하므로, 앞서 받은 인증번호는 더 이상 통하지 않는다 — 유효한 코드를 여러 개 동시에 살려 두면 대입 시도의 표적이 넓어진다. 클라이언트는 응답의 `challenge_id` 로 반드시 교체하고 입력란을 비워야 한다.
|
||||
|
||||
계정 잠금은 여기서도 다시 검사한다. challenge 를 받은 뒤 잠긴 계정에는 새 코드를 보내지 않는다. 로그인과 같은 요청 제한(`throttle:auth-login`)이 걸린다.
|
||||
|
||||
|
||||
### POST /api/auth/logout
|
||||
<!-- @generated:start:api.auth.logout -->
|
||||
- **라우트명**: `api.auth.logout`
|
||||
|
||||
@@ -2518,13 +2518,13 @@ HTTP/1.1 200
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) |
|
||||
| 403 | Forbidden | 요구 권한(`core.identity.cancel`)이 없는 경우, 또는 scope=self 가드가 본인 challenge 가 아니라고 판정한 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.identity.cancel`)이 없는 경우, scope=self 가드가 본인 challenge 가 아니라고 판정한 경우, 또는 challenge 의 `purpose` 가 `login` 인 경우 (`identity.errors.purpose_not_allowed` — `errors.failure_code = PURPOSE_NOT_ALLOWED`) |
|
||||
| 404 | Not Found | path 의 challenge 를 찾을 수 없거나 취소 처리에 실패한 경우 (`유효하지 않은 인증 요청입니다.`) |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 진행 중인 challenge 를 취소합니다. `auth:sanctum` + `core.identity.cancel` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 취소할 수 있으며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(모달 취소 시 audit trail 정합용). `IdentityVerificationService::cancel` 이 처리하고 대상 challenge 가 없으면 404 를 반환합니다. 사용자가 인증 모달을 닫을 때 서버 상태를 cancelled 로 남겨 이력 정합성을 맞추는 데 사용합니다.
|
||||
**설명** 진행 중인 challenge 를 취소합니다. **로그인 2단계 인증 challenge(`purpose = login`)는 이 경로로 취소할 수 없습니다** — 취소되면 그 challenge 로는 더 이상 로그인을 마칠 수 없게 되어, 사용자가 자기 로그인을 스스로 막는 상태가 됩니다(자기 DoS). 로그인 흐름은 `POST /api/auth/login/two-factor` · `.../resend` 만 사용합니다. `auth:sanctum` + `core.identity.cancel` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 취소할 수 있으며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(모달 취소 시 audit trail 정합용). `IdentityVerificationService::cancel` 이 처리하고 대상 challenge 가 없으면 404 를 반환합니다. 사용자가 인증 모달을 닫을 때 서버 상태를 cancelled 로 남겨 이력 정합성을 맞추는 데 사용합니다.
|
||||
|
||||
|
||||
### POST /api/identity/challenges/{challenge}/verify
|
||||
@@ -2593,13 +2593,13 @@ HTTP/1.1 200
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) |
|
||||
| 403 | Forbidden | 요구 권한(`core.identity.verify`)이 없는 경우, 또는 scope=self 가드가 본인 challenge 가 아니라고 판정한 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.identity.verify`)이 없는 경우, scope=self 가드가 본인 challenge 가 아니라고 판정한 경우, 또는 challenge 의 `purpose` 가 `login` 인 경우 (`identity.errors.purpose_not_allowed` — `errors.failure_code = PURPOSE_NOT_ALLOWED`) |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 challenge 가 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 검증 실패 — 응답 `error` 에 `failure_code`(예: `INVALID_CODE`/`EXPIRED`/`MAX_ATTEMPTS`) 와 서버 기준 `attempts`/`max_attempts` 를 함께 반환. 메시지는 `identity.errors.*` (`인증 코드가 올바르지 않습니다.` / `인증 시간이 만료되었습니다. 다시 시도해주세요.` / `시도 횟수를 초과했습니다. 다시 요청해주세요.` 등) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** challenge 를 검증(인증 완료) 합니다. `auth:sanctum` + `core.identity.verify` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 검증하며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(Mode B 가입 흐름). `code`(text_code 흐름) 또는 `token`(link/redirect 흐름) 을 전달하고 `IdentityVerificationService::verify` 가 처리합니다. 실패 시 422 로 `failure_code` 와 서버 기준 `attempts`/`max_attempts` 를 함께 내려 클라이언트의 "남은 시도 횟수" UI 를 서버와 동기화하며, 성공 시 후속 민감 작업에 제출할 `verification_token` 을 반환합니다. 확장은 `core.identity.verify_validation_rules` 필터 훅으로 파라미터를 추가할 수 있습니다.
|
||||
**설명** challenge 를 검증(인증 완료) 합니다. **로그인 2단계 인증 challenge(`purpose = login`)는 이 경로로 검증할 수 없습니다** — 여기서 검증되면 바로 뒤의 `POST /api/auth/login/two-factor` 가 「이미 처리된 요청」으로 거절해 그 challenge 로는 영영 로그인할 수 없게 됩니다. 게이트는 상태를 전혀 바꾸지 않으므로, 거부된 뒤에도 로그인 경로로 정상 완료할 수 있습니다. `auth:sanctum` + `core.identity.verify` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 검증하며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(Mode B 가입 흐름). `code`(text_code 흐름) 또는 `token`(link/redirect 흐름) 을 전달하고 `IdentityVerificationService::verify` 가 처리합니다. 실패 시 422 로 `failure_code` 와 서버 기준 `attempts`/`max_attempts` 를 함께 내려 클라이언트의 "남은 시도 횟수" UI 를 서버와 동기화하며, 성공 시 후속 민감 작업에 제출할 `verification_token` 을 반환합니다. 확장은 `core.identity.verify_validation_rules` 필터 훅으로 파라미터를 추가할 수 있습니다.
|
||||
|
||||
|
||||
### GET /api/identity/policies/resolve
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
12. [callExternal](#callexternal)
|
||||
13. [실전 예시](#실전-예시)
|
||||
|
||||
> `login` 절에 `loginTwoFactor` · `loginTwoFactorResend` 가 함께 설명되어 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## login / logout
|
||||
@@ -61,6 +63,110 @@
|
||||
| `admin` | 관리자 인증 |
|
||||
| `user` | 사용자 인증 |
|
||||
|
||||
### login 의 반환값 — 2단계 인증이 켜진 사이트
|
||||
|
||||
서버는 보안 환경설정에 따라 **두 가지 형태의 200** 을 돌려줍니다. `onSuccess` 는 두 형태 모두에서
|
||||
실행되므로, 후속 액션은 `response.two_factor_required` 로 분기해야 합니다.
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `user` | object \| null | 로그인한 사용자. 인증번호 확인이 남았으면 `null` |
|
||||
| `two_factor_required` | boolean | `true` 면 아직 로그인이 끝나지 않았습니다 |
|
||||
| `challenge_id` | string | 인증번호 확인에 그대로 전달할 식별자 |
|
||||
| `provider_id` | string | 인증번호를 보낸 프로바이더 |
|
||||
| `expires_at` | string \| null | 인증 요청 만료 시각 (ISO8601) |
|
||||
|
||||
분기를 두지 않으면 인증이 끝나기 전에 홈으로 이동하거나, 빈 사용자 정보가 상태에 실립니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "login",
|
||||
"target": "user",
|
||||
"params": { "body": { "email": "{{form.email}}", "password": "{{form.password}}" } },
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"if": "{{response.two_factor_required}}",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"twoFactor": {
|
||||
"required": true,
|
||||
"challenge_id": "{{response.challenge_id}}",
|
||||
"expires_at": "{{response.expires_at}}",
|
||||
"code": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "navigate",
|
||||
"if": "{{!response.two_factor_required}}",
|
||||
"params": { "path": "/" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
같은 시퀀스·`onSuccess` 안에서는 방금 저장한 상태(`_global.twoFactor.*`)를 다시 읽지 않습니다 —
|
||||
그 자리의 상태는 아직 갱신 전이므로 `{{response.*}}` 만 사용합니다.
|
||||
|
||||
### loginTwoFactor
|
||||
|
||||
인증번호를 확인해 로그인을 완료합니다. 성공 시 토큰이 발급되고 `{ user }` 를 돌려줍니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "loginTwoFactor",
|
||||
"target": "user",
|
||||
"params": {
|
||||
"body": {
|
||||
"challenge_id": "{{_global.twoFactor?.challenge_id}}",
|
||||
"code": "{{_global.twoFactor?.code}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{ "handler": "setState", "params": { "target": "global", "currentUser": "{{response.user}}", "twoFactor": null } },
|
||||
{ "handler": "navigate", "params": { "path": "/" } }
|
||||
],
|
||||
"onError": [
|
||||
{ "handler": "setState", "params": { "target": "global", "twoFactor.error": "{{error.message}}" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`params.body` 의 `challenge_id` 와 `code` 는 필수입니다. `target` 은 `login` 과 같은 값을 씁니다
|
||||
(`user` / `admin`) — 관리자 대상은 관리자 전용 엔드포인트를 호출하고, 관리자가 아니면 `403` 이 됩니다.
|
||||
|
||||
### loginTwoFactorResend
|
||||
|
||||
인증번호를 다시 보냅니다. 서버가 **기존 인증 요청을 취소하고 새로 발행**하므로, 반환된
|
||||
`challenge_id` 로 반드시 교체하고 입력란을 비워야 합니다 — 앞서 받은 번호는 더 이상 통하지 않습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "loginTwoFactorResend",
|
||||
"target": "user",
|
||||
"params": { "body": { "challenge_id": "{{_global.twoFactor?.challenge_id}}" } },
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"twoFactor": {
|
||||
"required": true,
|
||||
"challenge_id": "{{response.challenge_id}}",
|
||||
"expires_at": "{{response.expires_at}}",
|
||||
"code": "",
|
||||
"error": null,
|
||||
"resent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
반환값은 `{ two_factor_required, challenge_id, provider_id, expires_at }` 입니다.
|
||||
|
||||
### logout
|
||||
|
||||
로그아웃하고 토큰을 삭제합니다.
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
### UI 인터랙션 핸들러 → [상세 문서](actions-handlers-ui.md)
|
||||
|
||||
14. [login / logout](actions-handlers-ui.md#login--logout) - 인증
|
||||
- [login 의 반환값 — 2단계 인증이 켜진 사이트](actions-handlers-ui.md#login-의-반환값--2단계-인증이-켜진-사이트)
|
||||
- [loginTwoFactor](actions-handlers-ui.md#logintwofactor) - 인증번호 확인
|
||||
- [loginTwoFactorResend](actions-handlers-ui.md#logintwofactorresend) - 인증번호 다시 받기
|
||||
15. [openModal / closeModal](actions-handlers-ui.md#openmodal--closemodal) - 모달
|
||||
16. [showAlert / toast](actions-handlers-ui.md#showalert--toast) - 알림
|
||||
17. [confirm (액션 속성)](actions-handlers-ui.md#confirm-액션-속성) - 실행 전 확인 대화상자
|
||||
|
||||
@@ -98,14 +98,15 @@ private getAuthType(route: Route, pathname: string): AuthType {
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
| 구분 | 로그인 | 사용자 정보 | 로그아웃 | 토큰 갱신 |
|
||||
|------|--------|------------|---------|----------|
|
||||
| **관리자** | `/auth/login` | `/admin/auth/user` | `/admin/auth/logout` | `/admin/auth/refresh` |
|
||||
| **일반사용자** | `/auth/login` | `/user/auth/user` | `/user/auth/logout` | `/user/auth/refresh` |
|
||||
| 구분 | 로그인 | 인증번호 확인 | 인증번호 재발송 | 사용자 정보 | 로그아웃 | 토큰 갱신 |
|
||||
|------|--------|--------------|----------------|------------|---------|----------|
|
||||
| **관리자** | `/auth/admin/login` | `/auth/admin/login/two-factor` | `/auth/admin/login/two-factor/resend` | `/admin/auth/user` | `/admin/auth/logout` | `/admin/auth/refresh` |
|
||||
| **일반사용자** | `/auth/login` | `/auth/login/two-factor` | `/auth/login/two-factor/resend` | `/user/auth/user` | `/user/auth/logout` | `/user/auth/refresh` |
|
||||
|
||||
**공통**:
|
||||
|
||||
- 로그인 엔드포인트는 관리자/일반사용자 동일 (`/auth/login`)
|
||||
- 로그인 엔드포인트는 관리자/일반사용자가 다릅니다 (`AuthConfig.loginEndpoint`)
|
||||
- `updateConfig({ loginEndpoint })` 로 템플릿이 재정의한 값이 그대로 사용됩니다
|
||||
- 인증 후 작업은 각각 분리된 엔드포인트 사용
|
||||
- API 인증은 Bearer 토큰 전용 (세션 기반 인증 미사용)
|
||||
- 401 응답 시 서버가 세션 쿠키 만료 헤더를 자동 전송 (잔존 쿠키 정리)
|
||||
@@ -161,11 +162,12 @@ user.language 확인
|
||||
UI 즉시 업데이트
|
||||
```
|
||||
|
||||
**구현 위치**: `AuthManager.login()`
|
||||
**구현 위치**: `AuthManager.establishSession()` — 일반 로그인과 2단계 인증 완료가 같은 후처리를
|
||||
공유합니다. 갈라지면 한쪽 경로에서만 로케일 전환이나 이벤트 발행이 빠집니다.
|
||||
|
||||
```typescript
|
||||
// 로케일 변경 감지 및 처리
|
||||
const userLanguage = response.data.user.language;
|
||||
const userLanguage = user.language;
|
||||
const currentLocale = localStorage.getItem('g7_locale');
|
||||
const localeChanged = userLanguage && userLanguage !== currentLocale;
|
||||
|
||||
@@ -187,6 +189,51 @@ if (localeChanged && window.__templateApp) {
|
||||
|
||||
---
|
||||
|
||||
## 2단계 인증 로그인 (engine-v1.65.0+)
|
||||
|
||||
보안 환경설정의 「2단계 인증」이 켜져 있으면 서버는 비밀번호가 맞아도 토큰을 발급하지 않고
|
||||
인증 요청(challenge)만 돌려줍니다. 즉, **로그인 응답은 두 가지 형태의 200** 입니다.
|
||||
|
||||
```typescript
|
||||
export type LoginResult =
|
||||
| { status: 'authenticated'; user: AuthUser }
|
||||
| { status: 'two_factor_required'; challenge: TwoFactorChallenge };
|
||||
```
|
||||
|
||||
`AuthManager.login()` 은 이 판별 유니온을 돌려줍니다. 한 형태만 가정하면 challenge 응답에서
|
||||
`data.user.*` 접근이 예외가 되고, `data.token`(undefined)을 저장하면 `"undefined"` 문자열이 남아
|
||||
이후 모든 요청이 401 로 튕깁니다.
|
||||
|
||||
| 메서드 | 하는 일 |
|
||||
|--------|---------|
|
||||
| `login(type, credentials, options?)` | 비밀번호 확인. `LoginResult` 반환 |
|
||||
| `completeTwoFactor(type, { challengeId, code }, options?)` | 인증번호 확인 → 토큰 발급 → `AuthUser` 반환 |
|
||||
| `resendTwoFactor(type, { challengeId }, options?)` | 인증번호 재발송 → **새** `TwoFactorChallenge` 반환 |
|
||||
|
||||
레이아웃에서는 액션 핸들러 `login` / `loginTwoFactor` / `loginTwoFactorResend` 로 사용합니다
|
||||
(→ [actions-handlers-ui.md](actions-handlers-ui.md)).
|
||||
|
||||
### 레이아웃 작성 규칙
|
||||
|
||||
- 1단계 블록과 2단계 블록의 `if` 는 **상보적**이어야 합니다. 두 블록이 동시에 보이면 인증번호
|
||||
단계에서 이메일·비밀번호가 함께 노출됩니다.
|
||||
- 제출 시퀀스의 `login` 과 `loginTwoFactor` 도 상호배타 `if` 를 갖습니다. `if` 가 빠지면 인증번호
|
||||
단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨집니다. `if` 는 시퀀스 시작 시점
|
||||
스냅샷으로 평가되므로 한 번의 제출에 정확히 하나만 실행됩니다.
|
||||
- **같은 시퀀스·`onSuccess` 안에서 방금 저장한 상태를 다시 읽지 않습니다.** 그 자리의 상태는 아직
|
||||
갱신 전이므로 `{{response.*}}` 만 사용합니다.
|
||||
- 인증번호 입력은 자동바인딩이 아니라 `value` + `onChange` 로 상태가 값을 소유해야 합니다 —
|
||||
재발송 시 입력값을 비워야 하기 때문입니다.
|
||||
- 인증 단계 상태는 화면을 떠나도 남으므로, 로그인 화면 진입 시 `init_actions` 에서 초기화합니다.
|
||||
|
||||
### 재발송의 계약
|
||||
|
||||
서버는 기존 challenge 를 **취소하고 새로 발행**합니다. 유효한 코드를 여러 개 동시에 살려 두면
|
||||
대입 시도의 표적이 넓어지기 때문입니다. 따라서 클라이언트는 응답의 `challenge_id` 로 반드시
|
||||
교체하고 입력란을 비워야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### routes.json 설정
|
||||
|
||||
@@ -316,6 +316,17 @@ IDV 모달 파셜 (`_identity_challenge_modal.json`) 및 동일 패턴을 따르
|
||||
- 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 레퍼런스
|
||||
|
||||
@@ -168,6 +168,7 @@ Firefox/WebKit 프로젝트를 상시 스위트에 넣지 않은 이유:
|
||||
|---|---|---|---|
|
||||
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` (권한 경계 검증은 `--no-admin-role` 추가 — §5.1) |
|
||||
| 편집기 저장 spec 대상 시드 화면 | 코어 | `app/Console/Commands/PlaywrightSeedLayout.php` | `php artisan playwright:seed-layout [--remove]` (globalSetup/globalTeardown 자동 호출) |
|
||||
| 로그인 2단계 인증 계정·인증번호 | 코어 | `app/Console/Commands/PlaywrightSeedTwoFactor.php` | `php artisan playwright:seed-two-factor --ensure-user=… \| --plant=<challenge_id>` (§4.2) |
|
||||
| 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) |
|
||||
| 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` |
|
||||
| 플러그인 도메인 데이터 (결제 키) | 플러그인 | `plugins/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | 동일 |
|
||||
@@ -175,6 +176,50 @@ Firefox/WebKit 프로젝트를 상시 스위트에 넣지 않은 이유:
|
||||
|
||||
**핵심 원칙**: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드를 코어에 두면 의존 역전.
|
||||
|
||||
### 4.2 로그인 2단계 인증 픽스처
|
||||
|
||||
2단계 인증은 **사이트 설정과 메일 발송**에 의존하므로 브라우저만으로는 재현할 수 없다.
|
||||
`tests/Playwright/fixtures/two-factor.ts` 가 세 가지를 가역적으로 준비한다.
|
||||
|
||||
| 준비 항목 | 방법 | 원복 |
|
||||
|---|---|---|
|
||||
| 보안 설정 `security.two_factor_auth` | 관리자 토큰으로 `/api/admin/settings` GET → POST (탭 전체를 되돌려 보낸다) | 읽어 둔 탭 전체 값을 그대로 되쓴다 |
|
||||
| 메일 발송 | 받아서 버리는 로컬 SMTP 싱크를 띄우고 `mail.json` 의 `host`/`port`/`encryption` 을 줄 단위 치환 | 백업 파일에서 바이트 그대로 복원 |
|
||||
| 인증번호 | `php artisan playwright:seed-two-factor --plant=<challenge_id> --code=135790` | 없음 (challenge 는 일회성) |
|
||||
| 테스트 계정 | `--ensure-user=` 로 생성 | `--purge-users` 로 종료 시 즉시 제거 |
|
||||
|
||||
**메일을 `log` 메일러로 돌리지 않는 이유**: `log` 는 이 제품의 설정 스키마에 없다. 저장 검증이
|
||||
등록된 메일 드라이버(`smtp`·`mailgun`·`ses`)만 허용하고, 설정 파일을 직접 고쳐도 드라이버 해석
|
||||
단계에서 `smtp` 로 되돌아간다(실측 확인). 그래서 `tests/Playwright/fixtures/smtp-sink.ts` 가
|
||||
Node 기본 `net` 만으로 최소 SMTP 서버를 127.0.0.1 에 잠깐 띄우고 그쪽으로 돌린다 —
|
||||
인증도 TLS 도 요구하지 않으며 받은 메일은 어디에도 남기지 않는다.
|
||||
|
||||
**메일 설정만 파일을 직접 다루는 이유**: 원래 값(빈 SMTP 호스트)은 저장 검증(`smtp` 이면 host
|
||||
필수)을 통과하지 못해 **API 로는 되돌릴 수 없다**. 보안 설정은 API 로, 메일 설정은 파일
|
||||
백업·복원으로 왕복한다.
|
||||
|
||||
`playwright:seed-two-factor` 계약:
|
||||
|
||||
| 옵션 | 하는 일 |
|
||||
|---|---|
|
||||
| `--ensure-user=<접미사>` | `playwright_2fa_<접미사>@example.test` 계정을 알려진 비밀번호로 생성/갱신하고 이메일을 출력. 잠금·실패 카운트도 초기화한다 |
|
||||
| `--admin` | 위 계정에 admin 역할 부여 (없으면 기존 역할을 떼어 관리자 거부 경로를 재현할 수 있게 한다) |
|
||||
| `--password=` | `--ensure-user` 가 설정할 비밀번호 (기본 `Passw0rd!2fa`) |
|
||||
| `--plant=<uuid>` | 그 challenge 에 알려진 인증번호의 해시를 심고 코드를 출력 |
|
||||
| `--code=` | `--plant` 가 심을 인증번호 (기본 `135790`) |
|
||||
| `--gc-hours=` | 이 시간보다 오래된 테스트 계정 정리 (기본 6, `0` 이면 정리 안 함) |
|
||||
| `--purge-users` | 나이와 무관하게 테스트 계정을 전부 제거 — 실측 종료 직후 호출한다. 이 계정들은 알려진 비밀번호를 갖고 관리자용은 관리자 역할까지 가지므로, 나이 기준 정리를 기다리는 사이가 그대로 열린 문이 된다 |
|
||||
|
||||
가드는 `playwright:issue-token` 과 동형이다 — CLI 한정 + `G7_PLAYWRIGHT_BYPASS=1` + `APP_DEBUG` 강제.
|
||||
|
||||
인증번호는 해시로만 저장되어 되읽을 수 없다. 메일함을 실제로 여는 대신 알려진 값을 심는 이유이며,
|
||||
방식은 PHPUnit `TwoFactorAuthTest::issuedCode()` 와 같다 — 검증 대상은 코드 생성이 아니라
|
||||
로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
|
||||
|
||||
**원복 실패를 삼키지 않는다.** 되돌리지 못한 채 끝나면 사이트가 2단계 인증이 켜진 상태로 남아
|
||||
이후 **모든 로그인이 막힌다**. 그래서 이 spec 은 `test.describe.configure({ mode: 'serial' })`
|
||||
로 한 워커에서만 실행하고, `afterAll` 의 원복 실패는 그대로 던진다.
|
||||
|
||||
### 4.1 저장(PUT)하는 spec 은 제품 화면을 대상으로 두지 않는다
|
||||
|
||||
레이아웃 편집기 spec 이 저장까지 수행하면 그 편집 결과는 **그대로 영속된다**. 대상이 제품 화면
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.10] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- 로그인 시도가 많아 잠시 차단될 때의 안내와, 본인인증 화면에서 로그인용 인증 요청을 처리할 수 없다는 안내의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기 [화면 동작] 탭의 「인증번호 확인」·「인증번호 다시 받기」 항목 이름의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.9] - 2026-09-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -36,6 +36,7 @@ return [
|
||||
'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。',
|
||||
'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。',
|
||||
'account_locked_permanently' => 'ログイン試行回数の超過によりアカウントがロックされました。管理者にお問い合わせください。',
|
||||
'too_many_attempts' => 'リクエストが多すぎます。:seconds秒後にもう一度お試しください。',
|
||||
'account_unlocked' => 'アカウントのロックを解除しました。',
|
||||
'reset_token_invalid' => '無効なパスワードリセットトークンです。',
|
||||
'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。',
|
||||
|
||||
@@ -30,6 +30,7 @@ return [
|
||||
'admin_policy_has_no_default' => '管理者が直接作成したポリシーには宣言デフォルト値がありません。',
|
||||
'reset_field_failed' => '宣言デフォルト値の復元に失敗しました。フィールドが有効であることを確認してください。',
|
||||
'cannot_delete_system_policy' => 'システムが宣言したポリシーは削除できません。管理者が直接作成したポリシーのみ削除できます。',
|
||||
'purpose_not_allowed' => 'この認証リクエストはこの画面では処理できません。',
|
||||
],
|
||||
'messages' => [
|
||||
'challenge_requested' => '本人認証コードを送信しました。',
|
||||
|
||||
@@ -1280,6 +1280,14 @@
|
||||
"label": "ログイン処理",
|
||||
"param_body": "ログイン情報"
|
||||
},
|
||||
"login_two_factor": {
|
||||
"label": "認証番号の確認",
|
||||
"param_body": "認証情報"
|
||||
},
|
||||
"login_two_factor_resend": {
|
||||
"label": "認証番号の再送信",
|
||||
"param_body": "認証リクエスト情報"
|
||||
},
|
||||
"logout": {
|
||||
"label": "ログアウト処理",
|
||||
"param_target": "移動先"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.9] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 로그인 화면의 2단계 인증(인증번호 입력·다시 받기·처음부터·유효 시각)과 계정 잠금 해제 시각 안내의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.8] - 2026-09-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
"processing": "処理中...",
|
||||
"remember": "ログイン状態を保持する",
|
||||
"forgot": "パスワードをお忘れですか?",
|
||||
"two_factor": {
|
||||
"sent": "認証番号を送信しました。受け取った番号を入力してログインを完了してください。",
|
||||
"resent": "認証番号を再送信しました。新しく受け取った番号を入力してください。",
|
||||
"code_label": "認証番号",
|
||||
"code_placeholder": "受け取った認証番号を入力してください",
|
||||
"valid_until": "有効期限 {{until}} まで",
|
||||
"verify": "認証番号を確認",
|
||||
"verifying": "確認中...",
|
||||
"resend": "認証番号を再送信",
|
||||
"restart": "最初から"
|
||||
},
|
||||
"locked_until": "解除予定: {{until}}",
|
||||
"locked_permanent": "管理者にお問い合わせのうえロックを解除してください。",
|
||||
"error": {
|
||||
"email_required": "メールアドレスを入力してください。",
|
||||
"email_invalid": "正しいメールアドレスを入力してください。",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-admin_basic",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.3] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- 로그인 화면의 2단계 인증(인증번호 입력·다시 받기·처음부터·유효 시각)과 계정 잠금 해제 시각 안내의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.1.2] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -141,6 +141,19 @@
|
||||
"minTypes": "{{count}}種類以上の組み合わせ (大文字/小文字、数字、特殊文字)"
|
||||
}
|
||||
},
|
||||
"two_factor": {
|
||||
"sent": "認証番号を送信しました。受け取った番号を入力してログインを完了してください。",
|
||||
"resent": "認証番号を再送信しました。新しく受け取った番号を入力してください。",
|
||||
"code_label": "認証番号",
|
||||
"code_placeholder": "受け取った認証番号を入力してください",
|
||||
"valid_until": "有効期限 {{until}} まで",
|
||||
"verify": "認証番号を確認",
|
||||
"verifying": "確認中...",
|
||||
"resend": "認証番号を再送信",
|
||||
"restart": "最初から"
|
||||
},
|
||||
"locked_until": "解除予定: {{until}}",
|
||||
"locked_permanent": "管理者にお問い合わせのうえロックを解除してください。",
|
||||
"session_expired_toast": "セッションが期限切れになりました。もう一度ログインしてください。",
|
||||
"guest_checkout": {
|
||||
"divider": "または",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-basic",
|
||||
|
||||
@@ -38,6 +38,7 @@ return [
|
||||
'account_pending_verification' => 'Identity verification is not complete. Please complete the email verification.',
|
||||
'account_locked' => 'Too many failed login attempts. Your account is locked for :minutes minute(s).',
|
||||
'account_locked_permanently' => 'Too many failed login attempts. Your account has been locked. Please contact an administrator.',
|
||||
'too_many_attempts' => 'Too many requests. Please try again in :seconds second(s).',
|
||||
'account_unlocked' => 'The account lock has been released.',
|
||||
|
||||
// Password reset
|
||||
|
||||
@@ -31,6 +31,7 @@ return [
|
||||
'admin_policy_has_no_default' => 'Admin-created policies do not have a declared default.',
|
||||
'reset_field_failed' => 'Failed to reset the field to its declared default. Check if the field is valid.',
|
||||
'cannot_delete_system_policy' => 'System-declared policies cannot be deleted. Only administrator-created policies can be deleted.',
|
||||
'purpose_not_allowed' => 'This verification request cannot be handled on this screen.',
|
||||
],
|
||||
|
||||
'messages' => [
|
||||
|
||||
@@ -38,6 +38,7 @@ return [
|
||||
'account_pending_verification' => '본인인증이 완료되지 않은 계정입니다. 이메일 인증을 완료해주세요.',
|
||||
'account_locked' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. :minutes분 후 다시 시도해주세요.',
|
||||
'account_locked_permanently' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의해주세요.',
|
||||
'too_many_attempts' => '요청이 너무 잦습니다. :seconds초 후에 다시 시도해주세요.',
|
||||
'account_unlocked' => '계정 잠금이 해제되었습니다.',
|
||||
|
||||
// 비밀번호 재설정
|
||||
|
||||
@@ -31,6 +31,7 @@ return [
|
||||
'admin_policy_has_no_default' => '관리자가 직접 생성한 정책에는 선언 기본값이 없습니다.',
|
||||
'reset_field_failed' => '선언 기본값 복원에 실패했습니다. 필드가 유효한지 확인하세요.',
|
||||
'cannot_delete_system_policy' => '시스템이 선언한 정책은 삭제할 수 없습니다. 관리자가 직접 생성한 정책만 삭제할 수 있습니다.',
|
||||
'purpose_not_allowed' => '이 인증 요청은 이 화면에서 처리할 수 없습니다.',
|
||||
],
|
||||
|
||||
'messages' => [
|
||||
|
||||
@@ -1301,6 +1301,14 @@
|
||||
"label": "Handle login",
|
||||
"param_body": "Login info"
|
||||
},
|
||||
"login_two_factor": {
|
||||
"label": "Verify code",
|
||||
"param_body": "Verification info"
|
||||
},
|
||||
"login_two_factor_resend": {
|
||||
"label": "Resend code",
|
||||
"param_body": "Challenge info"
|
||||
},
|
||||
"logout": {
|
||||
"label": "Handle logout",
|
||||
"param_target": "Destination"
|
||||
|
||||
@@ -1301,6 +1301,14 @@
|
||||
"label": "로그인 처리",
|
||||
"param_body": "로그인 정보"
|
||||
},
|
||||
"login_two_factor": {
|
||||
"label": "인증번호 확인",
|
||||
"param_body": "인증 정보"
|
||||
},
|
||||
"login_two_factor_resend": {
|
||||
"label": "인증번호 다시 받기",
|
||||
"param_body": "인증 요청 정보"
|
||||
},
|
||||
"logout": {
|
||||
"label": "로그아웃 처리",
|
||||
"param_target": "이동할 곳"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+5
-5
File diff suppressed because one or more lines are too long
+66
-23
File diff suppressed because one or more lines are too long
@@ -117,8 +117,20 @@ class ApiClient {
|
||||
|
||||
/**
|
||||
* 토큰 저장
|
||||
*
|
||||
* 문자열이 아닌 값은 저장하지 않는다. `localStorage` 는 무엇을 넣든 문자열로 바꿔
|
||||
* 저장하므로 `undefined` 를 넘기면 `"undefined"` 라는 truthy 문자열이 남고, 이후
|
||||
* 모든 요청이 `Bearer undefined` 로 나가 401 로 튕긴다 — 화면에는 "세션이 만료되었습니다"
|
||||
* 로 보여 원인을 추적할 단서가 남지 않는다.
|
||||
*
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
setToken(token: string): void {
|
||||
if (typeof token !== 'string' || token === '') {
|
||||
logger.warn('Ignoring invalid auth token — token must be a non-empty string.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(this.TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,28 @@ describe('ApiClient', () => {
|
||||
it('토큰이 없으면 null을 반환해야 함', () => {
|
||||
expect(apiClient.getToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('문자열이 아닌 토큰은 저장하지 않아야 함', () => {
|
||||
// localStorage 는 무엇을 넣든 문자열로 바꾼다 — undefined 를 넘기면 "undefined" 라는
|
||||
// truthy 문자열이 남아 이후 모든 요청이 Bearer undefined 로 나가고 401 로 튕긴다.
|
||||
apiClient.setToken(undefined as unknown as string);
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull();
|
||||
expect(apiClient.getToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('빈 문자열 토큰도 저장하지 않아야 함', () => {
|
||||
apiClient.setToken('');
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('기존 토큰이 있어도 잘못된 값으로 덮어쓰지 않아야 함', () => {
|
||||
apiClient.setToken('valid-token');
|
||||
apiClient.setToken(null as unknown as string);
|
||||
|
||||
expect(apiClient.getToken()).toBe('valid-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 메서드', () => {
|
||||
|
||||
@@ -36,8 +36,38 @@ export interface AuthConfig {
|
||||
loginEndpoint: string;
|
||||
logoutEndpoint: string;
|
||||
refreshEndpoint: string;
|
||||
/** 2단계 인증 코드 확인 엔드포인트 */
|
||||
twoFactorEndpoint: string;
|
||||
/** 2단계 인증 코드 재발송 엔드포인트 */
|
||||
twoFactorResendEndpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 challenge 정보
|
||||
*
|
||||
* 비밀번호 확인만 통과한 상태다 — 토큰은 아직 발급되지 않았고, 코드 확인에 성공해야
|
||||
* 세션이 열린다.
|
||||
*
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
export interface TwoFactorChallenge {
|
||||
challengeId: string;
|
||||
providerId: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 결과
|
||||
*
|
||||
* 서버는 보안 환경설정에 따라 **두 가지 형태의 200** 을 돌려준다. 한 형태만 가정하면
|
||||
* 다른 형태에서 토큰·사용자 필드가 없어 화면이 알 수 없는 오류로 멈춘다.
|
||||
*
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
export type LoginResult =
|
||||
| { status: 'authenticated'; user: AuthUser }
|
||||
| { status: 'two_factor_required'; challenge: TwoFactorChallenge };
|
||||
|
||||
/**
|
||||
* 인증된 사용자 정보
|
||||
*/
|
||||
@@ -62,6 +92,20 @@ export interface AuthState {
|
||||
*/
|
||||
type EventHandler = (...args: any[]) => void;
|
||||
|
||||
/**
|
||||
* 로그인·2단계 인증 응답의 data 페이로드
|
||||
*
|
||||
* 두 형태(토큰 발급 / challenge 발급)가 같은 자리에 오므로 전부 선택 필드다.
|
||||
*/
|
||||
interface LoginResponseData {
|
||||
token?: string;
|
||||
user?: AuthUser;
|
||||
two_factor_required?: boolean;
|
||||
challenge_id?: string;
|
||||
provider_id?: string;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 인증 설정
|
||||
*/
|
||||
@@ -74,6 +118,8 @@ const defaultConfigs: Record<AuthType, AuthConfig> = {
|
||||
loginEndpoint: '/auth/admin/login',
|
||||
logoutEndpoint: '/admin/auth/logout',
|
||||
refreshEndpoint: '/admin/auth/refresh',
|
||||
twoFactorEndpoint: '/auth/admin/login/two-factor',
|
||||
twoFactorResendEndpoint: '/auth/admin/login/two-factor/resend',
|
||||
},
|
||||
user: {
|
||||
type: 'user',
|
||||
@@ -83,6 +129,8 @@ const defaultConfigs: Record<AuthType, AuthConfig> = {
|
||||
loginEndpoint: '/auth/login',
|
||||
logoutEndpoint: '/auth/logout',
|
||||
refreshEndpoint: '/auth/refresh',
|
||||
twoFactorEndpoint: '/auth/login/two-factor',
|
||||
twoFactorResendEndpoint: '/auth/login/two-factor/resend',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -238,15 +286,83 @@ export class AuthManager {
|
||||
/**
|
||||
* 로그인 처리
|
||||
*
|
||||
* 서버는 두 가지 형태의 200 을 돌려준다 — 토큰과 사용자가 실린 정상 응답, 그리고
|
||||
* 2단계 인증이 켜져 있을 때의 challenge 응답이다. 후자에는 토큰도 사용자도 없으므로
|
||||
* 세션을 열지 않고 challenge 만 돌려준다.
|
||||
*
|
||||
* @param type - 인증 타입
|
||||
* @param credentials - 로그인 자격 증명
|
||||
* @param options - 추가 옵션 (headers 등)
|
||||
* @returns 인증된 사용자 정보
|
||||
* @returns 인증 완료 또는 2단계 인증 요구
|
||||
* @since engine-v1.65.0 반환 타입이 `AuthUser` 에서 `LoginResult` 로 바뀌었습니다.
|
||||
*/
|
||||
async login(
|
||||
type: AuthType,
|
||||
credentials: { email: string; password: string },
|
||||
options?: { headers?: Record<string, string> }
|
||||
): Promise<LoginResult> {
|
||||
const apiClient = getApiClient();
|
||||
const config = this.config.get(type);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`Unknown auth type: ${type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 추가 헤더 설정 (globalHeaders 지원)
|
||||
const requestConfig = options?.headers ? { headers: options.headers } : undefined;
|
||||
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: LoginResponseData;
|
||||
}>(config.loginEndpoint, credentials, requestConfig);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 2단계 인증 요구 — 토큰이 없으므로 상태를 건드리지 않는다.
|
||||
if (response.data.two_factor_required === true) {
|
||||
trackAuthEvent('login', true, undefined, { type, two_factor: true });
|
||||
|
||||
return {
|
||||
status: 'two_factor_required',
|
||||
challenge: {
|
||||
challengeId: String(response.data.challenge_id ?? ''),
|
||||
providerId: String(response.data.provider_id ?? ''),
|
||||
expiresAt: response.data.expires_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 두 필드가 모두 있을 때만 세션을 연다 — 한쪽만 보고 진행하면 undefined 토큰이
|
||||
// 저장되어 이후 모든 요청이 401 이 된다.
|
||||
if (response.data.token && response.data.user) {
|
||||
return {
|
||||
status: 'authenticated',
|
||||
user: this.establishSession(type, response.data.token, response.data.user),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Login failed');
|
||||
} catch (error: any) {
|
||||
this.clearState();
|
||||
|
||||
throw this.enhanceAuthError(error, type, 'Login failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드를 확인하고 로그인을 완료합니다.
|
||||
*
|
||||
* @param type - 인증 타입
|
||||
* @param payload - challenge 식별자와 사용자가 받은 인증번호
|
||||
* @param options - 추가 옵션 (headers 등)
|
||||
* @returns 인증된 사용자 정보
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
async completeTwoFactor(
|
||||
type: AuthType,
|
||||
payload: { challengeId: string; code: string },
|
||||
options?: { headers?: Record<string, string> }
|
||||
): Promise<AuthUser> {
|
||||
const apiClient = getApiClient();
|
||||
const config = this.config.get(type);
|
||||
@@ -255,29 +371,100 @@ export class AuthManager {
|
||||
throw new Error(`Unknown auth type: ${type}`);
|
||||
}
|
||||
|
||||
// 로그인 엔드포인트 결정
|
||||
const loginEndpoint = type === 'admin'
|
||||
? defaultConfigs.admin.loginEndpoint
|
||||
: defaultConfigs.user.loginEndpoint;
|
||||
|
||||
try {
|
||||
// 추가 헤더 설정 (globalHeaders 지원)
|
||||
const requestConfig = options?.headers ? { headers: options.headers } : undefined;
|
||||
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
data: LoginResponseData;
|
||||
}>(
|
||||
config.twoFactorEndpoint,
|
||||
{ challenge_id: payload.challengeId, code: payload.code },
|
||||
requestConfig
|
||||
);
|
||||
|
||||
if (response.success && response.data?.token && response.data?.user) {
|
||||
return this.establishSession(type, response.data.token, response.data.user);
|
||||
}
|
||||
|
||||
throw new Error('Login failed');
|
||||
} catch (error: any) {
|
||||
this.clearState();
|
||||
|
||||
throw this.enhanceAuthError(error, type, 'Login failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드를 재발송합니다.
|
||||
*
|
||||
* 서버가 기존 challenge 를 취소하고 새로 발행하므로, 앞서 받은 인증번호는 더 이상
|
||||
* 통하지 않습니다 — 호출자는 반드시 새 challenge 로 교체해야 합니다.
|
||||
*
|
||||
* @param type - 인증 타입
|
||||
* @param payload - 재발송할 challenge 식별자
|
||||
* @param options - 추가 옵션 (headers 등)
|
||||
* @returns 새 challenge 정보
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
async resendTwoFactor(
|
||||
type: AuthType,
|
||||
payload: { challengeId: string },
|
||||
options?: { headers?: Record<string, string> }
|
||||
): Promise<TwoFactorChallenge> {
|
||||
const apiClient = getApiClient();
|
||||
const config = this.config.get(type);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`Unknown auth type: ${type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestConfig = options?.headers ? { headers: options.headers } : undefined;
|
||||
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: LoginResponseData;
|
||||
}>(
|
||||
config.twoFactorResendEndpoint,
|
||||
{ challenge_id: payload.challengeId },
|
||||
requestConfig
|
||||
);
|
||||
|
||||
if (response.success && response.data?.challenge_id) {
|
||||
return {
|
||||
challengeId: String(response.data.challenge_id),
|
||||
providerId: String(response.data.provider_id ?? ''),
|
||||
expiresAt: response.data.expires_at ?? null,
|
||||
};
|
||||
}>(loginEndpoint, credentials, requestConfig);
|
||||
}
|
||||
|
||||
throw new Error('Resend failed');
|
||||
} catch (error: any) {
|
||||
// 재발송 실패는 세션 상태와 무관하다 — 이미 열린 세션이 없으므로 상태를 지우지 않는다.
|
||||
throw this.enhanceAuthError(error, type, 'Resend failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰을 저장하고 인증 상태·로케일을 확정합니다.
|
||||
*
|
||||
* 정상 로그인과 2단계 인증 완료가 같은 후처리를 공유하도록 단일 지점에 둔다 —
|
||||
* 갈라지면 한쪽 경로에서만 로케일 전환이나 이벤트 발행이 빠진다.
|
||||
*
|
||||
* @param type - 인증 타입
|
||||
* @param token - 발급된 토큰
|
||||
* @param user - 인증된 사용자
|
||||
* @returns 인증된 사용자 정보
|
||||
*/
|
||||
private establishSession(type: AuthType, token: string, user: AuthUser): AuthUser {
|
||||
const apiClient = getApiClient();
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 토큰 저장
|
||||
apiClient.setToken(response.data.token);
|
||||
apiClient.setToken(token);
|
||||
|
||||
// 사용자의 language 설정 확인 및 로케일 변경 처리
|
||||
const userLanguage = response.data.user.language;
|
||||
const userLanguage = user.language;
|
||||
const currentLocale = localStorage.getItem(AuthManager.LOCALE_STORAGE_KEY);
|
||||
const localeChanged = userLanguage && userLanguage !== currentLocale;
|
||||
|
||||
@@ -292,8 +479,8 @@ export class AuthManager {
|
||||
// 상태 업데이트
|
||||
this.state = {
|
||||
isAuthenticated: true,
|
||||
user: response.data.user,
|
||||
type: type,
|
||||
user,
|
||||
type,
|
||||
};
|
||||
|
||||
this.emit('login', this.state);
|
||||
@@ -301,8 +488,8 @@ export class AuthManager {
|
||||
|
||||
// DevTools 추적
|
||||
trackAuthEvent('login', true, undefined, {
|
||||
userId: response.data.user.uuid,
|
||||
email: response.data.user.email,
|
||||
userId: user.uuid,
|
||||
email: user.email,
|
||||
type,
|
||||
});
|
||||
|
||||
@@ -313,28 +500,36 @@ export class AuthManager {
|
||||
(window as any).__templateApp.changeLocale(userLanguage);
|
||||
}
|
||||
|
||||
return response.data.user;
|
||||
return user;
|
||||
}
|
||||
|
||||
throw new Error('Login failed');
|
||||
} catch (error: any) {
|
||||
this.clearState();
|
||||
|
||||
/**
|
||||
* 인증 실패 오류에 서버 응답 정보를 실어 다시 던질 오류를 만듭니다.
|
||||
*
|
||||
* `code` 를 보존해야 호출자가 네트워크 실패(`ERR_NETWORK` 등)와 HTTP 오류를 구분해
|
||||
* 다국어 문구로 안내할 수 있다 — axios 오류는 `TypeError` 가 아니다.
|
||||
*
|
||||
* @param error - 원본 오류
|
||||
* @param type - 인증 타입
|
||||
* @param fallbackMessage - 서버 메시지가 없을 때 쓸 기본 문구
|
||||
* @returns 보강된 오류
|
||||
*/
|
||||
private enhanceAuthError(error: any, type: AuthType, fallbackMessage: string): any {
|
||||
// Axios 에러에서 API 응답 메시지 추출
|
||||
// error.response.data.message가 실제 서버 응답 메시지
|
||||
const apiMessage = error.response?.data?.message;
|
||||
const enhancedError: any = new Error(apiMessage || error.message || 'Login failed');
|
||||
enhancedError.response = error.response;
|
||||
enhancedError.status = error.response?.status;
|
||||
const apiMessage = error?.response?.data?.message;
|
||||
const enhancedError: any = new Error(apiMessage || error?.message || fallbackMessage);
|
||||
enhancedError.response = error?.response;
|
||||
enhancedError.status = error?.response?.status;
|
||||
enhancedError.code = error?.code;
|
||||
|
||||
// DevTools 추적
|
||||
trackAuthEvent('login', false, enhancedError.message, {
|
||||
type,
|
||||
status: error.response?.status,
|
||||
status: error?.response?.status,
|
||||
});
|
||||
|
||||
throw enhancedError;
|
||||
}
|
||||
return enhancedError;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -308,7 +308,7 @@ describe('AuthManager', () => {
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(result).toEqual({ status: 'authenticated', user: mockUser });
|
||||
expect(mockApiClient.setToken).toHaveBeenCalledWith('new-token');
|
||||
expect(authManager.isAuthenticated()).toBe(true);
|
||||
expect(authManager.getUser()).toEqual(mockUser);
|
||||
@@ -381,5 +381,155 @@ describe('AuthManager', () => {
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('2단계 인증 응답에서는 토큰을 저장하지 않고 challenge 를 돌려줘야 합니다', async () => {
|
||||
mockApiClient.post.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
two_factor_required: true,
|
||||
challenge_id: 'challenge-uuid',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: '2026-09-07T14:03:00+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await authManager.login('user', {
|
||||
email: 'test@example.com',
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'two_factor_required',
|
||||
challenge: {
|
||||
challengeId: 'challenge-uuid',
|
||||
providerId: 'g7:core.mail',
|
||||
expiresAt: '2026-09-07T14:03:00+09:00',
|
||||
},
|
||||
});
|
||||
// 토큰도 사용자도 없는 응답이다 — 저장하면 이후 모든 요청이 Bearer undefined 로 나간다.
|
||||
expect(mockApiClient.setToken).not.toHaveBeenCalled();
|
||||
expect(authManager.isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('updateConfig 로 바꾼 loginEndpoint 를 사용해야 합니다', async () => {
|
||||
mockApiClient.post.mockResolvedValue({
|
||||
success: true,
|
||||
data: { token: 'new-token', user: { id: 1, name: 'T', email: 't@e.com' } },
|
||||
});
|
||||
|
||||
authManager.updateConfig('user', { loginEndpoint: '/auth/custom-login' });
|
||||
|
||||
await authManager.login('user', { email: 'test@example.com', password: 'password' });
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith(
|
||||
'/auth/custom-login',
|
||||
{ email: 'test@example.com', password: 'password' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('네트워크 오류의 code 를 보존해야 합니다', async () => {
|
||||
const networkError: any = new Error('Network Error');
|
||||
networkError.code = 'ERR_NETWORK';
|
||||
mockApiClient.post.mockRejectedValue(networkError);
|
||||
|
||||
// TypeError 가 아니므로 code 가 사라지면 네트워크 실패 판정이 불가능해진다.
|
||||
await expect(
|
||||
authManager.login('user', { email: 'test@example.com', password: 'password' })
|
||||
).rejects.toMatchObject({ code: 'ERR_NETWORK' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeTwoFactor', () => {
|
||||
it('코드 확인에 성공하면 토큰을 저장하고 로그인 상태가 되어야 합니다', async () => {
|
||||
const mockUser: AuthUser = {
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
mockApiClient.post.mockResolvedValue({
|
||||
success: true,
|
||||
data: { token: 'two-factor-token', user: mockUser },
|
||||
});
|
||||
|
||||
const loginHandler = vi.fn();
|
||||
authManager.on('login', loginHandler);
|
||||
|
||||
const result = await authManager.completeTwoFactor('user', {
|
||||
challengeId: 'challenge-uuid',
|
||||
code: '135790',
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith(
|
||||
'/auth/login/two-factor',
|
||||
{ challenge_id: 'challenge-uuid', code: '135790' },
|
||||
undefined
|
||||
);
|
||||
expect(mockApiClient.setToken).toHaveBeenCalledWith('two-factor-token');
|
||||
expect(authManager.isAuthenticated()).toBe(true);
|
||||
expect(loginHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('관리자 타입은 관리자 전용 엔드포인트를 사용해야 합니다', async () => {
|
||||
mockApiClient.post.mockResolvedValue({
|
||||
success: true,
|
||||
data: { token: 't', user: { id: 1, name: 'T', email: 't@e.com' } },
|
||||
});
|
||||
|
||||
await authManager.completeTwoFactor('admin', {
|
||||
challengeId: 'challenge-uuid',
|
||||
code: '135790',
|
||||
});
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith(
|
||||
'/auth/admin/login/two-factor',
|
||||
{ challenge_id: 'challenge-uuid', code: '135790' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('실패 시 서버 메시지를 실은 에러를 throw 해야 합니다', async () => {
|
||||
const error: any = new Error('Request failed');
|
||||
error.response = { status: 401, data: { message: '인증번호가 올바르지 않습니다.' } };
|
||||
mockApiClient.post.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
authManager.completeTwoFactor('user', { challengeId: 'c', code: '000000' })
|
||||
).rejects.toThrow('인증번호가 올바르지 않습니다.');
|
||||
|
||||
expect(authManager.isAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendTwoFactor', () => {
|
||||
it('새 challenge 를 돌려주고 토큰은 건드리지 않아야 합니다', async () => {
|
||||
mockApiClient.post.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
two_factor_required: true,
|
||||
challenge_id: 'new-challenge',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await authManager.resendTwoFactor('user', {
|
||||
challengeId: 'old-challenge',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
challengeId: 'new-challenge',
|
||||
providerId: 'g7:core.mail',
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith(
|
||||
'/auth/login/two-factor/resend',
|
||||
{ challenge_id: 'old-challenge' },
|
||||
undefined
|
||||
);
|
||||
expect(mockApiClient.setToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,4 +4,6 @@ export {
|
||||
type AuthConfig,
|
||||
type AuthUser,
|
||||
type AuthState,
|
||||
type TwoFactorChallenge,
|
||||
type LoginResult,
|
||||
} from './AuthManager';
|
||||
@@ -132,6 +132,8 @@ export type ActionType =
|
||||
| 'replaceUrl' // URL만 변경 (데이터소스 refetch 없음)
|
||||
| 'apiCall' // API 호출
|
||||
| 'login' // 로그인 (토큰 저장 포함)
|
||||
| 'loginTwoFactor' // 2단계 인증 코드 확인 (로그인 완료)
|
||||
| 'loginTwoFactorResend' // 2단계 인증 코드 재발송
|
||||
| 'logout' // 로그아웃
|
||||
| 'setState' // 상태 변경
|
||||
| 'setError' // 에러 상태 설정
|
||||
@@ -2464,6 +2466,22 @@ export class ActionDispatcher {
|
||||
);
|
||||
break;
|
||||
|
||||
case 'loginTwoFactor':
|
||||
result = await this.handleLoginTwoFactor(
|
||||
resolvedTarget!,
|
||||
resolvedParams,
|
||||
context
|
||||
);
|
||||
break;
|
||||
|
||||
case 'loginTwoFactorResend':
|
||||
result = await this.handleLoginTwoFactorResend(
|
||||
resolvedTarget!,
|
||||
resolvedParams,
|
||||
context
|
||||
);
|
||||
break;
|
||||
|
||||
case 'logout':
|
||||
result = await this.handleLogout(resolvedTarget!, context);
|
||||
break;
|
||||
@@ -3828,8 +3846,7 @@ export class ActionDispatcher {
|
||||
);
|
||||
}
|
||||
|
||||
// target을 인증 타입으로 사용 (admin 또는 user)
|
||||
const authType: AuthType = target === 'user' ? 'user' : 'admin';
|
||||
const authType = this.resolveAuthType(target);
|
||||
|
||||
// 로그인 엔드포인트 결정 (globalHeaders 패턴 매칭용)
|
||||
// ApiClient는 baseURL이 '/api'이므로 실제 요청 경로에 '/api' prefix 추가
|
||||
@@ -3837,49 +3854,184 @@ export class ActionDispatcher {
|
||||
? '/api/auth/admin/login'
|
||||
: '/api/auth/login';
|
||||
|
||||
// globalHeaders에서 패턴 매칭되는 헤더 추출
|
||||
// Stale Closure 방지: G7Core.state.getGlobal/getLocal()로 최신 상태 조회
|
||||
// (cartKey 재발급 등 중간에 상태가 변경된 경우에도 최신 값 사용)
|
||||
const currentGlobalState = (window as any).G7Core?.state?.getGlobal?.() || _context.state?._global || {};
|
||||
const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || _context.state?._local || {};
|
||||
|
||||
const expressionContext: Record<string, any> = {
|
||||
_global: currentGlobalState,
|
||||
_local: currentLocalState,
|
||||
};
|
||||
const globalHeadersResolved = this.getMatchingGlobalHeaders(loginEndpoint, expressionContext);
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
try {
|
||||
// AuthManager.login()을 통해 로그인 및 토큰 저장
|
||||
// globalHeaders가 있으면 options.headers로 전달
|
||||
const loginOptions = Object.keys(globalHeadersResolved).length > 0
|
||||
? { headers: globalHeadersResolved }
|
||||
: undefined;
|
||||
|
||||
const user = await authManager.login(
|
||||
const result = await authManager.login(
|
||||
authType,
|
||||
{ email: body.email, password: body.password },
|
||||
loginOptions
|
||||
this.buildAuthRequestOptions(loginEndpoint, _context)
|
||||
);
|
||||
|
||||
// 2단계 인증이 켜진 사이트에서는 아직 로그인이 끝나지 않았다. 레이아웃이 그 사실을
|
||||
// 알 통로가 없으면 인증번호 입력 단계로 넘어갈 방법이 없다.
|
||||
if (result.status === 'two_factor_required') {
|
||||
return {
|
||||
user: null,
|
||||
two_factor_required: true,
|
||||
challenge_id: result.challenge.challengeId,
|
||||
provider_id: result.challenge.providerId,
|
||||
expires_at: result.challenge.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
// `user` 는 종전 계약 그대로 유지한다 — 기존 레이아웃이 `response.user` 를 읽는다.
|
||||
return { user: result.user, two_factor_required: false };
|
||||
} catch (error: any) {
|
||||
throw this.toLoginActionError(error, 'Login failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* loginTwoFactor 액션을 처리합니다.
|
||||
*
|
||||
* 비밀번호 확인 단계가 돌려준 challenge 와 사용자가 받은 인증번호로 로그인을 완료합니다.
|
||||
*
|
||||
* @param target 인증 타입 (admin 또는 user)
|
||||
* @param params 요청 파라미터 (body에 challenge_id, code 포함)
|
||||
* @param context 액션 컨텍스트
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
private async handleLoginTwoFactor(
|
||||
target: string,
|
||||
params: Record<string, any>,
|
||||
context: ActionContext
|
||||
): Promise<any> {
|
||||
const { body } = params;
|
||||
|
||||
if (!body || !body.challenge_id || !body.code) {
|
||||
throw new ActionError(
|
||||
'loginTwoFactor requires challenge_id and code in body params'
|
||||
);
|
||||
}
|
||||
|
||||
const authType = this.resolveAuthType(target);
|
||||
const endpoint = authType === 'admin'
|
||||
? '/api/auth/admin/login/two-factor'
|
||||
: '/api/auth/login/two-factor';
|
||||
|
||||
try {
|
||||
const user = await AuthManager.getInstance().completeTwoFactor(
|
||||
authType,
|
||||
{ challengeId: String(body.challenge_id), code: String(body.code) },
|
||||
this.buildAuthRequestOptions(endpoint, context)
|
||||
);
|
||||
|
||||
return { user };
|
||||
} catch (error: any) {
|
||||
// AuthManager에서 이미 API 응답 메시지를 추출한 에러를 throw하므로
|
||||
// error.message에 실제 서버 응답 메시지가 들어있음
|
||||
const errorMessage = error.message || 'Login failed';
|
||||
throw this.toLoginActionError(error, 'Login failed');
|
||||
}
|
||||
}
|
||||
|
||||
const apiError: any = new Error(errorMessage);
|
||||
apiError.response = error.response?.data || {};
|
||||
apiError.status = error.status || error.response?.status || 500;
|
||||
/**
|
||||
* loginTwoFactorResend 액션을 처리합니다.
|
||||
*
|
||||
* 서버가 기존 challenge 를 취소하고 새로 발행하므로, 레이아웃은 반환된 새
|
||||
* `challenge_id` 로 반드시 교체해야 합니다.
|
||||
*
|
||||
* @param target 인증 타입 (admin 또는 user)
|
||||
* @param params 요청 파라미터 (body에 challenge_id 포함)
|
||||
* @param context 액션 컨텍스트
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
private async handleLoginTwoFactorResend(
|
||||
target: string,
|
||||
params: Record<string, any>,
|
||||
context: ActionContext
|
||||
): Promise<any> {
|
||||
const { body } = params;
|
||||
|
||||
if (!body || !body.challenge_id) {
|
||||
throw new ActionError(
|
||||
errorMessage,
|
||||
undefined,
|
||||
apiError
|
||||
'loginTwoFactorResend requires challenge_id in body params'
|
||||
);
|
||||
}
|
||||
|
||||
const authType = this.resolveAuthType(target);
|
||||
const endpoint = authType === 'admin'
|
||||
? '/api/auth/admin/login/two-factor/resend'
|
||||
: '/api/auth/login/two-factor/resend';
|
||||
|
||||
try {
|
||||
const challenge = await AuthManager.getInstance().resendTwoFactor(
|
||||
authType,
|
||||
{ challengeId: String(body.challenge_id) },
|
||||
this.buildAuthRequestOptions(endpoint, context)
|
||||
);
|
||||
|
||||
return {
|
||||
two_factor_required: true,
|
||||
challenge_id: challenge.challengeId,
|
||||
provider_id: challenge.providerId,
|
||||
expires_at: challenge.expiresAt,
|
||||
};
|
||||
} catch (error: any) {
|
||||
throw this.toLoginActionError(error, 'Resend failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 target 을 인증 타입으로 해석합니다.
|
||||
*
|
||||
* @param target 액션 target
|
||||
* @returns 인증 타입
|
||||
*/
|
||||
private resolveAuthType(target: string): AuthType {
|
||||
return target === 'user' ? 'user' : 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 요청에 실을 globalHeaders 옵션을 만듭니다.
|
||||
*
|
||||
* 세 인증 핸들러가 같은 규칙을 공유하도록 단일 지점에 둔다 — 갈라지면 한 경로에만
|
||||
* 공통 헤더가 빠져 그 요청만 조용히 다르게 나간다.
|
||||
*
|
||||
* @param endpoint 요청 경로 (globalHeaders 패턴 매칭용, '/api' prefix 포함)
|
||||
* @param context 액션 컨텍스트
|
||||
* @returns headers 옵션 (매칭된 헤더가 없으면 undefined)
|
||||
*/
|
||||
private buildAuthRequestOptions(
|
||||
endpoint: string,
|
||||
context: ActionContext
|
||||
): { headers: Record<string, string> } | undefined {
|
||||
// Stale Closure 방지: G7Core.state.getGlobal/getLocal()로 최신 상태 조회
|
||||
// (cartKey 재발급 등 중간에 상태가 변경된 경우에도 최신 값 사용)
|
||||
const currentGlobalState = (window as any).G7Core?.state?.getGlobal?.() || context.state?._global || {};
|
||||
const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || context.state?._local || {};
|
||||
|
||||
const expressionContext: Record<string, any> = {
|
||||
_global: currentGlobalState,
|
||||
_local: currentLocalState,
|
||||
};
|
||||
|
||||
const resolved = this.getMatchingGlobalHeaders(endpoint, expressionContext);
|
||||
|
||||
return Object.keys(resolved).length > 0 ? { headers: resolved } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthManager 오류를 액션 오류로 재포장합니다.
|
||||
*
|
||||
* `code` 를 보존해야 호출자가 네트워크 실패와 HTTP 오류를 구분해 다국어 문구로
|
||||
* 안내할 수 있다 — axios 오류는 `TypeError` 가 아니다.
|
||||
*
|
||||
* @param error 원본 오류
|
||||
* @param fallbackMessage 서버 메시지가 없을 때 쓸 기본 문구
|
||||
* @returns 액션 오류
|
||||
*/
|
||||
private toLoginActionError(error: any, fallbackMessage: string): ActionError {
|
||||
// AuthManager에서 이미 API 응답 메시지를 추출한 에러를 throw하므로
|
||||
// error.message에 실제 서버 응답 메시지가 들어있음
|
||||
const errorMessage = error?.message || fallbackMessage;
|
||||
|
||||
const apiError: any = new Error(errorMessage);
|
||||
apiError.response = error?.response?.data || {};
|
||||
apiError.status = error?.status || error?.response?.status || 500;
|
||||
apiError.code = error?.code;
|
||||
|
||||
return new ActionError(errorMessage, undefined, apiError);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,27 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.65.0] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
#### 2단계 인증 로그인 단계 지원
|
||||
- 로그인 응답을 `LoginResult` 판별 유니온으로 표현 — 정상 로그인과 인증번호 요구(challenge)를 타입으로 구분 (AuthManager.ts)
|
||||
- `completeTwoFactor()` / `resendTwoFactor()` 추가 — 인증번호 확인·재발송 (AuthManager.ts)
|
||||
- `AuthConfig` 에 `twoFactorEndpoint` / `twoFactorResendEndpoint` 추가 (AuthManager.ts)
|
||||
- 액션 핸들러 `loginTwoFactor` / `loginTwoFactorResend` 추가 — 레이아웃에서 인증번호 확인·재발송 (ActionDispatcher.ts)
|
||||
- `login` 핸들러 반환에 `two_factor_required` / `challenge_id` / `provider_id` / `expires_at` 추가 (기존 `user` 필드 유지) (ActionDispatcher.ts)
|
||||
- 레이아웃 편집기 [화면 동작] 탭에 두 핸들러 등록 (coreActionRecipes.ts, ActionAddPicker.tsx)
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 2단계 인증이 켜진 사이트에서 로그인 화면이 영문 오류로 멈추던 문제
|
||||
- 로그인 응답 형태를 하나로 가정해 인증번호 요구 응답에서 `TypeError` 원문이 오류 박스에 노출되던 문제 (AuthManager.ts)
|
||||
- 문자열이 아닌 토큰이 저장되어 이후 모든 요청이 401 로 튕기던 문제 (ApiClient.ts)
|
||||
- `updateConfig({ loginEndpoint })` 로 지정한 엔드포인트가 무시되던 문제 (AuthManager.ts)
|
||||
- axios 네트워크 오류가 네트워크 실패로 판정되지 않아 영문 원문이 노출되던 문제 (networkResilience.ts, ActionDispatcher.ts)
|
||||
- 다국어 파라미터 값의 파이프 필터(`$t:key|until={{x | datetime}}`)가 평가되지 않아 문장에서 값만 사라지던 문제 (TranslationEngine.ts)
|
||||
|
||||
## [engine-v1.64.7] - 2026-09-04
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -19,6 +19,7 @@ import { fetchStaticFirst } from '../support/fetchStaticFirst';
|
||||
// 양쪽 모두 모듈 평가 시점이 아니라 메서드 실행 시점에만 서로를 참조하므로
|
||||
// live binding 이 채워진 뒤에 사용된다.
|
||||
import { dataBindingEngine } from './DataBindingEngine';
|
||||
import { hasPipes } from './PipeRegistry';
|
||||
|
||||
const logger = createLogger('TranslationEngine');
|
||||
|
||||
@@ -505,6 +506,20 @@ export class TranslationEngine {
|
||||
if (value.startsWith('{{') && value.endsWith('}}')) {
|
||||
const expression = value.slice(2, -2).trim();
|
||||
|
||||
// 파이프 필터(`{{x | datetime}}`)는 표현식 평가기가 모른다 — `|` 를 비트 연산자로
|
||||
// 읽어 평가에 실패하고, 아래 catch 가 빈 문자열을 돌려주므로 문장에서 값만 조용히
|
||||
// 사라진다("유효시간 까지"). 파이프 전용 평가기로 먼저 처리한다.
|
||||
// @since engine-v1.65.0
|
||||
if (hasPipes(expression) && dataContext) {
|
||||
try {
|
||||
const piped = dataBindingEngine.evaluatePipeExpression(expression, dataContext);
|
||||
return String(piped ?? '');
|
||||
} catch (error) {
|
||||
logger.error('Pipe expression evaluation failed:', expression, error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 복잡한 표현식인지 확인 (연산자, 괄호, 메서드 호출 등 포함)
|
||||
// 산술 연산자(+, -, *, /, %), 비교 연산자(<, >, =), 논리 연산자, 공백(피연산자 분리)도 포함
|
||||
const isComplexExpression = /[|&()[\]!?:+\-*/%<>=\s]/.test(expression);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { GlobalHeaderRule } from '../LayoutLoader';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
// AuthManager mock - login 호출 시 전달된 인자 추적
|
||||
const mockLogin = vi.fn().mockResolvedValue({ id: 1, name: 'Test User' });
|
||||
const mockLogin = vi.fn().mockResolvedValue({ status: 'authenticated', user: { id: 1, name: 'Test User' } });
|
||||
const mockLogout = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('../../auth/AuthManager', () => ({
|
||||
@@ -40,7 +40,7 @@ describe('ActionDispatcher - globalHeaders', () => {
|
||||
mockNavigate = vi.fn();
|
||||
mockGetToken.mockReset();
|
||||
mockLogin.mockReset();
|
||||
mockLogin.mockResolvedValue({ id: 1, name: 'Test User' });
|
||||
mockLogin.mockResolvedValue({ status: 'authenticated', user: { id: 1, name: 'Test User' } });
|
||||
dispatcher = new ActionDispatcher({ navigate: mockNavigate });
|
||||
Logger.getInstance().setDebug(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* ActionDispatcher 2단계 인증 핸들러 테스트
|
||||
*
|
||||
* 서버는 보안 환경설정에 따라 로그인에 **두 가지 형태의 200** 을 돌려준다. 레이아웃이
|
||||
* 그 사실을 알 통로가 없으면 인증번호 입력 단계로 넘어갈 방법이 없고, 화면은 알 수 없는
|
||||
* 오류로 멈춘다(공개 #133).
|
||||
*
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { ActionDispatcher, ActionDefinition } from '../ActionDispatcher';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
const mockLogin = vi.fn();
|
||||
const mockCompleteTwoFactor = vi.fn();
|
||||
const mockResendTwoFactor = vi.fn();
|
||||
const mockLogout = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('../../auth/AuthManager', () => ({
|
||||
AuthManager: {
|
||||
getInstance: vi.fn(() => ({
|
||||
login: mockLogin,
|
||||
logout: mockLogout,
|
||||
completeTwoFactor: mockCompleteTwoFactor,
|
||||
resendTwoFactor: mockResendTwoFactor,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/ApiClient', () => ({
|
||||
getApiClient: vi.fn(() => ({
|
||||
getToken: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('ActionDispatcher - 2단계 인증', () => {
|
||||
let dispatcher: ActionDispatcher;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogin.mockReset();
|
||||
mockCompleteTwoFactor.mockReset();
|
||||
mockResendTwoFactor.mockReset();
|
||||
dispatcher = new ActionDispatcher({ navigate: vi.fn() });
|
||||
Logger.getInstance().setDebug(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Logger.getInstance().setDebug(false);
|
||||
});
|
||||
|
||||
const context = () => ({ state: { _global: {}, _local: {} } });
|
||||
|
||||
const run = async (action: ActionDefinition) => {
|
||||
const result = await dispatcher.dispatchAction(action, context() as any);
|
||||
|
||||
if (!result.success) {
|
||||
throw result.error ?? new Error('action failed');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
describe('login', () => {
|
||||
it('challenge 응답을 레이아웃이 읽을 수 있는 형태로 돌려준다', async () => {
|
||||
mockLogin.mockResolvedValue({
|
||||
status: 'two_factor_required',
|
||||
challenge: {
|
||||
challengeId: 'challenge-uuid',
|
||||
providerId: 'g7:core.mail',
|
||||
expiresAt: '2026-09-07T14:03:00+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run({
|
||||
type: 'click',
|
||||
handler: 'login',
|
||||
target: 'user',
|
||||
params: { body: { email: 'a@b.c', password: 'pw' } },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
user: null,
|
||||
two_factor_required: true,
|
||||
challenge_id: 'challenge-uuid',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: '2026-09-07T14:03:00+09:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('정상 로그인은 종전대로 user 를 돌려준다', async () => {
|
||||
const user = { id: 1, name: 'Test User' };
|
||||
mockLogin.mockResolvedValue({ status: 'authenticated', user });
|
||||
|
||||
const result = await run({
|
||||
type: 'click',
|
||||
handler: 'login',
|
||||
target: 'user',
|
||||
params: { body: { email: 'a@b.c', password: 'pw' } },
|
||||
});
|
||||
|
||||
// 기존 레이아웃이 `response.user` 를 읽으므로 이 계약은 유지되어야 한다.
|
||||
expect(result).toMatchObject({ user, two_factor_required: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginTwoFactor', () => {
|
||||
it('challenge_id 와 code 로 로그인을 완료한다', async () => {
|
||||
const user = { id: 1, name: 'Test User' };
|
||||
mockCompleteTwoFactor.mockResolvedValue(user);
|
||||
|
||||
const result = await run({
|
||||
type: 'click',
|
||||
handler: 'loginTwoFactor',
|
||||
target: 'user',
|
||||
params: { body: { challenge_id: 'challenge-uuid', code: '135790' } },
|
||||
});
|
||||
|
||||
expect(mockCompleteTwoFactor).toHaveBeenCalledWith(
|
||||
'user',
|
||||
{ challengeId: 'challenge-uuid', code: '135790' },
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual({ user });
|
||||
});
|
||||
|
||||
it('globalHeaders 패턴이 관리자 확인 요청에도 적용된다', async () => {
|
||||
mockCompleteTwoFactor.mockResolvedValue({ id: 1 });
|
||||
dispatcher.setGlobalHeaders([
|
||||
{ pattern: '/api/auth/*', headers: { 'X-Cart-Key': 'ck_admin456' } },
|
||||
]);
|
||||
|
||||
await run({
|
||||
type: 'click',
|
||||
handler: 'loginTwoFactor',
|
||||
target: 'admin',
|
||||
params: { body: { challenge_id: 'c', code: '135790' } },
|
||||
});
|
||||
|
||||
expect(mockCompleteTwoFactor).toHaveBeenCalledWith(
|
||||
'admin',
|
||||
{ challengeId: 'c', code: '135790' },
|
||||
{ headers: { 'X-Cart-Key': 'ck_admin456' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('code 가 없으면 요청을 보내지 않는다', async () => {
|
||||
await expect(
|
||||
run({
|
||||
type: 'click',
|
||||
handler: 'loginTwoFactor',
|
||||
target: 'user',
|
||||
params: { body: { challenge_id: 'c' } },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockCompleteTwoFactor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('서버 오류 메시지를 그대로 실어 재포장한다', async () => {
|
||||
const error: any = new Error('인증번호가 올바르지 않거나 유효시간이 지났습니다.');
|
||||
error.response = { status: 401, data: { message: '인증번호가 올바르지 않거나 유효시간이 지났습니다.' } };
|
||||
error.status = 401;
|
||||
mockCompleteTwoFactor.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
run({
|
||||
type: 'click',
|
||||
handler: 'loginTwoFactor',
|
||||
target: 'user',
|
||||
params: { body: { challenge_id: 'c', code: '000000' } },
|
||||
})
|
||||
).rejects.toThrow('인증번호가 올바르지 않거나 유효시간이 지났습니다.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginTwoFactorResend', () => {
|
||||
it('새 challenge 를 돌려준다', async () => {
|
||||
mockResendTwoFactor.mockResolvedValue({
|
||||
challengeId: 'new-challenge',
|
||||
providerId: 'g7:core.mail',
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
const result = await run({
|
||||
type: 'click',
|
||||
handler: 'loginTwoFactorResend',
|
||||
target: 'user',
|
||||
params: { body: { challenge_id: 'old-challenge' } },
|
||||
});
|
||||
|
||||
expect(mockResendTwoFactor).toHaveBeenCalledWith(
|
||||
'user',
|
||||
{ challengeId: 'old-challenge' },
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual({
|
||||
two_factor_required: true,
|
||||
challenge_id: 'new-challenge',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('challenge_id 가 없으면 요청을 보내지 않는다', async () => {
|
||||
await expect(
|
||||
run({
|
||||
type: 'click',
|
||||
handler: 'loginTwoFactorResend',
|
||||
target: 'user',
|
||||
params: { body: {} },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockResendTwoFactor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -594,6 +594,9 @@ describe('TranslationEngine', () => {
|
||||
complex: {
|
||||
message: '{{user}}님이 {{action}}을 수행했습니다 ({{count}}건)',
|
||||
},
|
||||
auth: {
|
||||
valid_until: '유효시간 {{until}} 까지',
|
||||
},
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
@@ -710,6 +713,32 @@ describe('TranslationEngine', () => {
|
||||
);
|
||||
expect(result).toBe('총 100명 중 1-10명 표시');
|
||||
});
|
||||
|
||||
// 파라미터 값 안의 파이프는 **필터**다. 표현식 평가기는 `|` 를 비트 연산자로 읽어
|
||||
// 평가에 실패하고 빈 문자열을 돌려주므로, 문장에서 값만 조용히 사라진다
|
||||
// ("유효시간 까지"). 오류도 경고도 남지 않아 화면을 보지 않으면 드러나지 않는다.
|
||||
// @since engine-v1.65.0
|
||||
it('파라미터 값의 파이프 필터가 적용된다', () => {
|
||||
const result = engine.translate(
|
||||
'auth.valid_until',
|
||||
context,
|
||||
'|until={{expires_at | datetime}}',
|
||||
{ expires_at: '2026-09-07T14:03:00' }
|
||||
);
|
||||
|
||||
expect(result).toBe('유효시간 2026-09-07 14:03 까지');
|
||||
});
|
||||
|
||||
it('resolveTranslations 에서도 파라미터 파이프 필터가 적용된다', () => {
|
||||
const result = engine.resolveTranslations(
|
||||
'$t:auth.valid_until',
|
||||
context,
|
||||
{ expires_at: '2026-09-07T14:03:00' }
|
||||
);
|
||||
|
||||
// 파라미터가 없으면 자리표시자는 그대로 둔다 (기존 동작 유지)
|
||||
expect(result).toContain('유효시간');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTranslations 재귀 처리', () => {
|
||||
|
||||
+20
@@ -33,6 +33,26 @@ describe('resolveActionFailureMessage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('axios 네트워크 오류도 네트워크 계열로 안내한다', () => {
|
||||
// axios 는 TypeError 가 아니라 AxiosError 로 reject 하며 종류는 code 에만 남는다.
|
||||
// TypeError 만 보면 로그인 화면에 영문 원문(Network Error)이 그대로 노출된다.
|
||||
const axiosError: any = new Error('Network Error');
|
||||
axiosError.code = 'ERR_NETWORK';
|
||||
|
||||
expect(resolveActionFailureMessage(axiosError, 'login', undefined)).toBe(
|
||||
'$t:core.errors.network_request_failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('요청 시간 초과도 네트워크 계열로 안내한다', () => {
|
||||
const timeout: any = new Error('timeout of 30000ms exceeded');
|
||||
timeout.code = 'ECONNABORTED';
|
||||
|
||||
expect(resolveActionFailureMessage(timeout, 'login', undefined)).toBe(
|
||||
'$t:core.errors.network_request_failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('서버가 메시지를 주면 그대로 쓴다', () => {
|
||||
expect(
|
||||
resolveActionFailureMessage(new Error('boom'), 'apiCall', '유효하지 않은 레이아웃 전략입니다.')
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const EXPECTED_HANDLERS = [
|
||||
// C14~C16 데이터
|
||||
'refetchDataSource', 'appendDataSource', 'updateDataSource',
|
||||
// C17~C22 기타
|
||||
'scrollIntoView', 'login', 'logout', 'setLocale', 'emitEvent', 'apiCall',
|
||||
'scrollIntoView', 'login', 'loginTwoFactor', 'loginTwoFactorResend', 'logout', 'setLocale', 'emitEvent', 'apiCall',
|
||||
// C24~C27 제어흐름(C23 top-level if 는 엔진 처리 — 카탈로그 비포함) + C29 conditions
|
||||
'conditions', 'sequence', 'parallel', 'switch', 'suppress',
|
||||
// 결제 진입(requestPgPayment)은 코어 카탈로그에서 제외됨 — 결제는 커머스 도메인이라
|
||||
|
||||
+2
@@ -58,6 +58,8 @@ const GROUP_OF: Record<string, string> = {
|
||||
apiCall: 'data',
|
||||
scrollIntoView: 'etc',
|
||||
login: 'etc',
|
||||
loginTwoFactor: 'etc',
|
||||
loginTwoFactorResend: 'etc',
|
||||
logout: 'etc',
|
||||
setLocale: 'locale',
|
||||
emitEvent: 'etc',
|
||||
|
||||
@@ -285,6 +285,18 @@ export const CORE_ACTION_RECIPES: Record<string, ActionRecipeSpec> = {
|
||||
params: [{ key: 'body', label: `$t:${L}.login.param_body`, widget: 'key-value' }],
|
||||
build: { handler: 'login', params: { body: '{{body}}' } },
|
||||
},
|
||||
// C18-1 2단계 인증 코드 확인 (로그인 완료)
|
||||
loginTwoFactor: {
|
||||
label: `$t:${L}.login_two_factor.label`,
|
||||
params: [{ key: 'body', label: `$t:${L}.login_two_factor.param_body`, widget: 'key-value' }],
|
||||
build: { handler: 'loginTwoFactor', params: { body: '{{body}}' } },
|
||||
},
|
||||
// C18-2 2단계 인증 코드 재발송
|
||||
loginTwoFactorResend: {
|
||||
label: `$t:${L}.login_two_factor_resend.label`,
|
||||
params: [{ key: 'body', label: `$t:${L}.login_two_factor_resend.param_body`, widget: 'key-value' }],
|
||||
build: { handler: 'loginTwoFactorResend', params: { body: '{{body}}' } },
|
||||
},
|
||||
// C19 로그아웃 처리
|
||||
logout: {
|
||||
label: `$t:${L}.logout.label`,
|
||||
|
||||
@@ -59,13 +59,22 @@ export function isAbortError(error: unknown): boolean {
|
||||
* fetch 스펙상 네트워크 오류는 TypeError 로 reject 된다. 취소(AbortError)는
|
||||
* 호출부의 의도이므로 재시도 대상이 아니다.
|
||||
*
|
||||
* axios 경로(로그인 등 ApiClient 를 쓰는 요청)는 TypeError 가 아니라 `AxiosError` 로
|
||||
* reject 되며 종류는 `code` 에만 남는다. TypeError 만 보면 그 경로의 네트워크 실패가
|
||||
* 판정에서 빠져 영문 원문(`Network Error`)이 그대로 화면에 노출된다.
|
||||
*
|
||||
* @param error 검사할 에러
|
||||
* @return bool 재시도할 가치가 있는 네트워크 실패이면 true
|
||||
* @since engine-v1.53.0
|
||||
* @since engine-v1.65.0 axios 네트워크 오류 코드(ERR_NETWORK/ECONNABORTED) 인식
|
||||
*/
|
||||
export function isNetworkFailure(error: unknown): boolean {
|
||||
if (isAbortError(error)) return false;
|
||||
return error instanceof TypeError;
|
||||
if (error instanceof TypeError) return true;
|
||||
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
|
||||
return code === 'ERR_NETWORK' || code === 'ECONNABORTED';
|
||||
}
|
||||
|
||||
let unloading = false;
|
||||
|
||||
@@ -256,6 +256,9 @@ Route::prefix('auth')->group(function () {
|
||||
// 로그인과 같은 제한을 적용해 코드 대입 시도를 함께 억제한다.
|
||||
Route::post('login/two-factor', [UserAuthController::class, 'verifyTwoFactor'])
|
||||
->name('api.auth.login.two-factor');
|
||||
// 인증번호 재발송 — 기존 challenge 를 취소하고 새로 발행한다.
|
||||
Route::post('login/two-factor/resend', [UserAuthController::class, 'resendTwoFactor'])
|
||||
->name('api.auth.login.two-factor.resend');
|
||||
});
|
||||
|
||||
// 공개 인증 라우트 (세션 불필요)
|
||||
@@ -280,6 +283,13 @@ Route::prefix('auth')->group(function () {
|
||||
Route::post('login', [AdminAuthController::class, 'login'])
|
||||
->middleware(['throttle:auth-login', 'start.api.session'])
|
||||
->name('api.auth.admin.login');
|
||||
// 관리자 2단계 인증 확인·재발송 — 사용자 경로와 같은 제한을 적용한다.
|
||||
Route::post('login/two-factor', [AdminAuthController::class, 'verifyTwoFactor'])
|
||||
->middleware(['throttle:auth-login', 'start.api.session'])
|
||||
->name('api.auth.admin.login.two-factor');
|
||||
Route::post('login/two-factor/resend', [AdminAuthController::class, 'resendTwoFactor'])
|
||||
->middleware(['throttle:auth-login', 'start.api.session'])
|
||||
->name('api.auth.admin.login.two-factor.resend');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
- [ ] `_admin_base.json` 슬롯 구조(`content` 슬롯 등) 변경 시 그 슬롯에 의존하는 모든 화면(145개 레이아웃 대다수) 영향 검토
|
||||
- [ ] AdminSidebar 의 `MenuItem`/`AdminSidebarProps` 인터페이스 확장 시 이 문서의 §docs/components.md "AdminSidebar 상세" 동기화
|
||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec/` 블록을 함께 갱신 — 컴포넌트는 팔레트·역량·중첩 **넷 다** 손대야 편집기에서 온전히 동작하고, 하나만 빠지면 절반만 동작한다. 반영은 `php artisan template:update sirsoft-admin_basic --force` (편집기는 활성 디렉토리만 읽는다)
|
||||
- [ ] 로그인 화면의 2단계 인증 단계를 고쳤다면 1단계·2단계 `if` 의 상보성과 `login`/`loginTwoFactor` 의 상호배타 `if` 를 함께 확인 — 한쪽이 빠지면 인증번호 단계에서 Enter 가 새 challenge 를 발급한다
|
||||
|
||||
## 6. 금지 패턴
|
||||
|
||||
@@ -114,6 +115,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
| 필수 컴포넌트 목록 밖의 이 템플릿 전용 컴포넌트를 모듈 레이아웃에서 사용 | 필수 컴포넌트(config/template.php) 만 사용 | 다른 admin 템플릿으로 교체 시 그 화면만 깨진다 |
|
||||
| 사이드바 접힘 상태를 레이아웃 `init_actions` 로 매번 복원 | 템플릿 부트스트랩(`src/index.ts`)에서 1회 복원 | `init_actions` 는 화면 진입마다 재실행되어 불필요한 반복 처리가 된다 |
|
||||
| `_admin_base` 를 상속하는데 로그인 화면처럼 `initTheme`/메뉴 초기화를 다시 호출 | `_admin_base` 상속 화면은 이미 초기화된 전역 상태를 그대로 사용 | 중복 호출은 낭비이며, 두 초기화 지점의 결과가 어긋나면 화면 간 상태 불일치가 생긴다 |
|
||||
| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 자리에서는 `{{response.*}}` 만 읽는다 | 그 시점 컨텍스트는 아직 갱신 전이라 stale 값으로 조용히 분기한다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 7. 테스트 실행
|
||||
@@ -122,9 +124,9 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 208개 | `vitest.config.ts` |
|
||||
| Vitest | 210개 | `vitest.config.ts` |
|
||||
| Playwright | 9개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
||||
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
||||
|
||||
```bash
|
||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.9] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 인증번호의 유효 시각도 함께 표시됩니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.11 로 상향했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
|
||||
|
||||
## [1.0.8] - 2026-09-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-1.0.8-0066FF?style=flat-square" alt="version 1.0.8">
|
||||
<img src="https://img.shields.io/badge/version-1.0.9-0066FF?style=flat-square" alt="version 1.0.9">
|
||||
<img src="https://img.shields.io/badge/type-%ED%85%9C%ED%94%8C%EB%A6%BF-555555?style=flat-square" alt="type 템플릿">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 >=7.0.10">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.11-1F883D?style=flat-square" alt="그누보드7 >=7.0.11">
|
||||
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
|
||||
</p>
|
||||
<!-- @generated:badges END -->
|
||||
@@ -68,7 +68,7 @@ flowchart TD
|
||||
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 그누보드7 코어 | `>=7.0.10` |
|
||||
| 그누보드7 코어 | `>=7.0.11` |
|
||||
| PHP | `^8.2` |
|
||||
<!-- @generated:requirements END -->
|
||||
|
||||
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @file admin-login-two-factor-render.test.tsx
|
||||
* @description 관리자 로그인 2단계 인증 단계 **렌더링** 회귀 테스트 (sirsoft-admin_basic)
|
||||
*
|
||||
* 형제 파일 `admin-login-two-factor-step.test.tsx` 는 레이아웃 JSON 의 구조를 단언한다.
|
||||
* 이 파일은 그 JSON 을 실제로 렌더해 화면에 무엇이 나타나는지를 단언한다.
|
||||
*
|
||||
* 관리자가 들어갈 수 없으면 설정을 되돌릴 수단까지 사라지므로 사용자 화면보다 파급이 크다
|
||||
* (공개 #133).
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createLayoutTest } from '@/core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
|
||||
|
||||
import adminLogin from '../../layouts/admin_login.json';
|
||||
|
||||
// 공개 #133 의 원인은 레이아웃이 아니라 **응답 형태를 하나로 가정한 코어**였다. 상태를 직접
|
||||
// 주입해 그린 화면만 단언하면 그 경로를 한 번도 태우지 않으므로, 코어가 다시 challenge 응답에서
|
||||
// 던지더라도 이 파일은 초록으로 남는다. API 를 모킹해 login 액션을 실제로 통과시킨다.
|
||||
const apiPost = vi.fn();
|
||||
|
||||
vi.mock('@core/api/ApiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('@core/api/ApiClient')>(
|
||||
'@core/api/ApiClient'
|
||||
);
|
||||
const stub = {
|
||||
post: (...args: unknown[]) => apiPost(...args),
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getToken: vi.fn(() => null),
|
||||
setToken: vi.fn(),
|
||||
removeToken: vi.fn(),
|
||||
setLocale: vi.fn(),
|
||||
};
|
||||
return { ...actual, getApiClient: () => stub, createApiClient: () => stub };
|
||||
});
|
||||
|
||||
type Common = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const TestDiv: React.FC<Common & { role?: string; id?: string }> = ({ className, children, role }) => (
|
||||
<div className={className} role={role}>{children}</div>
|
||||
);
|
||||
const TestSpan: React.FC<Common> = ({ className, children, text }) => (
|
||||
<span className={className}>{children || text}</span>
|
||||
);
|
||||
const TestP: React.FC<Common & { role?: string }> = ({ className, children, text, role }) => (
|
||||
<p className={className} role={role}>{children || text}</p>
|
||||
);
|
||||
const TestH1: React.FC<Common> = ({ className, children, text }) => (
|
||||
<h1 className={className}>{children || text}</h1>
|
||||
);
|
||||
const TestLabel: React.FC<Common & { htmlFor?: string }> = ({ className, children, text, htmlFor }) => (
|
||||
<label className={className} htmlFor={htmlFor}>{children || text}</label>
|
||||
);
|
||||
const TestForm: React.FC<Common & { onSubmit?: (e: React.FormEvent) => void }> = ({
|
||||
className,
|
||||
children,
|
||||
onSubmit,
|
||||
}) => <form className={className} onSubmit={onSubmit}>{children}</form>;
|
||||
const TestButton: React.FC<Common & { type?: string; disabled?: boolean }> = ({
|
||||
type,
|
||||
className,
|
||||
disabled,
|
||||
children,
|
||||
text,
|
||||
}) => (
|
||||
<button type={type as 'button' | 'submit'} className={className} disabled={disabled}>
|
||||
{children || text}
|
||||
</button>
|
||||
);
|
||||
const TestInput: React.FC<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputMode?: string;
|
||||
autoComplete?: string;
|
||||
maxLength?: number;
|
||||
}> = ({ id, type, name, value, placeholder, disabled, className, inputMode, autoComplete, maxLength }) => (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
defaultValue={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputMode={inputMode as any}
|
||||
autoComplete={autoComplete}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
);
|
||||
const TestSelect: React.FC<{ className?: string; value?: string }> = ({ className }) => (
|
||||
<select className={className} />
|
||||
);
|
||||
const TestImg: React.FC<{ className?: string; src?: string; alt?: string }> = ({ className, src, alt }) => (
|
||||
<img className={className} src={src} alt={alt} />
|
||||
);
|
||||
const TestIcon: React.FC<{ className?: string }> = ({ className }) => <i className={className} />;
|
||||
const TestToast: React.FC = () => <div data-testid="toast-host" />;
|
||||
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
|
||||
|
||||
function setupRegistry(): ComponentRegistry {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
|
||||
(registry as any).registry = {
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
|
||||
H1: { component: TestH1, metadata: { name: 'H1', type: 'basic' } },
|
||||
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
|
||||
Form: { component: TestForm, metadata: { name: 'Form', type: 'basic' } },
|
||||
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
|
||||
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
|
||||
Select: { component: TestSelect, metadata: { name: 'Select', type: 'basic' } },
|
||||
Img: { component: TestImg, metadata: { name: 'Img', type: 'basic' } },
|
||||
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
|
||||
Toast: { component: TestToast, metadata: { name: 'Toast', type: 'composite' } },
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
};
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** init_actions 는 렌더 대상이 아니므로 제거하고 상태를 직접 준다. */
|
||||
const layout = { ...(adminLogin as any), init_actions: [] };
|
||||
|
||||
const CHALLENGE_LOCAL = {
|
||||
isLoggingIn: false,
|
||||
loginError: null,
|
||||
loginErrors: null,
|
||||
loginForm: { email: '', password: '' },
|
||||
twoFactor: {
|
||||
required: true,
|
||||
challenge_id: '9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: '2026-09-07T14:03:00+09:00',
|
||||
code: '',
|
||||
error: null,
|
||||
verifying: false,
|
||||
resending: false,
|
||||
resent: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('sirsoft-admin_basic 관리자 로그인 렌더링 — 2단계 인증', () => {
|
||||
beforeEach(() => {
|
||||
setupRegistry();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=ok, action=submit
|
||||
*
|
||||
* @effects credential_step_hidden_on_challenge
|
||||
*/
|
||||
it('초기 상태에서는 이메일·비밀번호만 보이고 인증번호 입력은 없다', async () => {
|
||||
const t = createLayoutTest(layout, {
|
||||
componentRegistry: setupRegistry(),
|
||||
initialState: { _local: { ...CHALLENGE_LOCAL, twoFactor: null } },
|
||||
});
|
||||
await t.render();
|
||||
|
||||
expect(document.querySelector('input[name="email"]')).not.toBeNull();
|
||||
expect(document.querySelector('input[name="password"]')).not.toBeNull();
|
||||
expect(document.querySelector('input[name="two_factor_code"]')).toBeNull();
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
|
||||
*/
|
||||
it('challenge 를 받으면 인증번호 입력으로 바뀌고 자격 증명 입력은 사라진다', async () => {
|
||||
const t = createLayoutTest(layout, {
|
||||
componentRegistry: setupRegistry(),
|
||||
initialState: { _local: CHALLENGE_LOCAL },
|
||||
});
|
||||
await t.render();
|
||||
|
||||
const code = document.querySelector('input[name="two_factor_code"]');
|
||||
expect(code, '챌린지 상태인데 인증번호 입력이 렌더되지 않았습니다').not.toBeNull();
|
||||
expect(code?.getAttribute('inputmode')).toBe('numeric');
|
||||
expect(code?.getAttribute('autocomplete')).toBe('one-time-code');
|
||||
|
||||
expect(document.querySelector('input[name="email"]')).toBeNull();
|
||||
expect(document.querySelector('input[name="password"]')).toBeNull();
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=403, action=submit
|
||||
*
|
||||
* @effects no_raw_typeerror_text, admin_required_message_rendered_in_code_step
|
||||
*/
|
||||
it('관리자 아님(403) 문구가 인증번호 오류 자리에 표시되고 TypeError 원문은 없다', async () => {
|
||||
const t = createLayoutTest(layout, {
|
||||
componentRegistry: setupRegistry(),
|
||||
initialState: {
|
||||
_local: {
|
||||
...CHALLENGE_LOCAL,
|
||||
twoFactor: { ...CHALLENGE_LOCAL.twoFactor, error: '관리자 권한이 필요합니다.' },
|
||||
},
|
||||
},
|
||||
});
|
||||
await t.render();
|
||||
|
||||
const body = document.body.textContent ?? '';
|
||||
|
||||
expect(body).toContain('관리자 권한이 필요합니다.');
|
||||
expect(body).not.toContain('Cannot read properties of undefined');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=401, action=submit
|
||||
*
|
||||
* @effects toast_host_mounted_on_standalone_layout
|
||||
*/
|
||||
it('독립 레이아웃이므로 Toast 호스트가 함께 렌더된다', async () => {
|
||||
const t = createLayoutTest(layout, {
|
||||
componentRegistry: setupRegistry(),
|
||||
initialState: { _local: { ...CHALLENGE_LOCAL, twoFactor: null } },
|
||||
});
|
||||
await t.render();
|
||||
|
||||
// 호스트가 없으면 안내가 성공으로 기록되고도 화면에 나타나지 않는다.
|
||||
expect(document.querySelector('[data-testid="toast-host"]')).not.toBeNull();
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* 상태 주입이 아니라 **서버 응답**에서 출발한다. 이 레이아웃의 login 액션을 그대로 실행해
|
||||
* 코어(AuthManager → 액션 반환값 → onSuccess 매핑)를 통과시킨 뒤 화면을 본다. 관리자 경로는
|
||||
* 종전에 서버가 먼저 500 을 내던 자리라 사용자 경로와 같은 강도로 잠가 둔다.
|
||||
*
|
||||
* 자격 증명 값만 리터럴로 바꾼다 — 폼 입력 값(`{{form.*}}`)은 렌더러가 들고 있고 이 하네스가
|
||||
* 관측하지 못하는 유일한 조각이기 때문이다.
|
||||
*
|
||||
* @scenario step=credentials, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge, no_raw_typeerror_text
|
||||
*/
|
||||
it('challenge 응답을 실제로 받으면 2단계로 넘어가고 오류 원문이 남지 않는다', async () => {
|
||||
apiPost.mockReset();
|
||||
apiPost.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
two_factor_required: true,
|
||||
challenge_id: '11111111-2222-3333-4444-555555555555',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: '2026-09-07T14:03:00+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
|
||||
await t.render();
|
||||
|
||||
// 이 레이아웃이 실제로 선언한 login 액션 — 자격 증명만 리터럴로 채운다.
|
||||
const form = (adminLogin as any).components[1].children[0].children[1];
|
||||
const loginAction = form.actions[0].actions.find(
|
||||
(a: any) => a.handler === 'login'
|
||||
);
|
||||
expect(loginAction, 'login 액션이 제출 시퀀스에서 사라졌습니다').toBeDefined();
|
||||
expect(loginAction.target, '관리자 로그인은 admin 대상이어야 합니다').toBe('admin');
|
||||
|
||||
await t.triggerAction({
|
||||
...loginAction,
|
||||
if: undefined,
|
||||
params: { body: { email: 'admin@example.com', password: 'pw' } },
|
||||
});
|
||||
|
||||
// 코어가 challenge 응답에서 던지면(=#133) 여기까지 오지 못한다.
|
||||
expect(apiPost).toHaveBeenCalled();
|
||||
const twoFactor = t.getState()._local?.twoFactor;
|
||||
expect(twoFactor?.required, 'challenge 응답인데 2단계 상태가 서지 않았습니다').toBe(true);
|
||||
expect(twoFactor?.challenge_id).toBe('11111111-2222-3333-4444-555555555555');
|
||||
expect(twoFactor?.code).toBe('');
|
||||
|
||||
const body = document.body.textContent ?? '';
|
||||
expect(body).not.toContain('Cannot read properties of undefined');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* 응답이 아예 없었던 실패(네트워크 끊김)는 HTTP 오류가 아니다. 그 자리에서 axios 오류 원문이나
|
||||
* 내부 식별 문구(`Failed to execute action: login`)가 새면 오류 박스에 영문이 그대로 실린다.
|
||||
*
|
||||
* @scenario step=credentials, response=network, action=submit
|
||||
*
|
||||
* @effects network_message_translated
|
||||
*/
|
||||
it('네트워크 실패는 영문 원문이 아니라 다국어 안내로 오류 박스에 실린다', async () => {
|
||||
apiPost.mockReset();
|
||||
apiPost.mockRejectedValue(Object.assign(new Error('Network Error'), {
|
||||
code: 'ERR_NETWORK',
|
||||
}));
|
||||
|
||||
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
|
||||
await t.render();
|
||||
|
||||
const form = (adminLogin as any).components[1].children[0].children[1];
|
||||
const loginAction = form.actions[0].actions.find((a: any) => a.handler === 'login');
|
||||
|
||||
await t.triggerAction({
|
||||
...loginAction,
|
||||
if: undefined,
|
||||
params: { body: { email: 'admin@example.com', password: 'pw' } },
|
||||
});
|
||||
|
||||
const loginError = String(t.getState()._local?.loginError ?? '');
|
||||
|
||||
expect(loginError).toContain('core.errors.network_request_failed');
|
||||
expect(loginError).not.toContain('Failed to execute action');
|
||||
expect(loginError).not.toBe('Network Error');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
});
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file admin-login-two-factor-step.test.tsx
|
||||
* @description 관리자 로그인 2단계 인증 단계 구조 회귀 테스트 (sirsoft-admin_basic)
|
||||
*
|
||||
* 2단계 인증이 켜진 사이트에서는 관리자 로그인도 challenge 를 받는다. 관리자가 들어갈 수
|
||||
* 없으면 설정을 되돌릴 수단까지 사라지므로 사용자 화면보다 파급이 크다(공개 #133).
|
||||
*
|
||||
* 사용자 템플릿(sirsoft-basic)의 `login-two-factor-step.test.tsx` 와 평행한 검증이며,
|
||||
* 네임스페이스만 `_local` 로 다르다 — 이 화면은 독립 레이아웃이라 전역 상태를 쓰지 않는다.
|
||||
*
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import adminLogin from '../../layouts/admin_login.json';
|
||||
|
||||
type Action = {
|
||||
event?: string;
|
||||
type?: string;
|
||||
handler?: string;
|
||||
target?: string;
|
||||
if?: string;
|
||||
params?: Record<string, any>;
|
||||
actions?: Action[];
|
||||
onSuccess?: Action[];
|
||||
onError?: Action[];
|
||||
};
|
||||
|
||||
type Node = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
if?: string;
|
||||
props?: Record<string, any>;
|
||||
children?: Node[] | string;
|
||||
text?: string;
|
||||
events?: Record<string, unknown>;
|
||||
actions?: Action[];
|
||||
};
|
||||
|
||||
function flatten(node: Node | Node[] | undefined): Node[] {
|
||||
if (!node) return [];
|
||||
if (Array.isArray(node)) return node.flatMap(flatten);
|
||||
const children = Array.isArray(node.children) ? node.children.flatMap(flatten) : [];
|
||||
return [node, ...children];
|
||||
}
|
||||
|
||||
function flattenActions(actions: Action[] | undefined): Action[] {
|
||||
if (!actions) return [];
|
||||
return actions.flatMap((a) => [
|
||||
a,
|
||||
...flattenActions(a.actions),
|
||||
...flattenActions(a.onSuccess),
|
||||
...flattenActions(a.onError),
|
||||
]);
|
||||
}
|
||||
|
||||
const nodes = flatten((adminLogin as any).components as Node[]);
|
||||
const form = nodes.find((n) => n.id === 'login_form_component');
|
||||
const formActions = flattenActions(form?.actions);
|
||||
|
||||
describe('sirsoft-admin_basic 관리자 로그인 2단계 인증 단계', () => {
|
||||
/**
|
||||
* @scenario step=code, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
|
||||
*/
|
||||
it('1단계 입력과 2단계 블록의 조건이 상보적이다', () => {
|
||||
const stepOne = nodes.filter((n) => n.if === '{{!_local.twoFactor?.required}}');
|
||||
const stepTwo = nodes.filter((n) => n.if === '{{_local.twoFactor?.required}}');
|
||||
|
||||
expect(stepOne.length).toBeGreaterThanOrEqual(3);
|
||||
expect(stepTwo.length).toBe(1);
|
||||
expect(stepTwo[0]?.id).toBe('login_two_factor_step');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_input_is_controlled_without_events_wrapper
|
||||
*/
|
||||
it('인증번호 입력이 controlled 이고 events 래퍼를 쓰지 않는다', () => {
|
||||
const codeInput = nodes.find((n) => n.id === 'login_two_factor_input');
|
||||
|
||||
expect(codeInput?.props?.value).toBe("{{_local.twoFactor?.code ?? ''}}");
|
||||
expect(codeInput?.props?.autoComplete).toBe('one-time-code');
|
||||
expect(codeInput?.props?.inputMode).toBe('numeric');
|
||||
expect(codeInput?.events).toBeUndefined();
|
||||
|
||||
const onChange = codeInput?.actions?.find((a) => a.event === 'onChange');
|
||||
expect(onChange?.handler).toBe('setState');
|
||||
expect(onChange?.params?.target).toBe('local');
|
||||
expect(onChange?.params?.['twoFactor.code']).toBe('{{$event.target.value}}');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=ok, action=enter_key
|
||||
*
|
||||
* @effects login_and_verify_are_mutually_exclusive, admin_required_message_rendered_in_code_step
|
||||
*/
|
||||
it('login 과 loginTwoFactor 가 상호배타 조건을 갖고 admin 을 대상으로 한다', () => {
|
||||
const login = formActions.find((a) => a.handler === 'login');
|
||||
const verify = formActions.find((a) => a.handler === 'loginTwoFactor');
|
||||
|
||||
expect(login?.if).toBe('{{!_local.twoFactor?.required}}');
|
||||
expect(verify?.if).toBe('{{_local.twoFactor?.required}}');
|
||||
expect(login?.target).toBe('admin');
|
||||
expect(verify?.target).toBe('admin');
|
||||
expect(verify?.params?.body?.challenge_id).toBe('{{_local.twoFactor?.challenge_id}}');
|
||||
expect(verify?.params?.body?.code).toBe('{{_local.twoFactor?.code}}');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=challenge, action=submit
|
||||
*
|
||||
* @effects no_raw_typeerror_text
|
||||
*/
|
||||
it('login 의 성공 후속 액션이 challenge 응답에서는 실행되지 않는다', () => {
|
||||
const login = formActions.find((a) => a.handler === 'login');
|
||||
const onSuccess = login?.onSuccess ?? [];
|
||||
|
||||
expect(onSuccess.length).toBeGreaterThan(0);
|
||||
for (const action of onSuccess) {
|
||||
expect(action.if, `후속 액션 ${action.handler} 에 조건이 없습니다`).toBeDefined();
|
||||
}
|
||||
|
||||
const challengeBranch = onSuccess.find((a) => a.if === '{{response.two_factor_required}}');
|
||||
expect(challengeBranch?.params?.target).toBe('local');
|
||||
expect(challengeBranch?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
|
||||
expect(JSON.stringify(challengeBranch?.params)).not.toContain('_local.twoFactor');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=ok, action=resend
|
||||
*
|
||||
* @effects resend_clears_code_and_replaces_challenge
|
||||
*/
|
||||
it('재발송 성공 시 새 challenge 로 교체하고 입력값을 비운다', () => {
|
||||
const resendButton = nodes.find((n) => n.id === 'login_two_factor_resend');
|
||||
const resendAction = flattenActions(resendButton?.actions).find(
|
||||
(a) => a.handler === 'loginTwoFactorResend'
|
||||
);
|
||||
|
||||
expect(resendAction?.target).toBe('admin');
|
||||
expect(resendAction?.params?.body?.challenge_id).toBe('{{_local.twoFactor?.challenge_id}}');
|
||||
|
||||
const success = resendAction?.onSuccess?.[0];
|
||||
expect(success?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
|
||||
expect(success?.params?.twoFactor?.code).toBe('');
|
||||
expect(success?.params?.twoFactor?.resent).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=ok, action=restart
|
||||
*
|
||||
* @effects init_actions_reset_two_factor_state, restart_resets_to_credential_step
|
||||
*/
|
||||
it('화면 진입 시 2단계 상태를 초기화한다', () => {
|
||||
const init = (adminLogin as any).init_actions as Action[];
|
||||
const reset = init.find(
|
||||
(a) => a.handler === 'setState' && a.params?.target === 'local' && 'twoFactor' in (a.params ?? {})
|
||||
);
|
||||
|
||||
expect(reset).toBeDefined();
|
||||
expect(reset?.params?.twoFactor).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=423, action=submit
|
||||
*
|
||||
* @effects locked_until_rendered
|
||||
*/
|
||||
it('계정 잠금 해제 시각을 렌더하는 지점이 있다', () => {
|
||||
const lockedUntil = nodes.find((n) => n.id === 'login_error_locked_until');
|
||||
const permanent = nodes.find((n) => n.id === 'login_error_locked_permanent');
|
||||
|
||||
expect(lockedUntil?.text).toContain('$t:auth.login.locked_until');
|
||||
expect(permanent?.text).toBe('$t:auth.login.locked_permanent');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=401, action=submit
|
||||
*
|
||||
* @effects toast_host_mounted_on_standalone_layout
|
||||
*/
|
||||
it('Toast 호스트가 마운트되어 있다', () => {
|
||||
// 독립 레이아웃이라 베이스가 호스트를 주입하지 않는다 — 없으면 안내가 조용히 사라진다.
|
||||
expect(nodes.some((n) => n.name === 'Toast')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -215,6 +215,30 @@
|
||||
`admin_reset_password`)에서만 호출됩니다. 사이드바 접힘 상태 복원(`initSidebar`)은 레이아웃이
|
||||
아니라 템플릿 부트스트랩(`src/index.ts`)에서 직접 호출됩니다. `_admin_base` 를 고칠 때 이
|
||||
문서의 낡은 구조를 그대로 믿지 말고 실제 JSON 을 확인하세요.
|
||||
|
||||
### `admin_login.json` 의 2단계 인증(인증번호) 단계
|
||||
|
||||
보안 설정에서 2단계 인증을 켠 사이트에서는 관리자 로그인 응답도 두 형태로 갈립니다 — 정상
|
||||
로그인과 인증번호 요구(challenge)입니다. `admin_login.json` 은 그 둘을 같은 카드 안에서 단계
|
||||
전환으로 처리합니다. 이 화면은 `_admin_base` 를 상속하지 않는 독립 레이아웃이라 모달을 쓸 수
|
||||
없고, Toast 호스트도 이 레이아웃이 직접 마운트합니다.
|
||||
|
||||
- 1단계(이메일·비밀번호) 블록은 `if: "{{!_local.twoFactor?.required}}"`, 2단계(인증번호) 블록은
|
||||
그 부정형입니다. 두 `if` 는 언제나 상보여야 합니다. 상태는 `_global` 이 아니라 **`_local`** 입니다.
|
||||
- 제출 시퀀스의 `login` 과 `loginTwoFactor` 도 같은 쌍으로 상호배타입니다. `loginTwoFactor` 쪽
|
||||
`if` 가 빠지면 인증번호 단계에서 Enter 를 눌렀을 때 새 challenge 가 발급되어 흐름이 끊깁니다.
|
||||
- 인증번호 입력은 controlled 입니다(`value` + `onChange` 의 `setState` 쌍). `events: {}` 래퍼는
|
||||
쓰지 않습니다.
|
||||
- 화면 진입 시 `init_actions` 의 `setState` 가 `twoFactor` 를 `null` 로 되돌립니다.
|
||||
- 관리자가 아닌 계정이 인증번호를 맞춰도 서버가 403(`auth.admin_required`)으로 거부하고 이미
|
||||
발급한 토큰을 회수합니다. 그 문구는 `loginTwoFactor` 의 `onError` 가 인증번호 오류 자리에 그대로
|
||||
싣습니다 — 별도 분기를 두지 않습니다.
|
||||
- 만료 시각은 정적 표기입니다(`| datetime`). 카운트다운을 쓰지 않습니다.
|
||||
|
||||
**금지 — 같은 시퀀스·onSuccess 안에서 방금 저장한 상태를 다시 읽지 않습니다**
|
||||
|
||||
`setState` 직후 형제 액션의 `if` 나 값으로 그 상태를 재독하면 갱신 이전 값을 읽습니다. 그 자리에서는
|
||||
`{{response.*}}` 만 씁니다. 오류도 경고도 남지 않고 분기만 조용히 어긋납니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 라우트 매핑
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
"processing": "Processing...",
|
||||
"remember": "Remember me",
|
||||
"forgot": "Forgot your password?",
|
||||
"two_factor": {
|
||||
"sent": "We sent a verification code. Enter it to finish signing in.",
|
||||
"resent": "We sent a new verification code. Enter the new code.",
|
||||
"code_label": "Verification code",
|
||||
"code_placeholder": "Enter the code you received",
|
||||
"valid_until": "Valid until {{until}}",
|
||||
"verify": "Verify code",
|
||||
"verifying": "Verifying...",
|
||||
"resend": "Resend code",
|
||||
"restart": "Start over"
|
||||
},
|
||||
"locked_until": "Unlocks at {{until}}",
|
||||
"locked_permanent": "Contact an administrator to unlock the account.",
|
||||
"error": {
|
||||
"email_required": "Please enter your email.",
|
||||
"email_invalid": "Please enter a valid email address.",
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
"processing": "처리 중...",
|
||||
"remember": "로그인 상태 유지",
|
||||
"forgot": "비밀번호를 잊으셨나요?",
|
||||
"two_factor": {
|
||||
"sent": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
|
||||
"resent": "인증번호를 다시 보냈습니다. 새로 받은 번호를 입력해주세요.",
|
||||
"code_label": "인증번호",
|
||||
"code_placeholder": "받은 인증번호를 입력하세요",
|
||||
"valid_until": "유효시간 {{until}} 까지",
|
||||
"verify": "인증번호 확인",
|
||||
"verifying": "확인 중...",
|
||||
"resend": "인증번호 다시 받기",
|
||||
"restart": "처음부터"
|
||||
},
|
||||
"locked_until": "해제 예정: {{until}}",
|
||||
"locked_permanent": "관리자에게 문의해 잠금을 해제해주세요.",
|
||||
"error": {
|
||||
"email_required": "이메일을 입력해주세요.",
|
||||
"email_invalid": "올바른 이메일 주소를 입력해주세요.",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"isLoggingIn": false,
|
||||
"loginError": null,
|
||||
"loginErrors": null,
|
||||
"twoFactor": null,
|
||||
"loginForm": {
|
||||
"email": "",
|
||||
"password": ""
|
||||
@@ -149,6 +150,27 @@
|
||||
"className": "text-sm text-red-600 dark:text-red-400"
|
||||
},
|
||||
"text": "{{_local.loginError}}"
|
||||
},
|
||||
{
|
||||
"comment": "계정 잠금 해제 시각 — 언제 다시 시도할 수 있는지 알려주지 않으면 계속 눌러 보게 된다",
|
||||
"id": "login_error_locked_until",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_local.loginErrors?.locked_until}}",
|
||||
"props": {
|
||||
"className": "mt-1 text-sm text-red-600 dark:text-red-400"
|
||||
},
|
||||
"text": "$t:auth.login.locked_until|until={{_local.loginErrors?.locked_until | datetime}}"
|
||||
},
|
||||
{
|
||||
"id": "login_error_locked_permanent",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_local.loginErrors?.permanent === true}}",
|
||||
"props": {
|
||||
"className": "mt-1 text-sm text-red-600 dark:text-red-400"
|
||||
},
|
||||
"text": "$t:auth.login.locked_permanent"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -157,6 +179,7 @@
|
||||
"id": "login_email_field",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!_local.twoFactor?.required}}",
|
||||
"children": [
|
||||
{
|
||||
"id": "login_email_label",
|
||||
@@ -200,6 +223,7 @@
|
||||
"id": "login_password_field",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!_local.twoFactor?.required}}",
|
||||
"children": [
|
||||
{
|
||||
"id": "login_password_label",
|
||||
@@ -243,6 +267,7 @@
|
||||
"id": "login_forgot_password",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!_local.twoFactor?.required}}",
|
||||
"props": {
|
||||
"className": "flex-center justify-end"
|
||||
},
|
||||
@@ -273,6 +298,7 @@
|
||||
"id": "login_submit_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{!_local.twoFactor?.required}}",
|
||||
"props": {
|
||||
"type": "submit",
|
||||
"className": "flex-center w-full px-4 py-3 rounded-lg font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 justify-center gap-2",
|
||||
@@ -303,6 +329,241 @@
|
||||
"text": "$t:auth.login.processing"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "2단계 인증 — 비밀번호 확인만 통과한 상태다. 인증번호를 확인해야 로그인이 완료된다.",
|
||||
"id": "login_two_factor_step",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.twoFactor?.required}}",
|
||||
"props": { "className": "space-y-6" },
|
||||
"children": [
|
||||
{
|
||||
"id": "login_two_factor_notice",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800",
|
||||
"role": "status",
|
||||
"aria-live": "polite"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "login_two_factor_notice_sent",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{!_local.twoFactor?.resent}}",
|
||||
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
|
||||
"text": "$t:auth.login.two_factor.sent"
|
||||
},
|
||||
{
|
||||
"id": "login_two_factor_notice_resent",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_local.twoFactor?.resent}}",
|
||||
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
|
||||
"text": "$t:auth.login.two_factor.resent"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "login_two_factor_error",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.twoFactor?.error}}",
|
||||
"props": { "className": "alert-danger", "role": "alert" },
|
||||
"children": [
|
||||
{
|
||||
"id": "login_two_factor_error_text",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-sm text-red-600 dark:text-red-400" },
|
||||
"text": "{{_local.twoFactor?.error ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "login_two_factor_field",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"id": "login_two_factor_label",
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "htmlFor": "two_factor_code", "className": "form-label" },
|
||||
"text": "$t:auth.login.two_factor.code_label"
|
||||
},
|
||||
{
|
||||
"comment": "자동바인딩을 쓰지 않는다 — 재발송 시 입력값을 비워야 하므로 상태가 값을 소유한다",
|
||||
"id": "login_two_factor_input",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"id": "two_factor_code",
|
||||
"type": "text",
|
||||
"name": "two_factor_code",
|
||||
"value": "{{_local.twoFactor?.code ?? ''}}",
|
||||
"inputMode": "numeric",
|
||||
"autoComplete": "one-time-code",
|
||||
"maxLength": 10,
|
||||
"placeholder": "$t:auth.login.two_factor.code_placeholder",
|
||||
"className": "input py-3 text-center text-xl tracking-widest font-mono",
|
||||
"disabled": "{{_local.isLoggingIn}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "onChange",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"twoFactor.code": "{{$event.target.value}}",
|
||||
"twoFactor.error": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "유효시각 정적 표기 — 카운트다운은 SPA 이탈 후에도 타이머가 남아 쓰지 않는다",
|
||||
"id": "login_two_factor_valid_until",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_local.twoFactor?.expires_at}}",
|
||||
"props": { "className": "mt-2 text-sm text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:auth.login.two_factor.valid_until|until={{_local.twoFactor?.expires_at | datetime}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "인증번호 확인 — submit 이라 Enter 키로도 제출된다",
|
||||
"id": "login_two_factor_submit",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "submit",
|
||||
"className": "flex-center w-full px-4 py-3 rounded-lg font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 justify-center gap-2",
|
||||
"disabled": "{{!_local.twoFactor?.code || _local.twoFactor.code.length < 4 || _local.isLoggingIn}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "login_two_factor_spinner",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.isLoggingIn}}",
|
||||
"props": { "className": "w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" }
|
||||
},
|
||||
{
|
||||
"id": "login_two_factor_submit_text",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!_local.isLoggingIn}}",
|
||||
"text": "$t:auth.login.two_factor.verify"
|
||||
},
|
||||
{
|
||||
"id": "login_two_factor_submit_processing",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{_local.isLoggingIn}}",
|
||||
"text": "$t:auth.login.two_factor.verifying"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "login_two_factor_actions",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-col sm:flex-row gap-2" },
|
||||
"children": [
|
||||
{
|
||||
"id": "login_two_factor_resend",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-1 px-4 py-3 rounded-lg font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer",
|
||||
"disabled": "{{_local.twoFactor?.resending || _local.isLoggingIn}}"
|
||||
},
|
||||
"text": "$t:auth.login.two_factor.resend",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"twoFactor.resending": true,
|
||||
"twoFactor.error": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "loginTwoFactorResend",
|
||||
"target": "admin",
|
||||
"params": {
|
||||
"body": { "challenge_id": "{{_local.twoFactor?.challenge_id}}" }
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"_comment": "같은 시퀀스 안에서는 상태가 아직 갱신 전이므로 응답 값만 읽는다.",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"twoFactor": {
|
||||
"required": true,
|
||||
"challenge_id": "{{response.challenge_id}}",
|
||||
"provider_id": "{{response.provider_id}}",
|
||||
"expires_at": "{{response.expires_at}}",
|
||||
"code": "",
|
||||
"error": null,
|
||||
"verifying": false,
|
||||
"resending": false,
|
||||
"resent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"twoFactor.resending": false,
|
||||
"twoFactor.error": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "login_two_factor_restart",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-1 px-4 py-3 rounded-lg font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer",
|
||||
"disabled": "{{_local.isLoggingIn}}"
|
||||
},
|
||||
"text": "$t:auth.login.two_factor.restart",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"twoFactor": null,
|
||||
"loginError": null,
|
||||
"loginErrors": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -320,8 +581,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "1·2단계는 상호배타다. if 는 시퀀스 시작 시점 스냅샷으로 평가되므로 한 제출에 정확히 하나만 실행된다 — 이 if 가 빠지면 인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨진다.",
|
||||
"handler": "login",
|
||||
"target": "admin",
|
||||
"if": "{{!_local.twoFactor?.required}}",
|
||||
"params": {
|
||||
"body": {
|
||||
"email": "{{_local.loginForm.email}}",
|
||||
@@ -330,7 +593,28 @@
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"_comment": "2단계 인증이 켜진 사이트는 아직 로그인이 끝나지 않았다 — 토큰도 사용자도 없다.",
|
||||
"handler": "setState",
|
||||
"if": "{{response.two_factor_required}}",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isLoggingIn": false,
|
||||
"twoFactor": {
|
||||
"required": true,
|
||||
"challenge_id": "{{response.challenge_id}}",
|
||||
"provider_id": "{{response.provider_id}}",
|
||||
"expires_at": "{{response.expires_at}}",
|
||||
"code": "",
|
||||
"error": null,
|
||||
"verifying": false,
|
||||
"resending": false,
|
||||
"resent": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "setState",
|
||||
"if": "{{!response.two_factor_required}}",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isLoggingIn": false
|
||||
@@ -338,6 +622,7 @@
|
||||
},
|
||||
{
|
||||
"handler": "navigate",
|
||||
"if": "{{!response.two_factor_required}}",
|
||||
"params": {
|
||||
"path": "{{query.redirect ?? '/admin'}}"
|
||||
}
|
||||
@@ -354,6 +639,45 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "loginTwoFactor",
|
||||
"target": "admin",
|
||||
"if": "{{_local.twoFactor?.required}}",
|
||||
"params": {
|
||||
"body": {
|
||||
"challenge_id": "{{_local.twoFactor?.challenge_id}}",
|
||||
"code": "{{_local.twoFactor?.code}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isLoggingIn": false,
|
||||
"twoFactor": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{query.redirect ?? '/admin'}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"_comment": "423 잠금은 1단계와 같은 문구 규칙을 따르도록 loginErrors 도 함께 싣는다. 403(관리자 아님)도 이 자리에 표시된다.",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isLoggingIn": false,
|
||||
"twoFactor.error": "{{error.message}}",
|
||||
"loginErrors": "{{error.errors}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sirsoft-admin_basic",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sirsoft-admin_basic",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sirsoft-admin_basic",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"description": "Gnuboard7 Basic Admin Template Components",
|
||||
"type": "module",
|
||||
"main": "dist/components.js",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "Admin Basic",
|
||||
"en": "Admin Basic"
|
||||
},
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 기본 관리자 템플릿",
|
||||
@@ -22,7 +22,7 @@
|
||||
"url": "https://sirsoft.com"
|
||||
},
|
||||
"release_date": "2026-05-15",
|
||||
"g7_version": ">=7.0.10",
|
||||
"g7_version": ">=7.0.11",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# audit:allow test-scenario-coverage reason: response 축(ok/challenge/401/403/423/429/503/network)은 서버 응답
|
||||
# 종류를 열거한 것이라 레이아웃 구조 테스트가 케이스별로 나눠 단언할 실체가 아니다 — 구조 테스트는 화면이
|
||||
# 그 응답들을 받을 수 있는 형태인지(상보 조건·controlled 입력·상호배타 액션)를 본다. 실제 회귀 가드는
|
||||
# effects 이며 전부 @effects 로 귀속되어 있다(미검증 0건). 응답별 화면 실측은 Playwright spec 이 담당한다.
|
||||
|
||||
feature: 관리자 로그인 화면 2단계 인증 단계 (sirsoft-admin_basic)
|
||||
|
||||
description: |
|
||||
관리자 로그인도 2단계 인증 대상이다. 관리자가 들어갈 수 없으면 설정을 되돌릴 수단까지
|
||||
사라지므로 사용자 화면보다 파급이 크다(공개 #133).
|
||||
|
||||
사용자 템플릿과 평행한 구조이며 네임스페이스만 _local 로 다르다 — 이 화면은 독립 레이아웃이라
|
||||
전역 상태를 쓰지 않는다. 관리자가 아닌 계정의 403 도 인증번호 오류 자리에 표시된다.
|
||||
|
||||
axes:
|
||||
step: [credentials, code]
|
||||
response: [ok, challenge, 401, 403, 423, 429, 503, network]
|
||||
action: [submit, resend, restart, enter_key]
|
||||
|
||||
exclusions:
|
||||
- { step: credentials, action: resend, reason: "재발송 버튼은 인증번호 단계에만 있다" }
|
||||
- { step: credentials, action: restart, reason: "동일" }
|
||||
- { step: code, response: challenge, reason: "challenge 는 비밀번호 단계의 응답이다" }
|
||||
|
||||
effects:
|
||||
- no_raw_typeerror_text
|
||||
- code_step_rendered_on_challenge
|
||||
- credential_step_hidden_on_challenge
|
||||
- login_and_verify_are_mutually_exclusive
|
||||
- code_input_is_controlled_without_events_wrapper
|
||||
- resend_clears_code_and_replaces_challenge
|
||||
- restart_resets_to_credential_step
|
||||
- locked_until_rendered
|
||||
- init_actions_reset_two_factor_state
|
||||
- admin_required_message_rendered_in_code_step
|
||||
- toast_host_mounted_on_standalone_layout
|
||||
- network_message_translated
|
||||
|
||||
test_files:
|
||||
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-login-two-factor-step.test.tsx
|
||||
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-login-two-factor-render.test.tsx
|
||||
- tests/Playwright/specs/auth/two-factor-login.spec.ts
|
||||
@@ -135,6 +135,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
||||
- [ ] TSX/TS 를 고쳤다면 `template:build --production` 후 `dist/` 동반 커밋 (`sourceMappingURL` 잔존 금지)
|
||||
- [ ] 프론트엔드 변경은 Playwright spec 동반 — 단위 테스트만으로는 화면 회귀가 드러나지 않는다
|
||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec/` 블록을 함께 갱신 — 컴포넌트는 팔레트·역량·중첩 **넷 다** 손대야 편집기에서 온전히 동작하고, 하나만 빠지면 절반만 동작한다. 반영은 `php artisan template:update sirsoft-basic --force` (편집기는 활성 디렉토리만 읽는다)
|
||||
- [ ] 로그인 화면의 2단계 인증 단계를 고쳤다면 1단계·2단계 `if` 의 상보성과 `login`/`loginTwoFactor` 의 상호배타 `if` 를 함께 확인 — 한쪽이 빠지면 인증번호 단계에서 Enter 가 새 challenge 를 발급한다
|
||||
|
||||
## 6. 금지 패턴
|
||||
|
||||
@@ -150,6 +151,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
||||
| 새 컴포넌트가 텍스트를 담는 prop 을 추가하면서 `seo-config.json` 을 그대로 두기 | `text_props` 에 그 prop 추가 | 봇 화면에서만 그 글자가 사라진다 — 사람 눈에는 정상이라 검색 노출이 줄어든 뒤에야 드러난다 |
|
||||
| 레이아웃 JSON 에 빌드된 CSS 에 없는 Tailwind 클래스 사용 | 기존 레이아웃에 쓰인 클래스이거나 빌드 산출물에 존재하는지 확인 | 그 스타일만 조용히 빠져 화면이 어긋난다 |
|
||||
| `dist/` 재빌드 없이 `src/` 만 고치고 커밋 | `template:build --production` 후 `dist/` 동반 커밋 | 브라우저가 받는 것은 커밋된 `dist/` 다 — 소스 수정이 사문화된다 |
|
||||
| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 자리에서는 `{{response.*}}` 만 읽는다 | 그 시점 컨텍스트는 아직 갱신 전이라 stale 값으로 조용히 분기한다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 7. 테스트 실행
|
||||
@@ -158,9 +160,9 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 145개 | `vitest.config.ts` |
|
||||
| Vitest | 147개 | `vitest.config.ts` |
|
||||
| Playwright | 8개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
||||
| 시나리오 매니페스트 | 4개 | `tests/scenarios` |
|
||||
|
||||
```bash
|
||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.4] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 인증번호의 유효 시각도 함께 표시됩니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.11 로 상향했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
|
||||
|
||||
## [1.1.3] - 2026-09-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-1.1.3-0066FF?style=flat-square" alt="version 1.1.3">
|
||||
<img src="https://img.shields.io/badge/version-1.1.4-0066FF?style=flat-square" alt="version 1.1.4">
|
||||
<img src="https://img.shields.io/badge/type-%ED%85%9C%ED%94%8C%EB%A6%BF-555555?style=flat-square" alt="type 템플릿">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 >=7.0.10">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.11-1F883D?style=flat-square" alt="그누보드7 >=7.0.11">
|
||||
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
|
||||
<img src="https://img.shields.io/badge/requires-sirsoft--board-BF8700?style=flat-square" alt="requires sirsoft-board">
|
||||
<img src="https://img.shields.io/badge/requires-sirsoft--ecommerce-BF8700?style=flat-square" alt="requires sirsoft-ecommerce">
|
||||
@@ -87,7 +87,7 @@ flowchart LR
|
||||
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 그누보드7 코어 | `>=7.0.10` |
|
||||
| 그누보드7 코어 | `>=7.0.11` |
|
||||
| PHP | `^8.2` |
|
||||
| 의존 모듈 | `sirsoft-board` `>=1.0.0` |
|
||||
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* @file login-two-factor-render.test.tsx
|
||||
* @description 로그인 2단계 인증 단계 **렌더링** 회귀 테스트 (sirsoft-basic)
|
||||
*
|
||||
* 형제 파일 `login-two-factor-step.test.tsx` 는 레이아웃 JSON 의 구조를 단언한다.
|
||||
* 이 파일은 그 JSON 을 **실제로 렌더해** 화면에 무엇이 나타나는지를 단언한다 — 구조가
|
||||
* 맞아도 조건식이 어긋나면 두 단계가 함께 보이거나 아무것도 보이지 않을 수 있고,
|
||||
* 그 차이는 구조 단언으로 드러나지 않는다.
|
||||
*
|
||||
* 핵심 회귀: challenge 응답을 받은 상태에서
|
||||
* - 인증번호 입력이 화면에 있고 이메일·비밀번호는 사라진다
|
||||
* - 오류 박스에 영문 TypeError 원문이 없다 (공개 #133 의 증상)
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createLayoutTest } from '@/core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
|
||||
|
||||
import loginForm from '../../layouts/partials/auth/_login_form.json';
|
||||
|
||||
// 공개 #133 의 원인은 레이아웃이 아니라 **응답 형태를 하나로 가정한 코어**였다. 상태를 직접
|
||||
// 주입해 그린 화면만 단언하면 그 경로를 한 번도 태우지 않으므로, 코어가 다시 challenge 응답에서
|
||||
// 던지더라도 이 파일은 초록으로 남는다. API 를 모킹해 login 액션을 실제로 통과시킨다.
|
||||
const apiPost = vi.fn();
|
||||
|
||||
vi.mock('@core/api/ApiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('@core/api/ApiClient')>(
|
||||
'@core/api/ApiClient'
|
||||
);
|
||||
const stub = {
|
||||
post: (...args: unknown[]) => apiPost(...args),
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getToken: vi.fn(() => null),
|
||||
setToken: vi.fn(),
|
||||
removeToken: vi.fn(),
|
||||
setLocale: vi.fn(),
|
||||
};
|
||||
return { ...actual, getApiClient: () => stub, createApiClient: () => stub };
|
||||
});
|
||||
|
||||
// ========== 테스트용 컴포넌트 ==========
|
||||
|
||||
type Common = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const TestDiv: React.FC<Common & { role?: string }> = ({ className, children, role }) => (
|
||||
<div className={className} role={role}>{children}</div>
|
||||
);
|
||||
|
||||
const TestSpan: React.FC<Common> = ({ className, children, text }) => (
|
||||
<span className={className}>{children || text}</span>
|
||||
);
|
||||
|
||||
const TestP: React.FC<Common & { role?: string }> = ({ className, children, text, role }) => (
|
||||
<p className={className} role={role}>{children || text}</p>
|
||||
);
|
||||
|
||||
const TestLabel: React.FC<Common> = ({ className, children, text }) => (
|
||||
<label className={className}>{children || text}</label>
|
||||
);
|
||||
|
||||
const TestForm: React.FC<Common & { onSubmit?: (e: React.FormEvent) => void }> = ({
|
||||
className,
|
||||
children,
|
||||
onSubmit,
|
||||
}) => (
|
||||
<form className={className} onSubmit={onSubmit}>{children}</form>
|
||||
);
|
||||
|
||||
const TestButton: React.FC<Common & { type?: string; disabled?: boolean }> = ({
|
||||
type,
|
||||
className,
|
||||
disabled,
|
||||
children,
|
||||
text,
|
||||
}) => (
|
||||
<button type={type as 'button' | 'submit'} className={className} disabled={disabled}>
|
||||
{children || text}
|
||||
</button>
|
||||
);
|
||||
|
||||
const TestInput: React.FC<{
|
||||
type?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputMode?: string;
|
||||
autoComplete?: string;
|
||||
maxLength?: number;
|
||||
}> = ({ type, name, value, placeholder, disabled, className, inputMode, autoComplete, maxLength }) => (
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
defaultValue={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputMode={inputMode as any}
|
||||
autoComplete={autoComplete}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
);
|
||||
|
||||
const TestPasswordInput: React.FC<{ name?: string; disabled?: boolean; className?: string }> = ({
|
||||
name,
|
||||
disabled,
|
||||
className,
|
||||
}) => <input type="password" name={name} disabled={disabled} className={className} />;
|
||||
|
||||
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
|
||||
|
||||
function setupRegistry(): ComponentRegistry {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
|
||||
(registry as any).registry = {
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
|
||||
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
|
||||
Form: { component: TestForm, metadata: { name: 'Form', type: 'basic' } },
|
||||
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
|
||||
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
|
||||
PasswordInput: { component: TestPasswordInput, metadata: { name: 'PasswordInput', type: 'basic' } },
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
};
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** 실제 파셜을 단독 레이아웃으로 감싼다 — 컴포넌트 트리는 그대로다. */
|
||||
const layout = {
|
||||
version: '1.0.0',
|
||||
layout_name: 'auth/login',
|
||||
components: [loginForm as any],
|
||||
};
|
||||
|
||||
/** 챌린지를 받은 뒤의 전역 상태 */
|
||||
const CHALLENGE_STATE = {
|
||||
_global: {
|
||||
twoFactor: {
|
||||
required: true,
|
||||
challenge_id: '9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: '2026-09-07T14:03:00+09:00',
|
||||
code: '',
|
||||
error: null,
|
||||
verifying: false,
|
||||
resending: false,
|
||||
resent: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('sirsoft-basic 로그인 폼 렌더링 — 2단계 인증', () => {
|
||||
beforeEach(() => {
|
||||
setupRegistry();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=ok, action=submit
|
||||
*
|
||||
* @effects credential_step_hidden_on_challenge
|
||||
*/
|
||||
it('초기 상태에서는 이메일·비밀번호만 보이고 인증번호 입력은 없다', async () => {
|
||||
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
|
||||
await t.render();
|
||||
|
||||
expect(document.querySelector('input[name="email"]')).not.toBeNull();
|
||||
expect(document.querySelector('input[name="password"]')).not.toBeNull();
|
||||
expect(document.querySelector('input[name="two_factor_code"]')).toBeNull();
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
|
||||
*/
|
||||
it('challenge 를 받으면 인증번호 입력으로 바뀌고 자격 증명 입력은 사라진다', async () => {
|
||||
const t = createLayoutTest(layout, {
|
||||
componentRegistry: setupRegistry(),
|
||||
initialState: CHALLENGE_STATE,
|
||||
});
|
||||
await t.render();
|
||||
|
||||
const code = document.querySelector('input[name="two_factor_code"]');
|
||||
expect(code, '챌린지 상태인데 인증번호 입력이 렌더되지 않았습니다').not.toBeNull();
|
||||
expect(code?.getAttribute('inputmode')).toBe('numeric');
|
||||
expect(code?.getAttribute('autocomplete')).toBe('one-time-code');
|
||||
|
||||
// 두 단계가 함께 보이면 사용자가 어느 쪽을 채워야 하는지 알 수 없다.
|
||||
expect(document.querySelector('input[name="email"]')).toBeNull();
|
||||
expect(document.querySelector('input[name="password"]')).toBeNull();
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=401, action=submit
|
||||
*
|
||||
* @effects no_raw_typeerror_text
|
||||
*/
|
||||
it('오류 문구 자리에 영문 TypeError 원문이 나타나지 않는다', async () => {
|
||||
const t = createLayoutTest(layout, {
|
||||
componentRegistry: setupRegistry(),
|
||||
initialState: {
|
||||
_global: {
|
||||
...CHALLENGE_STATE._global,
|
||||
twoFactor: {
|
||||
...CHALLENGE_STATE._global.twoFactor,
|
||||
error: '인증번호가 올바르지 않거나 유효시간이 지났습니다.',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await t.render();
|
||||
|
||||
const body = document.body.textContent ?? '';
|
||||
|
||||
expect(body).toContain('인증번호가 올바르지 않거나 유효시간이 지났습니다.');
|
||||
// 공개 #133 의 증상 — 응답 형태를 하나로 가정했을 때 화면에 그대로 실리던 문구다.
|
||||
expect(body).not.toContain('Cannot read properties of undefined');
|
||||
expect(body).not.toContain('undefined is not an object');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=423, action=submit
|
||||
*
|
||||
* @effects locked_until_rendered
|
||||
*/
|
||||
it('계정이 잠기면 해제 시각 줄이 함께 렌더된다', async () => {
|
||||
const t = createLayoutTest(layout, {
|
||||
componentRegistry: setupRegistry(),
|
||||
initialState: {
|
||||
_global: {
|
||||
loginError: '로그인 시도 횟수 초과로 계정이 잠겼습니다.',
|
||||
loginErrors: { locked_until: '2026-09-07T14:05:00+09:00', permanent: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
await t.render();
|
||||
|
||||
// 잠금 안내와 별개로 해제 시각 줄이 존재해야 한다 (문구 자체는 다국어 키 해석 대상).
|
||||
const paragraphs = Array.from(document.querySelectorAll('p'));
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(document.body.textContent ?? '').toContain('로그인 시도 횟수 초과로 계정이 잠겼습니다.');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* 상태 주입이 아니라 **서버 응답**에서 출발한다. 이 레이아웃의 login 액션을 그대로 실행해
|
||||
* 코어(AuthManager → 액션 반환값 → onSuccess 매핑)를 통과시킨 뒤 화면을 본다.
|
||||
*
|
||||
* 자격 증명 값만 리터럴로 바꾼다 — 폼 입력 값(`{{form.*}}`)은 렌더러가 들고 있고 이 하네스가
|
||||
* 관측하지 못하는 유일한 조각이기 때문이다. 그 밖의 경로는 전부 실물이다.
|
||||
*
|
||||
* @scenario step=credentials, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge, no_raw_typeerror_text
|
||||
*/
|
||||
it('challenge 응답을 실제로 받으면 2단계로 넘어가고 오류 원문이 남지 않는다', async () => {
|
||||
apiPost.mockReset();
|
||||
apiPost.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
two_factor_required: true,
|
||||
challenge_id: '11111111-2222-3333-4444-555555555555',
|
||||
provider_id: 'g7:core.mail',
|
||||
expires_at: '2026-09-07T14:03:00+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
|
||||
await t.render();
|
||||
|
||||
// 이 레이아웃이 실제로 선언한 login 액션 — 자격 증명만 리터럴로 채운다.
|
||||
const submitSequence = (loginForm as any).actions[0];
|
||||
const loginAction = submitSequence.actions.find(
|
||||
(a: any) => a.handler === 'login'
|
||||
);
|
||||
expect(loginAction, 'login 액션이 제출 시퀀스에서 사라졌습니다').toBeDefined();
|
||||
|
||||
await t.triggerAction({
|
||||
...loginAction,
|
||||
if: undefined,
|
||||
params: { body: { email: 'user@example.com', password: 'pw' } },
|
||||
});
|
||||
|
||||
// 코어가 challenge 응답에서 던지면(=#133) 여기까지 오지 못한다.
|
||||
expect(apiPost).toHaveBeenCalled();
|
||||
const twoFactor = t.getState()._global?.twoFactor;
|
||||
expect(twoFactor?.required, 'challenge 응답인데 2단계 상태가 서지 않았습니다').toBe(true);
|
||||
expect(twoFactor?.challenge_id).toBe('11111111-2222-3333-4444-555555555555');
|
||||
expect(twoFactor?.code).toBe('');
|
||||
|
||||
await t.rerender();
|
||||
|
||||
expect(document.querySelector('input[name="two_factor_code"]')).not.toBeNull();
|
||||
expect(document.querySelector('input[name="email"]')).toBeNull();
|
||||
|
||||
const body = document.body.textContent ?? '';
|
||||
expect(body).not.toContain('Cannot read properties of undefined');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* 응답이 아예 없었던 실패(네트워크 끊김)는 HTTP 오류가 아니다. 그 자리에서 axios 오류 원문이나
|
||||
* 내부 식별 문구(`Failed to execute action: login`)가 새면 오류 박스에 영문이 그대로 실린다.
|
||||
* 로그인 화면의 오류 문구는 사용자가 읽는 유일한 안내라 그 판정이 곧 화면 품질이다.
|
||||
*
|
||||
* @scenario step=credentials, response=network, action=submit
|
||||
*
|
||||
* @effects network_message_translated
|
||||
*/
|
||||
it('네트워크 실패는 영문 원문이 아니라 다국어 안내로 오류 박스에 실린다', async () => {
|
||||
apiPost.mockReset();
|
||||
apiPost.mockRejectedValue(Object.assign(new Error('Network Error'), {
|
||||
code: 'ERR_NETWORK',
|
||||
}));
|
||||
|
||||
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
|
||||
await t.render();
|
||||
|
||||
const loginAction = (loginForm as any).actions[0].actions.find(
|
||||
(a: any) => a.handler === 'login'
|
||||
);
|
||||
|
||||
await t.triggerAction({
|
||||
...loginAction,
|
||||
if: undefined,
|
||||
params: { body: { email: 'user@example.com', password: 'pw' } },
|
||||
});
|
||||
|
||||
const loginError = String(t.getState()._global?.loginError ?? '');
|
||||
|
||||
// 다국어 안내로 해석되는 값이어야 한다 — 번역기가 붙지 않은 하네스에서는 그 키가 남는다.
|
||||
expect(loginError).toContain('core.errors.network_request_failed');
|
||||
// 판정이 무너지면 이 두 형태 중 하나가 그대로 화면에 실린다.
|
||||
expect(loginError).not.toContain('Failed to execute action');
|
||||
expect(loginError).not.toBe('Network Error');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file login-two-factor-step.test.tsx
|
||||
* @description 로그인 2단계 인증 단계 구조 회귀 테스트 (sirsoft-basic)
|
||||
*
|
||||
* 2단계 인증이 켜진 사이트에서 서버는 로그인에 **두 가지 형태의 200** 을 돌려준다.
|
||||
* 화면이 한 형태만 가정하면 인증번호 요구 응답에서 영문 오류가 노출되고 로그인이
|
||||
* 불가능해진다(공개 #133).
|
||||
*
|
||||
* 검증 대상:
|
||||
* 1. 1단계·2단계 블록의 `if` 가 상보적이다 (동시 노출 금지)
|
||||
* 2. 인증번호 입력이 controlled — `value` + `onChange` 쌍, `events:{}` 래퍼 없음
|
||||
* 3. `login` 과 `loginTwoFactor` 가 상호배타 `if` 를 갖는다
|
||||
* 4. 재발송 onSuccess 가 새 challenge 로 교체하고 입력값을 비운다
|
||||
* 5. `login` 의 성공 후속 액션이 challenge 응답에서는 실행되지 않는다
|
||||
* 6. 로그인 화면 진입 시 2단계 상태가 초기화된다
|
||||
*
|
||||
* @since engine-v1.65.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import loginForm from '../../layouts/partials/auth/_login_form.json';
|
||||
import loginPage from '../../layouts/auth/login.json';
|
||||
|
||||
type Action = {
|
||||
event?: string;
|
||||
type?: string;
|
||||
handler?: string;
|
||||
target?: string;
|
||||
if?: string;
|
||||
params?: Record<string, any>;
|
||||
actions?: Action[];
|
||||
onSuccess?: Action[];
|
||||
onError?: Action[];
|
||||
};
|
||||
|
||||
type Node = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
if?: string;
|
||||
props?: Record<string, any>;
|
||||
children?: Node[] | string;
|
||||
text?: string;
|
||||
events?: Record<string, unknown>;
|
||||
actions?: Action[];
|
||||
};
|
||||
|
||||
/** 트리를 평탄화한다. */
|
||||
function flatten(node: Node | Node[] | undefined): Node[] {
|
||||
if (!node) return [];
|
||||
if (Array.isArray(node)) return node.flatMap(flatten);
|
||||
const children = Array.isArray(node.children) ? node.children.flatMap(flatten) : [];
|
||||
return [node, ...children];
|
||||
}
|
||||
|
||||
/** 시퀀스를 평탄화해 모든 액션을 모은다. */
|
||||
function flattenActions(actions: Action[] | undefined): Action[] {
|
||||
if (!actions) return [];
|
||||
return actions.flatMap((a) => [
|
||||
a,
|
||||
...flattenActions(a.actions),
|
||||
...flattenActions(a.onSuccess),
|
||||
...flattenActions(a.onError),
|
||||
]);
|
||||
}
|
||||
|
||||
const nodes = flatten(loginForm as unknown as Node);
|
||||
const allActions = flattenActions((loginForm as unknown as Node).actions);
|
||||
|
||||
describe('sirsoft-basic 로그인 2단계 인증 단계', () => {
|
||||
/**
|
||||
* @scenario step=code, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
|
||||
*/
|
||||
it('1단계 입력과 2단계 블록의 조건이 상보적이다', () => {
|
||||
// 두 블록이 같은 조건을 쓰면 인증번호 단계에서 이메일·비밀번호가 함께 보인다.
|
||||
const stepOne = nodes.filter((n) => n.if === '{{!_global.twoFactor?.required}}');
|
||||
const stepTwo = nodes.filter((n) => n.if === '{{_global.twoFactor?.required}}');
|
||||
|
||||
expect(stepOne.length).toBeGreaterThanOrEqual(3);
|
||||
expect(stepTwo.length).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=challenge, action=submit
|
||||
*
|
||||
* @effects code_input_is_controlled_without_events_wrapper
|
||||
*/
|
||||
it('인증번호 입력이 controlled 이고 events 래퍼를 쓰지 않는다', () => {
|
||||
const codeInput = nodes.find((n) => n.props?.name === 'two_factor_code');
|
||||
|
||||
expect(codeInput).toBeDefined();
|
||||
expect(codeInput?.props?.value).toBe("{{_global.twoFactor?.code ?? ''}}");
|
||||
expect(codeInput?.props?.autoComplete).toBe('one-time-code');
|
||||
expect(codeInput?.props?.inputMode).toBe('numeric');
|
||||
// 값의 소유자가 상태여야 재발송 시 입력값을 비울 수 있다.
|
||||
expect(codeInput?.events).toBeUndefined();
|
||||
|
||||
const onChange = codeInput?.actions?.find((a) => a.event === 'onChange');
|
||||
expect(onChange?.handler).toBe('setState');
|
||||
expect(onChange?.params?.['twoFactor.code']).toBe('{{$event.target.value}}');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=ok, action=enter_key
|
||||
*
|
||||
* @effects login_and_verify_are_mutually_exclusive
|
||||
*/
|
||||
it('login 과 loginTwoFactor 가 상호배타 조건을 갖는다', () => {
|
||||
const login = allActions.find((a) => a.handler === 'login');
|
||||
const verify = allActions.find((a) => a.handler === 'loginTwoFactor');
|
||||
|
||||
expect(login?.if).toBe('{{!_global.twoFactor?.required}}');
|
||||
expect(verify?.if).toBe('{{_global.twoFactor?.required}}');
|
||||
// 조건이 빠지면 인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급된다.
|
||||
expect(login?.target).toBe('user');
|
||||
expect(verify?.target).toBe('user');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=ok, action=submit
|
||||
*
|
||||
* @effects no_raw_typeerror_text
|
||||
*/
|
||||
it('loginTwoFactor 가 challenge_id 와 code 를 함께 보낸다', () => {
|
||||
const verify = allActions.find((a) => a.handler === 'loginTwoFactor');
|
||||
|
||||
expect(verify?.params?.body?.challenge_id).toBe('{{_global.twoFactor?.challenge_id}}');
|
||||
expect(verify?.params?.body?.code).toBe('{{_global.twoFactor?.code}}');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=challenge, action=submit
|
||||
*
|
||||
* @effects credential_step_hidden_on_challenge
|
||||
*/
|
||||
it('login 의 성공 후속 액션이 challenge 응답에서는 실행되지 않는다', () => {
|
||||
const login = allActions.find((a) => a.handler === 'login');
|
||||
const onSuccess = login?.onSuccess ?? [];
|
||||
|
||||
expect(onSuccess.length).toBeGreaterThan(0);
|
||||
for (const action of onSuccess) {
|
||||
// 조건이 없는 후속 액션이 하나라도 남으면 인증 전에 홈으로 이동하거나
|
||||
// 빈 사용자 정보가 전역 상태에 실린다.
|
||||
expect(action.if, `후속 액션 ${action.handler} 에 조건이 없습니다`).toBeDefined();
|
||||
}
|
||||
|
||||
const challengeBranch = onSuccess.find((a) => a.if === '{{response.two_factor_required}}');
|
||||
expect(challengeBranch?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
|
||||
// 같은 시퀀스 안에서는 _global 이 아직 갱신 전이므로 응답 값만 읽어야 한다.
|
||||
expect(JSON.stringify(challengeBranch?.params)).not.toContain('_global.twoFactor');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=code, response=ok, action=resend
|
||||
*
|
||||
* @effects resend_clears_code_and_replaces_challenge
|
||||
*/
|
||||
it('재발송 성공 시 새 challenge 로 교체하고 입력값을 비운다', () => {
|
||||
const resend = allActions.find((a) => a.handler === 'loginTwoFactorResend');
|
||||
expect(resend).toBeUndefined();
|
||||
|
||||
// 재발송은 버튼 노드의 액션에 있다 — 폼 submit 시퀀스가 아니다.
|
||||
const resendButton = nodes.find((n) =>
|
||||
flattenActions(n.actions).some((a) => a.handler === 'loginTwoFactorResend')
|
||||
);
|
||||
const resendAction = flattenActions(resendButton?.actions).find(
|
||||
(a) => a.handler === 'loginTwoFactorResend'
|
||||
);
|
||||
|
||||
expect(resendAction?.target).toBe('user');
|
||||
expect(resendAction?.params?.body?.challenge_id).toBe('{{_global.twoFactor?.challenge_id}}');
|
||||
|
||||
const success = resendAction?.onSuccess?.[0];
|
||||
expect(success?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
|
||||
// 이전 코드가 남아 있으면 새 코드를 받았는데 옛 코드로 제출된다.
|
||||
expect(success?.params?.twoFactor?.code).toBe('');
|
||||
expect(success?.params?.twoFactor?.resent).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=ok, action=restart
|
||||
*
|
||||
* @effects init_actions_reset_two_factor_state, restart_resets_to_credential_step
|
||||
*/
|
||||
it('로그인 화면 진입 시 2단계 상태를 초기화한다', () => {
|
||||
// 전역 상태라 화면을 떠나도 남는다 — 리셋이 없으면 다시 들어왔을 때 1단계가 보이지 않는다.
|
||||
const init = (loginPage as any).init_actions as Action[];
|
||||
const reset = init.find(
|
||||
(a) => a.handler === 'setState' && a.params?.target === 'global' && 'twoFactor' in (a.params ?? {})
|
||||
);
|
||||
|
||||
expect(reset).toBeDefined();
|
||||
expect(reset?.params?.twoFactor).toBeNull();
|
||||
expect(reset?.if).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario step=credentials, response=423, action=submit
|
||||
*
|
||||
* @effects locked_until_rendered
|
||||
*/
|
||||
it('계정 잠금 해제 시각을 렌더하는 지점이 있다', () => {
|
||||
const lockedUntil = nodes.find((n) => n.if === '{{_global.loginErrors?.locked_until}}');
|
||||
const permanent = nodes.find((n) => n.if === '{{_global.loginErrors?.permanent === true}}');
|
||||
|
||||
expect(lockedUntil?.text).toContain('$t:auth.locked_until');
|
||||
expect(permanent?.text).toBe('$t:auth.locked_permanent');
|
||||
});
|
||||
});
|
||||
@@ -572,6 +572,30 @@ slots.content:
|
||||
- `navigate` — 성공 후 홈/대시보드로 이동
|
||||
- `setState` — 폼 에러, 로딩 상태
|
||||
|
||||
**2단계 인증(인증번호) 단계**
|
||||
|
||||
관리자가 보안 설정에서 2단계 인증을 켠 사이트에서는 로그인 응답이 두 형태로 갈립니다 —
|
||||
정상 로그인과 인증번호 요구(challenge)입니다. `_login_form.json` 은 그 둘을 **같은 카드 안에서**
|
||||
단계 전환으로 처리합니다.
|
||||
|
||||
- 1단계(이메일·비밀번호) 블록은 `if: "{{!_global.twoFactor?.required}}"`, 2단계(인증번호) 블록은
|
||||
그 부정형을 답니다. 두 `if` 는 언제나 상보여야 합니다 — 한쪽만 고치면 두 단계가 겹쳐 보이거나
|
||||
둘 다 사라집니다.
|
||||
- 제출 시퀀스의 `login` 과 `loginTwoFactor` 도 같은 쌍으로 상호배타입니다. `loginTwoFactor` 쪽
|
||||
`if` 가 빠지면 인증번호 단계에서 Enter 를 눌렀을 때 새 challenge 가 발급되어 흐름이 끊깁니다.
|
||||
- 인증번호 입력은 controlled 입니다(`value` + `onChange` 의 `setState` 쌍). `events: {}` 래퍼는
|
||||
쓰지 않습니다.
|
||||
- 화면 진입 시 `auth/login.json` 의 `init_actions` 첫 액션이 `twoFactor` 를 `null` 로 되돌립니다.
|
||||
전역 상태라 화면을 떠나도 남기 때문에, 이 리셋이 없으면 다시 들어왔을 때 1단계가 보이지 않습니다.
|
||||
- 만료 시각은 정적 표기입니다(`| datetime`). 카운트다운을 쓰지 않습니다 — `startInterval` 은 등록
|
||||
시점 컨텍스트를 고정하고 정지 호출처가 없어 화면을 떠난 뒤에도 남습니다.
|
||||
|
||||
**금지 — 같은 시퀀스·onSuccess 안에서 방금 저장한 상태를 다시 읽지 않습니다**
|
||||
|
||||
`setState target:global` 직후 형제 액션의 `if` 나 값으로 `_global.*` 를 재독하면 갱신 이전 값을
|
||||
읽습니다(시퀀스 처리기는 `_local` 만 갱신합니다). 그 자리에서는 `{{response.*}}` 만 씁니다.
|
||||
오류도 경고도 남지 않고 분기만 조용히 어긋나므로 화면을 보지 않으면 드러나지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
#### 게시판 패턴
|
||||
|
||||
@@ -150,5 +150,18 @@
|
||||
"minTypes": "At least {{count}} types (uppercase, lowercase, number, special)"
|
||||
}
|
||||
},
|
||||
"two_factor": {
|
||||
"sent": "We sent a verification code. Enter it to finish signing in.",
|
||||
"resent": "We sent a new verification code. Enter the new code.",
|
||||
"code_label": "Verification code",
|
||||
"code_placeholder": "Enter the code you received",
|
||||
"valid_until": "Valid until {{until}}",
|
||||
"verify": "Verify code",
|
||||
"verifying": "Verifying...",
|
||||
"resend": "Resend code",
|
||||
"restart": "Start over"
|
||||
},
|
||||
"locked_until": "Unlocks at {{until}}",
|
||||
"locked_permanent": "Contact an administrator to unlock your account.",
|
||||
"session_expired_toast": "Your session has expired. Please log in again."
|
||||
}
|
||||
@@ -150,5 +150,18 @@
|
||||
"minTypes": "{{count}}가지 이상 조합 (대/소문자, 숫자, 특수문자)"
|
||||
}
|
||||
},
|
||||
"two_factor": {
|
||||
"sent": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
|
||||
"resent": "인증번호를 다시 보냈습니다. 새로 받은 번호를 입력해주세요.",
|
||||
"code_label": "인증번호",
|
||||
"code_placeholder": "받은 인증번호를 입력하세요",
|
||||
"valid_until": "유효시간 {{until}} 까지",
|
||||
"verify": "인증번호 확인",
|
||||
"verifying": "확인 중...",
|
||||
"resend": "인증번호 다시 받기",
|
||||
"restart": "처음부터"
|
||||
},
|
||||
"locked_until": "해제 예정: {{until}}",
|
||||
"locked_permanent": "관리자에게 문의해 잠금을 해제해주세요.",
|
||||
"session_expired_toast": "세션이 만료되었습니다. 다시 로그인해 주세요."
|
||||
}
|
||||
@@ -7,6 +7,11 @@
|
||||
"guest_only": true
|
||||
},
|
||||
"init_actions": [
|
||||
{
|
||||
"_comment": "2단계 인증 단계 상태는 전역이라 화면을 떠나도 남는다 — 진입할 때마다 1단계로 되돌린다.",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "twoFactor": null }
|
||||
},
|
||||
{
|
||||
"if": "{{query?.reason === 'session_expired'}}",
|
||||
"handler": "toast",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "로그인 폼 (이메일/비밀번호)"
|
||||
"description": "로그인 폼 (1단계 이메일/비밀번호 → 2단계 인증번호)"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Form",
|
||||
@@ -21,13 +21,29 @@
|
||||
"name": "P",
|
||||
"props": { "className": "text-sm text-red-600 dark:text-red-400" },
|
||||
"text": "{{_global.loginError}}"
|
||||
},
|
||||
{
|
||||
"comment": "계정 잠금 해제 시각 — 언제 다시 시도할 수 있는지 알려주지 않으면 사용자는 계속 눌러 보게 된다",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_global.loginErrors?.locked_until}}",
|
||||
"props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
|
||||
"text": "$t:auth.locked_until|until={{_global.loginErrors?.locked_until | datetime}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_global.loginErrors?.permanent === true}}",
|
||||
"props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
|
||||
"text": "$t:auth.locked_permanent"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "이메일 입력 필드",
|
||||
"comment": "이메일 입력 필드 (1단계)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!_global.twoFactor?.required}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -58,9 +74,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "비밀번호 입력 필드",
|
||||
"comment": "비밀번호 입력 필드 (1단계)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!_global.twoFactor?.required}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -84,8 +101,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "로그인 버튼 (1단계)",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{!_global.twoFactor?.required}}",
|
||||
"props": {
|
||||
"type": "submit",
|
||||
"className": "w-full py-3 mt-12 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2",
|
||||
@@ -112,6 +131,223 @@
|
||||
"text": "$t:auth.login_processing"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "2단계 인증 — 비밀번호 확인만 통과한 상태다. 인증번호를 확인해야 로그인이 완료된다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.twoFactor?.required}}",
|
||||
"props": { "className": "space-y-6" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800",
|
||||
"role": "status",
|
||||
"aria-live": "polite"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{!_global.twoFactor?.resent}}",
|
||||
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
|
||||
"text": "$t:auth.two_factor.sent"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_global.twoFactor?.resent}}",
|
||||
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
|
||||
"text": "$t:auth.two_factor.resent"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "인증번호 오류",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.twoFactor?.error}}",
|
||||
"props": {
|
||||
"className": "p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800",
|
||||
"role": "alert"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-sm text-red-600 dark:text-red-400" },
|
||||
"text": "{{_global.twoFactor?.error ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3" },
|
||||
"text": "$t:auth.two_factor.code_label"
|
||||
},
|
||||
{
|
||||
"comment": "자동바인딩을 쓰지 않는다 — 재발송 시 입력값을 비워야 하므로 상태가 값을 소유한다",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "two_factor_code",
|
||||
"value": "{{_global.twoFactor?.code ?? ''}}",
|
||||
"inputMode": "numeric",
|
||||
"autoComplete": "one-time-code",
|
||||
"maxLength": 10,
|
||||
"placeholder": "$t:auth.two_factor.code_placeholder",
|
||||
"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-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200",
|
||||
"disabled": "{{_global.isLoggingIn}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "onChange",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"twoFactor.code": "{{$event.target.value}}",
|
||||
"twoFactor.error": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "유효시각 정적 표기 — 남은 시간 카운트다운은 SPA 이탈 후에도 타이머가 남아 쓰지 않는다",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{_global.twoFactor?.expires_at}}",
|
||||
"props": { "className": "mt-2 text-xs text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:auth.two_factor.valid_until|until={{_global.twoFactor?.expires_at | datetime}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "인증번호 확인 — submit 이라 Enter 키로도 제출된다",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "submit",
|
||||
"className": "w-full py-3 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2",
|
||||
"disabled": "{{!_global.twoFactor?.code || _global.twoFactor.code.length < 4 || _global.isLoggingIn}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.isLoggingIn}}",
|
||||
"props": { "className": "w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!_global.isLoggingIn}}",
|
||||
"text": "$t:auth.two_factor.verify"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{_global.isLoggingIn}}",
|
||||
"text": "$t:auth.two_factor.verifying"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-col sm:flex-row gap-2" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-1 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
|
||||
"disabled": "{{_global.twoFactor?.resending || _global.isLoggingIn}}"
|
||||
},
|
||||
"text": "$t:auth.two_factor.resend",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "twoFactor.resending": true, "twoFactor.error": null }
|
||||
},
|
||||
{
|
||||
"handler": "loginTwoFactorResend",
|
||||
"target": "user",
|
||||
"params": {
|
||||
"body": { "challenge_id": "{{_global.twoFactor?.challenge_id}}" }
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"_comment": "같은 시퀀스 안에서는 _global 이 아직 갱신 전이므로 응답 값만 읽는다.",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"twoFactor": {
|
||||
"required": true,
|
||||
"challenge_id": "{{response.challenge_id}}",
|
||||
"provider_id": "{{response.provider_id}}",
|
||||
"expires_at": "{{response.expires_at}}",
|
||||
"code": "",
|
||||
"error": null,
|
||||
"verifying": false,
|
||||
"resending": false,
|
||||
"resent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"twoFactor.resending": false,
|
||||
"twoFactor.error": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-1 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
|
||||
"disabled": "{{_global.isLoggingIn}}"
|
||||
},
|
||||
"text": "$t:auth.two_factor.restart",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"twoFactor": null,
|
||||
"loginError": null,
|
||||
"loginErrors": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -136,8 +372,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "1·2단계는 상호배타다. if 는 시퀀스 시작 시점 스냅샷으로 평가되므로 한 제출에 정확히 하나만 실행된다 — 이 if 가 빠지면 인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨진다.",
|
||||
"handler": "login",
|
||||
"target": "user",
|
||||
"if": "{{!_global.twoFactor?.required}}",
|
||||
"params": {
|
||||
"body": {
|
||||
"email": "{{form.email}}",
|
||||
@@ -145,18 +383,68 @@
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{ "handler": "setState", "params": { "target": "global", "currentUser": "{{response.user}}" } },
|
||||
{ "handler": "setState", "params": { "target": "global", "isLoggingIn": false } },
|
||||
{
|
||||
"_comment": "2단계 인증이 켜진 사이트는 아직 로그인이 끝나지 않았다 — 토큰도 사용자도 없다.",
|
||||
"handler": "setState",
|
||||
"if": "{{response.two_factor_required}}",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"twoFactor": {
|
||||
"required": true,
|
||||
"challenge_id": "{{response.challenge_id}}",
|
||||
"provider_id": "{{response.provider_id}}",
|
||||
"expires_at": "{{response.expires_at}}",
|
||||
"code": "",
|
||||
"error": null,
|
||||
"verifying": false,
|
||||
"resending": false,
|
||||
"resent": false
|
||||
},
|
||||
"isLoggingIn": false
|
||||
}
|
||||
},
|
||||
{ "handler": "setState", "if": "{{!response.two_factor_required}}", "params": { "target": "global", "currentUser": "{{response.user}}" } },
|
||||
{ "handler": "setState", "if": "{{!response.two_factor_required}}", "params": { "target": "global", "isLoggingIn": false } },
|
||||
{
|
||||
"_comment": "로그인 직전에 비회원으로 발급받은 주문 조회 토큰은 회원 컨텍스트에 무효 — 잔존 시 동일 브라우저로 회원/비회원 페이지 동시 열람 가능. sessionStorage 3개 키 (token/orderNumber/expiresAt) 와 _global.guestOrderToken 을 한 번에 정리하는 모듈 커스텀 핸들러 사용.",
|
||||
"handler": "clearGuestOrderToken"
|
||||
"handler": "clearGuestOrderToken",
|
||||
"if": "{{!response.two_factor_required}}"
|
||||
},
|
||||
{ "handler": "toast", "params": { "type": "success", "message": "$t:auth.login_success" } },
|
||||
{ "handler": "navigate", "params": { "path": "{{query.redirect ?? '/'}}" } }
|
||||
{ "handler": "toast", "if": "{{!response.two_factor_required}}", "params": { "type": "success", "message": "$t:auth.login_success" } },
|
||||
{ "handler": "navigate", "if": "{{!response.two_factor_required}}", "params": { "path": "{{query.redirect ?? '/'}}" } }
|
||||
],
|
||||
"onError": [
|
||||
{ "handler": "setState", "params": { "target": "global", "isLoggingIn": false, "loginError": "{{error.message}}", "loginErrors": "{{error.errors}}" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "loginTwoFactor",
|
||||
"target": "user",
|
||||
"if": "{{_global.twoFactor?.required}}",
|
||||
"params": {
|
||||
"body": {
|
||||
"challenge_id": "{{_global.twoFactor?.challenge_id}}",
|
||||
"code": "{{_global.twoFactor?.code}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{ "handler": "setState", "params": { "target": "global", "currentUser": "{{response.user}}", "isLoggingIn": false, "twoFactor": null } },
|
||||
{ "handler": "clearGuestOrderToken" },
|
||||
{ "handler": "toast", "params": { "type": "success", "message": "$t:auth.login_success" } },
|
||||
{ "handler": "navigate", "params": { "path": "{{query.redirect ?? '/'}}" } }
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"_comment": "423 잠금은 1단계와 같은 문구 규칙을 따르도록 loginErrors 도 함께 싣는다.",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"isLoggingIn": false,
|
||||
"twoFactor.error": "{{error.message}}",
|
||||
"loginErrors": "{{error.errors}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
|
||||
"type": "module",
|
||||
"main": "dist/components.js",
|
||||
|
||||
@@ -166,10 +166,12 @@ const loginFormFixture = {
|
||||
],
|
||||
},
|
||||
// 이메일 입력 필드
|
||||
// 2단계 인증이 요구되면 자격 증명 입력은 숨는다 — 실제 `_login_form.json` 과 같은 조건.
|
||||
{
|
||||
id: 'email-field',
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
if: '{{!_global.twoFactor?.required}}',
|
||||
props: { 'data-testid': 'email-field' },
|
||||
children: [
|
||||
{
|
||||
@@ -197,6 +199,7 @@ const loginFormFixture = {
|
||||
id: 'password-field',
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
if: '{{!_global.twoFactor?.required}}',
|
||||
props: { 'data-testid': 'password-field' },
|
||||
children: [
|
||||
{
|
||||
@@ -224,6 +227,7 @@ const loginFormFixture = {
|
||||
id: 'submit-button',
|
||||
type: 'basic',
|
||||
name: 'Button',
|
||||
if: '{{!_global.twoFactor?.required}}',
|
||||
props: {
|
||||
type: 'submit',
|
||||
className: 'w-full py-3 mt-12 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2',
|
||||
@@ -457,4 +461,48 @@ describe('로그인 폼 레이아웃 렌더링 (Issue #72)', () => {
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
// 2단계 인증이 켜진 사이트에서는 로그인 응답이 인증번호 요구로 갈린다. 그때 자격 증명 입력이
|
||||
// 남아 있으면 두 단계가 겹쳐 보이고, 사용자가 이메일·비밀번호를 다시 제출해 새 challenge 를
|
||||
// 발급받는다. 이 fixture 의 `if` 는 실제 `_login_form.json` 과 같은 조건이므로, 한쪽이 바뀌면
|
||||
// 이 케이스가 red 가 된다.
|
||||
describe('2단계 인증 단계 전환', () => {
|
||||
it('twoFactor 가 없으면 자격 증명 입력이 보인다', async () => {
|
||||
// Given
|
||||
const testUtils = createLayoutTest(loginFormFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: {} },
|
||||
});
|
||||
|
||||
// When
|
||||
await testUtils.render();
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId('email-field')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('password-field')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('login-submit-btn')).toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('인증번호가 요구되면 자격 증명 입력과 제출 버튼이 사라진다', async () => {
|
||||
// Given
|
||||
const testUtils = createLayoutTest(loginFormFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: {
|
||||
_global: { twoFactor: { required: true, challenge_id: 'c-1', code: '' } },
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await testUtils.render();
|
||||
|
||||
// Then
|
||||
expect(screen.queryByTestId('email-field')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('password-field')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('login-submit-btn')).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "Basic",
|
||||
"en": "Basic"
|
||||
},
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 기본 사용자 템플릿",
|
||||
@@ -22,7 +22,7 @@
|
||||
"url": "https://sirsoft.com"
|
||||
},
|
||||
"release_date": "2026-01-07",
|
||||
"g7_version": ">=7.0.10",
|
||||
"g7_version": ">=7.0.11",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-board": ">=1.0.0",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# audit:allow test-scenario-coverage reason: response 축(ok/challenge/401/403/423/429/503/network)은 서버 응답
|
||||
# 종류를 열거한 것이라 레이아웃 구조 테스트가 케이스별로 나눠 단언할 실체가 아니다 — 구조 테스트는 화면이
|
||||
# 그 응답들을 받을 수 있는 형태인지(상보 조건·controlled 입력·상호배타 액션)를 본다. 실제 회귀 가드는
|
||||
# effects 이며 전부 @effects 로 귀속되어 있다(미검증 0건). 응답별 화면 실측은 Playwright spec 이 담당한다.
|
||||
|
||||
feature: 로그인 화면 2단계 인증 단계 (sirsoft-basic)
|
||||
|
||||
description: |
|
||||
2단계 인증이 켜진 사이트에서 로그인 화면은 비밀번호 단계와 인증번호 단계를 같은 카드 안에서
|
||||
전환한다. 서버가 두 가지 형태의 200 을 돌려주므로, 화면이 한 형태만 가정하면 영문 오류가
|
||||
노출되고 로그인이 불가능해진다(공개 #133).
|
||||
|
||||
핵심 동작:
|
||||
- 1단계 블록과 2단계 블록의 조건이 상보적이다 (동시 노출 금지).
|
||||
- 제출 시퀀스의 login 과 loginTwoFactor 가 상호배타 조건을 갖는다. 조건이 빠지면
|
||||
인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨진다.
|
||||
- 인증번호 입력은 상태가 값을 소유한다 (재발송 시 입력을 비워야 하므로).
|
||||
- 인증 단계 상태는 전역이라 화면 진입 시 초기화한다.
|
||||
|
||||
axes:
|
||||
step: [credentials, code]
|
||||
response: [ok, challenge, 401, 423, 429, 503, network]
|
||||
action: [submit, resend, restart, enter_key]
|
||||
|
||||
exclusions:
|
||||
- { step: credentials, action: resend, reason: "재발송 버튼은 인증번호 단계에만 있다" }
|
||||
- { step: credentials, action: restart, reason: "동일" }
|
||||
- { step: code, response: challenge, reason: "challenge 는 비밀번호 단계의 응답이다" }
|
||||
|
||||
effects:
|
||||
- no_raw_typeerror_text
|
||||
- code_step_rendered_on_challenge
|
||||
- credential_step_hidden_on_challenge
|
||||
- login_and_verify_are_mutually_exclusive
|
||||
- code_input_is_controlled_without_events_wrapper
|
||||
- resend_clears_code_and_replaces_challenge
|
||||
- restart_resets_to_credential_step
|
||||
- locked_until_rendered
|
||||
- init_actions_reset_two_factor_state
|
||||
- network_message_translated
|
||||
|
||||
test_files:
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/login-two-factor-step.test.tsx
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/login-two-factor-render.test.tsx
|
||||
- tests/Playwright/specs/auth/two-factor-login.spec.ts
|
||||
@@ -127,7 +127,7 @@ class LoginThrottleTest extends TestCase
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function login_attempt_enabled_가_OFF_이면_무제한_시도_허용된다(): void
|
||||
public function login_attempt_enabled_가_off_이면_무제한_시도_허용된다(): void
|
||||
{
|
||||
$this->setSecuritySettings([
|
||||
'login_attempt_enabled' => false,
|
||||
@@ -190,7 +190,7 @@ class LoginThrottleTest extends TestCase
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function 존재하지_않는_이메일은_DB에_카운트_저장되지_않는다(): void
|
||||
public function 존재하지_않는_이메일은_db에_카운트_저장되지_않는다(): void
|
||||
{
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'ghost@example.com',
|
||||
@@ -202,6 +202,41 @@ class LoginThrottleTest extends TestCase
|
||||
$this->assertDatabaseMissing('users', ['email' => 'ghost@example.com']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*
|
||||
* @scenario two_factor=off, controller=user, delivery=sent
|
||||
*
|
||||
* @effects too_many_attempts_message_translated
|
||||
*/
|
||||
public function 분당_요청_상한을_넘으면_다국어_문구로_응답한다(): void
|
||||
{
|
||||
// 기본 응답은 영문 "Too Many Attempts." 이다 — 로그인 화면이 그 문구를 그대로
|
||||
// 노출하므로, 한국어 사이트에서 영문 원문이 오류 박스에 뜬다.
|
||||
$limit = max(30, 3 * 6);
|
||||
|
||||
for ($i = 0; $i <= $limit; $i++) {
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'ghost@example.com',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() === 429) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$response->assertStatus(429);
|
||||
$this->assertNotSame('Too Many Attempts.', $response->json('message'));
|
||||
$this->assertStringNotContainsString(':seconds', (string) $response->json('message'));
|
||||
$this->assertSame(
|
||||
__('auth.too_many_attempts', ['seconds' => (int) $response->headers->get('Retry-After')]),
|
||||
$response->json('message')
|
||||
);
|
||||
// 재시도 시점 안내는 그대로 유지되어야 한다.
|
||||
$this->assertNotNull($response->headers->get('Retry-After'));
|
||||
}
|
||||
|
||||
private function setSecuritySettings(array $values): void
|
||||
{
|
||||
$existing = (array) config('g7_settings.core.security', []);
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Enums\IdentityVerificationPurpose;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\IdentityVerificationService;
|
||||
use Database\Seeders\IdentityMessageDefinitionSeeder;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -71,6 +74,9 @@ class TwoFactorAuthTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=off, controller=user
|
||||
*/
|
||||
#[Test]
|
||||
public function login_issues_a_token_directly_when_two_factor_is_off(): void
|
||||
{
|
||||
@@ -82,6 +88,11 @@ class TwoFactorAuthTest extends TestCase
|
||||
$this->assertNotEmpty($response->json('data.token'), '2단계 인증이 꺼져 있으면 종전대로 바로 로그인되어야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, delivery=sent
|
||||
*
|
||||
* @effects challenge_response_has_no_token, challenge_response_has_no_user
|
||||
*/
|
||||
#[Test]
|
||||
public function login_withholds_the_token_when_two_factor_is_on(): void
|
||||
{
|
||||
@@ -102,6 +113,9 @@ class TwoFactorAuthTest extends TestCase
|
||||
$this->assertNotEmpty($response->json('data.challenge_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, code=valid
|
||||
*/
|
||||
#[Test]
|
||||
public function verifying_the_challenge_completes_the_login(): void
|
||||
{
|
||||
@@ -119,6 +133,9 @@ class TwoFactorAuthTest extends TestCase
|
||||
$this->assertSame('two-factor@test.com', $response->json('data.user.email'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, code=invalid
|
||||
*/
|
||||
#[Test]
|
||||
public function a_wrong_code_does_not_issue_a_token(): void
|
||||
{
|
||||
@@ -135,6 +152,9 @@ class TwoFactorAuthTest extends TestCase
|
||||
$this->assertNull($response->json('data.token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, code=invalid
|
||||
*/
|
||||
#[Test]
|
||||
public function a_challenge_issued_for_another_purpose_cannot_be_used_to_log_in(): void
|
||||
{
|
||||
@@ -157,6 +177,11 @@ class TwoFactorAuthTest extends TestCase
|
||||
$this->assertNull($response->json('data.token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, delivery=failed
|
||||
*
|
||||
* @effects delivery_failure_503_with_reason
|
||||
*/
|
||||
#[Test]
|
||||
public function login_fails_clearly_when_the_code_cannot_be_delivered(): void
|
||||
{
|
||||
@@ -169,13 +194,289 @@ class TwoFactorAuthTest extends TestCase
|
||||
|
||||
$response = $this->login();
|
||||
|
||||
$response->assertStatus(401);
|
||||
// 자격 증명은 올바르다 — 401 로 답하면 사용자는 비밀번호를 의심하며 같은 실패를
|
||||
// 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
|
||||
$response->assertStatus(503);
|
||||
$this->assertSame(__('auth.two_factor_delivery_failed'), $response->json('message'));
|
||||
$this->assertNull(
|
||||
$response->json('data.token'),
|
||||
'발송 실패 시 2단계 인증을 건너뛰고 로그인시키면 보안 통제가 조용히 열립니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=admin, actor=admin, delivery=sent
|
||||
*
|
||||
* @effects admin_login_returns_challenge_not_500
|
||||
*/
|
||||
#[Test]
|
||||
public function admin_login_returns_a_challenge_instead_of_failing(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
$this->makeAdmin($this->user);
|
||||
|
||||
$response = $this->postJson('/api/auth/admin/login', [
|
||||
'email' => 'two-factor@test.com',
|
||||
'password' => 'Passw0rd!2fa',
|
||||
]);
|
||||
|
||||
// 관리자 로그인이 2단계 인증에서 500 이 되면 설정을 되돌릴 수단까지 사라진다.
|
||||
$response->assertStatus(200);
|
||||
$this->assertTrue((bool) $response->json('data.two_factor_required'));
|
||||
$this->assertNull($response->json('data.token'));
|
||||
$this->assertNotEmpty($response->json('data.challenge_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=admin, actor=admin, code=valid
|
||||
*
|
||||
* @effects admin_two_factor_issues_token_for_admin
|
||||
*/
|
||||
#[Test]
|
||||
public function admin_two_factor_completes_the_login(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
$this->makeAdmin($this->user);
|
||||
|
||||
$challengeId = $this->postJson('/api/auth/admin/login', [
|
||||
'email' => 'two-factor@test.com',
|
||||
'password' => 'Passw0rd!2fa',
|
||||
])->json('data.challenge_id');
|
||||
|
||||
$response = $this->postJson('/api/auth/admin/login/two-factor', [
|
||||
'challenge_id' => $challengeId,
|
||||
'code' => $this->issuedCode($challengeId),
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertNotEmpty($response->json('data.token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=admin, actor=non_admin, code=valid
|
||||
*
|
||||
* @effects non_admin_two_factor_revokes_token
|
||||
*/
|
||||
#[Test]
|
||||
public function a_non_admin_completing_admin_two_factor_keeps_no_token(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
|
||||
$challengeId = $this->postJson('/api/auth/admin/login', [
|
||||
'email' => 'two-factor@test.com',
|
||||
'password' => 'Passw0rd!2fa',
|
||||
])->json('data.challenge_id');
|
||||
|
||||
$response = $this->postJson('/api/auth/admin/login/two-factor', [
|
||||
'challenge_id' => $challengeId,
|
||||
'code' => $this->issuedCode($challengeId),
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
// 코드 확인 시점에 토큰이 이미 발급된다 — 회수하지 않으면 관리자가 아닌 사용자가
|
||||
// 응답만 403 을 받을 뿐 유효한 세션을 손에 쥔다.
|
||||
$this->assertSame(0, $this->user->tokens()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=off, controller=admin, actor=non_admin
|
||||
*
|
||||
* @effects non_admin_admin_login_revokes_token
|
||||
*/
|
||||
#[Test]
|
||||
public function a_non_admin_rejected_at_admin_login_keeps_no_token(): void
|
||||
{
|
||||
$this->setTwoFactor(false);
|
||||
|
||||
$response = $this->postJson('/api/auth/admin/login', [
|
||||
'email' => 'two-factor@test.com',
|
||||
'password' => 'Passw0rd!2fa',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$this->assertSame(0, $this->user->tokens()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, resend=active
|
||||
*
|
||||
* @effects resend_cancels_previous_challenge
|
||||
*/
|
||||
#[Test]
|
||||
public function resending_cancels_the_previous_challenge_and_issues_a_new_one(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
|
||||
$first = $this->login()->json('data.challenge_id');
|
||||
|
||||
$response = $this->postJson('/api/auth/login/two-factor/resend', [
|
||||
'challenge_id' => $first,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$second = $response->json('data.challenge_id');
|
||||
|
||||
$this->assertNotEmpty($second);
|
||||
$this->assertNotSame($first, $second, '재발송이 같은 challenge 를 돌려주면 새 코드가 발송되지 않은 것입니다.');
|
||||
$this->assertNull($response->json('data.token'));
|
||||
|
||||
// 앞선 코드가 계속 통하면 유효한 코드가 여러 개 살아 있어 대입 시도의 표적이 넓어진다.
|
||||
$this->assertSame(
|
||||
IdentityVerificationStatus::Cancelled->value,
|
||||
IdentityVerificationLog::find($first)->status->value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, resend=verified
|
||||
*
|
||||
* @effects resend_rejects_verified_challenge
|
||||
*/
|
||||
#[Test]
|
||||
public function a_verified_challenge_cannot_be_resent(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
|
||||
$challengeId = $this->login()->json('data.challenge_id');
|
||||
|
||||
$this->postJson('/api/auth/login/two-factor', [
|
||||
'challenge_id' => $challengeId,
|
||||
'code' => $this->issuedCode($challengeId),
|
||||
])->assertStatus(200);
|
||||
|
||||
$this->postJson('/api/auth/login/two-factor/resend', [
|
||||
'challenge_id' => $challengeId,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, resend=expired
|
||||
*
|
||||
* @effects resend_rejects_expired_challenge
|
||||
*/
|
||||
#[Test]
|
||||
public function an_expired_challenge_cannot_be_resent(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
|
||||
$challengeId = $this->login()->json('data.challenge_id');
|
||||
|
||||
$log = IdentityVerificationLog::find($challengeId);
|
||||
$log->expires_at = now()->subMinute();
|
||||
$log->save();
|
||||
|
||||
$this->postJson('/api/auth/login/two-factor/resend', [
|
||||
'challenge_id' => $challengeId,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, resend=cancelled
|
||||
*
|
||||
* @effects resend_rejects_cancelled_challenge
|
||||
*/
|
||||
#[Test]
|
||||
public function a_cancelled_challenge_cannot_be_resent(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
|
||||
$challengeId = $this->login()->json('data.challenge_id');
|
||||
|
||||
app(IdentityVerificationService::class)->cancel($challengeId);
|
||||
|
||||
$this->postJson('/api/auth/login/two-factor/resend', [
|
||||
'challenge_id' => $challengeId,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, resend=active, lock_state=active
|
||||
*
|
||||
* @effects resend_refused_while_locked
|
||||
*/
|
||||
#[Test]
|
||||
public function resending_is_refused_while_the_account_is_locked(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
|
||||
$challengeId = $this->login()->json('data.challenge_id');
|
||||
|
||||
// challenge 를 받아 둔 뒤 잠긴 계정에는 새 코드를 보내지 않는다.
|
||||
$this->user->forceFill(['locked_until' => now()->addMinutes(10)])->save();
|
||||
|
||||
$this->postJson('/api/auth/login/two-factor/resend', [
|
||||
'challenge_id' => $challengeId,
|
||||
])->assertStatus(423);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=admin, actor=admin, resend=active
|
||||
*
|
||||
* @effects admin_resend_returns_new_challenge
|
||||
*/
|
||||
#[Test]
|
||||
public function an_admin_can_resend_through_the_admin_endpoint(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
$this->makeAdmin($this->user);
|
||||
|
||||
$challengeId = $this->postJson('/api/auth/admin/login', [
|
||||
'email' => 'two-factor@test.com',
|
||||
'password' => 'Passw0rd!2fa',
|
||||
])->json('data.challenge_id');
|
||||
|
||||
$response = $this->postJson('/api/auth/admin/login/two-factor/resend', [
|
||||
'challenge_id' => $challengeId,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertNotEmpty($response->json('data.challenge_id'));
|
||||
$this->assertNotSame($challengeId, $response->json('data.challenge_id'));
|
||||
$this->assertNull($response->json('data.token'));
|
||||
// 해석된 사용자는 관리자 등급 판정에만 쓰고 응답에는 실지 않는다.
|
||||
$this->assertNull($response->json('data.user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=admin, actor=non_admin, resend=active
|
||||
*
|
||||
* @effects admin_resend_refuses_non_admin
|
||||
*/
|
||||
#[Test]
|
||||
public function a_non_admin_cannot_resend_through_the_admin_endpoint(): void
|
||||
{
|
||||
$this->setTwoFactor(true);
|
||||
|
||||
$challengeId = $this->postJson('/api/auth/admin/login', [
|
||||
'email' => 'two-factor@test.com',
|
||||
'password' => 'Passw0rd!2fa',
|
||||
])->json('data.challenge_id');
|
||||
|
||||
// 완료할 수 없는 상대에게 새 인증번호를 계속 보내지 않는다.
|
||||
$this->postJson('/api/auth/admin/login/two-factor/resend', [
|
||||
'challenge_id' => $challengeId,
|
||||
])->assertStatus(403);
|
||||
|
||||
$this->assertSame(0, $this->user->tokens()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자에게 admin 역할을 부여합니다.
|
||||
*
|
||||
* @param User $user 대상 사용자
|
||||
*/
|
||||
private function makeAdmin(User $user): void
|
||||
{
|
||||
// isAdmin() 은 역할 존재가 아니라 admin 타입 권한 보유로 판정한다 —
|
||||
// 역할만 만들어 붙이면 관리자로 인정되지 않아 403 을 측정하게 된다.
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$role = Role::where('identifier', 'admin')->firstOrFail();
|
||||
|
||||
$user->roles()->syncWithoutDetaching([$role->id => ['assigned_at' => now(), 'assigned_by' => null]]);
|
||||
$user->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* challenge 에 알려진 인증 코드를 심고 그 값을 돌려줍니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Identity;
|
||||
|
||||
use App\Enums\IdentityVerificationPurpose;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use App\Models\User;
|
||||
use App\Services\IdentityVerificationService;
|
||||
use Database\Seeders\IdentityMessageDefinitionSeeder;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 공개 본인인증 엔드포인트의 로그인 목적 게이트 테스트.
|
||||
*
|
||||
* 로그인 2단계 인증 challenge 는 `auth/login/two-factor` 로만 완료된다. 공개 본인인증
|
||||
* 화면(`identity/challenges/{id}/verify` · `/cancel`)이 같은 challenge 를 소비하면,
|
||||
* 그 뒤의 로그인 완료가 "이미 처리된 요청" 으로 거절되어 사용자가 자기 challenge 를
|
||||
* 스스로 못 쓰게 만든다(자기 DoS). 그 화면은 로그인 흐름을 모르므로 되돌릴 방법도 없다.
|
||||
*/
|
||||
class IdentityLoginPurposeGateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Mail::fake();
|
||||
|
||||
// 공개 IDV 라우트는 permission 미들웨어가 가드한다 — guest 역할 권한이 없으면
|
||||
// 원하는 게이트에 도달하기 전에 401 로 막혀 검사 자체가 공허해진다.
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->seed(IdentityMessageDefinitionSeeder::class);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'email' => 'purpose-gate@test.com',
|
||||
'password' => Hash::make('Passw0rd!2fa'),
|
||||
'status' => UserStatus::Active->value,
|
||||
]);
|
||||
|
||||
config(['g7_settings.core.security.two_factor_auth' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, code=consumed_by_public_verify
|
||||
*
|
||||
* @effects public_verify_rejects_login_purpose
|
||||
*/
|
||||
#[Test]
|
||||
public function the_public_verify_endpoint_refuses_a_login_challenge(): void
|
||||
{
|
||||
$challengeId = $this->startLoginChallenge();
|
||||
|
||||
$response = $this->postJson("/api/identity/challenges/{$challengeId}/verify", [
|
||||
'code' => $this->issuedCode($challengeId),
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$this->assertSame('PURPOSE_NOT_ALLOWED', $response->json('errors.failure_code'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, code=consumed_by_public_verify
|
||||
*
|
||||
* @effects public_cancel_rejects_login_purpose
|
||||
*/
|
||||
#[Test]
|
||||
public function the_public_cancel_endpoint_refuses_a_login_challenge(): void
|
||||
{
|
||||
$challengeId = $this->startLoginChallenge();
|
||||
|
||||
$this->postJson("/api/identity/challenges/{$challengeId}/cancel")
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, code=valid
|
||||
*
|
||||
* @effects login_completes_after_public_endpoints_refuse
|
||||
*/
|
||||
#[Test]
|
||||
public function a_login_challenge_still_completes_after_the_public_endpoints_refuse_it(): void
|
||||
{
|
||||
$challengeId = $this->startLoginChallenge();
|
||||
$code = $this->issuedCode($challengeId);
|
||||
|
||||
$this->postJson("/api/identity/challenges/{$challengeId}/verify", ['code' => $code]);
|
||||
$this->postJson("/api/identity/challenges/{$challengeId}/cancel");
|
||||
|
||||
// 게이트가 상태를 전혀 바꾸지 않아야 로그인이 그대로 완료된다.
|
||||
$response = $this->postJson('/api/auth/login/two-factor', [
|
||||
'challenge_id' => $challengeId,
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertNotEmpty($response->json('data.token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario two_factor=on, controller=user, code=valid
|
||||
*
|
||||
* @effects signup_purpose_unaffected_by_login_gate
|
||||
*/
|
||||
#[Test]
|
||||
public function a_signup_challenge_is_unaffected_by_the_gate(): void
|
||||
{
|
||||
// 대조군 — 게이트가 로그인 이외 목적까지 막으면 가입·비밀번호 재설정 화면이 통째로 멈춘다.
|
||||
$challenge = app(IdentityVerificationService::class)->start(
|
||||
IdentityVerificationPurpose::Signup->value,
|
||||
$this->user,
|
||||
['origin_type' => 'route', 'origin_identifier' => 'test']
|
||||
);
|
||||
|
||||
$response = $this->postJson("/api/identity/challenges/{$challenge->id}/verify", [
|
||||
'code' => $this->issuedCode($challenge->id),
|
||||
]);
|
||||
|
||||
$this->assertNotSame(403, $response->getStatusCode(), '로그인 이외 목적까지 막으면 가입 흐름이 멈춥니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 2단계 인증 challenge 를 발급하고 그 id 를 돌려줍니다.
|
||||
*
|
||||
* @return string challenge UUID
|
||||
*/
|
||||
private function startLoginChallenge(): string
|
||||
{
|
||||
return $this->postJson('/api/auth/login', [
|
||||
'email' => 'purpose-gate@test.com',
|
||||
'password' => 'Passw0rd!2fa',
|
||||
])->json('data.challenge_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* challenge 에 알려진 인증 코드를 심고 그 값을 돌려줍니다.
|
||||
*
|
||||
* @param string $challengeId challenge UUID
|
||||
* @return string 심어 둔 인증 코드
|
||||
*/
|
||||
private function issuedCode(string $challengeId): string
|
||||
{
|
||||
$code = '135790';
|
||||
|
||||
$log = IdentityVerificationLog::find($challengeId);
|
||||
$metadata = $log->metadata ?? [];
|
||||
$metadata['code_hash'] = Hash::make($code);
|
||||
|
||||
$log->metadata = $metadata;
|
||||
$log->save();
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 최소 SMTP 싱크 (테스트 전용).
|
||||
*
|
||||
* 2단계 인증 실측은 **인증번호 발송이 성공해야** 코드 입력 단계까지 갈 수 있다. 발송이
|
||||
* 실패하면 서버가 로그인을 503 으로 끊기 때문이다(그것도 정당한 동작이라 PHPUnit 이 따로 잰다).
|
||||
*
|
||||
* 그래서 실제 메일을 보내는 대신 받아서 버리는 SMTP 서버를 잠깐 띄우고 사이트의 메일 설정을
|
||||
* 그쪽으로 돌린다. `log` 메일러를 쓰지 않는 이유는 그것이 이 제품의 설정 스키마에 없어서다 —
|
||||
* 등록된 메일 드라이버(smtp·mailgun·ses) 밖의 값은 저장 검증에서 거부되고, 파일을 직접 고쳐도
|
||||
* 드라이버 해석 단계에서 smtp 로 되돌아간다.
|
||||
*
|
||||
* 의존성을 늘리지 않으려고 Node 기본 `net` 만 쓴다. 받은 메일은 어디에도 남기지 않는다.
|
||||
*/
|
||||
import { createServer, type Server, type Socket } from 'node:net';
|
||||
|
||||
/** 띄운 싱크의 핸들 */
|
||||
export type SmtpSink = {
|
||||
port: number;
|
||||
/** 받은 메시지 수 (진단용) */
|
||||
received(): number;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 한 연결의 SMTP 대화를 처리한다.
|
||||
*
|
||||
* 인증도 TLS 도 요구하지 않는다 — 테스트 전용이며 127.0.0.1 에만 바인딩한다.
|
||||
*
|
||||
* @param socket 클라이언트 소켓
|
||||
* @param onMessage 메시지 1건 수신 시 호출
|
||||
*/
|
||||
function handleConnection(socket: Socket, onMessage: () => void): void {
|
||||
let inData = false;
|
||||
let buffer = '';
|
||||
|
||||
socket.setEncoding('utf-8');
|
||||
socket.write('220 g7-test-sink ESMTP\r\n');
|
||||
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
|
||||
// 본문 수신 중에는 종료 표식(<CRLF>.<CRLF>)만 본다.
|
||||
if (inData) {
|
||||
const terminator = buffer.indexOf('\r\n.\r\n');
|
||||
if (terminator === -1) return;
|
||||
|
||||
buffer = buffer.slice(terminator + 5);
|
||||
inData = false;
|
||||
onMessage();
|
||||
socket.write('250 2.0.0 Ok: queued\r\n');
|
||||
}
|
||||
|
||||
let newline = buffer.indexOf('\r\n');
|
||||
while (! inData && newline !== -1) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 2);
|
||||
|
||||
const verb = line.split(' ')[0].toUpperCase();
|
||||
|
||||
if (verb === 'EHLO' || verb === 'HELO') {
|
||||
// 마지막 줄만 하이픈 없이 — 그래야 클라이언트가 목록의 끝을 안다.
|
||||
socket.write('250-g7-test-sink\r\n250 SIZE 10485760\r\n');
|
||||
} else if (verb === 'DATA') {
|
||||
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||
inData = true;
|
||||
} else if (verb === 'QUIT') {
|
||||
socket.write('221 2.0.0 Bye\r\n');
|
||||
socket.end();
|
||||
return;
|
||||
} else {
|
||||
// MAIL FROM / RCPT TO / RSET / NOOP 등 — 전부 수락한다.
|
||||
socket.write('250 2.0.0 Ok\r\n');
|
||||
}
|
||||
|
||||
newline = buffer.indexOf('\r\n');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
// 클라이언트가 먼저 끊는 것은 정상이다 — 테스트를 실패시키지 않는다.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 싱크를 띄운다.
|
||||
*
|
||||
* @param preferredPort 우선 시도할 포트 (사용 중이면 다음 포트로)
|
||||
* @returns 싱크 핸들
|
||||
*/
|
||||
export async function startSmtpSink(preferredPort = 2525): Promise<SmtpSink> {
|
||||
let count = 0;
|
||||
|
||||
const server: Server = createServer((socket) => {
|
||||
handleConnection(socket, () => {
|
||||
count += 1;
|
||||
});
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolvePort, rejectPort) => {
|
||||
let attempt = 0;
|
||||
|
||||
const tryListen = (candidate: number): void => {
|
||||
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE' && attempt < 20) {
|
||||
attempt += 1;
|
||||
tryListen(candidate + 1);
|
||||
return;
|
||||
}
|
||||
rejectPort(error);
|
||||
});
|
||||
|
||||
server.listen(candidate, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
resolvePort(typeof address === 'object' && address ? address.port : candidate);
|
||||
});
|
||||
};
|
||||
|
||||
tryListen(preferredPort);
|
||||
});
|
||||
|
||||
return {
|
||||
port,
|
||||
received: () => count,
|
||||
close: () =>
|
||||
new Promise<void>((resolveClose) => {
|
||||
server.close(() => resolveClose());
|
||||
// 열려 있는 연결이 남아도 테스트 종료를 막지 않는다.
|
||||
server.unref();
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Playwright 2단계 인증 fixture (코어 영역).
|
||||
*
|
||||
* 로그인 2단계 인증은 **사이트 설정과 메일 발송**에 의존한다. 브라우저만으로는 재현할 수 없으므로
|
||||
* 이 fixture 가 세 가지를 가역적으로 준비한다.
|
||||
*
|
||||
* ① 보안 환경설정 `security.two_factor_auth` 를 켜고, 끝나면 원래 값으로 되돌린다.
|
||||
* ② 메일을 받아서 버리는 로컬 SMTP 싱크를 띄우고 메일 설정을 그쪽으로 돌린다. 끝나면
|
||||
* 원래 값으로 되돌린다. (실제 메일을 보내면 테스트가 외부 상태를 바꾼다)
|
||||
* `log` 메일러를 쓰지 않는 이유는 `smtp-sink.ts` 머리말 참조.
|
||||
* ③ 인증번호는 해시로만 저장되어 되읽을 수 없으므로, 알려진 코드를 심는다
|
||||
* (`php artisan playwright:seed-two-factor --plant=…`).
|
||||
*
|
||||
* 보안 설정은 **관리자 설정 API** 로 바꾼다 — 운영자가 화면에서 하는 것과 같은 경로다.
|
||||
* 메일 설정만 파일을 직접 다룬다: 원래 값(빈 SMTP 호스트)은 저장 검증을 통과하지 못해
|
||||
* API 로는 **되돌릴 수 없기 때문**이다. 파일은 바이트 단위로 백업했다가 그대로 복원한다.
|
||||
*
|
||||
* 원복은 실패해도 조용히 넘기지 않는다 — 되돌리지 못한 채 끝나면 사이트가 2단계 인증이 켜진
|
||||
* 상태로 남아 이후 모든 로그인이 막힌다.
|
||||
*/
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { startSmtpSink, type SmtpSink } from './smtp-sink';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** artisan 이 있는 코어 루트 */
|
||||
function coreRoot(): string {
|
||||
return process.env.G7_ROOT || resolve(__dirname, '../../../');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 설정 API 경로.
|
||||
*
|
||||
* 절대 URL 을 만들지 않는다 — Playwright 의 `request` 컨텍스트가 `baseURL` 과
|
||||
* `ignoreHTTPSErrors` 를 이미 갖고 있다. Node 의 `fetch` 를 쓰면 자체 서명 인증서를 쓰는
|
||||
* 개발 호스트에서 TLS 로 막히고, 그것을 우회하려면 이 파일이 TLS 검증을 끄게 된다.
|
||||
*/
|
||||
const SETTINGS_PATH = '/api/admin/settings';
|
||||
|
||||
/**
|
||||
* `playwright:seed-two-factor` 를 실행하고 마지막 출력 줄을 돌려준다.
|
||||
*
|
||||
* @param args 커맨드 인자 (예: ['--plant=<uuid>', '--code=135790'])
|
||||
* @returns stdout 의 마지막 비어있지 않은 줄
|
||||
*/
|
||||
export function runSeedTwoFactor(args: string[]): string {
|
||||
const command = `php artisan playwright:seed-two-factor ${args.join(' ')}`.trim();
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const stdout = execSync(command, {
|
||||
cwd: coreRoot(),
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, G7_PLAYWRIGHT_BYPASS: '1' },
|
||||
});
|
||||
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`playwright:seed-two-factor 가 빈 응답을 반환했습니다: ${command}`);
|
||||
}
|
||||
return lines[lines.length - 1].trim();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < 2) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
||||
}
|
||||
|
||||
/**
|
||||
* 알려진 인증번호를 challenge 에 심는다.
|
||||
*
|
||||
* @param challengeId 로그인 응답이 돌려준 challenge UUID
|
||||
* @param code 심을 인증번호 (기본 135790)
|
||||
* @returns 심어 둔 인증번호
|
||||
*/
|
||||
export function plantTwoFactorCode(challengeId: string, code = '135790'): string {
|
||||
return runSeedTwoFactor([`--plant=${challengeId}`, `--code=${code}`, '--gc-hours=0']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실측이 만든 테스트 계정을 전부 제거한다.
|
||||
*
|
||||
* 이 계정들은 알려진 비밀번호를 갖고, 관리자용은 관리자 역할까지 갖는다 — 나이 기준 정리를
|
||||
* 기다리면 그 사이가 그대로 열린 문이다. 실측이 끝나면 즉시 지운다.
|
||||
*
|
||||
* @returns 커맨드의 마지막 출력 줄
|
||||
*/
|
||||
export function purgeTwoFactorUsers(): string {
|
||||
return runSeedTwoFactor(['--purge-users', '--gc-hours=0']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알려진 비밀번호를 가진 Active 테스트 계정을 준비한다.
|
||||
*
|
||||
* @param suffix 계정 구분 접미사 (예: 'user' / 'nonadmin')
|
||||
* @param options admin 역할 부여 여부와 비밀번호
|
||||
* @returns 준비된 계정 이메일
|
||||
*/
|
||||
export function ensureTwoFactorUser(
|
||||
suffix: string,
|
||||
options: { admin?: boolean; password?: string } = {}
|
||||
): string {
|
||||
const args = [`--ensure-user=${suffix}`, `--password=${options.password ?? 'Passw0rd!2fa'}`];
|
||||
if (options.admin) args.push('--admin');
|
||||
return runSeedTwoFactor(args);
|
||||
}
|
||||
|
||||
/** 되돌리기 위해 보관하는 상태 */
|
||||
type TwoFactorFixtureState = {
|
||||
/** 보안 탭 전체 값 (API 로 그대로 되쓴다) */
|
||||
security: Record<string, unknown>;
|
||||
/** 메일 설정 파일 백업 경로 (없으면 파일 자체가 없던 설치) */
|
||||
mailBackupPath: string | null;
|
||||
sink: SmtpSink;
|
||||
};
|
||||
|
||||
/** 메일 설정 파일 경로 */
|
||||
function mailSettingsPath(): string {
|
||||
return resolve(coreRoot(), 'storage/app/settings/mail.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 설정을 SMTP 싱크로 돌린다.
|
||||
*
|
||||
* 값 3개만 줄 단위로 치환한다 — 재직렬화하면 운영자 파일의 서식이 통째로 바뀐다.
|
||||
* `encryption` 을 비우는 이유는 싱크가 STARTTLS 를 제공하지 않기 때문이다.
|
||||
*
|
||||
* @param port 싱크 포트
|
||||
* @returns 백업 파일 경로 (설정 파일이 없으면 null)
|
||||
*/
|
||||
function redirectMailToSink(port: number): string | null {
|
||||
const path = mailSettingsPath();
|
||||
if (! existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backupPath = `${path}.bak-playwright-2fa`;
|
||||
copyFileSync(path, backupPath);
|
||||
|
||||
const original = readFileSync(path, 'utf-8');
|
||||
const swapped = original
|
||||
.replace(/"host"(\s*):(\s*)"[^"]*"/, `"host"$1:$2"127.0.0.1"`)
|
||||
.replace(/"port"(\s*):(\s*)\d+/, `"port"$1:$2${port}`)
|
||||
.replace(/"encryption"(\s*):(\s*)("[^"]*"|null)/, '"encryption"$1:$2""');
|
||||
|
||||
writeFileSync(path, swapped, 'utf-8');
|
||||
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 탭 하나를 통째로 저장한다.
|
||||
*
|
||||
* @param request Playwright API 요청 컨텍스트
|
||||
* @param token 관리자 Sanctum 토큰
|
||||
* @param tab 설정 탭
|
||||
* @param values 저장할 탭 전체 값
|
||||
*/
|
||||
async function writeSettingsTab(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
tab: string,
|
||||
values: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const write = await request.post(SETTINGS_PATH, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
data: { _tab: tab, [tab]: values },
|
||||
});
|
||||
|
||||
if (!write.ok()) {
|
||||
throw new Error(`${tab} 설정 저장 실패: HTTP ${write.status()} — ${await write.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 탭 하나를 읽고, 지정한 키만 바꿔 저장한다.
|
||||
*
|
||||
* 저장 페이로드는 `{ _tab, <탭>: { ... } }` 형태이고 탭 **전체**가 검증되므로, 바꿀 키만
|
||||
* 보내면 나머지 필수 항목이 422 로 거부된다. 현재 값을 그대로 되돌려 보내 다른 설정은
|
||||
* 건드리지 않는다.
|
||||
*
|
||||
* @param request Playwright API 요청 컨텍스트 (baseURL·ignoreHTTPSErrors 상속)
|
||||
* @param token 관리자 Sanctum 토큰
|
||||
* @param tab 설정 탭 (`security` · `mail` 등)
|
||||
* @param patch 바꿀 키-값
|
||||
* @returns 바꾸기 전의 탭 전체 값 (원복에 그대로 쓴다)
|
||||
*/
|
||||
async function patchSettingsTab(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
tab: string,
|
||||
patch: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const read = await request.get(`${SETTINGS_PATH}?tab=${tab}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!read.ok()) {
|
||||
throw new Error(`${tab} 설정 조회 실패: HTTP ${read.status()}`);
|
||||
}
|
||||
|
||||
const body = await read.json();
|
||||
const current = (body?.data?.[tab] ?? {}) as Record<string, unknown>;
|
||||
|
||||
// `_meta` 같은 내부 키는 저장 페이로드에서 제외한다.
|
||||
const previous = Object.fromEntries(
|
||||
Object.entries(current).filter(([key]) => !key.startsWith('_'))
|
||||
);
|
||||
|
||||
await writeSettingsTab(request, token, tab, { ...previous, ...patch });
|
||||
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 실측 환경을 준비한다.
|
||||
*
|
||||
* @param request Playwright API 요청 컨텍스트
|
||||
* @param token 관리자 Sanctum 토큰 (`core.settings.read` · `core.settings.update`)
|
||||
* @returns 되돌리기에 필요한 상태
|
||||
*/
|
||||
export async function enableTwoFactorFixture(
|
||||
request: APIRequestContext,
|
||||
token: string
|
||||
): Promise<TwoFactorFixtureState> {
|
||||
// 메일 경로를 먼저 돌린다 — 2단계 인증을 켠 뒤에 코드가 발행되면 실제 메일이 나간다.
|
||||
const sink = await startSmtpSink();
|
||||
const mailBackupPath = redirectMailToSink(sink.port);
|
||||
|
||||
const security = await patchSettingsTab(request, token, 'security', { two_factor_auth: true });
|
||||
|
||||
return { security, mailBackupPath, sink };
|
||||
}
|
||||
|
||||
/**
|
||||
* 실측 환경을 되돌린다.
|
||||
*
|
||||
* 되돌리지 못한 채 끝나면 사이트가 2단계 인증이 켜진 상태로 남아 이후 모든 로그인이 막힌다 —
|
||||
* 실패는 삼키지 않고 그대로 던진다. 보안 설정을 먼저 되돌리고, 그 실패가 메일 설정 복원을
|
||||
* 가리지 않도록 둘 다 시도한 뒤에 던진다.
|
||||
*
|
||||
* @param request Playwright API 요청 컨텍스트
|
||||
* @param token 관리자 Sanctum 토큰
|
||||
* @param state `enableTwoFactorFixture` 가 돌려준 상태
|
||||
*/
|
||||
export async function restoreTwoFactorFixture(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
state: TwoFactorFixtureState
|
||||
): Promise<void> {
|
||||
let firstError: unknown;
|
||||
|
||||
try {
|
||||
await writeSettingsTab(request, token, 'security', state.security);
|
||||
} catch (error) {
|
||||
firstError = error;
|
||||
}
|
||||
|
||||
if (state.mailBackupPath && existsSync(state.mailBackupPath)) {
|
||||
copyFileSync(state.mailBackupPath, mailSettingsPath());
|
||||
rmSync(state.mailBackupPath, { force: true });
|
||||
}
|
||||
|
||||
await state.sink.close();
|
||||
|
||||
if (firstError) {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 로그인 2단계 인증 종단 검증 (공개 #133).
|
||||
*
|
||||
* 2단계 인증을 켜면 서버가 로그인에 **두 가지 형태의 200** 을 돌려준다. 화면이 한 형태만
|
||||
* 가정하면 영문 오류(`Cannot read properties of undefined`)가 뜨고 로그인이 불가능해지며,
|
||||
* 관리자 로그인은 서버 오류가 되어 설정을 되돌릴 수단까지 사라진다.
|
||||
*
|
||||
* 이 spec 은 사이트 설정과 메일 설정을 **가역적으로** 바꾼다 — `test.afterAll` 이 실패해도
|
||||
* 되돌아가도록 fixture 가 원복을 담당한다. 되돌리지 못하면 이후 모든 로그인이 막히므로
|
||||
* 반드시 `mode: 'serial'` 로 한 워커에서만 실행한다.
|
||||
*
|
||||
* // @scenario controller=user,admin | two_factor=on | code=valid,invalid | resend=active
|
||||
* // @effects challenge_response_has_no_token, code_step_rendered_on_challenge, no_raw_typeerror_text, resend_cancels_previous_challenge, non_admin_two_factor_revokes_token
|
||||
*/
|
||||
import { test, expect, issueToken } from '../../fixtures/auth';
|
||||
import {
|
||||
enableTwoFactorFixture,
|
||||
ensureTwoFactorUser,
|
||||
plantTwoFactorCode,
|
||||
purgeTwoFactorUsers,
|
||||
restoreTwoFactorFixture,
|
||||
} from '../../fixtures/two-factor';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const PASSWORD = 'Passw0rd!2fa';
|
||||
|
||||
let adminToken: string;
|
||||
let fixtureRequest: import('@playwright/test').APIRequestContext | undefined;
|
||||
let fixtureState: Awaited<ReturnType<typeof enableTwoFactorFixture>>;
|
||||
let memberEmail: string;
|
||||
let adminEmail: string;
|
||||
let nonAdminEmail: string;
|
||||
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
adminToken = issueToken('core.settings.read', 'core.settings.update');
|
||||
|
||||
memberEmail = ensureTwoFactorUser('user', { password: PASSWORD });
|
||||
adminEmail = ensureTwoFactorUser('admin', { admin: true, password: PASSWORD });
|
||||
nonAdminEmail = ensureTwoFactorUser('nonadmin', { password: PASSWORD });
|
||||
|
||||
// worker 범위 request 컨텍스트 — baseURL·ignoreHTTPSErrors 는 설정에서 온다.
|
||||
fixtureRequest = await playwright.request.newContext({
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
fixtureState = await enableTwoFactorFixture(fixtureRequest, adminToken);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// 되돌리지 못한 채 끝나면 사이트의 모든 로그인이 2단계 인증을 요구하게 된다.
|
||||
if (fixtureState && fixtureRequest) {
|
||||
await restoreTwoFactorFixture(fixtureRequest, adminToken, fixtureState);
|
||||
}
|
||||
|
||||
await fixtureRequest?.dispose();
|
||||
|
||||
// 알려진 비밀번호를 가진 계정(관리자 포함)을 남기지 않는다.
|
||||
purgeTwoFactorUsers();
|
||||
});
|
||||
|
||||
/**
|
||||
* 로그인 응답에서 challenge_id 를 읽는다.
|
||||
*
|
||||
* @param page Playwright 페이지
|
||||
* @param urlPart 대기할 요청 경로 조각
|
||||
* @param submit 제출을 수행하는 함수
|
||||
*/
|
||||
async function submitAndReadChallenge(
|
||||
page: import('@playwright/test').Page,
|
||||
urlPart: string,
|
||||
submit: () => Promise<void>
|
||||
): Promise<string> {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes(urlPart) && r.request().method() === 'POST'),
|
||||
submit(),
|
||||
]);
|
||||
|
||||
const body = await response.json();
|
||||
expect(response.status(), '2단계 인증이 켜져 있으면 로그인은 200 챌린지를 돌려준다').toBe(200);
|
||||
expect(body?.data?.two_factor_required).toBe(true);
|
||||
// 코드 확인 전에 토큰이 실리면 2단계 인증이 없는 것과 같다.
|
||||
expect(body?.data?.token, '코드 확인 전에 토큰이 발급되었습니다').toBeFalsy();
|
||||
|
||||
return String(body.data.challenge_id);
|
||||
}
|
||||
|
||||
test('사용자 로그인 — 인증번호 단계로 전환되고 코드 확인 후 로그인된다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
// 2단계 입력이 나타나고 1단계 입력은 사라진다.
|
||||
const codeInput = page.locator('input[name="two_factor_code"]');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('input[name="email"]')).toHaveCount(0);
|
||||
|
||||
// 영문 TypeError 원문이 화면에 남으면 안 된다.
|
||||
await expect(page.locator('body')).not.toContainText('Cannot read properties of undefined');
|
||||
|
||||
// 오답 → 오류 문구, 로그인 미완료
|
||||
await codeInput.fill('000000');
|
||||
await page.click('form button[type="submit"]');
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 10_000 });
|
||||
expect(page.url()).toContain('/login');
|
||||
|
||||
// 정답 → 홈 이동
|
||||
const code = plantTwoFactorCode(challengeId);
|
||||
await codeInput.fill(code);
|
||||
await page.click('form button[type="submit"]');
|
||||
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(token, '로그인 완료 후 토큰이 저장되어야 합니다').toBeTruthy();
|
||||
expect(token).not.toBe('undefined');
|
||||
});
|
||||
|
||||
test('사용자 로그인 — 인증번호 다시 받기는 새 challenge 를 발급하고 입력을 비운다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const first = await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
const codeInput = page.locator('input[name="two_factor_code"]');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
await codeInput.fill('111111');
|
||||
|
||||
const [resendResponse] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/two-factor/resend') && r.request().method() === 'POST'),
|
||||
page.getByRole('button', { name: /다시|Resend|再送/ }).click(),
|
||||
]);
|
||||
|
||||
const resendBody = await resendResponse.json();
|
||||
expect(resendResponse.status()).toBe(200);
|
||||
const second = String(resendBody.data.challenge_id);
|
||||
expect(second, '재발송이 같은 challenge 를 돌려주면 새 코드가 발송되지 않은 것입니다').not.toBe(first);
|
||||
|
||||
// 앞서 입력한 값이 남아 있으면 새 코드를 받았는데 옛 값으로 제출된다.
|
||||
await expect(codeInput).toHaveValue('');
|
||||
|
||||
const code = plantTwoFactorCode(second);
|
||||
await codeInput.fill(code);
|
||||
await page.click('form button[type="submit"]');
|
||||
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 인증 단계 상태는 전역이라 화면을 떠나도 남는다. 그래서 되돌리는 통로가 셋 다 살아 있어야 한다 —
|
||||
* 「처음부터」 버튼 · 새로고침 · 다른 화면으로 나갔다 돌아오기. 하나라도 빠지면 사용자가 1단계로
|
||||
* 돌아오지 못한 채 이미 만료된 challenge 앞에 갇힌다.
|
||||
*
|
||||
* 새로고침 축은 잘못 저장된 토큰(`"undefined"`) 회귀도 함께 잡는다 — 그 값이 남으면 이후 요청이
|
||||
* 전부 401 이 되어 `/login?reason=session_expired` 로 튕겼다(공개 #133 의 두 번째 증상).
|
||||
*
|
||||
* // @scenario controller=user | two_factor=on | action=restart
|
||||
* // @effects restart_resets_to_credential_step, init_actions_reset_two_factor_state
|
||||
*/
|
||||
test('인증번호 단계 — 처음부터·새로고침·재진입 모두 1단계로 돌아온다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const enterCodeStep = async () => {
|
||||
await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
await expect(page.locator('input[name="two_factor_code"]')).toBeVisible({ timeout: 10_000 });
|
||||
};
|
||||
|
||||
const expectCredentialStep = async () => {
|
||||
await expect(page.locator('input[name="email"]')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('input[name="two_factor_code"]')).toHaveCount(0);
|
||||
};
|
||||
|
||||
// ① 「처음부터」 버튼
|
||||
await enterCodeStep();
|
||||
await page.getByRole('button', { name: /처음부터|Start over|最初から/ }).click();
|
||||
await expectCredentialStep();
|
||||
|
||||
// ② 새로고침 — 잘못된 토큰이 남았다면 여기서 session_expired 로 튕긴다.
|
||||
await enterCodeStep();
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
expect(page.url(), '새로고침이 만료 안내로 튕기면 토큰이 잘못 저장된 것입니다').not.toContain(
|
||||
'reason=session_expired'
|
||||
);
|
||||
const staleToken = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(staleToken, '코드 확인 전에는 토큰이 저장되면 안 됩니다').not.toBe('undefined');
|
||||
await expectCredentialStep();
|
||||
|
||||
// ③ 다른 화면으로 나갔다 돌아오기 — init_actions 리셋이 없으면 2단계가 그대로 남는다.
|
||||
await enterCodeStep();
|
||||
await page.goto('/register');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await expectCredentialStep();
|
||||
});
|
||||
|
||||
test('관리자 로그인 — 챌린지 응답이 서버 오류가 아니고 코드 확인 후 관리자 화면으로 간다', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/admin/login', async () => {
|
||||
await page.fill('input[name="email"]', adminEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('#login_submit_button');
|
||||
});
|
||||
|
||||
const codeInput = page.locator('#two_factor_code');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('body')).not.toContainText('Cannot read properties of undefined');
|
||||
|
||||
await codeInput.fill(plantTwoFactorCode(challengeId));
|
||||
await page.locator('#login_two_factor_submit').click();
|
||||
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/admin/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('관리자 로그인 — 관리자가 아닌 계정은 코드 확인에 성공해도 거부되고 토큰이 남지 않는다', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/admin/login', async () => {
|
||||
await page.fill('input[name="email"]', nonAdminEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('#login_submit_button');
|
||||
});
|
||||
|
||||
const codeInput = page.locator('#two_factor_code');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
await codeInput.fill(plantTwoFactorCode(challengeId));
|
||||
|
||||
const [verifyResponse] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/admin/login/two-factor') && r.request().method() === 'POST'),
|
||||
page.locator('#login_two_factor_submit').click(),
|
||||
]);
|
||||
|
||||
expect(verifyResponse.status()).toBe(403);
|
||||
|
||||
// 코드 확인 시점에 발급된 토큰이 회수되지 않으면 유효한 세션이 남는다.
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(token, '거부된 로그인이 토큰을 남겼습니다').toBeFalsy();
|
||||
expect(page.url()).toContain('/admin/login');
|
||||
});
|
||||
|
||||
test('인증번호 단계 — Enter 키는 확인만 보내고 새 challenge 를 발급하지 않는다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
const codeInput = page.locator('input[name="two_factor_code"]');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 세 자리까지는 확인 버튼이 눌리지 않는다.
|
||||
await codeInput.fill('123');
|
||||
const verifyButton = page.locator('form button[type="submit"]');
|
||||
await expect(verifyButton).toBeDisabled();
|
||||
|
||||
await codeInput.fill(plantTwoFactorCode(challengeId));
|
||||
await expect(verifyButton).toBeEnabled();
|
||||
|
||||
const posts: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.method() === 'POST' && req.url().includes('/api/auth/')) posts.push(req.url());
|
||||
});
|
||||
|
||||
await codeInput.press('Enter');
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const verifyCalls = posts.filter((u) => u.includes('/login/two-factor')).length;
|
||||
const loginCalls = posts.filter((u) => u.endsWith('/api/auth/login')).length;
|
||||
|
||||
expect(verifyCalls, 'Enter 가 인증번호 확인을 보내지 않았습니다').toBe(1);
|
||||
// 상호배타 조건이 빠지면 Enter 가 새 challenge 를 발급해 흐름이 깨진다.
|
||||
expect(loginCalls, 'Enter 가 비밀번호 단계를 다시 호출했습니다').toBe(0);
|
||||
});
|
||||
|
||||
test('인증번호 단계 — 모바일 폭과 일본어 로케일에서 문구·배치가 깨지지 않는다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.addInitScript(() => localStorage.setItem('g7_locale', 'ja'));
|
||||
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
await expect(page.locator('input[name="two_factor_code"]')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 다국어 키가 해석되지 않으면 `$t:` 토큰이 그대로 화면에 남는다.
|
||||
const body = (await page.locator('body').innerText()) ?? '';
|
||||
expect(body).not.toContain('$t:');
|
||||
expect(body).not.toContain('auth.two_factor');
|
||||
|
||||
// 다시 받기·처음부터 두 버튼이 좁은 폭에서도 컨테이너를 넘지 않는다.
|
||||
const buttons = page.locator('form button[type="button"]');
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const box = await buttons.nth(i).boundingBox();
|
||||
if (box) {
|
||||
expect(box.width, '버튼이 375px 화면을 넘칩니다').toBeLessThanOrEqual(375);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('공개 본인인증 화면으로는 로그인 challenge 를 소진할 수 없다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
const code = plantTwoFactorCode(challengeId);
|
||||
|
||||
const status = await page.evaluate(async (id) => {
|
||||
const res = await fetch(`/api/identity/challenges/${id}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ code: '135790' }),
|
||||
});
|
||||
return res.status;
|
||||
}, challengeId);
|
||||
|
||||
expect(status, '로그인 challenge 가 공개 본인인증 경로로 소진되었습니다').toBe(403);
|
||||
|
||||
// 거부는 상태를 바꾸지 않으므로 로그인은 그대로 완료된다.
|
||||
await page.locator('input[name="two_factor_code"]').fill(code);
|
||||
await page.click('form button[type="submit"]');
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 인증번호를 여러 번 틀려도 그 challenge 로 계속 시도할 수 있어야 한다.
|
||||
*
|
||||
* 오답 때마다 입력을 비우면 오타 한 글자를 고치려던 사용자가 전체를 다시 친다 —
|
||||
* 입력을 남기는 것이 확정된 동작이다(레이아웃의 `loginTwoFactor` onError 는 code 를
|
||||
* 건드리지 않는다). 비우도록 바뀌면 아래 `toHaveValue` 가 red 가 된다.
|
||||
*
|
||||
* // @scenario controller=user | two_factor=on | code=invalid,valid
|
||||
* // @effects invalid_code_retries_until_success
|
||||
*/
|
||||
test('인증번호 단계 — 세 번 틀려도 입력이 남고 네 번째 정답으로 로그인된다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
const codeInput = page.locator('input[name="two_factor_code"]');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 본인인증 정책 상한(max_attempts=5) 안이라 세 번까지는 challenge 가 살아 있다.
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
await codeInput.fill('000000');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('/api/auth/login/two-factor') &&
|
||||
!r.url().includes('/resend') &&
|
||||
r.request().method() === 'POST'
|
||||
),
|
||||
page.click('form button[type="submit"]'),
|
||||
]);
|
||||
|
||||
expect(response.status(), `${attempt}회째 오답이 거부되지 않았습니다`).toBe(401);
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 오답 뒤에도 입력은 남는다.
|
||||
await expect(codeInput).toHaveValue('000000');
|
||||
expect(page.url()).toContain('/login');
|
||||
}
|
||||
|
||||
await codeInput.fill(plantTwoFactorCode(challengeId));
|
||||
await page.click('form button[type="submit"]');
|
||||
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 로그인이 끝난 직후의 상태 — 2단계 흔적이 남으면 다음에 로그인 화면을 열었을 때
|
||||
* 비밀번호 단계가 아니라 코드 단계가 뜬다.
|
||||
*
|
||||
* // @scenario controller=user | two_factor=on | code=valid
|
||||
* // @effects session_state_cleared_after_two_factor_success
|
||||
*/
|
||||
test('인증 완료 직후 — 2단계 상태가 지워지고 사용자·토큰이 자리 잡는다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
await page.locator('input[name="two_factor_code"]').fill(plantTwoFactorCode(challengeId));
|
||||
await page.click('form button[type="submit"]');
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, any>;
|
||||
const globalState = w.__templateApp?.getGlobalState?.() ?? w.G7Core?.state?.get?.() ?? {};
|
||||
|
||||
return {
|
||||
twoFactor: globalState.twoFactor ?? null,
|
||||
isLoggingIn: globalState.isLoggingIn ?? null,
|
||||
uuid: String(globalState.currentUser?.uuid ?? ''),
|
||||
email: String(globalState.currentUser?.email ?? ''),
|
||||
token: String(localStorage.getItem('auth_token') ?? ''),
|
||||
};
|
||||
});
|
||||
|
||||
expect(state.twoFactor, '로그인이 끝났는데 2단계 상태가 남아 있습니다').toBeNull();
|
||||
expect(state.isLoggingIn, '로딩 표시가 켜진 채 남았습니다').toBe(false);
|
||||
expect(state.uuid.length, '로그인한 사용자가 전역 상태에 실리지 않았습니다').toBeGreaterThan(0);
|
||||
expect(state.email).toBe(memberEmail);
|
||||
// `"undefined"` 가 저장되던 결함(#133)은 길이 9 라 존재 검사만으로는 통과한다.
|
||||
expect(state.token.length, '저장된 토큰이 실제 토큰이 아닙니다').toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
/**
|
||||
* 확인 버튼을 빠르게 두 번 눌러도 인증 요청은 한 번만 나가야 한다.
|
||||
*
|
||||
* 두 번 나가면 두 번째가 이미 소진된 challenge 를 확인하려다 실패해, 로그인은 됐는데
|
||||
* 오류 문구가 함께 뜨는 상태가 된다.
|
||||
*
|
||||
* // @scenario controller=user | two_factor=on | code=valid
|
||||
* // @effects double_submit_sends_single_verify_request
|
||||
*/
|
||||
test('인증번호 단계 — 확인 버튼을 두 번 눌러도 확인 요청은 한 번만 나간다', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
|
||||
await page.fill('input[name="email"]', memberEmail);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('form button[type="submit"]');
|
||||
});
|
||||
|
||||
const codeInput = page.locator('input[name="two_factor_code"]');
|
||||
await expect(codeInput).toBeVisible({ timeout: 10_000 });
|
||||
await codeInput.fill(plantTwoFactorCode(challengeId));
|
||||
|
||||
const verifyCalls: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (
|
||||
req.method() === 'POST' &&
|
||||
req.url().includes('/api/auth/login/two-factor') &&
|
||||
!req.url().includes('/resend')
|
||||
) {
|
||||
verifyCalls.push(req.url());
|
||||
}
|
||||
});
|
||||
|
||||
const verifyButton = page.locator('form button[type="submit"]');
|
||||
const startedAt = Date.now();
|
||||
await verifyButton.click();
|
||||
|
||||
// 두 번째 클릭이 도달하기 전에 버튼이 잠겨야 한다. 잠금은 제출 시퀀스가 세우는
|
||||
// `isLoggingIn` 이 화면에 반영될 때 걸리므로, 그 반영이 사람의 두 번째 클릭보다
|
||||
// 빨라야 한다는 뜻이다.
|
||||
await expect(verifyButton).toBeDisabled({ timeout: 1_000 });
|
||||
const lockedAfterMs = Date.now() - startedAt;
|
||||
expect(
|
||||
lockedAfterMs,
|
||||
`확인 버튼이 잠기기까지 ${lockedAfterMs}ms 가 걸렸습니다 — 사람의 두 번째 클릭이 그 사이에 들어옵니다`
|
||||
).toBeLessThan(200);
|
||||
|
||||
// 잠긴 뒤의 두 번째 클릭은 브라우저가 비활성 버튼에 전달하지 않는다.
|
||||
await verifyButton.click({ force: true, timeout: 2_000 }).catch(() => undefined);
|
||||
|
||||
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
expect(verifyCalls.length, '확인 요청이 두 번 나갔습니다').toBe(1);
|
||||
});
|
||||
+12
-2
@@ -104,11 +104,21 @@ abstract class TestCase extends BaseTestCase
|
||||
* 기본 모드를 전제한 테스트(자산 URL 생성·blade 렌더)가 그 환경에서만 무더기로 깨진다.
|
||||
* 실제로 이 환경에서 8건이 그렇게 실패했다.
|
||||
*
|
||||
* 다른 모드를 검증해야 하는 테스트는 `AssetUrl::forceMode()` 로 자기 전제를 명시한다.
|
||||
* `security.two_factor_auth` 도 같다. 켜 둔 사이트에서는 `/api/auth/login` 이 토큰이
|
||||
* 아니라 인증 요청(challenge)을 돌려주므로, 로그인 성공을 전제한 테스트가 그 환경에서만
|
||||
* 깨진다. 게다가 테스트 메일러로는 인증번호를 보낼 수 없어 503 이 되므로 실패 메시지가
|
||||
* 원인을 가리키지도 않는다. 실제로 이 환경에서 3건이 그렇게 실패했다.
|
||||
*
|
||||
* 다른 모드를 검증해야 하는 테스트는 `AssetUrl::forceMode()` 로, 2단계 인증을 켜야 하는
|
||||
* 테스트는 `config(['g7_settings.core.security.two_factor_auth' => true])` 로 자기 전제를
|
||||
* 명시한다 (자식 `setUp()` 은 `parent::setUp()` 뒤에 실행되므로 그 지정이 이긴다).
|
||||
*/
|
||||
private function pinEnvironmentDependentSettings(): void
|
||||
{
|
||||
config(['g7_settings.core.general.asset_url_mode' => AssetUrl::MODE_EXTENSION]);
|
||||
config([
|
||||
'g7_settings.core.general.asset_url_mode' => AssetUrl::MODE_EXTENSION,
|
||||
'g7_settings.core.security.two_factor_auth' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# audit:allow test-scenario-coverage reason: 7축 cross product 는 조건부 축(actor 는 controller=admin 에서만,
|
||||
# lock_state 는 세션을 여는 단계에서만, delivery 는 비밀번호 단계에서만 의미가 있다)을 곱해 낸 명목상 조합이라
|
||||
# 개별 케이스에 docblock 을 매핑할 실체가 없다. 실제 회귀 가드는 effects 22건이며 전부 test_files 의
|
||||
# PHPUnit·Playwright 테스트에 @effects 로 귀속되어 있다(미검증 0건).
|
||||
|
||||
feature: 로그인 2단계 인증 (인증번호 확인·재발송)
|
||||
|
||||
description: |
|
||||
보안 환경설정의 「2단계 인증」이 켜져 있으면 비밀번호가 맞아도 토큰을 발급하지 않고
|
||||
인증 요청(challenge)만 돌려준다. 즉 로그인 응답은 **두 가지 형태의 200** 이다.
|
||||
|
||||
핵심 동작:
|
||||
- 사용자·관리자 두 경로가 같은 규칙으로 challenge 를 발급한다. 관리자 판정은
|
||||
코드 확인에 성공한 뒤에 수행한다 (그 전에는 사용자도 토큰도 없다).
|
||||
- 코드 확인 시점에 토큰이 발급되므로, 그 뒤 관리자 판정으로 거부하는 경로는
|
||||
발급분을 반드시 회수한다.
|
||||
- 재발송은 기존 challenge 를 취소하고 새로 발행한다 — 유효한 코드를 여러 개
|
||||
살려 두면 대입 시도의 표적이 넓어진다.
|
||||
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데
|
||||
401 로 뭉개면 사용자는 비밀번호를 의심하고 운영자는 원인을 알 수 없다.
|
||||
- 로그인 challenge 는 공개 본인인증 경로(verify/cancel)로 소진할 수 없다.
|
||||
소진되면 그 challenge 로 영영 로그인할 수 없게 된다(자기 DoS).
|
||||
|
||||
axes:
|
||||
controller: [user, admin]
|
||||
two_factor: [on, off]
|
||||
delivery: [sent, failed]
|
||||
code: [valid, invalid, expired, consumed_by_public_verify]
|
||||
resend: [none, active, expired, verified, cancelled]
|
||||
actor: [admin, non_admin]
|
||||
lock_state: [none, active]
|
||||
|
||||
exclusions:
|
||||
- { two_factor: off, code: valid, reason: "2단계 인증이 꺼져 있으면 코드 확인 단계가 없다" }
|
||||
- { two_factor: off, code: invalid, reason: "동일" }
|
||||
- { two_factor: off, code: expired, reason: "동일" }
|
||||
- { two_factor: off, code: consumed_by_public_verify, reason: "동일" }
|
||||
- { two_factor: off, resend: active, reason: "challenge 자체가 발급되지 않는다" }
|
||||
- { two_factor: off, resend: expired, reason: "동일" }
|
||||
- { two_factor: off, resend: verified, reason: "동일" }
|
||||
- { two_factor: off, resend: cancelled, reason: "동일" }
|
||||
- { two_factor: off, delivery: failed, reason: "발송 경로를 타지 않는다" }
|
||||
- { delivery: failed, code: valid, reason: "발송에 실패하면 확인할 코드가 없다" }
|
||||
- { delivery: failed, code: invalid, reason: "동일" }
|
||||
- { delivery: failed, code: expired, reason: "동일" }
|
||||
- { delivery: failed, code: consumed_by_public_verify, reason: "동일" }
|
||||
- { delivery: failed, resend: active, reason: "동일 — 재발송할 challenge 가 없다" }
|
||||
- { delivery: failed, resend: expired, reason: "동일" }
|
||||
- { delivery: failed, resend: verified, reason: "동일" }
|
||||
- { delivery: failed, resend: cancelled, reason: "동일" }
|
||||
- { controller: user, actor: non_admin, reason: "사용자 경로는 관리자 판정을 하지 않는다" }
|
||||
- { resend: active, code: invalid, reason: "재발송은 코드 확인 이전 단계라 코드 상태와 직교하지 않는다" }
|
||||
- { resend: active, code: expired, reason: "동일" }
|
||||
- { resend: active, code: consumed_by_public_verify, reason: "동일" }
|
||||
- { resend: expired, code: invalid, reason: "동일" }
|
||||
- { resend: expired, code: expired, reason: "동일" }
|
||||
- { resend: expired, code: consumed_by_public_verify, reason: "동일" }
|
||||
- { resend: verified, code: invalid, reason: "동일" }
|
||||
- { resend: verified, code: expired, reason: "동일" }
|
||||
- { resend: verified, code: consumed_by_public_verify, reason: "동일" }
|
||||
- { resend: cancelled, code: invalid, reason: "동일" }
|
||||
- { resend: cancelled, code: expired, reason: "동일" }
|
||||
- { resend: cancelled, code: consumed_by_public_verify, reason: "동일" }
|
||||
|
||||
effects:
|
||||
- challenge_response_has_no_token
|
||||
- challenge_response_has_no_user
|
||||
- delivery_failure_503_with_reason
|
||||
- admin_login_returns_challenge_not_500
|
||||
- admin_two_factor_issues_token_for_admin
|
||||
- non_admin_two_factor_revokes_token
|
||||
- non_admin_admin_login_revokes_token
|
||||
- public_verify_rejects_login_purpose
|
||||
- public_cancel_rejects_login_purpose
|
||||
- login_completes_after_public_endpoints_refuse
|
||||
- signup_purpose_unaffected_by_login_gate
|
||||
- resend_cancels_previous_challenge
|
||||
- resend_rejects_verified_challenge
|
||||
- resend_rejects_expired_challenge
|
||||
- resend_rejects_cancelled_challenge
|
||||
- resend_refused_while_locked
|
||||
- admin_resend_returns_new_challenge
|
||||
- admin_resend_refuses_non_admin
|
||||
- too_many_attempts_message_translated
|
||||
- invalid_code_retries_until_success
|
||||
- session_state_cleared_after_two_factor_success
|
||||
- double_submit_sends_single_verify_request
|
||||
|
||||
test_files:
|
||||
- tests/Feature/Auth/TwoFactorAuthTest.php
|
||||
- tests/Feature/Auth/TwoFactorAccountLockTest.php
|
||||
- tests/Feature/Auth/LoginThrottleTest.php
|
||||
- tests/Feature/Identity/IdentityLoginPurposeGateTest.php
|
||||
- tests/Playwright/specs/auth/two-factor-login.spec.ts
|
||||
Reference in New Issue
Block a user