Files
Gnuboard7/app/Database/Sample/AbstractIdentityVerificationLogSampleSeeder.php
T
2026-05-11 11:29:41 +09:00

296 lines
11 KiB
PHP

<?php
namespace App\Database\Sample;
use App\Enums\IdentityOriginType;
use App\Enums\IdentityVerificationStatus;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* 본인인증 이력 샘플 시더 추상 베이스.
*
* 코어/모듈/플러그인이 각자 영역의 IDV 이력을 채울 수 있도록 공통 골격을 제공한다.
* - 등록된 IdentityPolicy 중 자기 영역 정책만 추려서 사용
* - user_id = 실제 등록된 G7 사용자
* - provider_id = IdentityVerificationManager 에 등록된 실제 프로바이더
* - 상태 분포 = 운영 트래픽 비율 (verified 55, expired 15, failed 12, sent 7,
* cancelled 5, requested 3, policy_violation_logged 3)
* - attempts/expires_at/verified_at/consumed_at = 상태별 라이프사이클 일관성 보장
*
* 서브클래스는 영역 필터(applyPolicyScope) + 카운트 키/기본값 + 라벨을 정의한다.
*/
abstract class AbstractIdentityVerificationLogSampleSeeder extends Seeder
{
use HasSeederCounts;
/**
* 상태별 가중치 (총합 100).
*
* @var array<int, array{0: IdentityVerificationStatus, 1: int}>
*/
protected array $statusBuckets;
/**
* 한국/해외 IP 풀.
*
* @var array<int, string>
*/
protected array $ips = [
'121.78.45.12', '211.234.111.5', '125.142.88.91', '210.94.0.74',
'203.241.185.20', '180.182.50.7', '175.223.18.143', '218.236.42.61',
'14.45.110.222', '112.184.99.180', '61.43.232.18', '59.16.7.205',
'110.45.234.12', '106.247.83.190', '220.86.55.121',
'203.0.113.42', '198.51.100.7', '172.217.27.142',
'8.8.8.8', '1.1.1.1',
];
/**
* 데스크톱/모바일 UA 풀.
*
* @var array<int, string>
*/
protected array $userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; SM-S921N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0',
];
public function __construct()
{
$this->statusBuckets = [
[IdentityVerificationStatus::Verified, 55],
[IdentityVerificationStatus::Expired, 15],
[IdentityVerificationStatus::Failed, 12],
[IdentityVerificationStatus::Sent, 7],
[IdentityVerificationStatus::Cancelled, 5],
[IdentityVerificationStatus::Requested, 3],
[IdentityVerificationStatus::PolicyViolationLogged, 3],
];
}
/**
* IdentityPolicy 쿼리에 영역 필터를 적용한다.
*
* @param Builder $query IdentityPolicy 쿼리
* @return Builder 영역 필터가 적용된 쿼리
*/
abstract protected function applyPolicyScope(Builder $query): Builder;
/**
* 카운트 옵션 키.
*
* @return string 카운트 옵션 키
*/
abstract protected function countKey(): string;
/**
* 기본 생성 건수.
*
* @return int 기본 건수
*/
abstract protected function defaultCount(): int;
/**
* 콘솔 메시지에 사용할 영역 라벨.
*
* @return string 영역 라벨
*/
abstract protected function scopeLabel(): string;
/**
* 시더 실행.
*/
public function run(): void
{
$count = $this->getSeederCount($this->countKey(), $this->defaultCount());
$label = $this->scopeLabel();
$users = User::query()->get(['id', 'name', 'email']);
if ($users->isEmpty()) {
$this->command->warn("사용자 데이터가 없어 {$label} 본인인증 이력 시더를 건너뜁니다.");
return;
}
$policies = $this->applyPolicyScope(IdentityPolicy::query())
->get(['key', 'purpose', 'source_type', 'source_identifier', 'provider_id']);
if ($policies->isEmpty()) {
$this->command->warn("{$label} 영역 IdentityPolicy 가 없어 시더를 건너뜁니다.");
return;
}
$manager = app(IdentityVerificationManager::class);
$providerIds = array_keys($manager->all());
if (empty($providerIds)) {
$this->command->warn("등록된 본인인증 프로바이더가 없어 {$label} 시더를 건너뜁니다.");
return;
}
$this->command->info("{$label} 본인인증 이력 시딩 시작... ({$count}건)");
$ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15);
$maxAttempts = (int) config('settings.identity.max_attempts', 5);
$now = Carbon::now();
$batch = [];
for ($i = 0; $i < $count; $i++) {
$user = $users->random();
$policy = $policies->random();
$providerId = $policy->provider_id ?: $providerIds[array_rand($providerIds)];
$status = $this->pickStatus();
$renderHint = mt_rand(1, 100) <= 70 ? 'text_code' : 'email_link';
$createdAt = $this->randomCreatedAt($now);
[$expiresAt, $verifiedAt, $consumedAt, $attempts] = $this->buildLifecycle(
$status,
$createdAt,
$ttlMinutes,
$maxAttempts,
);
$properties = $renderHint === 'text_code'
? ['code_length' => 6]
: ['link_hint' => 'email_link'];
$metadata = $status === IdentityVerificationStatus::PolicyViolationLogged
? ['violation_reason' => 'fail_mode_log_only']
: ['hint_used' => $renderHint];
$batch[] = [
'id' => (string) Str::uuid(),
'provider_id' => $providerId,
'purpose' => $policy->purpose,
'channel' => 'email',
'user_id' => $user->id,
'target_hash' => hash('sha256', mb_strtolower($user->email)),
'status' => $status->value,
'render_hint' => $renderHint,
'attempts' => $attempts,
'max_attempts' => $maxAttempts,
'ip_address' => $this->ips[array_rand($this->ips)],
'user_agent' => $this->userAgents[array_rand($this->userAgents)],
// 본 시더의 모든 challenge 는 IdentityPolicy enforce 경로를 통한 것이므로
// origin_type 은 'policy' 로 분류한다 (이전 버전에서는 source_type 을 잘못 매핑).
'origin_type' => IdentityOriginType::Policy->value,
'origin_identifier' => $policy->source_identifier,
'origin_policy_key' => $policy->key,
'properties' => json_encode($properties, JSON_UNESCAPED_UNICODE),
'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
'verification_token' => $status === IdentityVerificationStatus::Verified
? bin2hex(random_bytes(32))
: null,
'expires_at' => $expiresAt,
'verified_at' => $verifiedAt,
'consumed_at' => $consumedAt,
'created_at' => $createdAt,
'updated_at' => $verifiedAt ?? $createdAt,
];
}
foreach (array_chunk($batch, 100) as $chunk) {
IdentityVerificationLog::insert($chunk);
}
$this->command->info("{$label} 본인인증 이력 시딩 완료 ({$count}건)");
}
/**
* 가중치 기반 상태 선택.
*
* @return IdentityVerificationStatus 선택된 상태
*/
protected function pickStatus(): IdentityVerificationStatus
{
$r = mt_rand(1, 100);
$acc = 0;
foreach ($this->statusBuckets as [$status, $weight]) {
$acc += $weight;
if ($r <= $acc) {
return $status;
}
}
return IdentityVerificationStatus::Verified;
}
/**
* 상태별 라이프사이클 일관성 있게 구성.
*
* @param IdentityVerificationStatus $status Challenge 상태
* @param Carbon $createdAt 생성 시각
* @param int $ttlMinutes TTL (분)
* @param int $maxAttempts 최대 시도 횟수
* @return array{0: Carbon|null, 1: Carbon|null, 2: Carbon|null, 3: int} [expires_at, verified_at, consumed_at, attempts]
*/
protected function buildLifecycle(
IdentityVerificationStatus $status,
Carbon $createdAt,
int $ttlMinutes,
int $maxAttempts,
): array {
$expiresAt = (clone $createdAt)->addMinutes($ttlMinutes);
$verifiedAt = null;
$consumedAt = null;
$attempts = 0;
switch ($status) {
case IdentityVerificationStatus::Verified:
$attempts = mt_rand(1, 3);
$verifiedAt = (clone $createdAt)->addSeconds(mt_rand(20, 600));
if (mt_rand(0, 1)) {
$consumedAt = (clone $verifiedAt)->addSeconds(mt_rand(1, 30));
}
break;
case IdentityVerificationStatus::Expired:
$attempts = mt_rand(0, 2);
break;
case IdentityVerificationStatus::Failed:
$attempts = $maxAttempts;
break;
case IdentityVerificationStatus::Cancelled:
$attempts = mt_rand(0, 2);
break;
case IdentityVerificationStatus::Sent:
case IdentityVerificationStatus::Requested:
$attempts = 0;
break;
case IdentityVerificationStatus::PolicyViolationLogged:
$expiresAt = null;
$attempts = 0;
break;
}
return [$expiresAt, $verifiedAt, $consumedAt, $attempts];
}
/**
* 최근 60일 내 임의 생성 시각.
*
* @param Carbon $now 기준 시각
* @return Carbon Challenge 생성 시각
*/
protected function randomCreatedAt(Carbon $now): Carbon
{
return (clone $now)
->subDays(mt_rand(0, 60))
->subHours(mt_rand(0, 23))
->subMinutes(mt_rand(0, 59))
->subSeconds(mt_rand(0, 59));
}
}