'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); // 계정 없거나 비번 불일치: 같은 메시지로 if (!$admin || !Hash::check($password, (string)$admin->password)) { return ['state' => 'invalid']; } if (($admin->status ?? 'blocked') !== 'active') { return ['state' => 'blocked']; } 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('FindId SMS send failed', [ 'phone' => $phoneDigits, 'error' => $ok, ]); 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, 'to' => 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); } }