Skip to content

5.5 跨域资源共享(CORS) ​

概述

CorsMiddleware 处理跨域请求,支持通过 .env 动态配置允许的来源域名。配置使用逗号分隔的字符串,中间件将其 explode 为数组后做白名单匹配。开发环境可设为 * 允许所有来源,生产环境应指定具体域名。

CORS 工作原理 ​

什么是跨域 ​

浏览器的同源策略限制了从一个源(协议+域名+端口)去请求另一个源的资源。当源不同时,就产生了跨域。

text
同源请求(允许):
  页面:https://admin.example.com/page
  API: https://admin.example.com/api/user
  → 协议+域名+端口相同,同源

跨域请求(被浏览器阻止):
  页面:https://admin.example.com/page
  API: https://api.example.com/user
  → 域名不同,跨域

  页面:http://admin.example.com/page
  API: https://admin.example.com/api/user
  → 协议不同,跨域

  页面:https://admin.example.com:80/page
  API: https://admin.example.com:8080/api/user
  → 端口不同,跨域

简单请求 vs 预检请求 ​

text
简单请求(Simple Request):
  满足以下条件的 GET/POST/HEAD 请求:
  - Content-Type 为 text/plain、multipart/form-data、application/x-www-form-urlencoded
  - 无自定义请求头
  → 浏览器直接发送请求,附带 Origin 头
  → 服务端返回 CORS 头,浏览器判断是否允许

预检请求(Preflight Request):
  不满足简单请求条件时(如 PUT/DELETE、JSON 请求体、自定义头):
  → 浏览器先发送 OPTIONS 请求询问服务端
  → 服务端返回允许的方法和头
  → 浏览器再发送实际请求

完整交互流程 ​

text
┌──────────┐                              ┌──────────┐
│  浏览器   │                              │  服务器   │
│ (前端)   │                              │ (后端)   │
└────┬─────┘                              └────┬─────┘
     │                                         │
     │  ─── 1. OPTIONS 预检请求 ──────────────►│
     │      Origin: https://admin.example.com  │
     │      Access-Control-Request-Method: PUT │
     │      Access-Control-Request-Headers:    │
     │        Content-Type, Authorization      │
     │                                         │
     │                              ┌──────────┴──────────┐
     │                              │ CorsMiddleware       │
     │                              │ 1. 检查 Origin 白名单│
     │                              │ 2. 返回 CORS 头      │
     │                              │ 3. 返回 204 空响应   │
     │                              └──────────┬──────────┘
     │                                         │
     │  ◄── 2. 预检响应 ─────────────────────│
     │      204 No Content                     │
     │      Access-Control-Allow-Origin:       │
     │        https://admin.example.com        │
     │      Access-Control-Allow-Methods:      │
     │        GET, POST, PUT, DELETE           │
     │      Access-Control-Allow-Headers:      │
     │        Content-Type, Authorization      │
     │      Access-Control-Max-Age: 3600       │
     │                                         │
     │  ─── 3. 实际请求 ─────────────────────►│
     │      PUT /api/user/update               │
     │      Origin: https://admin.example.com  │
     │      Authorization: Bearer <token>      │
     │      Content-Type: application/json     │
     │                                         │
     │                              ┌──────────┴──────────┐
     │                              │ 中间件链 → 控制器    │
     │                              └──────────┬──────────┘
     │                                         │
     │  ◄── 4. 实际响应 ─────────────────────│
     │      200 OK                             │
     │      Access-Control-Allow-Origin:       │
     │        https://admin.example.com        │
     │      { "code": 0, "data": {...} }       │
     │                                         │

配置方式 ​

环境变量 ​

ini
; .env

; 开发环境:允许所有来源
[CORS]
ALLOW_ORIGIN = *

; 生产环境:指定具体域名(多个用逗号分隔)
[CORS]
ALLOW_ORIGIN = https://admin.example.com,https://www.example.com

; 多域名示例
[CORS]
ALLOW_ORIGIN = https://admin.example.com,https://m.example.com,https://app.example.com

配置文件 ​

php
// config/cors.php
return [
    // 允许的来源域名,逗号分隔,* 表示允许所有
    // 注意:配置含 * 时中间件会回显请求的 Origin,而非直接返回通配符,
    // 以兼容携带凭证时浏览器不允许 Allow-Origin 为 * 的限制
    'allow_origin'      => explode(',', env('cors.allow_origin', '*')),

    // 允许的请求方法
    'allow_methods'     => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',

    // 允许的请求头
    'allow_headers'     => 'Content-Type, Authorization, X-Requested-With, Accept, Origin, Token',

    // 暴露给前端的响应头,便于前端读取
    'expose_headers'    => 'Authorization',

    // 预检请求(OPTIONS)结果的缓存时间,单位秒
    'max_age'           => 3600,

    // 是否允许携带凭证(Cookie 等)
    'allow_credentials' => true,
];

配置项说明 ​

配置项说明默认值生产建议
allow_origin允许的来源域名*指定具体域名
allow_methods允许的 HTTP 方法GET, POST, PUT, DELETE, PATCH, OPTIONS按需精简
allow_headers允许的请求头见配置文件按需精简
expose_headers暴露给前端的响应头Authorization按需添加
max_age预检缓存时间(秒)3600可适当增大
allow_credentials是否允许携带凭证true前端使用 Cookie 时必须为 true

中间件代码 ​

php
// app/middleware/CorsMiddleware.php

public function handle(Request $request, \Closure $next): Response
{
    $origin = $request->header('Origin', '');
    $config = config('cors');
    $allowed = $config['allow_origin'] ?? ['*'];

    // 判断 origin 是否允许:白名单匹配
    if (in_array('*', $allowed)) {
        // 配置含 * 时,回显请求 Origin(无 Origin 则用 *)
        $allowOrigin = $origin ?: '*';
    } else {
        // 仅在白名单内才回显 Origin,不在白名单则置空
        $allowOrigin = in_array($origin, $allowed) ? $origin : '';
    }

    $header = [
        'Access-Control-Allow-Origin'      => $allowOrigin,
        'Access-Control-Allow-Credentials' => $config['allow_credentials'] ? 'true' : 'false',
        'Access-Control-Allow-Methods'     => $config['allow_methods'],
        'Access-Control-Allow-Headers'     => $config['allow_headers'],
        'Access-Control-Expose-Headers'    => $config['expose_headers'],
        'Access-Control-Max-Age'           => (string)$config['max_age'],
    ];

    // OPTIONS 预检请求直接返回 204
    if ($request->isOptions()) {
        return response('', 204, $header);
    }

    $response = $next($request);

    // 添加跨域头
    $response->header($header);

    return $response;
}

关键实现细节 ​

Origin 白名单匹配 ​

配置通过 explode(',', env(...)) 将逗号分隔的字符串转为数组,中间件使用 in_array() 进行精确匹配:

text
请求 Origin: https://admin.example.com
白名单数组: ['https://admin.example.com', 'https://www.example.com']
→ in_array() 匹配成功,回显 Origin ✓

请求 Origin: https://evil.com
白名单数组: ['https://admin.example.com', 'https://www.example.com']
→ in_array() 匹配失败,$allowOrigin = ''  ✗
→ 浏览器收到空的 Allow-Origin,阻止前端读取响应

通配符 * 的特殊处理 ​

当配置为 * 时,explode(',', '*') 返回 ['*']。中间件检测到 in_array('*', $allowed) 后,会回显请求的 Origin 而非直接返回 *,这是为了兼容浏览器在携带凭证(Cookie)时不允许 Access-Control-Allow-Origin: * 的限制。

text
配置: ALLOW_ORIGIN = *
请求 Origin: https://admin.example.com

中间件行为:
  in_array('*', ['*']) → true
  $allowOrigin = $origin(回显 'https://admin.example.com')

响应头:
  Access-Control-Allow-Origin: https://admin.example.com  ✓
  Access-Control-Allow-Credentials: true                   ✓

如果直接返回 *:
  Access-Control-Allow-Origin: *                           ✗
  Access-Control-Allow-Credentials: true                   ✗
  → 浏览器报错:Allow-Origin 为 * 时不能携带凭证

OPTIONS 预检请求 ​

浏览器在跨域请求前会先发送 OPTIONS 预检请求,中间件通过 $request->isOptions() 判断后,直接返回 response('', 204, $header) 空响应,避免进入业务逻辑。

text
OPTIONS /api/user/update HTTP/1.1
Origin: https://admin.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

→ 中间件拦截,返回 204 + CORS 头
→ 不进入 AuthMiddleware / Controller

与其他中间件的协作 ​

text
请求进入
    │
    ▼
CorsMiddleware(全局,第一个执行)
    │ OPTIONS → 直接返回 204 + CORS 头
    │ 普通请求 → 设置 CORS 头,继续
    ▼
AuthMiddleware
    │ 校验 JWT + 权限
    ▼
TenantMiddleware
    │ 注入租户上下文
    ▼
Controller

执行顺序

CorsMiddleware 必须是第一个执行的中间件,确保 OPTIONS 预检请求在认证之前就被处理,否则预检请求会因缺少 Token 而被 AuthMiddleware 拒绝。

常见问题排查 ​

问题 1:No 'Access-Control-Allow-Origin' header ​

原因: 请求的 Origin 不在白名单中。

解决:

ini
# 检查 .env 中的 ALLOW_ORIGIN 是否包含前端的域名
[CORS]
ALLOW_ORIGIN = https://admin.example.com

问题 2:Allow-Origin 为 * 时携带凭证失败 ​

原因: 浏览器规定 Access-Control-Allow-Origin: * 时不能携带 Cookie。

解决: 中间件已自动处理——配置为 * 时回显请求 Origin 而非返回通配符。如果仍报错,检查前端是否设置了 withCredentials: true。

问题 3:预检请求返回 401/403 ​

原因: OPTIONS 请求被 AuthMiddleware 拦截。

解决: 确保 CorsMiddleware 在 AuthMiddleware 之前执行。检查中间件注册顺序:

php
// app/middleware.php(全局中间件)
return [
    \app\middleware\CorsMiddleware::class,  // 必须在最前面
];

问题 4:自定义请求头被拒绝 ​

原因: 请求头不在 allow_headers 列表中。

解决:

php
// config/cors.php
'allow_headers' => 'Content-Type, Authorization, X-Requested-With, Accept, Origin, Token, X-Custom-Header',

问题 5:前端无法读取自定义响应头 ​

原因: 响应头不在 expose_headers 列表中。

解决:

php
// config/cors.php
'expose_headers' => 'Authorization, X-Total-Count, X-Page-Count',

环境配置建议 ​

开发环境 ​

ini
[CORS]
ALLOW_ORIGIN = *

允许所有来源,方便前后端分离开发(前端 localhost:3000,后端 localhost:8080)。

测试环境 ​

ini
[CORS]
ALLOW_ORIGIN = https://test.admin.example.com

指定测试域名,提前验证跨域配置。

生产环境 ​

ini
[CORS]
ALLOW_ORIGIN = https://admin.example.com,https://www.example.com

只允许正式域名,不使用 *。

CORS 响应头速查 ​

响应头说明示例值
Access-Control-Allow-Origin允许的来源https://admin.example.com
Access-Control-Allow-Methods允许的方法GET, POST, PUT, DELETE
Access-Control-Allow-Headers允许的请求头Content-Type, Authorization
Access-Control-Expose-Headers暴露的响应头Authorization
Access-Control-Max-Age预检缓存时间3600
Access-Control-Allow-Credentials是否允许凭证true

小蚂蚁云团队 · 提供技术支持