修复认证与基础安全链路
This commit is contained in:
@@ -32,6 +32,7 @@ SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 安全保证:
|
||||
* - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护
|
||||
* - 普通用户无权访问此接口,无法伪造对他人的广播
|
||||
* - options 中的用户输入字段在后端经过 strip_tags 清洗
|
||||
* - options 中的用户输入字段在后端统一降级为纯文本 / 白名单样式值
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -23,6 +23,9 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 类功能:安全地下发大卡片广播消息。
|
||||
*/
|
||||
class BannerBroadcastController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -46,23 +49,39 @@ class BannerBroadcastController extends Controller
|
||||
'options.body' => ['nullable', 'string', 'max:500'],
|
||||
'options.sub' => ['nullable', 'string', 'max:200'],
|
||||
'options.gradient' => ['nullable', 'array', 'max:5'],
|
||||
'options.gradient.*' => ['nullable', 'string', 'max:30'],
|
||||
'options.titleColor' => ['nullable', 'string', 'max:30'],
|
||||
'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'],
|
||||
'options.buttons' => ['nullable', 'array', 'max:4'],
|
||||
'options.buttons.*.label' => ['nullable', 'string', 'max:30'],
|
||||
'options.buttons.*.color' => ['nullable', 'string', 'max:30'],
|
||||
'options.buttons.*.action' => ['nullable', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// 对可能包含用户输入的字段进行 HTML 净化(防 XSS)
|
||||
// 所有可见文案一律降级为纯文本,避免允许标签残留属性后在前端 innerHTML 中执行。
|
||||
$opts = $validated['options'];
|
||||
foreach (['title', 'name', 'body', 'sub'] as $field) {
|
||||
if (isset($opts[$field])) {
|
||||
$opts[$field] = strip_tags($opts[$field], '<b><strong><em><span><br>');
|
||||
$opts[$field] = $this->sanitizeBannerText($opts[$field]);
|
||||
}
|
||||
}
|
||||
// 按钮 label 不允许 HTML
|
||||
|
||||
if (isset($opts['titleColor'])) {
|
||||
$opts['titleColor'] = $this->sanitizeCssValue($opts['titleColor'], '#fde68a');
|
||||
}
|
||||
|
||||
if (! empty($opts['gradient'])) {
|
||||
$opts['gradient'] = array_values(array_map(
|
||||
fn ($color) => $this->sanitizeCssValue($color, '#4f46e5'),
|
||||
$opts['gradient']
|
||||
));
|
||||
}
|
||||
|
||||
// 按钮 label 与颜色都只允许安全文本 / 颜色值。
|
||||
if (! empty($opts['buttons'])) {
|
||||
$opts['buttons'] = array_map(function ($btn) {
|
||||
$btn['label'] = strip_tags($btn['label'] ?? '');
|
||||
$btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981');
|
||||
$btn['label'] = $this->sanitizeBannerText($btn['label'] ?? '');
|
||||
$btn['color'] = $this->sanitizeCssValue($btn['color'] ?? '#10b981', '#10b981');
|
||||
// action 只允许预定义值,防止注入任意 JS
|
||||
$btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link'])
|
||||
? $btn['action'] : 'close';
|
||||
@@ -79,4 +98,38 @@ class BannerBroadcastController extends Controller
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '广播已发送']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Banner 文案净化为安全纯文本。
|
||||
*/
|
||||
private function sanitizeBannerText(?string $text): string
|
||||
{
|
||||
return trim(strip_tags((string) $text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗颜色 / 渐变等 CSS 值,阻断样式属性注入。
|
||||
*/
|
||||
private function sanitizeCssValue(?string $value, string $default): string
|
||||
{
|
||||
$sanitized = strtolower(trim((string) $value));
|
||||
if ($sanitized === '' || preg_match('/(?:javascript|expression|url\s*\(|data:|var\s*\()/i', $sanitized)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$allowedPatterns = [
|
||||
'/^#[0-9a-f]{3,8}$/i',
|
||||
'/^rgba?\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
|
||||
'/^hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
|
||||
'/^(?:white|black|red|blue|green|gray|grey|yellow|orange|pink|purple|teal|cyan|indigo|amber|emerald|transparent|currentcolor)$/i',
|
||||
];
|
||||
|
||||
foreach ($allowedPatterns as $allowedPattern) {
|
||||
if (preg_match($allowedPattern, $sanitized)) {
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室前台登录、自动注册与退出登录。
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -61,7 +64,7 @@ class AuthController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$this->performLogin($user, $ip);
|
||||
$this->performLogin($user, $ip, $request);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '登录成功']);
|
||||
}
|
||||
@@ -83,7 +86,7 @@ class AuthController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$this->performLogin($user, $ip);
|
||||
$this->performLogin($user, $ip, $request);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '登录成功,且安全策略已自动升级']);
|
||||
}
|
||||
@@ -139,7 +142,7 @@ class AuthController extends Controller
|
||||
'inviter_id' => $inviterId, // 记录邀请人
|
||||
]);
|
||||
|
||||
$this->performLogin($newUser, $ip);
|
||||
$this->performLogin($newUser, $ip, $request);
|
||||
|
||||
// 如果是通过邀请注册的,响应成功后建议清除 Cookie,防止污染后续注册
|
||||
if ($inviterId) {
|
||||
@@ -152,9 +155,11 @@ class AuthController extends Controller
|
||||
/**
|
||||
* 执行实际的登录操作并记录时间、IP 等。
|
||||
*/
|
||||
private function performLogin(User $user, string $ip): void
|
||||
private function performLogin(User $user, string $ip, Request $request): void
|
||||
{
|
||||
Auth::login($user);
|
||||
// 登录成功后立即轮换 session id,阻断会话固定攻击。
|
||||
$request->session()->regenerate();
|
||||
|
||||
// 递增访问次数
|
||||
$user->increment('visit_num');
|
||||
|
||||
@@ -1,42 +1,106 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:在可信代理场景下解析客户端真实 IP。
|
||||
*
|
||||
* 仅当当前请求明确来自配置中的反向代理 / CDN 节点时,
|
||||
* 才会采信其透传的真实客户端 IP 头,避免外部客户端伪造来源。
|
||||
*/
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 类功能:为可信代理请求恢复真实客户端 IP。
|
||||
*/
|
||||
class CloudflareProxies
|
||||
{
|
||||
/**
|
||||
* 文件功能:强制信任并解析 CDN 传导的真实客户端 IP。
|
||||
* 解决 Herd 环境 / Nginx 本地反代时,丢失 X-Forwarded-For 导致全员 IP 变成 127.0.0.1 的问题。
|
||||
* 处理进入应用的请求,并在可信代理场景下覆写客户端 IP。
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 优先采纳 Cloudflare 的 CF-Connecting-IP
|
||||
if ($request->hasHeader('CF-Connecting-IP')) {
|
||||
$realIp = $request->header('CF-Connecting-IP');
|
||||
}
|
||||
// 腾讯云 EdgeOne CDN 自定义回源头部(后台配置名:EO-Client-IP)
|
||||
elseif ($request->hasHeader('EO-Client-IP')) {
|
||||
$realIp = $request->header('EO-Client-IP');
|
||||
}
|
||||
// 其他国内 CDN 厂商(阿里云 DCDN 等)通用头部
|
||||
elseif ($request->hasHeader('X-Real-IP')) {
|
||||
$realIp = $request->header('X-Real-IP');
|
||||
}
|
||||
// 最后兜底:取 X-Forwarded-For 最左边第一个(真实客户端)IP
|
||||
// 格式为 "真实客户端, CDN节点1, CDN节点2"
|
||||
elseif ($request->hasHeader('X-Forwarded-For')) {
|
||||
$realIp = trim(explode(',', $request->header('X-Forwarded-For'))[0]);
|
||||
}
|
||||
$realIp = $this->resolveTrustedClientIp($request);
|
||||
|
||||
if (! empty($realIp)) {
|
||||
// 仅在确认上游代理可信且透传 IP 合法时,才覆写 request()->ip() 的来源。
|
||||
$request->server->set('REMOTE_ADDR', $realIp);
|
||||
$request->headers->set('X-Forwarded-For', $realIp);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从可信代理头中解析真实客户端 IP。
|
||||
*/
|
||||
private function resolveTrustedClientIp(Request $request): ?string
|
||||
{
|
||||
$remoteAddress = (string) $request->server->get('REMOTE_ADDR', '');
|
||||
if (! $this->isTrustedProxy($remoteAddress)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['CF-Connecting-IP', 'EO-Client-IP', 'X-Real-IP'] as $headerName) {
|
||||
$resolvedIp = $this->sanitizeIp($request->header($headerName));
|
||||
if ($resolvedIp !== null) {
|
||||
return $resolvedIp;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extractForwardedForIp($request->header('X-Forwarded-For'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前请求是否来自受信代理节点。
|
||||
*/
|
||||
private function isTrustedProxy(string $remoteAddress): bool
|
||||
{
|
||||
if ($this->sanitizeIp($remoteAddress) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trustedProxies = config('app.trusted_proxies', ['127.0.0.1', '::1']);
|
||||
foreach ($trustedProxies as $trustedProxy) {
|
||||
$trustedProxy = trim((string) $trustedProxy);
|
||||
if ($trustedProxy !== '' && IpUtils::checkIp($remoteAddress, $trustedProxy)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 X-Forwarded-For 头中提取最左侧的合法 IP。
|
||||
*/
|
||||
private function extractForwardedForIp(?string $forwardedFor): ?string
|
||||
{
|
||||
if (! is_string($forwardedFor) || $forwardedFor === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (explode(',', $forwardedFor) as $candidateIp) {
|
||||
$resolvedIp = $this->sanitizeIp($candidateIp);
|
||||
if ($resolvedIp !== null) {
|
||||
return $resolvedIp;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并标准化 IP 文本。
|
||||
*/
|
||||
private function sanitizeIp(?string $ip): ?string
|
||||
{
|
||||
$normalizedIp = trim((string) $ip);
|
||||
|
||||
return filter_var($normalizedIp, FILTER_VALIDATE_IP) ? $normalizedIp : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,22 @@ namespace App\Providers;
|
||||
|
||||
use App\Listeners\SaveMarriageSystemMessage;
|
||||
use App\Models\Sysparam;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 类功能:注册应用级服务与全局安全配置。
|
||||
*/
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
* 注册应用级服务容器绑定。
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
@@ -27,10 +34,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
* 引导应用启动阶段的全局配置与事件订阅。
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// 注册登录入口限流器,阻断爆破和批量注册滥用。
|
||||
$this->registerAuthRateLimiters();
|
||||
|
||||
// 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史)
|
||||
Event::subscribe(SaveMarriageSystemMessage::class);
|
||||
|
||||
@@ -62,4 +72,49 @@ class AppServiceProvider extends ServiceProvider
|
||||
// 在安装初期表不存在时忽略,防止应用崩溃
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册聊天室前台登录与隐藏后台登录的独立限流器。
|
||||
*/
|
||||
private function registerAuthRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('chat-login', function (Request $request): Limit {
|
||||
return Limit::perMinute(5)
|
||||
->by($this->buildAuthRateLimitKey($request, 'chat-login'))
|
||||
->response(function (Request $request, array $headers) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '登录尝试过于频繁,请 1 分钟后再试。',
|
||||
], 429, $headers);
|
||||
});
|
||||
});
|
||||
|
||||
RateLimiter::for('admin-hidden-login', function (Request $request): Limit {
|
||||
return Limit::perMinute(5)
|
||||
->by($this->buildAuthRateLimitKey($request, 'admin-hidden-login'))
|
||||
->response(function (Request $request, array $headers) {
|
||||
$response = redirect()->route('admin.login')
|
||||
->withInput($request->except(['password', 'captcha']))
|
||||
->withErrors(['username' => '登录尝试过于频繁,请 1 分钟后再试。']);
|
||||
|
||||
foreach ($headers as $headerName => $headerValue) {
|
||||
$response->headers->set($headerName, $headerValue);
|
||||
}
|
||||
|
||||
$response->setStatusCode(429);
|
||||
|
||||
return $response;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造登录限流键,按场景 + 用户名 + IP 维度隔离计数。
|
||||
*/
|
||||
private function buildAuthRateLimitKey(Request $request, string $scene): string
|
||||
{
|
||||
$username = Str::lower(trim((string) $request->input('username', 'guest')));
|
||||
|
||||
return implode('|', [$scene, $username, $request->ip()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:注册 Horizon 面板的访问授权规则。
|
||||
*/
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\Horizon;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
/**
|
||||
* 类功能:注册 Horizon 面板访问门禁。
|
||||
*/
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
* 引导 Horizon 服务并加载父类默认注册逻辑。
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
@@ -21,14 +27,13 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
* 注册 Horizon 面板访问门禁。
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function ($user = null) {
|
||||
return $user && $user->user_level >= 15;
|
||||
// Horizon 属于高敏运维面板,仅站长账号允许进入,避免绕过后台主权限体系。
|
||||
return $user && (int) $user->id === 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -15,12 +15,17 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$trustedProxies = array_values(array_filter(array_map(
|
||||
static fn (string $proxy): string => trim($proxy),
|
||||
explode(',', (string) env('TRUSTED_PROXIES', '127.0.0.1,::1'))
|
||||
)));
|
||||
|
||||
// 强制解析并信任 CDN (如 Cloudflare) 透传的真实 IP (最高优先级)
|
||||
$middleware->prepend(\App\Http\Middleware\CloudflareProxies::class);
|
||||
|
||||
// 信任所有代理转发头(腾讯 EdgeCDN HTTPS 回源 HTTP 场景)
|
||||
// CDN 携带 X-Forwarded-Proto: https,Laravel 据此将请求识别为 HTTPS,url()/route() 生成正确的 https:// 链接
|
||||
$middleware->trustProxies(at: '*');
|
||||
// 仅信任显式配置的反向代理 / CDN 节点,避免外部客户端伪造转发头污染 request()->ip()。
|
||||
// 生产环境需要把实际代理 IP / CIDR 写入 TRUSTED_PROXIES。
|
||||
$middleware->trustProxies(at: empty($trustedProxies) ? null : $trustedProxies);
|
||||
|
||||
$middleware->alias([
|
||||
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
|
||||
|
||||
@@ -54,6 +54,21 @@ return [
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Trusted Proxies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 仅这些反向代理 / CDN 节点允许透传真实客户端 IP。
|
||||
| 生产环境请将此值配置为实际反向代理 IP 或 CIDR,多个值用逗号分隔。
|
||||
|
|
||||
*/
|
||||
|
||||
'trusted_proxies' => array_values(array_filter(array_map(
|
||||
static fn (string $proxy): string => trim($proxy),
|
||||
explode(',', (string) env('TRUSTED_PROXIES', '127.0.0.1,::1'))
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
||||
@@ -34,6 +34,25 @@
|
||||
* 用于展示任命公告、好友通知、红包选择等需要居中展示的大卡片。
|
||||
*/
|
||||
window.chatBanner = (function() {
|
||||
/**
|
||||
* 将任意文本转为 HTML 安全文本。
|
||||
*/
|
||||
function escapeBannerText(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将多行纯文本转为带 <br> 的安全 HTML。
|
||||
*/
|
||||
function renderMultilineText(text) {
|
||||
return escapeBannerText(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/** 注入入场/退场动画(全局只注入一次) */
|
||||
function ensureKeyframes() {
|
||||
if (document.getElementById('appoint-keyframes')) {
|
||||
@@ -89,7 +108,7 @@
|
||||
style="background:${btn.color || '#10b981'}; color:#fff; border:none; border-radius:8px;
|
||||
padding:8px 20px; font-size:13px; font-weight:bold; cursor:pointer;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.25);">
|
||||
${btn.label || '确定'}
|
||||
${escapeBannerText(btn.label || '确定')}
|
||||
</button>`;
|
||||
});
|
||||
buttonsHtml += '</div>';
|
||||
@@ -110,15 +129,15 @@
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);
|
||||
min-width: 260px;">
|
||||
${opts.icon ? `<div style="font-size:40px; margin-bottom:8px;">${opts.icon}</div>` : ''}
|
||||
${opts.icon ? `<div style="font-size:40px; margin-bottom:8px;">${escapeBannerText(opts.icon)}</div>` : ''}
|
||||
${opts.title ? `<div style="color:${titleColor}; font-size:13px; font-weight:bold; letter-spacing:3px; margin-bottom:12px;">
|
||||
══ ${opts.title} ══
|
||||
══ ${escapeBannerText(opts.title)} ══
|
||||
</div>` : ''}
|
||||
${opts.name ? `<div style="color:white; font-size:22px; font-weight:900; text-shadow:0 2px 8px rgba(0,0,0,0.3);">
|
||||
${escapeHtml(opts.name)}
|
||||
${escapeBannerText(opts.name)}
|
||||
</div>` : ''}
|
||||
${opts.body ? `<div style="color:rgba(255,255,255,0.9); font-size:14px; margin-top:10px;">${opts.body}</div>` : ''}
|
||||
${opts.sub ? `<div style="color:rgba(255,255,255,0.6); font-size:12px; margin-top:6px;">${opts.sub}</div>` : ''}
|
||||
${opts.body ? `<div style="color:rgba(255,255,255,0.9); font-size:14px; margin-top:10px;">${renderMultilineText(opts.body)}</div>` : ''}
|
||||
${opts.sub ? `<div style="color:rgba(255,255,255,0.6); font-size:12px; margin-top:6px;">${renderMultilineText(opts.sub)}</div>` : ''}
|
||||
${buttonsHtml}
|
||||
<div style="color:rgba(255,255,255,0.35); font-size:11px; margin-top:14px;">
|
||||
${new Date().toLocaleTimeString('zh-CN')}
|
||||
|
||||
+6
-2
@@ -32,10 +32,14 @@ Route::get('/', function () {
|
||||
|
||||
// 站长隐藏登录入口(仅 id=1 可使用,成功后直接进入后台控制台)
|
||||
Route::get('/lkddi', [AdminAuthController::class, 'create'])->name('admin.login');
|
||||
Route::post('/lkddi', [AdminAuthController::class, 'store'])->name('admin.login.store');
|
||||
Route::post('/lkddi', [AdminAuthController::class, 'store'])
|
||||
->middleware('throttle:admin-hidden-login')
|
||||
->name('admin.login.store');
|
||||
|
||||
// 处理登录/自动注册请求
|
||||
Route::post('/login', [AuthController::class, 'login'])->name('login.post');
|
||||
Route::post('/login', [AuthController::class, 'login'])
|
||||
->middleware('throttle:chat-login')
|
||||
->name('login.post');
|
||||
|
||||
// 处理退出登录
|
||||
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台认证控制器功能测试
|
||||
*
|
||||
* 覆盖登录即注册、老密码升级、封禁限制、退出登录、
|
||||
* session 轮换与服务端限流等关键认证行为。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Sysparam;
|
||||
@@ -7,14 +14,21 @@ use App\Models\User;
|
||||
use App\Models\UsernameBlacklist;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证前台认证链路的核心行为。
|
||||
*/
|
||||
class AuthControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 初始化验证码桩与基础系统参数。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -31,7 +45,37 @@ class AuthControllerTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_can_register_new_user()
|
||||
/**
|
||||
* 测试普通登录成功后会轮换 session id,阻断会话固定风险。
|
||||
*/
|
||||
public function test_login_regenerates_session_id(): void
|
||||
{
|
||||
Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'username' => 'session-user',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->startSession();
|
||||
$oldSessionId = session()->getId();
|
||||
|
||||
$response = $this->postJson('/login', [
|
||||
'username' => 'session-user',
|
||||
'password' => 'password123',
|
||||
'captcha' => '1234',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertNotSame($oldSessionId, session()->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证首次登录的用户会被自动注册并完成登录。
|
||||
*/
|
||||
public function test_can_register_new_user(): void
|
||||
{
|
||||
Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
|
||||
|
||||
@@ -54,7 +98,10 @@ class AuthControllerTest extends TestCase
|
||||
$this->assertAuthenticated();
|
||||
}
|
||||
|
||||
public function test_cannot_register_with_blacklisted_username()
|
||||
/**
|
||||
* 验证命中黑名单的用户名会被拒绝注册。
|
||||
*/
|
||||
public function test_cannot_register_with_blacklisted_username(): void
|
||||
{
|
||||
Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
|
||||
|
||||
@@ -79,7 +126,10 @@ class AuthControllerTest extends TestCase
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_can_login_existing_user()
|
||||
/**
|
||||
* 验证已有用户可以通过正确密码登录。
|
||||
*/
|
||||
public function test_can_login_existing_user(): void
|
||||
{
|
||||
Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
|
||||
|
||||
@@ -100,7 +150,10 @@ class AuthControllerTest extends TestCase
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_login_md5_user_upgrades_to_bcrypt()
|
||||
/**
|
||||
* 验证旧版 MD5 密码会在成功登录后升级为 bcrypt。
|
||||
*/
|
||||
public function test_login_md5_user_upgrades_to_bcrypt(): void
|
||||
{
|
||||
Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
|
||||
|
||||
@@ -132,7 +185,10 @@ class AuthControllerTest extends TestCase
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_banned_user_cannot_login()
|
||||
/**
|
||||
* 验证被封禁账号无法登录。
|
||||
*/
|
||||
public function test_banned_user_cannot_login(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => 'banneduser',
|
||||
@@ -152,7 +208,10 @@ class AuthControllerTest extends TestCase
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_banned_ip_cannot_login()
|
||||
/**
|
||||
* 验证被封禁 IP 无法登录普通账号。
|
||||
*/
|
||||
public function test_banned_ip_cannot_login(): void
|
||||
{
|
||||
Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(true);
|
||||
|
||||
@@ -171,7 +230,10 @@ class AuthControllerTest extends TestCase
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_can_logout()
|
||||
/**
|
||||
* 验证已登录用户可以正常退出。
|
||||
*/
|
||||
public function test_can_logout(): void
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create();
|
||||
@@ -181,4 +243,39 @@ class AuthControllerTest extends TestCase
|
||||
$response->assertRedirect('/');
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试前台登录接口在连续失败后会触发服务端限流。
|
||||
*/
|
||||
public function test_login_route_is_rate_limited_after_repeated_failures(): void
|
||||
{
|
||||
RateLimiter::clear('chat-login|rate-user|127.0.0.1');
|
||||
Redis::shouldReceive('sismember')->zeroOrMoreTimes()->andReturn(false);
|
||||
|
||||
User::factory()->create([
|
||||
'username' => 'rate-user',
|
||||
'password' => Hash::make('correct-password'),
|
||||
]);
|
||||
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
$response = $this->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
|
||||
->postJson('/login', [
|
||||
'username' => 'rate-user',
|
||||
'password' => 'wrong-password',
|
||||
'captcha' => '1234',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
$rateLimitedResponse = $this->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
|
||||
->postJson('/login', [
|
||||
'username' => 'rate-user',
|
||||
'password' => 'wrong-password',
|
||||
'captcha' => '1234',
|
||||
]);
|
||||
|
||||
$rateLimitedResponse->assertStatus(429)
|
||||
->assertJsonPath('status', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Tests\Feature\Feature;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -112,4 +113,37 @@ class AdminAuthControllerTest extends TestCase
|
||||
|
||||
$response->assertRedirect(route('admin.dashboard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试隐藏后台登录入口会在连续失败后触发服务端限流。
|
||||
*/
|
||||
public function test_hidden_admin_login_route_is_rate_limited_after_repeated_failures(): void
|
||||
{
|
||||
RateLimiter::clear('admin-hidden-login|site-owner|127.0.0.1');
|
||||
|
||||
User::factory()->create([
|
||||
'id' => 1,
|
||||
'username' => 'site-owner',
|
||||
'password' => Hash::make('correct-password'),
|
||||
]);
|
||||
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
$response = $this->from('/lkddi')->post('/lkddi', [
|
||||
'username' => 'site-owner',
|
||||
'password' => 'wrong-password',
|
||||
'captcha' => '1234',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/lkddi');
|
||||
}
|
||||
|
||||
$rateLimitedResponse = $this->from('/lkddi')->post('/lkddi', [
|
||||
'username' => 'site-owner',
|
||||
'password' => 'wrong-password',
|
||||
'captcha' => '1234',
|
||||
]);
|
||||
|
||||
$rateLimitedResponse->assertStatus(429);
|
||||
$rateLimitedResponse->assertSessionHasErrors('username');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:安全加固回归测试
|
||||
*
|
||||
* 覆盖可信代理 IP 解析、Banner 广播净化,以及 Horizon 访问门禁,
|
||||
* 确保本轮安全修复不会在后续迭代中退化。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\BannerNotification;
|
||||
use App\Http\Controllers\Admin\BannerBroadcastController;
|
||||
use App\Http\Middleware\CloudflareProxies;
|
||||
use App\Models\User;
|
||||
use App\Providers\HorizonServiceProvider;
|
||||
use Illuminate\Broadcasting\PendingBroadcast;
|
||||
use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证本轮安全修复的核心保护行为。
|
||||
*/
|
||||
class SecurityHardeningTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 验证只有来自可信代理的请求才允许采用透传客户端 IP。
|
||||
*/
|
||||
public function test_cloudflare_proxies_only_accepts_forwarded_ip_from_trusted_proxy(): void
|
||||
{
|
||||
config()->set('app.trusted_proxies', ['127.0.0.1', '::1']);
|
||||
$middleware = new CloudflareProxies;
|
||||
|
||||
$trustedRequest = Request::create('/rooms', 'GET', server: [
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
'HTTP_CF_CONNECTING_IP' => '203.0.113.10',
|
||||
]);
|
||||
|
||||
$middleware->handle($trustedRequest, fn () => response('ok'));
|
||||
$this->assertSame('203.0.113.10', $trustedRequest->server->get('REMOTE_ADDR'));
|
||||
|
||||
$untrustedRequest = Request::create('/rooms', 'GET', server: [
|
||||
'REMOTE_ADDR' => '198.51.100.25',
|
||||
'HTTP_CF_CONNECTING_IP' => '203.0.113.11',
|
||||
]);
|
||||
|
||||
$middleware->handle($untrustedRequest, fn () => response('ok'));
|
||||
$this->assertSame('198.51.100.25', $untrustedRequest->server->get('REMOTE_ADDR'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Banner 广播会将危险 HTML 净化为纯文本后再下发。
|
||||
*/
|
||||
public function test_banner_broadcast_sanitizes_user_controlled_html(): void
|
||||
{
|
||||
$fakeBroadcastFactory = new class
|
||||
{
|
||||
/**
|
||||
* 记录控制器最终广播出去的事件对象。
|
||||
*/
|
||||
public ?BannerNotification $capturedEvent = null;
|
||||
|
||||
/**
|
||||
* 截获 broadcast() helper 传入的事件实例。
|
||||
*/
|
||||
public function event($event): PendingBroadcast
|
||||
{
|
||||
$this->capturedEvent = $event;
|
||||
|
||||
return new class($event) extends PendingBroadcast
|
||||
{
|
||||
/**
|
||||
* 构造一个不会二次派发的占位 PendingBroadcast。
|
||||
*/
|
||||
public function __construct(mixed $event)
|
||||
{
|
||||
parent::__construct(app('events'), $event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试替身无需在析构时再次派发事件。
|
||||
*/
|
||||
public function __destruct() {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 满足广播工厂抽象依赖,当前测试不会走到该分支。
|
||||
*/
|
||||
public function connection($name = null): never
|
||||
{
|
||||
throw new \RuntimeException('SecurityHardeningTest 不应调用广播连接。');
|
||||
}
|
||||
};
|
||||
$this->app->instance(BroadcastFactory::class, $fakeBroadcastFactory);
|
||||
|
||||
$request = Request::create('/admin/banner/broadcast', 'POST', [
|
||||
'target' => 'room',
|
||||
'target_id' => 1,
|
||||
'options' => [
|
||||
'title' => '<span onclick="alert(1)">危险标题</span>',
|
||||
'name' => '<b>管理员</b>',
|
||||
'body' => '<span onclick="alert(1)">危险正文</span>',
|
||||
'sub' => '<img src=x onerror=alert(1)>副标题',
|
||||
'titleColor' => 'url(javascript:1)',
|
||||
'gradient' => ['#123456', 'rgba(1,2,3,0.5)', 'url(javascript:alert(1))'],
|
||||
'buttons' => [
|
||||
[
|
||||
'label' => '<b>立即处理</b>',
|
||||
'color' => 'rgba(16,185,129,1)',
|
||||
'action' => 'close',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
|
||||
$response = app(BannerBroadcastController::class)->send($request);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame('success', $response->getData(true)['status']);
|
||||
$this->assertInstanceOf(BannerNotification::class, $fakeBroadcastFactory->capturedEvent);
|
||||
$this->assertSame('危险标题', $fakeBroadcastFactory->capturedEvent->options['title']);
|
||||
$this->assertSame('管理员', $fakeBroadcastFactory->capturedEvent->options['name']);
|
||||
$this->assertSame('危险正文', $fakeBroadcastFactory->capturedEvent->options['body']);
|
||||
$this->assertSame('副标题', $fakeBroadcastFactory->capturedEvent->options['sub']);
|
||||
$this->assertStringNotContainsString('"', $fakeBroadcastFactory->capturedEvent->options['titleColor']);
|
||||
$this->assertStringNotContainsString('javascript', $fakeBroadcastFactory->capturedEvent->options['gradient'][2]);
|
||||
$this->assertSame('立即处理', $fakeBroadcastFactory->capturedEvent->options['buttons'][0]['label']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Horizon 面板仅允许站长账号访问。
|
||||
*/
|
||||
public function test_horizon_gate_only_allows_site_owner(): void
|
||||
{
|
||||
$provider = new class($this->app) extends HorizonServiceProvider
|
||||
{
|
||||
/**
|
||||
* 显式注册测试所需的 Horizon gate。
|
||||
*/
|
||||
public function registerTestGate(): void
|
||||
{
|
||||
$this->gate();
|
||||
}
|
||||
};
|
||||
$provider->registerTestGate();
|
||||
|
||||
$siteOwner = new User([
|
||||
'username' => 'site-owner',
|
||||
'user_level' => 1,
|
||||
]);
|
||||
$siteOwner->id = 1;
|
||||
|
||||
$revokedManager = new User([
|
||||
'username' => 'revoked-manager',
|
||||
'user_level' => 100,
|
||||
]);
|
||||
|
||||
$this->assertTrue(Gate::forUser($siteOwner)->allows('viewHorizon'));
|
||||
$this->assertFalse(Gate::forUser($revokedManager)->allows('viewHorizon'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证前台登录入口在命中限流后会直接返回 429。
|
||||
*/
|
||||
public function test_chat_login_route_returns_429_when_rate_limited(): void
|
||||
{
|
||||
$rateLimitKey = md5('chat-login'.'chat-login|testuser|127.0.0.1');
|
||||
RateLimiter::clear($rateLimitKey);
|
||||
|
||||
foreach (range(1, 5) as $attempt) {
|
||||
RateLimiter::hit($rateLimitKey, 60);
|
||||
}
|
||||
|
||||
$response = $this->withoutMiddleware(ValidateCsrfToken::class)
|
||||
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
|
||||
->postJson('/login', [
|
||||
'username' => 'TestUser',
|
||||
'password' => 'secret',
|
||||
'captcha' => 'invalid',
|
||||
]);
|
||||
|
||||
$response->assertStatus(429)
|
||||
->assertJsonPath('status', 'error');
|
||||
|
||||
RateLimiter::clear($rateLimitKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证隐藏后台登录入口在命中限流后会直接拒绝请求。
|
||||
*/
|
||||
public function test_hidden_admin_login_route_returns_429_when_rate_limited(): void
|
||||
{
|
||||
$rateLimitKey = md5('admin-hidden-login'.'admin-hidden-login|site-owner|127.0.0.1');
|
||||
RateLimiter::clear($rateLimitKey);
|
||||
|
||||
foreach (range(1, 5) as $attempt) {
|
||||
RateLimiter::hit($rateLimitKey, 60);
|
||||
}
|
||||
|
||||
$response = $this->withoutMiddleware(ValidateCsrfToken::class)
|
||||
->from('/lkddi')
|
||||
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
|
||||
->post('/lkddi', [
|
||||
'username' => 'site-owner',
|
||||
'password' => 'secret',
|
||||
'captcha' => 'invalid',
|
||||
]);
|
||||
|
||||
$response->assertStatus(429);
|
||||
$this->assertStringContainsString('/lkddi', $response->headers->get('Location', ''));
|
||||
|
||||
RateLimiter::clear($rateLimitKey);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user