feat(core,board,ecommerce,page,ckeditor5,kginicis): 업로드 이미지 축소·로그인 2단계 인증 도입 + 파생 결함 수정

인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서
발견한 결함 2건을 함께 고쳤다.

기능 도입:
- 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality`
 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지
 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호).
 PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다.
- 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면
 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호
 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor`
 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의
 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이
 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다.

결함 수정:
- 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과
 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고
 있어 같은 요청이 화면에 따라 다르게 동작했다.
- 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드
 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃
 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다.
- 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도
 매번 404 요청이 나갔다.
- 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다.

커밋 전 점검에서 발견:
- 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose
 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose`
 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지
 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀
 테스트를 함께 둔다.
- 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다.
 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지
 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다.

재발 방지:
- audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출)
- 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차)
- `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존)

공개 이슈: gnuboard/g7
This commit is contained in:
HeuJung
2026-07-30 07:57:06 +09:00
parent d17ea28da5
commit 7b56d5f23a
72 changed files with 2024 additions and 99 deletions
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace Tests\Feature\Auth;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 로그인 2단계 인증 테스트
*
* `security.two_factor_auth` 는 설정 화면에만 있고 구현이 없어, 켜도 아무 일도 일어나지
* 않았습니다. 관리자는 2단계 인증이 걸린 줄 알지만 실제로는 비밀번호 하나로 로그인됩니다.
*
* 코어 본인인증(IDV) 인프라를 재사용하며, 이 흐름의 불변조건은 하나입니다 —
* **코드 확인을 마치기 전에는 토큰이 발급되지 않는다.**
*/
class TwoFactorAuthTest extends TestCase
{
use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
// 인증번호 메일을 구성하려면 본인인증 메시지 정의가 있어야 한다. 실제 설치에는 있고
// RefreshDatabase 직후에는 비어 있으므로, 없는 상태를 그대로 두면 "코드 발송 실패" 가 되어
// 게이트 동작이 아니라 시드 부재를 측정하게 된다.
$this->seed(IdentityMessageDefinitionSeeder::class);
$this->user = User::factory()->create([
'email' => 'two-factor@test.com',
'password' => Hash::make('Passw0rd!2fa'),
'status' => UserStatus::Active->value,
]);
}
/**
* 2단계 인증 설정을 켜거나 끕니다.
*
* @param bool $enabled 활성화 여부
*/
private function setTwoFactor(bool $enabled): void
{
config(['g7_settings.core.security.two_factor_auth' => $enabled]);
}
/**
* 로그인을 시도합니다.
*
* @return TestResponse 로그인 응답
*/
private function login()
{
return $this->postJson('/api/auth/login', [
'email' => 'two-factor@test.com',
'password' => 'Passw0rd!2fa',
]);
}
#[Test]
public function login_issues_a_token_directly_when_two_factor_is_off(): void
{
$this->setTwoFactor(false);
$response = $this->login();
$response->assertStatus(200);
$this->assertNotEmpty($response->json('data.token'), '2단계 인증이 꺼져 있으면 종전대로 바로 로그인되어야 합니다.');
}
#[Test]
public function login_withholds_the_token_when_two_factor_is_on(): void
{
$this->setTwoFactor(true);
$response = $this->login();
$response->assertStatus(200);
$this->assertTrue(
(bool) $response->json('data.two_factor_required'),
'2단계 인증이 켜져 있는데 추가 확인 없이 응답했습니다.'
);
$this->assertNull(
$response->json('data.token'),
'코드 확인 전에 토큰이 발급되면 2단계 인증이 없는 것과 같습니다.'
);
$this->assertNotEmpty($response->json('data.challenge_id'));
}
#[Test]
public function verifying_the_challenge_completes_the_login(): void
{
$this->setTwoFactor(true);
$challengeId = $this->login()->json('data.challenge_id');
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => $this->issuedCode($challengeId),
]);
$response->assertStatus(200);
$this->assertNotEmpty($response->json('data.token'), '코드 확인에 성공했는데 토큰이 발급되지 않았습니다.');
$this->assertSame('two-factor@test.com', $response->json('data.user.email'));
}
#[Test]
public function a_wrong_code_does_not_issue_a_token(): void
{
$this->setTwoFactor(true);
$challengeId = $this->login()->json('data.challenge_id');
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => '000000',
]);
$response->assertStatus(401);
$this->assertNull($response->json('data.token'));
}
#[Test]
public function a_challenge_issued_for_another_purpose_cannot_be_used_to_log_in(): void
{
$this->setTwoFactor(true);
// 가입·비밀번호 재설정 등 다른 용도로 발급된 challenge 를 들고 와 로그인할 수 있으면,
// 그 흐름 하나만 통과해도 남의 계정에 들어갈 수 있다.
$foreign = app(IdentityVerificationService::class)->start(
IdentityVerificationPurpose::PasswordReset->value,
$this->user,
['origin_type' => 'route', 'origin_identifier' => 'test']
);
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $foreign->id,
'code' => $this->issuedCode($foreign->id),
]);
$response->assertStatus(401);
$this->assertNull($response->json('data.token'));
}
#[Test]
public function login_fails_clearly_when_the_code_cannot_be_delivered(): void
{
$this->setTwoFactor(true);
// 메일 발송이 실패하면 프로바이더가 challenge 를 failed 로 표시한다. 그 상태를 무시하면
// 사용자는 "인증번호를 보냈습니다" 안내를 받고 완료할 수 없는 challenge 를 들고 막힌다.
Mail::shouldReceive('send')->andThrow(new \RuntimeException('SMTP unavailable'));
Mail::shouldReceive('to')->andThrow(new \RuntimeException('SMTP unavailable'));
$response = $this->login();
$response->assertStatus(401);
$this->assertNull(
$response->json('data.token'),
'발송 실패 시 2단계 인증을 건너뛰고 로그인시키면 보안 통제가 조용히 열립니다.'
);
}
/**
* challenge 에 알려진 인증 코드를 심고 그 값을 돌려줍니다.
*
* 발급된 코드는 해시로만 저장되므로 되읽을 수 없습니다. 기존 IDV 테스트와 같은 방식으로
* 알려진 값의 해시를 심어 "코드가 맞을 때" 를 재현합니다 — 검증 대상은 코드 생성이 아니라
* 로그인 게이트(코드 확인 전 토큰 미발급 / 확인 후 발급)입니다.
*
* @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;
}
}