You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
260 lines
9.6 KiB
260 lines
9.6 KiB
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Admin;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class AdminSmsChallengeService
|
|
{
|
|
public function canSend(string $mobile, string $ip = ''): bool
|
|
{
|
|
if (!$this->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'));
|
|
}
|
|
}
|