'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]; } // 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'=>true] | ['ok'=>false] */ public function resetPassword(int $adminId, string $newPassword, string $ip): array { $admin = $this->users->findActiveById($adminId); if (!$admin) return ['ok' => false]; $this->users->setPassword($admin, $newPassword); 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): array { $admin = $this->users->find($adminId); if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; if (!$forceReset && !empty($admin->totp_verified_at) && !empty($admin->totp_secret_enc)) { return ['ok' => false, 'message' => '이미 Google OTP가 등록되어 있습니다.']; } // 새 시크릿 생성(기본 16) $secret = $this->totpProvider->generateSecretKey(16); // :contentReference[oaicite:2]{index=2} $secretEnc = Crypt::encryptString($secret); $ok = $this->users->updateTotpStart($adminId, $secretEnc); if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 시작에 실패했습니다.']; return ['ok' => true, 'message' => 'Google OTP 등록을 시작합니다. 앱에서 QR을 스캔한 뒤 인증코드를 입력해 주세요.']; } /** 등록 확인(코드 검증) */ public function totpConfirm(int $adminId, string $code): array { $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 등록 완료 처리에 실패했습니다.']; return ['ok' => true, 'message' => 'Google OTP 등록이 완료되었습니다.']; } /** 삭제(해제) */ public function totpDisable(int $adminId): array { $admin = $this->users->find($adminId); if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; $ok = $this->users->disableTotp($adminId); if (!$ok) return ['ok' => false, 'message' => 'Google OTP 삭제에 실패했습니다.']; 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): array { $admin = $this->users->find($adminId); 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차 인증방법 저장에 실패했습니다.']; return ['ok' => true, 'message' => '2차 인증방법이 저장되었습니다.']; } }