保留本地最新 admin 打包产物,合并远程后台安全相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>master
commit
403f9143cf
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RevokeAdminTokens extends Command
|
||||
{
|
||||
protected $signature = 'admin:revoke-tokens';
|
||||
protected $description = 'Revoke all existing administrator API tokens';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = 0;
|
||||
Admin::query()->chunkById(100, function ($admins) use (&$count) {
|
||||
foreach ($admins as $admin) {
|
||||
$count += $admin->tokens()->delete();
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Revoked {$count} administrator token(s).");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SwaggerAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// Documentation is opt-in and must never be available with debug off.
|
||||
if (!config('app.debug') || !config('app.swagger_enabled') || app()->environment('production')) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'cache_store' => env('ADMIN_SMS_CACHE_STORE', 'redis'),
|
||||
// Empty means all source IPs are allowed; configure trusted office/VPN IPs to restrict access.
|
||||
'allowed_ips' => array_values(array_filter(array_map('trim', explode(',', env('ADMIN_SMS_ALLOWED_IPS', ''))))),
|
||||
'challenge_ttl' => 300,
|
||||
'challenge_max_failures' => 5,
|
||||
'phone_failure_window' => 600,
|
||||
'phone_max_failures' => 5,
|
||||
'account_lock_seconds' => 900,
|
||||
'account_max_failures' => 5,
|
||||
'ip_failure_window' => 600,
|
||||
'ip_max_failures' => 30,
|
||||
'send_minute_window' => 60,
|
||||
'send_daily_window' => 86400,
|
||||
'send_daily_limit' => 10,
|
||||
];
|
||||
|
Before Width: | Height: | Size: 665 B After Width: | Height: | Size: 665 B |
|
Before Width: | Height: | Size: 628 B After Width: | Height: | Size: 628 B |
@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-https://wx.sstbc.com}"
|
||||
CURL_TIMEOUT="${CURL_TIMEOUT:-20}"
|
||||
CURL_RESOLVE="${CURL_RESOLVE:-}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
CURL_ARGS=(-sS -L --max-time "$CURL_TIMEOUT")
|
||||
if [[ "${CURL_INSECURE:-0}" == "1" ]]; then
|
||||
CURL_ARGS+=(-k)
|
||||
fi
|
||||
if [[ -n "$CURL_RESOLVE" ]]; then
|
||||
CURL_ARGS+=(--resolve "$CURL_RESOLVE")
|
||||
fi
|
||||
|
||||
paths=(
|
||||
"/docs/"
|
||||
"/swagger/"
|
||||
"/swagger/json"
|
||||
"/api/documentation"
|
||||
"/docs/api-docs.json"
|
||||
"/docs/api-docs.yaml"
|
||||
"/swagger/index.html"
|
||||
"/swagger/swagger-ui.js"
|
||||
"/storage/api-docs/api-docs.json"
|
||||
)
|
||||
|
||||
failed=0
|
||||
for index in "${!paths[@]}"; do
|
||||
path="${paths[$index]}"
|
||||
body="$TMP_DIR/response-$index.body"
|
||||
status=""
|
||||
|
||||
if ! status="$(curl "${CURL_ARGS[@]}" -o "$body" -w '%{http_code}' "$BASE_URL$path")"; then
|
||||
status="000"
|
||||
fi
|
||||
|
||||
if [[ "$status" != "403" && "$status" != "404" ]]; then
|
||||
printf '%-38s HTTP %s FAIL\n' "$path" "$status"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if grep -Eiq 'swagger|openapi|api-docs' "$body"; then
|
||||
printf '%-38s HTTP %s DOCUMENT_CONTENT_FAIL\n' "$path" "$status"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%-38s HTTP %s PASS\n' "$path" "$status"
|
||||
done
|
||||
|
||||
if [[ "$failed" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'VULN02_DOCUMENTATION_EXPOSURE PASS\n'
|
||||
@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-https://wx.sstbc.com}"
|
||||
CURL_TIMEOUT="${CURL_TIMEOUT:-20}"
|
||||
CURL_RESOLVE="${CURL_RESOLVE:-}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
CURL_ARGS=(-sS -L --max-time "$CURL_TIMEOUT")
|
||||
if [[ "${CURL_INSECURE:-0}" == "1" ]]; then CURL_ARGS+=(-k); fi
|
||||
if [[ -n "$CURL_RESOLVE" ]]; then CURL_ARGS+=(--resolve "$CURL_RESOLVE"); fi
|
||||
|
||||
request() {
|
||||
local name="$1"
|
||||
local method="$2"
|
||||
local path="$3"
|
||||
local payload="${4:-{\"mobile\":\"13800138000\",\"code\":\"1234\",\"challenge_id\":\"not-a-uuid\"}}"
|
||||
local body="$TMP_DIR/$name.json"
|
||||
local status
|
||||
|
||||
if ! status="$(curl "${CURL_ARGS[@]}" -X "$method" -H 'Accept: application/json' -H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
-o "$body" -w '%{http_code}' "$BASE_URL$path")"; then
|
||||
status="000"
|
||||
fi
|
||||
printf '%-32s HTTP %s\n' "$name" "$status"
|
||||
printf '%s\n' "$body"
|
||||
printf '%s' "$status" > "$TMP_DIR/$name.status"
|
||||
}
|
||||
|
||||
request legacy-login GET /api/admin/auth/sms-login
|
||||
request legacy-send GET /api/admin/auth/send-sms
|
||||
request invalid-login POST /api/admin/auth/sms-login
|
||||
request invalid-send POST /api/admin/auth/send-sms '{"mobile":"invalid"}'
|
||||
|
||||
if [[ "$(cat "$TMP_DIR/legacy-login.status")" != "405" || "$(cat "$TMP_DIR/legacy-send.status")" != "405" ]]; then
|
||||
printf 'LEGACY_GET_CLOSED FAIL\n'
|
||||
exit 1
|
||||
fi
|
||||
printf 'LEGACY_GET_CLOSED PASS\n'
|
||||
|
||||
if ! grep -Eq '"errcode"[[:space:]]*:[[:space:]]*30001' "$TMP_DIR/invalid-login.json"; then
|
||||
printf 'LOGIN_VALIDATION FAIL\n'
|
||||
exit 1
|
||||
fi
|
||||
if grep -Eiq 'access_token|token' "$TMP_DIR/invalid-login.json"; then
|
||||
printf 'LOGIN_TOKEN_LEAK FAIL\n'
|
||||
exit 1
|
||||
fi
|
||||
printf 'LOGIN_VALIDATION PASS\n'
|
||||
|
||||
if ! grep -Eq '"errcode"[[:space:]]*:[[:space:]]*30001' "$TMP_DIR/invalid-send.json"; then
|
||||
printf 'SEND_VALIDATION FAIL\n'
|
||||
exit 1
|
||||
fi
|
||||
printf 'SEND_VALIDATION PASS\n'
|
||||
|
||||
printf 'VULN03_ENDPOINT_REGRESSION PASS\n'
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminSmsLoginSecurityTest extends TestCase
|
||||
{
|
||||
public function test_legacy_get_sms_login_is_not_available(): void
|
||||
{
|
||||
$this->getJson('/api/admin/auth/sms-login')->assertStatus(405);
|
||||
$this->getJson('/api/admin/auth/send-sms')->assertStatus(405);
|
||||
}
|
||||
|
||||
public function test_sms_login_requires_six_digit_code_and_challenge(): void
|
||||
{
|
||||
$response = $this->postJson('/api/admin/auth/sms-login', [
|
||||
'mobile' => '13800138000',
|
||||
'code' => '1234',
|
||||
'challenge_id' => 'not-a-uuid',
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonPath('errcode', 30001);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class SwaggerExposureTest extends TestCase
|
||||
{
|
||||
public function test_documentation_routes_are_hidden_when_disabled(): void
|
||||
{
|
||||
config([
|
||||
'app.debug' => false,
|
||||
'app.swagger_enabled' => true,
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
'/swagger/json',
|
||||
'/api/documentation',
|
||||
'/docs',
|
||||
'/docs/asset/swagger-ui.css',
|
||||
'/docs/api-docs.json',
|
||||
'/docs/api-docs.yaml',
|
||||
'/swagger/index.html',
|
||||
'/swagger/swagger-ui.js',
|
||||
'/storage/api-docs/api-docs.json',
|
||||
] as $path) {
|
||||
$response = $this->get($path);
|
||||
|
||||
$response->assertNotFound();
|
||||
$this->assertStringNotContainsString('openapi', strtolower($response->getContent()));
|
||||
$this->assertStringNotContainsString('swagger', strtolower($response->getContent()));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_documentation_can_be_enabled_in_a_debug_test_environment(): void
|
||||
{
|
||||
config([
|
||||
'app.debug' => true,
|
||||
'app.swagger_enabled' => true,
|
||||
]);
|
||||
|
||||
$response = $this->get('/swagger/json');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure(['openapi', 'paths']);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Services\AdminSmsChallengeService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminSmsChallengeServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'app.key' => 'test-key',
|
||||
'admin-sms.cache_store' => 'array',
|
||||
]);
|
||||
Cache::store('array')->flush();
|
||||
}
|
||||
|
||||
public function test_challenge_is_six_digits_hashed_and_single_use(): void
|
||||
{
|
||||
$service = app(AdminSmsChallengeService::class);
|
||||
$sentCode = null;
|
||||
$challengeId = $service->issueChallenge('13800138000', '127.0.0.1', function ($code) use (&$sentCode) {
|
||||
$sentCode = $code;
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->assertNotNull($challengeId);
|
||||
$this->assertMatchesRegularExpression('/^\d{6}$/', $sentCode);
|
||||
$stored = Cache::store('array')->get($service->challengeKey($challengeId));
|
||||
$this->assertArrayNotHasKey('code', $stored);
|
||||
$this->assertNotSame($sentCode, $stored['code_hash']);
|
||||
$this->assertTrue(Hash::check($sentCode, $stored['code_hash']));
|
||||
|
||||
$admin = new Admin();
|
||||
$admin->forceFill(['id' => 7, 'mobile' => '13800138000']);
|
||||
$success = $service->verify($challengeId, '13800138000', $sentCode, '127.0.0.1', $admin);
|
||||
$replay = $service->verify($challengeId, '13800138000', $sentCode, '127.0.0.1', $admin);
|
||||
|
||||
$this->assertTrue($success['ok']);
|
||||
$this->assertFalse($replay['ok']);
|
||||
}
|
||||
|
||||
public function test_fifth_failed_attempt_locks_and_invalidates_challenge(): void
|
||||
{
|
||||
$service = app(AdminSmsChallengeService::class);
|
||||
$challengeId = $service->issueChallenge('13800138000', '127.0.0.1', fn () => true);
|
||||
$admin = new Admin();
|
||||
$admin->forceFill(['id' => 7, 'mobile' => '13800138000']);
|
||||
|
||||
$result = null;
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$result = $service->verify($challengeId, '13800138000', '000000', '127.0.0.1', $admin);
|
||||
}
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertTrue($result['locked']);
|
||||
$this->assertNull(Cache::store('array')->get($service->challengeKey($challengeId)));
|
||||
}
|
||||
|
||||
public function test_phone_send_limit_allows_one_request_per_minute(): void
|
||||
{
|
||||
$service = app(AdminSmsChallengeService::class);
|
||||
|
||||
$this->assertTrue($service->canSend('13800138000'));
|
||||
$this->assertFalse($service->canSend('13800138000'));
|
||||
}
|
||||
|
||||
public function test_ip_limit_applies_across_multiple_mobile_numbers(): void
|
||||
{
|
||||
config([
|
||||
'admin-sms.ip_max_failures' => 2,
|
||||
'admin-sms.phone_max_failures' => 10,
|
||||
]);
|
||||
$service = app(AdminSmsChallengeService::class);
|
||||
$admin = new Admin();
|
||||
$admin->forceFill(['id' => 7, 'mobile' => '13800138000']);
|
||||
|
||||
foreach (['13800138000', '13900139000'] as $mobile) {
|
||||
$challengeId = $service->issueChallenge($mobile, '127.0.0.1', fn () => true);
|
||||
$result = $service->verify($challengeId, $mobile, '000000', '127.0.0.1', $mobile === $admin->mobile ? $admin : null);
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
$third = $service->issueChallenge('13700137000', '127.0.0.1', fn () => true);
|
||||
$result = $service->verify($third, '13700137000', '000000', '127.0.0.1', null);
|
||||
$this->assertTrue($result['locked']);
|
||||
}
|
||||
|
||||
public function test_account_lock_applies_across_challenges(): void
|
||||
{
|
||||
config([
|
||||
'admin-sms.account_max_failures' => 2,
|
||||
'admin-sms.phone_max_failures' => 10,
|
||||
'admin-sms.ip_max_failures' => 30,
|
||||
]);
|
||||
$service = app(AdminSmsChallengeService::class);
|
||||
$admin = new Admin();
|
||||
$admin->forceFill(['id' => 8, 'mobile' => '13800138000']);
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$challengeId = $service->issueChallenge('13800138000', '127.0.0.' . ($attempt + 1), fn () => true);
|
||||
$result = $service->verify($challengeId, '13800138000', '000000', '127.0.0.' . ($attempt + 1), $admin);
|
||||
}
|
||||
|
||||
$this->assertTrue($result['locked']);
|
||||
$third = $service->issueChallenge('13800138000', '127.0.0.3', fn () => true);
|
||||
$result = $service->verify($third, '13800138000', '000000', '127.0.0.3', $admin);
|
||||
$this->assertTrue($result['locked']);
|
||||
}
|
||||
|
||||
public function test_production_sms_sending_is_restricted_to_configured_ips(): void
|
||||
{
|
||||
config([
|
||||
'admin-sms.allowed_ips' => ['10.0.0.8'],
|
||||
]);
|
||||
$this->app->instance('env', 'production');
|
||||
|
||||
$service = app(AdminSmsChallengeService::class);
|
||||
|
||||
$this->assertFalse($service->canSend('13800138000', '10.0.0.9'));
|
||||
$this->assertTrue($service->canSend('13800138000', '10.0.0.8'));
|
||||
|
||||
config(['admin-sms.allowed_ips' => []]);
|
||||
$this->assertTrue($service->canSend('13900139000', '10.0.0.9'));
|
||||
|
||||
$this->app->instance('env', 'testing');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue