diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/TempUrlOpenstackGenerator.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/TempUrlOpenstackGenerator.php new file mode 100644 index 000000000..742e4285c --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/TempUrlOpenstackGenerator.php @@ -0,0 +1,192 @@ +max_expire_delay; + $submit_delay ??= $this->max_submit_delay; + + if ($delay < 2) { + throw new TempUrlGeneratorException("The delay of {$delay} is too ".'short (<2 sec) to properly use this token'); + } + + if ($delay > $this->max_expire_delay) { + throw new TempUrlGeneratorException('The given delay is greater than the max delay authorized.'); + } + + if ($submit_delay < 15) { + throw new TempUrlGeneratorException("The submit delay of {$delay} is too ".'short (<15 sec) to properly use this token'); + } + + if ($submit_delay > $this->max_submit_delay) { + throw new TempUrlGeneratorException('The given submit delay is greater than the max submit delay authorized.'); + } + + if ($max_file_count > $this->max_file_count) { + throw new TempUrlGeneratorException('The number of files is greater than the authorized number of files'); + } + + $expires = $this->clock->now()->add(new \DateInterval('PT'.(string) $delay.'S')); + + $object_name = $this->generateObjectName(); + + $g = new SignedUrlPost( + $url = $this->generateUrl($object_name), + $expires, + $this->max_post_file_size, + $max_file_count, + $submit_delay, + '', + $object_name, + $this->generateSignaturePost($url, $expires) + ); + + $this->event_dispatcher->dispatch( + new TempUrlGenerateEvent($g), + ); + + $this->logger->info( + 'generate signature for url', + (array) $g + ); + + return $g; + } + + /** + * Generate an absolute public url for a GET request on the object. + */ + public function generate(string $method, string $object_name, int $expire_delay = null): SignedUrl + { + if ($expire_delay > $this->max_expire_delay) { + throw new TempUrlGeneratorException(sprintf('The expire delay (%d) is greater than the max_expire_delay (%d)', $expire_delay, $this->max_expire_delay)); + } + $url = $this->generateUrl($object_name); + + $expires = $this->clock->now() + ->add(new \DateInterval(sprintf('PT%dS', $expire_delay ?? $this->max_expire_delay))); + $args = [ + 'temp_url_sig' => $this->generateSignature($method, $url, $expires), + 'temp_url_expires' => $expires->format('U'), + ]; + $url = $url.'?'.\http_build_query($args); + + $signature = new SignedUrl($method, $url, $expires); + + $this->event_dispatcher->dispatch( + new TempUrlGenerateEvent($signature) + ); + + return $signature; + } + + private function generateUrl($relative_path): string + { + return match (str_ends_with($this->base_url, '/')) { + true => $this->base_url.$relative_path, + false => $this->base_url.'/'.$relative_path + }; + } + + private function generateObjectName() + { + // inspiration from https://stackoverflow.com/a/4356295/1572236 + $charactersLength = strlen(self::CHARACTERS); + $randomString = ''; + for ($i = 0; $i < 21; ++$i) { + $randomString .= self::CHARACTERS[random_int(0, $charactersLength - 1)]; + } + + return $randomString; + } + + private function generateSignaturePost($url, \DateTimeImmutable $expires) + { + $path = \parse_url((string) $url, PHP_URL_PATH); + + $body = sprintf( + "%s\n%s\n%s\n%s\n%s", + $path, + '', // redirect is empty + (string) $this->max_post_file_size, + (string) $this->max_file_count, + $expires->format('U') + ) + ; + + $this->logger->debug( + 'generate signature post', + ['url' => $body, 'method' => 'POST'] + ); + + return \hash_hmac('sha512', $body, $this->key, false); + } + + private function generateSignature($method, $url, \DateTimeImmutable $expires) + { + if ('POST' === $method) { + return $this->generateSignaturePost($url, $expires); + } + + $path = \parse_url((string) $url, PHP_URL_PATH); + + $body = sprintf( + "%s\n%s\n%s", + $method, + $expires->format('U'), + $path + ) + ; + + $this->logger->debug( + 'generate signature GET', + ['url' => $body, 'method' => 'GET'] + ); + + return \hash_hmac('sha512', $body, $this->key, false); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Event/TempUrlGenerateEvent.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Event/TempUrlGenerateEvent.php new file mode 100644 index 000000000..619dba4e0 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Event/TempUrlGenerateEvent.php @@ -0,0 +1,31 @@ +data->method; + } + + public function getData(): SignedUrl + { + return $this->data; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Exception/TempUrlGeneratorException.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Exception/TempUrlGeneratorException.php new file mode 100644 index 000000000..29c7e78ed --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Exception/TempUrlGeneratorException.php @@ -0,0 +1,14 @@ +generate($method, $objectName, $expireDelay); + + self::assertEquals($expected, $signedUrl); + } + + /** + * @dataProvider dataProviderGeneratePost + */ + public function testGeneratePost(string $baseUrl, \DateTimeImmutable $now, string $key, string $method, string $objectName, int $expireDelay, SignedUrl $expected): void + { + $logger = new NullLogger(); + $eventDispatcher = new EventDispatcher(); + $clock = new MockClock($now); + + $generator = new TempUrlOpenstackGenerator( + $logger, + $eventDispatcher, + $clock, + $baseUrl, + $key, + 1800, + 1800, + 150 + ); + + $signedUrl = $generator->generatePost(); + + self::assertEquals('POST', $signedUrl->method); + self::assertEquals((int) $clock->now()->format('U') + 1800, $signedUrl->expires->getTimestamp()); + self::assertEquals(150, $signedUrl->max_file_size); + self::assertEquals(1, $signedUrl->max_file_count); + self::assertEquals(1800, $signedUrl->submit_delay); + self::assertEquals('', $signedUrl->redirect); + self::assertGreaterThanOrEqual(20, strlen($signedUrl->prefix)); + } + + public function dataProviderGenerate(): iterable + { + $now = \DateTimeImmutable::createFromFormat('U', '1702041743'); + $expireDelay = 1800; + $baseUrls = [ + 'https://objectstore.example/v1/my_account/container/', + 'https://objectstore.example/v1/my_account/container', + ]; + $objectName = 'object'; + $method = 'GET'; + $key = 'MYKEY'; + + $signedUrl = new SignedUrl( + 'GET', + 'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', + \DateTimeImmutable::createFromFormat('U', '1702043543') + ); + + foreach ($baseUrls as $baseUrl) { + yield [ + $baseUrl, + $now, + $key, + $method, + $objectName, + $expireDelay, + $signedUrl, + ]; + } + } + + public function dataProviderGeneratePost(): iterable + { + $now = \DateTimeImmutable::createFromFormat('U', '1702041743'); + $expireDelay = 1800; + $baseUrls = [ + 'https://objectstore.example/v1/my_account/container/', + 'https://objectstore.example/v1/my_account/container', + ]; + $objectName = 'object'; + $method = 'GET'; + $key = 'MYKEY'; + + $signedUrl = new SignedUrlPost( + 'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', + \DateTimeImmutable::createFromFormat('U', '1702043543'), + 150, + 1, + 1800, + '', + 'abc', + 'abc' + ); + + foreach ($baseUrls as $baseUrl) { + yield [ + $baseUrl, + $now, + $key, + $method, + $objectName, + $expireDelay, + $signedUrl, + ]; + } + } +}