Compare commits
2 Commits
58931b4971
...
e22b70f8de
| Author | SHA1 | Date |
|---|---|---|
|
|
e22b70f8de | 2 months ago |
|
|
b8cb1e63d7 | 2 months ago |
@ -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,
|
||||
];
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
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