fix(sirsoft-gdpr): 쿠키 동의 보안 결함 3건 수정 + 하위호환 고지

이슈 체크리스트와는 별개로, 처리 완료 후 지시로 진행한
sirsoft-gdpr 플러그인 전수 비판적 검토에서 발견한 보안·결함 3건을
수정한다.

- Admin 설정 저장 API 의 검증 우회 fallback(빈 요청 시 $request->all
 사용) 제거. UpdateAdminSettingsRequest 필드가 모두 nullable 이라
 검증을 거치지 않은 임의 키가 저장 로직까지 전달될 수 있었다.
- 게스트 세션 식별 쿠키(gdpr_session) 에 HMAC-SHA256 서명 적용.
 api 미들웨어 그룹에는 EncryptCookies 가 없어 쿠키가 평문으로 오가
 임의 UUID로 타인의 게스트 동의 상태를 조회/철회할 수 있었다.
 결제 플러그인들의 IssuesReceiptCookie 트레이트와 동일 컨벤션 채택.
 이 조치로 배포 이전 발급된 비회원 쿠키는 1회 초기화되며 이미
 배너를 닫았던 비회원은 다음 방문 시 배너를 다시 보게 된다(회원
 동의 이력은 영향 없음) — CHANGELOG 에 고지.
- 게스트로 쿠키 배너를 닫은 뒤 동의 이력 없는 다른 계정으로 로그인해도
 배너가 계속 숨겨져 있던 결함 수정. _global.gdprBannerDismissedFor 가
 "닫힘 여부"(boolean) 대신 "누가 닫았는지"(사용자 식별자)를 저장하도록
 변경해, 로그인으로 사용자가 바뀌면 다시 노출되게 한다. 실제 로그인
 폼 제출을 거쳐 재현하는 E2E spec 신규 작성(PlaywrightSeedGdprGuestLogin
 시드 커맨드 포함, sirsoft-page 의 PlaywrightSeedPage 패턴 재사용).

부수: 자동 차단 정책 안내에 보호 범위 한계 고지 추가, GDPR 플러그인
번들 로딩 우선순위 상향(다른 확장의 스크립트로부터 보호 범위 확대).
This commit is contained in:
chym1217
2026-07-30 10:50:49 +09:00
parent 192000cfce
commit 2a036704f4
25 changed files with 720 additions and 218 deletions
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-29
### Added
- 자동 차단 정책 카드에 보호 범위 한계 안내 문구(운영자가 직접 삽입한 외부 스크립트는 보호 범위 밖) 일본어 번역 추가
## [1.0.1] - 2026-07-27
### Added
@@ -4,9 +4,9 @@ return [
'consent' => [
'granted' => '同意が保存されました。',
'granted_again' => '同意が更新されました。',
'rejected_saved' => '同意せずに続行します。選択項目は使用されません。',
'rejected_saved' => '同意しないで続行します。選択項目は使用されません。',
'revoked' => '同意が取り消されました。',
'renew_all_success' => '有効な項目 {renewed} 個を新しいポリシーバージョンに更新しました。',
'renew_all_success' => 'アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。',
'granting' => '同意中...',
'revoking' => '取り消し中...',
'renewing' => '更新中...',
@@ -19,35 +19,35 @@ return [
'category_cookie_marketing' => 'マーケティングクッキー',
],
'blocked_domains' => [
'invalid_format' => '正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)',
'invalid_format_functional' => '機能カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)',
'invalid_format_analytics' => '分析カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)',
'invalid_format_marketing' => 'マーケティングカテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)',
'too_long' => 'ドメインは 253 文字を超えることができません。',
'too_long_functional' => '機能カテゴリ — ドメインは 253 文字を超えることができません。',
'too_long_analytics' => '分析カテゴリ — ドメインは 253 文字を超えることができません。',
'too_long_marketing' => 'マーケティングカテゴリ — ドメインは 253 文字を超えることができません。',
'must_be_array' => 'カテゴリごとのドメインリストは配列である必要があります。',
'invalid_format' => '正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)',
'invalid_format_functional' => '機能カテゴリー — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)',
'invalid_format_analytics' => '分析カテゴリー — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)',
'invalid_format_marketing' => 'マーケティングカテゴリー — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)',
'too_long' => 'ドメインは 253 文字を超えることはできません。',
'too_long_functional' => '機能カテゴリー — ドメインは 253 文字を超えることはできません。',
'too_long_analytics' => '分析カテゴリー — ドメインは 253 文字を超えることはできません。',
'too_long_marketing' => 'マーケティングカテゴリー — ドメインは 253 文字を超えることはできません。',
'must_be_array' => 'カテゴリー別ドメイン一覧は配列である必要があります。',
],
'settings' => [
'saved' => '設定が保存されました。',
'title' => 'GDPR 設定',
'description' => 'クッキー同意バナーと マイページ同意管理カードの動作を管理者が制御します。',
'description' => 'クッキー同意バナーとマイページ同意管理カードの動作を運営者が制御します。',
'section' => [
'operator' => '運営主体 / プライバシーポリシー',
'operator_desc' => 'クッキーバナー・ポリシーページに表示される運営主体、データ保存場所、ポリシーページスラッグを登録します。(GDPR Art.13 透明性義務)',
'operator' => '運営主体 / 個人情報保護方針',
'operator_desc' => 'クッキーバナー·ポリシーページに表示される運営主体、データ保存位置、ポリシーページスラッグを登録します。(GDPR Art.13 透明性義務)',
'cookie_banner' => 'クッキー同意バナー',
'cookie_banner_desc' => '訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一箇所で設定します。',
'cookie_banner_desc' => '訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリー/ポリシーバージョンを一箇所で設定します。',
'cookie_banner_settings' => 'バナー表示設定',
'cookie_categories' => 'クッキーカテゴリ',
'cookie_categories' => 'クッキーカテゴリー',
'cookie_policy_version' => 'クッキーポリシーバージョン',
'auto_blocking' => '自動ブロック設定',
'auto_blocking_desc' => 'クッキーバナー表示が ON の場合、下記のカテゴリごとのブロックドメインリストの外部トラッキングリソースがユーザー同意前まで自動ブロックされます。(別途トグルなし — バナー表示と共に自動稼働)',
'auto_blocking' => '自動ブロックポリシー',
'auto_blocking_desc' => 'クッキーバナー表示が ON の場合、下記カテゴリー別ブロックドメイン一覧の外部トラッキングリソースがユーザー同意前まで自動ブロックされます。(個別トグルなし — バナー表示と共に自動で動作)',
],
'nav' => [
'operator' => '運営情報',
'cookie_banner' => 'クッキーバナー',
'auto_blocking_policy' => '自動ブロック設定',
'auto_blocking_policy' => '自動ブロックポリシー',
],
'status' => [
'enabled' => '有効化',
@@ -58,29 +58,29 @@ return [
'fields' => [
'cookie_policy_version' => [
'label' => 'ポリシーバージョン',
'hint' => '管理者が直接入力せず、システムが自動管理します。
• カテゴリの追加/削除、カテゴリの意味の変更、ポリシーページの変更 → 新バージョン自動公開 + すべての会員に再同意
• ドメインの追加/削除、ラベル変更など単純な変更 → 公開なし',
'hint' => '運営者が直接入力せず、システムが自動管理します。
• カテゴリー追加/削除、カテゴリー意味変更、ポリシーページ変更 → 新バージョン自動公開 + すべての会員再同意
• ドメイン追加/削除、ラベル変更など単純変更 → 公開なし',
],
'privacy_policy_slug' => [
'label' => 'プライバシーポリシーページスラッグ',
'hint' => 'ページプラグインに登録されたプライバシーポリシーページのスラッグ。空の場合、クッキーバナーのポリシーリンクが表示されません。',
'label' => '個人情報保護方針ページスラッグ',
'hint' => 'ページプラグインに登録された処理方針ページのスラッグ。空の場合、クッキーバナーのポリシーリンクが表示されません。',
'placeholder' => '例: privacy-policy',
],
'legal_entity_name' => [
'label' => '法人名 / 運営主体',
'hint' => 'クッキーバナーと マイページに表示されるサイト運営主体名です。',
'hint' => 'クッキーバナーとマイページに表示されるサイト運営主体の名前です。',
'placeholder' => '例: (株)会社名',
],
'data_storage_location' => [
'label' => 'データ保存場所',
'hint' => 'ユーザーに通知するデータ保存国を入力してください。例: "日本"、"アメリカ (AWS)"。IP アドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスはセキュリティ上入力しないでください。',
'placeholder' => '例: 日本 (自社データセンター) / アメリカ (AWS)',
'sensitive_format' => 'データ保存場所に IP アドレス、CIDR、クラウドリージョンコード(例: ap-northeast-2) は入力できません。国名(例: 日本、アメリカ) で記載してください。',
'label' => 'データ保存位置',
'hint' => 'ユーザーに案内するデータ保存国を入力してください。例: 「大韓民国」、「アメリカ (AWS)」。IP アドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスはセキュリティ上、入力しないでください。',
'placeholder' => '例: 大韓民国 (自社データセンター) / アメリカ (AWS)',
'sensitive_format' => 'データ保存位置に IP アドレス、CIDR、クラウドリージョンコード(例: ap-northeast-2) は入力できません。国名(例: 大韓民国、アメリカ) で表記してください。',
],
'banner_enabled' => [
'label' => 'クッキーバナー表示',
'hint' => '有効化すると、クッキー同意バナー表示 + 同意前の外部トラッキング自動ブロック + マイページ同意管理カードが一括有効化されます。(GDPR Art.6 "同意前処理禁止" の強制メカニズムであるブロックを個別トグルで無効化できないよう、単一トグルに統合)',
'hint' => '有効化時、クッキー同意バナー表示 + 同意前外部トラッキング自動ブロックが一緒に開始されます。(GDPR Art.6 「同意前処理禁止」 の強制メカニズムであるブロックを個別トグルで無効化できないよう単一トグルに統合) マイページ同意管理カードはこのトグルとは無関係に、同意/取り消し履歴のある会員に常に表示されます。',
],
'banner_position' => [
'label' => 'バナー位置',
@@ -91,21 +91,21 @@ return [
'option_centered_modal' => '中央モーダル',
],
'blocked_domains' => [
'warnings_title' => '注意',
'auto_blocking_off_hint' => '自動ブロックはクッキーバナー表示が ON の場合のみ稼働します。「クッキーバナー」カードでクッキーバナー表示トグルをまずオンにしてください。',
'warning_visual_break' => '外部フォント・スタイルシートは分類に注意してください。誤ってブロックするとページが静かに破損する可能性があります。',
'warnings_title' => '案内',
'auto_blocking_off_hint' => '自動ブロックはクッキーバナー表示が ON のときのみ動作します。「クッキーバナー」カードのクッキーバナー表示トグルをまず有効化してください。',
'warning_visual_break' => '外部フォント·スタイルシートは分類に注意してください。誤ってブロックするとページが静かに崩れる可能性があります。',
'warning_policy_bump' => 'ブロックドメインを変更した後、クッキーポリシーバージョンを上げてください。既存の同意者が新しいドメインにも同意したものとして処理される問題を防ぎます。',
'warning_domain_format' => 'ドメイン形式: ドット 1 個以上を含むドメイン。ワイルドカード *.example.com 対応。シングルラベル(localhost) および多言語ドメイン(xn-- 変換) は未対応。',
'tag_input_placeholder' => 'ドメイン入力後 Enter (例: google-analytics.com、*.hotjar.com)',
'warning_domain_format' => 'ドメイン形式: ドットが 1 個以上含まれるドメイン。ワイルドカード *.example.com 可能。単一ラベル(localhost) および韓国語ドメイン(xn-- 変換) 非サポート。',
'tag_input_placeholder' => 'ドメイン入力後 Enter キー (例: google-analytics.com, *.hotjar.com)',
'tag_no_options' => '推奨ドメインはありません。直接入力してください。',
'category' => [
'functional' => '機能カテゴリブロックドメイン',
'analytics' => '分析カテゴリブロックドメイン',
'marketing' => 'マーケティングカテゴリブロックドメイン',
'functional' => '機能カテゴリーブロックドメイン',
'analytics' => '分析カテゴリーブロックドメイン',
'marketing' => 'マーケティングカテゴリーブロックドメイン',
],
],
'cookie_categories' => [
'description' => 'クッキーカテゴリ 4 種類の用途とブロックツール例は、各カードの [情報を展開] で確認できます。',
'description' => 'クッキーカテゴリー 4 種の用途とブロック作業の例は、各カードの [情報を展開] から確認できます。',
'category_required_badge' => '必須',
'category_optional_badge' => '選択',
],
@@ -123,12 +123,12 @@ return [
'publishing' => '公開中...',
'change_type' => [
'material' => '再同意トリガー',
'non_material' => '簡単な変更',
'non_material' => '単純変更',
'initial' => '初期公開',
],
'history_modal' => [
'title' => 'ポリシーバージョン公開履歴',
'empty' => '公開されたポリシーバージョンはありません。',
'empty' => '公開されたポリシーバージョンがありません。',
],
'history' => [
'col' => [
@@ -144,21 +144,21 @@ return [
'material_modal' => [
'title' => 'ポリシーバージョン公開 + 再同意トリガー',
'body' => '次の変更は、ポリシーバージョンの自動公開とすべての会員の再同意トリガーを発生させます。',
'effect_bump' => 'ポリシーバージョンの自動公開(単調増加)',
'effect_reconsent' => 'すべての会員の次回訪問時に再同意画面が表示されます',
'effect_history' => '履歴モーダルに新しいバージョン行を追加',
'effect_bump' => 'ポリシーバージョン自動公開(単調増加)',
'effect_reconsent' => 'すべての会員の次回訪問時に再同意画面を表示',
'effect_history' => '履歴モーダルに新バージョン行を追加',
'memo_label' => '変更理由(必須)',
'memo_placeholder' => '例:分析カテゴリ新設(Hotjar導入)',
'memo_placeholder' => '例:分析カテゴリー新設(Hotjar導入)',
'confirm' => '新バージョン公開後に保存',
],
'snapshot_modal' => [
'title' => 'ポリシーバージョン v:version 本文',
'description' => '公開時点での設定のカテゴリリスト・ポリシー本文スラッグ・ブロックドメイン等、全体の本文です。DPO が同意紛争時の立証資料として活用します。',
'section_categories' => 'クッキーカテゴリ',
'description' => '公開時点での設定のカテゴリー一覧・ポリシー本文スラッグ・ブロックドメインなど全体の本文です。DPOが同意紛争時の証拠資料として活用します。',
'section_categories' => 'クッキーカテゴリー',
'section_privacy_policy' => 'ポリシー本文スラッグ',
'section_blocked_domains' => 'ブロックドメインカタログ',
'category_required_badge' => '必須',
'no_categories' => '登録されたカテゴリがありません。',
'no_categories' => '登録されたカテゴリーがありません。',
'no_blocked_domains' => '登録されたブロックドメインがありません。',
'view_button' => 'v:version 本文を表示',
'close' => '閉じる',
@@ -171,7 +171,7 @@ return [
'admin' => [
'consent_log' => [
'title' => 'GDPR同意履歴',
'description' => '会員・ゲストの同意・撤回変更履歴を照会します。IP·User-Agent等の監査情報が一緒に表示されます。',
'description' => '会員・ゲストの同意・撤回変更履歴を照会します。IP・User-Agentなど監査情報が合わせて表示されます。',
'refresh' => '更新',
'empty' => '照会された同意履歴がありません。',
'clear_filters' => 'フィルターをリセット',
@@ -211,19 +211,19 @@ return [
'detail' => [
'session_id_label' => 'セッションID',
'user_agent_label' => 'User-Agent',
'categories_label' => 'カテゴリスナップショット',
'categories_hint' => '同意時点で会員がカテゴリ別に示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。',
'categories_label' => 'カテゴリースナップショット',
'categories_hint' => '同意時点で会員がカテゴリー別に表示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。',
'snapshot_granted' => '同意',
'snapshot_revoked' => '非同意',
'snapshot_revoked' => '未同意',
],
],
],
'mypage' => [
'privacy' => [
'title' => 'クッキー同意管理',
'description' => 'サイトが使用中のクッキー項目に対する同意を変更または撤回できます。',
'description' => 'サイトが使用中のクッキー項目の同意を変更または撤回できます。',
'section_consents' => '私の同意状況',
'section_consents_hint' => '同意/撤回時点に従ってすぐに反映されます。必須項目は撤回できません。',
'section_consents_hint' => '同意・撤回時点に応じて即座に反映されます。必須項目は撤回できません。',
'col_consent_key' => '同意項目',
'col_consented' => 'ステータス',
'col_consented_at' => '同意日',
@@ -231,12 +231,12 @@ return [
'col_policy_version' => 'ポリシーバージョン',
'col_action' => '作業',
'consented' => '同意',
'revoked' => '取消',
'revoked' => '撤回',
'rejected_label' => '拒否',
'status' => [
'granted' => '同意',
'consented' => '同意',
'revoked' => '取消',
'revoked' => '撤回',
'rejected' => '拒否',
],
'badge' => [
@@ -245,20 +245,20 @@ return [
'rejected' => '拒否しました',
'unset' => '未設定',
],
'revoke' => '取消',
'revoke' => '撤回',
'grant' => '同意',
'grant_again' => '再度同意',
'required_label' => '必須',
'required_hint' => 'サービス提供に必須であるため、取消することはできません。',
'revoke_confirm_title' => '同意取消確認',
'revoke_confirm_message' => 'このアイテムを取消しますか? 同意を取消すると、該当するカテゴリーの関連機能が制限される可能性があります。',
'no_consents' => 'アクティブな同意項目がありません。',
'required_hint' => 'サービス提供に必須のため撤回することはできません。',
'revoke_confirm_title' => '同意撤回確認',
'revoke_confirm_message' => 'この項目を撤回してよろしいですか?同意を撤回すると、該当カテゴリ関連の機能が制限される可能性があります。',
'no_consents' => '有効な同意項目がありません。',
'needs_renewal' => 'クッキーポリシーが変更されました。同意を更新してください。',
'needs_renewal_with_version' => 'クッキーポリシーがバージョン {version} に変更されました。選択項目の同意を再度確認してください。',
'needs_renewal_hint' => '必須クッキーはサイト運営に必ず必要な項目のため、ポリシー変更の有無に関わらず常に適用されます。',
'needs_renewal_hint' => '必須クッキーはサイト運営に必要な項目であるため、ポリシー変更に関わらず常に適用されます。',
'btn_renew_all' => '現在の同意更新',
'btn_renew_this_item' => 'ポリシー更新',
'renew_all_success' => 'アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。',
'renew_all_success' => '有効な項目 {renewed} 個を新しいポリシーバージョンに更新しました。',
],
],
'common' => [
@@ -269,15 +269,15 @@ return [
're_appear_reason' => 'クッキーポリシーがバージョン :version に変更されたため、同意を再度確認します。',
'consent_required_hint' => '拒否した項目に関連する機能が制限される可能性があります。',
'btn_renew_consent' => '現在の同意更新',
'title' => 'クッキー使用のお知らせ',
'description' => '本サイトはユーザーエクスペリエンスの改善と分析のためクッキーを使用します。項目ごとに同意の有無を選択できます。',
'title' => 'クッキー使用案内',
'description' => '本サイトはユーザー体験の向上と分析のためクッキーを使用します。項目ごとに同意の有無を選択できます。',
'accept_all' => 'すべてに同意',
'reject_continue' => '同意しないで続ける',
'reject_continue' => '同意せずに続行',
'preferences' => '選択同意',
'save' => '選択を保存',
'policy_link' => 'プライバシーポリシー',
'preferences_title' => 'クッキー環境設定',
'preferences_description' => 'カテゴリー別のクッキー使用を個別に同意・取消できます。必須カテゴリーはオフにできません。',
'preferences_description' => 'カテゴリ別クッキー使用について個別に同意・撤回できます。必須カテゴリはオフにできません。',
'category_required_badge' => '必須',
'view_policy_label' => '変更されたポリシー本文を表示',
],
@@ -15,14 +15,14 @@
"consent": {
"granted": "同意が保存されました。",
"granted_again": "同意が更新されました。",
"rejected_saved": "同意しないで続行します。選択項目は使用されません。",
"revoked": "同意が撤回されました。",
"rejected_saved": "同意せずに続行します。選択項目は使用されません。",
"revoked": "同意が取り消されました。",
"renew_all_success": "アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。",
"granting": "同意中...",
"revoking": "撤回中...",
"revoking": "取り消し中...",
"renewing": "更新中...",
"already_same_state": "既に同じ状態です。",
"required_cannot_revoke": "必須項目は撤回できません。",
"required_cannot_revoke": "必須項目は取り消せません。",
"invalid_key": "無効な同意項目です。",
"category_cookie_necessary": "必須クッキー",
"category_cookie_functional": "機能クッキー",
@@ -30,10 +30,10 @@
"category_cookie_marketing": "マーケティングクッキー"
},
"blocked_domains": {
"invalid_format": "正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)",
"invalid_format_functional": "機能カテゴリ — 正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)",
"invalid_format_analytics": "分析カテゴリ — 正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)",
"invalid_format_marketing": "マーケティングカテゴリ — 正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)",
"invalid_format": "正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)",
"invalid_format_functional": "機能カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)",
"invalid_format_analytics": "分析カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)",
"invalid_format_marketing": "マーケティングカテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)",
"too_long": "ドメインは253文字を超えることはできません。",
"too_long_functional": "機能カテゴリ — ドメインは253文字を超えることはできません。",
"too_long_analytics": "分析カテゴリ — ドメインは253文字を超えることはできません。",
@@ -43,17 +43,17 @@
"settings": {
"saved": "設定が保存されました。",
"title": "GDPR設定",
"description": "クッキー同意バナーと本人ページ同意管理カードの動作を運営者が制御します。",
"description": "クッキー同意バナーとマイページ同意管理カードの動作を運営者が制御します。",
"section": {
"operator": "運営主体·プライバシーポリシー",
"operator_desc": "クッキーバナー·ポリシーページに表示される運営主体、データ保管位置、ポリシーページスラッグを登録します。",
"operator": "運営主体/プライバシーポリシー",
"operator_desc": "クッキーバナー・ポリシーページに表示される運営主体、データ保管位置、ポリシーページスラッグを登録します。",
"cookie_banner": "クッキー同意バナー",
"cookie_banner_desc": "訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一か所で設定します。",
"cookie_banner_desc": "訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一箇所で設定します。",
"cookie_banner_settings": "バナー表示設定",
"cookie_categories": "クッキーカテゴリ",
"cookie_policy_version": "クッキーポリシーバージョン",
"auto_blocking": "自動ブロックポリシー",
"auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメインリストの外部追跡リソースがユーザーの同意前まで自動ブロックされます。"
"auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメインリストの外部追跡リソースはユーザーの同意前まで自動ブロックされます。"
},
"nav": {
"operator": "運営情報",
@@ -69,16 +69,16 @@
"fields": {
"cookie_policy_version": {
"label": "ポリシーバージョン",
"hint": "ポリシー本文、カテゴリの意味、委託者·データ保管情報が変わった場合は「+ 新規バージョン公開」をクリックしてください。公開直後にすべての会員が次回訪問時に再同意を進めます。"
"hint": "ポリシー本文、カテゴリの意味、受託者・データ保管情報が変わったら「+ 新しいバージョンを公開」を押してください。公開直後にすべての会員が次回訪問時に再同意を進めます。"
},
"privacy_policy_slug": {
"label": "プライバシーポリシーページスラッグ",
"hint": "ページプラグインに登録された処理方針ページのスラッグ。空白の場合、クッキーバナーのポリシーリンクが表示されません。",
"hint": "ページプラグインに登録された処理方針ページのスラッグ。空の場合、クッキーバナーのポリシーリンクは表示されません。",
"placeholder": "例: privacy-policy"
},
"legal_entity_name": {
"label": "法人名·運営主体",
"hint": "クッキーバナーと本人ページに表示されるサイト運営主体の名前です。",
"label": "法人名/運営主体",
"hint": "クッキーバナーとマイページに表示されるサイト運営主体の名前です。",
"placeholder": "例: (株)会社名"
},
"data_storage_location": {
@@ -88,7 +88,7 @@
},
"banner_enabled": {
"label": "クッキーバナー表示",
"hint": "有効化時にクッキーバナー表示、同意前外部追跡自動ブロック、本人ページ同意管理カードが一緒に開始されます。"
"hint": "有効化時、クッキーバナーの表示と同意前の外部追跡自動ブロックが一緒に開始されます。マイページ同意管理カードはこのトグルと無関係に、同意/取消履歴がある会員に常に表示されます。"
},
"banner_position": {
"label": "バナー位置",
@@ -99,75 +99,76 @@
"option_centered_modal": "中央モーダル"
},
"blocked_domains": {
"warnings_title": "案内",
"auto_blocking_off_hint": "自動ブロックはクッキーバナー表示がONの時のみ動作します。「クッキーバナー」カードでクッキーバナー表示トグルをまず有効化してください。",
"warning_visual_break": "外部フォント·スタイルシートは分類に注意してください。誤ってブロックするとページが静かに壊れる可能性があります。",
"warnings_title": "ご案内",
"auto_blocking_off_hint": "自動ブロックはクッキーバナー表示が ON の場合のみ動作します。「クッキーバナー」カードでクッキーバナー表示トグルを先に有効化してください。",
"warning_visual_break": "外部フォント・スタイルシートは分類に注意してください。誤ってブロックするとページが静かに崩れる可能性があります。",
"warning_policy_bump": "ブロックドメインを変更した後、クッキーポリシーバージョンを上げてください。既存の同意者が新しいドメインにも同意したものとして処理される問題を防ぎます。",
"warning_domain_format": "ドメイン形式: example.com、*.example.com (ワイルドカード) 可能。localhostのように点がない名前や韓国語ドメインはサポートされていません。",
"tag_input_placeholder": "ドメイン入力後、Enter キー (例: google-analytics.com、*.hotjar.com)",
"tag_no_options": "推奨ドメインがありません。直接入力してください。",
"warning_domain_format": "ドメイン形式: example.com、*.example.com (ワイルドカード) 可能。localhost のようにドットのない名前やハングルドメインはサポートしていません。",
"warning_scope_limit": "この自動ブロックはプラグイン・モジュールが登録するスクリプトのみが対象です。「テンプレート外部スクリプト」など、運営者が直接 head 領域に挿入した外部スクリプトはこの自動ブロックの保護範囲外なので、別途ロード条件を確認してください。",
"tag_input_placeholder": "ドメイン入力後 Enter (例: google-analytics.com、*.hotjar.com)",
"tag_no_options": "推奨ドメインはありません。直接入力してください。",
"category": {
"functional": "機能カテゴリブロックドメイン",
"analytics": "分析カテゴリブロックドメイン",
"marketing": "マーケティング カテゴリ 차단 도메인"
"analytics": "分析カテゴリ ブロック対象ドメイン",
"marketing": "マーケティングカテゴリ ブロック対象ドメイン"
}
},
"cookie_categories": {
"description": "クッキー カテゴリ 4種の用途と차단 도구 例は各カードの【情報を展開】で確認できます。",
"description": "クッキーカテゴリ4種の用途とブロック機能の例は、各カードの【情報を展開】で確認できます。",
"category_required_badge": "必須",
"category_optional_badge": "選択",
"info": {
"expand": "情報を展開",
"collapse": "情報を閉じる",
"description_label": "ユーザー案内文句",
"scope_label": "自動차단 対象",
"tools_label": "代表 ツール例",
"description_label": "ユーザーへの案内文",
"scope_label": "自動ブロック対象",
"tools_label": "代表的なツール例",
"necessary": {
"scope": "自動차단しません。セッション·ログイン トークン、カート識別子、ユーザーが会員登録 時に選択した言語 設定、クッキー 同意 記録など サイト動作に必ず必要な項目は常に許可されます。",
"tools": "セッション ID、認証 トークン、CSRF トークン、カート識別子、多言語 設定など(別途設定 不要)"
"scope": "自動ブロックしません。セッション·ログイントークン、カート識別子、ユーザーが登録時に選択した言語設定、クッキー同意記録など、サイト動作に不可欠な項目は常に許可されます。",
"tools": "セッションID、認証トークン、CSRFトークン、カート識別子、多言語設定など(別途設定不要)"
},
"functional": {
"scope": "「自動차단 ポリシー」タブの機能 カテゴリ ドメイン リストに登録された外部リソースが同意前まで自動차断されます。また ダークモード·通貨 選好のような ユーザー 利便性 保存も同意後にのみ保管されます。",
"tools": "顧客サポート チャットボット(Crisp、Intercom、Tawk.to)、多言語 自動翻訳 ウィジェット、ユーザー設定 同期 サービスなど"
"scope": "「自動ブロックポリシー」タブの機能カテゴリドメインリストに登録された外部リソースが、同意前までブロックされます。また、ダークモード·通貨選好などのユーザー利便設定も、同意後のみ保存されます。",
"tools": "顧客サポートチャットボット(Crisp、Intercom、Tawk.to)、多言語自動翻訳ウィジェット、ユーザー設定同期サービスなど"
},
"analytics": {
"scope": "「自動차단 ポリシー」タブの分析 カテゴリ ドメイン リストに登録された外部分析 スクリプト·埋め込み·トラッキング ピクセルが同意前まで自動차단されます。同意すると即座に復元されて作動します。",
"tools": "Google Analytics 4(GA4)、Hotjar、Microsoft Clarity、Mixpanel、Matomo、ネイバー プレミアム ログ分析など"
"scope": "「自動ブロックポリシー」タブの分析カテゴリドメインリストに登録された外部分析スクリプト·埋め込み·トラッキングピクセルが、同意前までブロックされます。同意すると即座に復元され、機能します。",
"tools": "Google Analytics 4(GA4)、Hotjar、Microsoft Clarity、Mixpanel、Matomo、Naver プレミアムログ分析など"
},
"marketing": {
"scope": "「自動차단 ポリシー」タブのマーケティング カテゴリ ドメイン リストに登録された外部広告·埋め込み·トラッキング ピクセルが同意前まで自動차단されます。同意すると即座に復元されて作動します。",
"tools": "Meta ピクセル(Facebook)、Google 広告 リマーケティング、カカオ ピクセル、YouTube 動画 埋め込みなど"
"scope": "「自動ブロックポリシー」タブのマーケティングカテゴリドメインリストに登録された外部広告·埋め込み·トラッキングピクセルが、同意前までブロックされます。同意すると即座に復元され、機能します。",
"tools": "Meta Pixel(Facebook)、Google 広告リマーケティング、カカオピクセル、YouTube動画埋め込みなど"
}
}
}
},
"save_success": "設定が保存されました。",
"save_error": "設定 保存 中 エラーが発生しました。",
"validation_error": "入力値を確認してください。",
"unsaved_changes": "保存されていない変更事項があります。",
"save_error": "設定の保存中にエラーが発生しました。",
"validation_error": "入力値をご確認ください。",
"unsaved_changes": "保存されていない変更があります。",
"policy_version": {
"current_label": "現在バージョン",
"published_at_label": "公開 日時",
"current_label": "現在のバージョン",
"published_at_label": "公開日時",
"publisher_label": "公開者",
"publisher_system": "システム",
"memo_label": "変更 理由",
"publish_button": "新 バージョン 公開",
"history_toggle_show": "履歴を見る",
"memo_label": "変更理由",
"publish_button": "新バージョンを公開",
"history_toggle_show": "履歴を表示",
"history_toggle_hide": "履歴を閉じる",
"history_empty": "公開された ポリシー バージョンがありません。",
"publish_success": "新しい ポリシー バージョンが公開されました。すべての会員が次回 訪問時に再同意を進めます。",
"publishing": "公開 中...",
"guide_title": "いつ新しい バージョンを公開しますか?",
"guide_body": "차断 ドメイン 変更、UI 文句 修正のような運用上の変更は新しい バージョン 公開が必要ではありません。",
"history_empty": "公開されたポリシーバージョンがありません。",
"publish_success": "新しいポリシーバージョンが公開されました。すべての会員は次回訪問時に再同意を行います。",
"publishing": "公開中...",
"guide_title": "いつ新バージョンを公開しますか?",
"guide_body": "ブロック対象ドメインの変更、UI文句の修正などの運用上の変更は、新バージョンの公開は不要です。",
"change_type": {
"material": "再同意 トリガー",
"non_material": "単純 変更",
"initial": "初期 公開"
"material": "再同意トリガー",
"non_material": "単純な変更",
"initial": "初回公開"
},
"history": {
"col": {
"version": "バージョン",
"created_at": "公開 日時",
"created_at": "公開日時",
"publisher": "公開者",
"change_type": "種類",
"memo": "理由",
@@ -176,50 +177,50 @@
},
"snapshot_view_short": "表示",
"material_modal": {
"title": "新 ポリシー バージョン 公開 + 再同意 トリガー",
"body": "「新 バージョン 公開」を押すと即座に公開され、すべての ユーザーが次回 訪問時に再同意する必要があります。",
"effect_bump": "ポリシー バージョン 単調 増加(例:v2 → v3)",
"effect_reconsent": "すべての会員が次回 訪問時に再同意 画面を見ることになります",
"effect_history": "履歴 表に新しい バージョン row が追加されます",
"memo_label": "変更 理由(必須)",
"memo_placeholder": "例:規約 ページ 本文 修正/委託 運営者 変更",
"confirm": "新 バージョン 公開"
"title": "新ポリシーバージョンの公開 + 再同意トリガー",
"body": "【新バージョンを公開】をクリックすると、即座に公開され、すべてのユーザーが次回訪問時に再同意する必要があります。",
"effect_bump": "ポリシーバージョンが単調増加(例:v2 → v3)",
"effect_reconsent": "すべての会員が次回訪問時に再同意画面を見ることになります",
"effect_history": "履歴テーブルに新バージョン行が追加されます",
"memo_label": "変更理由(必須)",
"memo_placeholder": "例:規約ページ本文の修正/受託運営者の変更",
"confirm": "新バージョンを公開"
},
"snapshot_modal": {
"editor_label": "ポリシー バージョン 本文を見る",
"title": "ポリシー バージョン v{version} 本文",
"description": "公開 時点 settings のカテゴリ リスト·ポリシー 本文 スラッグ·차단 ドメインなど全体 本文です。DPO が同意 紛争時に立証 資料として活用します。",
"section_categories": "クッキー カテゴリ",
"section_privacy_policy": "ポリシー 本文 スラッグ",
"section_blocked_domains": "차단 ドメイン カタログ",
"editor_label": "ポリシーバージョン本文を表示",
"title": "ポリシーバージョンv{version}本文",
"description": "公開時点での settings のカテゴリリスト·ポリシー本文スラッグ·ブロック対象ドメインなど全体の本文です。DPO が同意紛争時に立証資料として活用します。",
"section_categories": "クッキーカテゴリ",
"section_privacy_policy": "ポリシー本文スラッグ",
"section_blocked_domains": "ブロック対象ドメインカタログ",
"category_required_badge": "必須",
"no_categories": "登録されたカテゴリがありません。",
"no_blocked_domains": "登録された차단 ドメインがありません。",
"view_button": "v{version} 本文を見る",
"no_blocked_domains": "登録されたブロック対象ドメインがありません。",
"view_button": "v{version}本文を表示",
"close": "閉じる",
"open_policy_page": "本文 ページを開く",
"no_policy_slug": "登録されたポリシー 本文 ページがありません。"
"open_policy_page": "本文ページを開く",
"no_policy_slug": "登録されたポリシー本文ページがありません。"
},
"not_found": "該当するポリシー バージョンが見つかりません。"
"not_found": "そのポリシーバージョンが見つかりません。"
}
},
"admin": {
"consent_log": {
"title": "GDPR 同意 履歴",
"description": "会員·ゲストの同意/撤回 変更 履歴を照会します。IP·User-Agent などの監査 情報が合わせて露出されます。",
"title": "GDPR 同意履歴",
"description": "会員·ゲストの同意/取消の変更履歴を照会します。IP·User-Agent など監査情報も併せて表示されます。",
"refresh": "更新",
"empty": "照会された同意 履歴がありません。",
"clear_filters": "フィルター 初期化",
"empty": "照会された同意履歴がありません。",
"clear_filters": "フィルターを初期化",
"guest_label": "ゲスト",
"view_member_tooltip": "会員 詳細 ページへ移動",
"search_placeholder": "メールアドレス または セッション ID で検索",
"view_member_tooltip": "会員詳細ページへ移動",
"search_placeholder": "メールアドレスまたはセッションID で検索",
"search_button": "検索",
"search_type": {
"email": "メール",
"session_id": "セッション ID"
"email": "メールアドレス",
"session_id": "セッションID"
},
"filter": {
"consent_key_label": "同意 項目",
"consent_key_label": "同意項目",
"action_label": "アクション",
"source_label": "出典",
"option_all": "すべて"
@@ -244,10 +245,10 @@
"ip": "IP"
},
"detail": {
"session_id_label": "セッション ID",
"session_id_label": "セッションID",
"user_agent_label": "User-Agent",
"categories_label": "カテゴリースナップショット",
"categories_hint": "同意時点で会員がカテゴリー別に表示した意思です。ポリシー本文が更新されてもこの記録は不変として保存されます。",
"categories_label": "カテゴリスナップショット",
"categories_hint": "同意時点に会員がカテゴリ別に表示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。",
"snapshot_granted": "同意",
"snapshot_revoked": "未同意"
}
@@ -256,9 +257,9 @@
"mypage": {
"privacy": {
"title": "クッキー同意管理",
"description": "サイトが使用中のクッキー項目について同意を変更または撤回できます。",
"description": "サイトが使用しているクッキー項目に対する同意を変更または撤回できます。",
"section_consents": "私の同意状況",
"section_consents_hint": "同意/撤回時点に応じて即座に反映されます。必須項目は撤回できません。",
"section_consents_hint": "同意·撤回時点に従って即座に反映されます。必須項目は撤回できません。",
"col_consent_key": "同意項目",
"col_consented": "ステータス",
"col_consented_at": "同意日",
@@ -270,29 +271,29 @@
"rejected_label": "拒否",
"revoke": "撤回",
"grant": "同意",
"grant_again": "再度同意",
"label_suffix": "{label} クッキー",
"grant_again": "改めて同意",
"label_suffix": "{label}クッキー",
"locked_label": "必須",
"enable_all": "すべてに同意",
"enable_all_hint": "同意していない選択項目があります。一度にすべてに同意できます。",
"enable_all_success": "選択項目にすべて同意しました。",
"enable_all_hint": "同意していない選択項目があります。一度にすべて同意できます。",
"enable_all_success": "選択項目のすべてに同意しました。",
"required_label": "必須",
"required_hint": "サービス提供に必須のため撤回できません。",
"grant_confirm_title": "クッキー同意確認",
"grant_confirm_message": "このクッキーに同意しますか?同意すると該当カテゴリーのクッキーが使用され、いつでも再度撤回できます。",
"grant_confirm_message": "このクッキーに同意いたしますか?同意すると、該当カテゴリのクッキーが使用され、いつでも再度撤回できます。",
"revoke_confirm_title": "同意撤回確認",
"revoke_confirm_message": "この項目を撤回しますか?同意を撤回すると該当カテゴリー関連機能が制限される可能性があります。",
"revoke_target_consented_at": "同意 · {date}",
"no_consents": "有効な同意項目がありません。",
"revoke_confirm_message": "この項目を撤回いたしますか?同意を撤回すると、該当カテゴリ関連機能が制限される可能性があります。",
"revoke_target_consented_at": "同意·{date}",
"no_consents": "アクティブな同意項目がありません。",
"needs_renewal": "クッキーポリシーが変更されました。同意を更新してください。",
"needs_renewal_with_version": "クッキーポリシーがバージョン {version} に変更されました。選択項目の同意を再度確認してください。",
"needs_renewal_hint": "必須クッキーはサイト運営に必ず必要な項目のためポリシー変更と無関係に常に適用されます。",
"needs_renewal_with_version": "クッキーポリシーがバージョン{version}に変更されました。選択項目の同意を改めてご確認ください。",
"needs_renewal_hint": "必須クッキーはサイト運営に不可欠な項目のため、ポリシー変更に関わらず常に適用されます。",
"btn_renew_all": "現在の同意更新",
"btn_renew_this_item": "ポリシー更新",
"renew_all_success": "有効項目 {renewed} 個を新しいポリシーバージョンに更新しました。",
"operator_and_storage_meta": "本サイトは {entity} が運営しており、データは {location} に保存されます",
"operator_only_meta": "本サイトは {entity} が運営しています",
"storage_only_meta": "ユーザーデータは {location} に保存されます"
"renew_all_success": "アクティブな項目{renewed}個を新しいポリシーバージョンに更新しました。",
"operator_and_storage_meta": "本サイトは{entity}が運営しており、データは{location}に保存されます",
"operator_only_meta": "本サイトは{entity}が運営しています",
"storage_only_meta": "ユーザーデータは{location}に保存されます"
}
},
"common": {
@@ -300,22 +301,22 @@
"refresh": "更新"
},
"banner": {
"re_appear_reason": "クッキーポリシーがバージョン {version} に変更されたため同意を再度確認します。",
"re_appear_reason": "クッキーポリシーがバージョン{version}に変更されたため、同意を改めてご確認します。",
"consent_required_hint": "拒否した項目に関連する機能が制限される可能性があります。",
"btn_renew_consent": "現在の同意更新",
"title": "クッキー使用のお知らせ",
"description": "本サイトはユーザー体験の改善と分析のためクッキーを使用しています。項目ごとに同意の有無を選択できます。",
"title": "クッキー使用案内",
"description": "本サイトはユーザーエクスペリエンスの向上と分析のためクッキーを使用します。項目別に同意の有無を選択できます。",
"accept_all": "すべてに同意",
"reject_continue": "同意せずに続行",
"reject_continue": "同意せずに続ける",
"preferences": "選択同意",
"save": "選択を保存",
"save": "選択保存",
"policy_link": "プライバシーポリシー",
"preferences_title": "クッキー環境設定",
"preferences_description": "カテゴリー別クッキー使用を個別に同意/撤回できます。必須カテゴリーはオフにできません。",
"preferences_description": "カテゴリ別クッキー使用を個別に同意·撤回できます。必須カテゴリはオフにできません。",
"category_required_badge": "必須",
"view_policy_label": "変更されたポリシー本文を表示",
"operator_and_storage_line": "本サイトは {entity} が運営しており、データは {location} に保存されます",
"operator_only_line": "本サイトは {entity} が運営しています",
"storage_only_line": "ユーザーデータは {location} に保存されます"
"operator_and_storage_line": "本サイトは{entity}が運営しており、データは{location}に保存されます",
"operator_only_line": "本サイトは{entity}が運営しています",
"storage_only_line": "ユーザーデータは{location}に保存されます"
}
}
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-gdpr",
@@ -1,4 +1,4 @@
{
"name": "GDPR (一般データ保護規則)",
"description": "クッキー同意バナー、自動ブロック、同意履歴保存、マイページ同意撤回を提供するGDPR対応プラグインです。"
"name": "GDPR(一般データ保護規則)",
"description": "クッキー同意バナー、自動ブロック、同意履歴の保存、マイページでの同意取消を提供するGDPR対応プラグインです。"
}
@@ -1,6 +1,6 @@
{
"sirsoft-gdpr.privacy": {
"name": "個人情報管理者",
"description": "GDPR ドメイン運用権限 (設定·同意履歴閲覧)"
"name": "個人情報運営者",
"description": "GDPRドメイン運用権限(設定・同意履歴閲覧)"
}
}
@@ -12,6 +12,7 @@
- 거부한 이력을 동의·철회와 구분해 관리자 「GDPR 동의 이력」에서 별도 배지와 [거부] 필터로 확인할 수 있습니다.
- 거부 후 다시 동의하면 상태가 동의로 갱신되며, 거부·동의 이력은 모두 보존됩니다.
- 마이페이지 「쿠키 동의 관리」에 「모두 동의」 버튼 추가 — 동의하지 않은 선택 항목을 한 번에 동의할 수 있습니다.
- 자동 차단 정책 안내에 보호 범위 한계를 명시했습니다 — 이 자동 차단은 플러그인·모듈이 등록한 스크립트만 대상이며, 운영자가 템플릿 외부 스크립트 등으로 head 영역에 직접 삽입한 외부 스크립트는 보호 범위 밖임을 안내합니다.
### Changed
@@ -30,6 +31,8 @@
- 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다.
- 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다.
- 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다.
- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음).
- 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다.
## [1.0.1] - 2026-07-16
@@ -65,7 +65,7 @@ _대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있
<!-- @generated:end -->
**설명** 공개 쿠키 동의 배너에서 방문자가 선택한 카테고리별 동의를 저장합니다. `optional.sanctum` 라우트로 게스트와 회원 모두 호출할 수 있으며, 회원(sanctum 토큰 보유)이면 user_id 기준으로 status를 upsert하고 history를 남기고, 게스트면 session_id 기준으로 history를 기록합니다. 게스트가 처음 호출해 세션 식별자가 없으면 UUID 기반 `gdpr_session` 쿠키(1년, SameSite=Lax)를 응답에 발급해 첨부합니다. 동의 철회 시 실제 쿠키 파기는 이 엔드포인트가 아니라 클라이언트 정리기와 후속 응답의 CookieConsentMiddleware가 담당합니다.
**설명** 공개 쿠키 동의 배너에서 방문자가 선택한 카테고리별 동의를 저장합니다. `optional.sanctum` 라우트로 게스트와 회원 모두 호출할 수 있으며, 회원(sanctum 토큰 보유)이면 user_id 기준으로 status를 upsert하고 history를 남기고, 게스트면 session_id 기준으로 history를 기록합니다. 게스트가 처음 호출해 세션 식별자가 없으면 UUID에 HMAC-SHA256 서명을 붙인(`{uuid}|{서명}`) `gdpr_session` 쿠키(1년, SameSite=Lax)를 응답에 발급해 첨부합니다 — 서명은 위조 방지 목적이며, 요청으로 들어온 값의 서명이 유효하지 않으면 신원 불명(새 게스트)으로 처리합니다. 동의 철회 시 실제 쿠키 파기는 이 엔드포인트가 아니라 클라이언트 정리기와 후속 응답의 CookieConsentMiddleware가 담당합니다.
### GET /api/plugins/sirsoft-gdpr/consent/cookie/status
@@ -188,6 +188,8 @@ Content-Type: application/json
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**참고** 위 표에 정의되지 않은 요청 필드는 검증 대상에서 제외되며(`FormRequest::validated()` 결과만 사용), 저장 로직에 전달되지 않습니다.
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
+1 -1
View File
@@ -31,6 +31,6 @@
},
"loading": {
"strategy": "global",
"priority": 50
"priority": 0
}
}
@@ -27,8 +27,8 @@
"id": "gdpr_cookie_banner",
"type": "basic",
"name": "Div",
"comment": "GDPR 쿠키 동의 배너 (W-1) — banner_enabled 토글 기반. 환경설정은 모달 대신 배너 내부 인라인 펼침으로 처리. 동의 완료 여부는 gdprMyConsent (서버) 가 우선이며, 사용자 클릭 직후 즉시 사라지도록 _global.gdprBannerDismissed 도 함께 검사",
"if": "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}",
"comment": "GDPR 쿠키 동의 배너 (W-1) — banner_enabled 토글 기반. 환경설정은 모달 대신 배너 내부 인라인 펼침으로 처리. 동의 완료 여부는 gdprMyConsent (서버) 가 우선이며, 사용자 클릭 직후 즉시 사라지도록 _global.gdprBannerDismissedFor 도 함께 검사 — 닫은 시점의 사용자 식별자(uuid 또는 'guest')를 저장해 현재 사용자와 비교. 로그인으로 사용자가 바뀌면(uuid 불일치) 다시 노출되어, 게스트로 닫은 뒤 미동의 계정으로 로그인해도 배너가 계속 숨겨지는 회귀를 방지.",
"if": "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissedFor !== (_global.currentUser?.uuid ?? 'guest')}}",
"props": {
"className": "fixed z-50 {{(gdprPublicSettings?.data?.banner_position === 'bottom_left_popup') ? 'bottom-4 left-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'bottom_right_popup') ? 'bottom-4 right-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'centered_modal') ? 'top-1/2 -translate-y-1/2 inset-x-4 mx-auto max-w-md rounded-lg' : 'bottom-4 left-4 right-4 rounded-lg'))}} bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700"
},
@@ -178,7 +178,7 @@
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}",
"gdprPreferencesOpen": false,
"gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: c.required === true }), {})}}"
}
@@ -270,7 +270,7 @@
"target": "/api/plugins/sirsoft-gdpr/consent/renew-all",
"params": { "method": "POST" },
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null, "gdprBannerDismissed": true } },
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null, "gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}" } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsent" } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.mypage.privacy.renew_all_success|renewed={{response?.data?.renewed ?? 0}}" } }
],
@@ -318,7 +318,7 @@
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}",
"gdprPreferencesOpen": false,
"gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: true }), {})}}"
}
@@ -535,7 +535,7 @@
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}",
"gdprPreferencesOpen": false
}
},
@@ -3,7 +3,7 @@
*
* @description
* - extension_point: user_global_overlay (sirsoft-basic _user_base 가 사전에 정의)
* - banner_enabled === true && !gdprBannerDismissed 조건 표시
* - banner_enabled === true && !has_consented && dismissedFor(닫은 주체) !== 현재 사용자 조건 표시
* - 모두 동의 / 필수만 사용 / 환경설정 / 정책 링크 제공
* - 환경설정은 모달이 아니라 배너 내부 인라인 펼침 패널 (gdpr_preferences_panel)
* - banner_position 4종 분기 (bottom_bar / bottom_left_popup / bottom_right_popup / centered_modal)
@@ -79,17 +79,86 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
expect(banner).toBeTruthy();
});
it('banner_enabled + has_consented + dismissed 3중 조건 — 데이터소스 경로는 한 단계 (?.data?.<key>)', () => {
it('banner_enabled + has_consented + dismissedFor 3중 조건 — 데이터소스 경로는 한 단계 (?.data?.<key>)', () => {
// 회귀 가드:
// 1. ResponseHelper::success($msg, $data) 응답은 데이터소스에서 그대로 보존되므로 단일 객체 응답은 `<id>.data.<key>` 한 단계 경로.
// 과거 통합본에서 `?.data?.data?` 두 단계로 작성되어 항상 undefined → if false → 배너 미출력 사고.
// 2. _global.gdprBannerDismissed 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장.
// 2. _global.gdprBannerDismissedFor 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장.
// 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
// 4. dismissedFor 는 "누가 닫았는지"(uuid|'guest') 를 저장해 현재 사용자(_global.currentUser?.uuid)와 비교한다.
// boolean(gdprBannerDismissed) 만 저장하던 이전 방식은 게스트로 닫은 뒤 미동의 계정으로 로그인해도
// 배너가 계속 숨겨지는 회귀가 있었다 (닫은 주체를 구분하지 않았기 때문).
expect(banner?.if).toBe(
'{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}'
"{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissedFor !== (_global.currentUser?.uuid ?? 'guest')}}"
);
});
/**
* 회귀 가드 — 게스트로 배너를 닫은 뒤 미동의 계정으로 로그인해도 배너가 계속
* 숨겨지던 결함. banner.if 표현식을 실제 JS 로 평가해 각 시나리오의 최종
* 노출 여부를 직접 검증한다 (문자열 일치만으로는 실제 평가 결과를 보장하지 못함).
*/
describe('닫은 주체(dismissedFor) vs 현재 사용자 비교 — 실제 평가', () => {
const evaluateBannerIf = (
gdprMyConsent: { data?: { has_consented?: boolean } } | undefined,
_global: { gdprBannerDismissedFor?: string; currentUser?: { uuid?: string } }
): boolean => {
const gdprPublicSettings = { data: { banner_enabled: true } };
const expr = (banner?.if ?? '').replace(/^\{\{/, '').replace(/\}\}$/, '');
// eslint-disable-next-line no-new-func
const fn = new Function('gdprPublicSettings', 'gdprMyConsent', '_global', `return (${expr});`);
return fn(gdprPublicSettings, gdprMyConsent, _global) as boolean;
};
it('게스트로 배너를 닫으면(dismissedFor="guest") 같은 게스트 방문에는 배너가 다시 뜨지 않는다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'guest', currentUser: undefined }
);
expect(shown).toBe(false);
});
it('게스트로 배너를 닫은 뒤 미동의 계정으로 로그인하면 배너가 다시 뜬다 (회귀 재현 케이스)', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'guest', currentUser: { uuid: 'user-uuid-1' } }
);
expect(shown).toBe(true);
});
it('회원 A 가 닫은 뒤 같은 세션에서 회원 A 로 계속 조회하면 배너가 다시 뜨지 않는다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'user-uuid-1', currentUser: { uuid: 'user-uuid-1' } }
);
expect(shown).toBe(false);
});
it('회원 A 가 닫은 뒤 (동일 세션에서) 회원 B 로 전환되면 배너가 다시 뜬다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'user-uuid-1', currentUser: { uuid: 'user-uuid-2' } }
);
expect(shown).toBe(true);
});
it('서버가 이미 동의 완료(has_consented=true)로 응답하면 dismissedFor 값과 무관하게 배너를 띄우지 않는다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: true } },
{ gdprBannerDismissedFor: 'guest', currentUser: { uuid: 'user-uuid-1' } }
);
expect(shown).toBe(false);
});
it('dismissedFor 가 아직 설정되지 않은 최초 방문(게스트)에는 배너가 뜬다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: undefined, currentUser: undefined }
);
expect(shown).toBe(true);
});
});
it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (결정 — 배너 통합)', () => {
// 모달 폐기 회귀 가드: 모달과 배너의 시각적 중복 + UX 혼란 + dark pattern 회피 위해
// 모달 통째 제거하고 배너에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
@@ -293,7 +362,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
it('동의 저장 직후 onSuccess 에서 gdprMyConsent 를 refetch (3개 버튼 모두)', () => {
// 회귀 가드: 동의 후 새로고침 시 배너 재출력을 막으려면 서버 동의 상태를 다시 조회해야 함.
// setState gdprBannerDismissed=true 만으로는 글로벌 상태가 새로고침 시 휘발됨.
// setState gdprBannerDismissedFor=<uuid|'guest'> 만으로는 글로벌 상태가 새로고침 시 휘발됨.
const refetchOccurrences = (text.match(/"handler":\s*"refetchDataSource"/g) ?? []).length;
expect(refetchOccurrences).toBeGreaterThanOrEqual(3);
expect(text).toContain('"dataSourceId": "gdprMyConsent"');
@@ -398,10 +467,10 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
expect(text).toContain('"disabled": "{{category?.required === true}}"');
});
it('저장 버튼 클릭 시 POST /consent/cookie + 배너 dismiss + 성공 토스트', () => {
it('저장 버튼 클릭 시 POST /consent/cookie + 배너 dismissedFor 갱신 + 성공 토스트', () => {
const text = serializeForSearch(panel);
expect(text).toContain('/api/plugins/sirsoft-gdpr/consent/cookie');
expect(text).toContain('"gdprBannerDismissed": true');
expect(text).toContain('"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? \'guest\'}}"');
expect(text).toContain('"gdprPreferencesOpen": false');
expect(text).toContain('sirsoft-gdpr.consent.granted');
});
@@ -104,6 +104,7 @@
"warning_visual_break": "Be careful when classifying external font/stylesheet domains. Misclassification can break the page silently.",
"warning_policy_bump": "Bump the cookie policy version after changing the blocked domain list. This prevents existing consenters from being implicitly enrolled into newly added trackers.",
"warning_domain_format": "Domain format: example.com, *.example.com (wildcard) are supported. Names without dots (like localhost) and non-ASCII domains are not supported.",
"warning_scope_limit": "Auto-blocking only covers scripts registered by plugins/modules. External scripts inserted directly into the head area by admins (e.g. via Template External Scripts) are outside this protection — verify their load conditions separately.",
"tag_input_placeholder": "Type a domain and press Enter (e.g., google-analytics.com, *.hotjar.com)",
"tag_no_options": "No suggestions. Type to add.",
"category": {
@@ -104,6 +104,7 @@
"warning_visual_break": "외부 폰트·스타일시트는 분류에 주의하세요. 잘못 차단하면 페이지가 조용히 깨질 수 있습니다.",
"warning_policy_bump": "차단 도메인을 변경한 후 쿠키 정책 버전을 올리세요. 기존 동의자가 새 도메인에도 동의한 것으로 처리되는 문제를 막습니다.",
"warning_domain_format": "도메인 형식: example.com, *.example.com (와일드카드) 가능. localhost 처럼 점이 없는 이름이나 한글 도메인은 지원하지 않습니다.",
"warning_scope_limit": "이 자동 차단은 플러그인·모듈이 등록하는 스크립트만 대상입니다. 「템플릿 외부 스크립트」 등 운영자가 직접 head 영역에 삽입한 외부 스크립트는 이 자동 차단의 보호 범위 밖이므로, 별도로 로드 조건을 확인하세요.",
"tag_input_placeholder": "도메인 입력 후 Enter (예: google-analytics.com, *.hotjar.com)",
"tag_no_options": "추천 도메인이 없습니다. 직접 입력하세요.",
"category": {
@@ -1668,6 +1668,11 @@
"type": "basic",
"name": "Li",
"text": "$t:sirsoft-gdpr.settings.fields.blocked_domains.warning_domain_format"
},
{
"type": "basic",
"name": "Li",
"text": "$t:sirsoft-gdpr.settings.fields.blocked_domains.warning_scope_limit"
}
]
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\Gdpr\Concerns;
/**
* 게스트 세션 식별 쿠키(gdpr_session) 발급/검증 트레이트
*
* api 미들웨어 그룹에는 EncryptCookies 가 등록되지 않아 쿠키가 평문으로 오간다.
* 서버가 발급하지 않은 값을 그대로 신뢰하면 임의로 조작한 session_id 로 타인의
* 게스트 동의 이력을 조회/철회할 수 있으므로, HMAC 서명을 붙여 위조를 차단한다.
*
* sirsoft-pay_nicepayments 등 결제 플러그인의 IssuesReceiptCookie 트레이트와
* 동일한 서명 컨벤션(hash_hmac + config('app.key'))을 사용한다.
*/
trait IssuesGuestSessionCookie
{
/**
* 쿠키 값에 서명을 붙입니다.
*
* @param string $sessionId UUID v4 게스트 세션 식별자
* @return string "{sessionId}|{signature}" 형태의 쿠키 값
*/
protected function signGuestSessionId(string $sessionId): string
{
return $sessionId.'|'.$this->computeGuestSessionSignature($sessionId);
}
/**
* 쿠키 값에서 서명을 검증하고 원본 session_id 를 반환합니다.
*
* 서명이 없거나 위조된 값은 신뢰하지 않고 null 을 반환합니다
* (호출자는 미식별 게스트로 취급해야 함).
*
* @param string|null $cookieValue 요청 쿠키 원본 값
* @return string|null 검증된 session_id, 위조/형식 오류 시 null
*/
protected function verifyGuestSessionId(?string $cookieValue): ?string
{
if (! is_string($cookieValue) || $cookieValue === '') {
return null;
}
$parts = explode('|', $cookieValue, 2);
if (count($parts) !== 2) {
return null;
}
[$sessionId, $signature] = $parts;
if ($sessionId === '' || ! ctype_xdigit($signature) || strlen($signature) !== 64) {
return null;
}
if (! hash_equals($this->computeGuestSessionSignature($sessionId), $signature)) {
return null;
}
return substr($sessionId, 0, 100);
}
/**
* session_id 에 대한 HMAC-SHA256 서명을 계산합니다.
*
* @param string $sessionId 게스트 세션 식별자
* @return string 64자 hex 서명
*/
private function computeGuestSessionSignature(string $sessionId): string
{
return hash_hmac('sha256', $sessionId, (string) config('app.key', ''));
}
}
@@ -0,0 +1,106 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Console\Commands;
use App\Models\User;
use App\Services\PluginSettingsService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
/**
* Playwright E2E 용 GDPR "게스트로 배너 닫음 → 다른 계정 로그인" 시나리오 시드 커맨드.
*
* 배너 노출 조건 회귀(cookie_banner.json 의 gdprBannerDismissedFor)를 실제 로그인 폼
* 제출을 거쳐 재현하기 위해 다음을 준비한다:
* 1. banner_enabled=true 저장 (배너 자체가 꺼져 있으면 시나리오 자체가 성립하지 않음)
* 2. 서명된 게스트 세션 쿠키 값 발급 + 그 세션 ID 로 "모두 동의" 이력을 실제로 기록
* (spec 이 쿠키를 심어 "이미 게스트로 배너를 닫은 상태"를 재현)
* 3. 로그인 폼에 실제로 제출할 미동의 회원 계정(email/password 고정) 1명 생성
* (이 계정은 신규 유저라 동의 이력이 전혀 없음 — 로그인 시 배너가 다시 떠야 정상)
*
* 보안 가드 (코어 PlaywrightIssueToken 과 동일 3중 패턴):
* ① CLI 한정 — `php_sapi_name() === 'cli'`
* ② G7_PLAYWRIGHT_BYPASS=1 환경변수 옵트인
* ③ APP_DEBUG=true inline override
*
* 호출 예시:
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-gdpr-guest-login --json
*/
class PlaywrightSeedGdprGuestLogin extends Command
{
use IssuesGuestSessionCookie;
/**
* 시나리오에서 로그인 폼에 실제로 입력할 고정 비밀번호.
*/
private const MEMBER_PASSWORD = 'e2e-test-password-1234';
protected $signature = 'playwright:seed-gdpr-guest-login
{--json : 결과를 JSON 으로 출력}';
protected $description = 'Playwright E2E 용 GDPR 게스트→로그인 배너 재노출 시나리오 데이터 시드 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
/**
* 커맨드를 실행합니다.
*
* @param GdprConsentService $consentService 게스트 동의 이력 기록용
* @param PluginSettingsService $pluginSettings banner_enabled 저장용
* @return int 종료 코드
*/
public function handle(GdprConsentService $consentService, PluginSettingsService $pluginSettings): int
{
// ① CLI 한정
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다.');
return self::FAILURE;
}
// ② 명시 옵트인
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다.');
return self::FAILURE;
}
// ③ APP_DEBUG 강제
Config::set('app.debug', true);
// 배너 자체가 꺼져 있으면 재현이 불가능하므로 명시적으로 켠다.
$pluginSettings->save('sirsoft-gdpr', ['banner_enabled' => true]);
// 게스트 세션: 서명된 쿠키 값을 발급하고, 동일 session_id 로 "모두 동의" 이력을 기록한다.
$guestSessionId = (string) Str::uuid();
$signedCookieValue = $this->signGuestSessionId($guestSessionId);
$consentService->updateConsents(
null,
$guestSessionId,
['cookie_necessary' => true, 'cookie_functional' => true, 'cookie_analytics' => true, 'cookie_marketing' => true],
'banner'
);
// 로그인 대상 회원: 동의 이력이 전혀 없는 신규 계정.
$email = 'e2e-gdpr-guest-login-'.uniqid().'@example.test';
$member = User::factory()->create([
'email' => $email,
'password' => bcrypt(self::MEMBER_PASSWORD),
]);
$result = [
'guest_session_cookie_value' => $signedCookieValue,
'member_email' => $member->email,
'member_password' => self::MEMBER_PASSWORD,
];
if ($this->option('json')) {
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE));
} else {
$this->info('GDPR 게스트→로그인 시나리오 시드 완료: '.$member->email);
}
return self::SUCCESS;
}
}
@@ -30,8 +30,8 @@ class GdprAdminSettingsController extends AdminBaseController
/**
* GdprAdminSettingsController 생성자
*
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스 (코어 — 조회용)
* @param GdprSettingsService $settingsService GDPR 설정 저장 서비스
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스 (코어 — 조회용)
* @param GdprSettingsService $settingsService GDPR 설정 저장 서비스
*/
public function __construct(
private readonly PluginSettingsService $pluginSettings,
@@ -62,18 +62,13 @@ class GdprAdminSettingsController extends AdminBaseController
* 정책 버전은 자동 발행되지 않습니다. 운영자가 별도로
* 「+ 새 버전 발행」 을 클릭해야 발행됩니다.
*
* @param UpdateAdminSettingsRequest $request 검증된 요청
* @param UpdateAdminSettingsRequest $request 검증된 요청
* @return JsonResponse
*/
public function update(UpdateAdminSettingsRequest $request): JsonResponse
{
$validated = $request->validated();
// 동적 스키마 fallback (validation.md "동적 스키마 기반 FormRequest 패턴")
if (empty($validated)) {
$validated = $request->all();
}
// 옛 자동 발행 흐름의 change_memo 필드는 더 이상 사용하지 않음 — 정책 버전 발행은 별도 엔드포인트로 일원화
unset($validated['change_memo']);
@@ -87,7 +82,7 @@ class GdprAdminSettingsController extends AdminBaseController
/**
* 응답에서 JSON 필드는 디코드하여 객체/배열로 노출합니다.
*
* @param array<string, mixed> $settings 설정 배열
* @param array<string, mixed> $settings 설정 배열
* @return array<string, mixed>
*/
private function normalizeJsonFields(array $settings): array
@@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Public;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Http\Requests\StoreCookieConsentRequest;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
@@ -18,6 +19,8 @@ use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
*/
class GdprCookieConsentController extends PublicBaseController
{
use IssuesGuestSessionCookie;
/**
* GdprCookieConsentController 생성자
*
@@ -120,17 +123,18 @@ class GdprCookieConsentController extends PublicBaseController
/**
* 게스트 세션 ID를 결정합니다.
*
* 클라이언트가 쿠키 또는 헤더로 전달한 session_id가 있으면 사용,
* 없으면 Laravel session ID를 fallback으로 사용합니다.
* 클라이언트가 쿠키로 전달한 session_id는 서명을 검증한 뒤 신뢰합니다
* (위조된 값은 미식별 게스트로 취급). 쿠키가 없으면 Laravel session ID를
* fallback으로 사용합니다.
*
* @param \Illuminate\Http\Request $request 요청
* @return string|null
*/
private function resolveGuestSessionId($request): ?string
{
$cookieValue = $request->cookie('gdpr_session');
if (is_string($cookieValue) && $cookieValue !== '') {
return substr($cookieValue, 0, 100);
$verified = $this->verifyGuestSessionId($request->cookie('gdpr_session'));
if ($verified !== null) {
return $verified;
}
try {
@@ -156,6 +160,7 @@ class GdprCookieConsentController extends PublicBaseController
* 응답에 게스트 세션 쿠키 (gdpr_session) 를 첨부합니다.
*
* 1년 유효, path=/, SameSite=Lax. HTTPS 환경에서는 Secure 자동 적용.
* 쿠키 값은 HMAC 서명이 붙어 위변조 시 서버가 거부합니다.
*
* @param JsonResponse $response 응답
* @param string $sessionId 발급된 세션 ID
@@ -165,7 +170,7 @@ class GdprCookieConsentController extends PublicBaseController
{
$response->cookie(
'gdpr_session',
$sessionId,
$this->signGuestSessionId($sessionId),
60 * 24 * 365,
'/',
null,
@@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\Gdpr\Http\Middleware;
use Closure;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
use Symfony\Component\HttpFoundation\Response;
@@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
*/
class CookieConsentMiddleware
{
use IssuesGuestSessionCookie;
/**
* CookieConsentMiddleware 생성자
*
@@ -106,6 +109,9 @@ class CookieConsentMiddleware
/**
* 게스트 세션 식별자를 추출합니다 (gdpr_session cookie 또는 Laravel session ID).
*
* 쿠키 값은 서명을 검증한 뒤 신뢰합니다 — 위조된 값은 미식별 게스트로
* 취급하고 Laravel session ID로 폴백합니다.
*
* @param Request $request HTTP 요청
* @return string|null 세션 식별자 (회원이거나 식별 불가 시 null)
*/
@@ -115,9 +121,9 @@ class CookieConsentMiddleware
return null;
}
$cookieValue = $request->cookie('gdpr_session');
if (is_string($cookieValue) && $cookieValue !== '') {
return substr($cookieValue, 0, 100);
$verified = $this->verifyGuestSessionId($request->cookie('gdpr_session'));
if ($verified !== null) {
return $verified;
}
try {
@@ -3,6 +3,7 @@
namespace Plugins\Sirsoft\Gdpr\Providers;
use App\Extension\BasePluginServiceProvider;
use Plugins\Sirsoft\Gdpr\Console\Commands\PlaywrightSeedGdprGuestLogin;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
@@ -27,4 +28,15 @@ class GdprServiceProvider extends BasePluginServiceProvider
GdprUserConsentHistoryRepositoryInterface::class => GdprUserConsentHistoryRepository::class,
GdprPolicyVersionRepositoryInterface::class => GdprPolicyVersionRepository::class,
];
public function boot(): void
{
parent::boot();
if ($this->app->runningInConsole()) {
$this->commands([
PlaywrightSeedGdprGuestLogin::class,
]);
}
}
}
@@ -153,11 +153,32 @@ class GdprCookieConsentControllerTest extends PluginTestCase
'신규 게스트 session_id 는 UUID v4 형식이어야 함'
);
// 응답에 gdpr_session 쿠키가 첨부되어야 함
// 응답에 gdpr_session 쿠키가 첨부되어야 함 — 값은 위조 방지를 위해 HMAC 서명이 붙어
// "{session_id}|{signature}" 형식이므로 session_id 는 접두 일치로 확인한다.
$cookies = collect($response->headers->getCookies())
->firstWhere(fn ($c) => $c->getName() === 'gdpr_session');
$this->assertNotNull($cookies, '게스트 신규 동의 시 gdpr_session 쿠키가 발급되어야 함');
$this->assertSame($sessionId, $cookies->getValue());
$this->assertStringStartsWith($sessionId.'|', (string) $cookies->getValue());
}
/**
* 회귀 가드: gdpr_session 쿠키 값을 임의로 조작해 보내면 서버가 그 값을 신뢰하지 않고
* 새로운(다른) session_id 로 취급해야 한다 — 서명 없는 값을 그대로 신뢰하던 결함 회귀 방지.
*/
public function test_tampered_gdpr_session_cookie_is_not_trusted(): void
{
$this->mockSettings(['cookie_policy_version' => '1.0']);
$forgedSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
$response = $this->withCookie('gdpr_session', $forgedSessionId)
->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [
'consents' => ['cookie_necessary' => true, 'cookie_analytics' => true],
'source' => 'banner',
]);
$response->assertOk();
$this->assertNotSame($forgedSessionId, $response->json('data.session_id'), '서명 없는 위조 session_id 는 신뢰되면 안 됨');
}
public function test_guest_consent_persists_via_service_with_session_id(): void
@@ -0,0 +1,43 @@
/**
* GDPR "게스트로 배너 닫음 → 다른 계정 로그인" 시나리오 도메인 시드 fixture.
*
* `playwright:seed-gdpr-guest-login` artisan 커맨드를 호출하여 다음을 발급받는다:
* - 서명된 게스트 세션 쿠키 값 (이미 "모두 동의" 이력이 기록된 상태)
* - 동의 이력이 전혀 없는 신규 회원 계정 (email/password 고정, 로그인 폼에 실제 입력)
*/
import { test as base } from '@playwright/test';
import { execSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 환경(package.json "type": "module")에서는 __dirname 미정의 → import.meta.url 로 재구성.
const __dirname = dirname(fileURLToPath(import.meta.url));
type GdprGuestLoginSeedFixtures = {
/** 게스트→로그인 배너 재노출 시나리오용 시드 데이터 */
gdprGuestLoginSeed: {
guest_session_cookie_value: string;
member_email: string;
member_password: string;
};
};
export const test = base.extend<GdprGuestLoginSeedFixtures>({
gdprGuestLoginSeed: async ({}, use) => {
// 6단계 상위 = 코어 루트 (artisan 실행 cwd)
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../../');
const out = execSync('php artisan playwright:seed-gdpr-guest-login --json', {
cwd: coreRoot,
encoding: 'utf-8',
env: {
...process.env,
G7_PLAYWRIGHT_BYPASS: '1',
},
});
const jsonLine = out.trim().split(/\r?\n/).filter((l) => l.trim().startsWith('{')).pop() ?? '{}';
const seed = JSON.parse(jsonLine);
await use(seed);
},
});
export { expect } from '@playwright/test';
@@ -0,0 +1,60 @@
/**
* E2E: 게스트로 쿠키 배너를 닫은 뒤 동의 이력 없는 계정으로 로그인 시 배너 재노출 회귀 가드
*
* @scenario cookie_banner_guest_dismiss_then_login_reappear
* @effects banner_hidden_for_consented_guest, banner_reappears_after_login_with_no_consent_history
*
* 배경: `_global.gdprBannerDismissedFor` 가 "닫힘 여부"(boolean)가 아니라 "누가 닫았는지"
* (사용자 식별자)를 저장하도록 수정되기 전에는, 게스트로 배너를 닫은 뒤 동의 이력이 없는
* 다른 계정으로 로그인해도 배너가 계속 숨겨져 있는 결함이 있었다 (PO 가 스크린샷으로 재현·지적).
* 현재는 cookie_banner.json 의 노출 조건이 `_global.gdprBannerDismissedFor !== (사용자 식별자)`
* 로 비교하므로, 로그인으로 사용자가 바뀌면(게스트 uuid → 회원 uuid) 다시 노출되어야 한다.
*
* 검증:
* 1. 서명된 "모두 동의" 게스트 세션 쿠키를 심은 상태로 홈에 진입하면 배너가 노출되지 않는다
* (게스트 자신은 이미 동의했으므로 정상)
* 2. 로그인 폼으로 동의 이력이 전혀 없는 신규 계정에 실제로 로그인하면 배너가 다시 노출된다
* (회귀 재현 케이스 — 수정 전에는 여기서 실패)
*/
import { test, expect } from '../../fixtures/gdpr-guest-login-seed';
const BANNER = '#gdpr_cookie_banner';
test('#509 보안검토 - 게스트로 동의 완료 후 미동의 계정으로 로그인하면 배너가 다시 노출된다', async ({ page, gdprGuestLoginSeed }) => {
// 먼저 baseURL 로 진입해 현재 오리진을 확보한 뒤(하드코딩 회피), 서명된 게스트 세션
// 쿠키를 심는다 (이미 "모두 동의" 이력이 기록된 상태).
await page.goto('/');
await page.context().addCookies([
{
name: 'gdpr_session',
value: gdprGuestLoginSeed.guest_session_cookie_value,
url: page.url(),
path: '/',
},
]);
await page.reload();
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 이미 동의한 게스트이므로 배너가 노출되지 않아야 한다.
await expect(page.locator(BANNER)).not.toBeVisible({ timeout: 15_000 });
// 2. 로그인 폼으로 동의 이력이 전혀 없는 신규 계정에 실제로 로그인한다.
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const emailInput = page.locator('input[name="email"]').first();
await expect(emailInput).toBeVisible({ timeout: 15_000 });
await emailInput.fill(gdprGuestLoginSeed.member_email);
const passwordInput = page.locator('input[name="password"]').first();
await passwordInput.fill(gdprGuestLoginSeed.member_password);
await page.locator('button[type="submit"]').first().click();
// 로그인 성공 후 리다이렉트 대기 (로그인 페이지를 벗어남).
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
// 회귀 가드: 동의 이력 없는 계정으로 전환됐으므로 배너가 다시 노출되어야 한다.
await expect(page.locator(BANNER)).toBeVisible({ timeout: 15_000 });
});
@@ -0,0 +1,93 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Tests\Unit\Concerns;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
/**
* IssuesGuestSessionCookie 트레이트 테스트
*
* gdpr_session 쿠키의 HMAC 서명 발급/검증 — 위조된 session_id 를 신뢰하지 않는지 검증.
*/
class IssuesGuestSessionCookieTest extends PluginTestCase
{
/**
* @return object 트레이트를 사용하는 익명 클래스 인스턴스
*/
private function subject(): object
{
return new class
{
use IssuesGuestSessionCookie;
public function sign(string $sessionId): string
{
return $this->signGuestSessionId($sessionId);
}
public function verify(?string $cookieValue): ?string
{
return $this->verifyGuestSessionId($cookieValue);
}
};
}
public function test_signed_cookie_value_round_trips_to_original_session_id(): void
{
$subject = $this->subject();
$sessionId = '11111111-2222-3333-4444-555555555555';
$signed = $subject->sign($sessionId);
$verified = $subject->verify($signed);
$this->assertSame($sessionId, $verified);
}
public function test_tampered_session_id_with_stale_signature_is_rejected(): void
{
$subject = $this->subject();
$signed = $subject->sign('11111111-2222-3333-4444-555555555555');
[, $signature] = explode('|', $signed, 2);
$tampered = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee|'.$signature;
$this->assertNull($subject->verify($tampered));
}
public function test_arbitrary_unsigned_value_is_rejected(): void
{
$subject = $this->subject();
$this->assertNull($subject->verify('just-a-random-string'));
}
public function test_empty_or_null_value_is_rejected(): void
{
$subject = $this->subject();
$this->assertNull($subject->verify(null));
$this->assertNull($subject->verify(''));
}
public function test_malformed_signature_format_is_rejected(): void
{
$subject = $this->subject();
// 서명 길이가 64자 hex 가 아님
$this->assertNull($subject->verify('11111111-2222-3333-4444-555555555555|not-a-valid-signature'));
}
public function test_signature_computed_with_different_app_key_is_rejected(): void
{
$subject = $this->subject();
$sessionId = '11111111-2222-3333-4444-555555555555';
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
$signed = $subject->sign($sessionId);
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
$this->assertNull($subject->verify($signed));
}
}