diff --git a/CHANGELOG.md b/CHANGELOG.md index bf541358..d35503a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ - 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다. - 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다. +- 같은 규칙에서 벗어나 있던 필터 이름 6개(플러그인 설정 저장, 비밀번호 재설정 토큰 확인, 비밀번호 확인, 변경사항 조회, 통합 검색, 프로필 사진 업로드)를 추가로 통일했습니다. 이번에는 예전 이름도 함께 계속 발행하므로 기존 확장은 수정 없이 그대로 동작하며, 예전 이름을 사용 중인 확장이 있으면 서버 기록에 한 번 안내가 남습니다. 새로 만드는 확장은 새 이름을 사용하세요. ### Fixed @@ -75,6 +76,7 @@ - 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다. - 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). - 본인인증 진행 상태를 조회하는 화면용 응답에 지금까지의 인증 시도 횟수가 함께 실려 나가던 것을 빼도록 바로잡았습니다. 원래 내보내지 않기로 한 값이며, 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있었습니다. 화면 동작에는 영향이 없습니다. +- 플러그인 설정 화면이 저장된 비밀값(암호화 키·API 시크릿 등)을 다시 내려받던 것을 가려서 표시하도록 바로잡았습니다. 값이 저장되어 있다는 표시만 보여주며, 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지됩니다. - 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다. - 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다. - 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다. diff --git a/app/Extension/HookManager.php b/app/Extension/HookManager.php index 6af97a4b..9d214950 100644 --- a/app/Extension/HookManager.php +++ b/app/Extension/HookManager.php @@ -19,6 +19,13 @@ class HookManager implements HookManagerInterface private static array $dispatching = []; + /** + * 구 훅 이름 사용 경고를 이미 남긴 훅 이름 집합 — 요청마다 한 번씩만 경고합니다. + * + * @var array + */ + private static array $legacyHookNoticeShown = []; + /** * 현재 실행 중인 훅 이름 스택 — 정책 Listener 등 "어느 훅에서 호출되었는지" 알아야 하는 * 단일 핸들러 패턴에 사용됩니다. (내부 전용) @@ -362,4 +369,43 @@ class HookManager implements HookManagerInterface return static::applyFilters($hookName, $value, ...$args); } + + /** + * 표준 이름과 구 이름을 함께 발행하는 Filter 실행 + * + * 이름이 표준(`core.{대상}.{동작}_validation_rules`)과 어긋난 채 이미 공개된 훅을 표준 이름으로 + * 옮길 때 사용합니다. 표준 이름을 먼저 적용하고 그 결과에 구 이름을 다시 적용하므로, + * 새로 구독하는 확장과 구 이름을 구독 중인 기존 확장이 함께 동작합니다. + * + * 구 이름에 실제 구독자가 있을 때만 한 번 경고를 남깁니다 — 개명 사실을 확장 개발자가 알 수 있게 + * 하되, 구독자가 없는 환경에서 로그를 어지럽히지 않기 위함입니다. + * + * @param string $hookName 표준 훅 이름 + * @param string $legacyHookName 구 훅 이름 (하위호환용) + * @param mixed $value 필터링할 값 + * @param mixed ...$args 필터에 전달할 추가 인자 + * @return mixed 필터링된 값 + */ + public static function applyFiltersWithLegacyName( + string $hookName, + string $legacyHookName, + mixed $value = null, + ...$args + ): mixed { + $value = static::applyFilters($hookName, $value, ...$args); + + if (! isset(self::$filters[$legacyHookName])) { + return $value; + } + + if (! isset(self::$legacyHookNoticeShown[$legacyHookName])) { + self::$legacyHookNoticeShown[$legacyHookName] = true; + Log::warning('구 훅 이름 사용 중 — 표준 이름으로 옮겨 주세요', [ + 'legacy' => $legacyHookName, + 'standard' => $hookName, + ]); + } + + return static::applyFilters($legacyHookName, $value, ...$args); + } } diff --git a/app/Http/Controllers/Api/Admin/PluginSettingsController.php b/app/Http/Controllers/Api/Admin/PluginSettingsController.php index 0bdae085..cebd3ef2 100644 --- a/app/Http/Controllers/Api/Admin/PluginSettingsController.php +++ b/app/Http/Controllers/Api/Admin/PluginSettingsController.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\Api\Admin; +use App\Extension\PluginManager; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Admin\UpdatePluginSettingsRequest; use App\Services\PluginSettingsService; +use App\Support\SensitiveSettingMask; use Illuminate\Http\JsonResponse; /** @@ -18,13 +20,35 @@ class PluginSettingsController extends AdminBaseController * PluginSettingsController 생성자 * * @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스 + * @param PluginManager $pluginManager 설정 스키마(sensitive 플래그) 조회용 */ public function __construct( - private PluginSettingsService $pluginSettingsService + private PluginSettingsService $pluginSettingsService, + private PluginManager $pluginManager ) { parent::__construct(); } + /** + * 응답에 실을 설정에서 민감값을 마스크로 치환한다. + * + * 이 응답은 관리자 화면으로 나가므로 암호화 키·시크릿의 평문이 브라우저·개발자 도구·프록시 + * 로그에 남지 않아야 한다. 값이 저장되어 있다는 사실만 마스크로 알린다. 운영자가 값을 건드리지 + * 않으면 화면이 마스크를 그대로 되돌려 보내고, 저장 단계가 그것을 걸러 기존 값을 보존한다. + * + * @param string $identifier 플러그인 식별자 + * @param array $settings 복호화된 설정 + * @return array 마스킹된 설정 + */ + private function maskSensitive(string $identifier, array $settings): array + { + $plugin = $this->pluginManager->getPlugin($identifier); + + return $plugin === null + ? $settings + : SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema()); + } + /** * 플러그인 설정을 조회합니다. * @@ -39,7 +63,7 @@ class PluginSettingsController extends AdminBaseController return $this->notFound('plugins.not_found'); } - return $this->success('messages.success', $settings); + return $this->success('messages.success', $this->maskSensitive($identifier, $settings)); } /** @@ -65,7 +89,7 @@ class PluginSettingsController extends AdminBaseController return $this->success( 'plugins.settings.updated', - $this->pluginSettingsService->get($identifier) + $this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? []) ); } diff --git a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php index f988857f..2b25a1e6 100644 --- a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php +++ b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php @@ -106,19 +106,16 @@ class UpdatePluginSettingsRequest extends FormRequest $rules[$field] = $fieldRules; } - // audit:allow core-formrequest-hook-filter reason: 훅 이름이 표준(`{대상}.{동작}_validation_rules`)과 - // 어긋난 상태로 이미 공개되어 있다(`core.plugin_settings.update_rules`). 같은 이탈이 코어에 - // 6개 더 있으며(core.auth.validate_reset_token_rules / core.auth.verify_password_rules / - // core.extension.changelog_rules / core.layout.get_validation_messages / - // core.search.validation_rules / core.user.upload_avatar_rules), 이름을 바꾸면 이 훅을 - // 구독 중인 제3자 확장이 조용히 동작을 멈춘다. 일괄 개명은 PO 판단 사항이라 여기서 - // 단독으로 바꾸지 않는다. + // 표준 이름(`core.{대상}.{동작}_validation_rules`)으로 발행하되, 이미 공개돼 구독 중일 수 있는 + // 구 이름(`core.plugin_settings.update_rules`)도 함께 발행한다 — 구 이름을 구독하는 제3자 + // 확장이 조용히 멈추지 않도록 한다. 구 이름은 구독자가 있을 때만 1회 경고가 남는다. // // 3번째 인자로 이번 요청의 입력값을 함께 넘긴다. 확장이 "현재 입력한 모드에 따라 // 다른 필드를 필수로 만드는" 조건부 규칙을 만들려면 입력값이 필요한데, 그것이 없어 // 확장이 request() 를 직접 들여다보던 것을 없애기 위함이다. // 기존 2인자 구독자는 그대로 동작한다(초과 인자는 무시된다). - return HookManager::applyFilters( + return HookManager::applyFiltersWithLegacyName( + 'core.plugin_settings.update_validation_rules', 'core.plugin_settings.update_rules', $rules, $identifier, diff --git a/app/Http/Requests/Auth/ValidateResetTokenRequest.php b/app/Http/Requests/Auth/ValidateResetTokenRequest.php index 7a4cd8a9..531799eb 100644 --- a/app/Http/Requests/Auth/ValidateResetTokenRequest.php +++ b/app/Http/Requests/Auth/ValidateResetTokenRequest.php @@ -13,11 +13,13 @@ class ValidateResetTokenRequest extends FormRequest /** * Determine if the user is authorized to make this request. * + * 공개 엔드포인트이므로 항상 통과시킨다 — 접근 제어는 미들웨어가 담당한다. + * * @return bool */ public function authorize(): bool { - return true; // 공개 엔드포인트 + return true; } /** @@ -32,7 +34,8 @@ class ValidateResetTokenRequest extends FormRequest 'email' => 'required|email', ]; - return HookManager::applyFilters( + return HookManager::applyFiltersWithLegacyName( + 'core.auth.validate_reset_token_validation_rules', 'core.auth.validate_reset_token_rules', $rules, $this diff --git a/app/Http/Requests/Auth/VerifyPasswordRequest.php b/app/Http/Requests/Auth/VerifyPasswordRequest.php index 578b4470..fb30c3fc 100644 --- a/app/Http/Requests/Auth/VerifyPasswordRequest.php +++ b/app/Http/Requests/Auth/VerifyPasswordRequest.php @@ -9,6 +9,8 @@ class VerifyPasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { @@ -27,6 +29,9 @@ class VerifyPasswordRequest extends FormRequest ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 - return HookManager::applyFilters('core.auth.verify_password_rules', $rules); + return HookManager::applyFiltersWithLegacyName( + 'core.auth.verify_password_validation_rules', + 'core.auth.verify_password_rules', + $rules); } } diff --git a/app/Http/Requests/Extension/ChangelogRequest.php b/app/Http/Requests/Extension/ChangelogRequest.php index 4bf6da1e..89cd72e1 100644 --- a/app/Http/Requests/Extension/ChangelogRequest.php +++ b/app/Http/Requests/Extension/ChangelogRequest.php @@ -14,6 +14,8 @@ class ChangelogRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { @@ -33,7 +35,10 @@ class ChangelogRequest extends FormRequest 'to_version' => 'nullable|string|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$/|required_with:from_version', ]; - return HookManager::applyFilters('core.extension.changelog_rules', $rules); + return HookManager::applyFiltersWithLegacyName( + 'core.extension.changelog_validation_rules', + 'core.extension.changelog_rules', + $rules); } /** diff --git a/app/Http/Requests/Public/SearchRequest.php b/app/Http/Requests/Public/SearchRequest.php index 47845067..00fc8f22 100644 --- a/app/Http/Requests/Public/SearchRequest.php +++ b/app/Http/Requests/Public/SearchRequest.php @@ -15,6 +15,8 @@ class SearchRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { @@ -39,7 +41,10 @@ class SearchRequest extends FormRequest // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 // 예: sort의 in 규칙, board_slug 등 모듈별 파라미터 - return HookManager::applyFilters('core.search.validation_rules', $rules, $this); + return HookManager::applyFiltersWithLegacyName( + 'core.search.index_validation_rules', + 'core.search.validation_rules', + $rules, $this); } /** diff --git a/app/Http/Requests/User/UploadAvatarRequest.php b/app/Http/Requests/User/UploadAvatarRequest.php index 11d9413d..9c55a20a 100644 --- a/app/Http/Requests/User/UploadAvatarRequest.php +++ b/app/Http/Requests/User/UploadAvatarRequest.php @@ -24,6 +24,8 @@ class UploadAvatarRequest extends FormRequest /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { @@ -46,7 +48,10 @@ class UploadAvatarRequest extends FormRequest ], ]; - return HookManager::applyFilters('core.user.upload_avatar_rules', $rules); + return HookManager::applyFiltersWithLegacyName( + 'core.user.upload_avatar_validation_rules', + 'core.user.upload_avatar_rules', + $rules); } /** diff --git a/app/Services/PluginSettingsService.php b/app/Services/PluginSettingsService.php index ec30888e..b8f6e2a5 100644 --- a/app/Services/PluginSettingsService.php +++ b/app/Services/PluginSettingsService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Extension\HookManager; use App\Extension\PluginManager; use App\Extension\TemplateManager; +use App\Support\SensitiveSettingMask; use App\Traits\FiltersFrontendSchema; use App\Traits\NormalizesSettingsData; use Illuminate\Support\Arr; @@ -140,6 +141,17 @@ class PluginSettingsService return $settings ?? []; } + /** + * 플러그인 설정을 저장합니다. + * + * 스키마의 sensitive 필드는 암호화하여 보관하며, 화면이 마스크를 그대로 되돌려 보낸 항목은 + * "변경 없음" 으로 보아 기존 값을 유지합니다. 저장은 기존 설정 위 병합이므로 요청에 없는 + * 키는 종전 값이 남습니다. + * + * @param string $identifier 플러그인 식별자 + * @param array $settings 저장할 설정 (검증 통과분) + * @return bool 저장 성공 여부 + */ public function save(string $identifier, array $settings): bool { // Before 훅 @@ -162,6 +174,9 @@ class PluginSettingsService // 스키마 기반으로 민감한 필드 암호화 $schema = $pluginInstance->getSettingsSchema(); + // 화면이 마스크를 그대로 되돌려 보낸 sensitive 필드는 "변경 없음" 이다 — 병합에서 기존 값이 + // 유지되도록 요청에서 제거한다. 빈 문자열은 제거하지 않는다(지우겠다는 명시적 의사). + $settings = SensitiveSettingMask::stripUnchanged($settings, $schema); $settings = $this->encryptSensitiveFields($settings, $schema); // 기존 설정과 병합 @@ -202,6 +217,14 @@ class PluginSettingsService return $storage->put('settings', self::SETTINGS_FILENAME, $content); } + /** + * 플러그인 설정을 기본값으로 되돌립니다. + * + * 설정 파일을 삭제하고 캐시를 비웁니다 — 이후 조회는 플러그인이 선언한 기본값을 돌려줍니다. + * + * @param string $identifier 플러그인 식별자 + * @return bool 초기화 성공 여부 + */ public function reset(string $identifier): bool { // Before 훅 @@ -230,6 +253,14 @@ class PluginSettingsService return true; } + /** + * 플러그인 설정 디렉토리를 통째로 삭제합니다. + * + * 플러그인 삭제 시 남은 설정 파일을 정리하는 용도입니다. + * + * @param string $identifier 플러그인 식별자 + * @return bool 삭제 성공 여부 + */ public function deleteSettingsDirectory(string $identifier): bool { // Before 훅 diff --git a/app/Support/SensitiveSettingMask.php b/app/Support/SensitiveSettingMask.php new file mode 100644 index 00000000..c9b54228 --- /dev/null +++ b/app/Support/SensitiveSettingMask.php @@ -0,0 +1,80 @@ + $settings 설정 배열 (복호화된 상태) + * @param array $schema 설정 스키마 (sensitive 플래그 보유) + * @return array 마스킹된 설정 배열 + */ + public static function apply(array $settings, array $schema): array + { + foreach ($schema as $field => $config) { + if (! is_array($config) || ! ($config['sensitive'] ?? false)) { + continue; + } + if (! array_key_exists($field, $settings)) { + continue; + } + if ($settings[$field] === null || $settings[$field] === '') { + continue; + } + + $settings[$field] = self::MASK; + } + + return $settings; + } + + /** + * 저장 요청에서 마스크로 되돌아온 sensitive 필드를 제거한다. + * + * 제거된 필드는 저장 단계의 기존 설정 병합에서 종전 값이 유지된다. + * + * @param array $settings 저장 요청 설정 배열 + * @param array $schema 설정 스키마 + * @return array 마스크 필드가 제거된 설정 배열 + */ + public static function stripUnchanged(array $settings, array $schema): array + { + foreach ($schema as $field => $config) { + if (! is_array($config) || ! ($config['sensitive'] ?? false)) { + continue; + } + if (($settings[$field] ?? null) === self::MASK) { + unset($settings[$field]); + } + } + + return $settings; + } +} diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md index 76829251..e5402fca 100644 --- a/docs/backend/api/auth.md +++ b/docs/backend/api/auth.md @@ -2860,7 +2860,7 @@ HTTP/1.1 200 | token | body | string | 예 | — | 인증/검증 토큰 | | email | body | email | 예 | — | 이메일 주소 | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_rules`, `core.auth.validate_reset_token_messages`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_validation_rules`, `core.auth.validate_reset_token_messages`). **요청 예시** diff --git a/docs/backend/api/avatar.md b/docs/backend/api/avatar.md index fa1fe9cf..6c0a2046 100644 --- a/docs/backend/api/avatar.md +++ b/docs/backend/api/avatar.md @@ -67,7 +67,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | --- | --- | --- | | avatar | body | image | 예 | max 2048 | 아바타 이미지 | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_validation_rules`). **요청 예시** @@ -103,6 +103,6 @@ Content-Type: image/png -**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_rules` 훅으로 검증 규칙을 추가할 수 있습니다. +**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_validation_rules` 훅으로 검증 규칙을 추가할 수 있습니다. diff --git a/docs/backend/api/core-update.md b/docs/backend/api/core-update.md index fda80651..7fa9d80d 100644 --- a/docs/backend/api/core-update.md +++ b/docs/backend/api/core-update.md @@ -31,7 +31,7 @@ | from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | | to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`). **요청 예시** @@ -1586,7 +1586,7 @@ HTTP/1.1 200 -**설명** 코어의 버전별 변경사항(CHANGELOG)을 구조화된 배열로 조회합니다. `source`(active/bundled/github)로 어느 위치의 CHANGELOG를 읽을지, `from_version`/`to_version`으로 조회 범위를 지정합니다. `core.settings.read` 권한이 필요하며, 업데이트 안내 화면에서 새 버전에 무엇이 바뀌는지 보여줄 때 사용합니다. 확장은 `core.extension.changelog_rules` 훅으로 파라미터를 확장할 수 있습니다. +**설명** 코어의 버전별 변경사항(CHANGELOG)을 구조화된 배열로 조회합니다. `source`(active/bundled/github)로 어느 위치의 CHANGELOG를 읽을지, `from_version`/`to_version`으로 조회 범위를 지정합니다. `core.settings.read` 권한이 필요하며, 업데이트 안내 화면에서 새 버전에 무엇이 바뀌는지 보여줄 때 사용합니다. 확장은 `core.extension.changelog_validation_rules` 훅으로 파라미터를 확장할 수 있습니다. ### POST /api/admin/core-update/check diff --git a/docs/backend/api/modules.md b/docs/backend/api/modules.md index baa90713..a9aa6a75 100644 --- a/docs/backend/api/modules.md +++ b/docs/backend/api/modules.md @@ -902,7 +902,7 @@ HTTP/1.1 200 | from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | | to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`). **요청 예시** diff --git a/docs/backend/api/plugins.md b/docs/backend/api/plugins.md index 502ad42c..05bfd0e3 100644 --- a/docs/backend/api/plugins.md +++ b/docs/backend/api/plugins.md @@ -753,7 +753,7 @@ Authorization: Bearer {YOUR_TOKEN} | from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | | to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`). **요청 예시** @@ -924,7 +924,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | --- | --- | --- | | identifier | path | string | 예 | — | 대상 리소스의 식별자 | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin_settings.update_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin_settings.update_validation_rules`). **요청 예시** diff --git a/docs/backend/api/search.md b/docs/backend/api/search.md index b1ee332b..1a763230 100644 --- a/docs/backend/api/search.md +++ b/docs/backend/api/search.md @@ -32,10 +32,10 @@ | sort | query | string | 아니오 | `relevance`, `latest`, `oldest`, `views`, `popular`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | | per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | -| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) | +| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.index_validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) | | category_id | query | integer | 아니오 | — | category 식별자 | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.validation_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.index_validation_rules`). **요청 예시** diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md index 0603c52e..b2a13ac6 100644 --- a/docs/backend/api/templates.md +++ b/docs/backend/api/templates.md @@ -704,7 +704,7 @@ Authorization: Bearer {YOUR_TOKEN} | from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | | to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`). **요청 예시** diff --git a/docs/backend/api/verify-password.md b/docs/backend/api/verify-password.md index 44d418dd..7bff8313 100644 --- a/docs/backend/api/verify-password.md +++ b/docs/backend/api/verify-password.md @@ -29,7 +29,7 @@ | --- | --- | --- | --- | --- | --- | | password | body | string | 예 | — | 비밀번호 | -> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_rules`). +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_validation_rules`). **요청 예시** diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index 1b3e8268..6a79391b 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -131,6 +131,15 @@ core.permission.update_validation_rules core.menu.store_validation_rules core.menu.update_validation_rules +# 구 이름에서 표준 이름으로 옮긴 훅 (7.0.6) — 구 이름도 함께 발행되므로 기존 구독은 유지되나, +# 새로 구독할 때는 표준 이름을 쓴다. 구 이름에 구독자가 있으면 로그에 1회 안내가 남는다. +core.plugin_settings.update_validation_rules # 구: core.plugin_settings.update_rules +core.auth.validate_reset_token_validation_rules # 구: core.auth.validate_reset_token_rules +core.auth.verify_password_validation_rules # 구: core.auth.verify_password_rules +core.extension.changelog_validation_rules # 구: core.extension.changelog_rules +core.search.index_validation_rules # 구: core.search.validation_rules +core.user.upload_avatar_validation_rules # 구: core.user.upload_avatar_rules + # Layout Extension 훅 core.layout_extension.before_apply core.layout_extension.after_apply diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php index b159844a..4e87a04a 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php @@ -14,7 +14,7 @@ use Modules\Sirsoft\Board\Traits\FormatsBoardDate; * * core.search.results Filter Hook을 구독하여 검색 결과에 게시글을 추가합니다. * core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다. - * core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다. + * core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다. */ class SearchPostsListener implements HookListenerInterface { @@ -43,7 +43,7 @@ class SearchPostsListener implements HookListenerInterface 'priority' => 10, 'type' => 'filter', ], - 'core.search.validation_rules' => [ + 'core.search.index_validation_rules' => [ 'method' => 'addValidationRules', 'priority' => 10, 'type' => 'filter', diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php index a4d2f2e7..d74a9b76 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php @@ -38,7 +38,7 @@ class SearchPostsListenerTest extends TestCase $this->assertArrayHasKey('core.search.results', $hooks); $this->assertArrayHasKey('core.search.build_response', $hooks); - $this->assertArrayHasKey('core.search.validation_rules', $hooks); + $this->assertArrayHasKey('core.search.index_validation_rules', $hooks); foreach ($hooks as $hook) { $this->assertEquals('filter', $hook['type']); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php index 52bc0fc7..4e81f347 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php @@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Services\ProductService; * * core.search.results Filter Hook을 구독하여 검색 결과에 상품을 추가합니다. * core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다. - * core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다. + * core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다. */ class SearchProductsListener implements HookListenerInterface { @@ -41,7 +41,7 @@ class SearchProductsListener implements HookListenerInterface 'priority' => 20, 'type' => 'filter', ], - 'core.search.validation_rules' => [ + 'core.search.index_validation_rules' => [ 'method' => 'addValidationRules', 'priority' => 20, 'type' => 'filter', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php index ae099091..a3ebee74 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php @@ -36,7 +36,7 @@ class SearchProductsListenerTest extends TestCase $this->assertArrayHasKey('core.search.results', $hooks); $this->assertArrayHasKey('core.search.build_response', $hooks); - $this->assertArrayHasKey('core.search.validation_rules', $hooks); + $this->assertArrayHasKey('core.search.index_validation_rules', $hooks); // 모두 filter 타입인지 확인 foreach ($hooks as $hook) { diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php index 7e6ab6f2..25196217 100644 --- a/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php +++ b/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php @@ -11,7 +11,7 @@ use Modules\Sirsoft\Page\Services\PageService; * * core.search.results Filter Hook을 구독하여 검색 결과에 페이지를 추가합니다. * core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다. - * core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다. + * core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다. */ class SearchPagesListener implements HookListenerInterface { @@ -37,7 +37,7 @@ class SearchPagesListener implements HookListenerInterface 'priority' => 10, 'type' => 'filter', ], - 'core.search.validation_rules' => [ + 'core.search.index_validation_rules' => [ 'method' => 'addValidationRules', 'priority' => 10, 'type' => 'filter', diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php index 76bfb006..e06da21d 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php @@ -49,7 +49,7 @@ class SearchPagesListenerTest extends ModuleTestCase $this->assertCount(3, $hooks); $this->assertArrayHasKey('core.search.results', $hooks); $this->assertArrayHasKey('core.search.build_response', $hooks); - $this->assertArrayHasKey('core.search.validation_rules', $hooks); + $this->assertArrayHasKey('core.search.index_validation_rules', $hooks); } /** diff --git a/plugins/_bundled/sirsoft-verification_kginicis/plugin.php b/plugins/_bundled/sirsoft-verification_kginicis/plugin.php index 25645735..5e1bb08e 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/plugin.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/plugin.php @@ -76,7 +76,7 @@ class Plugin extends AbstractPlugin * * 라이브 모드(is_test_mode=false) 진입 시 live_mid / live_api_key 를 required 로 강제하는 * 조건부 검증은 본 정적 스키마로 표현할 수 없어 ValidateInicisSettingsListener 가 - * core.plugin_settings.update_rules 필터로 동적 부여한다. + * core.plugin_settings.update_validation_rules 필터로 동적 부여한다. * * @return array> */ diff --git a/plugins/_bundled/sirsoft-verification_kginicis/src/Listeners/ValidateInicisSettingsListener.php b/plugins/_bundled/sirsoft-verification_kginicis/src/Listeners/ValidateInicisSettingsListener.php index bb6b7df1..28bb390f 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/src/Listeners/ValidateInicisSettingsListener.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/src/Listeners/ValidateInicisSettingsListener.php @@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Lang; * * 코어 UpdatePluginSettingsRequest 의 정적 스키마는 `required`/타입 규칙만 표현할 수 있어 * "is_test_mode=false 일 때만 live_mid / live_api_key 필수" 같은 조건부 검증을 담을 수 없다. - * 본 listener 가 `core.plugin_settings.update_rules` filter 로 라이브 모드 진입 시 라이브 + * 본 listener 가 `core.plugin_settings.update_validation_rules` filter 로 라이브 모드 진입 시 라이브 * 자격증명에 required 규칙을 동적 부여한다. * * 검증 기준은 InicisIdentityProvider::isAvailable() 의 라이브 운영 가능 조건 @@ -34,7 +34,7 @@ class ValidateInicisSettingsListener implements HookListenerInterface public static function getSubscribedHooks(): array { return [ - 'core.plugin_settings.update_rules' => [ + 'core.plugin_settings.update_validation_rules' => [ 'method' => 'addLiveModeRules', 'priority' => 10, 'type' => 'filter', diff --git a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/live-mode-settings-validation.yaml b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/live-mode-settings-validation.yaml index e9ae6f89..6acf8d04 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/live-mode-settings-validation.yaml +++ b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/live-mode-settings-validation.yaml @@ -5,7 +5,7 @@ description: | (PUT /api/admin/plugins/{id}/settings → UpdatePluginSettingsRequest → PluginSettingsService)에서: - (라이브 검증) is_test_mode=false(라이브 모드) 진입 시 live_mid / live_api_key 를 required 로 강제. 조건부 검증은 코어 정적 스키마로 표현 불가하므로 ValidateInicisSettingsListener 가 - core.plugin_settings.update_rules 필터로 동적 부여. + core.plugin_settings.update_validation_rules 필터로 동적 부여. - (연관) plugin.php::getSettingsSchema() 를 결제 플러그인 동형으로 정의하고 키 필드에 sensitive:true 를 부여해 API 키 평문 저장 결함 해소 (저장 시 암호화 / 조회 시 복호화). - (연관) verify()/cancel() 의 재처리 가드가 enum 캐스팅된 status 와 @@ -44,7 +44,7 @@ test_files: rules_layer_coverage: - rule: filter-hook-type-marker - coverage: ValidateInicisSettingsListener::getSubscribedHooks() 가 core.plugin_settings.update_rules 에 type='filter' 명시 (반환값 무시 회귀 차단). + coverage: ValidateInicisSettingsListener::getSubscribedHooks() 가 core.plugin_settings.update_validation_rules 에 type='filter' 명시 (반환값 무시 회귀 차단). - rule: listener-must-implement-hooklistenerinterface coverage: ValidateInicisSettingsListener implements HookListenerInterface + 정적 getSubscribedHooks(). - rule: i18n-key-naming @@ -57,7 +57,7 @@ design_rationale: | 코어 UpdatePluginSettingsRequest 가 빈 스키마를 받음 → 검증 규칙 0개 → 무조건 통과. - GDPR 은 자체 라우트+FormRequest 로 정밀 검증하나, 본인인증 플러그인엔 과함. - 채택: (a) 스키마를 plugin.php 로 이전(결제 플러그인 동형 + sensitive) + (b) 라이브 조건부 - required 는 코어가 이미 발행하는 core.plugin_settings.update_rules 필터로 주입. 코어 무수정. + required 는 코어가 이미 발행하는 core.plugin_settings.update_validation_rules 필터로 주입. 코어 무수정. - 라이브 검증 기준은 InicisIdentityProvider::isAvailable()(live_mid + live_api_key 비어있지 않음) 과 일치. live_mid 의 SRB 프리픽스는 buildLiveMid() 가 자동 부착하므로 별도 형식 검증 미도입. - (라벨 로케일 보강) ServiceProvider 의 validation.attributes 라벨 등록이 ['ko','en'] 하드코딩이라 diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md index c860004a..7d3bff49 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md @@ -42,3 +42,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 플러그인을 설치하면 성인인증 항목의 본인확인 수단이 NHN KCP 로 자동 설정됩니다. 환경설정에서 다른 수단으로 변경할 수 있습니다. 플러그인을 삭제하면 이 설정도 함께 정리되어 존재하지 않는 인증 수단이 남지 않습니다(운영자가 다른 수단으로 바꿔 둔 경우에는 그 설정을 유지합니다). - 한국어·영어를 기본 제공하며, 일본어는 번들 언어팩으로 제공됩니다. - 코어 7.0.6 이상이 필요합니다. 운영 모드 전환 시 사이트코드·암호화 키를 필수로 검사하는 기능이 코어 7.0.6 의 설정 저장 검증 표면을 사용합니다. +- 인증창을 그냥 닫고 이탈하면 그 인증 요청을 서버에도 종료로 표시합니다. 이전에는 브라우저만 알고 있어 인증 이력에 "보낸 뒤 소식 없는" 항목이 남았습니다. diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-verification_nhnkcp/dist/js/plugin.iife.js index 2b19a09e..d55e17fb 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/dist/js/plugin.iife.js @@ -1,2 +1,2 @@ -var SirsoftVerificationNhnkcp=function(c){"use strict";const i="sirsoft-verification_nhnkcp",M="nhnkcp",k="width=410,height=500,scrollbars=yes,resizable=yes",E="kcp_cert_popup",S="identity-challenge-modal",A="g7.identity.redirectStash",h="sirsoft-verification_nhnkcp.formStash",l={info:(...e)=>console.info(`[${i}]`,...e),warn:(...e)=>console.warn(`[${i}]`,...e),error:(...e)=>console.error(`[${i}]`,...e)};function u(){return window.G7Core??null}const R={9999:`$t:${i}.errors.cancelled`,NOT_ADULT:`$t:${i}.errors.not_adult`,DUPLICATE:`$t:${i}.errors.duplicate`,REMOTE_CALL_FAILED:`$t:${i}.errors.remote_call_failed`,DECRYPT_FAILED:`$t:${i}.errors.decrypt_failed`,INCOMPLETE_IDENTITY:`$t:${i}.errors.incomplete_identity`,NOT_FOUND:`$t:${i}.errors.not_found`,ALREADY_CONSUMED:`$t:${i}.errors.already_consumed`,EXPIRED:`$t:${i}.errors.not_found`,IDENTITY_BINDING_MISMATCH:`$t:${i}.errors.binding_mismatch`,STORAGE_FAILED:`$t:${i}.errors.storage_failed`,PROVIDER_ERROR:`$t:${i}.errors.verify_failed`},v=`$t:${i}.errors.verify_failed`,T=new Set(["NOT_ADULT","DUPLICATE"]);function F(e){return R[e]??v}function g(e,t){const n=R[e];if(n)return n;const r=(t??"").trim();return r===""?v:e?`[${e}] ${r}`:r}const N=1023;function b(){if(typeof window>"u")return!1;const e=window.navigator?.userAgent??"";return/Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(e)?!0:typeof window.innerWidth=="number"&&window.innerWidth<=N}function p(e){const t=u()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}function I(){const e=u()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function _(e,t="error"){const n=u()?.dispatch;if(typeof n=="function")try{await n({handler:"toast",params:{type:t,message:e}})}catch{}}function O(e,t){const n=document.createElement("form");n.setAttribute("name","kcpCertForm"),n.setAttribute("method","POST"),n.setAttribute("action",e.call_url),n.setAttribute("target",t),n.setAttribute("accept-charset","UTF-8"),n.style.display="none";const r={reg_cert_key:e.reg_cert_key,kcp_page_submit_yn:"Y"};for(const[o,s]of Object.entries(r)){const a=document.createElement("input");a.type="hidden",a.name=o,a.value=s,n.appendChild(a)}document.body.appendChild(n),n.submit(),window.setTimeout(()=>{try{n.remove()}catch{}},0)}function U(){try{window.sessionStorage.setItem(A,JSON.stringify({return_url:window.location.href,payload:{},stashed_at:Date.now()}))}catch(e){l.warn("복귀 주소 보관 실패 — 인증 후 처음 화면으로 돌아갈 수 있습니다.",e)}try{window.sessionStorage.setItem(h,JSON.stringify({fields:G(),stashed_at:Date.now()}))}catch(e){l.warn("입력값 보관 실패",e)}}function G(){const e={};return document.querySelectorAll("input[name], select[name], textarea[name]").forEach(n=>{const r=n.getAttribute("name")??"";if(!r)return;const o=(n.getAttribute("type")??"").toLowerCase();if(o==="password"||o==="file"||o==="hidden")return;if(o==="checkbox"||o==="radio"){n.checked&&(e[r]=String(n.value??"on"));return}const s=String(n.value??"");s!==""&&(e[r]=s)}),e}function H(e){const t=Object.keys(e);if(t.length===0)return;let n=0;const r=window.setInterval(()=>{n+=1;let o=0;t.forEach(s=>{const a=document.querySelector(`[name="${Y(s)}"]`);if(!a)return;const d=(a.getAttribute("type")??"").toLowerCase();if(d==="checkbox"||d==="radio"){const f=a;f.value===e[s]&&!f.checked&&f.click(),o+=1;return}j(a,e[s]),o+=1}),(o===t.length||n>=25)&&window.clearInterval(r)},200)}function Y(e){const t=globalThis.CSS;return t&&typeof t.escape=="function"?t.escape(e):e.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}function j(e,t){const n=Object.getPrototypeOf(e),r=Object.getOwnPropertyDescriptor(n,"value");r?.set?r.set.call(e,t):e.value=t,e.dispatchEvent(new Event("input",{bubbles:!0})),e.dispatchEvent(new Event("change",{bubbles:!0}))}function K(e){const t=u()?.dispatch;return typeof t!="function"?Promise.resolve():t({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:S}]}}).catch(()=>{})}const P=new Set;function $(e){e&&P.add(e)}function V(e){return!!e&&P.has(e)}async function B(e){const t=u(),n={purpose:e.purpose};e.provider_id&&(n.provider_id=e.provider_id),e.target&&(n.target=e.target);try{const r=t?.api?.getToken?.()??null,o=(()=>{try{return window.localStorage?.getItem("g7_locale")}catch{return null}})(),s=await fetch("/api/identity/challenges",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Accept:"application/json",...o?{"Accept-Language":o}:{},...r?{Authorization:`Bearer ${r}`}:{}},body:JSON.stringify(n)}),a=await s.json().catch(()=>null);if(!s.ok||!a?.success)return l.error("challenge 재발급 실패",a),null;const d=a.data??a,f={...e,challenge_id:d.id,expires_at:d.expires_at,public_payload:d.public_payload??{},error:null};return t?.state?.set?.({identityChallenge:f}),f}catch(r){return l.error("challenge 재발급 중 오류",r),null}}async function y(){const e=u();let t=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!t){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}if(V(t.challenge_id)){const o=await B(t);if(!o){await _(`$t:${i}.modal.payload_not_ready`);return}t=o}const n=t.public_payload;if(!n?.call_url||!n?.reg_cert_key){l.error("public_payload 의 call_url/reg_cert_key 부재 — 거래등록 응답 확인 필요",n),await _(`$t:${i}.modal.payload_not_ready`);return}if(b()){U(),p(!0),O(n,"_self");return}const r=window.open("",E,k);if(!r){l.error("window.open null — 팝업 차단됨"),await _(`$t:${i}.modal.popup_blocked`);return}p(!0),O(n,E),q(r)}function q(e){let t=!1,n=null,r=null;const o=()=>{r&&(window.removeEventListener("message",r),r=null),n!==null&&(window.clearInterval(n),n=null)};r=s=>{if(s.origin!==window.location.origin)return;const a=s.data;if(!a||a.type!=="identity_result"||t)return;t=!0,o();try{e.close()}catch{}if(p(!1),a.identity_error){const f=a.challenge_id||u()?.state?.getGlobal?.()?.identityChallenge?.challenge_id;$(f),T.has(a.identity_error)&&I(),_(g(a.identity_error,a.identity_error_message),"error");return}const d=a.verification_token?{result:"verified",token:a.verification_token}:{result:"cancelled"};K(d)},window.addEventListener("message",r),n=window.setInterval(()=>{if(t){o();return}e.closed&&(t=!0,o(),p(!1),$(u()?.state?.getGlobal?.()?.identityChallenge?.challenge_id))},500)}function w(){if(typeof window>"u")return;const e=new URLSearchParams(window.location.search),t=e.get("verification_token")??"",n=e.get("identity_error")??"";if(t===""&&n==="")return;const r=J();if(r&&H(r.fields),t!==""){_(`$t:${i}.modal.mobile_return_success`,"success");return}T.has(n)&&I(),_(g(n,e.get("identity_error_message")??void 0),"error"),e.delete("identity_error"),e.delete("identity_error_message");const o=e.toString(),s=window.location.pathname+(o?`?${o}`:"")+window.location.hash;try{window.history.replaceState(null,"",s)}catch{}}function J(){let e=null;try{e=window.sessionStorage.getItem(h),window.sessionStorage.removeItem(h)}catch{return null}if(!e)return null;try{const t=JSON.parse(e);return!t?.fields||typeof t.stashed_at!="number"||Date.now()-t.stashed_at>6e5?null:t}catch{return null}}function C(){const t=u()?.getActionDispatcher;if(typeof t!="function")return!1;const n=t();return!n||typeof n.registerHandler!="function"?!1:(n.registerHandler(`${i}.startAuth`,y,{category:"plugin",source:i}),l.info("startAuth handler registered"),!0)}function D(){if(C())return;let e=0;const t=window.setInterval(()=>{e++,(C()||e>=50)&&(window.clearInterval(t),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}function L(){D()}function m(){w(),D()}return m(),window.__SirsoftVerificationNhnkcp={identifier:i,init:m,initPlugin:L,startAuthHandler:y,handleMobileReturn:w},c.FORM_STASH_KEY=h,c.MODAL_ID=S,c.PLUGIN_IDENTIFIER=i,c.PROVIDER_ID=M,c.REDIRECT_STASH_KEY=A,c.handleMobileReturn=w,c.init=m,c.initPlugin=L,c.isMobileEnvironment=b,c.resolveFailureMessage=g,c.resolveFailureMessageKey=F,c.startAuthHandler=y,Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}),c}({}); +var SirsoftVerificationNhnkcp=function(c){"use strict";const i="sirsoft-verification_nhnkcp",M="nhnkcp",k="width=410,height=500,scrollbars=yes,resizable=yes",E="kcp_cert_popup",S="identity-challenge-modal",A="g7.identity.redirectStash",h="sirsoft-verification_nhnkcp.formStash",l={info:(...e)=>console.info(`[${i}]`,...e),warn:(...e)=>console.warn(`[${i}]`,...e),error:(...e)=>console.error(`[${i}]`,...e)};function u(){return window.G7Core??null}const R={9999:`$t:${i}.errors.cancelled`,NOT_ADULT:`$t:${i}.errors.not_adult`,DUPLICATE:`$t:${i}.errors.duplicate`,REMOTE_CALL_FAILED:`$t:${i}.errors.remote_call_failed`,DECRYPT_FAILED:`$t:${i}.errors.decrypt_failed`,INCOMPLETE_IDENTITY:`$t:${i}.errors.incomplete_identity`,NOT_FOUND:`$t:${i}.errors.not_found`,ALREADY_CONSUMED:`$t:${i}.errors.already_consumed`,EXPIRED:`$t:${i}.errors.not_found`,IDENTITY_BINDING_MISMATCH:`$t:${i}.errors.binding_mismatch`,STORAGE_FAILED:`$t:${i}.errors.storage_failed`,PROVIDER_ERROR:`$t:${i}.errors.verify_failed`},v=`$t:${i}.errors.verify_failed`,T=new Set(["NOT_ADULT","DUPLICATE"]);function F(e){return R[e]??v}function g(e,t){const n=R[e];if(n)return n;const r=(t??"").trim();return r===""?v:e?`[${e}] ${r}`:r}const N=1023;function b(){if(typeof window>"u")return!1;const e=window.navigator?.userAgent??"";return/Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(e)?!0:typeof window.innerWidth=="number"&&window.innerWidth<=N}function p(e){const t=u()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}function I(){const e=u()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function _(e,t="error"){const n=u()?.dispatch;if(typeof n=="function")try{await n({handler:"toast",params:{type:t,message:e}})}catch{}}function O(e,t){const n=document.createElement("form");n.setAttribute("name","kcpCertForm"),n.setAttribute("method","POST"),n.setAttribute("action",e.call_url),n.setAttribute("target",t),n.setAttribute("accept-charset","UTF-8"),n.style.display="none";const r={reg_cert_key:e.reg_cert_key,kcp_page_submit_yn:"Y"};for(const[o,s]of Object.entries(r)){const a=document.createElement("input");a.type="hidden",a.name=o,a.value=s,n.appendChild(a)}document.body.appendChild(n),n.submit(),window.setTimeout(()=>{try{n.remove()}catch{}},0)}function U(){try{window.sessionStorage.setItem(A,JSON.stringify({return_url:window.location.href,payload:{},stashed_at:Date.now()}))}catch(e){l.warn("복귀 주소 보관 실패 — 인증 후 처음 화면으로 돌아갈 수 있습니다.",e)}try{window.sessionStorage.setItem(h,JSON.stringify({fields:G(),stashed_at:Date.now()}))}catch(e){l.warn("입력값 보관 실패",e)}}function G(){const e={};return document.querySelectorAll("input[name], select[name], textarea[name]").forEach(n=>{const r=n.getAttribute("name")??"";if(!r)return;const o=(n.getAttribute("type")??"").toLowerCase();if(o==="password"||o==="file"||o==="hidden")return;if(o==="checkbox"||o==="radio"){n.checked&&(e[r]=String(n.value??"on"));return}const s=String(n.value??"");s!==""&&(e[r]=s)}),e}function H(e){const t=Object.keys(e);if(t.length===0)return;let n=0;const r=window.setInterval(()=>{n+=1;let o=0;t.forEach(s=>{const a=document.querySelector(`[name="${Y(s)}"]`);if(!a)return;const d=(a.getAttribute("type")??"").toLowerCase();if(d==="checkbox"||d==="radio"){const f=a;f.value===e[s]&&!f.checked&&f.click(),o+=1;return}j(a,e[s]),o+=1}),(o===t.length||n>=25)&&window.clearInterval(r)},200)}function Y(e){const t=globalThis.CSS;return t&&typeof t.escape=="function"?t.escape(e):e.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}function j(e,t){const n=Object.getPrototypeOf(e),r=Object.getOwnPropertyDescriptor(n,"value");r?.set?r.set.call(e,t):e.value=t,e.dispatchEvent(new Event("input",{bubbles:!0})),e.dispatchEvent(new Event("change",{bubbles:!0}))}function K(e){const t=u()?.dispatch;return typeof t!="function"?Promise.resolve():t({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:S}]}}).catch(()=>{})}const P=new Set;function $(e){e&&P.add(e)}function V(e){return!!e&&P.has(e)}async function B(e){if(!e)return;const t=u()?.api?.getToken?.()??null;try{await fetch(`/api/identity/challenges/${encodeURIComponent(e)}/cancel`,{method:"POST",credentials:"include",headers:{Accept:"application/json",...t?{Authorization:`Bearer ${t}`}:{}}})}catch(n){l.warn("이탈 거래 취소 통보 실패 (재시도 흐름에는 영향 없음)",n)}}async function q(e){const t=u(),n={purpose:e.purpose};e.provider_id&&(n.provider_id=e.provider_id),e.target&&(n.target=e.target);try{const r=t?.api?.getToken?.()??null,o=(()=>{try{return window.localStorage?.getItem("g7_locale")}catch{return null}})(),s=await fetch("/api/identity/challenges",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Accept:"application/json",...o?{"Accept-Language":o}:{},...r?{Authorization:`Bearer ${r}`}:{}},body:JSON.stringify(n)}),a=await s.json().catch(()=>null);if(!s.ok||!a?.success)return l.error("challenge 재발급 실패",a),null;const d=a.data??a,f={...e,challenge_id:d.id,expires_at:d.expires_at,public_payload:d.public_payload??{},error:null};return t?.state?.set?.({identityChallenge:f}),f}catch(r){return l.error("challenge 재발급 중 오류",r),null}}async function y(){const e=u();let t=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!t){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}if(V(t.challenge_id)){const o=await q(t);if(!o){await _(`$t:${i}.modal.payload_not_ready`);return}t=o}const n=t.public_payload;if(!n?.call_url||!n?.reg_cert_key){l.error("public_payload 의 call_url/reg_cert_key 부재 — 거래등록 응답 확인 필요",n),await _(`$t:${i}.modal.payload_not_ready`);return}if(b()){U(),p(!0),O(n,"_self");return}const r=window.open("",E,k);if(!r){l.error("window.open null — 팝업 차단됨"),await _(`$t:${i}.modal.popup_blocked`);return}p(!0),O(n,E),J(r)}function J(e){let t=!1,n=null,r=null;const o=()=>{r&&(window.removeEventListener("message",r),r=null),n!==null&&(window.clearInterval(n),n=null)};r=s=>{if(s.origin!==window.location.origin)return;const a=s.data;if(!a||a.type!=="identity_result"||t)return;t=!0,o();try{e.close()}catch{}if(p(!1),a.identity_error){const f=a.challenge_id||u()?.state?.getGlobal?.()?.identityChallenge?.challenge_id;$(f),T.has(a.identity_error)&&I(),_(g(a.identity_error,a.identity_error_message),"error");return}const d=a.verification_token?{result:"verified",token:a.verification_token}:{result:"cancelled"};K(d)},window.addEventListener("message",r),n=window.setInterval(()=>{if(t){o();return}if(e.closed){t=!0,o(),p(!1);const s=u()?.state?.getGlobal?.()?.identityChallenge?.challenge_id;$(s),B(s)}},500)}function w(){if(typeof window>"u")return;const e=new URLSearchParams(window.location.search),t=e.get("verification_token")??"",n=e.get("identity_error")??"";if(t===""&&n==="")return;const r=W();if(r&&H(r.fields),t!==""){_(`$t:${i}.modal.mobile_return_success`,"success");return}T.has(n)&&I(),_(g(n,e.get("identity_error_message")??void 0),"error"),e.delete("identity_error"),e.delete("identity_error_message");const o=e.toString(),s=window.location.pathname+(o?`?${o}`:"")+window.location.hash;try{window.history.replaceState(null,"",s)}catch{}}function W(){let e=null;try{e=window.sessionStorage.getItem(h),window.sessionStorage.removeItem(h)}catch{return null}if(!e)return null;try{const t=JSON.parse(e);return!t?.fields||typeof t.stashed_at!="number"||Date.now()-t.stashed_at>6e5?null:t}catch{return null}}function C(){const t=u()?.getActionDispatcher;if(typeof t!="function")return!1;const n=t();return!n||typeof n.registerHandler!="function"?!1:(n.registerHandler(`${i}.startAuth`,y,{category:"plugin",source:i}),l.info("startAuth handler registered"),!0)}function D(){if(C())return;let e=0;const t=window.setInterval(()=>{e++,(C()||e>=50)&&(window.clearInterval(t),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}function L(){D()}function m(){w(),D()}return m(),window.__SirsoftVerificationNhnkcp={identifier:i,init:m,initPlugin:L,startAuthHandler:y,handleMobileReturn:w},c.FORM_STASH_KEY=h,c.MODAL_ID=S,c.PLUGIN_IDENTIFIER=i,c.PROVIDER_ID=M,c.REDIRECT_STASH_KEY=A,c.handleMobileReturn=w,c.init=m,c.initPlugin=L,c.isMobileEnvironment=b,c.resolveFailureMessage=g,c.resolveFailureMessageKey=F,c.startAuthHandler=y,Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}),c}({}); //# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.php b/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.php index 8f826111..3247abb6 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.php +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.php @@ -80,7 +80,7 @@ class Plugin extends AbstractPlugin * * 라이브 모드(is_test_mode=false) 진입 시 live_site_cd / live_enc_key 를 required 로 * 강제하는 조건부 검증은 정적 스키마로 표현할 수 없어 ValidateKcpSettingsListener 가 - * `core.plugin_settings.update_rules` 필터로 동적 부여한다. + * `core.plugin_settings.update_validation_rules` 필터로 동적 부여한다. * * @return array> */ diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/startAuthHandler.test.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/startAuthHandler.test.ts index 771cf4e6..48170ecb 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/startAuthHandler.test.ts +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/startAuthHandler.test.ts @@ -247,6 +247,42 @@ describe('startAuth 핸들러', () => { vi.useRealTimers(); }); + /** + * 인증창을 그냥 닫으면 서버에도 그 거래가 끝났음을 알려야 한다. + * + * 브라우저만 알고 있으면 인증 이력이 "보낸 뒤 소식 없는" 상태로, 거래 행은 소비 시각 없이 + * 남는다. 만료 시간이 지나면 정리되지만 그때까지 그 거래키는 유효한 상태로 남는다. + * + * @scenario environment=desktop,outcome=cancelled + * @effects abandoned_challenge_is_cancelled_on_server + */ + it('인증창을 그냥 닫으면 서버에 취소를 통보한다', async () => { + vi.useFakeTimers(); + const popup = { closed: false, close: vi.fn() }; + vi.stubGlobal('open', vi.fn(() => popup)); + const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ success: true }) })); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + globalState.identityChallenge = { + provider_id: 'nhnkcp', + purpose: 'signup', + challenge_id: 'challenge-abandoned', + public_payload: { call_url: CALL_URL, reg_cert_key: REG_CERT_KEY }, + }; + + await startAuthHandler(); + popup.closed = true; + vi.advanceTimersByTime(500); + await vi.runAllTicks?.(); + + const calls = fetchMock.mock.calls as unknown as Array<[string, RequestInit]>; + const cancelCall = calls.find(([url]) => String(url).includes('/cancel')); + expect(cancelCall, '취소 엔드포인트 호출이 있어야 한다').toBeTruthy(); + expect(cancelCall![0]).toBe('/api/identity/challenges/challenge-abandoned/cancel'); + expect(cancelCall![1].method).toBe('POST'); + + vi.useRealTimers(); + }); + /** * 닫힘 감지 후에도 감시 타이머가 계속 돌면 다음 시도의 상태를 덮어써 버린다. * diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/index.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/index.ts index 88b4ba0d..468e76a5 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/index.ts +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/index.ts @@ -431,6 +431,38 @@ function isChallengeConsumed(challengeId?: string): boolean { return !!challengeId && consumedChallengeIds.has(challengeId); } +/** + * 인증을 마치지 않고 이탈한 challenge 를 서버에도 종료로 알린다. + * + * 사용자가 인증창을 그냥 닫으면 브라우저는 그 거래를 끝난 것으로 처리하지만, 서버는 아무 통보를 + * 받지 못해 인증 이력이 `sent` 상태로, 거래 행은 `consumed_at = NULL` 로 남는다. TTL(15분)이 + * 지나면 만료되므로 기능 결함은 아니나, 관리자 이력에서 "보낸 뒤 소식 없는" 행이 쌓이고 그 거래키가 + * 만료 전까지 유효한 상태로 남는다. 코어 취소 엔드포인트를 호출해 두 상태를 함께 닫는다. + * + * 실패해도 흐름을 막지 않는다 — 이탈 정리는 부가 작업이고, 재시도는 브라우저측 소비 표시만으로도 + * 정상 동작한다(새 거래를 발급받는다). + * + * @param challengeId 이탈한 challenge 식별자 + */ +async function reportAbandonedChallenge(challengeId?: string): Promise { + if (!challengeId) return; + + const token = getG7Core()?.api?.getToken?.() ?? null; + + try { + await fetch(`/api/identity/challenges/${encodeURIComponent(challengeId)}/cancel`, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch (e) { + logger.warn('이탈 거래 취소 통보 실패 (재시도 흐름에는 영향 없음)', e); + } +} + /** * 새 challenge 를 발급받아 `_global.identityChallenge` 를 갱신한다. * @@ -626,9 +658,14 @@ function watchPopup(popup: Window): void { // 이 거래는 이미 표준창에 제출됐다. KCP 거래키에는 유효 시간이 있어 같은 거래로 다시 // 열면 한참 뒤의 재시도가 아무 안내 없이 실패할 수 있으므로, 다시 시작할 때 새 거래를 // 받도록 소비 표시를 남긴다. - markChallengeConsumed( - getG7Core()?.state?.getGlobal?.()?.identityChallenge?.challenge_id as string | undefined, - ); + const abandonedId = getG7Core()?.state?.getGlobal?.()?.identityChallenge?.challenge_id as + | string + | undefined; + markChallengeConsumed(abandonedId); + + // 브라우저만 알고 있으면 서버 이력은 `sent` 로, 거래 행은 `consumed_at = NULL` 로 + // 남는다. 코어 취소 엔드포인트로 두 상태를 함께 닫는다. + void reportAbandonedChallenge(abandonedId); } }, POPUP_CLOSED_POLL_MS); } diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php index 0b5a73bd..2bff5b0f 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php @@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Lang; * * 코어 UpdatePluginSettingsRequest 의 정적 스키마는 `required`/타입 규칙만 표현할 수 있어 * "is_test_mode=false 일 때만 live_site_cd / live_enc_key 필수" 같은 조건부 검증을 담을 수 없다. - * 본 listener 가 `core.plugin_settings.update_rules` filter 로 라이브 모드 진입 시 라이브 + * 본 listener 가 `core.plugin_settings.update_validation_rules` filter 로 라이브 모드 진입 시 라이브 * 자격증명에 required 규칙을 동적 부여한다. * * 검증 기준은 KcpIdentityProvider::isAvailable() 의 라이브 운영 가능 조건과 일치시킨다. @@ -31,7 +31,7 @@ class ValidateKcpSettingsListener implements HookListenerInterface public static function getSubscribedHooks(): array { return [ - 'core.plugin_settings.update_rules' => [ + 'core.plugin_settings.update_validation_rules' => [ 'method' => 'addLiveModeRules', 'priority' => 10, 'type' => 'filter', diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php index 879ef8f2..9aaada0e 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php @@ -7,6 +7,7 @@ use App\Models\Permission; use App\Models\Role; use App\Models\User; use App\Services\PluginSettingsService; +use App\Support\SensitiveSettingMask; use Illuminate\Testing\TestResponse; use Plugins\Sirsoft\VerificationNhnkcp\Tests\PluginTestCase; @@ -132,6 +133,74 @@ class KcpLiveModeSettingsValidationTest extends PluginTestCase ); } + /** + * 관리자 설정 응답에 암호화 키 평문이 실리지 않는지 확인한다. + * + * 저장은 암호화되어 있어도 조회 응답이 복호화된 값을 그대로 내려보내면 브라우저·개발자 도구· + * 프록시 로그에 비밀값이 남는다. 값이 있다는 사실만 마스크로 알려야 한다. + * + * @scenario mode=live,live_credentials=complete + * + * @effects sensitive_key_is_not_returned_in_plain_text + */ + public function test_admin_response_masks_encryption_key(): void + { + $this->putSettings([ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => 'super-secret-live-key', + ])->assertStatus(200) + ->assertJsonPath('data.live_enc_key', SensitiveSettingMask::MASK); + + $show = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + ])->getJson('/api/admin/plugins/'.self::PLUGIN_IDENTIFIER.'/settings'); + + $show->assertStatus(200)->assertJsonPath('data.live_enc_key', SensitiveSettingMask::MASK); + $this->assertStringNotContainsString('super-secret-live-key', $show->getContent()); + + // 민감하지 않은 항목은 그대로 보여야 한다 (마스킹이 과하게 번지지 않는지). + $show->assertJsonPath('data.live_site_cd', 'A1B2C'); + } + + /** + * 마스크를 그대로 되돌려 보내면 저장된 값이 유지되는지 확인한다. + * + * 운영자가 암호화 키를 건드리지 않고 다른 항목만 바꿔 저장하면, 화면은 마스크를 그대로 보낸다. + * 그것을 값으로 받아 저장하면 저장된 키가 마스크 문자열로 덮여 운영 인증이 통째로 멈춘다. + * + * @scenario mode=live,live_credentials=complete + * + * @effects sensitive_key_is_not_returned_in_plain_text + */ + public function test_resubmitting_mask_preserves_stored_key(): void + { + $this->putSettings([ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => 'super-secret-live-key', + ])->assertStatus(200); + + // 화면이 마스크를 그대로 되돌려 보내는 상황 재현 + $this->putSettings([ + 'is_test_mode' => false, + 'live_site_cd' => 'Z9Y8X', + 'live_enc_key' => SensitiveSettingMask::MASK, + ])->assertStatus(200); + + $this->assertSame( + 'super-secret-live-key', + app(PluginSettingsService::class)->get(self::PLUGIN_IDENTIFIER, 'live_enc_key'), + '마스크 재전송은 저장된 키를 덮어써서는 안 된다', + ); + $this->assertSame( + 'Z9Y8X', + app(PluginSettingsService::class)->get(self::PLUGIN_IDENTIFIER, 'live_site_cd'), + '같은 요청의 다른 항목은 정상 저장되어야 한다', + ); + } + /** * core.plugins.update 권한을 가진 관리자 생성. */ @@ -139,11 +208,17 @@ class KcpLiveModeSettingsValidationTest extends PluginTestCase { $user = User::factory()->create(); - $permission = Permission::firstOrCreate( + $updatePermission = Permission::firstOrCreate( ['identifier' => 'core.plugins.update'], ['name' => json_encode(['ko' => '플러그인 수정', 'en' => 'Update Plugins']), 'type' => 'admin'] ); + // 설정 조회(GET)는 별도 권한을 요구한다 — 마스킹 응답 검증에 필요. + $readPermission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.read'], + ['name' => json_encode(['ko' => '플러그인 조회', 'en' => 'Read Plugins']), 'type' => 'admin'] + ); + $adminRole = Role::firstOrCreate( ['identifier' => 'admin'], ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] @@ -154,7 +229,7 @@ class KcpLiveModeSettingsValidationTest extends PluginTestCase 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), 'type' => 'admin', ]); - $testRole->permissions()->sync([$permission->id]); + $testRole->permissions()->sync([$updatePermission->id, $readPermission->id]); $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml index bd13749b..363ed9d5 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml @@ -21,6 +21,7 @@ effects: - mobile_return_shows_failure_notice_and_strips_error_param - popup_blocked_shows_notice_without_entering_progress_state - popup_closed_without_result_restores_start_button + - abandoned_challenge_is_cancelled_on_server - failed_result_keeps_panel_open_for_retry - verified_result_resolves_and_closes_panel - retry_after_failure_issues_new_transaction diff --git a/tests/Unit/Extension/HookManagerLegacyNameTest.php b/tests/Unit/Extension/HookManagerLegacyNameTest.php new file mode 100644 index 00000000..f693d536 --- /dev/null +++ b/tests/Unit/Extension/HookManagerLegacyNameTest.php @@ -0,0 +1,88 @@ + $rules + ['standard' => true]); + + $result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base' => true]); + + $this->assertSame(['base' => true, 'standard' => true], $result); + } + + public function test_legacy_name_subscriber_still_receives_value(): void + { + HookManager::addFilter(self::LEGACY, fn (array $rules) => $rules + ['legacy' => true]); + + $result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base' => true]); + + $this->assertSame( + ['base' => true, 'legacy' => true], + $result, + '구 이름을 구독 중인 확장이 개명으로 조용히 멈춰서는 안 된다', + ); + } + + public function test_both_names_apply_in_order(): void + { + HookManager::addFilter(self::STANDARD, fn (array $r) => [...$r, 'standard']); + HookManager::addFilter(self::LEGACY, fn (array $r) => [...$r, 'legacy']); + + $result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base']); + + $this->assertSame(['base', 'standard', 'legacy'], $result, '표준 이름 결과에 구 이름이 이어 적용된다'); + } + + public function test_no_subscriber_returns_value_unchanged(): void + { + $result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base' => true]); + + $this->assertSame(['base' => true], $result); + } + + public function test_extra_arguments_reach_both_names(): void + { + $seen = []; + HookManager::addFilter(self::STANDARD, function (array $r, string $id) use (&$seen) { + $seen['standard'] = $id; + + return $r; + }); + HookManager::addFilter(self::LEGACY, function (array $r, string $id) use (&$seen) { + $seen['legacy'] = $id; + + return $r; + }); + + HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, [], 'probe-plugin'); + + $this->assertSame(['standard' => 'probe-plugin', 'legacy' => 'probe-plugin'], $seen); + } +}