Compare commits

...

2 Commits

@ -17,6 +17,10 @@ DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SWAGGER_ENABLED=false
ADMIN_SMS_CACHE_STORE=redis
# Empty allows all source IPs; configure office/VPN egress IPs to restrict production access.
ADMIN_SMS_ALLOWED_IPS=
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file

@ -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;
}
}

@ -12,7 +12,7 @@ use App\Models\Config;
use App\Models\OperateLog;
use App\Models\Permission;
use App\Models\RoleHasPermission;
use Illuminate\Support\Facades\Cache;
use App\Services\AdminSmsChallengeService;
use Illuminate\Support\Facades\Validator;
class AuthController extends Controller
@ -164,14 +164,17 @@ class AuthController extends Controller
}
/**
* @OA\Get(
* @OA\Post(
* path="/api/admin/auth/sms-login",
* tags={"后台管理"},
* summary="验证码登陆",
* description="",
* @OA\Parameter(name="mobile", in="query", @OA\Schema(type="string"), required=true, description="手机号"),
* @OA\Parameter(name="code", in="query", @OA\Schema(type="string"), required=true, description="验证码"),
* @OA\Parameter(name="token", in="query", @OA\Schema(type="string"), required=true, description="token"),
* @OA\RequestBody(required=true, @OA\JsonContent(
* required={"mobile", "code", "challenge_id"},
* @OA\Property(property="mobile", type="string", example="13800138000"),
* @OA\Property(property="code", type="string", example="123456"),
* @OA\Property(property="challenge_id", type="string", format="uuid")
* )),
* @OA\Response(
* response="200",
* description=""
@ -180,27 +183,31 @@ class AuthController extends Controller
*/
public function smsLogin()
{
$all = \request()->all();
$all = request()->all();
$messages = [
'mobile.required' => '手机号必填',
'mobile.numeric' => '手机号格式错误',
'code' => '验证码必填',
'mobile.digits' => '手机号格式错误',
'code.digits' => '验证码格式错误',
];
$validator = Validator::make($all, [
'mobile' => 'required|numeric',
'code' => 'required',
'mobile' => 'required|digits:11',
'code' => 'required|digits:6',
'challenge_id' => 'required|uuid',
], $messages);
if ($validator->fails()) {
return $this->fail([StarterResponseCode::START_ERROR_PARAMETER, implode(',', $validator->errors()->all())]);
}
$key = 'sms_admin_' . $all['mobile'];
$check = Cache::get($key);
if (empty($check)) return $this->fail([ResponseCode::ERROR_BUSINESS, '请先发送验证码']);
if ($check['code'] != $all['code']) return $this->fail([ResponseCode::ERROR_BUSINESS, '验证码错误']);
// 判断手机号是否存在
$admin = Admin::where('mobile', $all['mobile'])->first();
if (empty($admin)) {
return $this->fail([ResponseCode::ERROR_BUSINESS, '用户不存在']);
$result = app(AdminSmsChallengeService::class)->verify(
$all['challenge_id'],
$all['mobile'],
$all['code'],
request()->ip(),
$admin,
(string) request()->userAgent()
);
if (!$result['ok']) {
return $this->fail([ResponseCode::ERROR_BUSINESS, $result['message']]);
}
$token = $admin->createToken("token")->plainTextToken;
// 加日志
@ -209,13 +216,15 @@ class AuthController extends Controller
}
/**
* @OA\Get (
* @OA\Post(
* path="/api/admin/auth/send-sms",
* tags={"后台管理"},
* summary="短信发送",
* description="",
* @OA\Parameter(name="mobile", in="query", @OA\Schema(type="string"), required=true, description="手机号"),
* @OA\Parameter(name="token", in="query", @OA\Schema(type="string"), required=true, description="token"),
* @OA\RequestBody(required=true, @OA\JsonContent(
* required={"mobile"},
* @OA\Property(property="mobile", type="string", example="13800138000")
* )),
* @OA\Response(
* response="200",
* description=""
@ -224,37 +233,35 @@ class AuthController extends Controller
*/
public function sendSms()
{
$all = \request()->all();
$all = request()->all();
$messages = [
'mobile.required' => '手机号必填',
'mobile.numeric' => '手机号格式错误',
'mobile.digits' => '手机号格式错误',
];
$validator = Validator::make($all, [
'mobile' => 'required|numeric',
'mobile' => 'required|digits:11',
], $messages);
if ($validator->fails()) {
return $this->fail([StarterResponseCode::START_ERROR_PARAMETER, implode(',', $validator->errors()->all())]);
}
$key = 'sms_admin_' . $all['mobile'];
$check = Cache::get($key);
if (isset($check) && time() - $check['time'] <= 60) {
return $this->fail([ResponseCode::ERROR_BUSINESS, '请勿频繁发送']);
}
// 用户检测
$service = app(AdminSmsChallengeService::class);
$canSend = $service->canSend($all['mobile'], request()->ip());
$admin = Admin::where('mobile', $all['mobile'])->first();
if (empty($admin)) {
return $this->fail([ResponseCode::ERROR_BUSINESS, '用户不存在']);
}
$code = rand(1000, 9999);
$smsSign = Config::getValueByKey('sms_sign');
$content = "{$smsSign}验证码{$code}您正在登陆苏州科技商学院信息化系统请在5分钟内完成验证。";
$result = ymSms($all['mobile'], $content);
if ($result) {
// 缓存
Cache::put($key, ['code' => $code, 'time' => time()], 300);
return $this->success("发送成功");
$challengeId = null;
if ($canSend && $admin) {
$smsSign = Config::getValueByKey('sms_sign');
$challengeId = $service->issueChallenge($all['mobile'], request()->ip(), function ($code) use ($all, $smsSign) {
$content = "{$smsSign}验证码{$code}您正在登陆苏州科技商学院信息化系统请在5分钟内完成验证。";
return (bool) ymSms($all['mobile'], $content);
}, (string) request()->userAgent());
}
return $this->fail([StarterResponseCode::START_ERROR_PARAMETER, "发送失败"]);
// Always return the same shape, including for unknown or throttled numbers.
return $this->success([
'message' => '如果手机号已登记,验证码将发送',
'challenge_id' => $challengeId ?: $service->issueDecoyChallenge($all['mobile'], request()->ip(), (string) request()->userAgent()),
]);
}

@ -9,7 +9,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use OpenApi\Attributes as OA;
define('APP_NAME', env("APP_NAME"));
define('APP_NAME', env("APP_NAME", 'Application API'));
#[OA\Info(title: APP_NAME, version: "1.0")]
class Controller extends BaseController

@ -9,9 +9,11 @@ class SwaggerController extends Controller
*/
public function getJSON()
{
if(config('app.debug')){
$swagger = \OpenApi\Generator::scan([app_path('Http/Controllers/')]);
return response()->json($swagger, 200);
if (!config('app.debug') || !config('app.swagger_enabled') || app()->environment('production')) {
abort(404);
}
$swagger = \OpenApi\Generator::scan([app_path('Http/Controllers/')]);
return response()->json($swagger, 200);
}
}

@ -4,6 +4,7 @@ namespace App\Http;
use App\Http\Middleware\Rbac;
use App\Http\Middleware\SanctumJWT;
use App\Http\Middleware\SwaggerAccess;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
@ -67,5 +68,6 @@ class Kernel extends HttpKernel
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'sanctum.jwt' => SanctumJWT::class,
'rbac' => Rbac::class,
'swagger.access' => SwaggerAccess::class,
];
}

@ -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,
];

@ -54,6 +54,9 @@ return [
'debug' => (bool)env('APP_DEBUG', false),
// Swagger is disabled by default and must be explicitly enabled outside production.
'swagger_enabled' => (bool)env('SWAGGER_ENABLED', false),
/*
|--------------------------------------------------------------------------
| Application URL

@ -74,7 +74,9 @@ return [
/*
* Route Group options
*/
'group_options' => [],
'group_options' => [
'middleware' => ['swagger.access'],
],
],
'paths' => [

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

@ -41,8 +41,8 @@ Route::group(["namespace" => "Admin", "prefix" => "admin"], function () {
Route::get('other/courses-home-export', [\App\Http\Controllers\Admin\OtherController::class, "coursesHomeExport"]);
// 验证码登陆
Route::get('auth/sms-login', [\App\Http\Controllers\Admin\AuthController::class, "smsLogin"]);
Route::get('auth/send-sms', [\App\Http\Controllers\Admin\AuthController::class, "sendSms"]);
Route::post('auth/sms-login', [\App\Http\Controllers\Admin\AuthController::class, "smsLogin"]);
Route::post('auth/send-sms', [\App\Http\Controllers\Admin\AuthController::class, "sendSms"]);
Route::get('company/config', [\App\Http\Controllers\Admin\CompanyController::class, "config"]);

@ -17,7 +17,7 @@ Route::get('/', function () {
return redirect('/admin/index.html');
});
Route::group(['prefix' => 'swagger'], function () {
Route::group(['prefix' => 'swagger', 'middleware' => ['swagger.access']], function () {
Route::get('json', [\App\Http\Controllers\SwaggerController::class, "getJSON"]);
});

@ -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…
Cancel
Save