diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d072d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea +/vendor +/composer.lock \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc7b685 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# Think-Cors + +## 代码引用 +- [asm89/stack-cors](https://github.com/asm89/stack-cors) \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..efbf6d6 --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "nhzex/think-cors", + "type": "library", + "description": "", + "keywords": [ + "thinkphp6", + "thinkphp", + "cors" + ], + "license": "Apache-2.0", + "authors": [ + { + "name": "auooru", + "email": "auooru@outlook.com" + } + ], + "require": { + "php": "^7.1", + "topthink/framework": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^8|^7|^9", + "symfony/var-dumper": "^5.0|^4.0" + }, + "autoload": { + "psr-4": { + "HZEX\\Think\\Cors\\": "src" + }, + "files": [ + "src/helper.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Test\\Think\\": "tests" + } + }, + "extra": { + "think": { + "services": [ + ], + "config": { + "cors": "config/cors.php" + } + } + }, + "config": { + "sort-packages": true + } +} diff --git a/config/cros.php b/config/cros.php new file mode 100644 index 0000000..d0a4e5e --- /dev/null +++ b/config/cros.php @@ -0,0 +1,17 @@ + ['*'], + + 'allowed_origins_patterns' => [], + + 'allowed_methods' => ['*'], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => false, +]; diff --git a/src/CorsMiddleware.php b/src/CorsMiddleware.php new file mode 100644 index 0000000..f3a04d5 --- /dev/null +++ b/src/CorsMiddleware.php @@ -0,0 +1,59 @@ +get('cros', []); + + $this->cors = new CorsService( + $conf['allowed_origins'] ?? [], + $conf['allowed_origins_patterns'] ?? [], + $conf['allowed_methods'] ?? [], + $conf['allowed_headers'] ?? [], + $conf['exposed_headers'] ?? [], + $conf['supports_credentials'] ?? false, + $conf['max_age'] ?? 0 + ); + } + + /** + * 允许跨域请求 + * @access public + * @param Request $request + * @param Closure $next + * @return Response + */ + public function handle(Request $request, Closure $next): Response + { + if ($this->cors->isPreflightRequest($request)) { + return $this->cors->handlePreflightRequest($request); + } + + if (!$this->cors->isRequestAllowed($request)) { + return Response::create('Not allowed in CORS policy.', 'html', 403); + } + + /** @var Response $response */ + $response = $next($request); + + if ($this->cors->isCorsRequest($request)) { + $this->cors->addRequestHeaders($response, $request); + } + + return $response; + } +} diff --git a/src/CorsService.php b/src/CorsService.php new file mode 100644 index 0000000..d5ea6a2 --- /dev/null +++ b/src/CorsService.php @@ -0,0 +1,343 @@ +supportsCredentials = $supportsCredentials; + + $allowedOrigins = in_array('*', $allowedOrigins) ? true : $allowedOrigins; + if (is_array($allowedOrigins)) { + foreach ($allowedOrigins as $allowedOrigin) { + $allowedOrigin = rtrim($allowedOrigin, '/'); + if (str_starts_with($allowedOrigin, '//')) { + $this->allowedOrigins[] = "http:{$allowedOrigin}"; + $this->allowedOrigins[] = "https:{$allowedOrigin}"; + continue; + } + if (!str_starts_with($allowedOrigin, 'http')) { + $this->allowedOrigins[] = "http://{$allowedOrigin}"; + $this->allowedOrigins[] = "https://{$allowedOrigin}"; + continue; + } + $this->allowedOrigins[] = $allowedOrigin; + } + } else { + $this->allowedOrigins = $allowedOrigins; + } + + $this->allowedOriginsPatterns = $allowedOriginsPatterns; + + $this->allowedMethods = in_array('*', $allowedMethods) + ? true + : array_map('\strtoupper', $allowedMethods); + + $this->allowedHeaders = in_array('*', $allowedHeaders) + ? true + : array_map('\strtolower', $allowedHeaders); + + $this->exposedHeaders = $exposedHeaders; + + $this->maxAge = $maxAge; + } + + /** + * 是否存在请求来源 + * @param Request $request + * @return bool + */ + private function hasOrigin(Request $request): bool + { + return !empty($request->header('Origin')); + } + + /** + * 获取请求来源 + * @param Request $request + * @return string + */ + private function getOrigin(Request $request): string + { + $origin = $request->header('Origin'); + + if (!$origin) { + return ''; + } + + return rtrim($origin, '/'); + } + + private function getHost(Request $request): string + { + return "{$request->scheme()}://{$request->host()}"; + } + + /** + * 是否一个Cors请求 + * @param Request $request + * @return bool + */ + public function isCorsRequest(Request $request) + { + return $this->hasOrigin($request) && !$this->isSameHost($request); + } + + /** + * 检查Host是否一致 + * @param Request $request + * @return bool + */ + public function isSameHost(Request $request): bool + { + return $this->getOrigin($request) === $this->getHost($request); + } + + /** + * 检查请求源 + * @param Request $request + * @return bool + */ + public function checkOrigin(Request $request): bool + { + if ($this->allowedOrigins === true) { + return true; + } + + $origin = $this->getOrigin($request); + + if (in_array($origin, $this->allowedOrigins)) { + return true; + } + + foreach ($this->allowedOriginsPatterns as $pattern) { + if (preg_match($pattern, $origin)) { + return true; + } + } + + return false; + } + + /** + * 检查请求方法 + * @param Request $request + * @return bool + */ + public function checkMethod(Request $request): bool + { + if ($this->allowedMethods === true) { + return true; + } + + $method = strtoupper($request->header('Access-Control-Request-Method', '')); + + return in_array($method, $this->allowedMethods); + } + + /** + * 是否允许来源请求 + * @param Request $request + * @return bool + */ + public function isRequestAllowed(Request $request) + { + return $this->checkOrigin($request); + } + + /** + * 是否预检请求 + * @param Request $request + * @return bool + */ + public function isPreflightRequest(Request $request): bool + { + // Sec-Fetch-Mode: cors + return $this->isCorsRequest($request) + && $request->method(true) === 'OPTIONS' + && $request->header('Access-Control-Request-Method'); + } + + /** + * 处理预检请求 + * @param Request $request + * @return Response + */ + public function handlePreflightRequest(Request $request): Response + { + if ($check = $this->checkPreflightRequestConditions($request)) { + return $check; + } + + return $this->buildPreflightResponse($request); + } + + /** + * 构建预检响应 + * @param Request $request + * @return Response + */ + public function buildPreflightResponse(Request $request): Response + { + $response = Response::create('', 'html', 204); + + $headers = [ + 'Access-Control-Allow-Origin' => $this->getOrigin($request), + ]; + + if ($this->supportsCredentials) { + $headers['Access-Control-Allow-Credentials'] = 'true'; + } + + if ($this->maxAge) { + $headers['Access-Control-Max-Age'] = $this->maxAge; + } + + $headers['Access-Control-Allow-Methods'] = $this->allowedMethods === true + ? strtoupper($request->header('Access-Control-Request-Method', '')) + : implode(', ', $this->allowedMethods); + + $headers['Access-Control-Allow-Headers'] = $this->allowedHeaders === true + ? strtoupper($request->header('Access-Control-Request-Headers')) + : implode(', ', $this->allowedHeaders); + + $response->header($headers); + + return $response; + } + + /** + * 检查预检请求 + * @param Request $request + * @return Response|null + */ + public function checkPreflightRequestConditions(Request $request): ?Response + { + if (!$this->checkOrigin($request)) { + return $this->createBadRequestResponse(403, 'Origin not allowed'); + } + + if (!$this->checkMethod($request)) { + return $this->createBadRequestResponse(405, 'Method not allowed'); + } + + if ($this->allowedHeaders && $headers = $request->header('Access-Control-Request-Headers')) { + $headers = array_filter(explode(',', strtolower($headers))); + + foreach ($headers as $header) { + if (!in_array(trim($header), $this->allowedHeaders)) { + return $this->createBadRequestResponse(403, 'Header not allowed'); + } + } + } + + return null; + } + + /** + * @param Response $response + * @param Request $request + * @return Response + */ + public function addRequestHeaders(Response $response, Request $request): Response + { + $headers = [ + 'Access-Control-Allow-Origin' => $this->getOrigin($request), + ]; + + if ($vary = $response->getHeader('Vary')) { + $headers['Vary'] = "{$vary}, Origin"; + } else { + $headers['Vary'] = 'Origin'; + } + + if ($this->supportsCredentials) { + $headers['Access-Control-Allow-Credentials'] = 'true'; + } + + if ($this->exposedHeaders) { + $exposedHeaders = array_uintersect( + $this->exposedHeaders, + array_keys($response->getHeader()), + '\strcasecmp' + ); + if ($exposedHeaders) { + $headers['Access-Control-Expose-Headers'] = implode(', ', $exposedHeaders); + } + } + + $response->header($headers); + + return $response; + } + + private function createBadRequestResponse(int $code, string $reason = ''): Response + { + return Response::create($reason, 'html', $code); + } +} diff --git a/src/Service.php b/src/Service.php new file mode 100644 index 0000000..62cfe27 --- /dev/null +++ b/src/Service.php @@ -0,0 +1,17 @@ +app->middleware->unshift(CorsMiddleware::class, 'route'); + } + } +} diff --git a/src/helper.php b/src/helper.php new file mode 100644 index 0000000..18b5995 --- /dev/null +++ b/src/helper.php @@ -0,0 +1,26 @@ +