Laravel安全代理适配方案


在一些特殊的部署环境中,laravel的安全代理机制会造成代理无法正常访问,比如通过前置代理服务器部署访问入口为https://domain.com:10020,然后nginx代理到另一个内网的laravel上,这时候laravel中输出的资源路径都是内网主机的地址,而不是入口地址,资源默认都是全路径而不是相对路径,这与vue3前端的构建方式不一样。

为了安全的解决这个适配问题,建议采用以下方案:

前置代理配置

server {
    listen 10020 ssl;
    server_name domain.com;
    
    # SSL 配置
    ssl_certificate /www/ssl/domain.com.pem;
    ssl_certificate_key /www/ssl/domain.com.key;
    ssl_protocols TLSv1.1 TLSv1.2;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_pass http://172.19.205.1:80;  #这里是laravel的地址

        # 传递原始请求信息
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;      # 关键:协议,这个要设置成固定的,不用从$scheme读取
        proxy_set_header X-Forwarded-Host $host;       # 关键:域名
        proxy_set_header X-Forwarded-Port $server_port; # 关键:端口
        proxy_set_header X-Forwarded-Ssl on;           # 辅助 SSL 标识
        proxy_set_header Front-End-Https on;           # 另一种辅助标识

        # 超时设置
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

准备阶段

创建一个routes/web.php或修改:

// 仅在调试模式下有效的调试代理路由
if (config('app.debug')) {
    Route::get('/debug-proxy', function (Request $request) {
        // 获取直接连接到服务器的客户端 IP
        $serverRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown';

        // 获取通过 Laravel 方法获取的客户端 IP
        $laravelClientIp = $request->getClientIp();

        // 获取所有相关的请求头
        $relevantHeaders = [
            'x-real-ip'         => $request->header('x-real-ip'),
            'x-forwarded-for'   => $request->header('x-forwarded-for'),
            'forwarded'         => $request->header('forwarded'),
            'forwarded-for'     => $request->header('forwarded-for'),
            'x-forwarded-host'  => $request->header('x-forwarded-host'),
            'x-forwarded-proto' => $request->header('x-forwarded-proto'),
            'x-forwarded-port'  => $request->header('x-forwarded-port'),
            'x-forwarded-ssl'   => $request->header('x-forwarded-ssl'), 
            'front-end-https'   => $request->header('front-end-https'), 
        ];

        return response()->json([
            'server_remote_addr' => $serverRemoteAddr,
            'laravel_client_ip'  => $laravelClientIp,
            'relevant_headers'   => $relevantHeaders,
            'all_headers'        => $request->headers->all(),
            'trusted_proxies'    => $_ENV['TRUSTED_PROXIES'] ?? 'not set',
        ]);
    });
}

通过这个方式来看laravel之前的nginx有没有把头部信息正常传过来,有可能有两个nginx,一个是前置代理服务器,一个是laravel主机的nginx(这个地方我一开始用的caddy,会自动把非正常的https信息给改回http)。

.env配置

# 受信任的代理服务器列表,用于处理转发头信息
# '*' 表示信任所有代理(开发环境)
# 在生产环境中应该指定具体的IP地址或CIDR范围以提高安全性
# 通过/debug-proxy来获取server_remote_addr,并将其设置为可信代理
# 示例: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_PROXIES=127.0.0.1
# TRUSTED_PROXIES=*

TRUSTED_PROXIES这个设置开发环境下可以设置为所有地址,即*,正式环境下最好为精确的前置代理的地址,也就是准备阶段获取到的server_remote_addr的值。

TrustProxies 中间件配置

创建一个app\Http\Middleware\TrustProxies.php

<?php
namespace App\Http\Middleware;

use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;

class TrustProxies extends Middleware
{
    /**
     * 可信代理列表
     *
     * @var array<int, string>|string|null
     */
    protected $proxies;
    
    /**
     * 用于检测代理的头部信息
     *
     * @var int
     */
    protected $headers =
        Request::HEADER_X_FORWARDED_FOR |
        Request::HEADER_X_FORWARDED_HOST |
        Request::HEADER_X_FORWARDED_PORT |
        Request::HEADER_X_FORWARDED_PROTO |
        Request::HEADER_X_FORWARDED_PREFIX |
        Request::HEADER_X_FORWARDED_AWS_ELB;

    /**
     * 构造函数
     *
     * @return void
     */
    public function __construct()
    {
        // 从 .env 文件中读取可信代理配置
        $this->proxies = env('TRUSTED_PROXIES', '*');
    }
}

启用TrustProxies

在bootstrap\app.php中增加这个中间件:

->withMiddleware(function (Middleware $middleware) {

$middleware->web(append: [

。。。其他中间件

\App\Http\Middleware\TrustProxies::class,

。。。其他中间件

]);

到这里laravel自身的适配就完成了,但如果还用了swagger-ui,还要让swaager-ui适配一下。

L5SwaggerExtendedServiceProvider

创建一个app\Providers\L5SwaggerExtendedServiceProvider.php

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Blade;

class L5SwaggerExtendedServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap services.
     */
    public function boot(): void
    {
        // 注册自定义 Blade 指令来处理带端口和协议的 Swagger 资源
        Blade::directive('l5_swagger_secure_asset', function ($expression) {
            return "<?php
                // 获取原始资产URL
                \$originalUrl = l5_swagger_asset($expression);
                
                // 获取转发相关信息
                \$forwardedPort = \$_SERVER['HTTP_X_FORWARDED_PORT'] ?? null;
                \$forwardedProto = \$_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null;
                \$forwardedHost = \$_SERVER['HTTP_X_FORWARDED_HOST'] ?? null;
                
                // 解析原始URL
                \$parsedUrl = parse_url(\$originalUrl);
                \$scheme = \$parsedUrl['scheme'] ?? 'http';
                \$host = \$parsedUrl['host'] ?? '';
                \$port = \$parsedUrl['port'] ?? null;
                \$path = \$parsedUrl['path'] ?? '';
                \$query = isset(\$parsedUrl['query']) ? '?' . \$parsedUrl['query'] : '';
                
                // 如果原始URL已经有端口,直接返回
                if (\$port) {
                    echo \$originalUrl;
                    return;
                }
                
                // 确定协议
                if (\$forwardedProto) {
                    \$scheme = \$forwardedProto;
                }
                
                // 确定主机
                if (\$forwardedHost) {
                    \$host = \$forwardedHost;
                }
                
                // 确定端口
                if (\$forwardedPort) {
                    \$port = \$forwardedPort;
                } elseif (\$scheme === 'https' && !isset(\$parsedUrl['port'])) {
                    // HTTPS 默认端口443,通常不显示
                    \$port = null;
                } elseif (\$scheme === 'http' && !isset(\$parsedUrl['port'])) {
                    // HTTP 默认端口80,通常不显示
                    \$port = null;
                }
                
                // 构造新URL
                \$newUrl = \$scheme . '://' . \$host;
                if (\$port && ((\$scheme === 'https' && \$port != 443) || (\$scheme === 'http' && \$port != 80))) {
                    \$newUrl .= ':' . \$port;
                }
                \$newUrl .= \$path . \$query;
                
                echo \$newUrl;
            ?>";
        });
    }
}

注册

在bootstrap\providers.php中添加

return [
    。。。其他
    App\Providers\L5SwaggerExtendedServiceProvider::class,
];

修改视图

resources\views\vendor\l5-swagger\index.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{{config('l5-swagger.documentations.'.$documentation.'.api.title')}}</title>
    <link rel="stylesheet" type="text/css" href="@l5_swagger_secure_asset($documentation, 'swagger-ui.css')">
    <link rel="icon" type="image/png" href="@l5_swagger_secure_asset($documentation, 'favicon-32x32.png')" sizes="32x32"/>
    <link rel="icon" type="image/png" href="@l5_swagger_secure_asset($documentation, 'favicon-16x16.png')" sizes="16x16"/>
    <style>
    html
    {
        box-sizing: border-box;
        overflow: -moz-scrollbars-vertical;
        overflow-y: scroll;
    }
    *,
    *:before,
    *:after
    {
        box-sizing: inherit;
    }

    body {
      margin:0;
      background: #fafafa;
    }
    </style>
    @if(config('l5-swagger.defaults.ui.display.dark_mode'))
        <style>
            body#dark-mode,
            #dark-mode .scheme-container {
                background: #1b1b1b;
            }
            #dark-mode .scheme-container,
            #dark-mode .opblock .opblock-section-header{
                box-shadow: 0 1px 2px 0 rgba(255, 255, 255, 0.15);
            }
            #dark-mode .operation-filter-input,
            #dark-mode .dialog-ux .modal-ux,
            #dark-mode input[type=email],
            #dark-mode input[type=file],
            #dark-mode input[type=password],
            #dark-mode input[type=search],
            #dark-mode input[type=text],
            #dark-mode textarea{
                background: #343434;
                color: #e7e7e7;
            }
            #dark-mode .title,
            #dark-mode li,
            #dark-mode p,
            #dark-mode table,
            #dark-mode label,
            #dark-mode .opblock-tag,
            #dark-mode .opblock .opblock-summary-operation-id,
            #dark-mode .opblock .opblock-summary-path,
            #dark-mode .opblock .opblock-summary-path__deprecated,
            #dark-mode h1,
            #dark-mode h2,
            #dark-mode h3,
            #dark-mode h4,
            #dark-mode h5,
            #dark-mode .btn,
            #dark-mode .tab li,
            #dark-mode .parameter__name,
            #dark-mode .parameter__type,
            #dark-mode .prop-format,
            #dark-mode .loading-container .loading:after{
                color: #e7e7e7;
            }
            #dark-mode .opblock-description-wrapper p,
            #dark-mode .opblock-external-docs-wrapper p,
            #dark-mode .opblock-title_normal p,
            #dark-mode .response-col_status,
            #dark-mode table thead tr td,
            #dark-mode table thead tr th,
            #dark-mode .response-col_links,
            #dark-mode .swagger-ui{
                color: wheat;
            }
            #dark-mode .parameter__extension,
            #dark-mode .parameter__in,
            #dark-mode .model-title{
                color: #949494;
            }
            #dark-mode table thead tr td,
            #dark-mode table thead tr th{
                border-color: rgba(120,120,120,.2);
            }
            #dark-mode .opblock .opblock-section-header{
                background: transparent;
            }
            #dark-mode .opblock.opblock-post{
                background: rgba(73,204,144,.25);
            }
            #dark-mode .opblock.opblock-get{
                background: rgba(97,175,254,.25);
            }
            #dark-mode .opblock.opblock-put{
                background: rgba(252,161,48,.25);
            }
            #dark-mode .opblock.opblock-delete{
                background: rgba(249,62,62,.25);
            }
            #dark-mode .loading-container .loading:before{
                border-color: rgba(255,255,255,10%);
                border-top-color: rgba(255,255,255,.6);
            }
            #dark-mode svg:not(:root){
                fill: #e7e7e7;
            }
        </style>
    @endif
</head>

<body @if(config('l5-swagger.defaults.ui.display.dark_mode')) id="dark-mode" @endif>
<div id="swagger-ui"></div>

<script src="@l5_swagger_secure_asset($documentation, 'swagger-ui-bundle.js')"></script>
<script src="@l5_swagger_secure_asset($documentation, 'swagger-ui-standalone-preset.js')"></script>
<script>
    window.onload = function() {
        // 确保 API 文档 URL 包含正确的协议、主机和端口
        let urlToDocs = "{!! $urlToDocs !!}";
        
        // 获取转发信息
        const forwardedPort = "{{ request()->header('x-forwarded-port') }}";
        const forwardedProto = "{{ request()->header('x-forwarded-proto') }}";
        const forwardedHost = "{{ request()->header('x-forwarded-host') }}";
        const hostHeader = "{{ request()->header('host') }}";
        
        try {
            // 解析原始 URL
            const url = new URL(urlToDocs);
            
            // 设置协议
            if (forwardedProto) {
                url.protocol = forwardedProto + ':';
            }
            
            // 设置主机
            if (forwardedHost) {
                // 如果转发主机包含端口,分离主机和端口
                if (forwardedHost.includes(':')) {
                    const parts = forwardedHost.split(':');
                    url.hostname = parts[0];
                    if (parts[1]) {
                        url.port = parts[1];
                    }
                } else {
                    url.hostname = forwardedHost;
                }
            }
            
            // 设置端口(如果明确提供了转发端口)
            if (forwardedPort) {
                url.port = forwardedPort;
            } else if (forwardedProto === 'https' && !forwardedPort) {
                // HTTPS 默认端口处理
                if (url.port === '80' || url.port === '') {
                    url.port = '';
                }
            } else if (forwardedProto === 'http' && !forwardedPort) {
                // HTTP 默认端口处理
                if (url.port === '443' || url.port === '') {
                    url.port = '';
                }
            }
            
            urlToDocs = url.toString();
        } catch (e) {
            console.error('Failed to parse URL:', e);
        }

        // Build a system
        const ui = SwaggerUIBundle({
            dom_id: '#swagger-ui',
            url: urlToDocs,
            operationsSorter: {!! isset($operationsSorter) ? '"' . $operationsSorter . '"' : 'null' !!},
            configUrl: {!! isset($configUrl) ? '"' . $configUrl . '"' : 'null' !!},
            validatorUrl: {!! isset($validatorUrl) ? '"' . $validatorUrl . '"' : 'null' !!},
            oauth2RedirectUrl: "{{ route('l5-swagger.'.$documentation.'.oauth2_callback', [], $useAbsolutePath) }}",

            requestInterceptor: function(request) {
                request.headers['X-CSRF-TOKEN'] = '{{ csrf_token() }}';
                return request;
            },

            presets: [
                SwaggerUIBundle.presets.apis,
                SwaggerUIStandalonePreset
            ],

            plugins: [
                SwaggerUIBundle.plugins.DownloadUrl
            ],

            layout: "StandaloneLayout",
            docExpansion : "{!! config('l5-swagger.defaults.ui.display.doc_expansion', 'none') !!}",
            deepLinking: true,
            filter: {!! config('l5-swagger.defaults.ui.display.filter') ? 'true' : 'false' !!},
            persistAuthorization: "{!! config('l5-swagger.defaults.ui.authorization.persist_authorization') ? 'true' : 'false' !!}",

        })

        window.ui = ui

        @if(in_array('oauth2', array_column(config('l5-swagger.defaults.securityDefinitions.securitySchemes'), 'type')))
        ui.initOAuth({
            usePkceWithAuthorizationCodeGrant: "{!! (bool)config('l5-swagger.defaults.ui.authorization.oauth2.use_pkce_with_authorization_code_grant') !!}"
        })
        @endif
    }
</script>
</body>
</html>

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

探索未来出版

九录科技愿意通过最前沿的技术和深厚的行业理解,为您的数字业务提供架构简单但很灵活的从创作到发布的全方位支持。

本站内容部分由AI生成,仅供参考,具体业务可随时电话/微信咨询(18610359982)。