giftcon_dev/app/Services/Admin/AdminAuthService.php
2026-02-05 21:03:38 +09:00

620 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 ?? ''),
'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,
];
}
}