isIpAllowed($ip)) { Log::warning('admin_sms_send_blocked', [ 'mobile_hash' => $this->hashValue($mobile), 'ip_hash' => $this->hashValue($ip), 'reason' => 'ip_not_allowed', ]); return false; } $mobileKey = $this->key('send', $this->hashValue($mobile)); $minuteCount = $this->increment($mobileKey . ':minute', config('admin-sms.send_minute_window')); $dailyCount = $this->increment($mobileKey . ':daily', config('admin-sms.send_daily_window')); $allowed = $minuteCount <= 1 && $dailyCount <= config('admin-sms.send_daily_limit'); if (!$allowed) { Log::warning('admin_sms_send_blocked', [ 'mobile_hash' => $this->hashValue($mobile), 'ip_hash' => $this->hashValue($ip), 'reason' => $minuteCount > 1 ? 'minute_limit' : 'daily_limit', ]); } return $allowed; } public function issueChallenge(string $mobile, string $ip, callable $send, string $device = ''): ?string { $challengeId = (string) Str::uuid(); $code = (string) random_int(100000, 999999); $cache = $this->cache(); $challenge = [ 'mobile_hash' => $this->hashValue($mobile), 'ip_hash' => $this->hashValue($ip), 'device_hash' => $this->hashValue($device), 'scenario' => 'admin_login', 'code_hash' => Hash::make($code), 'created_at' => time(), ]; if (!$send($code)) { return null; } $cache->put($this->challengeKey($challengeId), $challenge, config('admin-sms.challenge_ttl')); Log::info('admin_sms_challenge_sent', [ 'mobile_hash' => $challenge['mobile_hash'], 'ip_hash' => $challenge['ip_hash'], ]); return $challengeId; } public function issueDecoyChallenge(string $mobile, string $ip, string $device = ''): string { $challengeId = (string) Str::uuid(); $this->cache()->put($this->challengeKey($challengeId), [ 'mobile_hash' => $this->hashValue($mobile), 'ip_hash' => $this->hashValue($ip), 'device_hash' => $this->hashValue($device), 'scenario' => 'admin_login', 'code_hash' => Hash::make((string) random_int(100000, 999999)), 'created_at' => time(), 'decoy' => true, ], config('admin-sms.challenge_ttl')); return $challengeId; } public function verify(string $challengeId, string $mobile, string $code, string $ip, ?Admin $admin, string $device = ''): array { $cache = $this->cache(); $lock = $cache->lock($this->key('lock', $challengeId), 5); try { $lock->block(2); if (!$this->isIpAllowed($ip) || $this->isLocked($mobile, $ip, $admin)) { Log::warning('admin_sms_login_blocked', [ 'mobile_hash' => $this->hashValue($mobile), 'ip_hash' => $this->hashValue($ip), ]); return ['ok' => false, 'locked' => true, 'message' => '登录失败次数过多,请稍后再试']; } $challenge = $cache->get($this->challengeKey($challengeId)); if (!is_array($challenge) || ($challenge['scenario'] ?? null) !== 'admin_login' || !hash_equals((string) ($challenge['mobile_hash'] ?? ''), $this->hashValue($mobile)) || !hash_equals((string) ($challenge['ip_hash'] ?? ''), $this->hashValue($ip)) || !hash_equals((string) ($challenge['device_hash'] ?? ''), $this->hashValue($device))) { return $this->failure($challengeId, $mobile, $ip, $admin); } if (!Hash::check($code, $challenge['code_hash'] ?? '')) { return $this->failure($challengeId, $mobile, $ip, $admin); } $cache->forget($this->challengeKey($challengeId)); $cache->forget($this->key('challenge-fail', $challengeId)); $this->clearFailureCounters($mobile, $ip, $admin); if (!$admin || !empty($challenge['decoy'])) { return ['ok' => false, 'message' => '验证码错误或已失效']; } return ['ok' => true, 'admin' => $admin]; } finally { optional($lock)->release(); } } public function challengeKey(string $challengeId): string { return $this->key('challenge', $challengeId); } private function failure(string $challengeId, string $mobile, string $ip, ?Admin $admin): array { $challengeFailures = $this->increment( $this->key('challenge-fail', $challengeId), config('admin-sms.challenge_ttl') ); $phoneFailures = $this->increment( $this->key('phone-fail', $this->hashValue($mobile)), config('admin-sms.phone_failure_window') ); $ipFailures = $this->increment( $this->key('ip-fail', $this->hashValue($ip)), config('admin-sms.ip_failure_window') ); $accountFailures = 0; if ($admin) { $accountFailures = $this->increment( $this->key('account-fail', (string) $admin->getKey()), config('admin-sms.account_lock_seconds') ); if ($accountFailures >= config('admin-sms.account_max_failures')) { $this->cache()->put( $this->key('account-lock', (string) $admin->getKey()), true, config('admin-sms.account_lock_seconds') ); } } if ($challengeFailures >= config('admin-sms.challenge_max_failures')) { $this->cache()->forget($this->challengeKey($challengeId)); } $accountLocked = $admin ? $this->cache()->has($this->key('account-lock', (string) $admin->getKey())) : false; $locked = $phoneFailures >= config('admin-sms.phone_max_failures') || $ipFailures >= config('admin-sms.ip_max_failures') || $accountFailures >= config('admin-sms.account_max_failures') || $accountLocked; $result = [ 'ok' => false, 'locked' => $locked, 'message' => $locked ? '登录失败次数过多,请稍后再试' : '验证码错误或已失效', ]; Log::warning('admin_sms_login_failed', [ 'challenge_hash' => hash('sha256', $challengeId), 'mobile_hash' => $this->hashValue($mobile), 'ip_hash' => $this->hashValue($ip), 'locked' => $locked, ]); return $result; } private function clearFailureCounters(string $mobile, string $ip, ?Admin $admin): void { $cache = $this->cache(); $hadFailures = $cache->has($this->key('phone-fail', $this->hashValue($mobile))) || $cache->has($this->key('ip-fail', $this->hashValue($ip))) || ($admin && $cache->has($this->key('account-fail', (string) $admin->getKey()))); $cache->forget($this->key('phone-fail', $this->hashValue($mobile))); $cache->forget($this->key('ip-fail', $this->hashValue($ip))); if ($admin) { $cache->forget($this->key('account-fail', (string) $admin->getKey())); $cache->forget($this->key('account-lock', (string) $admin->getKey())); } if ($hadFailures) { Log::info('admin_sms_login_unlocked', [ 'mobile_hash' => $this->hashValue($mobile), 'ip_hash' => $this->hashValue($ip), 'admin_id_hash' => $admin ? hash('sha256', (string) $admin->getKey()) : null, ]); } } private function isLocked(string $mobile, string $ip, ?Admin $admin): bool { $cache = $this->cache(); $phoneFailures = (int) $cache->get($this->key('phone-fail', $this->hashValue($mobile)), 0); $ipFailures = (int) $cache->get($this->key('ip-fail', $this->hashValue($ip)), 0); if ($phoneFailures >= config('admin-sms.phone_max_failures') || $ipFailures >= config('admin-sms.ip_max_failures')) { return true; } return $admin && $cache->has($this->key('account-lock', (string) $admin->getKey())); } private function isIpAllowed(string $ip): bool { $allowed = config('admin-sms.allowed_ips', []); if (!app()->environment('production')) { return true; } // An empty allowlist is an explicit compatibility mode: rate limiting, // challenge binding and one-time verification still remain enabled. if ($allowed === []) { return true; } return $ip !== '' && in_array($ip, $allowed, true); } private function increment(string $key, int $ttl): int { $cache = $this->cache(); $cache->add($key, 0, $ttl); return (int) $cache->increment($key); } private function cache() { return Cache::store(config('admin-sms.cache_store', 'redis')); } private function key(string $type, string $value): string { return 'admin_sms_v2:' . $type . ':' . $value; } private function hashValue(string $value): string { return hash_hmac('sha256', $value, (string) config('app.key')); } }