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.
wx.sstbc.com/app/Services/AdminSmsChallengeService.php

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'));
}
}