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:
HeuJung
2026-09-07 17:08:14 +09:00
parent 72fb12f267
commit 50007d5cc6
98 changed files with 5815 additions and 231 deletions
+1 -1
View File
@@ -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 를 인식하려면
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
+1 -1
View File
@@ -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 를 인식하려면
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
+19 -1
View File
@@ -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).
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+2 -1
View File
@@ -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');
}
}
+109 -22
View File
@@ -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'),
];
}
}
+13 -1
View File
@@ -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);
});
});
}
+83 -3
View File
@@ -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
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.10'),
'version' => env('APP_VERSION', '7.0.11'),
/*
|--------------------------------------------------------------------------
+2 -2
View File
@@ -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
View File
@@ -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`
+4 -4
View File
@@ -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
+106
View File
@@ -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
로그아웃하고 토큰을 삭제합니다.
+3
View File
@@ -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-액션-속성) - 실행 전 확인 대화상자
+54 -7
View File
@@ -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 설정
+11
View File
@@ -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 레퍼런스
+45
View File
@@ -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",
+1
View File
@@ -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
+1
View File
@@ -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' => [
+1
View File
@@ -38,6 +38,7 @@ return [
'account_pending_verification' => '본인인증이 완료되지 않은 계정입니다. 이메일 인증을 완료해주세요.',
'account_locked' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. :minutes분 후 다시 시도해주세요.',
'account_locked_permanently' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의해주세요.',
'too_many_attempts' => '요청이 너무 잦습니다. :seconds초 후에 다시 시도해주세요.',
'account_unlocked' => '계정 잠금이 해제되었습니다.',
// 비밀번호 재설정
+1
View File
@@ -31,6 +31,7 @@ return [
'admin_policy_has_no_default' => '관리자가 직접 생성한 정책에는 선언 기본값이 없습니다.',
'reset_field_failed' => '선언 기본값 복원에 실패했습니다. 필드가 유효한지 확인하세요.',
'cannot_delete_system_policy' => '시스템이 선언한 정책은 삭제할 수 없습니다. 관리자가 직접 생성한 정책만 삭제할 수 있습니다.',
'purpose_not_allowed' => '이 인증 요청은 이 화면에서 처리할 수 없습니다.',
],
'messages' => [
+8
View File
@@ -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"
+8
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
@@ -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 메서드', () => {
+263 -68
View File
@@ -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,88 +371,167 @@ 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;
};
}>(loginEndpoint, credentials, requestConfig);
data: LoginResponseData;
}>(
config.twoFactorEndpoint,
{ challenge_id: payload.challengeId, code: payload.code },
requestConfig
);
if (response.success && response.data) {
// 토큰 저장
apiClient.setToken(response.data.token);
// 사용자의 language 설정 확인 및 로케일 변경 처리
const userLanguage = response.data.user.language;
const currentLocale = localStorage.getItem(AuthManager.LOCALE_STORAGE_KEY);
const localeChanged = userLanguage && userLanguage !== currentLocale;
if (userLanguage) {
try {
localStorage.setItem(AuthManager.LOCALE_STORAGE_KEY, userLanguage);
} catch (error) {
logger.warn('Failed to save user language to localStorage:', error);
}
}
// 상태 업데이트
this.state = {
isAuthenticated: true,
user: response.data.user,
type: type,
};
this.emit('login', this.state);
this.emit('authStateChange', this.state);
// DevTools 추적
trackAuthEvent('login', true, undefined, {
userId: response.data.user.uuid,
email: response.data.user.email,
type,
});
// 로케일이 변경된 경우 TemplateApp 재초기화
if (localeChanged && (window as any).__templateApp) {
// changeLocale은 비동기이므로 await 하지 않고 실행
// navigate가 먼저 실행되고, changeLocale이 완료되면 UI가 업데이트됨
(window as any).__templateApp.changeLocale(userLanguage);
}
return response.data.user;
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();
// 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;
// DevTools 추적
trackAuthEvent('login', false, enhancedError.message, {
type,
status: error.response?.status,
});
throw enhancedError;
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,
};
}
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();
// 토큰 저장
apiClient.setToken(token);
// 사용자의 language 설정 확인 및 로케일 변경 처리
const userLanguage = user.language;
const currentLocale = localStorage.getItem(AuthManager.LOCALE_STORAGE_KEY);
const localeChanged = userLanguage && userLanguage !== currentLocale;
if (userLanguage) {
try {
localStorage.setItem(AuthManager.LOCALE_STORAGE_KEY, userLanguage);
} catch (error) {
logger.warn('Failed to save user language to localStorage:', error);
}
}
// 상태 업데이트
this.state = {
isAuthenticated: true,
user,
type,
};
this.emit('login', this.state);
this.emit('authStateChange', this.state);
// DevTools 추적
trackAuthEvent('login', true, undefined, {
userId: user.uuid,
email: user.email,
type,
});
// 로케일이 변경된 경우 TemplateApp 재초기화
if (localeChanged && (window as any).__templateApp) {
// changeLocale은 비동기이므로 await 하지 않고 실행
// navigate가 먼저 실행되고, changeLocale이 완료되면 UI가 업데이트됨
(window as any).__templateApp.changeLocale(userLanguage);
}
return user;
}
/**
* 인증 실패 오류에 서버 응답 정보를 실어 다시 던질 오류를 만듭니다.
*
* `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 || 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,
});
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();
});
});
});
+2
View File
@@ -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 재귀 처리', () => {
@@ -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', '유효하지 않은 레이아웃 전략입니다.')
@@ -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)은 코어 카탈로그에서 제외됨 — 결제는 커머스 도메인이라
@@ -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;
+10
View File
@@ -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 &gt;=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 &gt;=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 -->
@@ -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();
});
});
@@ -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
View File
@@ -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": {}
@@ -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
+4 -2
View File
@@ -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
+3 -3
View File
@@ -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 &gt;=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 &gt;=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,17 +383,67 @@
}
},
"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", "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": [
{ "handler": "setState", "params": { "target": "global", "isLoggingIn": false, "loginError": "{{error.message}}", "loginErrors": "{{error.errors}}" } }
{
"_comment": "423 잠금은 1단계와 같은 문구 규칙을 따르도록 loginErrors 도 함께 싣는다.",
"handler": "setState",
"params": {
"target": "global",
"isLoggingIn": false,
"twoFactor.error": "{{error.message}}",
"loginErrors": "{{error.errors}}"
}
}
]
}
]
+2 -2
View File
@@ -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
+37 -2
View File
@@ -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', []);
+302 -1
View File
@@ -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;
}
}
+131
View File
@@ -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();
}),
};
}
+283
View File
@@ -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
View File
@@ -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