623 lines
22 KiB
PHP
623 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin;
|
|
|
|
use App\Models\AdminUser;
|
|
use App\Repositories\Admin\AdminUserRepository;
|
|
use App\Services\SmsService;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Log;
|
|
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
|
use BaconQrCode\Renderer\ImageRenderer;
|
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
|
use BaconQrCode\Writer;
|
|
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
|
use App\Services\Admin\AdminAuditService;
|
|
|
|
final class AdminAuthService
|
|
{
|
|
public function __construct(
|
|
private readonly AdminUserRepository $users,
|
|
private readonly SmsService $sms,
|
|
private readonly TwoFactorAuthenticationProvider $totpProvider,
|
|
private readonly AdminAuditService $audit,
|
|
) {}
|
|
|
|
/**
|
|
* return:
|
|
* - ['state'=>'invalid']
|
|
* - ['state'=>'blocked']
|
|
* - ['state'=>'must_reset','admin_id'=>int]
|
|
* - ['state'=>'otp_sent','challenge_id'=>string,'masked_phone'=>string]
|
|
*/
|
|
public function startLogin(string $email, string $password, bool $remember, string $ip): array
|
|
{
|
|
$admin = $this->users->findByEmail($email);
|
|
|
|
// 계정 없으면 invalid (계정 존재 여부 노출 방지)
|
|
if (!$admin) {
|
|
return ['state' => 'invalid'];
|
|
}
|
|
|
|
// 상태 체크: active만 로그인 허용 (너 DB가 active/blocked라면 여기만 쓰자)
|
|
if (($admin->status ?? 'blocked') !== 'active') {
|
|
return ['state' => 'blocked'];
|
|
}
|
|
|
|
// 잠금 체크: locked_until != null 이면 "잠금 상태"(영구잠금)
|
|
if (($admin->locked_until ?? null) !== null) {
|
|
return ['state' => 'locked', 'admin_id' => (int)$admin->id];
|
|
}
|
|
|
|
// 비밀번호 검증
|
|
if (!Hash::check($password, (string)$admin->password)) {
|
|
|
|
// 실패 카운트 +1, 3회 이상이면 잠금
|
|
$bumped = $this->users->bumpLoginFail((int)$admin->id, 3);
|
|
|
|
if (($bumped['locked'] ?? false) === true) {
|
|
return ['state' => 'locked', 'admin_id' => (int)$admin->id];
|
|
}
|
|
|
|
$left = max(0, 3 - (int)($bumped['count'] ?? 0));
|
|
return ['state' => 'invalid', 'attempts_left' => $left];
|
|
}
|
|
|
|
// 3회 안에 성공하면 실패/잠금 초기화
|
|
if ((int)($admin->failed_login_count ?? 0) > 0 || ($admin->locked_until ?? null) !== null) {
|
|
$this->users->clearLoginFailAndUnlock((int)$admin->id);
|
|
}
|
|
|
|
// 비번 리셋 강제 정책
|
|
if ((int)($admin->must_reset_password ?? 0) === 1) {
|
|
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
|
|
}
|
|
|
|
// TOTP 모드면 SMS 발송 없이 "TOTP 입력"으로 보냄
|
|
$totpEnabled = (int)($admin->totp_enabled ?? 0) === 1;
|
|
$totpReady = $totpEnabled
|
|
&& !empty($admin->totp_secret_enc)
|
|
&& !empty($admin->totp_verified_at);
|
|
|
|
if ($totpReady) {
|
|
return [
|
|
'state' => 'totp_required',
|
|
'admin_id' => (int)$admin->id,
|
|
];
|
|
}
|
|
|
|
// totp_enabled=1인데 등록이 깨진 상태면 로그 남기고 SMS로 fallback (락아웃 방지)
|
|
if ($totpEnabled && !$totpReady) {
|
|
Log::warning('[admin-auth] totp_enabled=1 but not registered/verified', [
|
|
'admin_id' => (int)$admin->id,
|
|
'has_secret' => !empty($admin->totp_secret_enc),
|
|
'has_verified_at' => !empty($admin->totp_verified_at),
|
|
]);
|
|
}
|
|
|
|
|
|
// phone_enc 복호화(E164 or digits)
|
|
$phoneDigits = $this->decryptPhoneToDigits($admin);
|
|
if ($phoneDigits === '') {
|
|
// 내부 데이터 문제라서 카운트 올리진 않음(원하면 올려도 됨)
|
|
return ['state' => 'invalid'];
|
|
}
|
|
|
|
// OTP 발급
|
|
$code = (string)random_int(100000, 999999);
|
|
$nonce = Str::random(32);
|
|
$otpHash = hash('sha256', $code . '|' . $nonce);
|
|
|
|
$challengeId = (string)Str::uuid();
|
|
$ttl = (int)config('admin.sms_ttl', 180);
|
|
$maxAttempts = (int)config('admin.otp_max_attempts', 5);
|
|
|
|
Cache::store('redis')->put(
|
|
$this->otpKey($challengeId),
|
|
[
|
|
'admin_id' => (int)$admin->id,
|
|
'otp_hash' => $otpHash,
|
|
'nonce' => $nonce,
|
|
'remember' => $remember,
|
|
'ip' => $ip,
|
|
'attempts' => 0,
|
|
'max_attempts' => $maxAttempts,
|
|
],
|
|
$ttl
|
|
);
|
|
|
|
$smsPayload = [
|
|
'from_number' => config('services.sms.from', '1833-4856'),
|
|
'to_number' => $phoneDigits,
|
|
//'to_number' => '01036828958',
|
|
'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. ({$ttl}초 이내)",
|
|
'sms_type' => 'sms',
|
|
];
|
|
|
|
try {
|
|
$ok = app(SmsService::class)->send($smsPayload, 'lguplus');
|
|
|
|
if (!$ok) {
|
|
Cache::store('redis')->forget($this->otpKey($challengeId));
|
|
Log::error('Admin login SMS send failed', [
|
|
'admin_id' => (int)$admin->id,
|
|
'to_last4' => substr($phoneDigits, -4),
|
|
]);
|
|
return ['state' => 'sms_error'];
|
|
}
|
|
|
|
return [
|
|
'state' => 'otp_sent',
|
|
'challenge_id' => $challengeId,
|
|
'masked_phone' => $this->maskPhone($phoneDigits),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Cache::store('redis')->forget($this->otpKey($challengeId));
|
|
|
|
Log::error('[admin-auth] sms send exception', [
|
|
'challenge_id' => $challengeId,
|
|
'admin_id' => (int)$admin->id,
|
|
'to_last4' => substr($phoneDigits, -4),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['state' => 'sms_error'];
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* return:
|
|
* - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked']
|
|
* - ['ok'=>true,'admin'=>AdminUser,'remember'=>bool]
|
|
*/
|
|
public function verifyOtp(string $challengeId, string $otp, string $ip): array
|
|
{
|
|
$key = $this->otpKey($challengeId);
|
|
$data = Cache::store('redis')->get($key);
|
|
|
|
if (!is_array($data) || empty($data['admin_id'])) {
|
|
return ['ok' => false, 'reason' => 'expired'];
|
|
}
|
|
|
|
// IP 고정 (원치 않으면 제거 가능)
|
|
if (!hash_equals((string)($data['ip'] ?? ''), $ip)) {
|
|
Cache::store('redis')->forget($key);
|
|
return ['ok' => false, 'reason' => 'ip'];
|
|
}
|
|
|
|
$attempts = (int)($data['attempts'] ?? 0);
|
|
$max = (int)($data['max_attempts'] ?? 5);
|
|
|
|
if ($attempts >= $max) {
|
|
Cache::store('redis')->forget($key);
|
|
return ['ok' => false, 'reason' => 'attempts'];
|
|
}
|
|
|
|
// attempts 증가 후 TTL은 동일하게 다시 설정(단순화)
|
|
$data['attempts'] = $attempts + 1;
|
|
Cache::store('redis')->put($key, $data, (int)config('admin.sms_ttl', 180));
|
|
|
|
$nonce = (string)($data['nonce'] ?? '');
|
|
$expect = (string)($data['otp_hash'] ?? '');
|
|
$got = hash('sha256', $otp . '|' . $nonce);
|
|
|
|
if (!hash_equals($expect, $got)) {
|
|
return ['ok' => false, 'reason' => 'invalid'];
|
|
}
|
|
|
|
$admin = $this->users->findActiveById((int)$data['admin_id']);
|
|
if (!$admin) {
|
|
Cache::store('redis')->forget($key);
|
|
return ['ok' => false, 'reason' => 'blocked'];
|
|
}
|
|
|
|
// 로그인 메타 업데이트
|
|
$this->users->touchLogin($admin, $ip);
|
|
|
|
Cache::store('redis')->forget($key);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'admin' => $admin,
|
|
'remember' => (bool)($data['remember'] ?? false),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* return:
|
|
* - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked|not_registered']
|
|
* - ['ok'=>true,'admin'=>AdminUser]
|
|
*/
|
|
public function verifyTotp(int $adminId, string $code, string $ip): array
|
|
{
|
|
$admin = $this->users->findActiveById($adminId);
|
|
if (!$admin) {
|
|
return ['ok' => false, 'reason' => 'blocked'];
|
|
}
|
|
|
|
if (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at)) {
|
|
return ['ok' => false, 'reason' => 'not_registered'];
|
|
}
|
|
|
|
try {
|
|
$secret = Crypt::decryptString((string) $admin->totp_secret_enc);
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => false, 'reason' => 'not_registered'];
|
|
}
|
|
|
|
$code = preg_replace('/\D+/', '', $code);
|
|
if (strlen($code) !== 6) {
|
|
return ['ok' => false, 'reason' => 'invalid'];
|
|
}
|
|
|
|
$valid = $this->totpProvider->verify($secret, $code);
|
|
if (!$valid) {
|
|
return ['ok' => false, 'reason' => 'invalid'];
|
|
}
|
|
|
|
// 로그인 메타 업데이트
|
|
$this->users->touchLogin($admin, $ip);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'admin' => $admin,
|
|
];
|
|
}
|
|
|
|
|
|
/**
|
|
* 비밀번호 초기화 처리
|
|
* return: ['ok'=>true] | ['ok'=>false]
|
|
*/
|
|
public function resetPassword(int $adminId, string $newPassword, string $ip, string $ua = ''): array
|
|
{
|
|
$beforeAdmin = $this->users->find($adminId);
|
|
$before = $this->snapPwPolicy($beforeAdmin);
|
|
|
|
$admin = $this->users->findActiveById($adminId);
|
|
if (!$admin) return ['ok' => false];
|
|
|
|
$this->users->setPassword($admin, $newPassword);
|
|
|
|
$afterAdmin = $this->users->find($adminId);
|
|
$after = $this->snapPwPolicy($afterAdmin);
|
|
|
|
$this->audit->log(
|
|
actorAdminId: $adminId,
|
|
action: 'password_force_reset_done',
|
|
targetType: 'admin_users',
|
|
targetId: $adminId,
|
|
before: $before,
|
|
after: $after,
|
|
ip: $ip,
|
|
ua: $ua,
|
|
);
|
|
|
|
return ['ok' => true];
|
|
}
|
|
|
|
|
|
|
|
private function otpKey(string $challengeId): string
|
|
{
|
|
$prefix = (string)config('admin.redis_prefix', 'admin:2fa:');
|
|
return $prefix . $challengeId;
|
|
}
|
|
|
|
private function decryptPhoneToDigits(AdminUser $admin): string
|
|
{
|
|
$enc = (string) $admin->phone_enc;
|
|
if ($enc === '') return '';
|
|
|
|
// 1) encryptString() 로 저장한 값은 decryptString() 으로 복호화해야 함
|
|
try {
|
|
$raw = Crypt::decryptString($enc);
|
|
$digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
|
|
|
|
// 최소 길이 검증(한국 휴대폰 기준 10~11자리 정도, E164면 9~15)
|
|
if (!preg_match('/^\d{9,15}$/', $digits)) {
|
|
Log::warning('[admin-auth] phone decrypted but invalid digits length', [
|
|
'admin_id' => $admin->id,
|
|
'digits_len' => strlen($digits),
|
|
]);
|
|
return '';
|
|
}
|
|
|
|
return $digits;
|
|
} catch (\Throwable $e1) {
|
|
// 2) 혹시 예전 encrypt() (serialize 기반)로 저장한 데이터가 섞였으면 이걸로 복구
|
|
try {
|
|
Log::warning('[admin-auth] phone decrypt failed', [
|
|
'admin_id' => $admin->id,
|
|
'enc_len' => strlen($enc),
|
|
'enc_head' => substr($enc, 0, 16),
|
|
'e1' => $e1->getMessage(),
|
|
]);
|
|
$raw = decrypt($enc, false); // unserialize=false
|
|
$digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
|
|
return preg_match('/^\d{9,15}$/', $digits) ? $digits : '';
|
|
} catch (\Throwable $e2) {
|
|
// 3) 진짜 평문 숫자만 예외적으로 허용(암호문에서 숫자 긁는 fallback 절대 금지)
|
|
if (preg_match('/^\d{9,15}$/', $enc)) {
|
|
return $enc;
|
|
}
|
|
|
|
Log::warning('[admin-auth] phone decrypt failed', [
|
|
'admin_id' => $admin->id,
|
|
'enc_len' => strlen($enc),
|
|
'enc_head' => substr($enc, 0, 16),
|
|
'e1' => $e1->getMessage(),
|
|
'e2' => $e2->getMessage(),
|
|
]);
|
|
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
|
|
private function maskPhone(string $digits): string
|
|
{
|
|
if (strlen($digits) < 7) return $digits;
|
|
return substr($digits, 0, 3) . '-****-' . substr($digits, -4);
|
|
}
|
|
|
|
public function totpViewModel(int $adminId): array
|
|
{
|
|
$admin = $this->users->find($adminId);
|
|
if (!$admin) {
|
|
return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
|
|
}
|
|
|
|
$secret = '';
|
|
if (!empty($admin->totp_secret_enc)) {
|
|
try {
|
|
$secret = Crypt::decryptString((string) $admin->totp_secret_enc);
|
|
} catch (\Throwable $e) {
|
|
$secret = '';
|
|
}
|
|
}
|
|
|
|
$isRegistered = ($secret !== '') && !empty($admin->totp_verified_at);
|
|
$isPending = ($secret !== '') && empty($admin->totp_verified_at);
|
|
|
|
$issuer = (string) (config('app.name') ?: 'Admin');
|
|
$email = (string) ($admin->email ?? '');
|
|
|
|
$otpauthUrl = '';
|
|
$qrSvg = '';
|
|
|
|
if ($isPending) {
|
|
// Fortify provider가 otpauth URL 생성
|
|
$otpauthUrl = $this->totpProvider->qrCodeUrl($issuer, $email, $secret);
|
|
|
|
// QR SVG 생성 (bacon/bacon-qr-code)
|
|
$writer = new Writer(
|
|
new ImageRenderer(new RendererStyle(180), new SvgImageBackEnd())
|
|
);
|
|
$qrSvg = $writer->writeString($otpauthUrl);
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'admin' => $admin,
|
|
'isRegistered'=> $isRegistered,
|
|
'isPending' => $isPending,
|
|
'secret' => $secret, // pending 때만 화면에서 노출
|
|
'qrSvg' => $qrSvg,
|
|
];
|
|
}
|
|
|
|
/** 등록 시작(시크릿 생성) */
|
|
public function totpStart(int $adminId, bool $forceReset = false, string $ip = '', string $ua = ''): array
|
|
{
|
|
$beforeAdmin = $this->users->find($adminId);
|
|
$before = $this->snapAdmin2fa($beforeAdmin);
|
|
|
|
$admin = $beforeAdmin;
|
|
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
|
|
|
|
if (!$forceReset && !empty($admin->totp_verified_at) && !empty($admin->totp_secret_enc)) {
|
|
return ['ok' => false, 'message' => '이미 Google OTP가 등록되어 있습니다.'];
|
|
}
|
|
|
|
$secret = $this->totpProvider->generateSecretKey(16);
|
|
$secretEnc = Crypt::encryptString($secret);
|
|
|
|
$ok = $this->users->updateTotpStart($adminId, $secretEnc);
|
|
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 시작에 실패했습니다.'];
|
|
|
|
$afterAdmin = $this->users->find($adminId);
|
|
$after = $this->snapAdmin2fa($afterAdmin);
|
|
|
|
// Audit
|
|
$this->audit->log(
|
|
actorAdminId: $adminId,
|
|
action: $forceReset ? 'totp_reset' : 'totp_start',
|
|
targetType: 'admin_users',
|
|
targetId: $adminId,
|
|
before: $before,
|
|
after: $after,
|
|
ip: $ip,
|
|
ua: $ua,
|
|
);
|
|
|
|
return ['ok' => true, 'message' => 'Google OTP 등록을 시작합니다. 앱에서 QR을 스캔한 뒤 인증코드를 입력해 주세요.'];
|
|
}
|
|
|
|
/** 등록 확인(코드 검증) */
|
|
public function totpConfirm(int $adminId, string $code, string $ip = '', string $ua = ''): array
|
|
{
|
|
$beforeAdmin = $this->users->find($adminId);
|
|
$before = $this->snapAdmin2fa($beforeAdmin);
|
|
|
|
$vm = $this->totpViewModel($adminId);
|
|
if (!($vm['ok'] ?? false)) return ['ok' => false, 'message' => $vm['message'] ?? '오류'];
|
|
|
|
if (!($vm['isPending'] ?? false)) {
|
|
return ['ok' => false, 'message' => 'OTP 등록 진행 상태가 아닙니다.'];
|
|
}
|
|
|
|
$secret = (string) ($vm['secret'] ?? '');
|
|
if ($secret === '') return ['ok' => false, 'message' => 'OTP 시크릿을 읽을 수 없습니다. 다시 등록을 시작해 주세요.'];
|
|
|
|
$code = preg_replace('/\D+/', '', $code);
|
|
if (strlen($code) !== 6) return ['ok' => false, 'message' => '인증코드는 6자리 숫자여야 합니다.'];
|
|
|
|
// Fortify provider로 검증 :contentReference[oaicite:3]{index=3}
|
|
$valid = $this->totpProvider->verify($secret, $code);
|
|
if (!$valid) return ['ok' => false, 'message' => '인증코드가 올바르지 않습니다. 다시 확인해 주세요.'];
|
|
|
|
$ok = $this->users->confirmTotp($adminId);
|
|
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 완료 처리에 실패했습니다.'];
|
|
|
|
$afterAdmin = $this->users->find($adminId);
|
|
$after = $this->snapAdmin2fa($afterAdmin);
|
|
|
|
$this->audit->log(
|
|
actorAdminId: $adminId,
|
|
action: 'totp_confirm',
|
|
targetType: 'admin_users',
|
|
targetId: $adminId,
|
|
before: $before,
|
|
after: $after,
|
|
ip: $ip,
|
|
ua: $ua,
|
|
);
|
|
|
|
return ['ok' => true, 'message' => 'Google OTP 등록이 완료되었습니다.'];
|
|
}
|
|
|
|
/** 삭제(해제) */
|
|
public function totpDisable(int $adminId, string $ip = '', string $ua = ''): array
|
|
{
|
|
$beforeAdmin = $this->users->find($adminId);
|
|
$before = $this->snapAdmin2fa($beforeAdmin);
|
|
|
|
$admin = $beforeAdmin;
|
|
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
|
|
|
|
$ok = $this->users->disableTotp($adminId);
|
|
if (!$ok) return ['ok' => false, 'message' => 'Google OTP 삭제에 실패했습니다.'];
|
|
|
|
$afterAdmin = $this->users->find($adminId);
|
|
$after = $this->snapAdmin2fa($afterAdmin);
|
|
|
|
$this->audit->log(
|
|
actorAdminId: $adminId,
|
|
action: 'totp_disable',
|
|
targetType: 'admin_users',
|
|
targetId: $adminId,
|
|
before: $before,
|
|
after: $after,
|
|
ip: $ip,
|
|
ua: $ua,
|
|
);
|
|
|
|
return ['ok' => true, 'message' => 'Google OTP가 삭제되었습니다. 이제 SMS 인증을 사용합니다.'];
|
|
}
|
|
|
|
|
|
/** 재등록(새 시크릿 발급) */
|
|
public function totpReset(int $adminId): array
|
|
{
|
|
// 그냥 start(force=true)로 처리
|
|
return $this->totpStart($adminId, true);
|
|
}
|
|
|
|
/** sms/otp 모드 저장(선택) */
|
|
public function totpMode(int $adminId, int $enabled, string $ip = '', string $ua = ''): array
|
|
{
|
|
$beforeAdmin = $this->users->find($adminId);
|
|
$before = $this->snapAdmin2fa($beforeAdmin);
|
|
|
|
$admin = $beforeAdmin;
|
|
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
|
|
|
|
if ($enabled === 1) {
|
|
if (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at)) {
|
|
return ['ok' => false, 'message' => 'Google OTP 미등록 상태에서는 OTP 인증으로 전환할 수 없습니다.'];
|
|
}
|
|
}
|
|
|
|
$ok = $this->users->updateTotpMode($adminId, $enabled ? 1 : 0);
|
|
if (!$ok) return ['ok' => false, 'message' => '2차 인증방법 저장에 실패했습니다.'];
|
|
|
|
$afterAdmin = $this->users->find($adminId);
|
|
$after = $this->snapAdmin2fa($afterAdmin);
|
|
|
|
$this->audit->log(
|
|
actorAdminId: $adminId,
|
|
action: 'totp_mode',
|
|
targetType: 'admin_users',
|
|
targetId: $adminId,
|
|
before: $before,
|
|
after: $after,
|
|
ip: $ip,
|
|
ua: $ua,
|
|
);
|
|
|
|
return ['ok' => true, 'message' => '2차 인증방법이 저장되었습니다.'];
|
|
}
|
|
|
|
|
|
public function buildAdminSessionContext(int $adminId): array
|
|
{
|
|
$admin = $this->users->findActiveById($adminId);
|
|
if (!$admin) return [];
|
|
|
|
// repo가 반환: ['id'=>?, 'code'=>?, 'name'=>?] (code=권한코드, name=표시명)
|
|
$rows = $this->users->getRolesForUser($adminId);
|
|
|
|
$roles = array_map(fn($r) => [
|
|
'id' => (int)($r['id'] ?? 0),
|
|
'name' => (string)($r['code'] ?? ''), // 체크용
|
|
'label' => (string)($r['name'] ?? ''), // 표시용
|
|
], $rows);
|
|
|
|
$roleIds = array_values(array_filter(array_map(fn($x) => (int)$x['id'], $roles)));
|
|
$roleNames = array_values(array_filter(array_map(fn($x) => (string)$x['name'], $roles)));
|
|
|
|
return [
|
|
'id' => (int)$admin->id,
|
|
'email' => (string)($admin->email ?? ''),
|
|
'name' => (string)($admin->name ?? ''),
|
|
'nickname' => (string)($admin->nickname ?? ''),
|
|
'phone_enc' => (string)($admin->phone_enc ?? ''),
|
|
'roles' => $roles,
|
|
'role_ids' => $roleIds,
|
|
'role_names' => $roleNames,
|
|
'at' => time(),
|
|
];
|
|
}
|
|
|
|
private function snapAdmin2fa(?object $admin): ?array
|
|
{
|
|
if (!$admin) return null;
|
|
|
|
return [
|
|
'id' => (int)($admin->id ?? 0),
|
|
'email' => (string)($admin->email ?? ''),
|
|
'totp_enabled' => (int)($admin->totp_enabled ?? 0),
|
|
'totp_verified_at' => $admin->totp_verified_at ? (string)$admin->totp_verified_at : null,
|
|
// 시크릿 원문/암호문 저장 금지
|
|
'totp_secret_set' => !empty($admin->totp_secret_enc) ? 1 : 0,
|
|
];
|
|
}
|
|
|
|
private function snapPwPolicy(?object $admin): ?array
|
|
{
|
|
if (!$admin) return null;
|
|
|
|
return [
|
|
'id' => (int)($admin->id ?? 0),
|
|
'email' => (string)($admin->email ?? ''),
|
|
'must_reset_password' => (int)($admin->must_reset_password ?? 0),
|
|
'password_changed_at' => $admin->password_changed_at ? (string)$admin->password_changed_at : null,
|
|
];
|
|
}
|
|
}
|