Merge remote-tracking branch 'origin/master'

保留本地最新 admin 打包产物,合并远程后台安全相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
master
lion 6 days ago
commit 403f9143cf

@ -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,8 @@ 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\Log;
use Illuminate\Support\Facades\Validator;
class AuthController extends Controller
@ -164,14 +165,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 +184,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 +217,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 +234,44 @@ 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分钟内完成验证。";
try {
return (bool) ymSms($all['mobile'], $content);
} catch (\Throwable $e) {
// Do not expose provider/network errors or the mobile number to the client.
Log::error('admin_sms_provider_error', [
'mobile_hash' => hash('sha256', (string) $all['mobile']),
'exception' => get_class($e),
]);
return false;
}
}, (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' => [

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