diff --git a/src/framework/src/Di/Annotation/Inject.php b/src/framework/src/Di/Annotation/Inject.php index fd22c24a..891ae708 100644 --- a/src/framework/src/Di/Annotation/Inject.php +++ b/src/framework/src/Di/Annotation/Inject.php @@ -22,10 +22,10 @@ class Inject implements PropertyAnnotation { /** - * @param null|string $id 注入的类型 + * @param string $id 注入的类型 */ public function __construct( - protected ?string $id = null + protected string $id = '' ) { } diff --git a/src/framework/src/Exception/Handler/WhoopsExceptionHandler.php b/src/framework/src/Exception/Handler/WhoopsExceptionHandler.php index dd18e6af..326b7e1f 100644 --- a/src/framework/src/Exception/Handler/WhoopsExceptionHandler.php +++ b/src/framework/src/Exception/Handler/WhoopsExceptionHandler.php @@ -11,6 +11,8 @@ namespace Max\Exception\Handler; +use Max\Http\Message\Contract\HeaderInterface; +use Max\Http\Message\Contract\StatusCodeInterface; use Max\Http\Message\Response; use Max\Http\Message\Stream\StringStream; use Max\Utils\Str; @@ -43,12 +45,12 @@ public function handle(Throwable $throwable, ServerRequestInterface $request): ? $whoops->{RunInterface::EXCEPTION_HANDLER}($throwable); $content = ob_get_clean(); - return new Response(500, ['Content-Type' => $contentType], new StringStream($content)); + return new Response(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, [HeaderInterface::HEADER_CONTENT_TYPE => $contentType], new StringStream($content)); } protected function negotiateHandler(ServerRequestInterface $request) { - $accepts = $request->getHeaderLine('accept'); + $accepts = $request->getHeaderLine(HeaderInterface::HEADER_ACCEPT); foreach (self::$preference as $contentType => $handler) { if (Str::contains($accepts, $contentType)) { return [$this->setupHandler(new $handler(), $request), $contentType]; diff --git a/src/http-message/src/Contract/HeaderInterface.php b/src/http-message/src/Contract/HeaderInterface.php index f680209a..40bf4d61 100644 --- a/src/http-message/src/Contract/HeaderInterface.php +++ b/src/http-message/src/Contract/HeaderInterface.php @@ -4,11 +4,18 @@ interface HeaderInterface { - public const HEADER_CONTENT_TYPE = 'Content-Type'; - public const HEADER_SET_COOKIE = 'Set-Cookie'; - public const HEADER_PRAGMA = 'Pragma'; - public const HEADER_EXPIRES = 'Expires'; - public const HEADER_CACHE_CONTROL = 'Cache-Control'; - public const HEADER_CONTENT_TRANSFER_ENCODING = 'Content-Transfer-Encoding'; - public const HEADER_CONTENT_DISPOSITION = 'Content-Disposition'; + public const HEADER_CONTENT_TYPE = 'Content-Type'; + public const HEADER_SET_COOKIE = 'Set-Cookie'; + public const HEADER_PRAGMA = 'Pragma'; + public const HEADER_ACCEPT = 'Accept'; + public const HEADER_EXPIRES = 'Expires'; + public const HEADER_CACHE_CONTROL = 'Cache-Control'; + public const HEADER_CONTENT_TRANSFER_ENCODING = 'Content-Transfer-Encoding'; + public const HEADER_CONTENT_DISPOSITION = 'Content-Disposition'; + public const HEADER_ORIGIN = 'Origin'; + public const HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'; + public const HEADER_ACCESS_CONTROL_MAX_AGE = 'Access-Control-Max-Age'; + public const HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS = 'Access-Control-Allow-Credentials'; + public const HEADER_ACCESS_CONTROL_ALLOW_METHODS = 'Access-Control-Allow-Methods'; + public const HEADER_ACCESS_CONTROL_ALLOW_HEADERS = 'Access-Control-Allow-Headers'; } diff --git a/src/http-message/src/Cookie.php b/src/http-message/src/Cookie.php index b7cd40c4..2376f495 100644 --- a/src/http-message/src/Cookie.php +++ b/src/http-message/src/Cookie.php @@ -15,6 +15,10 @@ class Cookie { + public const SAME_SITE_LAX = 'lax'; + public const SAME_SITE_NONE = 'none'; + public const SAME_SITE_STRICT = 'strict'; + public function __construct( protected string $name, protected string $value, @@ -30,6 +34,51 @@ public function __construct( } } + /** + * 解析Cookie字符串,返回对象 + */ + public static function parse(string $str): Cookie + { + $parts = [ + 'expires' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => false, + 'samesite' => '', + ]; + foreach (explode(';', $str) as $part) { + if (! str_contains($part, '=')) { + $key = $part; + $value = true; + } else { + [$key, $value] = explode('=', trim($part), 2); + $value = trim($value); + } + switch ($key = trim(strtolower($key))) { + case 'max-age': + $parts['expires'] = time() + (int) $value; + break; + default: + if (array_key_exists($key, $parts)) { + $parts[$key] = $value; + } else { + $parts['name'] = $key; + $parts['value'] = $value; + } + } + } + return new static( + $parts['name'], $parts['value'], + (int) $parts['expires'], $parts['path'], + $parts['domain'], (bool) $parts['secure'], + (bool) $parts['httponly'], $parts['samesite'] + ); + } + + /** + * 生成对应的Cookie字符串 + */ public function __toString(): string { $str = $this->name . '='; @@ -104,45 +153,6 @@ public function getMaxAge(): int return $this->expires !== 0 ? $this->expires - time() : 0; } - public static function parse(string $str): Cookie - { - $parts = [ - 'expires' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httponly' => false, - 'samesite' => '', - ]; - foreach (explode(';', $str) as $part) { - if (! str_contains($part, '=')) { - $key = $part; - $value = true; - } else { - [$key, $value] = explode('=', trim($part), 2); - $value = trim($value); - } - switch ($key = trim(strtolower($key))) { - case 'max-age': - $parts['expires'] = time() + (int) $value; - break; - default: - if (array_key_exists($key, $parts)) { - $parts[$key] = $value; - } else { - $parts['name'] = $key; - $parts['value'] = $value; - } - } - } - return new static( - $parts['name'], $parts['value'], - (int) $parts['expires'], $parts['path'], - $parts['domain'], (bool) $parts['secure'], - (bool) $parts['httponly'], $parts['samesite'] - ); - } - public function getName(): string { return $this->name; diff --git a/src/http-server/src/Exception/CSRFException.php b/src/http-server/src/Exception/CSRFException.php new file mode 100644 index 00000000..35604cb2 --- /dev/null +++ b/src/http-server/src/Exception/CSRFException.php @@ -0,0 +1,18 @@ + 'true', - 'Access-Control-Max-Age' => 1800, - 'Access-Control-Allow-Methods' => 'GET, POST, PATCH, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers' => 'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With', + HeaderInterface::HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS => 'true', + HeaderInterface::HEADER_ACCESS_CONTROL_MAX_AGE => 1800, + HeaderInterface::HEADER_ACCESS_CONTROL_ALLOW_METHODS => 'GET, POST, PATCH, PUT, DELETE, OPTIONS', + HeaderInterface::HEADER_ACCESS_CONTROL_ALLOW_HEADERS => 'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With', ]; public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $allowOrigin = in_array('*', $this->allowOrigin) ? '*' : $request->getHeaderLine('Origin'); - if ($allowOrigin !== '') { - $headers = $this->addedHeaders; - $headers['Access-Control-Allow-Origin'] = $allowOrigin; - if (strcasecmp($request->getMethod(), 'OPTIONS') === 0) { - return new Response(204, $headers); - } - $response = $handler->handle($request); - foreach ($headers as $name => $header) { - $response = $response->withHeader($name, $header); + if ($this->shouldCrossOrigin($origin = $request->getHeaderLine(HeaderInterface::HEADER_ORIGIN))) { + $headers = $this->createCORSHeaders($origin); + if (strcasecmp($request->getMethod(), RequestMethodInterface::METHOD_OPTIONS) === 0) { + return new Response(StatusCodeInterface::STATUS_NO_CONTENT, $headers); } - return $response; + + return $this->addHeadersToResponse($handler->handle($request), $headers); } return $handler->handle($request); } + + /** + * 创建响应头部 + */ + protected function createCORSHeaders(string $origin): array + { + $headers = $this->addedHeaders; + $headers[HeaderInterface::HEADER_ACCESS_CONTROL_ALLOW_ORIGIN] = $origin; + return $headers; + } + + /** + * 将头部添加到响应 + */ + protected function addHeadersToResponse(ResponseInterface $response, array $headers): ResponseInterface + { + foreach ($headers as $name => $header) { + $response = $response->withHeader($name, $header); + } + return $response; + } + + /** + * 允许跨域 + */ + protected function shouldCrossOrigin(string $origin) + { + if (empty($origin)) { + return false; + } + return collect($this->allowOrigin)->first(function($allowOrigin) use ($origin) { + return Str::is($allowOrigin, $origin); + }); + } } diff --git a/src/http-server/src/Middleware/ExceptionHandleMiddleware.php b/src/http-server/src/Middleware/ExceptionHandleMiddleware.php index 7a01bbd3..6b4a6773 100644 --- a/src/http-server/src/Middleware/ExceptionHandleMiddleware.php +++ b/src/http-server/src/Middleware/ExceptionHandleMiddleware.php @@ -11,6 +11,8 @@ namespace Max\Http\Server\Middleware; +use Max\Http\Message\Contract\HeaderInterface; +use Max\Http\Message\Contract\StatusCodeInterface; use Max\Http\Message\Exception\HttpException; use Max\Http\Message\Response; use Psr\Http\Message\ResponseInterface; @@ -49,7 +51,7 @@ protected function renderException(Throwable $throwable, ServerRequestInterface { $message = $throwable->getMessage(); $statusCode = $this->getStatusCode($throwable); - if (str_contains($request->getHeaderLine('Accept'), 'application/json') + if (str_contains($request->getHeaderLine(HeaderInterface::HEADER_ACCEPT), 'application/json') || strcasecmp('XMLHttpRequest', $request->getHeaderLine('X-REQUESTED-WITH')) === 0) { return new Response($statusCode, [], json_encode([ 'status' => false, @@ -68,6 +70,6 @@ protected function renderException(Throwable $throwable, ServerRequestInterface protected function getStatusCode(Throwable $throwable) { - return $throwable instanceof HttpException ? $throwable->getCode() : 500; + return $throwable instanceof HttpException ? $throwable->getCode() : StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR; } } diff --git a/src/http-server/src/Middleware/SessionMiddleware.php b/src/http-server/src/Middleware/SessionMiddleware.php index 1ae9c0c5..17631fdb 100644 --- a/src/http-server/src/Middleware/SessionMiddleware.php +++ b/src/http-server/src/Middleware/SessionMiddleware.php @@ -27,28 +27,23 @@ class SessionMiddleware implements MiddlewareInterface * Cookie 过期时间【+9小时,实际1小时后过期,和时区有关】. */ protected int $expires = 9 * 3600; - - protected string $name = 'MAXPHP_SESSION_ID'; - - protected bool $httponly = true; - - protected string $path = '/'; - - protected string $domain = ''; - - protected bool $secure = true; - /** - * @var mixed|SessionHandlerInterface + * 会话Cookie名 */ + protected string $name = 'MAXPHP_SESSION_ID'; + protected bool $httponly = true; + protected string $path = '/'; + protected string $domain = ''; + protected bool $secure = true; + protected string $sameSite = Cookie::SAME_SITE_LAX; protected SessionHandlerInterface $handler; public function __construct(ConfigInterface $config) { $config = $config->get('session'); $handler = $config['handler']; - $options = $config['options']; - $this->handler = new $handler($options); + $config = $config['config']; + $this->handler = new $handler($config); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -59,7 +54,16 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $response = $handler->handle($request); $session->save(); $session->close(); - $cookie = new Cookie($this->name, $session->getId(), time() + $this->expires, $this->path, $this->domain, $this->secure, $this->httponly); + + return $this->addCookieToResponse($response, $this->name, $session->getId()); + } + + /** + * 将cookie添加到响应 + */ + protected function addCookieToResponse(ResponseInterface $response, string $name, string $value): ResponseInterface + { + $cookie = new Cookie($name, $value, time() + $this->expires, $this->path, $this->domain, $this->secure, $this->httponly, $this->sameSite); return $response->withAddedHeader(HeaderInterface::HEADER_SET_COOKIE, $cookie->__toString()); } diff --git a/src/http-server/src/Middleware/VerifyCSRFToken.php b/src/http-server/src/Middleware/VerifyCSRFToken.php new file mode 100644 index 00000000..051e4b26 --- /dev/null +++ b/src/http-server/src/Middleware/VerifyCSRFToken.php @@ -0,0 +1,114 @@ +shouldVerify($request)) { + if (is_null($previousToken = $request->getCookieParams()['X-XSRF-TOKEN'] ?? null)) { + $this->abort(); + } + + $token = $this->parseToken($request); + + if ('' === $token || $token !== $previousToken) { + $this->abort(); + } + } + + return $this->addCookieToResponse($handler->handle($request)); + } + + /** + * 从头部获取CSRF/XSRF Token,如果都不存在则获取表单提交的参数为__token的值 + */ + protected function parseToken(ServerRequestInterface $request): string + { + return $request->getHeaderLine('X-CSRF-TOKEN') ?: $request->getHeaderLine('X-XSRF-TOKEN') ?: ($request->getParsedBody()['__token'] ?? ''); + } + + /** + * 将token添加到cookie中 + * + * @throws Exception + */ + protected function addCookieToResponse(ResponseInterface $response): ResponseInterface + { + return $response->withCookie('X-XSRF-TOKEN', $this->newCSRFToken(), time() + $this->expires); + } + + /** + * 生成CSRF Token + * + * @throws Exception + */ + protected function newCSRFToken(): string + { + return bin2hex((random_bytes(32))); + } + + /** + * @throws CSRFException + */ + protected function abort() + { + throw new CSRFException('CSRF token is invalid', 419); + } + + /** + * 是否需要验证 + */ + protected function shouldVerify(ServerRequestInterface $request): bool + { + if (in_array($request->getMethod(), $this->shouldVerifyMethods)) { + return !collect($this->except)->first(function($pattern) use ($request) { + return $request->is($pattern); + }); + } + return false; + } +} diff --git a/src/http-server/src/ResponseEmitter/FPMResponseEmitter.php b/src/http-server/src/ResponseEmitter/FPMResponseEmitter.php index b8efe583..353f2e50 100644 --- a/src/http-server/src/ResponseEmitter/FPMResponseEmitter.php +++ b/src/http-server/src/ResponseEmitter/FPMResponseEmitter.php @@ -11,6 +11,7 @@ namespace Max\Http\Server\ResponseEmitter; +use Max\Http\Message\Contract\HeaderInterface; use Max\Http\Message\Cookie; use Max\Http\Server\Contract\ResponseEmitterInterface; use Psr\Http\Message\ResponseInterface; @@ -20,7 +21,7 @@ class FPMResponseEmitter implements ResponseEmitterInterface public function emit(ResponseInterface $psrResponse, $sender = null) { header(sprintf('HTTP/%s %d %s', $psrResponse->getProtocolVersion(), $psrResponse->getStatusCode(), $psrResponse->getReasonPhrase()), true); - foreach ($psrResponse->getHeader('Set-Cookie') as $cookie) { + foreach ($psrResponse->getHeader(HeaderInterface::HEADER_SET_COOKIE) as $cookie) { $cookie = Cookie::parse($cookie); setcookie( $cookie->getName(), @@ -32,7 +33,7 @@ public function emit(ResponseInterface $psrResponse, $sender = null) $cookie->isHttponly() ); } - $psrResponse = $psrResponse->withoutHeader('Set-Cookie'); + $psrResponse = $psrResponse->withoutHeader(HeaderInterface::HEADER_SET_COOKIE); foreach ($psrResponse->getHeaders() as $name => $value) { header($name . ': ' . implode(', ', $value)); } diff --git a/src/http-server/src/ResponseEmitter/SwooleResponseEmitter.php b/src/http-server/src/ResponseEmitter/SwooleResponseEmitter.php index 53fa504d..7aa323f7 100644 --- a/src/http-server/src/ResponseEmitter/SwooleResponseEmitter.php +++ b/src/http-server/src/ResponseEmitter/SwooleResponseEmitter.php @@ -11,6 +11,7 @@ namespace Max\Http\Server\ResponseEmitter; +use Max\Http\Message\Contract\HeaderInterface; use Max\Http\Message\Cookie; use Max\Http\Message\Stream\FileStream; use Max\Http\Server\Contract\ResponseEmitterInterface; @@ -25,10 +26,20 @@ class SwooleResponseEmitter implements ResponseEmitterInterface public function emit(ResponseInterface $psrResponse, $sender = null) { $sender->status($psrResponse->getStatusCode(), $psrResponse->getReasonPhrase()); - foreach ($psrResponse->getHeader('Set-Cookie') as $cookie) { - $this->sendCookie(Cookie::parse($cookie), $sender); + foreach ($psrResponse->getHeader(HeaderInterface::HEADER_SET_COOKIE) as $cookieLine) { + $cookie = Cookie::parse($cookieLine); + $sender->cookie( + $cookie->getName(), + $cookie->getValue(), + $cookie->getExpires(), + $cookie->getPath(), + $cookie->getDomain(), + $cookie->isSecure(), + $cookie->isHttponly(), + $cookie->getSameSite() + ); } - $psrResponse = $psrResponse->withoutHeader('Set-Cookie'); + $psrResponse = $psrResponse->withoutHeader(HeaderInterface::HEADER_SET_COOKIE); foreach ($psrResponse->getHeaders() as $key => $value) { $sender->header($key, implode(', ', $value)); } @@ -42,18 +53,4 @@ public function emit(ResponseInterface $psrResponse, $sender = null) } $body?->close(); } - - protected function sendCookie(Cookie $cookie, Response $response): void - { - $response->cookie( - $cookie->getName(), - $cookie->getValue(), - $cookie->getExpires(), - $cookie->getPath(), - $cookie->getDomain(), - $cookie->isSecure(), - $cookie->isHttponly(), - $cookie->getSameSite() - ); - } } diff --git a/src/http-server/src/ResponseEmitter/WorkerManResponseEmitter.php b/src/http-server/src/ResponseEmitter/WorkerManResponseEmitter.php index 04123f59..8a276148 100644 --- a/src/http-server/src/ResponseEmitter/WorkerManResponseEmitter.php +++ b/src/http-server/src/ResponseEmitter/WorkerManResponseEmitter.php @@ -11,6 +11,7 @@ namespace Max\Http\Server\ResponseEmitter; +use Max\Http\Message\Contract\HeaderInterface; use Max\Http\Message\Cookie; use Max\Http\Message\Stream\FileStream; use Max\Http\Server\Contract\ResponseEmitterInterface; @@ -26,8 +27,8 @@ class WorkerManResponseEmitter implements ResponseEmitterInterface public function emit(ResponseInterface $psrResponse, $sender = null) { $response = new Response($psrResponse->getStatusCode()); - $cookies = $psrResponse->getHeader('Set-Cookie'); - $psrResponse = $psrResponse->withoutHeader('Set-Cookie'); + $cookies = $psrResponse->getHeader(HeaderInterface::HEADER_SET_COOKIE); + $psrResponse = $psrResponse->withoutHeader(HeaderInterface::HEADER_SET_COOKIE); foreach ($psrResponse->getHeaders() as $name => $values) { $response->header($name, implode(', ', $values)); } @@ -49,7 +50,7 @@ public function emit(ResponseInterface $psrResponse, $sender = null) $cookie->getSameSite() ); } - $sender->send($response->withBody((string) $body?->getContents())); + $sender->send($response->withBody((string)$body?->getContents())); } $body?->close(); $sender->close(); diff --git a/src/session/publish/session.php b/src/session/publish/session.php index e9c0bbdc..e9b8aef6 100644 --- a/src/session/publish/session.php +++ b/src/session/publish/session.php @@ -11,14 +11,14 @@ return [ 'handler' => 'Max\Session\Handler\FileHandler', - 'options' => [ + 'config' => [ 'path' => __DIR__ . '/../runtime/session', 'gcDivisor' => 100, 'gcProbability' => 1, 'gcMaxLifetime' => 1440, ], // 'handler' => 'Max\Session\Handler\RedisHandler', - // 'options' => [ + // 'config' => [ // 'connector' => 'Max\Redis\Connector\BaseConnector', // 'host' => '127.0.0.1', // 'port' => 6379, diff --git a/src/swoole/src/Context.php b/src/swoole/src/Context.php new file mode 100644 index 00000000..01666291 --- /dev/null +++ b/src/swoole/src/Context.php @@ -0,0 +1,24 @@ +add(count($this->callbacks)); foreach ($this->callbacks as $key => $callback) { $this->concurrentChannel && $this->concurrentChannel->push(true); - Coroutine::create(function () use ($callback, $key, $wg, &$result, &$throwables) { + Coroutine::create(function() use ($callback, $key, $wg, &$result, &$throwables) { try { $result[$key] = call($callback); - } catch (\Throwable $throwable) { + } catch (Throwable $throwable) { $throwables[$key] = $throwable; } finally { $this->concurrentChannel && $this->concurrentChannel->pop(); @@ -90,13 +89,13 @@ public function clear(): void /** * Format throwables into a nice list. * - * @param \Throwable[] $throwables + * @param Throwable[] $throwables */ private function formatThrowables(array $throwables): string { $output = ''; foreach ($throwables as $key => $value) { - $output .= \sprintf('(%s) %s: %s' . PHP_EOL . '%s' . PHP_EOL, $key, get_class($value), $value->getMessage(), $value->getTraceAsString()); + $output .= sprintf('(%s) %s: %s' . PHP_EOL . '%s' . PHP_EOL, $key, get_class($value), $value->getMessage(), $value->getTraceAsString()); } return $output; }