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