종량제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>
316 lines
11 KiB
PHP
316 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* PII(개인정보) 필드 암호화/복호화 헬퍼.
|
|
* encryption.key 가 .env에 설정된 경우에만 동작. 키가 없으면 평문 유지(기존 데이터 호환).
|
|
*
|
|
* 저장 형식: 암호화된 값은 "ENC:" + base64(암호문) 으로 저장. "ENC:" 없으면 평문(기존)으로 간주.
|
|
*/
|
|
|
|
if (! function_exists('pii_encrypt')) {
|
|
function pii_encrypt(?string $value): string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return '';
|
|
}
|
|
try {
|
|
$config = config('Encryption');
|
|
if ($config->key === '') {
|
|
return $value;
|
|
}
|
|
$encrypter = service('encrypter');
|
|
$encrypted = $encrypter->encrypt($value);
|
|
return 'ENC:' . base64_encode($encrypted);
|
|
} catch (Throwable $e) {
|
|
return $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! function_exists('pii_decrypt')) {
|
|
function pii_decrypt(?string $value): string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return '';
|
|
}
|
|
if (strpos($value, 'ENC:') !== 0) {
|
|
return $value;
|
|
}
|
|
try {
|
|
$config = config('Encryption');
|
|
if ($config->key === '') {
|
|
return $value;
|
|
}
|
|
$encrypter = service('encrypter');
|
|
$payload = substr($value, 4);
|
|
|
|
// 현재 포맷: ENC: + base64(raw ciphertext)
|
|
$raw = base64_decode($payload, true);
|
|
if ($raw !== false) {
|
|
try {
|
|
return $encrypter->decrypt($raw);
|
|
} catch (Throwable $e) {
|
|
// legacy 포맷 재시도
|
|
}
|
|
}
|
|
|
|
// 레거시 포맷 호환:
|
|
// - ENC: + encrypter 반환값(rawData=false 환경 등) 또는
|
|
// - ENC: + 기타 문자열 포맷
|
|
return $encrypter->decrypt($payload);
|
|
} catch (Throwable $e) {
|
|
return $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 암호화 대상 개인정보 필드 (member 테이블) */
|
|
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);
|
|
}
|
|
}
|
|
}
|