diff --git a/src/bundle/Core/DependencyInjection/IbexaCoreExtension.php b/src/bundle/Core/DependencyInjection/IbexaCoreExtension.php index 26abba501c..ed7eddc35c 100644 --- a/src/bundle/Core/DependencyInjection/IbexaCoreExtension.php +++ b/src/bundle/Core/DependencyInjection/IbexaCoreExtension.php @@ -4,6 +4,8 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ +declare(strict_types=1); + namespace Ibexa\Bundle\Core\DependencyInjection; use Ibexa\Bundle\Core\DependencyInjection\Compiler\QueryTypePass; @@ -392,6 +394,7 @@ private function handleApiLoading(ContainerBuilder $container, FileLoader $loade $coreLoader->load('user_preference.yml'); $coreLoader->load('events.yml'); $coreLoader->load('thumbnails.yml'); + $coreLoader->load('tokens.yml'); $coreLoader->load('content_location_mapper.yml'); // Public API services diff --git a/src/bundle/Core/Resources/config/storage/legacy/schema.yaml b/src/bundle/Core/Resources/config/storage/legacy/schema.yaml index 2c2d93df70..d67cd96eee 100644 --- a/src/bundle/Core/Resources/config/storage/legacy/schema.yaml +++ b/src/bundle/Core/Resources/config/storage/legacy/schema.yaml @@ -638,3 +638,28 @@ tables: group: { type: string, nullable: false, length: 128 } identifier: { type: string, nullable: false, length: 128 } value: { type: json, nullable: false, length: 0 } + ibexa_token_type: + uniqueConstraints: + ibexa_token_type_unique: { fields: [identifier] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + identifier: { type: string, nullable: false, length: 32 } + ibexa_token: + uniqueConstraints: + ibexa_token_unique: { fields: [token, identifier, type_id] } + foreignKeys: + ibexa_token_type_id_fk: + fields: [type_id] + foreignTable: ibexa_token_type + foreignFields: [id] + options: + onDelete: CASCADE + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + type_id: { type: integer, nullable: false } + token: { type: string, nullable: false, length: 255 } + identifier: { type: string, nullable: true, length: 128 } + created: { type: integer, nullable: false, options: { default: '0' } } + expires: { type: integer, nullable: false, options: { default: '0' } } diff --git a/src/contracts/Persistence/Token/CreateStruct.php b/src/contracts/Persistence/Token/CreateStruct.php new file mode 100644 index 0000000000..abc93c0bf1 --- /dev/null +++ b/src/contracts/Persistence/Token/CreateStruct.php @@ -0,0 +1,25 @@ +innerService = $innerService; + } + + public function getToken( + string $tokenType, + string $token, + ?string $identifier = null + ): Token { + return $this->innerService->getToken( + $tokenType, + $token, + $identifier + ); + } + + public function checkToken( + string $tokenType, + string $token, + ?string $identifier = null + ): bool { + return $this->innerService->checkToken( + $tokenType, + $token, + $identifier + ); + } + + public function generateToken( + string $type, + int $ttl, + ?string $identifier = null, + int $tokenLength = 64, + ?TokenGeneratorInterface $tokenGenerator = null + ): Token { + return $this->innerService->generateToken( + $type, + $ttl, + $identifier, + $tokenLength, + $tokenGenerator + ); + } + + public function deleteToken(Token $token): void + { + $this->innerService->deleteToken($token); + } +} diff --git a/src/contracts/Repository/Events/Token/BeforeCheckTokenEvent.php b/src/contracts/Repository/Events/Token/BeforeCheckTokenEvent.php new file mode 100644 index 0000000000..cb18bd54bd --- /dev/null +++ b/src/contracts/Repository/Events/Token/BeforeCheckTokenEvent.php @@ -0,0 +1,70 @@ +tokenType = $tokenType; + $this->token = $token; + $this->identifier = $identifier; + } + + public function getResult(): bool + { + if (!$this->hasResult()) { + throw new UnexpectedValueException( + 'Return value is not set.' . PHP_EOL + . 'Check hasResult() or set it using setResult() before you call the getter.' + ); + } + + return $this->result; + } + + public function setResult(?bool $result): void + { + $this->result = $result; + } + + public function hasResult(): bool + { + return $this->result !== null; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getToken(): string + { + return $this->token; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } +} diff --git a/src/contracts/Repository/Events/Token/BeforeDeleteTokenEvent.php b/src/contracts/Repository/Events/Token/BeforeDeleteTokenEvent.php new file mode 100644 index 0000000000..0656b688bb --- /dev/null +++ b/src/contracts/Repository/Events/Token/BeforeDeleteTokenEvent.php @@ -0,0 +1,27 @@ +token = $token; + } + + public function getToken(): Token + { + return $this->token; + } +} diff --git a/src/contracts/Repository/Events/Token/BeforeGenerateTokenEvent.php b/src/contracts/Repository/Events/Token/BeforeGenerateTokenEvent.php new file mode 100644 index 0000000000..06d0fe1f88 --- /dev/null +++ b/src/contracts/Repository/Events/Token/BeforeGenerateTokenEvent.php @@ -0,0 +1,90 @@ +tokenType = $type; + $this->ttl = $ttl; + $this->identifier = $identifier; + $this->tokenLength = $tokenLength; + $this->tokenGenerator = $tokenGenerator; + } + + public function getToken(): Token + { + if (!$this->hasToken()) { + throw new UnexpectedValueException( + 'Return value is not set.' . PHP_EOL + . 'Check hasToken() or set it using setToken() before you call the getter.', + ); + } + + return $this->token; + } + + public function setToken(?Token $token): void + { + $this->token = $token; + } + + public function hasToken(): bool + { + return $this->token instanceof Token; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } + + public function getTtl(): int + { + return $this->ttl; + } + + public function getTokenLength(): int + { + return $this->tokenLength; + } + + public function getTokenGenerator(): ?TokenGeneratorInterface + { + return $this->tokenGenerator; + } +} diff --git a/src/contracts/Repository/Events/Token/BeforeGetTokenEvent.php b/src/contracts/Repository/Events/Token/BeforeGetTokenEvent.php new file mode 100644 index 0000000000..517b8bc496 --- /dev/null +++ b/src/contracts/Repository/Events/Token/BeforeGetTokenEvent.php @@ -0,0 +1,71 @@ +tokenType = $tokenType; + $this->token = $token; + $this->identifier = $identifier; + } + + public function getResult(): Token + { + if (!$this->hasResult()) { + throw new UnexpectedValueException( + 'Return value is not set.' . PHP_EOL + . 'Check hasResult() or set it using setResult() before you call the getter.' + ); + } + + return $this->result; + } + + public function setResult(?Token $result): void + { + $this->result = $result; + } + + public function hasResult(): bool + { + return $this->result instanceof Token; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getToken(): string + { + return $this->token; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } +} diff --git a/src/contracts/Repository/Events/Token/CheckTokenEvent.php b/src/contracts/Repository/Events/Token/CheckTokenEvent.php new file mode 100644 index 0000000000..0918a52b5e --- /dev/null +++ b/src/contracts/Repository/Events/Token/CheckTokenEvent.php @@ -0,0 +1,54 @@ +result = $result; + $this->tokenType = $tokenType; + $this->token = $token; + $this->identifier = $identifier; + } + + public function getResult(): bool + { + return $this->result; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getToken(): string + { + return $this->token; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } +} diff --git a/src/contracts/Repository/Events/Token/DeleteTokenEvent.php b/src/contracts/Repository/Events/Token/DeleteTokenEvent.php new file mode 100644 index 0000000000..e93d4e34c8 --- /dev/null +++ b/src/contracts/Repository/Events/Token/DeleteTokenEvent.php @@ -0,0 +1,27 @@ +token = $token; + } + + public function getToken(): Token + { + return $this->token; + } +} diff --git a/src/contracts/Repository/Events/Token/GenerateTokenEvent.php b/src/contracts/Repository/Events/Token/GenerateTokenEvent.php new file mode 100644 index 0000000000..6c60905cdf --- /dev/null +++ b/src/contracts/Repository/Events/Token/GenerateTokenEvent.php @@ -0,0 +1,74 @@ +token = $token; + $this->tokenType = $tokenType; + $this->identifier = $identifier; + $this->ttl = $ttl; + $this->tokenLength = $tokenLength; + $this->tokenGenerator = $tokenGenerator; + } + + public function getToken(): Token + { + return $this->token; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } + + public function getTtl(): int + { + return $this->ttl; + } + + public function getTokenLength(): int + { + return $this->tokenLength; + } + + public function getTokenGenerator(): ?TokenGeneratorInterface + { + return $this->tokenGenerator; + } +} diff --git a/src/contracts/Repository/Events/Token/GetTokenEvent.php b/src/contracts/Repository/Events/Token/GetTokenEvent.php new file mode 100644 index 0000000000..9e3ebc0842 --- /dev/null +++ b/src/contracts/Repository/Events/Token/GetTokenEvent.php @@ -0,0 +1,55 @@ +result = $result; + $this->tokenType = $tokenType; + $this->token = $token; + $this->identifier = $identifier; + } + + public function getResult(): Token + { + return $this->result; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getToken(): string + { + return $this->token; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } +} diff --git a/src/contracts/Repository/TokenService.php b/src/contracts/Repository/TokenService.php new file mode 100644 index 0000000000..aebd8a7730 --- /dev/null +++ b/src/contracts/Repository/TokenService.php @@ -0,0 +1,40 @@ +id = $id; + $this->type = $type; + $this->token = $token; + $this->identifier = $identifier; + $this->created = $created; + $this->expires = $expires; + } + + public function getId(): int + { + return $this->id; + } + + public function getType(): string + { + return $this->type; + } + + public function getToken(): string + { + return $this->token; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } + + public function getCreated(): DateTimeImmutable + { + return $this->created; + } + + public function getExpires(): DateTimeImmutable + { + return $this->expires; + } + + public function __toString(): string + { + return $this->token; + } +} diff --git a/src/contracts/Test/IbexaTestKernel.php b/src/contracts/Test/IbexaTestKernel.php index a7ca42770a..ece393d629 100644 --- a/src/contracts/Test/IbexaTestKernel.php +++ b/src/contracts/Test/IbexaTestKernel.php @@ -88,6 +88,7 @@ class IbexaTestKernel extends Kernel Repository\SearchService::class, Repository\SectionService::class, Repository\UserService::class, + Repository\TokenService::class, ]; /** diff --git a/src/contracts/Token/TokenGeneratorInterface.php b/src/contracts/Token/TokenGeneratorInterface.php new file mode 100644 index 0000000000..c897c509a3 --- /dev/null +++ b/src/contracts/Token/TokenGeneratorInterface.php @@ -0,0 +1,14 @@ +setMessageTemplate("Token '%tokenType%:%token%' expired on '%when%'"); + $this->setParameters([ + '%tokenType%' => $tokenType, + '%token%' => $token, + '%when%' => $when->format(DateTimeInterface::ATOM), + ]); + + parent::__construct($this->getBaseTranslation(), self::UNAUTHORIZED, $previous); + } +} diff --git a/src/lib/Base/Exceptions/TokenLengthException.php b/src/lib/Base/Exceptions/TokenLengthException.php new file mode 100644 index 0000000000..f3507ddec3 --- /dev/null +++ b/src/lib/Base/Exceptions/TokenLengthException.php @@ -0,0 +1,32 @@ +eventDispatcher = $eventDispatcher; + } + + public function getToken( + string $tokenType, + string $token, + ?string $identifier = null + ): Token { + $eventData = [$tokenType, $token, $identifier]; + + $beforeEvent = new BeforeGetTokenEvent(...$eventData); + + $this->eventDispatcher->dispatch($beforeEvent); + if ($beforeEvent->isPropagationStopped()) { + return $beforeEvent->getResult(); + } + + $result = $beforeEvent->hasResult() + ? $beforeEvent->getToken() + : $this->innerService->getToken(...$eventData); + + $this->eventDispatcher->dispatch( + new GetTokenEvent($result, ...$eventData) + ); + + return $result; + } + + public function checkToken( + string $tokenType, + string $token, + ?string $identifier = null + ): bool { + $eventData = [$tokenType, $token, $identifier]; + + $beforeEvent = new BeforeCheckTokenEvent(...$eventData); + + $this->eventDispatcher->dispatch($beforeEvent); + if ($beforeEvent->isPropagationStopped()) { + return $beforeEvent->getResult(); + } + + $result = $beforeEvent->hasResult() + ? $beforeEvent->getResult() + : $this->innerService->checkToken(...$eventData); + + $this->eventDispatcher->dispatch( + new CheckTokenEvent($result, ...$eventData) + ); + + return $result; + } + + public function generateToken( + string $type, + int $ttl, + ?string $identifier = null, + int $tokenLength = 64, + ?TokenGeneratorInterface $tokenGenerator = null + ): Token { + $eventData = [$type, $ttl, $identifier, $tokenLength, $tokenGenerator]; + + $beforeEvent = new BeforeGenerateTokenEvent(...$eventData); + + $this->eventDispatcher->dispatch($beforeEvent); + if ($beforeEvent->isPropagationStopped()) { + return $beforeEvent->getToken(); + } + + $token = $beforeEvent->hasToken() + ? $beforeEvent->getToken() + : $this->innerService->generateToken(...$eventData); + + $this->eventDispatcher->dispatch( + new GenerateTokenEvent($token, ...$eventData) + ); + + return $token; + } + + public function deleteToken(Token $token): void + { + $eventData = [$token]; + + $beforeEvent = new BeforeDeleteTokenEvent(...$eventData); + + $this->eventDispatcher->dispatch($beforeEvent); + if ($beforeEvent->isPropagationStopped()) { + return; + } + + $this->innerService->deleteToken($token); + + $this->eventDispatcher->dispatch( + new DeleteTokenEvent(...$eventData) + ); + } +} diff --git a/src/lib/Persistence/Legacy/Token/AbstractGateway.php b/src/lib/Persistence/Legacy/Token/AbstractGateway.php new file mode 100644 index 0000000000..4d8b8f9752 --- /dev/null +++ b/src/lib/Persistence/Legacy/Token/AbstractGateway.php @@ -0,0 +1,32 @@ + $this->getAliasedColumn($column, $alias), + $columns + ); + } + + protected function getAliasedColumn( + string $column, + string $alias + ): string { + return sprintf('%s.%s', $alias, $column); + } +} diff --git a/src/lib/Persistence/Legacy/Token/Gateway/Token/Doctrine/DoctrineGateway.php b/src/lib/Persistence/Legacy/Token/Gateway/Token/Doctrine/DoctrineGateway.php new file mode 100644 index 0000000000..96be5225fc --- /dev/null +++ b/src/lib/Persistence/Legacy/Token/Gateway/Token/Doctrine/DoctrineGateway.php @@ -0,0 +1,213 @@ +connection = $connection; + } + + public static function getColumns(): array + { + return [ + self::COLUMN_ID, + self::COLUMN_TYPE_ID, + self::COLUMN_TOKEN, + self::COLUMN_IDENTIFIER, + self::COLUMN_CREATED, + self::COLUMN_EXPIRES, + ]; + } + + public function insert( + int $typeId, + string $token, + ?string $identifier, + int $ttl + ): int { + $now = $this->getCurrentUnixTimestamp(); + $this->connection->insert( + self::TABLE_NAME, + [ + self::COLUMN_TYPE_ID => $typeId, + self::COLUMN_TOKEN => $token, + self::COLUMN_IDENTIFIER => $identifier, + self::COLUMN_CREATED => $now, + self::COLUMN_EXPIRES => $now + $ttl, + ], + [ + self::COLUMN_TYPE_ID => ParameterType::INTEGER, + self::COLUMN_CREATED => ParameterType::INTEGER, + self::COLUMN_EXPIRES => ParameterType::INTEGER, + ] + ); + + return (int)$this->connection->lastInsertId(self::TOKEN_SEQ); + } + + public function delete(int $tokenId): void + { + $this->connection->delete( + self::TABLE_NAME, + [ + self::COLUMN_ID => $tokenId, + ], + [ + self::COLUMN_ID => ParameterType::INTEGER, + ] + ); + } + + public function deleteExpired(?int $typeId = null): void + { + $query = $this->connection->createQueryBuilder(); + $query + ->delete(self::TABLE_NAME) + ->andWhere( + $query->expr()->lt(self::COLUMN_EXPIRES, ':now') + ) + ->setParameter(':now', $this->getCurrentUnixTimestamp(), ParameterType::INTEGER); + + if (null !== $typeId) { + $query->andWhere( + $query->expr()->eq( + self::COLUMN_TYPE_ID, + ':type_id' + ) + ); + $query->setParameter(':type_id', $typeId, ParameterType::INTEGER); + } + + $query->execute(); + } + + public function getToken( + string $tokenType, + string $token, + ?string $identifier = null + ): array { + $query = $this->getTokenSelectQueryBuilder($tokenType, $token, $identifier); + $row = $query->execute()->fetchAssociative(); + + if (false === $row) { + throw new NotFound('token', "token: $token, type: $tokenType, identifier: $identifier"); + } + + return $row; + } + + public function getTokenById(int $tokenId): array + { + $query = $this->connection->createQueryBuilder(); + $query + ->select(...$this->getAliasedColumns(self::DEFAULT_TABLE_ALIAS, self::getColumns())) + ->from(self::TABLE_NAME, self::DEFAULT_TABLE_ALIAS) + ->andWhere( + $query->expr()->eq( + $this->getAliasedColumn(self::COLUMN_ID, self::DEFAULT_TABLE_ALIAS), + ':token_id' + ) + ); + + $query->setParameter(':token_id', $tokenId, ParameterType::INTEGER); + + $row = $query->execute()->fetchAssociative(); + + if (false === $row) { + throw new NotFound('token', "id: $tokenId"); + } + + return $row; + } + + private function getCurrentUnixTimestamp(): int + { + return time(); + } + + private function getTokenSelectQueryBuilder( + string $tokenType, + string $token, + ?string $identifier = null + ): QueryBuilder { + $query = $this->connection->createQueryBuilder(); + $expr = $query->expr(); + $query + ->select(...$this->getAliasedColumns(self::DEFAULT_TABLE_ALIAS, self::getColumns())) + ->from(self::TABLE_NAME, self::DEFAULT_TABLE_ALIAS) + ->innerJoin( + self::DEFAULT_TABLE_ALIAS, + TokenTypeGateway::TABLE_NAME, + TokenTypeGateway::DEFAULT_TABLE_ALIAS, + $expr->eq( + $this->getAliasedColumn(self::COLUMN_TYPE_ID, self::DEFAULT_TABLE_ALIAS), + $this->getAliasedColumn( + TokenTypeGateway::COLUMN_ID, + TokenTypeGateway::DEFAULT_TABLE_ALIAS + ) + ) + ) + ->andWhere( + $query->expr()->eq( + $this->getAliasedColumn(self::COLUMN_TOKEN, self::DEFAULT_TABLE_ALIAS), + ':token' + ) + ) + ->andWhere( + $query->expr()->eq( + $this->getAliasedColumn( + TokenTypeGateway::COLUMN_IDENTIFIER, + TokenTypeGateway::DEFAULT_TABLE_ALIAS + ), + ':token_type' + ) + ); + + $query->setParameter(':token_type', $tokenType, ParameterType::STRING); + $query->setParameter(':token', $token, ParameterType::STRING); + + if (null !== $identifier) { + $query->andWhere( + $query->expr()->eq( + $this->getAliasedColumn(self::COLUMN_IDENTIFIER, self::DEFAULT_TABLE_ALIAS), + ':identifier' + ) + ); + $query->setParameter(':identifier', $identifier, ParameterType::STRING); + } + + return $query; + } +} diff --git a/src/lib/Persistence/Legacy/Token/Gateway/Token/Gateway.php b/src/lib/Persistence/Legacy/Token/Gateway/Token/Gateway.php new file mode 100644 index 0000000000..85451a5876 --- /dev/null +++ b/src/lib/Persistence/Legacy/Token/Gateway/Token/Gateway.php @@ -0,0 +1,50 @@ +connection = $connection; + } + + public static function getColumns(): array + { + return [ + self::COLUMN_ID, + self::COLUMN_IDENTIFIER, + ]; + } + + public function insert(string $identifier): int + { + $this->connection->insert(self::TABLE_NAME, [ + self::COLUMN_IDENTIFIER => $identifier, + ]); + + return (int)$this->connection->lastInsertId(self::TOKEN_TYPE_SEQ); + } + + /** + * @throws \Doctrine\DBAL\Exception + */ + public function deleteById(int $typeId): void + { + $this->connection->delete(self::TABLE_NAME, [ + self::COLUMN_ID => $typeId, + ]); + } + + /** + * @throws \Doctrine\DBAL\Exception + */ + public function deleteByIdentifier(string $identifier): void + { + $this->connection->delete(self::TABLE_NAME, [ + self::COLUMN_IDENTIFIER => $identifier, + ]); + } + + public function getTypeById(int $typeId): array + { + $query = $this->connection->createQueryBuilder(); + $query + ->select(...$this->getAliasedColumns(self::DEFAULT_TABLE_ALIAS, self::getColumns())) + ->from(self::TABLE_NAME, self::DEFAULT_TABLE_ALIAS) + ->andWhere( + $query->expr()->eq( + $this->getAliasedColumn(self::COLUMN_ID, self::DEFAULT_TABLE_ALIAS), + ':type_id' + ) + ); + + $query->setParameter(':type_id', $typeId, ParameterType::INTEGER); + + $row = $query->execute()->fetchAssociative(); + + if (false === $row) { + throw new NotFound('token_type', "id: $typeId"); + } + + return $row; + } + + public function getTypeByIdentifier(string $identifier): array + { + $query = $this->connection->createQueryBuilder(); + $query + ->select(...$this->getAliasedColumns(self::DEFAULT_TABLE_ALIAS, self::getColumns())) + ->from(self::TABLE_NAME, self::DEFAULT_TABLE_ALIAS) + ->andWhere( + $query->expr()->eq( + $this->getAliasedColumn(self::COLUMN_IDENTIFIER, self::DEFAULT_TABLE_ALIAS), + ':identifier' + ) + ); + + $query->setParameter(':identifier', $identifier, ParameterType::STRING); + + $row = $query->execute()->fetchAssociative(); + + if (false === $row) { + throw new NotFound('token_type', "identifier: $identifier"); + } + + return $row; + } +} diff --git a/src/lib/Persistence/Legacy/Token/Gateway/TokenType/Gateway.php b/src/lib/Persistence/Legacy/Token/Gateway/TokenType/Gateway.php new file mode 100644 index 0000000000..8acf21ffe9 --- /dev/null +++ b/src/lib/Persistence/Legacy/Token/Gateway/TokenType/Gateway.php @@ -0,0 +1,35 @@ +mapper = $mapper; + $this->tokenGateway = $tokenGateway; + $this->tokenTypeGateway = $tokenTypeGateway; + } + + /** + * @throws \Doctrine\DBAL\Driver\Exception + * @throws \Doctrine\DBAL\Exception + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Core\Base\Exceptions\TokenExpiredException + * @throws \Exception + */ + public function getToken( + string $tokenType, + string $token, + ?string $identifier = null + ): Token { + $persistenceTokenValue = $this->mapper->mapToken( + $this->tokenGateway->getToken($tokenType, $token, $identifier) + ); + + if ($persistenceTokenValue->expires < time()) { + throw new TokenExpiredException( + $tokenType, + $persistenceTokenValue->token, + new DateTimeImmutable('@' . $persistenceTokenValue->expires) + ); + } + + return $persistenceTokenValue; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Doctrine\DBAL\Exception + * @throws \Doctrine\DBAL\Driver\Exception + */ + public function getTokenType( + string $identifier + ): TokenType { + return $this->mapper->mapTokenType( + $this->tokenTypeGateway->getTypeByIdentifier($identifier) + ); + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Doctrine\DBAL\Driver\Exception + * @throws \Doctrine\DBAL\Exception + */ + public function createToken(CreateStruct $createStruct): Token + { + try { + $typeId = $this->getTokenType($createStruct->type)->id; + } catch (NotFoundException $exception) { + $typeId = $this->tokenTypeGateway->insert($createStruct->type); + } + + $tokenId = $this->tokenGateway->insert( + $typeId, + $createStruct->token, + $createStruct->identifier, + $createStruct->ttl + ); + + return $this->mapper->mapToken( + $this->tokenGateway->getTokenById($tokenId) + ); + } + + public function deleteToken(Token $token): void + { + $this->deleteTokenById($token->id); + } + + public function deleteTokenById(int $tokenId): void + { + $this->tokenGateway->delete($tokenId); + } + + public function deleteExpiredTokens(?string $tokenType = null): void + { + try { + if (null !== $tokenType) { + $typeId = $this->getTokenType($tokenType); + } + } catch (NotFoundException $exception) { + return; + } + + $this->tokenGateway->deleteExpired($typeId ?? null); + } +} diff --git a/src/lib/Persistence/Legacy/Token/Mapper.php b/src/lib/Persistence/Legacy/Token/Mapper.php new file mode 100644 index 0000000000..9b52479ae1 --- /dev/null +++ b/src/lib/Persistence/Legacy/Token/Mapper.php @@ -0,0 +1,38 @@ + (int)$tokenRow['id'], + 'typeId' => (int)$tokenRow['type_id'], + 'token' => (string)$tokenRow['token'], + 'identifier' => $tokenRow['identifier'] === null ? null : (string)$tokenRow['identifier'], + 'created' => (int)$tokenRow['created'], + 'expires' => (int)$tokenRow['expires'], + ]); + } + + public function mapTokenType(array $tokenTypeRow): TokenType + { + return new TokenType([ + 'id' => (int)$tokenTypeRow['id'], + 'identifier' => (string)$tokenTypeRow['identifier'], + ]); + } +} diff --git a/src/lib/Repository/TokenService.php b/src/lib/Repository/TokenService.php new file mode 100644 index 0000000000..d8d6ffbad1 --- /dev/null +++ b/src/lib/Repository/TokenService.php @@ -0,0 +1,128 @@ +persistenceHandler = $persistenceHandler; + $this->defaultTokenGenerator = $defaultTokenGenerator; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + * @throws \Exception + */ + public function getToken( + string $tokenType, + string $token, + ?string $identifier = null + ): Token { + $type = $this->persistenceHandler->getTokenType($tokenType); + + return $this->buildDomainObject( + $this->persistenceHandler->getToken( + $type->identifier, + $token, + $identifier + ), + $type + ); + } + + /** + * @throws \Exception + */ + public function checkToken( + string $tokenType, + string $token, + ?string $identifier = null + ): bool { + try { + $this->getToken($tokenType, $token, $identifier); + + return true; + } catch (NotFoundException|UnauthorizedException $exception) { + return false; + } + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Exception + */ + public function generateToken( + string $type, + int $ttl, + ?string $identifier = null, + int $tokenLength = 64, + ?TokenGeneratorInterface $tokenGenerator = null + ): Token { + if ($tokenLength > Token::MAX_LENGTH) { + throw new TokenLengthException($tokenLength); + } + + $createStruct = new CreateStruct([ + 'type' => $type, + 'token' => ($tokenGenerator ?? $this->defaultTokenGenerator)->generateToken($tokenLength), + 'identifier' => $identifier, + 'ttl' => $ttl, + ]); + + $token = $this->persistenceHandler->createToken($createStruct); + $tokenType = $this->persistenceHandler->getTokenType($type); + + return $this->buildDomainObject( + $token, + $tokenType + ); + } + + public function deleteToken(Token $token): void + { + $this->persistenceHandler->deleteTokenById($token->getId()); + } + + /** + * @throws \Exception + */ + private function buildDomainObject( + PersistenceTokenValue $token, + PersistenceTokenTypeValue $tokenType + ): Token { + return new Token( + $token->id, + $tokenType->identifier, + $token->token, + $token->identifier, + new DateTimeImmutable('@' . $token->created), + new DateTimeImmutable('@' . $token->expires) + ); + } +} diff --git a/src/lib/Resources/settings/repository/autowire.yml b/src/lib/Resources/settings/repository/autowire.yml index c72fda418c..a462cd5a12 100644 --- a/src/lib/Resources/settings/repository/autowire.yml +++ b/src/lib/Resources/settings/repository/autowire.yml @@ -21,6 +21,7 @@ services: Ibexa\Contracts\Core\Repository\URLAliasService: '@ibexa.api.service.url_alias' Ibexa\Contracts\Core\Repository\TrashService: '@ibexa.api.service.trash' Ibexa\Contracts\Core\Repository\SettingService: '@Ibexa\Core\Event\SettingService' + Ibexa\Contracts\Core\Repository\TokenService: '@Ibexa\Core\Event\TokenService' Ibexa\Contracts\Core\Repository\PermissionService: '@Ibexa\Core\Repository\Permission\CachedPermissionService' Ibexa\Contracts\Core\Repository\PermissionResolver: '@Ibexa\Contracts\Core\Repository\PermissionService' diff --git a/src/lib/Resources/settings/repository/event.yml b/src/lib/Resources/settings/repository/event.yml index f92c4c1e9e..7007cad563 100644 --- a/src/lib/Resources/settings/repository/event.yml +++ b/src/lib/Resources/settings/repository/event.yml @@ -96,3 +96,7 @@ services: Ibexa\Core\Event\SettingService: arguments: $innerService: '@Ibexa\Core\Repository\SettingService' + + Ibexa\Core\Event\TokenService: + arguments: + $innerService: '@Ibexa\Core\Repository\TokenService' diff --git a/src/lib/Resources/settings/repository/inner.yml b/src/lib/Resources/settings/repository/inner.yml index 9144d73501..50eb0fd3ce 100644 --- a/src/lib/Resources/settings/repository/inner.yml +++ b/src/lib/Resources/settings/repository/inner.yml @@ -128,6 +128,10 @@ services: $settingHandler: '@Ibexa\Core\Persistence\Cache\SettingHandler' $permissionResolver: '@Ibexa\Contracts\Core\Repository\PermissionResolver' + Ibexa\Core\Repository\TokenService: + autowire: true + autoconfigure: true + # Factories Ibexa\Bundle\Core\EventListener\BackgroundIndexingTerminateListener: class: Ibexa\Core\Search\Common\BackgroundIndexer\NullIndexer diff --git a/src/lib/Resources/settings/storage_engines/legacy.yml b/src/lib/Resources/settings/storage_engines/legacy.yml index 1051bbdb74..3942b9e426 100644 --- a/src/lib/Resources/settings/storage_engines/legacy.yml +++ b/src/lib/Resources/settings/storage_engines/legacy.yml @@ -20,6 +20,7 @@ imports: - {resource: storage_engines/legacy/notification.yml} - {resource: storage_engines/legacy/user_preference.yml} - {resource: storage_engines/legacy/setting.yml} + - {resource: storage_engines/legacy/token.yml} services: Ibexa\Core\Persistence\Legacy\Handler: diff --git a/src/lib/Resources/settings/storage_engines/legacy/token.yml b/src/lib/Resources/settings/storage_engines/legacy/token.yml new file mode 100644 index 0000000000..11ace00162 --- /dev/null +++ b/src/lib/Resources/settings/storage_engines/legacy/token.yml @@ -0,0 +1,25 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + Ibexa\Core\Persistence\Legacy\Token\Gateway\Token\Doctrine\DoctrineGateway: + arguments: + $connection: '@ibexa.persistence.connection' + + Ibexa\Core\Persistence\Legacy\Token\Gateway\Token\Gateway: + alias: Ibexa\Core\Persistence\Legacy\Token\Gateway\Token\Doctrine\DoctrineGateway + + Ibexa\Core\Persistence\Legacy\Token\Gateway\TokenType\Doctrine\DoctrineGateway: + arguments: + $connection: '@ibexa.persistence.connection' + + Ibexa\Core\Persistence\Legacy\Token\Gateway\TokenType\Gateway: + alias: Ibexa\Core\Persistence\Legacy\Token\Gateway\TokenType\Doctrine\DoctrineGateway + + Ibexa\Core\Persistence\Legacy\Token\Mapper: ~ + + Ibexa\Core\Persistence\Legacy\Token\Handler: ~ + + Ibexa\Contracts\Core\Persistence\Token\Handler: '@Ibexa\Core\Persistence\Legacy\Token\Handler' diff --git a/src/lib/Resources/settings/tokens.yml b/src/lib/Resources/settings/tokens.yml new file mode 100644 index 0000000000..6160e88aad --- /dev/null +++ b/src/lib/Resources/settings/tokens.yml @@ -0,0 +1,14 @@ +services: + _defaults: + public: false + autoconfigure: true + autowire: true + + Ibexa\Core\Token\RandomBytesGenerator: ~ + + Ibexa\Core\Token\WebSafeGenerator: + arguments: + $tokenGenerator: '@Ibexa\Core\Token\RandomBytesGenerator' + + Ibexa\Contracts\Core\Token\TokenGeneratorInterface: + alias: Ibexa\Core\Token\WebSafeGenerator diff --git a/src/lib/Token/RandomBytesGenerator.php b/src/lib/Token/RandomBytesGenerator.php new file mode 100644 index 0000000000..341ef6deed --- /dev/null +++ b/src/lib/Token/RandomBytesGenerator.php @@ -0,0 +1,22 @@ +tokenGenerator = $tokenGenerator; + } + + /** + * @throws \Exception + */ + public function generateToken(int $length = 64): string + { + $token = $this->tokenGenerator->generateToken($length); + $encoded = base64_encode($token); + + return substr( + rtrim( + strtr($encoded, '+-', '/_'), + '=' + ), + 0, + $length + ); + } +} diff --git a/tests/integration/Core/LegacyTestContainerBuilder.php b/tests/integration/Core/LegacyTestContainerBuilder.php index 132236b464..fcbff57704 100644 --- a/tests/integration/Core/LegacyTestContainerBuilder.php +++ b/tests/integration/Core/LegacyTestContainerBuilder.php @@ -92,6 +92,7 @@ private function loadCoreSettings(string $settingsPath): LoaderInterface $loader->load('policies.yml'); $loader->load('events.yml'); $loader->load('thumbnails.yml'); + $loader->load('tokens.yml'); $loader->load('content_location_mapper.yml'); // tests/integration/Core/Resources/settings/common.yml diff --git a/tests/integration/Core/Repository/TokenServiceTest.php b/tests/integration/Core/Repository/TokenServiceTest.php new file mode 100644 index 0000000000..066120c316 --- /dev/null +++ b/tests/integration/Core/Repository/TokenServiceTest.php @@ -0,0 +1,197 @@ +tokenService = self::getServiceByClassName(TokenService::class); + } + + /** + * @dataProvider provideTokenData + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + */ + public function testGenerateToken( + string $type, + int $tll, + ?string $identifier, + int $length = 64 + ): void { + $token = $this->tokenService->generateToken($type, $tll, $identifier, $length); + + self::assertSame($type, $token->getType()); + self::assertSame($identifier, $token->getIdentifier()); + self::assertSame($length, strlen($token->getToken())); + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + */ + public function testGenerateTokenThrowsTokenLengthException(): void + { + $length = 300; + + $this->expectException(TokenLengthException::class); + $this->expectExceptionMessage('Token length is too long: 300 characters. Max length is 255.'); + + $this->tokenService->generateToken( + self::TOKEN_TYPE, + self::TOKEN_TTL, + self::TOKEN_IDENTIFIER, + $length + ); + } + + /** + * @dataProvider provideDataForTestCheckToken + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + */ + public function testCheckExistingToken( + string $type, + int $tll, + ?string $identifier + ): void { + $token = $this->tokenService->generateToken($type, $tll, $identifier); + + self::assertTrue( + $this->tokenService->checkToken( + $token->getType(), + $token->getToken(), + $token->getIdentifier() + ) + ); + } + + public function testCheckNotExistentToken(): void + { + self::assertFalse( + $this->tokenService->checkToken( + 'bar', + '1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik,', + 'test' + ) + ); + } + + /** + * @dataProvider provideTokenData + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + */ + public function testGetToken( + string $type, + int $tll, + ?string $identifier, + int $length = 64 + ): void { + $token = $this->tokenService->generateToken($type, $tll, $identifier, $length); + + self::assertEquals( + $token, + $this->tokenService->getToken($type, $token->getToken(), $identifier) + ); + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + */ + public function testDeleteToken(): void + { + $token = $this->tokenService->generateToken( + self::TOKEN_TYPE, + self::TOKEN_TTL, + self::TOKEN_IDENTIFIER + ); + + $this->tokenService->deleteToken($token); + + self::assertFalse( + $this->tokenService->checkToken( + $token->getType(), + $token->getToken(), + $token->getIdentifier() + ) + ); + } + + /** + * @return iterable + */ + public function provideTokenData(): iterable + { + yield 'Token with default length 64 and custom identifier' => [ + self::TOKEN_TYPE, + self::TOKEN_TTL, + self::TOKEN_IDENTIFIER, + 64, + ]; + + yield 'Token with length 200 and custom identifier' => [ + self::TOKEN_TYPE, + self::TOKEN_TTL, + self::TOKEN_IDENTIFIER, + 200, + ]; + + yield 'Token without identifier' => [ + self::TOKEN_TYPE, + self::TOKEN_TTL, + null, + ]; + } + + /** + * @return iterable + */ + public function provideDataForTestCheckToken(): iterable + { + yield 'Token with identifier' => [ + self::TOKEN_TYPE, + self::TOKEN_TTL, + self::TOKEN_IDENTIFIER, + ]; + + yield 'Token without identifier' => [ + self::TOKEN_TYPE, + self::TOKEN_TTL, + null, + ]; + } +} diff --git a/tests/lib/Token/RandomBytesGeneratorTest.php b/tests/lib/Token/RandomBytesGeneratorTest.php new file mode 100644 index 0000000000..138485510e --- /dev/null +++ b/tests/lib/Token/RandomBytesGeneratorTest.php @@ -0,0 +1,71 @@ +tokenGenerator = new RandomBytesGenerator(); + } + + /** + * @dataProvider provideDataForTestGenerateToken + * + * @throws \Exception + */ + public function testGenerateToken(int $expectedTokenLength): void + { + $generatedToken = $this->tokenGenerator->generateToken($expectedTokenLength); + + self::assertNotSame( + $generatedToken, + $this->tokenGenerator->generateToken($expectedTokenLength), + 'Token generator should return different values on subsequent calls', + ); + + self::assertSame( + $expectedTokenLength, + strlen($generatedToken) + ); + } + + /** + * @return iterable + */ + public function provideDataForTestGenerateToken(): iterable + { + yield [ + 20, + ]; + + yield [ + 64, + ]; + + yield [ + 100, + ]; + + yield [ + 256, + ]; + } +} diff --git a/tests/lib/Token/WebSafeGeneratorTest.php b/tests/lib/Token/WebSafeGeneratorTest.php new file mode 100644 index 0000000000..e3ed9daafb --- /dev/null +++ b/tests/lib/Token/WebSafeGeneratorTest.php @@ -0,0 +1,103 @@ +randomBytesTokenGenerator = $this->createMock(TokenGeneratorInterface::class); + $this->tokenGenerator = new WebSafeGenerator($this->randomBytesTokenGenerator); + } + + /** + * @dataProvider provideDataForTestGenerateToken + * + * @throws \Exception + */ + public function testGenerateToken( + int $expectedTokenLength, + string $mockGeneratorOutputToken, + string $expectedToken + ): void { + $this->mockTokenGeneratorGenerateToken( + $expectedTokenLength, + $mockGeneratorOutputToken + ); + + $generatedToken = $this->tokenGenerator->generateToken($expectedTokenLength); + + self::assertSame( + $expectedTokenLength, + strlen($generatedToken) + ); + + self::assertSame( + $expectedToken, + $generatedToken + ); + } + + /** + * @return iterable + */ + public function provideDataForTestGenerateToken(): iterable + { + yield [ + 20, + '123456+-1az2w3edc4==', + 'MTIzNDU2Ky0xYXoydzNl', + ]; + + yield [ + 64, + '123/561qaz2wsx3edc4rfv1234561qaz2wsx+-dc=3edc4rv1234561qarfv145=', + 'MTIzLzU2MXFhejJ3c3gzZWRjNHJmdjEyMzQ1NjFxYXoyd3N4Ky1kYz0zZWRjNHJ2', + ]; + + yield [ + 100, + '+-34561qaz2wsx3ec4rfv1234561qax3edc4rfv5tgbz2wsxaz2wsxdc4rfv123ec4rfv1234561qaz2wsx3edc4rfv457yhnzz=', + 'Ky0zNDU2MXFhejJ3c3gzZWM0cmZ2MTIzNDU2MXFheDNlZGM0cmZ2NXRnYnoyd3N4YXoyd3N4ZGM0cmZ2MTIzZWM0cmZ2MTIzNDU2', + ]; + + yield [ + 256, + '1234561qaz2wsx3ed+-rfv1234561qa561qaz2wsx3edc4rfv1==234561qaz2wsxz2wsx3ec4rfv1234561qaz2wsx3edc4rfv145=7yhnzz1234561qaz2wsx3edc4rfv1234561qaz2wsx3ec4rfv1234561qaz2wsx3edc4rfv1fv1234561qaz2wsx3ec4rf==234564567yhnz3ec4rfv1234561qaz2wsx3edc4rfv14567yhnzz12345', + 'MTIzNDU2MXFhejJ3c3gzZWQrLXJmdjEyMzQ1NjFxYTU2MXFhejJ3c3gzZWRjNHJmdjE9PTIzNDU2MXFhejJ3c3h6MndzeDNlYzRyZnYxMjM0NTYxcWF6MndzeDNlZGM0cmZ2MTQ1PTd5aG56ejEyMzQ1NjFxYXoyd3N4M2VkYzRyZnYxMjM0NTYxcWF6MndzeDNlYzRyZnYxMjM0NTYxcWF6MndzeDNlZGM0cmZ2MWZ2MTIzNDU2MXFhejJ3c3gz', + ]; + } + + private function mockTokenGeneratorGenerateToken( + int $length, + string $token + ): void { + $this->randomBytesTokenGenerator + ->expects(self::once()) + ->method('generateToken') + ->with($length) + ->willReturn($token); + } +}