feat: 지정판매소 개인정보(PII) 보호 — 마스킹·암호화·열람 게이트 (기본 OFF)
종량제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 <noreply@anthropic.com>
This commit is contained in:
86
app/Commands/ProtectDesignatedShops.php
Normal file
86
app/Commands/ProtectDesignatedShops.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use Config\Database;
|
||||
|
||||
/**
|
||||
* 지정판매소 PII 일괄 암호화 + 블라인드 인덱스 백필 (설계안 P3·P4).
|
||||
*
|
||||
* ⚠️ 운영 데이터를 변경하는 비가역적 작업. 반드시 DB 백업 후 실행.
|
||||
* php spark pii:protect-designated-shops --dry (미리보기: 변경 안 함)
|
||||
* php spark pii:protect-designated-shops --force (실제 실행)
|
||||
*
|
||||
* - 멱등: 이미 ENC: 인 필드는 건너뜀. 두 번 돌려도 안전.
|
||||
* - 앱 encrypter(.env encryption.key) 사용 → 반드시 키가 설정돼 있어야 함.
|
||||
*/
|
||||
class ProtectDesignatedShops extends BaseCommand
|
||||
{
|
||||
protected $group = 'PII';
|
||||
protected $name = 'pii:protect-designated-shops';
|
||||
protected $description = '지정판매소 PII를 암호화하고 블라인드 인덱스를 백필합니다(비가역적, 백업 필수).';
|
||||
protected $usage = 'pii:protect-designated-shops [--dry|--force]';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
helper('pii_encryption');
|
||||
$dry = array_key_exists('dry', $params) || in_array('--dry', $_SERVER['argv'] ?? [], true);
|
||||
$force = array_key_exists('force', $params) || in_array('--force', $_SERVER['argv'] ?? [], true);
|
||||
|
||||
if (! $dry && ! $force) {
|
||||
CLI::error('안전을 위해 --dry(미리보기) 또는 --force(실행) 중 하나가 필요합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
$cfg = config('Encryption');
|
||||
if ((string) $cfg->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Config/Pii.php
Normal file
32
app/Config/Pii.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* 개인정보 비식별화·암호화 설정.
|
||||
* 마스터 플래그가 OFF이면 현재 동작(원문 그대로) 유지 — 준비 완료 후 .env에서 켠다.
|
||||
* .env: pii.designatedShopProtection = true
|
||||
*/
|
||||
class Pii extends BaseConfig
|
||||
{
|
||||
/** 지정판매소 PII 보호(마스킹·암호화·게이트) 마스터 스위치. 기본 OFF(안전). */
|
||||
public bool $designatedShopProtection = false;
|
||||
|
||||
/** 지정판매소 PII 필드 (암호화 저장 + 마스킹 대상) */
|
||||
public array $designatedShopPiiFields = [
|
||||
'ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account',
|
||||
];
|
||||
|
||||
/** 저장 계층 암호화 사용 여부 (P3). 마이그레이션·백업 후 .env에서 켠다. */
|
||||
public bool $encryptAtRest = false;
|
||||
|
||||
/**
|
||||
* 블라인드 인덱스 HMAC 키 (.env: pii.blindIndexKey).
|
||||
* encryption.key 와 별도 키 권장. 비어 있으면 검색 인덱스 생성/사용 안 함.
|
||||
*/
|
||||
public string $blindIndexKey = '';
|
||||
}
|
||||
@@ -160,6 +160,7 @@ $routes->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');
|
||||
|
||||
@@ -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회 조회 후 캐시).
|
||||
*/
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<string, mixed> $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);
|
||||
|
||||
|
||||
@@ -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'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> 암호화·마스킹 대상 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?php
|
||||
helper('admin');
|
||||
helper(['admin', 'pii_encryption']);
|
||||
$repDisp = static function ($row): string {
|
||||
$v = (string) ($row->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';
|
||||
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label class="text-sm text-gray-600">대표자명</label>
|
||||
<input type="text" name="ds_rep" value="<?= esc($dsRep ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-28" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||
<label class="text-sm text-gray-600">전화</label>
|
||||
<input type="text" name="ds_tel" value="<?= esc($dsTel ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-32" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||
<label class="text-sm text-gray-600">상태</label>
|
||||
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
||||
<option value="">전체</option>
|
||||
@@ -282,14 +290,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
|
||||
data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
|
||||
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
|
||||
data-sort-rep="<?= esc($row->ds_rep_name ?? '', 'attr') ?>"
|
||||
data-sort-rep="<?= esc($repDisp($row), 'attr') ?>"
|
||||
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
|
||||
data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
|
||||
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
|
||||
data-sort-zone="<?= esc($zone, 'attr') ?>"
|
||||
data-sort-state="<?= (int) $st ?>">
|
||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($repDisp($row)) ?>"><?= esc($repDisp($row)) ?></td>
|
||||
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
|
||||
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
|
||||
@@ -312,6 +320,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
|
||||
<div class="ds-detail-inner" id="ds-detail-box">
|
||||
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
|
||||
|
||||
<!-- 개인정보 마스킹 안내 + 원문 보기(2단계 인증) -->
|
||||
<div id="ds-pii-reveal" class="hidden mb-2 border border-amber-300 bg-amber-50 rounded p-2 text-[12px] no-print">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-amber-800">개인정보가 마스킹되어 있습니다.</span>
|
||||
<button type="button" id="ds-pii-reveal-btn" class="border border-amber-500 text-amber-800 px-2 py-0.5 rounded hover:bg-amber-100">원문 보기</button>
|
||||
</div>
|
||||
<div id="ds-pii-reveal-form" class="hidden mt-2 space-y-1">
|
||||
<input type="text" id="ds-pii-otp" inputmode="numeric" maxlength="6" placeholder="인증코드 6자리(OTP)" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||
<input type="text" id="ds-pii-reason" maxlength="200" placeholder="열람 사유" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||
<div class="flex gap-1">
|
||||
<button type="button" id="ds-pii-reveal-submit" class="bg-[#243a5e] text-white px-3 py-1 rounded text-[12px]">인증 후 열람</button>
|
||||
<button type="button" id="ds-pii-reveal-cancel" class="bg-gray-200 text-gray-700 px-3 py-1 rounded text-[12px]">취소</button>
|
||||
</div>
|
||||
<p id="ds-pii-reveal-msg" class="text-red-600"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ds-detail-fields" class="hidden">
|
||||
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
|
||||
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
||||
@@ -478,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
fieldsWrap.classList.remove('hidden');
|
||||
fillDetailInfoTable(d);
|
||||
|
||||
// 마스킹된 행이면 "원문 보기" 안내 노출
|
||||
var revealBox = document.getElementById('ds-pii-reveal');
|
||||
if (revealBox) {
|
||||
revealBox.classList.toggle('hidden', !d.pii_masked);
|
||||
var rf = document.getElementById('ds-pii-reveal-form');
|
||||
if (rf) rf.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (!readOnly && editLink && delForm && delBtn) {
|
||||
var id = d.ds_idx;
|
||||
editLink.href = editBase + '/' + id;
|
||||
@@ -569,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
delBtn.disabled = true;
|
||||
delBtn.classList.add('pointer-events-none', 'opacity-40');
|
||||
}
|
||||
|
||||
// ── 개인정보 원문 보기(2단계 인증 step-up) ──
|
||||
(function () {
|
||||
var box = document.getElementById('ds-pii-reveal');
|
||||
if (!box) return;
|
||||
var btn = document.getElementById('ds-pii-reveal-btn');
|
||||
var form = document.getElementById('ds-pii-reveal-form');
|
||||
var otp = document.getElementById('ds-pii-otp');
|
||||
var reason = document.getElementById('ds-pii-reason');
|
||||
var submit = document.getElementById('ds-pii-reveal-submit');
|
||||
var cancel = document.getElementById('ds-pii-reveal-cancel');
|
||||
var msg = document.getElementById('ds-pii-reveal-msg');
|
||||
var endpoint = new URL('<?= mgmt_url('designated-shops/reveal-pii') ?>', window.location.href).pathname;
|
||||
var csrfName = '<?= csrf_token() ?>';
|
||||
btn.addEventListener('click', function () { form.classList.remove('hidden'); otp.focus(); });
|
||||
cancel.addEventListener('click', function () { form.classList.add('hidden'); msg.textContent = ''; });
|
||||
submit.addEventListener('click', function () {
|
||||
msg.textContent = '';
|
||||
var body = new URLSearchParams();
|
||||
body.set('totp_code', (otp.value || '').trim());
|
||||
body.set('reason', (reason.value || '').trim());
|
||||
var t = document.querySelector('meta[name="' + csrfName + '"]');
|
||||
body.set(csrfName, '<?= csrf_hash() ?>');
|
||||
fetch(endpoint, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.ok) { location.reload(); }
|
||||
else { msg.textContent = (d && d.error) || '열람 승인 실패'; }
|
||||
})
|
||||
.catch(function () { msg.textContent = '통신 오류'; });
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -630,7 +696,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<td class="text-left"><?= esc($dongDispP) ?></td>
|
||||
<td class="text-center"><?= esc($daDispP) ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
|
||||
<td class="text-left"><?= esc($repDisp($row)) ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
|
||||
<td class="text-left"><?= esc($zipP) ?></td>
|
||||
<td class="text-left"><?= esc($addrCombinedP) ?></td>
|
||||
|
||||
Reference in New Issue
Block a user