From 5e8e81f404b5627e606a15bbdeebe86b96d1f0cd Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Thu, 16 Jul 2026 15:45:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=80=EC=A0=95=ED=8C=90=EB=A7=A4?= =?UTF-8?q?=EC=86=8C=20=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4(PII)=20?= =?UTF-8?q?=EB=B3=B4=ED=98=B8=20=E2=80=94=20=EB=A7=88=EC=8A=A4=ED=82=B9?= =?UTF-8?q?=C2=B7=EC=95=94=ED=98=B8=ED=99=94=C2=B7=EC=97=B4=EB=9E=8C=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=20(=EA=B8=B0=EB=B3=B8=20OFF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 종량제3 세션 작업. 마스터 플래그(Config\Pii.designatedShopProtection)가 OFF면 현재 동작(원문 그대로) 유지, ON일 때만 마스킹·게이트 적용. - 표시 계층 단일 지점 마스킹: 대표자명/전화/이메일/계좌/가상계좌 (목록·상세·엑셀·판매대장·전화주문) - 원문 열람 게이트: 지자체 허용 IP 자동열람 or 2단계 인증(TOTP) step-up + 감사기록(revealPii) - 정확일치 검색: 블라인드 인덱스(HMAC) 컬럼 있으면 사용, 없으면 평문 폴백 - 저장계층 암호화(encryptAtRest, 기본 OFF): 모델 콜백 + spark 명령 pii:protect-designated-shops - SQL(추가·멱등): designated_shop_add_pii_bidx / local_government_add_allow_ips - Auth: TOTP 실제 통과 시에만 세션 auth_2fa_verified=true Co-Authored-By: Claude Opus 4.8 --- app/Commands/ProtectDesignatedShops.php | 86 +++++++ app/Config/Pii.php | 32 +++ app/Config/Routes.php | 1 + app/Controllers/Admin/DesignatedShop.php | 128 ++++++++- app/Controllers/Admin/SalesReport.php | 10 + app/Controllers/Auth.php | 8 +- app/Controllers/Bag.php | 13 +- app/Helpers/pii_encryption_helper.php | 243 ++++++++++++++++++ app/Models/DesignatedShopModel.php | 112 ++++++++ app/Views/admin/designated_shop/index.php | 74 +++++- .../database/designated_shop_add_pii_bidx.sql | 23 ++ .../local_government_add_allow_ips.sql | 15 ++ 12 files changed, 726 insertions(+), 19 deletions(-) create mode 100644 app/Commands/ProtectDesignatedShops.php create mode 100644 app/Config/Pii.php create mode 100644 writable/database/designated_shop_add_pii_bidx.sql create mode 100644 writable/database/local_government_add_allow_ips.sql diff --git a/app/Commands/ProtectDesignatedShops.php b/app/Commands/ProtectDesignatedShops.php new file mode 100644 index 0000000..7a1dabd --- /dev/null +++ b/app/Commands/ProtectDesignatedShops.php @@ -0,0 +1,86 @@ +key === '') { + CLI::error('encryption.key 가 비어 있습니다. .env 설정 후 실행하세요. (키 없이 암호화 불가)'); + return; + } + + $db = Database::connect(); + $piiFields = ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account']; + $hasTelBidx = $db->fieldExists('ds_tel_bidx', 'designated_shop'); + $hasNameBidx = $db->fieldExists('ds_rep_name_bidx', 'designated_shop'); + + $rows = $db->table('designated_shop')->select('ds_idx, ' . implode(', ', $piiFields))->get()->getResultArray(); + CLI::write('대상 행: ' . count($rows) . ($dry ? ' [DRY-RUN: 변경 없음]' : ' [실제 실행]'), $dry ? 'yellow' : 'green'); + + $encCount = 0; $rowCount = 0; + foreach ($rows as $r) { + $update = []; + foreach ($piiFields as $f) { + $v = (string) ($r[$f] ?? ''); + if ($v !== '' && strpos($v, 'ENC:') !== 0) { + $update[$f] = pii_encrypt($v); + $encCount++; + } + } + // 블라인드 인덱스(원본값 기준) + if ($hasTelBidx) { + $update['ds_tel_bidx'] = pii_blind_index((string) ($r['ds_tel'] ?? '')); + } + if ($hasNameBidx) { + $update['ds_rep_name_bidx'] = pii_blind_index((string) ($r['ds_rep_name'] ?? '')); + } + if ($update === []) { + continue; + } + $rowCount++; + if (! $dry) { + $db->table('designated_shop')->where('ds_idx', (int) $r['ds_idx'])->update($update); + } + } + + CLI::write('변경 대상 행: ' . $rowCount . ' / 암호화 필드: ' . $encCount, 'green'); + if ($dry) { + CLI::write('DRY-RUN 이었습니다. 실제 반영하려면 --force 로 다시 실행하세요.', 'yellow'); + } else { + CLI::write('완료. (멱등: 재실행해도 이미 암호화된 값은 건너뜁니다)', 'green'); + } + } +} diff --git a/app/Config/Pii.php b/app/Config/Pii.php new file mode 100644 index 0000000..cf6b236 --- /dev/null +++ b/app/Config/Pii.php @@ -0,0 +1,32 @@ +group('bag', ['filter' => 'adminAuth'], static function ($routes): void $routes->get('designated-shops', 'Admin\DesignatedShop::index'); $routes->get('designated-shops/create', 'Admin\DesignatedShop::create'); $routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes'); + $routes->post('designated-shops/reveal-pii', 'Admin\DesignatedShop::revealPii'); $routes->post('designated-shops/store', 'Admin\DesignatedShop::store'); $routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1'); $routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1'); diff --git a/app/Controllers/Admin/DesignatedShop.php b/app/Controllers/Admin/DesignatedShop.php index 51af1c2..61a6606 100644 --- a/app/Controllers/Admin/DesignatedShop.php +++ b/app/Controllers/Admin/DesignatedShop.php @@ -174,7 +174,7 @@ class DesignatedShop extends BaseController /** * 목록 검색과 동일한 조건을 모델 쿼리에 적용한다. */ - private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): void + private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): void { $model->where('ds_lg_idx', $lgIdx); if ($dsName !== null && $dsName !== '') { @@ -186,12 +186,47 @@ class DesignatedShop extends BaseController if ($dsState !== null && $dsState !== '') { $model->where('ds_state', (int) $dsState); } + $this->applyPiiSearchFilter($model, $dsRep, $dsTel); + } + + /** + * 대표자명·전화 검색(정확일치). 블라인드 인덱스 컬럼+키가 있으면 HMAC 정확일치, + * 없으면(플래그 OFF/미마이그레이션) 평문 정확일치로 폴백. + * + * @param DesignatedShopModel|\CodeIgniter\Database\BaseBuilder $q + */ + private function applyPiiSearchFilter($q, ?string $dsRep, ?string $dsTel): void + { + $dsRep = $dsRep !== null ? trim($dsRep) : ''; + $dsTel = $dsTel !== null ? trim($dsTel) : ''; + if ($dsRep === '' && $dsTel === '') { + return; + } + helper('pii_encryption'); + $db = \Config\Database::connect(); + + if ($dsRep !== '') { + $bidx = pii_blind_index($dsRep); + if ($bidx !== '' && $db->fieldExists('ds_rep_name_bidx', 'designated_shop')) { + $q->where('ds_rep_name_bidx', $bidx); + } else { + $q->where('ds_rep_name', $dsRep); // 평문 폴백(정확일치) + } + } + if ($dsTel !== '') { + $bidx = pii_blind_index($dsTel); + if ($bidx !== '' && $db->fieldExists('ds_tel_bidx', 'designated_shop')) { + $q->where('ds_tel_bidx', $bidx); + } else { + $q->where('ds_tel', $dsTel); + } + } } /** * @return array{1: int, 2: int, 3: int, total: int} */ - private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): array + private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): array { $db = \Config\Database::connect(); $builder = $db->table('designated_shop'); @@ -205,6 +240,7 @@ class DesignatedShop extends BaseController if ($dsState !== null && $dsState !== '') { $builder->where('ds_state', (int) $dsState); } + $this->applyPiiSearchFilter($builder, $dsRep, $dsTel); $rows = $builder->select('ds_state, COUNT(*) AS cnt', false) ->groupBy('ds_state') ->get() @@ -229,7 +265,7 @@ class DesignatedShop extends BaseController */ private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array { - helper('admin'); + helper(['admin', 'pii_encryption']); $lgIdx = admin_effective_lg_idx() ?? 0; $gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : []; $payload = []; @@ -258,7 +294,7 @@ class DesignatedShop extends BaseController $dongDisplay = $gugunName; } - $payload[] = [ + $p = [ 'ds_idx' => (int) $row->ds_idx, 'ds_shop_no' => $sn, 'shop_no_display' => $shortNo, @@ -291,7 +327,18 @@ class DesignatedShop extends BaseController 'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'), 'ds_regdate' => (string) ($row->ds_regdate ?? ''), 'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '', + 'pii_masked' => false, ]; + + // 개인정보 마스킹(표시 계층 단일 지점) — 열람 권한 없으면 PII 필드 마스킹 + if (! can_view_shop_pii((int) ($row->ds_lg_idx ?? 0))) { + foreach (['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account', 'ds_va_number'] as $f) { + $p[$f] = mask_shop_field($f, (string) $p[$f]); + } + $p['pii_masked'] = true; + } + + $payload[] = $p; } return $payload; @@ -311,11 +358,13 @@ class DesignatedShop extends BaseController return null; } - // 다조건 검색 (P2-15) + // 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스) $dsName = $this->request->getGet('ds_name'); $dsGugunCode = $this->request->getGet('ds_gugun_code'); $dsState = $this->request->getGet('ds_state'); - $this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState); + $dsRep = $this->request->getGet('ds_rep'); + $dsTel = $this->request->getGet('ds_tel'); + $this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel); // 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시 $list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll(); @@ -327,7 +376,7 @@ class DesignatedShop extends BaseController $lgMap[$lg->lg_idx] = $lg->lg_name; } - $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState); + $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel); $gugunNameMap = $this->gugunCodeNameMap($lgIdx); // 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용 @@ -356,6 +405,8 @@ class DesignatedShop extends BaseController 'dsName' => $dsName ?? '', 'dsGugunCode' => $dsGugunCode ?? '', 'dsState' => $dsState ?? '', + 'dsRep' => $dsRep ?? '', + 'dsTel' => $dsTel ?? '', 'gugunCodes' => $gugunCodes, 'stateCounts' => $stateCounts, 'gugunNameMap' => $gugunNameMap, @@ -508,6 +559,7 @@ class DesignatedShop extends BaseController return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.'); } + helper('pii_encryption'); $list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll(); // 동코드(구·군 동코드) 산출 @@ -522,19 +574,30 @@ class DesignatedShop extends BaseController $dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : ''; $da = $row->ds_designated_at ?? null; $daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : ''; + + // 엑셀에도 마스킹 적용(열람 권한 없으면) + $canView = can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)); + $repName = (string) ($row->ds_rep_name ?? ''); + $telVal = (string) ($row->ds_tel ?? ''); + $vaAccount = $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : (string) ($row->ds_va_number ?? ''); + if (! $canView) { + $repName = mask_shop_field('ds_rep_name', $repName); + $telVal = mask_shop_field('ds_tel', $telVal); + $vaAccount = mask_shop_field('ds_va_account', $vaAccount); + } $rows[] = [ $row->ds_idx, $row->ds_shop_no, $row->ds_name, - $row->ds_rep_name, + $repName, $dongDisp, $daDisp, $row->ds_biz_no, $this->designatedShopScalar($row, 'ds_biz_type'), $this->designatedShopScalar($row, 'ds_biz_kind'), $this->designatedShopScalar($row, 'ds_va_bank'), - $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''), - $row->ds_tel ?? '', + $vaAccount, + $telVal, $row->ds_addr ?? '', $this->designatedShopScalar($row, 'ds_zone_code'), $this->designatedShopScalar($row, 'ds_branch_no'), @@ -1675,6 +1738,51 @@ class DesignatedShop extends BaseController ]); } + /** + * [AJAX] 개인정보 원문 보기 — step-up 재인증(TOTP) + 열람사유 → 세션 열람 승인 + 감사기록. + * 조건 미충족(IP밖·Super Admin 등) 사용자가 마스킹된 PII를 열람할 때 사용. + */ + public function revealPii() + { + helper(['admin', 'pii_encryption']); + $lgIdx = admin_effective_lg_idx(); + if (! $lgIdx) { + return $this->response->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']); + } + $mbIdx = (int) session()->get('mb_idx'); + $code = trim((string) $this->request->getPost('totp_code')); + $reason = trim((string) $this->request->getPost('reason')); + if ($code === '' || ! preg_match('/^\d{6}$/', $code)) { + return $this->response->setJSON(['ok' => false, 'error' => '6자리 인증코드를 입력해 주세요.']); + } + if ($reason === '') { + return $this->response->setJSON(['ok' => false, 'error' => '열람 사유를 입력해 주세요.']); + } + + $member = model(\App\Models\MemberModel::class)->find($mbIdx); + $secret = $member ? pii_decrypt((string) ($member->mb_totp_secret ?? '')) : ''; + if ($secret === '' || (int) ($member->mb_totp_enabled ?? 0) !== 1) { + return $this->response->setJSON(['ok' => false, 'error' => '2단계 인증(TOTP)이 등록돼 있지 않아 원문 열람을 승인할 수 없습니다.']); + } + $totp = new \App\Libraries\TotpService(); + if (! $totp->verify($secret, $code)) { + return $this->response->setJSON(['ok' => false, 'error' => '인증코드가 올바르지 않습니다.']); + } + + // 이 세션에서 해당 지자체 PII 원문 열람 승인(10분) + session()->set('pii_stepup_grant', ['lg_idx' => (int) $lgIdx, 'until' => time() + 600]); + + // 감사기록 — 누가/언제/사유 + helper('audit'); + audit_log('pii_view', 'designated_shop', 0, null, [ + 'lg_idx' => (int) $lgIdx, + 'reason' => mb_substr($reason, 0, 200), + 'ip' => (string) $this->request->getIPAddress(), + ]); + + return $this->response->setJSON(['ok' => true]); + } + /** * designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시). */ diff --git a/app/Controllers/Admin/SalesReport.php b/app/Controllers/Admin/SalesReport.php index 5d314cd..e3d27d4 100644 --- a/app/Controllers/Admin/SalesReport.php +++ b/app/Controllers/Admin/SalesReport.php @@ -118,6 +118,16 @@ class SalesReport extends BaseController $hasBsFee ); + // 대표자명 PII: 암호화 대비 복호화 + 권한 없으면 마스킹 (표시 계층) + helper('pii_encryption'); + $canViewPii = can_view_shop_pii((int) $lgIdx); + foreach ($detailRows as $row) { + if (isset($row->ds_rep_name) && $row->ds_rep_name !== '') { + $rep = pii_decrypt((string) $row->ds_rep_name); + $row->ds_rep_name = $canViewPii ? $rep : mask_shop_field('ds_rep_name', $rep); + } + } + $filtered = []; foreach ($detailRows as $row) { if ($this->ledgerRowMatchesCategories($row, $cats)) { diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 2be141a..16506b1 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -233,7 +233,7 @@ class Auth extends BaseController return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); } - return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); + return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx), true); } public function showTotpSetup() @@ -329,7 +329,7 @@ class Auth extends BaseController return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.'); } - return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx)); + return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx), true); } public function logout() @@ -573,7 +573,7 @@ class Auth extends BaseController /** * @param array $logData */ - private function completeLogin(object $member, array $logData): RedirectResponse + private function completeLogin(object $member, array $logData, bool $twofaVerified = false): RedirectResponse { $this->clearPending2faSession(); // 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨 @@ -586,6 +586,8 @@ class Auth extends BaseController 'mb_lg_idx' => $member->mb_lg_idx ?? null, 'logged_in' => true, 'session_token' => $sessionToken, + // 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true) + 'auth_2fa_verified' => $twofaVerified, ]; session()->set($sessionData); diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index 33af166..9f6b1d9 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -7616,16 +7616,25 @@ SQL; $shopMap = []; $dsIdxs = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0))); if ($dsIdxs !== [] && $db->tableExists('designated_shop')) { + helper('pii_encryption'); + $canViewPii = can_view_shop_pii((int) $lgIdx); // 이 화면은 현재 지자체 판매소만 $shopRows = $db->table('designated_shop') ->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail') ->whereIn('ds_idx', $dsIdxs) ->get()->getResultArray(); foreach ($shopRows as $s) { + // 암호화 저장 대비 복호화 후, 권한 없으면 마스킹 + $rep = pii_decrypt((string) ($s['ds_rep_name'] ?? '')); + $tel = pii_decrypt((string) ($s['ds_tel'] ?? '')); + if (! $canViewPii) { + $rep = mask_shop_field('ds_rep_name', $rep); + $tel = mask_shop_field('ds_tel', $tel); + } $shopMap[(int) $s['ds_idx']] = [ 'shop_no' => (string) ($s['ds_shop_no'] ?? ''), 'name' => (string) ($s['ds_name'] ?? ''), - 'rep_name' => (string) ($s['ds_rep_name'] ?? ''), - 'tel' => (string) ($s['ds_tel'] ?? ''), + 'rep_name' => $rep, + 'tel' => $tel, 'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')), ]; } diff --git a/app/Helpers/pii_encryption_helper.php b/app/Helpers/pii_encryption_helper.php index 86cc89c..0d25bb2 100644 --- a/app/Helpers/pii_encryption_helper.php +++ b/app/Helpers/pii_encryption_helper.php @@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) { if (! defined('PII_ENCRYPTED_FIELDS')) { define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']); } + +/* ========================================================================= + * 개인정보 마스킹(비식별화) 헬퍼 — 표시 계층에서만 사용(원본 데이터는 그대로). + * ======================================================================= */ + +if (! function_exists('pii_mask_name')) { + /** 이름 마스킹: 홍길동→홍*동, 홍길→홍*, 홍→홍 */ + function pii_mask_name(?string $v): string + { + $v = trim((string) $v); + if ($v === '') { + return ''; + } + $len = mb_strlen($v, 'UTF-8'); + if ($len === 1) { + return $v; + } + if ($len === 2) { + return mb_substr($v, 0, 1, 'UTF-8') . '*'; + } + return mb_substr($v, 0, 1, 'UTF-8') . str_repeat('*', $len - 2) . mb_substr($v, $len - 1, 1, 'UTF-8'); + } +} + +if (! function_exists('pii_mask_phone')) { + /** 전화 마스킹: 앞 국번 + 마지막 4자리만 노출, 가운데 마스킹. 010-1234-5678→010-****-5678 */ + function pii_mask_phone(?string $v): string + { + $v = trim((string) $v); + if ($v === '') { + return ''; + } + // 하이픈 형식이면 가운데 그룹만 마스킹 + if (preg_match('/^(\d{2,4})-(\d{3,4})-(\d{4})$/', $v, $m) === 1) { + return $m[1] . '-' . str_repeat('*', strlen($m[2])) . '-' . $m[3]; + } + // 그 외: 숫자만 추출해 앞3·뒤4 노출 + $digits = preg_replace('/\D/', '', $v); + $n = strlen($digits); + if ($n <= 4) { + return str_repeat('*', $n); + } + $head = substr($digits, 0, 3); + $tail = substr($digits, -4); + return $head . str_repeat('*', max(1, $n - 7)) . $tail; + } +} + +if (! function_exists('pii_mask_email')) { + /** 이메일 마스킹: 앞부분 첫 글자만 노출. hong@a.com→h***@a.com */ + function pii_mask_email(?string $v): string + { + $v = trim((string) $v); + if ($v === '' || strpos($v, '@') === false) { + return $v === '' ? '' : pii_mask_name($v); + } + [$local, $domain] = explode('@', $v, 2); + $llen = mb_strlen($local, 'UTF-8'); + $maskedLocal = $llen <= 1 ? '*' : mb_substr($local, 0, 1, 'UTF-8') . str_repeat('*', $llen - 1); + return $maskedLocal . '@' . $domain; + } +} + +if (! function_exists('pii_mask_account')) { + /** 계좌/번호 마스킹: 마지막 4자리만 노출 */ + function pii_mask_account(?string $v): string + { + $v = trim((string) $v); + if ($v === '') { + return ''; + } + $len = mb_strlen($v, 'UTF-8'); + if ($len <= 4) { + return str_repeat('*', $len); + } + return str_repeat('*', $len - 4) . mb_substr($v, $len - 4, 4, 'UTF-8'); + } +} + +if (! function_exists('pii_blind_index')) { + /** + * 블라인드 인덱스(정확일치 검색용): HMAC-SHA256(정규화값, 별도 인덱스키). + * 키(pii.blindIndexKey)가 없으면 '' 반환(검색 인덱스 비활성). + */ + function pii_blind_index(?string $value): string + { + $value = trim((string) $value); + if ($value === '') { + return ''; + } + try { + $key = (string) (config('Pii')->blindIndexKey ?? ''); + } catch (Throwable $e) { + $key = ''; + } + if ($key === '') { + return ''; + } + // 정규화: 공백 제거 + 소문자 (전화는 숫자만) + $norm = preg_replace('/\s+/u', '', $value); + if (preg_match('/^[\d\-()+ ]+$/', $value) === 1) { + $norm = preg_replace('/\D/', '', $value); + } else { + $norm = mb_strtolower($norm, 'UTF-8'); + } + return hash_hmac('sha256', $norm, $key); + } +} + +/* ========================================================================= + * 지정판매소 PII 열람 게이트 — canViewShopPii (단일 판별 지점). + * ======================================================================= */ + +if (! function_exists('pii_shop_protection_enabled')) { + /** 지정판매소 PII 보호 마스터 플래그 (OFF면 현재 동작=항상 원문) */ + function pii_shop_protection_enabled(): bool + { + try { + return (bool) (config('Pii')->designatedShopProtection ?? false); + } catch (Throwable $e) { + return false; + } + } +} + +if (! function_exists('pii_ip_in_cidr')) { + /** IP가 CIDR/단일IP 목록(쉼표·공백 구분)에 포함되는지 (IPv4) */ + function pii_ip_in_cidr(string $ip, string $list): bool + { + $ip = trim($ip); + if ($ip === '' || $list === '') { + return false; + } + $ipLong = ip2long($ip); + if ($ipLong === false) { + return false; + } + foreach (preg_split('/[\s,]+/', $list, -1, PREG_SPLIT_NO_EMPTY) as $entry) { + if (strpos($entry, '/') !== false) { + [$subnet, $bits] = explode('/', $entry, 2); + $subnetLong = ip2long(trim($subnet)); + $bits = (int) $bits; + if ($subnetLong === false || $bits < 0 || $bits > 32) { + continue; + } + $mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF; + if (($ipLong & $mask) === ($subnetLong & $mask)) { + return true; + } + } elseif (ip2long(trim($entry)) === $ipLong) { + return true; + } + } + return false; + } +} + +if (! function_exists('pii_request_ip_in_lg_range')) { + /** 현재 요청 IP가 해당 지자체 허용 IP 대역에 속하는지 (local_government.lg_allow_ips) */ + function pii_request_ip_in_lg_range(int $lgIdx): bool + { + if ($lgIdx <= 0) { + return false; + } + try { + $db = \Config\Database::connect(); + if (! $db->fieldExists('lg_allow_ips', 'local_government')) { + return false; // 컬럼 없으면 대역 미설정 → 자동 원문 불가(마스킹+step-up) + } + $row = $db->table('local_government')->select('lg_allow_ips')->where('lg_idx', $lgIdx)->get()->getRowArray(); + $list = (string) ($row['lg_allow_ips'] ?? ''); + if ($list === '') { + return false; + } + return pii_ip_in_cidr((string) service('request')->getIPAddress(), $list); + } catch (Throwable $e) { + return false; + } + } +} + +if (! function_exists('pii_stepup_granted')) { + /** 이 세션에서 step-up 재인증으로 원문 열람이 승인됐는지 (P2에서 세팅) */ + function pii_stepup_granted(int $dsLgIdx): bool + { + $g = session('pii_stepup_grant'); + if (! is_array($g)) { + return false; + } + $until = (int) ($g['until'] ?? 0); + $lg = (int) ($g['lg_idx'] ?? -1); + return $until > time() && ($lg === 0 || $lg === $dsLgIdx); + } +} + +if (! function_exists('can_view_shop_pii')) { + /** + * 지정판매소 개인정보 원문 열람 가능 여부(단일 판별 지점). + * 보호 OFF → 항상 true(현재 동작). 보호 ON → 조건 판별. + */ + function can_view_shop_pii(int $dsLgIdx): bool + { + if (! pii_shop_protection_enabled()) { + return true; + } + $level = (int) session('mb_level'); + $adminLg = (int) session('admin_lg_idx'); + $twofa = (bool) session('auth_2fa_verified'); + + // 정상(자동 원문): 지자체관리자 + 본인 지자체 + 2FA + 지자체 IP대역 + if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN + && $adminLg === $dsLgIdx && $adminLg > 0 + && $twofa + && pii_request_ip_in_lg_range($dsLgIdx)) { + return true; + } + + // 그 외(Super Admin·본부 포함, IP 밖 등): step-up 재인증으로 승인된 경우만 + return pii_stepup_granted($dsLgIdx); + } +} + +if (! function_exists('mask_shop_field')) { + /** 필드명 기준 마스킹 라우팅 (원문 표시 불가일 때 사용) */ + function mask_shop_field(string $field, ?string $value): string + { + $value = (string) $value; + switch ($field) { + case 'ds_rep_name': + return pii_mask_name($value); + case 'ds_tel': + case 'ds_rep_phone': + return pii_mask_phone($value); + case 'ds_email': + return pii_mask_email($value); + case 'ds_va_account': + case 'ds_va_number': + return pii_mask_account($value); + default: + return $value === '' ? '' : pii_mask_name($value); + } + } +} diff --git a/app/Models/DesignatedShopModel.php b/app/Models/DesignatedShopModel.php index 5890dbd..521a088 100644 --- a/app/Models/DesignatedShopModel.php +++ b/app/Models/DesignatedShopModel.php @@ -41,6 +41,118 @@ class DesignatedShopModel extends Model 'ds_state_changed_at', 'ds_change_reason', 'ds_regdate', + // 블라인드 인덱스(정확일치 검색용). 컬럼이 있을 때만 실제로 기록됨. + 'ds_tel_bidx', + 'ds_rep_name_bidx', ]; + + // PII 암복호화·블라인드인덱스 콜백 (설계안 P3·P4) + protected $beforeInsert = ['piiBlindIndex', 'piiEncrypt']; + protected $beforeUpdate = ['piiBlindIndex', 'piiEncrypt']; + protected $afterFind = ['piiDecrypt']; + + private ?bool $telBidxExists = null; + private ?bool $nameBidxExists = null; + + /** 저장 계층 암호화 사용 여부 (Config\Pii.encryptAtRest) */ + private function encryptAtRest(): bool + { + try { + return (bool) (config('Pii')->encryptAtRest ?? false); + } catch (\Throwable $e) { + return false; + } + } + + /** @return list 암호화·마스킹 대상 PII 필드 */ + private function piiFields(): array + { + try { + $f = config('Pii')->designatedShopPiiFields ?? []; + return is_array($f) && $f !== [] ? $f : ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account']; + } catch (\Throwable $e) { + return ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account']; + } + } + + /** 저장 전 PII 필드 암호화 (encryptAtRest ON일 때만, 이미 ENC:면 건너뜀) */ + protected function piiEncrypt(array $data): array + { + if (! $this->encryptAtRest() || empty($data['data']) || ! is_array($data['data'])) { + return $data; + } + helper('pii_encryption'); + foreach ($this->piiFields() as $f) { + if (array_key_exists($f, $data['data']) && $data['data'][$f] !== null && $data['data'][$f] !== '') { + $v = (string) $data['data'][$f]; + if (strpos($v, 'ENC:') !== 0) { + $data['data'][$f] = pii_encrypt($v); + } + } + } + return $data; + } + + /** 저장 전 블라인드 인덱스(전화·이름) 기록 — 컬럼과 인덱스키가 있을 때만 */ + protected function piiBlindIndex(array $data): array + { + if (empty($data['data']) || ! is_array($data['data'])) { + return $data; + } + helper('pii_encryption'); + $db = $this->db; + if ($this->telBidxExists === null) { + $this->telBidxExists = $db->fieldExists('ds_tel_bidx', $this->table); + } + if ($this->nameBidxExists === null) { + $this->nameBidxExists = $db->fieldExists('ds_rep_name_bidx', $this->table); + } + // 원본값이 넘어온 경우에만 인덱스 갱신(암호화 콜백이 먼저 돌면 ENC:라 인덱스 불가 → piiBlindIndex를 piiEncrypt보다 먼저 두지 않음에 주의) + if ($this->telBidxExists && array_key_exists('ds_tel', $data['data'])) { + $raw = (string) $data['data']['ds_tel']; + $data['data']['ds_tel_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw); + } + if ($this->nameBidxExists && array_key_exists('ds_rep_name', $data['data'])) { + $raw = (string) $data['data']['ds_rep_name']; + $data['data']['ds_rep_name_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw); + } + return $data; + } + + /** 조회 후 PII 필드 복호화 (ENC: 값만; 평문은 그대로) */ + protected function piiDecrypt(array $data): array + { + if (empty($data['data'])) { + return $data; + } + helper('pii_encryption'); + $fields = $this->piiFields(); + $decodeOne = static function ($row) use ($fields) { + if (is_object($row)) { + foreach ($fields as $f) { + if (isset($row->$f) && is_string($row->$f) && strpos($row->$f, 'ENC:') === 0) { + $row->$f = pii_decrypt($row->$f); + } + } + } elseif (is_array($row)) { + foreach ($fields as $f) { + if (isset($row[$f]) && is_string($row[$f]) && strpos($row[$f], 'ENC:') === 0) { + $row[$f] = pii_decrypt($row[$f]); + } + } + } + return $row; + }; + if (is_array($data['data'])) { + // findAll 등: 결과 배열 + foreach ($data['data'] as $i => $row) { + $data['data'][$i] = $decodeOne($row); + } + } else { + // find/first: 단일 + $data['data'] = $decodeOne($data['data']); + } + return $data; + } } diff --git a/app/Views/admin/designated_shop/index.php b/app/Views/admin/designated_shop/index.php index 96cc615..032d59f 100644 --- a/app/Views/admin/designated_shop/index.php +++ b/app/Views/admin/designated_shop/index.php @@ -1,5 +1,9 @@ ds_rep_name ?? ''); + return can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)) ? $v : mask_shop_field('ds_rep_name', $v); +}; $currentPath = current_nav_request_path(); if ($currentPath === 'bag/designated-shops') { $readOnly = false; @@ -213,6 +217,10 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops'; + + + + + +
+ + +
+

+ + +