diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a642471..02270118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - 허용 파일 형식을 저장해도 업로드 제한에 반영되지 않던 문제를 수정했습니다. 입력 칸은 `jpg,png,pdf` 처럼 쉼표로 구분한 한 줄이지만 적용 단계가 그 형태를 받아들이지 못해, 업로드 탭을 한 번이라도 저장하면 형식 제한이 저장 이전 목록으로 남았습니다. 저장은 성공하고 화면에도 값이 보여 어긋난 것을 알 방법이 없었습니다. 대문자나 앞의 점(`.JPG`, `.png`)은 자동으로 정리되며, 이미 저장되어 있던 값도 그대로 적용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 업로드된 파일의 저장 확장자를 파일명이 아니라 파일 내용에서 판별하도록 바꿨습니다. 확장자만 바꿔 올린 파일이 원래 형식과 다르게 저장되던 문제가 해소됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.) +- 보안 환경설정의 '2단계 인증'이 실제로 동작합니다. 이전에는 항목만 있고 켜도 아무 일이 일어나지 않아, 관리자는 2단계 인증이 걸린 줄 알지만 비밀번호 하나로 로그인됐습니다. 이제 켜 두면 비밀번호 확인 뒤 인증번호를 메일로 보내고, 그 번호를 확인해야 로그인이 완료됩니다. 번호를 확인하기 전에는 로그인 상태가 만들어지지 않습니다. 안내 메일 문구는 환경설정 > 본인인증에서 '로그인 2단계 인증' 목적을 골라 따로 지정할 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 업로드의 이미지 최대 가로/세로 크기와 품질이 실제 업로드에 적용됩니다. 이전에는 값을 저장해도 이미지를 줄이는 동작이 없어 원본이 그대로 저장됐습니다. 크기를 지정하지 않으면 종전처럼 줄이지 않으며, 지정한 크기 이내인 이미지는 다시 변환하지 않아 불필요한 화질 손실이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.) ### Added diff --git a/app/Enums/IdentityVerificationPurpose.php b/app/Enums/IdentityVerificationPurpose.php index 32fc9a21..b720fe62 100644 --- a/app/Enums/IdentityVerificationPurpose.php +++ b/app/Enums/IdentityVerificationPurpose.php @@ -5,10 +5,15 @@ namespace App\Enums; /** * 본인인증 코어 purpose Enum. * - * 코어가 계약으로 보장하는 4종 — `MailIdentityProvider` 가 모두 지원합니다. + * 코어가 계약으로 보장하는 5종 — `MailIdentityProvider` 가 모두 지원합니다. * 모듈/플러그인은 `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()` * 로 추가 purpose 를 선언할 수 있으며, 그 값은 `IdentityVerificationManager::declaredPurposes` - * 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 4종만 정의합니다. + * 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 5종만 정의합니다. + * + * case 를 추가하면 `IdentityVerificationManager::$corePurposes` 등록과 + * `lang/{ko,en}/identity.php` 의 `purposes.{value}` 라벨을 함께 추가해야 합니다. + * 하나라도 빠지면 `label()` 이 i18n 키 원문을 그대로 돌려주고, 관리자 화면의 + * 목적 목록에서 그 목적이 통째로 빠집니다. * * @since 7.0.0-beta.5 */ @@ -26,6 +31,15 @@ enum IdentityVerificationPurpose: string /** 민감 작업 (결제 등) */ case SensitiveAction = 'sensitive_action'; + /** + * 로그인 2단계 인증 + * + * 비밀번호 확인을 통과한 뒤 한 단계를 더 요구하는 용도입니다. 다른 purpose 와 달리 + * 이 challenge 는 **아직 로그인하지 않은 주체**를 대상으로 하므로, 검증을 마치기 + * 전까지 토큰이 발급되지 않습니다. + */ + case Login = 'login'; + /** * 코어 purpose 값 배열. * diff --git a/app/Extension/IdentityVerification/IdentityVerificationManager.php b/app/Extension/IdentityVerification/IdentityVerificationManager.php index 761dd6bc..05836573 100644 --- a/app/Extension/IdentityVerification/IdentityVerificationManager.php +++ b/app/Extension/IdentityVerification/IdentityVerificationManager.php @@ -38,9 +38,13 @@ class IdentityVerificationManager /** * 코어 기본 purpose 목록. * - * `signup` / `password_reset` / `self_update` / `sensitive_action` — 이 4종은 + * `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` — 이 5종은 * 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다. * + * `IdentityVerificationPurpose` 에 case 를 추가하면 이 레지스트리에도 반드시 등록해야 + * 합니다. 등록하지 않으면 `getAllPurposes()` 에서 빠져 관리자가 그 목적의 메시지 템플릿· + * 정책을 만들 수 없고, `hasPurpose()` 도 false 가 됩니다. + * * @var array> */ protected array $corePurposes = [ @@ -76,6 +80,14 @@ class IdentityVerificationManager 'source_type' => IdentityPolicySourceType::Core->value, 'source_identifier' => 'core', ], + IdentityVerificationPurpose::Login->value => [ + 'label' => 'identity.purposes.login.label', + 'description' => 'identity.purposes.login.description', + 'default_provider' => null, + 'allowed_channels' => [IdentityVerificationChannel::Email->value], + 'source_type' => IdentityPolicySourceType::Core->value, + 'source_identifier' => 'core', + ], ]; /** @@ -87,7 +99,6 @@ class IdentityVerificationManager * 프로바이더를 등록합니다. * * @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스 - * @return void */ public function register(IdentityVerificationInterface $provider): void { @@ -98,7 +109,6 @@ class IdentityVerificationManager * 프로바이더 등록을 해제합니다. * * @param string $id 프로바이더 식별자 (예: g7:core.mail) - * @return void */ public function unregister(string $id): void { @@ -249,7 +259,6 @@ class IdentityVerificationManager * @param array> $purposes key => metadata 매핑 * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹) * @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin') - * @return void */ public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void { @@ -284,7 +293,6 @@ class IdentityVerificationManager * @param array $meta label/description/allowed_channels 등 메타데이터 * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin') * @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin') - * @return void */ public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void { diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index d0532e17..d481397e 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -9,6 +9,7 @@ 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\ValidateResetTokenRequest; use App\Http\Resources\UserResource; use App\Services\AuthService; @@ -26,6 +27,8 @@ class AuthController extends AuthBaseController // 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용 $this->middleware('auth:sanctum')->except([ 'login', + // 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다 + 'verifyTwoFactor', 'register', 'forgotPassword', 'resetPassword', @@ -47,6 +50,11 @@ class AuthController extends AuthBaseController $request->validated()['password'] ); + // 2단계 인증이 켜져 있으면 아직 토큰이 없다 — 인증 코드 확인 단계로 안내한다 + if ($data['two_factor_required'] ?? false) { + return $this->success('auth.two_factor_required', $data); + } + // 사용자 정보는 Resource로, 토큰은 그대로 $data['user'] = new UserResource($data['user']); @@ -68,6 +76,33 @@ class AuthController extends AuthBaseController } } + /** + * 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']] + ); + + $data['user'] = new UserResource($data['user']); + + return $this->success('auth.login_success', $data); + } catch (ValidationException $e) { + return $this->unauthorized('auth.two_factor_failed'); + } + } + /** * 새로운 사용자를 등록시킵니다. * diff --git a/app/Http/Requests/Auth/TwoFactorChallengeRequest.php b/app/Http/Requests/Auth/TwoFactorChallengeRequest.php new file mode 100644 index 00000000..f1043207 --- /dev/null +++ b/app/Http/Requests/Auth/TwoFactorChallengeRequest.php @@ -0,0 +1,58 @@ + 검증 규칙 + */ + public function rules(): array + { + $rules = [ + 'challenge_id' => ['required', 'string', 'uuid'], + // 코드 자리수는 프로바이더 설정에 따라 달라지므로 상·하한만 느슨하게 두고 + // 정확한 일치 판정은 프로바이더가 수행한다. + 'code' => ['required', 'string', 'min:4', 'max:16'], + ]; + + // 확장이 자체 2단계 수단(앱 OTP·SMS 등)을 붙일 때 입력 필드를 추가할 수 있도록 개방한다 + return HookManager::applyFilters('core.auth.two_factor_validation_rules', $rules, $this); + } + + /** + * 검증 실패 메시지를 반환합니다. + * + * @return array 검증 메시지 + */ + public function messages(): array + { + return [ + 'challenge_id.required' => __('validation.auth.two_factor.challenge_required'), + 'challenge_id.uuid' => __('validation.auth.two_factor.challenge_invalid'), + 'code.required' => __('validation.auth.two_factor.code_required'), + ]; + } +} diff --git a/app/Http/Requests/Identity/PurposesIndexRequest.php b/app/Http/Requests/Identity/PurposesIndexRequest.php index f17ff486..1d937e55 100644 --- a/app/Http/Requests/Identity/PurposesIndexRequest.php +++ b/app/Http/Requests/Identity/PurposesIndexRequest.php @@ -7,14 +7,14 @@ use Illuminate\Foundation\Http\FormRequest; /** * 등록된 IDV purpose 목록 조회 요청 (GET /api/identity/purposes). * - * 코어 4종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환. + * 코어 5종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환. */ class PurposesIndexRequest extends FormRequest { /** * 요청 권한 — 공개 엔드포인트. * - * @return bool + * @return bool 항상 true (인증 불필요) */ public function authorize(): bool { diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index 5e1ea0c9..b176c9c5 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -277,6 +277,7 @@ class SaveSettingsRequest extends FormRequest 'max:'.config('core.settings_limits.security_password_min_length_max', 64), ], 'security.require_password_special_char' => ['nullable', 'boolean'], + 'security.two_factor_auth' => ['nullable', 'boolean'], // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지 'security.allow_internal_outbound_urls' => ['nullable', 'boolean'], @@ -876,6 +877,7 @@ class SaveSettingsRequest extends FormRequest 'security.login_lockout_time' => __('validation.attributes.login_lockout_time'), 'security.password_min_length' => __('validation.attributes.password_min_length'), 'security.require_password_special_char' => __('validation.attributes.require_password_special_char'), + 'security.two_factor_auth' => __('validation.attributes.two_factor_auth'), 'security.allow_internal_outbound_urls' => __('validation.attributes.allow_internal_outbound_urls'), // advanced 'advanced.cache_enabled' => __('validation.attributes.advanced_cache_enabled'), diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 0fca4ea5..94a49f6d 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -667,5 +667,13 @@ class SettingsServiceProvider extends ServiceProvider AllowedExtensions::normalize($uploadSettings['allowed_extensions']) ); } + + // 이미지 축소 한계값·품질 — 픽셀/퍼센트라 단위 변환이 없다. + // 미설정(빈 값)은 "축소하지 않음" 이므로 그대로 넘겨 ImageResizer 가 판정하게 둔다. + foreach (['image_max_width', 'image_max_height', 'image_quality'] as $key) { + if (isset($uploadSettings[$key]) && $uploadSettings[$key] !== '') { + Config::set("attachment.{$key}", (int) $uploadSettings[$key]); + } + } } } diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php index 430e4759..540a4c46 100644 --- a/app/Services/AttachmentService.php +++ b/app/Services/AttachmentService.php @@ -8,6 +8,7 @@ use App\Enums\AttachmentSourceType; use App\Extension\HookManager; use App\Models\Attachment; use App\Models\User; +use App\Support\ImageResizer; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; @@ -28,10 +29,12 @@ class AttachmentService * * @param AttachmentRepositoryInterface $repository 첨부파일 리포지토리 * @param StorageInterface $storage 스토리지 드라이버 + * @param ImageResizer $imageResizer 업로드 이미지 축소기 */ public function __construct( private AttachmentRepositoryInterface $repository, - private StorageInterface $storage + private StorageInterface $storage, + private ImageResizer $imageResizer ) {} /** @@ -59,6 +62,10 @@ class AttachmentService // 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트) $file = HookManager::applyFilters('core.attachment.filter_upload_file', $file); + // 환경설정 > 업로드의 최대 가로/세로·품질을 실제로 적용한다. + // 임시 파일을 제자리에서 줄이므로 아래의 저장·크기·메타 계산이 모두 축소본을 본다. + $this->imageResizer->resizeInPlace($file->getRealPath(), $file->getMimeType()); + // 저장 경로 생성 (날짜별 디렉토리). // 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 — // 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다. diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 5ffde989..3bc9e0ab 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -7,6 +7,8 @@ use App\Contracts\Repositories\RoleRepositoryInterface; use App\Contracts\Repositories\UserConsentRepositoryInterface; use App\Contracts\Repositories\UserRepositoryInterface; use App\Enums\ConsentType; +use App\Enums\IdentityVerificationPurpose; +use App\Enums\IdentityVerificationStatus; use App\Enums\UserStatus; use App\Exceptions\Auth\AccountLockedException; use App\Extension\HookManager; @@ -14,6 +16,7 @@ use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Laravel\Sanctum\PersonalAccessToken; @@ -142,6 +145,142 @@ class AuthService ]); } + // 2단계 인증이 켜져 있으면 여기서 토큰을 발급하지 않는다. + // 비밀번호 확인만으로 세션이 열리면 2단계가 없는 것과 같으므로, 인증 코드를 확인할 + // 때까지 로그인 상태를 만들지 않고 challenge 만 돌려준다. + if ($this->isTwoFactorRequired($user)) { + Auth::logout(); + + return $this->startTwoFactorChallenge($user, $email); + } + + return $this->issueLoginSession($user, $email); + } + + /** + * 이 사용자에게 2단계 인증을 요구해야 하는지 판정합니다. + * + * @param User $user 로그인 시도 사용자 + * @return bool 2단계 인증 필요 여부 + */ + private function isTwoFactorRequired(User $user): bool + { + if (! (bool) g7_core_settings('security.two_factor_auth', false)) { + return false; + } + + // 코드를 받을 수단이 없으면 요구할 수 없다 — 요구하면 그 계정은 영구히 잠긴다. + return filled($user->email); + } + + /** + * 2단계 인증 challenge 를 발행합니다. + * + * @param User $user 대상 사용자 + * @param string $email 로그인에 사용한 이메일 + * @return array{two_factor_required: bool, challenge_id: string, provider_id: string, expires_at: mixed} challenge 정보 + */ + private function startTwoFactorChallenge(User $user, string $email): array + { + $identity = app(IdentityVerificationService::class); + + $challenge = $identity->start( + IdentityVerificationPurpose::Login->value, + $user, + [ + 'origin_type' => 'route', + 'origin_identifier' => 'auth.login', + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ] + ); + + // 코드를 보내지 못하면 사용자는 완료할 수 없는 challenge 를 들고 막다른 길에 선다. + // "인증번호를 보냈습니다" 로 안내하면 원인을 알 방법이 없으므로, 발송 실패는 실패로 알린다. + // 이때 2단계 인증을 건너뛰고 로그인시키지는 않는다 — 보안 통제가 조용히 열리면 + // 메일 설정이 깨진 동안 2단계 인증이 없는 것과 같아진다. + if (($identity->getStatus($challenge->id)['status'] ?? null) === IdentityVerificationStatus::Failed->value) { + Log::error('2단계 인증 코드 발송 실패 — 로그인을 차단합니다', [ + 'user_id' => $user->id, + 'challenge_id' => $challenge->id, + 'provider_id' => $challenge->providerId, + ]); + + throw ValidationException::withMessages([ + 'email' => [__('auth.two_factor_delivery_failed')], + ]); + } + + HookManager::doAction('core.auth.two_factor_requested', $user, [ + 'email' => $email, + 'challenge_id' => $challenge->id, + 'ip_address' => request()->ip(), + ]); + + return [ + 'two_factor_required' => true, + 'challenge_id' => $challenge->id, + 'provider_id' => $challenge->providerId, + 'expires_at' => $challenge->expiresAt ?? null, + ]; + } + + /** + * 2단계 인증 코드를 확인하고 로그인을 완료합니다. + * + * @param string $challengeId challenge UUID + * @param array $input 프로바이더 입력 (코드 등) + * @return array{user: User, token: string, token_type: string} 로그인 결과 + * + * @throws ValidationException 검증 실패 또는 challenge 불일치 + */ + public function completeTwoFactor(string $challengeId, array $input): array + { + $identity = app(IdentityVerificationService::class); + + $status = $identity->getStatus($challengeId); + + // 이 흐름 전용 challenge 인지 먼저 확인한다. purpose 를 검사하지 않으면 다른 용도로 + // 발급된 challenge(가입·비밀번호 재설정 등)를 들고 와 로그인할 수 있다. + if (($status['purpose'] ?? null) !== IdentityVerificationPurpose::Login->value) { + throw ValidationException::withMessages([ + 'challenge_id' => [__('auth.two_factor_invalid_challenge')], + ]); + } + + $result = $identity->verify($challengeId, $input, [ + 'origin_type' => 'route', + 'origin_identifier' => 'auth.login', + ]); + + if (! $result->success) { + throw ValidationException::withMessages([ + 'code' => [__('auth.two_factor_failed')], + ]); + } + + $user = $identity->resolveVerifiedUser($challengeId, IdentityVerificationPurpose::Login->value); + + if (! $user || $user->status !== UserStatus::Active->value) { + throw ValidationException::withMessages([ + 'challenge_id' => [__('auth.two_factor_invalid_challenge')], + ]); + } + + Auth::login($user); + + return $this->issueLoginSession($user, (string) $user->email); + } + + /** + * 토큰을 발급하고 로그인 완료 훅을 실행합니다. + * + * @param User $user 로그인 사용자 + * @param string $email 로그인에 사용한 이메일 + * @return array{user: User, token: string, token_type: string} 로그인 결과 + */ + private function issueLoginSession(User $user, string $email): array + { $token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken; // 세션에 사용자 저장 (/dev 대시보드 인증용) diff --git a/app/Services/IdentityVerificationService.php b/app/Services/IdentityVerificationService.php index 881a7965..4a7cee70 100644 --- a/app/Services/IdentityVerificationService.php +++ b/app/Services/IdentityVerificationService.php @@ -214,6 +214,35 @@ class IdentityVerificationService ]; } + /** + * 지정 purpose 로 검증을 마친 challenge 의 대상 사용자를 반환합니다. + * + * 로그인 2단계 인증처럼 **아직 인증되지 않은 주체**를 challenge 로만 식별해야 하는 흐름에서 + * 사용합니다. `getStatus()` 는 challenge id 만 알면 누구나 조회할 수 있는 공개 폴링 경로라 + * `user_id` 를 싣지 않으므로, 주체 해석은 이 메서드로 분리합니다. + * + * purpose 를 인자로 받아 대조하는 이유: 다른 용도(가입·비밀번호 재설정)로 발급된 challenge 를 + * 들고 와 로그인하는 것을 막기 위함입니다. + * + * @param string $challengeId Challenge UUID + * @param string $purpose 기대하는 purpose + * @return User|null 검증 완료된 대상 사용자 (조건 불일치 시 null) + */ + public function resolveVerifiedUser(string $challengeId, string $purpose): ?User + { + $log = $this->logRepository->findById($challengeId); + + if (! $log || $log->purpose !== $purpose || $log->verified_at === null) { + return null; + } + + if ($log->user_id === null) { + return null; + } + + return $this->userRepository->findById((int) $log->user_id); + } + /** * 외부 IDV provider 의 redirect 콜백을 처리합니다. * diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php index d9596476..3506d6d0 100644 --- a/app/Services/TemplateLayoutAttachmentService.php +++ b/app/Services/TemplateLayoutAttachmentService.php @@ -6,6 +6,7 @@ use App\Contracts\Extension\StorageInterface; use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface; use App\Contracts\Repositories\TemplateRepositoryInterface; use App\Models\TemplateLayoutAttachment; +use App\Support\ImageResizer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Auth; @@ -56,6 +57,11 @@ class TemplateLayoutAttachmentService $relativePath = "{$templateIdentifier}/".date('Y/m/d')."/{$storedFilename}"; $disk = config('attachment.disk', 'attachments'); + + // 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용). + // 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 기록이 모두 축소본을 본다. + app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType()); + $stored = $this->storage ->withDisk($disk) ->put(self::STORAGE_CATEGORY, $relativePath, file_get_contents($file->getRealPath())); diff --git a/app/Support/ImageResizer.php b/app/Support/ImageResizer.php new file mode 100644 index 00000000..77d345a9 --- /dev/null +++ b/app/Support/ImageResizer.php @@ -0,0 +1,187 @@ + 업로드) + * + * `upload.image_max_width` / `image_max_height` / `image_quality` 는 저장·검증만 되고 + * 실제로 이미지를 줄이는 코드가 없었습니다. 관리자는 값을 바꾸면 적용된다고 인지하지만 + * 원본이 그대로 저장돼, 설정 화면과 실제 동작이 어긋나 있었습니다. + * + * 단위 규약: config/attachment.image_* 는 픽셀, `image_quality` 는 1~100 입니다. + * MB↔KB 처럼 변환이 필요한 값이 아니므로 `SettingsServiceProvider` 가 그대로 옮깁니다. + * + * 한계값이 없으면(설정 미지정) 아무 것도 하지 않습니다 — 기존 동작을 유지해, + * 이 기능이 도입됐다는 이유만으로 기존 사이트의 이미지가 갑자기 줄어들지 않도록 합니다. + */ +class ImageResizer +{ + /** + * 축소를 지원하는 MIME 타입 → GD 로더/세이버 매핑 + */ + private const SUPPORTED = [ + 'image/jpeg' => 'jpeg', + 'image/jpg' => 'jpeg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + + /** + * 기본 품질 (설정 미지정 시) + */ + private const DEFAULT_QUALITY = 85; + + /** + * 이미지가 설정 상한을 넘으면 비율을 유지한 채 파일을 제자리에서 축소합니다. + * + * 상한 이내이거나 지원하지 않는 형식이면 파일을 건드리지 않습니다 — 상한 이내인데도 + * 다시 인코딩하면 아무 이득 없이 화질만 떨어집니다. + * + * @param string $path 이미지 파일 절대 경로 (제자리 수정) + * @param string|null $mimeType 파일 MIME 타입 + * @return bool 실제로 축소했으면 true + */ + public function resizeInPlace(string $path, ?string $mimeType): bool + { + $format = self::SUPPORTED[strtolower((string) $mimeType)] ?? null; + + if ($format === null || ! extension_loaded('gd') || ! is_file($path)) { + return false; + } + + $maxWidth = $this->limit('image_max_width'); + $maxHeight = $this->limit('image_max_height'); + + if ($maxWidth === null && $maxHeight === null) { + return false; + } + + $size = @getimagesize($path); + + if ($size === false) { + return false; + } + + [$width, $height] = [(int) $size[0], (int) $size[1]]; + + if ($width <= 0 || $height <= 0) { + return false; + } + + $scale = min( + $maxWidth !== null ? $maxWidth / $width : 1.0, + $maxHeight !== null ? $maxHeight / $height : 1.0, + 1.0 + ); + + if ($scale >= 1.0) { + return false; + } + + // 반올림으로 0 이 되는 극단 비율에서도 최소 1px 은 남긴다 + $targetWidth = max(1, (int) round($width * $scale)); + $targetHeight = max(1, (int) round($height * $scale)); + + try { + return $this->write($path, $format, $targetWidth, $targetHeight); + } catch (\Throwable $e) { + // 축소 실패가 업로드 자체를 막지는 않는다 — 원본이 그대로 저장된다 + Log::warning('업로드 이미지 축소 실패 (원본을 그대로 저장합니다)', [ + 'path' => $path, + 'mime_type' => $mimeType, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * 설정된 픽셀 한계값을 반환합니다. + * + * @param string $key 설정 키 (image_max_width / image_max_height) + * @return int|null 한계값 (미설정이면 null) + */ + private function limit(string $key): ?int + { + $value = config("attachment.{$key}"); + + if ($value === null || $value === '') { + return null; + } + + $value = (int) $value; + + return $value > 0 ? $value : null; + } + + /** + * 축소한 이미지를 원본 경로에 다시 씁니다. + * + * @param string $path 대상 경로 + * @param string $format GD 포맷 (jpeg/png/gif/webp) + * @param int $targetWidth 결과 가로 + * @param int $targetHeight 결과 세로 + * @return bool 성공 여부 + */ + private function write(string $path, string $format, int $targetWidth, int $targetHeight): bool + { + $source = match ($format) { + 'jpeg' => @imagecreatefromjpeg($path), + 'png' => @imagecreatefrompng($path), + 'gif' => @imagecreatefromgif($path), + 'webp' => @imagecreatefromwebp($path), + }; + + if (! $source) { + return false; + } + + $target = imagecreatetruecolor($targetWidth, $targetHeight); + + // PNG·GIF·WebP 의 투명도를 보존한다 (미처리 시 검은 배경으로 채워진다) + if (in_array($format, ['png', 'gif', 'webp'], true)) { + imagealphablending($target, false); + imagesavealpha($target, true); + imagefill($target, 0, 0, imagecolorallocatealpha($target, 0, 0, 0, 127)); + } + + imagecopyresampled( + $target, $source, + 0, 0, 0, 0, + $targetWidth, $targetHeight, + imagesx($source), imagesy($source) + ); + + $quality = $this->quality(); + + $written = match ($format) { + 'jpeg' => imagejpeg($target, $path, $quality), + // PNG 압축 레벨은 0~9 이고 값이 클수록 더 압축된다 — 품질(1~100)과 방향이 반대다 + 'png' => imagepng($target, $path, (int) round((100 - $quality) / 100 * 9)), + 'gif' => imagegif($target, $path), + 'webp' => imagewebp($target, $path, $quality), + }; + + imagedestroy($source); + imagedestroy($target); + + return (bool) $written; + } + + /** + * 설정된 이미지 품질을 반환합니다. + * + * @return int 1~100 범위의 품질 + */ + private function quality(): int + { + $quality = (int) config('attachment.image_quality', self::DEFAULT_QUALITY); + + return max(1, min(100, $quality > 0 ? $quality : self::DEFAULT_QUALITY)); + } +} diff --git a/config/attachment.php b/config/attachment.php index 5e5c936b..75e64a1a 100644 --- a/config/attachment.php +++ b/config/attachment.php @@ -58,4 +58,23 @@ return [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', ], + + /* + |-------------------------------------------------------------------------- + | 이미지 축소 + |-------------------------------------------------------------------------- + | + | 업로드된 이미지가 이 크기(픽셀)를 넘으면 비율을 유지한 채 축소한다. + | 관리자 환경설정의 `upload.image_max_width` / `image_max_height` / `image_quality` + | 가 저장되면 그 값으로 대체된다. + | + | null = 축소하지 않음. 기본값을 null 로 두는 이유는, 이 기능이 도입됐다는 이유만으로 + | 기존 사이트의 업로드 이미지가 갑자기 줄어들지 않게 하기 위함이다. + | + | image_quality 는 1~100 이며 JPEG/WebP 에 그대로, PNG 에는 압축 레벨로 환산해 쓴다. + | + */ + 'image_max_width' => env('ATTACHMENT_IMAGE_MAX_WIDTH'), + 'image_max_height' => env('ATTACHMENT_IMAGE_MAX_HEIGHT'), + 'image_quality' => env('ATTACHMENT_IMAGE_QUALITY', 85), ]; diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md index a9d1a3ae..76829251 100644 --- a/docs/backend/api/auth.md +++ b/docs/backend/api/auth.md @@ -1434,6 +1434,64 @@ Content-Type: application/json 일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보(`errors.locked_until`, `errors.retry_after_seconds`)와 함께 반환한다. 보안 환경설정의 잠금 시간이 `0`(무한대)이면 무기한 잠금이 되어 `423 auth.account_locked_permanently` 와 함께 `errors.permanent=true`, `errors.locked_until=null`, `errors.retry_after_seconds=null` 을 반환하며 `Retry-After` 헤더도 붙지 않는다. 이 경우 해제 수단은 관리자 해제 API(`POST /api/admin/users/{user}/unlock`) 뿐이다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다. +**2단계 인증이 켜져 있는 경우**: 보안 환경설정 `security.two_factor_auth` 가 켜져 있으면 비밀번호가 맞아도 **토큰을 발급하지 않는다**. 대신 `200` 과 함께 `message: auth.two_factor_required` 및 아래 필드를 반환하며, 클라이언트는 `POST /api/auth/login/two-factor` 로 코드를 확인해야 로그인이 완료된다. + +| 필드 | 타입 | 설명 | +| --- | --- | --- | +| `two_factor_required` | boolean | 항상 `true` — 이 응답이 추가 확인 단계임을 나타낸다 | +| `challenge_id` | string(uuid) | 확인 단계에 그대로 전달할 challenge 식별자 | +| `provider_id` | string | 코드를 발송한 본인인증 프로바이더 | +| `expires_at` | string(ISO8601)\|null | challenge 만료 시각 | + +이 응답에는 `data.token` 과 `data.user` 가 없다. 토큰 존재 여부로 로그인 완료를 판정하는 클라이언트는 그대로 동작한다. + + +### POST /api/auth/login/two-factor + +- **라우트명**: `api.auth.login.two-factor` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@verifyTwoFactor` +- **인증/권한**: 공개 (인증 불필요 — 주체는 challenge 가 식별) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| challenge_id | body | uuid | 예 | — | 로그인 응답이 돌려준 challenge 식별자 | +| code | body | string | 예 | 4~16자 | 사용자가 받은 인증 코드 | + +**요청 예시** + +```http +POST /api/auth/login/two-factor HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "challenge_id": "0f8c2b6e-1a2b-4c3d-9e8f-7a6b5c4d3e2f", + "code": "135790" +} +``` + +**응답 필드** (`data` 내부) + +로그인 성공과 동일하다 — `token`(Sanctum Bearer) · `token_type` · `user`. + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthorized | 코드 불일치·만료·시도 횟수 초과, 또는 challenge 가 로그인 용도(`login`)가 아니거나 대상 사용자가 활성 상태가 아닌 경우 (`auth.two_factor_failed`) | +| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 | + +**설명** + +비밀번호 단계가 돌려준 challenge 를 확인해 로그인을 완료한다. 로그인과 동일한 요청 제한(`throttle:auth-login`)이 걸려 코드 대입 시도도 함께 억제된다. + +challenge 의 `purpose` 가 `login` 인지 먼저 대조한다 — 대조하지 않으면 회원가입·비밀번호 재설정 등 다른 흐름에서 발급된 challenge 로 로그인할 수 있다. 코드 확인에 성공하기 전에는 어떤 경우에도 토큰이 발급되지 않는다. + ### POST /api/auth/logout diff --git a/docs/backend/api/identity.md b/docs/backend/api/identity.md index 9fdf8f87..8a79b952 100644 --- a/docs/backend/api/identity.md +++ b/docs/backend/api/identity.md @@ -28,7 +28,7 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | provider_id | query | string | 아니오 | max 64 | provider 식별자 | -| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) | | status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | | channel | query | string | 아니오 | max 16 | 전송 채널 필터 (email 등 — 해당 채널로 시도한 이력만) | | origin_type | query | string | 아니오 | — | 인증 트리거 출처 유형 필터 (route/hook/policy/middleware/api/custom/system — IdentityOriginType) | @@ -66,7 +66,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | --- | --- | --- | --- | | id | string | `e6ab6cd6-cdff-46cd-b4c2-b89dfda8745b` | 기본 키 (내부 식별자) | | provider_id | string | `inicis` | provider 식별자 (연관 리소스 참조) | -| purpose | string | `sensitive_action` | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| purpose | string | `sensitive_action` | 인증 목적 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) | | channel | string | `ipin` | 인증에 사용된 전송 채널 (email 등 코어 채널 또는 모듈 provider 자체 식별자) | | user_id | integer | `130` | user 식별자 (연관 리소스 참조) | | target_hash | string | `d88d36166bbeffc41eb6994e390f4715280c1…` | 인증 대상 해시 (SHA256(email\|phone) — PII 원본 저장 회피) | @@ -1806,7 +1806,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) | | target | body | array | 아니오 | — | 비로그인 게스트의 인증 대상 (target.email 또는 target.phone — 로그인 사용자는 본인으로 자동 설정) | | provider_id | body | string | 아니오 | max 64 | provider 식별자 | @@ -1880,7 +1880,7 @@ _단건 응답: `data` 객체의 필드._ | id | string | `00484973-8cd3-4a1d-85f2-78361feb6f0d` | 기본 키 (내부 식별자) | | status | string | `verified` | requested\|sent\|processing\|verified\|failed\|expired\|cancelled\|policy_violation_logged | | provider_id | string | `inicis` | 프로바이더 식별자 (예: g7:core.mail, kcp) | -| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|*module-defined*) — 코어 4종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 | +| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|login\|*module-defined*) — 코어 5종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 | | render_hint | string | `text_code` | 프론트 렌더 힌트 (text_code\|link\|external_redirect) | | expires_at | string | `2026-05-12T18:14:19+00:00` | expires 일시 | | attempts | integer | `3` | 시도 횟수 | @@ -2132,7 +2132,7 @@ Accept: application/json | description | string | `신규 가입자의 이메일/전화번호 소유 확인.` | 설명 (다국어 필드는 로케일별 값 객체) | | default_provider | string | `g7:core.mail` | 이 목적에 매핑된 기본 프로바이더 ID (요청 시 provider_id 미지정이면 이 값 사용, 미설정 시 null) | | allowed_channels | array | `["mail","sms"]` | 이 목적에서 사용 가능한 전송 채널 목록 | -| source_type | string | `core` | 목적 출처 (core: 코어 기본 4종 / module / plugin — 어느 확장이 선언했는지) | +| source_type | string | `core` | 목적 출처 (core: 코어 기본 5종 / module / plugin — 어느 확장이 선언했는지) | | source_identifier | string | `core` | 출처 식별자 (목적을 선언한 확장의 identifier) | **응답 예시** @@ -2194,6 +2194,18 @@ HTTP/1.1 200 "source_type": "core", "source_identifier": "core" }, + { + "id": "login", + "label": "로그인 2단계 인증", + "description": "2단계 인증을 켰을 때 비밀번호 확인 뒤 한 단계를 더 요구.", + "default_provider": "g7:core.mail", + "allowed_channels": [ + "mail", + "sms" + ], + "source_type": "core", + "source_identifier": "core" + }, { "id": "checkout_verification", "label": "결제 시 본인 확인", @@ -2217,6 +2229,6 @@ _대표 에러 없음 (공개 조회). 인증·권한 미요구 엔드포인트 -**설명** 등록된 인증 목적(purpose) 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않습니다. `IdentityVerificationManager::getAllPurposes()` 로 코어 기본 4종(signup/password_reset/self_update/sensitive_action) + 활성 모듈/플러그인의 `getIdentityPurposes()` 선언 + `core.identity.purposes` 필터 훅(서드파티 동적 확장) 을 병합하며, 각 항목의 label/description 은 i18n 키·다국어 배열·평문 세 형태를 현재 로케일 문자열로 정규화해 내려줍니다. 각 목적의 기본 프로바이더·허용 채널·출처(source_type/source_identifier) 를 함께 반환하므로, 인증 UI 가 목적별 선택지와 문구를 구성할 때 사용합니다. +**설명** 등록된 인증 목적(purpose) 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않습니다. `IdentityVerificationManager::getAllPurposes()` 로 코어 기본 5종(signup/password_reset/self_update/sensitive_action/login) + 활성 모듈/플러그인의 `getIdentityPurposes()` 선언 + `core.identity.purposes` 필터 훅(서드파티 동적 확장) 을 병합하며, 각 항목의 label/description 은 i18n 키·다국어 배열·평문 세 형태를 현재 로케일 문자열로 정규화해 내려줍니다. 각 목적의 기본 프로바이더·허용 채널·출처(source_type/source_identifier) 를 함께 반환하므로, 인증 UI 가 목적별 선택지와 문구를 구성할 때 사용합니다. diff --git a/docs/backend/identity-messages.md b/docs/backend/identity-messages.md index 2b4b9928..45817083 100644 --- a/docs/backend/identity-messages.md +++ b/docs/backend/identity-messages.md @@ -112,7 +112,7 @@ IdentityMessageResolver::resolve( | 변수 | 설명 | 적용 | |------|------|------| -| `{code}` | 인증 코드 (text_code 흐름) | signup / self_update / sensitive_action | +| `{code}` | 인증 코드 (text_code 흐름) | signup / self_update / sensitive_action / login | | `{action_url}` | 검증 링크 URL (link 흐름) | password_reset | | `{expire_minutes}` | 만료까지 남은 분 | 모든 정의 | | `{purpose_label}` | 인증 목적 라벨 (다국어 해석) | 모든 정의 | @@ -136,6 +136,8 @@ IdentityMessageResolver::resolve( **시드 위치**: `database/seeders/IdentityMessageDefinitionSeeder.php` +로그인 2단계 인증(`purpose=login`)은 전용 시드가 없어 `provider_default` 템플릿으로 발송됩니다. 전용 문구가 필요하면 관리자 화면에서 `scope_type=purpose` / `scope_value=login` 정의를 추가합니다 — 목적 목록에 `login` 이 등록되어 있으므로 선택할 수 있습니다. + --- ## 클래스 계층 diff --git a/docs/backend/identity-policies.md b/docs/backend/identity-policies.md index 673a0d99..54c44ba4 100644 --- a/docs/backend/identity-policies.md +++ b/docs/backend/identity-policies.md @@ -22,7 +22,7 @@ applies_to 는 permission 기반 admin 판정 (User::isAdmin() — type='admin' | `key` | 고유 식별자 (시드 기준) | `core.auth.signup_before_submit` | | `scope` | 매칭 단위 | `route` (라우트 미들웨어) / `hook` (action 훅) | | `target` | scope 별 대상 식별자 | route name 또는 hook name | -| `purpose` | IDV 목적 | `signup` / `password_reset` / `self_update` / `sensitive_action` 등 | +| `purpose` | IDV 목적 | `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` 등 | | `provider_id` | 강제할 IDV provider | `null` 이면 매니저 default | | `applies_to` | 강제 대상 사용자군 | `self` (관리자 제외) / `admin` (관리자만) / `both` | | `enabled` | 활성 여부 | bool | diff --git a/docs/backend/identity-providers.md b/docs/backend/identity-providers.md index ebe56885..89de200b 100644 --- a/docs/backend/identity-providers.md +++ b/docs/backend/identity-providers.md @@ -100,7 +100,7 @@ final class SmsIdentityProvider implements VerificationProviderInterface | --- | --- | --- | | `id` | string | challenge UUID | | `providerId` | string | provider 식별자 | -| `purpose` | string | signup / password_reset / self_update / sensitive_action / 확장 정의 | +| `purpose` | string | signup / password_reset / self_update / sensitive_action / login / 확장 정의 | | `channel` | string | email / sms / ipin / 확장 채널 | | `targetHash` | string | SHA256(식별자) | | `expiresAt` | CarbonInterface | 만료 시각 | @@ -210,7 +210,7 @@ public static function providerMatrix(): array | `target_hash` 를 raw 식별자로 저장 | 반드시 SHA256(lower(식별자)) 로 PII 보호 | | `verify()` 성공 시 `consumed_at = now()` 자동 set | 다운스트림 listener (가입/비번리셋 등) 가 사용 시점에 set | | provider 가 listener 책임을 가져가서 직접 사용자 생성 | provider 는 verify 만 담당. 사용자 생성/세션은 도메인 service | -| `purpose` 를 정책 purpose 와 다르게 임의 부여 | 정책-provider purpose 일치 (`signup`, `password_reset`, `sensitive_action`, `self_update`) | +| `purpose` 를 정책 purpose 와 다르게 임의 부여 | 정책-provider purpose 일치 (`signup`, `password_reset`, `sensitive_action`, `self_update`, `login`) | ## 8. 관련 코드 진입점 diff --git a/docs/extension/module-identity-settings.md b/docs/extension/module-identity-settings.md index 6116fe55..e0c4e13f 100644 --- a/docs/extension/module-identity-settings.md +++ b/docs/extension/module-identity-settings.md @@ -60,7 +60,7 @@ public function getIdentityPolicies(): array ### 신규 purpose 등록 -코어가 제공하는 4 종(`signup` / `password_reset` / `self_update` / `sensitive_action`) 외에 도메인 특화 purpose 를 도입하려면 `getIdentityPurposes()` 를 오버라이드합니다. +코어가 제공하는 5 종(`signup` / `password_reset` / `self_update` / `sensitive_action` / `login`) 외에 도메인 특화 purpose 를 도입하려면 `getIdentityPurposes()` 를 오버라이드합니다. ```php public function getIdentityPurposes(): array diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 63f7e1d9..af6e4982 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -15,6 +15,7 @@ - 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 환경설정 저장 시 오류 안내에 나오는 항목 이름의 일본어 번역 추가 — 보안·업로드·SEO·고급·드라이버 등 각 설정 항목이 내부 식별자 대신 일본어 항목명으로 표시됩니다. +- 로그인 2단계 인증 안내(인증번호 발송·불일치·요청 만료)와 인증번호 입력 검증 문구의 일본어 번역 추가. ## [1.0.6] - 2026-07-22 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php index d6d5733e..5b5c0096 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php @@ -3,6 +3,9 @@ return [ 'login_success' => 'ログインが成功しました。', 'login_failed' => '認証に失敗しました。', + 'two_factor_required' => '認証番号を送信しました。受け取った番号を入力してログインを完了してください。', + 'two_factor_failed' => '認証番号が正しくないか、有効期限が過ぎています。', + 'two_factor_invalid_challenge' => '認証リクエストが見つかりません。最初からログインし直してください。', 'register_success' => '会員登録が成功しました。', 'register_failed' => '会員登録に失敗しました。', 'logout_success' => 'ログアウトが成功しました。', @@ -38,4 +41,5 @@ return [ 'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。', 'password_reset_email_sent' => 'パスワードリセットメールが送信されました。', 'email_send_failed' => 'メール送信に失敗しました。しばらく後に再度お試しください。', + 'two_factor_delivery_failed' => '認証番号を送信できませんでした。管理者にお問い合わせください。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php index 962182ac..ee10002c 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php @@ -61,6 +61,10 @@ return [ 'label' => '機密操作', 'description' => 'アカウント削除·管理者操作など再認証が必要な時点。', ], + 'login' => [ + 'label' => 'ログイン2段階認証', + 'description' => '2段階認証を有効にした場合、パスワード確認後にもう一段階を要求。', + ], ], 'channels' => [ 'email' => 'メール', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index 400adc6e..086c6037 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -692,6 +692,11 @@ return [ 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。', ], 'auth' => [ + 'two_factor' => [ + 'challenge_required' => '認証リクエスト情報がありません。最初からログインし直してください。', + 'challenge_invalid' => '認証リクエスト情報が正しくありません。', + 'code_required' => '認証番号を入力してください。', + ], 'email' => [ 'required' => 'メールアドレスは必須です。', 'email' => '正しいメールアドレス形式ではありません。', @@ -1108,6 +1113,7 @@ return [ 'login_lockout_time' => 'ログインブロック時間', 'password_min_length' => 'パスワード最小長', 'require_password_special_char' => 'パスワード特殊文字必須', + 'two_factor_auth' => '2段階認証', 'allow_internal_outbound_urls' => '内部ネットワークアドレス呼び出しの許可', 'advanced_cache_enabled' => 'キャッシュの使用', 'layout_cache_enabled' => 'レイアウトキャッシュの使用', diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 1119f77c..43dd0ba6 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 환경설정 > 보안의 2단계 인증 항목 문구 일본어 번역 추가 (`settings.security.two_factor_auth*`) — 항목 이름과 설명이 일본어 로케일에서 자연스럽게 표시됩니다. - 회원 상세의 계정 잠금 상태 표시와 잠금 해제 버튼 문구 일본어 번역 추가 (`users.detail.unlock*`, `users.detail.lock_status`) — 무기한 잠금·기한부 잠금·정상 상태 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 언어팩 관리 목록의 "파일 없음" 배지와 다시 설치 버튼 문구 일본어 번역 추가 (`language_packs.badge.files_missing`, `language_packs.actions.reinstall`). - 일반 설정의 에셋 파일 서빙 방식 항목 문구 일본어 번역 추가 (`settings.general.asset_url_mode*`) — 항목 이름과 설명, 선택지, 자동 감지 결과(확장자를 가로채는 서버 규칙 안내 포함)가 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 14f5381a..3602a0c4 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1492,6 +1492,9 @@ "require_password_special_char": "特殊文字必須", "require_password_special_char_label": "パスワードに特殊文字を必ず含める", "require_password_special_char_hint": "有効にすると、新しく設定するパスワードに特殊文字が少なくとも1つ必要です。", + "two_factor_auth": "2段階認証", + "two_factor_auth_label": "ログイン時に認証番号をもう一度確認", + "two_factor_auth_hint": "有効にすると、パスワード確認後に登録メールへ認証番号を送信し、その番号を入力してログインが完了します。", "ip_throttle_floor_hint": "IP単位のリクエスト制限はこの値の6倍で適用され、1分あたり最低30回は常に保証されます。" }, "identity": { diff --git a/lang/en/auth.php b/lang/en/auth.php index a5e2fcab..3e782708 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -4,6 +4,10 @@ return [ // Authentication related messages 'login_success' => 'Login successful.', 'login_failed' => 'Authentication failed.', + 'two_factor_required' => 'A verification code has been sent. Enter it to finish signing in.', + 'two_factor_failed' => 'The verification code is incorrect or has expired.', + 'two_factor_invalid_challenge' => 'Verification request not found. Please sign in again.', + 'two_factor_delivery_failed' => 'Could not send the verification code. Please contact the administrator.', 'register_success' => 'Registration successful.', 'register_failed' => 'Registration failed.', 'logout_success' => 'Logout successful.', diff --git a/lang/en/identity.php b/lang/en/identity.php index ddc105d6..65555f2c 100644 --- a/lang/en/identity.php +++ b/lang/en/identity.php @@ -65,6 +65,10 @@ return [ 'label' => 'Sensitive Action', 'description' => 'Re-verify before sensitive actions such as account deletion or admin operations.', ], + 'login' => [ + 'label' => 'Two-Factor Login', + 'description' => 'Require one more step after the password when two-factor authentication is enabled.', + ], ], 'channels' => [ diff --git a/lang/en/validation.php b/lang/en/validation.php index 645b0aeb..359464da 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -762,6 +762,11 @@ return [ // Auth validation messages 'auth' => [ + 'two_factor' => [ + 'challenge_required' => 'Verification request is missing. Please sign in again.', + 'challenge_invalid' => 'Verification request is not valid.', + 'code_required' => 'Enter the verification code.', + ], 'email' => [ 'required' => 'Email is required.', 'email' => 'Please enter a valid email address.', @@ -1237,6 +1242,7 @@ return [ 'login_lockout_time' => 'login lockout time', 'password_min_length' => 'minimum password length', 'require_password_special_char' => 'require special character in password', + 'two_factor_auth' => 'two-factor authentication', 'allow_internal_outbound_urls' => 'allow internal outbound URLs', // Advanced settings (additional) 'advanced_cache_enabled' => 'cache', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index bc1cffc5..fe18839f 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -4,6 +4,10 @@ return [ // 인증 관련 메시지 'login_success' => '로그인이 성공했습니다.', 'login_failed' => '인증에 실패했습니다.', + 'two_factor_required' => '인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.', + 'two_factor_failed' => '인증번호가 올바르지 않거나 유효시간이 지났습니다.', + 'two_factor_invalid_challenge' => '인증 요청을 찾을 수 없습니다. 처음부터 다시 로그인해주세요.', + 'two_factor_delivery_failed' => '인증번호를 보내지 못했습니다. 관리자에게 문의해주세요.', 'register_success' => '회원가입이 성공했습니다.', 'register_failed' => '회원가입에 실패했습니다.', 'logout_success' => '로그아웃이 성공했습니다.', diff --git a/lang/ko/identity.php b/lang/ko/identity.php index aa21a8f4..50302998 100644 --- a/lang/ko/identity.php +++ b/lang/ko/identity.php @@ -65,6 +65,10 @@ return [ 'label' => '민감 작업', 'description' => '계정 탈퇴·관리자 작업 등 재인증이 필요한 시점.', ], + 'login' => [ + 'label' => '로그인 2단계 인증', + 'description' => '2단계 인증을 켰을 때 비밀번호 확인 뒤 한 단계를 더 요구.', + ], ], 'channels' => [ diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 2044e1f6..6a32f20c 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -763,6 +763,11 @@ return [ // 인증 관련 검증 메시지 'auth' => [ + 'two_factor' => [ + 'challenge_required' => '인증 요청 정보가 없습니다. 처음부터 다시 로그인해주세요.', + 'challenge_invalid' => '인증 요청 정보가 올바르지 않습니다.', + 'code_required' => '인증번호를 입력해주세요.', + ], 'email' => [ 'required' => '이메일은 필수입니다.', 'email' => '올바른 이메일 형식이 아닙니다.', @@ -1230,6 +1235,7 @@ return [ 'login_lockout_time' => '로그인 차단 시간', 'password_min_length' => '비밀번호 최소 길이', 'require_password_special_char' => '비밀번호 특수문자 필수', + 'two_factor_auth' => '2단계 인증', 'allow_internal_outbound_urls' => '내부 네트워크 주소 호출 허용', // 고급 설정 (추가) 'advanced_cache_enabled' => '캐시 사용', diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 5a697eeb..2880ccc9 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -26,6 +26,9 @@ - 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 글을 수정하면서 미리 올려 둔 파일을 첨부하면 저장은 되는데 첨부만 연결되지 않던 문제를 수정했습니다. 관리자 화면에서는 정상 연결되어 같은 요청이 화면에 따라 다르게 동작했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 개별 게시판을 수정할 때 목록 표시 개수·제목/내용 길이·댓글 깊이·첨부 개수 등 숫자 항목의 허용 범위가 화면에 표시되지 않던 문제를 수정했습니다. 게시판 환경설정(기본값) 화면에는 범위가 표시되는데 개별 게시판 화면에는 없어, 범위를 벗어난 값을 넣어도 저장을 눌러 봐야 알 수 있었습니다. 이제 두 화면이 같은 범위를 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index 34e75197..93504de1 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -3148,9 +3148,13 @@ HTTP/1.1 200 | --- | --- | --- | --- | --- | --- | | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | | id | path | string | 예 | — | 대상 리소스의 식별자 | +| attachment_ids | body | integer[] | 아니오 | — | 미리 업로드해 둔 첨부 ID 목록. 게시글에 연결된다 (관리자 수정 경로와 동일) | +| temp_key | body | string | 아니오 | 최대 64자 | 임시 업로드 묶음 키. 해당 묶음의 첨부가 게시글로 이동·연결된다 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`). +`attachment_ids` 는 **연결 대상**이며, 목록에서 뺀 기존 첨부를 삭제하지는 않습니다. 첨부 개수 상한은 이미 연결된 첨부까지 합산해 판정하므로 상한을 넘기면 `422`(`errors.code = attachment_limit_exceeded`)로 거부됩니다. + **요청 예시** ```http diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts new file mode 100644 index 00000000..9cf9d93c --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts @@ -0,0 +1,134 @@ +/** + * 개별 게시판 수정 화면 — 숫자 입력 경계값 계약 테스트 (#493 T8) + * + * 배경: 같은 게시판 설정 항목이 두 화면에 있다. + * - 일괄(모듈 기본값): `admin_board_settings` → 데이터소스 `settings`(initLocal "form") + * - 개별(게시판별) : `admin_board_form` → 데이터소스 `boards`(initLocal "form") + * + * 두 화면 모두 서버가 `data._meta.limits` 로 같은 경계값을 내려준다 + * (`BoardSettingsController::index()` / `BoardController::getFormData()`). + * 그런데 일괄 화면만 그 값을 min/max 로 바인딩하고 개별 화면은 아무 경계도 렌더하지 않아, + * 같은 항목인데 한 화면에서만 범위를 알 수 있는 비대칭이 생겼다. + * + * 저장은 `StoreBoardRequest`/`UpdateBoardRequest` 가 `ReadsBoardLimits` 로 동일하게 막으므로 + * 잘못된 값이 저장되지는 않는다. 문제는 사용자가 **제출 전에** 알 수 없다는 것이다. + * + * 회귀 차단 포인트: + * 1. 개별 화면의 숫자 입력은 min/max 를 가진다 (미지정 금지). + * 2. 그 값은 리터럴이 아니라 `_local.form._meta.limits.*` 표현식이다 + * (리터럴이면 config 를 바꿔도 화면이 따라오지 않아 원래 결함으로 되돌아간다). + * 3. 참조하는 limits 키는 일괄 화면이 쓰는 키와 동일하다 (두 화면이 갈라지지 않도록). + */ + +import { describe, it, expect } from 'vitest'; + +import formTabList from '../../../layouts/admin/partials/admin_board_form/_tab_list.json'; +import formTabPost from '../../../layouts/admin/partials/admin_board_form/_tab_post.json'; + +/** + * [필드명, partial, limits 키 prefix] + * + * per_page_mobile 은 per_page 와 같은 범위를 공유한다(일괄 화면과 동일 규약). + */ +const fields: Array<[string, unknown, string]> = [ + ['per_page', formTabList, 'per_page'], + ['per_page_mobile', formTabList, 'per_page'], + ['min_title_length', formTabPost, 'min_title_length'], + ['max_title_length', formTabPost, 'max_title_length'], + ['min_content_length', formTabPost, 'min_content_length'], + ['max_content_length', formTabPost, 'max_content_length'], + ['new_display_hours', formTabPost, 'new_display_hours'], + ['max_reply_depth', formTabPost, 'max_reply_depth'], + ['min_comment_length', formTabPost, 'min_comment_length'], + ['max_comment_length', formTabPost, 'max_comment_length'], + ['max_comment_depth', formTabPost, 'max_comment_depth'], + ['max_file_size', formTabPost, 'max_file_size'], + ['max_file_count', formTabPost, 'max_file_count'], +]; + +/** + * 레이아웃 트리에서 지정한 name 의 number 입력 props 를 찾습니다. + * + * @param node 탐색 대상 노드 + * @param name 찾을 입력 name + * @returns 찾은 props (없으면 null) + */ +function findNumberInput(node: unknown, name: string): Record | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findNumberInput(child, name); + if (found) return found; + } + + return null; + } + + if (node && typeof node === 'object') { + const props = (node as Record).props as Record | undefined; + + if (props && props.type === 'number' && props.name === name) { + return props; + } + + for (const value of Object.values(node as Record)) { + const found = findNumberInput(value, name); + if (found) return found; + } + } + + return null; +} + +describe('개별 게시판 수정 화면 — 숫자 입력 경계값 계약 (#493 T8)', () => { + it.each(fields)('%s — min/max 가 지정되어 있다', (field, layout) => { + const props = findNumberInput(layout, field); + + expect(props, `${field} 입력을 찾지 못했습니다`).not.toBeNull(); + expect(props?.min, `${field}: min 미지정 — 사용자가 하한을 알 수 없습니다`).toBeDefined(); + expect(props?.max, `${field}: max 미지정 — 사용자가 상한을 알 수 없습니다`).toBeDefined(); + }); + + it.each(fields)('%s — 경계값이 _meta.limits 표현식이다 (리터럴 금지)', (field, layout, limitsKey) => { + const props = findNumberInput(layout, field); + + expect(String(props?.min)).toBe( + `{{_local?.form?._meta?.limits?.${limitsKey}_min ?? ${fallbacks[`${limitsKey}_min`]}}}` + ); + expect(String(props?.max)).toBe( + `{{_local?.form?._meta?.limits?.${limitsKey}_max ?? ${fallbacks[`${limitsKey}_max`]}}}` + ); + }); +}); + +/** + * 표현식 폴백 값 — `config/board.php` 의 limits 및 `ReadsBoardLimits` 기본치와 같은 값. + * + * 폴백까지 고정하는 이유: 폴백이 서버 기본치와 다르면 설정 응답이 늦거나 실패한 순간 + * 화면이 서버와 다른 경계를 보여 준다. + */ +const fallbacks: Record = { + per_page_min: 5, + per_page_max: 100, + min_title_length_min: 0, + min_title_length_max: 200, + max_title_length_min: 1, + max_title_length_max: 1000, + min_content_length_min: 0, + min_content_length_max: 10000, + max_content_length_min: 1, + max_content_length_max: 100000, + new_display_hours_min: 0, + new_display_hours_max: 720, + max_reply_depth_min: 1, + max_reply_depth_max: 10, + min_comment_length_min: 0, + min_comment_length_max: 1000, + max_comment_length_min: 1, + max_comment_length_max: 10000, + max_comment_depth_min: 0, + max_comment_depth_max: 10, + max_file_size_min: 1, + max_file_size_max: 200, + max_file_count_min: 1, + max_file_count_max: 20, +}; diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json index 8d5f4536..ca702761 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json @@ -180,6 +180,8 @@ "props": { "type": "number", "name": "per_page", + "min": "{{_local?.form?._meta?.limits?.per_page_min ?? 5}}", + "max": "{{_local?.form?._meta?.limits?.per_page_max ?? 100}}", "required": true, "disabled": "{{_computed.isReadOnly}}", "className": "" @@ -235,6 +237,8 @@ "props": { "type": "number", "name": "per_page_mobile", + "min": "{{_local?.form?._meta?.limits?.per_page_min ?? 5}}", + "max": "{{_local?.form?._meta?.limits?.per_page_max ?? 100}}", "required": true, "disabled": "{{_computed.isReadOnly}}", "className": "" diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json index 0e6bc7d9..d54430e0 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json @@ -72,6 +72,8 @@ "props": { "type": "number", "name": "min_title_length", + "min": "{{_local?.form?._meta?.limits?.min_title_length_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.min_title_length_max ?? 200}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -126,6 +128,8 @@ "props": { "type": "number", "name": "max_title_length", + "min": "{{_local?.form?._meta?.limits?.max_title_length_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_title_length_max ?? 1000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -190,6 +194,8 @@ "props": { "type": "number", "name": "min_content_length", + "min": "{{_local?.form?._meta?.limits?.min_content_length_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.min_content_length_max ?? 10000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -244,6 +250,8 @@ "props": { "type": "number", "name": "max_content_length", + "min": "{{_local?.form?._meta?.limits?.max_content_length_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_content_length_max ?? 100000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -354,6 +362,8 @@ "props": { "type": "number", "name": "new_display_hours", + "min": "{{_local?.form?._meta?.limits?.new_display_hours_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.new_display_hours_max ?? 720}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -453,7 +463,8 @@ "props": { "type": "number", "name": "max_reply_depth", - "min": 1, + "min": "{{_local?.form?._meta?.limits?.max_reply_depth_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_reply_depth_max ?? 10}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -561,6 +572,8 @@ "props": { "type": "number", "name": "min_comment_length", + "min": "{{_local?.form?._meta?.limits?.min_comment_length_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.min_comment_length_max ?? 1000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -615,6 +628,8 @@ "props": { "type": "number", "name": "max_comment_length", + "min": "{{_local?.form?._meta?.limits?.max_comment_length_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_comment_length_max ?? 10000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -672,6 +687,8 @@ "props": { "type": "number", "name": "max_comment_depth", + "min": "{{_local?.form?._meta?.limits?.max_comment_depth_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.max_comment_depth_max ?? 10}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -825,6 +842,8 @@ "props": { "type": "number", "name": "max_file_count", + "min": "{{_local?.form?._meta?.limits?.max_file_count_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_file_count_max ?? 20}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -879,6 +898,8 @@ "props": { "type": "number", "name": "max_file_size", + "min": "{{_local?.form?._meta?.limits?.max_file_size_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_file_size_max ?? 200}}", "placeholder": "$t:sirsoft-board.admin.form.fields.max_file_size.placeholder", "disabled": "{{_computed.isReadOnly}}", "className": "" diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json index cbbe1d46..908e4a24 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json @@ -43,8 +43,7 @@ "id": "date_display_format_field", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -101,10 +100,27 @@ "type": "radio", "name": "display.date_display_format", "value": "standard", - "className": "radio mt-0.5 pointer-events-none", + "className": "radio mt-0.5", "disabled": "{{_computed.isReadOnly}}", "checked": "{{(_local.form?.display?.date_display_format ?? 'standard') === 'standard'}}" - } + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form": { + "...": "{{_local.form}}", + "display": { + "...": "{{_local.form?.display}}", + "date_display_format": "standard" + } + }, + "hasChanges": true + } + } + ] }, { "type": "basic", @@ -173,10 +189,27 @@ "type": "radio", "name": "display.date_display_format", "value": "relative", - "className": "radio mt-0.5 pointer-events-none", + "className": "radio mt-0.5", "disabled": "{{_computed.isReadOnly}}", "checked": "{{(_local.form?.display?.date_display_format ?? 'standard') === 'relative'}}" - } + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form": { + "...": "{{_local.form}}", + "display": { + "...": "{{_local.form?.display}}", + "date_display_format": "relative" + } + }, + "hasChanges": true + } + } + ] }, { "type": "basic", diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php index 2581a388..9b27420b 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php @@ -382,7 +382,14 @@ class PostController extends PublicBaseController // 게시글 수정 $data = $request->validated(); - $post = $this->postService->updatePost($slug, $id, $data); + + // `attachment_ids` 를 Service 로 넘기지 않으면 검증(형식·개수 상한 합산)은 통과하고 + // 첨부만 조용히 연결되지 않는다 (200 + 첨부 0 건). 관리자 경로는 넘기고 있으므로 + // 같은 요청이 화면에 따라 다르게 동작했다. + $attachmentIds = $data['attachment_ids'] ?? []; + unset($data['attachment_ids']); + + $post = $this->postService->updatePost($slug, $id, $data, $attachmentIds); // board 관계 수동 설정 $post->setRelation('board', $board); diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php index 9028858c..a84c572e 100644 --- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Services; use App\Contracts\Extension\StorageInterface; use App\Extension\HookManager; use App\Helpers\PermissionHelper; +use App\Support\ImageResizer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\UploadedFile; @@ -127,6 +128,10 @@ class AttachmentService $path = "{$slug}/temp/{$tempKey}/{$storedFilename}"; } + // 환경설정 > 업로드의 최대 가로/세로·품질을 적용한다 (코어 설정이 모든 업로드 경로에 동일 적용). + // 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 계산이 모두 축소본을 본다. + app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType()); + // 스토리지에 파일 저장 (category: 'attachments') $this->storage->put('attachments', $path, file_get_contents($file->getRealPath())); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php b/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php index 6f50ce5b..c31ecc52 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php @@ -442,6 +442,64 @@ class AttachmentLimitTest extends BoardTestCase ->assertSuccessful(); } + /** + * 수정 시 `attachment_ids` 로 보낸 첨부가 사용자 경로에서도 실제로 연결된다. + * + * 결함: 관리자 컨트롤러는 `attachment_ids` 를 뽑아 Service 로 넘기는데 사용자 컨트롤러는 + * 넘기지 않았다. 그래서 같은 요청이 검증(형식·개수 상한 합산)은 통과하고 200 을 받는데 + * 첨부만 조용히 연결되지 않았다 — 화면에는 성공으로 보이고 첨부만 사라진다. + * 작성 경로의 `files[]` 유실과 같은 계열이며, 두 경로가 같은 강도여야 한다. + */ + public function test_update_links_attachment_ids_on_user_path(): void + { + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $tempKey = 'tk_'.str_repeat('u', 20); + $this->seedTempAttachments($tempKey, 2); + $attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all(); + + $response = $this->actingAs($this->memberUser, 'sanctum')->putJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}", + [ + 'title' => '첨부 연결 수정', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'attachment_ids' => $attachmentIds, + ] + ); + + $response->assertStatus(200); + + $this->assertSame( + 2, + Attachment::where('post_id', $postId)->count(), + 'attachment_ids 로 보낸 첨부가 게시글에 연결되지 않았습니다 — 응답은 성공인데 첨부만 사라집니다.' + ); + } + + /** + * 수정 경로의 `attachment_ids` 도 개수 상한을 넘기면 차단된다 (위 경계의 대조군). + */ + public function test_update_attachment_ids_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $tempKey = 'tk_'.str_repeat('o', 20); + $this->seedTempAttachments($tempKey, 6); + $attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all(); + + $response = $this->actingAs($this->memberUser, 'sanctum')->putJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}", + [ + 'title' => '상한 초과 첨부 수정', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'attachment_ids' => $attachmentIds, + ] + ); + + $response->assertStatus(422); + $this->assertSame(0, Attachment::where('post_id', $postId)->count()); + } + /** * 상한 미설정(0) 게시판은 제한하지 않는다 (기존 동작 유지). */ diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts new file mode 100644 index 00000000..3e7b01b9 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts @@ -0,0 +1,78 @@ +/** + * 개별 게시판 수정 화면의 숫자 입력 경계값이 서버 한계값과 일치하는지 검증 (공개 이슈 #81). + * + * 결함: 같은 설정 항목이 두 화면에 있는데 경계 안내가 한쪽에만 있었다. + * - 게시판 환경설정(모듈 기본값): 숫자 입력에 허용 범위가 붙어 있음 + * - 개별 게시판 수정: min/max 가 아예 없어 범위를 알 수 없음 + * 저장은 서버가 동일하게 막으므로 잘못된 값이 들어가지는 않았지만, 사용자는 저장을 + * 눌러 422 를 받아야만 범위를 알 수 있었다. + * + * 단위/정적: + * - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts + * 가 레이아웃 JSON 의 표현식(_local.form._meta.limits.*)과 폴백 값을 전수 고정 + * 이 spec 은 브라우저 수준 — 서버가 실제로 내려준 한계값이 DOM 속성으로 해석되는지를 담당한다. + * (표현식이 잘못된 경로를 가리켜도 폴백 값이 서버 기본치와 같으면 화면만 봐서는 구분되지 + * 않으므로, 응답의 `_meta.limits` 를 읽어 그 값과 대조한다.) + * + * 시드 의존: 샘플 시드의 자유게시판(`free`). + * + * @scenario board-form-range-parity + * @axes screen=per_board screen=module_defaults + * @effects form_boundary_matches_server_limits + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const SLUG = 'free'; + +/** 개별 게시판 화면의 입력 name → 서버 limits 키 prefix (per_page_mobile 은 per_page 범위 공유) */ +const FIELD_TO_LIMIT: Record = { + per_page: 'per_page', + per_page_mobile: 'per_page', + min_title_length: 'min_title_length', + max_title_length: 'max_title_length', + min_content_length: 'min_content_length', + max_content_length: 'max_content_length', + new_display_hours: 'new_display_hours', + min_comment_length: 'min_comment_length', + max_comment_length: 'max_comment_length', + max_comment_depth: 'max_comment_depth', + max_file_size: 'max_file_size', + max_file_count: 'max_file_count', +}; + +test('#81 - 개별 게시판 수정 화면의 숫자 경계값이 서버 한계값과 일치한다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + + await page.goto(`/admin/boards/${SLUG}/edit`); + await expect(page.locator('input[name="per_page"]')).toBeAttached({ timeout: 20_000 }); + + // 서버가 실제로 내려주는 한계값을 그대로 읽는다 (화면 폴백과 구분하기 위함) + const limits = await page.evaluate(async ({ slug, bearer }) => { + const response = await fetch( + `/api/modules/sirsoft-board/admin/boards/form-data?board_slug=${slug}`, + { headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' } } + ); + const body = await response.json(); + + return (body?.data?._meta?.limits ?? {}) as Record; + }, { slug: SLUG, bearer: boardManageToken }); + + expect( + Object.keys(limits).length, + '게시판 한계값이 폼 데이터 응답에 실려 있지 않습니다 — 화면 바인딩이 영구히 폴백으로 떨어집니다.' + ).toBeGreaterThan(0); + + for (const [field, limitKey] of Object.entries(FIELD_TO_LIMIT)) { + const input = page.locator(`input[name="${field}"]`).first(); + + // 토글 뒤에 숨은 입력(예: 답글 사용)은 이 화면 기본 상태에서 렌더되지 않을 수 있다 + if ((await input.count()) === 0) { + continue; + } + + await expect(input, `${field}: min 이 서버 한계값과 다릅니다`) + .toHaveAttribute('min', String(limits[`${limitKey}_min`])); + await expect(input, `${field}: max 가 서버 한계값과 다릅니다`) + .toHaveAttribute('max', String(limits[`${limitKey}_max`])); + } +}); diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 2bf8c30a..a937b2ea 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -30,6 +30,7 @@ ### Fixed +- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 상품 고시정보 템플릿 목록에서 한 번에 가져올 개수에 상한(100개)을 두었습니다. 이전에는 임의의 큰 값을 넘겨 한 요청으로 전체 데이터를 가져갈 수 있었습니다. '전체' 조회는 종전대로 동작합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts new file mode 100644 index 00000000..5d68b937 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts @@ -0,0 +1,118 @@ +/** + * 라디오 선택이 키보드로도 조작 가능한지 계약 테스트 (#493 후속) + * + * 배경: 라디오를 커스텀 스타일로 그리기 위해 `