From 539a4acaaa4c187019c23448d7888d6cce094620 Mon Sep 17 00:00:00 2001 From: Syndesi Date: Tue, 14 Nov 2023 23:13:23 +0100 Subject: [PATCH 1/6] wip --- docs/_sidebar.md | 4 +- ...reate-help.html => token-create-help.html} | 2 +- ...er-token-create.html => token-create.html} | 2 +- docs/commands/assets/token-revoke-help.html | 62 ++++ docs/commands/assets/token-revoke.html | 54 +++ docs/commands/token/token-create.md | 17 + docs/commands/token/token-revoke.md | 17 + docs/commands/user/user-token-create.md | 17 - .../src/Service/EmberNexusConfiguration.php | 3 + ...eateCommand.php => TokenCreateCommand.php} | 4 +- src/Command/TokenRevokeCommand.php | 318 ++++++++++++++++++ src/Security/TokenGenerator.php | 1 + src/Type/TokenStateType.php | 17 + 13 files changed, 496 insertions(+), 22 deletions(-) rename docs/commands/assets/{user-token-create-help.html => token-create-help.html} (98%) mode change 100755 => 100644 rename docs/commands/assets/{user-token-create.html => token-create.html} (97%) mode change 100755 => 100644 create mode 100644 docs/commands/assets/token-revoke-help.html create mode 100644 docs/commands/assets/token-revoke.html create mode 100644 docs/commands/token/token-create.md create mode 100644 docs/commands/token/token-revoke.md delete mode 100644 docs/commands/user/user-token-create.md rename src/Command/{UserTokenCreateCommand.php => TokenCreateCommand.php} (97%) mode change 100755 => 100644 create mode 100644 src/Command/TokenRevokeCommand.php create mode 100644 src/Type/TokenStateType.php diff --git a/docs/_sidebar.md b/docs/_sidebar.md index b41d3604..80a4a973 100755 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -67,7 +67,9 @@ - [`healthcheck`](/commands/system/healthcheck) - **User Commands** - [`user:create`](/commands/user/user-create) - - [`user:token:create`](/commands/user/user-token-create) + - **Token Commands** + - [`token:create`](/commands/token/token-create) + - [`token:revoke`](/commands/token/token-revoke) - **Backup Commands** - [`backup:list`](/commands/backup/backup-list) - [`backup:fetch`](/commands/backup/backup-fetch) diff --git a/docs/commands/assets/user-token-create-help.html b/docs/commands/assets/token-create-help.html old mode 100755 new mode 100644 similarity index 98% rename from docs/commands/assets/user-token-create-help.html rename to docs/commands/assets/token-create-help.html index bd1cb845..f0a06596 --- a/docs/commands/assets/user-token-create-help.html +++ b/docs/commands/assets/token-create-help.html @@ -42,7 +42,7 @@ Creates a new token for an user. Usage: - user:token:create <identifier> <password> + token:create <identifier> <password> Arguments: identifier Unique identifier of the user (email) or the user's UUID diff --git a/docs/commands/assets/user-token-create.html b/docs/commands/assets/token-create.html old mode 100755 new mode 100644 similarity index 97% rename from docs/commands/assets/user-token-create.html rename to docs/commands/assets/token-create.html index 7db1819a..ca5ced76 --- a/docs/commands/assets/user-token-create.html +++ b/docs/commands/assets/token-create.html @@ -43,7 +43,7 @@ ▀ ▀ Ember Nexus API v0.0.38, production mode - User Token Create + Token Create Found user with identifier 'command-test@localhost.dev', user's UUID is d604dae6-3922-45bd-aa76-0a9f1fcce1d5. diff --git a/docs/commands/assets/token-revoke-help.html b/docs/commands/assets/token-revoke-help.html new file mode 100644 index 00000000..1e5cd1fa --- /dev/null +++ b/docs/commands/assets/token-revoke-help.html @@ -0,0 +1,62 @@ + + + + + + +stdin + + + + +
+Description:
+  Creates a new token for an user.
+
+Usage:
+  token:create <identifier> <password>
+
+Arguments:
+  identifier            Unique identifier of the user (email) or the user's UUID
+  password              Password of the user
+
+Options:
+  -h, --help            Display help for the given command. When no command is given display help for the list command
+  -q, --quiet           Do not output any message
+  -V, --version         Display this application version
+      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
+  -n, --no-interaction  Do not ask any interactive question
+  -e, --env=ENV         The Environment name. [default: "prod"]
+      --no-debug        Switch off debug mode.
+  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
+
+ + diff --git a/docs/commands/assets/token-revoke.html b/docs/commands/assets/token-revoke.html new file mode 100644 index 00000000..734e7010 --- /dev/null +++ b/docs/commands/assets/token-revoke.html @@ -0,0 +1,54 @@ + + + + + + +stdin + + + + +
+
+       
+  ▀ ▀  Ember Nexus API
+      v0.0.36, prod mode
+
+  Token Create
+
+  Found user with identifier 'command-test@localhost.dev', user's UUID is a0c8fe7b-f5be-4c08-9dff-44582081ea7c.
+
+▶ Successfully created new token: secret-token:TBOdmNurTLRk0WNrOi0Qdt .
+
+
+ + diff --git a/docs/commands/token/token-create.md b/docs/commands/token/token-create.md new file mode 100644 index 00000000..9706c8c8 --- /dev/null +++ b/docs/commands/token/token-create.md @@ -0,0 +1,17 @@ +# `token:create` + +
Help Output
+ +```bash +php bin/console token:create --help +``` + +[](../assets/token-create-help.html ':include :type=html') + +
Example Command
+ +```bash +php bin/console token:create command-test@localhost.dev 1234 +``` + +[](../assets/token-create.html ':include :type=html') diff --git a/docs/commands/token/token-revoke.md b/docs/commands/token/token-revoke.md new file mode 100644 index 00000000..b9edd4de --- /dev/null +++ b/docs/commands/token/token-revoke.md @@ -0,0 +1,17 @@ +# `token:revoke` + +
Help Output
+ +```bash +php bin/console token:revoke --help +``` + +[](../assets/token-revoke-help.html ':include :type=html') + +
Example Command
+ +```bash +php bin/console token:revoke +``` + +[](../assets/token-revoke.html ':include :type=html') diff --git a/docs/commands/user/user-token-create.md b/docs/commands/user/user-token-create.md deleted file mode 100644 index 244a2240..00000000 --- a/docs/commands/user/user-token-create.md +++ /dev/null @@ -1,17 +0,0 @@ -# `user:token:create` - -
Help Output
- -```bash -php bin/console user:token:create --help -``` - -[](../assets/user-token-create-help.html ':include :type=html') - -
Example Command
- -```bash -php bin/console user:token:create command-test@localhost.dev 1234 -``` - -[](../assets/user-token-create.html ':include :type=html') diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index 8aeccb5f..53b98d63 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -241,6 +241,9 @@ public function setRegisterUniqueIdentifier(string $registerUniqueIdentifier): E if (0 === strlen($registerUniqueIdentifier)) { throw new Exception('Unique identifier can not be an empty string.'); } + if (!preg_match("/^[A-Za-z0-9_]+$/", $registerUniqueIdentifier)) { + throw new Exception("Unique identifier must only contain alphanumeric characters and '_'."); + } $this->registerUniqueIdentifier = $registerUniqueIdentifier; return $this; diff --git a/src/Command/UserTokenCreateCommand.php b/src/Command/TokenCreateCommand.php old mode 100755 new mode 100644 similarity index 97% rename from src/Command/UserTokenCreateCommand.php rename to src/Command/TokenCreateCommand.php index f2efbaf3..ebdf42fa --- a/src/Command/UserTokenCreateCommand.php +++ b/src/Command/TokenCreateCommand.php @@ -22,8 +22,8 @@ /** * @psalm-suppress PropertyNotSetInConstructor $io */ -#[AsCommand(name: 'user:token:create', description: 'Creates a new token for an user.')] -class UserTokenCreateCommand extends Command +#[AsCommand(name: 'token:create', description: 'Creates a new token for an user.')] +class TokenCreateCommand extends Command { private OutputStyle $io; diff --git a/src/Command/TokenRevokeCommand.php b/src/Command/TokenRevokeCommand.php new file mode 100644 index 00000000..c5c0695f --- /dev/null +++ b/src/Command/TokenRevokeCommand.php @@ -0,0 +1,318 @@ +addOption( + self::OPTION_FORCE, + 'f', + InputOption::VALUE_NEGATABLE, + 'If enabled, command will not ask for manual confirmation.', + false + ); + $this->addOption( + self::OPTION_DRY_RUN, + null, + InputOption::VALUE_NEGATABLE, + 'Lists tokens which would be affected by revocation. Does not apply said revocation.', + false + ); + // command filters + $this->addOption( + self::OPTION_USER, + 'u', + InputOption::VALUE_REQUIRED, + 'Token revocation is only applied to given user. User can be specified by its UUID (takes precedent) or its identifier.', + null + ); + $this->addOption( + self::OPTION_GROUP, + 'g', + InputOption::VALUE_REQUIRED, + 'Token revocation is only applied to users of given group. Group must be specified by its UUID.', + null + ); + $this->addOption( + self::OPTION_ISSUED_BEFORE, + 'b', + InputOption::VALUE_REQUIRED, + 'Token revocation is only applied to tokens issued before a given datetime. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).', + null + ); + $this->addOption( + self::OPTION_ISSUED_AFTER, + 'a', + InputOption::VALUE_REQUIRED, + 'Token revocation is only applied to tokens issued after a given datetime. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).', + null + ); + $this->addOption( + self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE, + null, + InputOption::VALUE_NEGATABLE, + 'Token revocation is only applied to tokens with no explicit expiration date.', + false + ); + $this->addOption( + self::OPTION_ISSUED_WITH_EXPIRATION_DATE, + null, + InputOption::VALUE_NEGATABLE, + 'Token revocation is only applied to tokens with explicit expiration date set.', + false + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->io = new EmberNexusStyle($input, $output); + + $this->io->title('Token Revocation'); + + $filters = []; + $arguments = []; + + if ( + $input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE) === true && + $input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE) === true + ) { + throw new Exception(sprintf( + "Using both %s and %s is not possible.", + self::OPTION_ISSUED_WITH_EXPIRATION_DATE, + self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE + )); + } + if ($input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE) === true) { + $filters[] = 't.expirationDate IS NOT NULL'; + } + if ($input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE) === true) { + $filters[] = 't.expirationDate IS NULL'; + } + + + $optionIssuedBefore = null; + if ($input->getOption(self::OPTION_ISSUED_BEFORE) !== null) { + $optionIssuedBefore = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_BEFORE)); + $filters[] = 't.created < $issuedBefore'; + $arguments['issuedBefore'] = $optionIssuedBefore; + } + + $optionIssuedAfter = null; + if ($input->getOption(self::OPTION_ISSUED_AFTER) !== null) { + $optionIssuedAfter = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_AFTER)); + $filters[] = 't.created > $issuedAfter'; + $arguments['issuedAfter'] = $optionIssuedAfter; + } + + if ($optionIssuedBefore && $optionIssuedAfter) { + if ($optionIssuedBefore->getTimestamp() < $optionIssuedAfter->getTimestamp()) { + throw new Exception(sprintf( + "%s can not be before %s.", + self::OPTION_ISSUED_BEFORE, + self::OPTION_ISSUED_AFTER + )); + } + } + + if ($input->getOption(self::OPTION_USER) !== null) { + $userIdentifier = $input->getOption(self::OPTION_USER); + if (preg_match(Regex::UUID_V4, $userIdentifier)) { + $filters[] = 'u.id = $userIdentifier'; + } else { + $filters[] = sprintf( + "u.%s = \$userIdentifier", + $this->emberNexusConfiguration->getRegisterUniqueIdentifier() + ); + } + $arguments['userIdentifier'] = $userIdentifier; + } + + if ($input->getOption(self::OPTION_GROUP) !== null) { + $filters[] = '(t)<-[:OWNS]-(:User)-[:IS_IN_GROUP*1..]->(:Group {id: $groupIdentifier})'; + $arguments['groupIdentifier'] = $input->getOption(self::OPTION_GROUP); + } + + $joinedFilters = join("\nAND ", $filters); + $finalQuery = sprintf( + "MATCH (t:Token)<-[:OWNS]-(u:User)\n". + "%s%s%s". + "RETURN t.id, t.created, t.expirationDate, u.id, u.%s as userUniqueIdentifier\n". + "ORDER BY t.created ASC, t.id ASC", + count($filters) > 0 ? 'WHERE ' : '', + $joinedFilters, + count($filters) > 0 ? "\n" : '', + $this->emberNexusConfiguration->getRegisterUniqueIdentifier() + ); + +// $this->io->writeln("----------------------------"); +// +// foreach ($filters as $filter) { +// $this->io->writeln(' '.$filter); +// } +// +// $this->io->writeln("----------------------------"); + + $res = $this->cypherEntityManager->getClient()->runStatement( + new Statement($finalQuery, $arguments) + ); + + + $countTokensToBeRevoked = count($res); + if ($countTokensToBeRevoked === 0) { + $this->io->finalMessage('No tokens found.'); + return Command::SUCCESS; + } + + $this->io->writeln(sprintf( + " %d tokens are affected of revocation%s", + $countTokensToBeRevoked, + $countTokensToBeRevoked > 10 ? '. First 10 are shown:' : ':' + )); + $this->io->newLine(); + + $rows = []; + foreach ($res as $i => $tokenResult) { + $tokenCreated = $tokenResult['t.created']; + if ($tokenCreated instanceof DateTimeZoneId) { + $tokenCreated = $tokenCreated->toDateTime()->format('Y-m-d H:i:s'); + } + $tokenExpires = $tokenResult['t.expirationDate']; + if ($tokenExpires instanceof DateTimeZoneId) { + $tokenExpires = $tokenExpires->toDateTime()->format('Y-m-d H:i:s'); + } + $rows[] = [ + $tokenResult['t.id'], + $tokenResult['u.id'], + $tokenResult['userUniqueIdentifier'], + $tokenCreated, + $tokenExpires ?? '-' + ]; + if (count($rows) >= 10) { + break; + } + } + + $table = $this->io->createCompactTable(); + $table->setHeaders([ + 'Token UUID', + 'User UUID', + 'User identifier', + 'Created', + 'Expires on' + ]); + $table->setRows($rows); + $table->render(); + $this->io->newLine(); + + + /** + * @var QuestionHelper $helper + */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion(sprintf( + " Are you sure you want to revoke all %d tokens? [y/N]: ", + $countTokensToBeRevoked + ), false); + if (!$helper->ask($input, $output, $question)) { + $this->io->finalMessage('Aborted revoking tokens.'); + + return self::FAILURE; + } + $this->io->newLine(); + +// foreach ($res as $tokenResult) { +// $this->io->writeln(sprintf( +// "Token: %s, owned by %s", +// $tokenResult['t.id'], +// $tokenResult['userUniqueIdentifier'] +// )); +// } + + + + + + + + + +// $normalizedArguments = []; +// foreach ($arguments as $i => $argument) { +// if ($argument instanceof \DateTimeInterface) { +// $argument = 'datetime("'.$argument->format('c').'")'; +// } else { +// $argument = \Safe\json_encode($argument, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +// } +// $normalizedArguments[] = sprintf( +// " %s: %s,", +// $i, +// $argument +// ); +// } +// +// $finalArguments = sprintf( +// ":params\n{\n%s\n}", +// join("\n", $normalizedArguments) +// ); +// +// $this->io->writeln($finalArguments); +// $this->io->newLine(); +// $this->io->writeln($finalQuery); + + $this->io->finalMessage('Successfully revoked tokens.'); + + return Command::SUCCESS; + } +} diff --git a/src/Security/TokenGenerator.php b/src/Security/TokenGenerator.php index b04d8516..7e618c1d 100755 --- a/src/Security/TokenGenerator.php +++ b/src/Security/TokenGenerator.php @@ -71,6 +71,7 @@ public function createNewToken(UuidInterface $userUuid, array $data = [], int $l ->addProperties([ 'hash' => $hash, 'expirationDate' => (new DateTime())->add(new DateInterval(sprintf('PT%sS', $lifetimeInSeconds))), + 'state' => '' ]); $this->elementManager->create($tokenNode); $this->elementManager->flush(); diff --git a/src/Type/TokenStateType.php b/src/Type/TokenStateType.php new file mode 100644 index 00000000..2270f998 --- /dev/null +++ b/src/Type/TokenStateType.php @@ -0,0 +1,17 @@ + Date: Fri, 1 Dec 2023 11:08:54 +0100 Subject: [PATCH 2/6] Add command `token:revoke`, closes #59. Rename command `user:token:create` to `token:create`, related to #59. --- CHANGELOG.md | 3 +- .../src/Service/EmberNexusConfiguration.php | 2 +- src/Command/TokenRevokeCommand.php | 148 ++++++++---------- ...piKeyCheckOnKernelRequestEventListener.php | 4 +- src/Security/TokenGenerator.php | 3 +- src/Type/TokenStateType.php | 8 - .../FeatureTests/Security/TokenStateTest.php | 31 ++++ 7 files changed, 103 insertions(+), 96 deletions(-) create mode 100644 tests/FeatureTests/Security/TokenStateTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c32756d..a5056ee2 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - Add documentation and automatic documentation tests for endpoint DELETE `/token`, closes #208. +- Add command `token:revoke`, closes #59. ### Changed - **Switch license to AGPL-3.0-only, closes #215.** - Remove commented and unused code, configuration etc., closes #168. - Remove test CI triggers "pull_request" and "fork", closes #216. +- Rename command `user:token:create` to `token:create`, related to #59. ### Fixed - Deleting tokens now deletes the correct token from Redis, fixes #186. - Add missing feature tests, related to #168. @@ -20,7 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add documentation and automatic example generation for healthcheck command, closes [#184]. - Add 401 error case for the GET /me endpoint, closes [#190]. - Add parameters `page` and `pageSize` to documentation of collection endpoints, closes [#189]. - ### Changed - Rename `_PartialUnifiedCollection` to `_PartialElementCollection`, closes [#187]. - All CI tasks are configured to have timeouts, closes [#201]. diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index 53b98d63..98bc4638 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -241,7 +241,7 @@ public function setRegisterUniqueIdentifier(string $registerUniqueIdentifier): E if (0 === strlen($registerUniqueIdentifier)) { throw new Exception('Unique identifier can not be an empty string.'); } - if (!preg_match("/^[A-Za-z0-9_]+$/", $registerUniqueIdentifier)) { + if (!preg_match('/^[A-Za-z0-9_]+$/', $registerUniqueIdentifier)) { throw new Exception("Unique identifier must only contain alphanumeric characters and '_'."); } $this->registerUniqueIdentifier = $registerUniqueIdentifier; diff --git a/src/Command/TokenRevokeCommand.php b/src/Command/TokenRevokeCommand.php index c5c0695f..91b4b01c 100644 --- a/src/Command/TokenRevokeCommand.php +++ b/src/Command/TokenRevokeCommand.php @@ -11,12 +11,10 @@ use Exception; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Types\DateTimeZoneId; -use Ramsey\Uuid\Uuid; use Safe\DateTime; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -34,14 +32,14 @@ class TokenRevokeCommand extends Command { private OutputStyle $io; - const OPTION_FORCE = 'force'; - const OPTION_DRY_RUN = 'dry-run'; - const OPTION_USER = 'user'; - const OPTION_GROUP = 'group'; - const OPTION_ISSUED_BEFORE = 'issued-before'; - const OPTION_ISSUED_AFTER = 'issued-after'; - const OPTION_ISSUED_WITHOUT_EXPIRATION_DATE = 'issued-without-expiration-date'; - const OPTION_ISSUED_WITH_EXPIRATION_DATE = 'issued-with-expiration-date'; + public const OPTION_FORCE = 'force'; + public const OPTION_DRY_RUN = 'dry-run'; + public const OPTION_USER = 'user'; + public const OPTION_GROUP = 'group'; + public const OPTION_ISSUED_BEFORE = 'issued-before'; + public const OPTION_ISSUED_AFTER = 'issued-after'; + public const OPTION_ISSUED_WITHOUT_EXPIRATION_DATE = 'issued-without-expiration-date'; + public const OPTION_ISSUED_WITH_EXPIRATION_DATE = 'issued-with-expiration-date'; public function __construct( private ElementManager $elementManager, @@ -125,32 +123,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int $arguments = []; if ( - $input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE) === true && - $input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE) === true + true === $input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE) + && true === $input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE) ) { - throw new Exception(sprintf( - "Using both %s and %s is not possible.", - self::OPTION_ISSUED_WITH_EXPIRATION_DATE, - self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE - )); + throw new Exception(sprintf('Using both %s and %s is not possible.', self::OPTION_ISSUED_WITH_EXPIRATION_DATE, self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE)); } - if ($input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE) === true) { + if (true === $input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE)) { $filters[] = 't.expirationDate IS NOT NULL'; } - if ($input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE) === true) { + if (true === $input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE)) { $filters[] = 't.expirationDate IS NULL'; } - $optionIssuedBefore = null; - if ($input->getOption(self::OPTION_ISSUED_BEFORE) !== null) { + if (null !== $input->getOption(self::OPTION_ISSUED_BEFORE)) { $optionIssuedBefore = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_BEFORE)); $filters[] = 't.created < $issuedBefore'; $arguments['issuedBefore'] = $optionIssuedBefore; } $optionIssuedAfter = null; - if ($input->getOption(self::OPTION_ISSUED_AFTER) !== null) { + if (null !== $input->getOption(self::OPTION_ISSUED_AFTER)) { $optionIssuedAfter = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_AFTER)); $filters[] = 't.created > $issuedAfter'; $arguments['issuedAfter'] = $optionIssuedAfter; @@ -158,28 +151,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($optionIssuedBefore && $optionIssuedAfter) { if ($optionIssuedBefore->getTimestamp() < $optionIssuedAfter->getTimestamp()) { - throw new Exception(sprintf( - "%s can not be before %s.", - self::OPTION_ISSUED_BEFORE, - self::OPTION_ISSUED_AFTER - )); + throw new Exception(sprintf('%s can not be before %s.', self::OPTION_ISSUED_BEFORE, self::OPTION_ISSUED_AFTER)); } } - if ($input->getOption(self::OPTION_USER) !== null) { + if (null !== $input->getOption(self::OPTION_USER)) { $userIdentifier = $input->getOption(self::OPTION_USER); if (preg_match(Regex::UUID_V4, $userIdentifier)) { $filters[] = 'u.id = $userIdentifier'; } else { $filters[] = sprintf( - "u.%s = \$userIdentifier", + 'u.%s = $userIdentifier', $this->emberNexusConfiguration->getRegisterUniqueIdentifier() ); } $arguments['userIdentifier'] = $userIdentifier; } - if ($input->getOption(self::OPTION_GROUP) !== null) { + if (null !== $input->getOption(self::OPTION_GROUP)) { $filters[] = '(t)<-[:OWNS]-(:User)-[:IS_IN_GROUP*1..]->(:Group {id: $groupIdentifier})'; $arguments['groupIdentifier'] = $input->getOption(self::OPTION_GROUP); } @@ -187,36 +176,36 @@ protected function execute(InputInterface $input, OutputInterface $output): int $joinedFilters = join("\nAND ", $filters); $finalQuery = sprintf( "MATCH (t:Token)<-[:OWNS]-(u:User)\n". - "%s%s%s". + '%s%s%s'. "RETURN t.id, t.created, t.expirationDate, u.id, u.%s as userUniqueIdentifier\n". - "ORDER BY t.created ASC, t.id ASC", + 'ORDER BY t.created ASC, t.id ASC', count($filters) > 0 ? 'WHERE ' : '', $joinedFilters, count($filters) > 0 ? "\n" : '', $this->emberNexusConfiguration->getRegisterUniqueIdentifier() ); -// $this->io->writeln("----------------------------"); -// -// foreach ($filters as $filter) { -// $this->io->writeln(' '.$filter); -// } -// -// $this->io->writeln("----------------------------"); + // $this->io->writeln("----------------------------"); + // + // foreach ($filters as $filter) { + // $this->io->writeln(' '.$filter); + // } + // + // $this->io->writeln("----------------------------"); $res = $this->cypherEntityManager->getClient()->runStatement( new Statement($finalQuery, $arguments) ); - $countTokensToBeRevoked = count($res); - if ($countTokensToBeRevoked === 0) { + if (0 === $countTokensToBeRevoked) { $this->io->finalMessage('No tokens found.'); + return Command::SUCCESS; } $this->io->writeln(sprintf( - " %d tokens are affected of revocation%s", + ' %d tokens are affected of revocation%s', $countTokensToBeRevoked, $countTokensToBeRevoked > 10 ? '. First 10 are shown:' : ':' )); @@ -237,7 +226,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $tokenResult['u.id'], $tokenResult['userUniqueIdentifier'], $tokenCreated, - $tokenExpires ?? '-' + $tokenExpires ?? '-', ]; if (count($rows) >= 10) { break; @@ -250,19 +239,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'User UUID', 'User identifier', 'Created', - 'Expires on' + 'Expires on', ]); $table->setRows($rows); $table->render(); $this->io->newLine(); - /** * @var QuestionHelper $helper */ $helper = $this->getHelper('question'); $question = new ConfirmationQuestion(sprintf( - " Are you sure you want to revoke all %d tokens? [y/N]: ", + ' Are you sure you want to revoke all %d tokens? [y/N]: ', $countTokensToBeRevoked ), false); if (!$helper->ask($input, $output, $question)) { @@ -272,44 +260,36 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $this->io->newLine(); -// foreach ($res as $tokenResult) { -// $this->io->writeln(sprintf( -// "Token: %s, owned by %s", -// $tokenResult['t.id'], -// $tokenResult['userUniqueIdentifier'] -// )); -// } - - - - - - - - - -// $normalizedArguments = []; -// foreach ($arguments as $i => $argument) { -// if ($argument instanceof \DateTimeInterface) { -// $argument = 'datetime("'.$argument->format('c').'")'; -// } else { -// $argument = \Safe\json_encode($argument, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); -// } -// $normalizedArguments[] = sprintf( -// " %s: %s,", -// $i, -// $argument -// ); -// } -// -// $finalArguments = sprintf( -// ":params\n{\n%s\n}", -// join("\n", $normalizedArguments) -// ); -// -// $this->io->writeln($finalArguments); -// $this->io->newLine(); -// $this->io->writeln($finalQuery); + // foreach ($res as $tokenResult) { + // $this->io->writeln(sprintf( + // "Token: %s, owned by %s", + // $tokenResult['t.id'], + // $tokenResult['userUniqueIdentifier'] + // )); + // } + + // $normalizedArguments = []; + // foreach ($arguments as $i => $argument) { + // if ($argument instanceof \DateTimeInterface) { + // $argument = 'datetime("'.$argument->format('c').'")'; + // } else { + // $argument = \Safe\json_encode($argument, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + // } + // $normalizedArguments[] = sprintf( + // " %s: %s,", + // $i, + // $argument + // ); + // } + // + // $finalArguments = sprintf( + // ":params\n{\n%s\n}", + // join("\n", $normalizedArguments) + // ); + // + // $this->io->writeln($finalArguments); + // $this->io->newLine(); + // $this->io->writeln($finalQuery); $this->io->finalMessage('Successfully revoked tokens.'); diff --git a/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php b/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php index 6e292245..7570ac43 100644 --- a/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php +++ b/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php @@ -5,6 +5,7 @@ use App\Factory\Exception\Client401UnauthorizedExceptionFactory; use App\Security\AuthProvider; use App\Security\TokenGenerator; +use App\Type\TokenStateType; use App\Type\UserUuidAndTokenUuidObject; use Laudis\Neo4j\Databags\Statement; use Predis\Client; @@ -57,9 +58,10 @@ private function getUserUuidAndTokenUuidObjectFromTokenFromCypher(string $token) $hashedToken = $this->tokenGenerator->hashToken($token); $res = $this->cypherEntityManager->getClient()->runStatement( Statement::create( - 'MATCH (user:User)-[:OWNS]->(token:Token {hash: $hash}) RETURN user.id, token.id', + 'MATCH (user:User)-[:OWNS]->(token:Token {hash: $hash, state: $state}) RETURN user.id, token.id', [ 'hash' => $hashedToken, + 'state' => TokenStateType::ACTIVE->value, ] ) ); diff --git a/src/Security/TokenGenerator.php b/src/Security/TokenGenerator.php index 7e618c1d..9ae33c77 100755 --- a/src/Security/TokenGenerator.php +++ b/src/Security/TokenGenerator.php @@ -5,6 +5,7 @@ use App\Service\ElementManager; use App\Type\NodeElement; use App\Type\RelationElement; +use App\Type\TokenStateType; use DateInterval; use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Databags\Statement; @@ -71,7 +72,7 @@ public function createNewToken(UuidInterface $userUuid, array $data = [], int $l ->addProperties([ 'hash' => $hash, 'expirationDate' => (new DateTime())->add(new DateInterval(sprintf('PT%sS', $lifetimeInSeconds))), - 'state' => '' + 'state' => TokenStateType::ACTIVE->value, ]); $this->elementManager->create($tokenNode); $this->elementManager->flush(); diff --git a/src/Type/TokenStateType.php b/src/Type/TokenStateType.php index 2270f998..2bbff96c 100644 --- a/src/Type/TokenStateType.php +++ b/src/Type/TokenStateType.php @@ -6,12 +6,4 @@ enum TokenStateType: string { case ACTIVE = 'ACTIVE'; case REVOKED = 'REVOKED'; - - - - case READ = 'READ'; - case CREATE = 'CREATE'; - case UPDATE = 'UPDATE'; - case DELETE = 'DELETE'; - case SEARCH = 'SEARCH'; } diff --git a/tests/FeatureTests/Security/TokenStateTest.php b/tests/FeatureTests/Security/TokenStateTest.php new file mode 100644 index 00000000..64fd917a --- /dev/null +++ b/tests/FeatureTests/Security/TokenStateTest.php @@ -0,0 +1,31 @@ +runGetRequest(sprintf('/%s', self::DATA_UUID), self::TOKEN_ACTIVE); + $this->assertIsNodeResponse($indexResponse, 'Data'); + } + + public function testAccessingApiWithRevokedTokenDoesNotWork(): void + { + $indexResponse = $this->runGetRequest(sprintf('/%s', self::DATA_UUID), self::TOKEN_REVOKED); + $this->assertIsProblemResponse($indexResponse, 401); + } + + public function testAccessingApiWithExpiredTokenDoesNotWork(): void + { + $indexResponse = $this->runGetRequest(sprintf('/%s', self::DATA_UUID), self::TOKEN_EXPIRED); + $this->assertIsProblemResponse($indexResponse, 401); + } +} From ae203b051dd6c3faaa0389858d514ecf4e87cfbc Mon Sep 17 00:00:00 2001 From: Syndesi Date: Sun, 3 Dec 2023 11:47:15 +0100 Subject: [PATCH 3/6] wip --- docs/commands/assets/backup-load.html | 4 +- docs/commands/assets/token-revoke-help.html | 35 ++-- docs/commands/assets/token-revoke.html | 26 ++- docs/commands/token/token-revoke.md | 2 +- src/Command/BackupCreateCommand.php | 20 +- src/Command/BackupFetchCommand.php | 11 +- src/Command/BackupLoadCommand.php | 20 +- src/Command/TokenCreateCommand.php | 2 +- src/Command/TokenRevokeCommand.php | 176 ++++++++++-------- src/Style/EmberNexusStyle.php | 10 + .../Backup/BackupFetchTest.php | 2 +- .../TokenCreateTest.php} | 16 +- .../Token/TokenRevokeTest.php | 38 ++++ 13 files changed, 228 insertions(+), 134 deletions(-) rename tests/ExampleGenerationCommand/{User/UserTokenCreateTest.php => Token/TokenCreateTest.php} (51%) create mode 100644 tests/ExampleGenerationCommand/Token/TokenRevokeTest.php diff --git a/docs/commands/assets/backup-load.html b/docs/commands/assets/backup-load.html index 86b7d449..a4cd7afd 100644 --- a/docs/commands/assets/backup-load.html +++ b/docs/commands/assets/backup-load.html @@ -46,10 +46,10 @@ Backup Load Step 1 of 4: Loading Nodes ─────────────────────────────────────────────────── -└ Loaded 291 nodes. +└ Loaded 306 nodes. ┌ Step 2 of 4: Loading Relations ─────────────────────────────────────────────── -└ Loaded 344 relations. +└ Loaded 356 relations. ┌ Step 3 of 4: Loading Files ─────────────────────────────────────────────────── │ Currently not implemented. diff --git a/docs/commands/assets/token-revoke-help.html b/docs/commands/assets/token-revoke-help.html index 1e5cd1fa..f5d1ebc7 100644 --- a/docs/commands/assets/token-revoke-help.html +++ b/docs/commands/assets/token-revoke-help.html @@ -39,24 +39,29 @@
 Description:
-  Creates a new token for an user.
+  Revoke tokens.
 
 Usage:
-  token:create <identifier> <password>
-
-Arguments:
-  identifier            Unique identifier of the user (email) or the user's UUID
-  password              Password of the user
+  token:revoke [options]
 
 Options:
-  -h, --help            Display help for the given command. When no command is given display help for the list command
-  -q, --quiet           Do not output any message
-  -V, --version         Display this application version
-      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
-  -n, --no-interaction  Do not ask any interactive question
-  -e, --env=ENV         The Environment name. [default: "prod"]
-      --no-debug        Switch off debug mode.
-  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
+  -f, --force|--no-force                                        If enabled, command will not ask for manual confirmation.
+      --dry-run|--no-dry-run                                    Does not apply revocation. Also lists all tokens which would be affected.
+  -l, --list-all-affected-tokens|--no-list-all-affected-tokens  Lists all tokens.
+  -u, --user=USER                                               Token revocation is only applied to an given user. User can be specified by its UUID (takes precedent) or its identifier.
+  -g, --group=GROUP                                             Token revocation is only applied to users of given group. Group must be specified by its UUID.
+  -b, --issued-before=ISSUED-BEFORE                             Token revocation is only applied to tokens issued before a given point in time. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).
+  -a, --issued-after=ISSUED-AFTER                               Token revocation is only applied to tokens issued after a given point in time. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).
+      --issued-without-expiration-date                          Token revocation is only applied to tokens with no explicit expiration date.
+      --issued-with-expiration-date                             Token revocation is only applied to tokens with explicit expiration date set.
+  -h, --help                                                    Display help for the given command. When no command is given display help for the list command
+  -q, --quiet                                                   Do not output any message
+  -V, --version                                                 Display this application version
+      --ansi|--no-ansi                                          Force (or disable --no-ansi) ANSI output
+  -n, --no-interaction                                          Do not ask any interactive question
+  -e, --env=ENV                                                 The Environment name. [default: "prod"]
+      --no-debug                                                Switch off debug mode.
+  -v|vv|vvv, --verbose                                          Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
 
- + \ No newline at end of file diff --git a/docs/commands/assets/token-revoke.html b/docs/commands/assets/token-revoke.html index 734e7010..28b4c277 100644 --- a/docs/commands/assets/token-revoke.html +++ b/docs/commands/assets/token-revoke.html @@ -39,16 +39,30 @@
 
-       
+    
   ▀ ▀  Ember Nexus API
-      v0.0.36, prod mode
+      v0.0.37, production mode
 
-  Token Create
+  Token Revocation
 
-  Found user with identifier 'command-test@localhost.dev', user's UUID is a0c8fe7b-f5be-4c08-9dff-44582081ea7c.
+  51 tokens are affected by revocation. First 10 are shown:
 
-▶ Successfully created new token: secret-token:TBOdmNurTLRk0WNrOi0Qdt .
+  Token UUID                            User UUID                             User identifier                                     Created              Expires on 
+  374fa7ea-4df7-475c-a6a6-b35ff7b9bae1  1a0fd886-9f62-497f-8d20-bd61a40b49b7  user@node.deleteElement.endpoint.localhost.dev      2023-12-02 17:18:39  -
+  840fee16-996e-4b1a-af2a-77d77c4aeda7  a70e49dc-ec45-414a-beb2-43e6fa56eb41  user@relation.deleteElement.endpoint.localhost.dev  2023-12-02 17:18:39  -
+  5f450171-c535-4365-ac70-a283955d4ff5  4250a83d-707b-4f7a-b7dc-225af430f093  user@node.patchElement.endpoint.localhost.dev       2023-12-02 17:18:39  -
+  9543e70d-6552-4409-ab6a-dfaf003382a2  8d70608f-ebca-4077-98d2-12733b7fd08e  user@relation.patchElement.endpoint.localhost.dev   2023-12-02 17:18:39  -
+  6009d74a-1db9-4d1a-bc16-a948706dca58  2274873a-b9a2-4e77-b330-900857b76c1d  user@node.postIndex.endpoint.localhost.dev          2023-12-02 17:18:39  -
+  571b30e2-40eb-43f5-a888-a6b7ec6c15fc  fdf0509d-812c-4435-a863-a4fd6f17e9c4  user@relation.postIndex.endpoint.localhost.dev      2023-12-02 17:18:39  -
+  ca9fa4fe-1fbd-4e8a-a6bf-701d97a7ec12  7935a9d2-e453-4dec-bc4a-243e3ced678a  user@node.putElement.endpoint.localhost.dev         2023-12-02 17:18:39  -
+  84e32a4f-840b-435a-a064-2e72f19d8b20  53ae199c-6b71-44c1-b10c-fca306b32cbf  user@relation.putElement.endpoint.localhost.dev     2023-12-02 17:18:39  -
+  befd839e-5711-414e-8fe3-a8a3225dd603  1eb25cca-9c9e-4ec9-a9be-9044741b2b8c  user@deleteToken.user.endpoint.localhost.dev        2023-12-02 17:18:39  -
+  ed1891bf-d7a8-4e64-ba0d-ebec82ad173e  1eb25cca-9c9e-4ec9-a9be-9044741b2b8c  user@deleteToken.user.endpoint.localhost.dev        2023-12-02 17:18:39  -
+
+  Revoking tokens...
+
+▶ Successfully revoked tokens.
 
 
- + \ No newline at end of file diff --git a/docs/commands/token/token-revoke.md b/docs/commands/token/token-revoke.md index b9edd4de..955cc201 100644 --- a/docs/commands/token/token-revoke.md +++ b/docs/commands/token/token-revoke.md @@ -11,7 +11,7 @@ php bin/console token:revoke --help
Example Command
```bash -php bin/console token:revoke +php bin/console token:revoke -f ``` [](../assets/token-revoke.html ':include :type=html') diff --git a/src/Command/BackupCreateCommand.php b/src/Command/BackupCreateCommand.php index 47c9dcda..6939c94e 100755 --- a/src/Command/BackupCreateCommand.php +++ b/src/Command/BackupCreateCommand.php @@ -122,8 +122,8 @@ private function backupNodes(): void 'Found %d nodes.', $this->nodeCount )); - $progressBar = $this->io->createProgressBar($this->nodeCount); - $progressBar->display(); + $progressBar = $this->io->createProgressBarInInteractiveTerminal($this->nodeCount); + $progressBar?->display(); $nextPage = true; $currentPage = 0; while ($nextPage) { @@ -156,10 +156,10 @@ private function backupNodes(): void } ++$currentPage; - $progressBar->advance(count($nodeIds)); - $progressBar->display(); + $progressBar?->advance(count($nodeIds)); + $progressBar?->display(); } - $progressBar->clear(); + $progressBar?->clear(); $this->io->stopSection(sprintf( 'Successfully backed up %d nodes.', $this->nodeCount @@ -173,8 +173,8 @@ private function backupRelations(): void 'Found %d relations.', $this->relationCount )); - $progressBar = $this->io->createProgressBar($this->relationCount); - $progressBar->display(); + $progressBar = $this->io->createProgressBarInInteractiveTerminal($this->relationCount); + $progressBar?->display(); $nextPage = true; $currentPage = 0; while ($nextPage) { @@ -207,10 +207,10 @@ private function backupRelations(): void } ++$currentPage; - $progressBar->advance(count($relationIds)); - $progressBar->display(); + $progressBar?->advance(count($relationIds)); + $progressBar?->display(); } - $progressBar->clear(); + $progressBar?->clear(); $this->io->stopSection(sprintf( 'Successfully backed up %d relations.', $this->relationCount diff --git a/src/Command/BackupFetchCommand.php b/src/Command/BackupFetchCommand.php index c1d041e0..feb0e994 100644 --- a/src/Command/BackupFetchCommand.php +++ b/src/Command/BackupFetchCommand.php @@ -12,7 +12,6 @@ use League\Flysystem\ZipArchive\ZipArchiveAdapter; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -175,8 +174,8 @@ public function copyFolder(FilesystemOperator $source, string $sourcePath, Files ]); $manager->createDirectory(sprintf('dest://%s', $destinationPath)); $listing = $manager->listContents('source://'.$sourcePath, true); - $progressBar = new ProgressBar($this->io); - $progressBar->start(); + $progressBar = $this->io->createProgressBarInInteractiveTerminal(); + $progressBar?->start(); /** @var \League\Flysystem\StorageAttributes $item */ foreach ($listing as $item) { $itemPath = $item->path(); @@ -192,14 +191,14 @@ public function copyFolder(FilesystemOperator $source, string $sourcePath, Files ] ); } - $progressBar->advance(); + $progressBar?->advance(); if ($item->isDir()) { $manager->createDirectory(sprintf('dest://%s/%s/%s', $destinationPath, $itemDir, $itemName)); } } - $progressBar->finish(); - $progressBar->clear(); + $progressBar?->finish(); + $progressBar?->clear(); } public function copyFile(FilesystemOperator $source, string $sourcePath, FilesystemOperator $destination, string $destinationPath): void diff --git a/src/Command/BackupLoadCommand.php b/src/Command/BackupLoadCommand.php index 2fb85ec2..ba718eaa 100755 --- a/src/Command/BackupLoadCommand.php +++ b/src/Command/BackupLoadCommand.php @@ -86,8 +86,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function loadNodes(): void { $this->io->startSection('Step 1 of 4: Loading Nodes'); - $progressBar = $this->io->createProgressBar($this->nodeCount); - $progressBar->display(); + $progressBar = $this->io->createProgressBarInInteractiveTerminal($this->nodeCount); + $progressBar?->display(); $nodeFiles = $this->backupStorage->listContents($this->backupName.'/node/', true); $pageCount = 0; $totalCount = 0; @@ -105,14 +105,14 @@ private function loadNodes(): void ++$pageCount; if ($pageCount >= $this->pageSize) { $this->elementManager->flush(); - $progressBar->advance($pageCount); + $progressBar?->advance($pageCount); $totalCount += $pageCount; $pageCount = 0; } } $this->elementManager->flush(); - $progressBar->advance($pageCount); - $progressBar->clear(); + $progressBar?->advance($pageCount); + $progressBar?->clear(); $totalCount += $pageCount; $this->io->stopSection(sprintf( 'Loaded %d nodes.', @@ -123,8 +123,8 @@ private function loadNodes(): void private function loadRelations(): void { $this->io->startSection('Step 2 of 4: Loading Relations'); - $progressBar = $this->io->createProgressBar($this->relationCount); - $progressBar->display(); + $progressBar = $this->io->createProgressBarInInteractiveTerminal($this->relationCount); + $progressBar?->display(); $relationFiles = $this->backupStorage->listContents($this->backupName.'/relation/', true); $totalCount = 0; $pageCount = 0; @@ -142,14 +142,14 @@ private function loadRelations(): void ++$pageCount; if ($pageCount >= $this->pageSize) { $this->elementManager->flush(); - $progressBar->advance($pageCount); + $progressBar?->advance($pageCount); $totalCount += $pageCount; $pageCount = 0; } } $this->elementManager->flush(); - $progressBar->advance($pageCount); - $progressBar->clear(); + $progressBar?->advance($pageCount); + $progressBar?->clear(); $totalCount += $pageCount; $this->io->stopSection(sprintf( 'Loaded %d relations.', diff --git a/src/Command/TokenCreateCommand.php b/src/Command/TokenCreateCommand.php index ebdf42fa..20b6bd0f 100644 --- a/src/Command/TokenCreateCommand.php +++ b/src/Command/TokenCreateCommand.php @@ -53,7 +53,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $this->io = new EmberNexusStyle($input, $output); - $this->io->title('User Token Create'); + $this->io->title('Token Create'); $identifier = $input->getArgument('identifier'); diff --git a/src/Command/TokenRevokeCommand.php b/src/Command/TokenRevokeCommand.php index 91b4b01c..414033b5 100644 --- a/src/Command/TokenRevokeCommand.php +++ b/src/Command/TokenRevokeCommand.php @@ -2,15 +2,16 @@ namespace App\Command; -use App\Factory\Exception\Client400IncompleteMutualDependencyExceptionFactory; use App\Helper\Regex; -use App\Security\TokenGenerator; use App\Service\ElementManager; use App\Style\EmberNexusStyle; +use App\Type\TokenStateType; use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Types\DateTimeZoneId; +use Psr\Log\LoggerInterface; +use Ramsey\Uuid\Uuid; use Safe\DateTime; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -34,19 +35,21 @@ class TokenRevokeCommand extends Command public const OPTION_FORCE = 'force'; public const OPTION_DRY_RUN = 'dry-run'; + public const OPTION_LIST_ALL_AFFECTED_TOKENS = 'list-all-affected-tokens'; public const OPTION_USER = 'user'; public const OPTION_GROUP = 'group'; public const OPTION_ISSUED_BEFORE = 'issued-before'; public const OPTION_ISSUED_AFTER = 'issued-after'; public const OPTION_ISSUED_WITHOUT_EXPIRATION_DATE = 'issued-without-expiration-date'; public const OPTION_ISSUED_WITH_EXPIRATION_DATE = 'issued-with-expiration-date'; + public const NUMBER_OF_TOKENS_TO_DISPLAY = 10; + public const TOKEN_REVOKE_BATCH_SIZE = 10; public function __construct( private ElementManager $elementManager, private CypherEntityManager $cypherEntityManager, - private TokenGenerator $tokenGenerator, private EmberNexusConfiguration $emberNexusConfiguration, - private Client400IncompleteMutualDependencyExceptionFactory $client400IncompleteMutualDependencyExceptionFactory + private LoggerInterface $logger ) { parent::__construct(); } @@ -65,7 +68,14 @@ protected function configure(): void self::OPTION_DRY_RUN, null, InputOption::VALUE_NEGATABLE, - 'Lists tokens which would be affected by revocation. Does not apply said revocation.', + 'Does not apply revocation. Also lists all tokens which would be affected.', + false + ); + $this->addOption( + self::OPTION_LIST_ALL_AFFECTED_TOKENS, + 'l', + InputOption::VALUE_NEGATABLE, + 'Lists all tokens.', false ); // command filters @@ -73,7 +83,7 @@ protected function configure(): void self::OPTION_USER, 'u', InputOption::VALUE_REQUIRED, - 'Token revocation is only applied to given user. User can be specified by its UUID (takes precedent) or its identifier.', + 'Token revocation is only applied to an given user. User can be specified by its UUID (takes precedent) or its identifier.', null ); $this->addOption( @@ -87,29 +97,27 @@ protected function configure(): void self::OPTION_ISSUED_BEFORE, 'b', InputOption::VALUE_REQUIRED, - 'Token revocation is only applied to tokens issued before a given datetime. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).', + 'Token revocation is only applied to tokens issued before a given point in time. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).', null ); $this->addOption( self::OPTION_ISSUED_AFTER, 'a', InputOption::VALUE_REQUIRED, - 'Token revocation is only applied to tokens issued after a given datetime. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).', + 'Token revocation is only applied to tokens issued after a given point in time. Datetime must be in the format "YYYY-MM-DD HH:MM" (UTC).', null ); $this->addOption( self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE, null, - InputOption::VALUE_NEGATABLE, - 'Token revocation is only applied to tokens with no explicit expiration date.', - false + InputOption::VALUE_NONE, + 'Token revocation is only applied to tokens with no explicit expiration date.' ); $this->addOption( self::OPTION_ISSUED_WITH_EXPIRATION_DATE, null, - InputOption::VALUE_NEGATABLE, - 'Token revocation is only applied to tokens with explicit expiration date set.', - false + InputOption::VALUE_NONE, + 'Token revocation is only applied to tokens with explicit expiration date set.' ); } @@ -175,44 +183,57 @@ protected function execute(InputInterface $input, OutputInterface $output): int $joinedFilters = join("\nAND ", $filters); $finalQuery = sprintf( - "MATCH (t:Token)<-[:OWNS]-(u:User)\n". + "MATCH (t:Token {state: '%s'})<-[:OWNS]-(u:User)\n". '%s%s%s'. "RETURN t.id, t.created, t.expirationDate, u.id, u.%s as userUniqueIdentifier\n". 'ORDER BY t.created ASC, t.id ASC', + TokenStateType::ACTIVE->value, count($filters) > 0 ? 'WHERE ' : '', $joinedFilters, count($filters) > 0 ? "\n" : '', $this->emberNexusConfiguration->getRegisterUniqueIdentifier() ); - // $this->io->writeln("----------------------------"); - // - // foreach ($filters as $filter) { - // $this->io->writeln(' '.$filter); - // } - // - // $this->io->writeln("----------------------------"); - $res = $this->cypherEntityManager->getClient()->runStatement( new Statement($finalQuery, $arguments) ); + $this->io->isDecorated(); + $countTokensToBeRevoked = count($res); if (0 === $countTokensToBeRevoked) { - $this->io->finalMessage('No tokens found.'); + $this->io->finalMessage('No active tokens found.'); return Command::SUCCESS; } - $this->io->writeln(sprintf( - ' %d tokens are affected of revocation%s', - $countTokensToBeRevoked, - $countTokensToBeRevoked > 10 ? '. First 10 are shown:' : ':' - )); + $showOnlyFirstTokens = $countTokensToBeRevoked > self::NUMBER_OF_TOKENS_TO_DISPLAY + && false === $input->getOption(self::OPTION_LIST_ALL_AFFECTED_TOKENS) + && false === $input->getOption(self::OPTION_DRY_RUN); + + if (true === $input->getOption(self::OPTION_DRY_RUN)) { + $this->io->writeln(sprintf( + ' %d tokens would be affected by revocation:', + $countTokensToBeRevoked + )); + } else { + if ($showOnlyFirstTokens) { + $this->io->writeln(sprintf( + ' %d tokens are affected by revocation. First %s are shown:', + $countTokensToBeRevoked, + self::NUMBER_OF_TOKENS_TO_DISPLAY + )); + } else { + $this->io->writeln(sprintf( + ' %d tokens are affected by revocation:', + $countTokensToBeRevoked + )); + } + } $this->io->newLine(); $rows = []; - foreach ($res as $i => $tokenResult) { + foreach ($res as $tokenResult) { $tokenCreated = $tokenResult['t.created']; if ($tokenCreated instanceof DateTimeZoneId) { $tokenCreated = $tokenCreated->toDateTime()->format('Y-m-d H:i:s'); @@ -228,7 +249,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $tokenCreated, $tokenExpires ?? '-', ]; - if (count($rows) >= 10) { + if (count($rows) >= self::NUMBER_OF_TOKENS_TO_DISPLAY && $showOnlyFirstTokens) { break; } } @@ -245,51 +266,58 @@ protected function execute(InputInterface $input, OutputInterface $output): int $table->render(); $this->io->newLine(); - /** - * @var QuestionHelper $helper - */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion(sprintf( - ' Are you sure you want to revoke all %d tokens? [y/N]: ', - $countTokensToBeRevoked - ), false); - if (!$helper->ask($input, $output, $question)) { - $this->io->finalMessage('Aborted revoking tokens.'); - - return self::FAILURE; + if (true === $input->getOption(self::OPTION_DRY_RUN)) { + $this->io->finalMessage('Dry run finished.'); + + return self::SUCCESS; } - $this->io->newLine(); - // foreach ($res as $tokenResult) { - // $this->io->writeln(sprintf( - // "Token: %s, owned by %s", - // $tokenResult['t.id'], - // $tokenResult['userUniqueIdentifier'] - // )); - // } - - // $normalizedArguments = []; - // foreach ($arguments as $i => $argument) { - // if ($argument instanceof \DateTimeInterface) { - // $argument = 'datetime("'.$argument->format('c').'")'; - // } else { - // $argument = \Safe\json_encode($argument, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - // } - // $normalizedArguments[] = sprintf( - // " %s: %s,", - // $i, - // $argument - // ); - // } - // - // $finalArguments = sprintf( - // ":params\n{\n%s\n}", - // join("\n", $normalizedArguments) - // ); - // - // $this->io->writeln($finalArguments); - // $this->io->newLine(); - // $this->io->writeln($finalQuery); + if (true !== $input->getOption(self::OPTION_FORCE)) { + /** + * @var QuestionHelper $helper + */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion(sprintf( + ' Are you sure you want to revoke all %d tokens? [y/N]: ', + $countTokensToBeRevoked + ), false); + if (!$helper->ask($input, $output, $question)) { + $this->io->finalMessage('Aborted revoking tokens.'); + + return self::FAILURE; + } + $this->io->newLine(); + } + + $this->io->writeln(' Revoking tokens...'); + + $progressBar = $this->io->createProgressBarInInteractiveTerminal($countTokensToBeRevoked); + $progressBar?->display(); + + foreach ($res as $i => $tokenResult) { + $tokenUuid = Uuid::fromString($tokenResult['t.id']); + $tokenElement = $this->elementManager->getNode($tokenUuid); + if (!$tokenElement) { + $message = sprintf( + 'Unable to revoke token with UUID %s from user with UUID %s, as token could not be fetched from database.', + $tokenResult['t.id'], + $tokenResult['u.id'] + ); + $this->io->writeln($message); + $this->logger->notice($message); + continue; + } + $tokenElement->addProperty('state', TokenStateType::REVOKED->value); + $this->elementManager->merge($tokenElement); + if (0 === $i % self::TOKEN_REVOKE_BATCH_SIZE) { + $this->elementManager->flush(); + $progressBar?->setProgress($i + 1); + } + } + $this->elementManager->flush(); + $progressBar?->finish(); + $progressBar?->clear(); + $this->io->newLine(); $this->io->finalMessage('Successfully revoked tokens.'); diff --git a/src/Style/EmberNexusStyle.php b/src/Style/EmberNexusStyle.php index 608fa9dc..1c6331c8 100755 --- a/src/Style/EmberNexusStyle.php +++ b/src/Style/EmberNexusStyle.php @@ -5,6 +5,7 @@ use App\Console\EmberNexusOutputWrapper; use Exception; use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; @@ -151,4 +152,13 @@ public function getLineLength(): int { return $this->lineLength; } + + public function createProgressBarInInteractiveTerminal(int $max = 0): ?ProgressBar + { + if (getenv('TERM')) { + return null; + } + + return parent::createProgressBar($max); + } } diff --git a/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php b/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php index 8e80e7b0..20a75d9e 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php @@ -26,7 +26,7 @@ public function testBackupFetchHelp(): void public function testBackupFetch(): void { $commandOutput = $this->runCommand(sprintf( - 'APP_ENV=prod VERSION=%s php bin/console backup:fetch --ansi reference-dataset https://github.com/ember-nexus/reference-dataset/archive/refs/tags/0.0.12.zip | aha -s --black --css "./cli-style.css"', + 'APP_ENV=prod VERSION=%s php bin/console backup:fetch --ansi reference-dataset https://github.com/ember-nexus/reference-dataset/archive/refs/tags/0.0.19.zip | aha -s --black --css "./cli-style.css"', $this->getCurrentVersion() )); $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput( diff --git a/tests/ExampleGenerationCommand/User/UserTokenCreateTest.php b/tests/ExampleGenerationCommand/Token/TokenCreateTest.php similarity index 51% rename from tests/ExampleGenerationCommand/User/UserTokenCreateTest.php rename to tests/ExampleGenerationCommand/Token/TokenCreateTest.php index e3ee762e..90052e94 100644 --- a/tests/ExampleGenerationCommand/User/UserTokenCreateTest.php +++ b/tests/ExampleGenerationCommand/Token/TokenCreateTest.php @@ -1,31 +1,31 @@ runCommand(sprintf( - 'APP_ENV=prod VERSION=%s php bin/console user:token:create --ansi --help | aha -s --black --css "./cli-style.css"', + 'APP_ENV=prod VERSION=%s php bin/console token:create --ansi --help | aha -s --black --css "./cli-style.css"', $this->getCurrentVersion() )); - $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput(self::PATH_TO_ROOT, 'docs/commands/assets/user-token-create-help.html', $commandOutput); + $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput(self::PATH_TO_ROOT, 'docs/commands/assets/token-create-help.html', $commandOutput); } - public function testUserTokenCreate(): void + public function testTokenCreate(): void { $commandOutput = $this->runCommand(sprintf( - 'APP_ENV=prod VERSION=%s php bin/console user:token:create --ansi command-test@localhost.dev 1234 | aha -s --black --css "./cli-style.css"', + 'APP_ENV=prod VERSION=%s php bin/console token:create --ansi command-test@localhost.dev 1234 | aha -s --black --css "./cli-style.css"', $this->getCurrentVersion() )); $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput( self::PATH_TO_ROOT, - 'docs/commands/assets/user-token-create.html', + 'docs/commands/assets/token-create.html', $commandOutput, [ 'Found user with identifier', diff --git a/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php new file mode 100644 index 00000000..e24f86e2 --- /dev/null +++ b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php @@ -0,0 +1,38 @@ +runCommand(sprintf( + 'APP_ENV=prod VERSION=%s php bin/console token:revoke --ansi --help | aha -s --black --css "./cli-style.css"', + $this->getCurrentVersion() + )); + $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput(self::PATH_TO_ROOT, 'docs/commands/assets/token-revoke-help.html', $commandOutput); + } + + public function testTokenRevoke(): void + { + \Safe\exec('APP_ENV=prod php bin/console database:drop -f'); + \Safe\exec('APP_ENV=prod php bin/console backup:load reference-dataset'); + $commandOutput = $this->runCommand(sprintf( + 'APP_ENV=prod VERSION=%s php bin/console token:revoke -f --ansi | aha -s --black --css "./cli-style.css"', + $this->getCurrentVersion() + )); + $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput( + self::PATH_TO_ROOT, + 'docs/commands/assets/token-revoke.html', + $commandOutput, + [ + 'Found user with identifier', + 'Successfully revoked new token:', + ] + ); + } +} From 329925080b28453c984105c252283fdd21c99870 Mon Sep 17 00:00:00 2001 From: Syndesi Date: Sat, 16 Dec 2023 16:30:40 +0100 Subject: [PATCH 4/6] Add command `token:revoke`, closes #59. --- .env | 2 +- CHANGELOG.md | 2 + docs/commands/assets/backup-create-help.html | 2 +- docs/commands/assets/backup-fetch.html | 12 +- docs/commands/assets/backup-list.html | 2 +- docs/commands/assets/system-healthcheck.html | 2 +- docs/commands/assets/token-create.html | 4 +- docs/commands/assets/token-revoke-help.html | 1 + docs/commands/assets/token-revoke.html | 22 +- docs/commands/assets/user-create.html | 2 +- docs/commands/token/token-create.md | 2 +- src/Command/DatabaseDropCommand.php | 2 +- src/Command/TokenCreateCommand.php | 10 +- src/Command/TokenRevokeCommand.php | 290 ++++++++++++------ src/Command/UserCreateCommand.php | 2 +- ...pertyElementDefragmentizeEventListener.php | 20 +- ...ropertyElementFragmentizeEventListener.php | 28 +- ...piKeyCheckOnKernelRequestEventListener.php | 20 +- src/Type/TokenRevokeEntry.php | 49 +++ .../Token/TokenCreateTest.php | 5 +- tests/FeatureTests/BaseRequestTestCase.php | 7 + .../Command/RevokeTokenGroupTest.php | 53 ++++ .../Command/RevokeTokenIssuedAfterTest.php | 26 ++ .../Command/RevokeTokenIssuedBeforeTest.php | 26 ++ ...evokeTokenIssuedWithExpirationDateTest.php | 26 ++ ...keTokenIssuedWithoutExpirationDateTest.php | 26 ++ .../Command/RevokeTokenUserTest.php | 38 +++ 27 files changed, 542 insertions(+), 139 deletions(-) create mode 100644 src/Type/TokenRevokeEntry.php create mode 100644 tests/FeatureTests/Command/RevokeTokenGroupTest.php create mode 100644 tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php create mode 100644 tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php create mode 100644 tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php create mode 100644 tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php create mode 100644 tests/FeatureTests/Command/RevokeTokenUserTest.php diff --git a/.env b/.env index b450ecff..87303128 100755 --- a/.env +++ b/.env @@ -28,4 +28,4 @@ ELASTIC_AUTH=ember-nexus-elasticsearch:9200 REDIS_AUTH=tcp://ember-nexus-redis?password=redis-password RABBITMQ_AUTH=amqp://user:password@ember-nexus-rabbitmq:5672 -REFERENCE_DATASET_VERSION=0.0.20 +REFERENCE_DATASET_VERSION=0.0.22 diff --git a/CHANGELOG.md b/CHANGELOG.md index a5056ee2..b685a732 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove commented and unused code, configuration etc., closes #168. - Remove test CI triggers "pull_request" and "fork", closes #216. - Rename command `user:token:create` to `token:create`, related to #59. +- Tokens cached by Redis now automatically expire within 30 minutes or as their expiration date is reached, part of #59. ### Fixed - Deleting tokens now deletes the correct token from Redis, fixes #186. - Add missing feature tests, related to #168. +- Fix bug where datetime-similar properties could not be correctly returned to the user, uncovered during #59. ## 0.0.38 - 2023-12-08 ### Added diff --git a/docs/commands/assets/backup-create-help.html b/docs/commands/assets/backup-create-help.html index d6564f87..e711d9f4 100644 --- a/docs/commands/assets/backup-create-help.html +++ b/docs/commands/assets/backup-create-help.html @@ -45,7 +45,7 @@ backup:create [options] [--] [<name>] Arguments: - name Name of the backup, defaults to the current timestamp. [default: "20231203120809"] + name Name of the backup, defaults to the current timestamp. [default: "20231216152852"] Options: -p, --pretty|--no-pretty Activates pretty print of JSON. diff --git a/docs/commands/assets/backup-fetch.html b/docs/commands/assets/backup-fetch.html index cddee222..6a13880d 100644 --- a/docs/commands/assets/backup-fetch.html +++ b/docs/commands/assets/backup-fetch.html @@ -46,20 +46,20 @@ Backup Fetch Downloading and inspecting archive ─────────────────────────────────────────── -│ Loaded archive is 298.77 KB big. -│ SHA1 sum of file is f3e3bcac6e354425771bb2c8ac839841d88150e5. -│ Found backup inside ZIP in folder reference-dataset-0.0.12. +│ Loaded archive is 313.67 KB big. +│ SHA1 sum of file is f232952d9fc8a742d7f8441e97d3ceeafb14622a. +│ Found backup inside ZIP in folder reference-dataset-0.0.19. │ Required folders exist. └ Download complete. ┌ Extracting nodes ───────────────────────────────────────────────────────────── - 0 [>---------------------------] 61 [->--------------------------] 107 [--->------------------------] 153 [----->----------------------] 206 [------->--------------------] 253 [--------->------------------] 303 [----------->----------------] 345 [============================]└ All nodes extracted. +└ All nodes extracted. ┌ Extracting relations ───────────────────────────────────────────────────────── - 0 [>---------------------------] 60 [->--------------------------] 105 [--->------------------------] 147 [----->----------------------] 189 [------->--------------------] 239 [--------->------------------] 290 [----------->----------------] 340 [------------->--------------] 391 [-------------->-------------] 399 [============================]└ All relations extracted. +└ All relations extracted. ┌ Extracting files ───────────────────────────────────────────────────────────── - 0 [>---------------------------] 1 [============================]└ All files extracted. +└ All files extracted. ┌ Extracting summary.json ────────────────────────────────────────────────────── └ summary.json extracted. diff --git a/docs/commands/assets/backup-list.html b/docs/commands/assets/backup-list.html index 28144aae..72e9e007 100644 --- a/docs/commands/assets/backup-list.html +++ b/docs/commands/assets/backup-list.html @@ -46,7 +46,7 @@ Backup List Name Hostname Version Date Nodes Edges Files - test 4909d747e715 0.0.38 2023-12-03 0 0 0 + test 1a157b4a46a8 0.0.38 2023-12-16 0 0 0 reference-dataset ember-nexus dev 2023-05-06 282 338 0 ▶ Command finished successfully. diff --git a/docs/commands/assets/system-healthcheck.html b/docs/commands/assets/system-healthcheck.html index 1d9a705e..bd6b06a7 100644 --- a/docs/commands/assets/system-healthcheck.html +++ b/docs/commands/assets/system-healthcheck.html @@ -54,7 +54,7 @@ └ All databases are online. ┌ Check internal services ────────────────────────────────────────────────────── -│ Alpine version: 3.18.4 +│ Alpine version: 3.19.0 │ NGINX Unit version: 1.31.1 │ PHP version: 8.3.0 └ Internal services are ok. diff --git a/docs/commands/assets/token-create.html b/docs/commands/assets/token-create.html index ca5ced76..70cc4657 100644 --- a/docs/commands/assets/token-create.html +++ b/docs/commands/assets/token-create.html @@ -45,9 +45,9 @@ Token Create - Found user with identifier 'command-test@localhost.dev', user's UUID is d604dae6-3922-45bd-aa76-0a9f1fcce1d5. + Found user with identifier 'token-create-test@localhost.dev', user's UUID is de970375-ae1e-46e5-9890-db04b9e51357. -▶ Successfully created new token: secret-token:Hrdb2bgQVHiAB4EvBMhted . +▶ Successfully created new token: secret-token:8hWTs5uFMAEYmYsRnlcZga . diff --git a/docs/commands/assets/token-revoke-help.html b/docs/commands/assets/token-revoke-help.html index f5d1ebc7..86a9e16a 100644 --- a/docs/commands/assets/token-revoke-help.html +++ b/docs/commands/assets/token-revoke-help.html @@ -61,6 +61,7 @@ -n, --no-interaction Do not ask any interactive question -e, --env=ENV The Environment name. [default: "prod"] --no-debug Switch off debug mode. + --profile Enables profiling (requires debug). -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug diff --git a/docs/commands/assets/token-revoke.html b/docs/commands/assets/token-revoke.html index 28b4c277..29f3f1a2 100644 --- a/docs/commands/assets/token-revoke.html +++ b/docs/commands/assets/token-revoke.html @@ -41,23 +41,23 @@ ▀ ▀ Ember Nexus API - v0.0.37, production mode + v0.0.38, production mode Token Revocation 51 tokens are affected by revocation. First 10 are shown: Token UUID User UUID User identifier Created Expires on - 374fa7ea-4df7-475c-a6a6-b35ff7b9bae1 1a0fd886-9f62-497f-8d20-bd61a40b49b7 user@node.deleteElement.endpoint.localhost.dev 2023-12-02 17:18:39 - - 840fee16-996e-4b1a-af2a-77d77c4aeda7 a70e49dc-ec45-414a-beb2-43e6fa56eb41 user@relation.deleteElement.endpoint.localhost.dev 2023-12-02 17:18:39 - - 5f450171-c535-4365-ac70-a283955d4ff5 4250a83d-707b-4f7a-b7dc-225af430f093 user@node.patchElement.endpoint.localhost.dev 2023-12-02 17:18:39 - - 9543e70d-6552-4409-ab6a-dfaf003382a2 8d70608f-ebca-4077-98d2-12733b7fd08e user@relation.patchElement.endpoint.localhost.dev 2023-12-02 17:18:39 - - 6009d74a-1db9-4d1a-bc16-a948706dca58 2274873a-b9a2-4e77-b330-900857b76c1d user@node.postIndex.endpoint.localhost.dev 2023-12-02 17:18:39 - - 571b30e2-40eb-43f5-a888-a6b7ec6c15fc fdf0509d-812c-4435-a863-a4fd6f17e9c4 user@relation.postIndex.endpoint.localhost.dev 2023-12-02 17:18:39 - - ca9fa4fe-1fbd-4e8a-a6bf-701d97a7ec12 7935a9d2-e453-4dec-bc4a-243e3ced678a user@node.putElement.endpoint.localhost.dev 2023-12-02 17:18:39 - - 84e32a4f-840b-435a-a064-2e72f19d8b20 53ae199c-6b71-44c1-b10c-fca306b32cbf user@relation.putElement.endpoint.localhost.dev 2023-12-02 17:18:39 - - befd839e-5711-414e-8fe3-a8a3225dd603 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2023-12-02 17:18:39 - - ed1891bf-d7a8-4e64-ba0d-ebec82ad173e 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2023-12-02 17:18:39 - + 374fa7ea-4df7-475c-a6a6-b35ff7b9bae1 1a0fd886-9f62-497f-8d20-bd61a40b49b7 user@node.deleteElement.endpoint.localhost.dev 2023-12-16 15:29:06 - + 840fee16-996e-4b1a-af2a-77d77c4aeda7 a70e49dc-ec45-414a-beb2-43e6fa56eb41 user@relation.deleteElement.endpoint.localhost.dev 2023-12-16 15:29:06 - + 5f450171-c535-4365-ac70-a283955d4ff5 4250a83d-707b-4f7a-b7dc-225af430f093 user@node.patchElement.endpoint.localhost.dev 2023-12-16 15:29:06 - + 9543e70d-6552-4409-ab6a-dfaf003382a2 8d70608f-ebca-4077-98d2-12733b7fd08e user@relation.patchElement.endpoint.localhost.dev 2023-12-16 15:29:06 - + 6009d74a-1db9-4d1a-bc16-a948706dca58 2274873a-b9a2-4e77-b330-900857b76c1d user@node.postIndex.endpoint.localhost.dev 2023-12-16 15:29:06 - + 571b30e2-40eb-43f5-a888-a6b7ec6c15fc fdf0509d-812c-4435-a863-a4fd6f17e9c4 user@relation.postIndex.endpoint.localhost.dev 2023-12-16 15:29:06 - + ca9fa4fe-1fbd-4e8a-a6bf-701d97a7ec12 7935a9d2-e453-4dec-bc4a-243e3ced678a user@node.putElement.endpoint.localhost.dev 2023-12-16 15:29:06 - + 84e32a4f-840b-435a-a064-2e72f19d8b20 53ae199c-6b71-44c1-b10c-fca306b32cbf user@relation.putElement.endpoint.localhost.dev 2023-12-16 15:29:06 - + befd839e-5711-414e-8fe3-a8a3225dd603 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2023-12-16 15:29:06 - + ed1891bf-d7a8-4e64-ba0d-ebec82ad173e 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2023-12-16 15:29:06 - Revoking tokens... diff --git a/docs/commands/assets/user-create.html b/docs/commands/assets/user-create.html index 59c5e9d5..4917aeca 100644 --- a/docs/commands/assets/user-create.html +++ b/docs/commands/assets/user-create.html @@ -45,7 +45,7 @@ User Create -▶ Created user with email 'command-test@localhost.dev' successfully, UUID is d604dae6-3922-45bd-aa76-0a9f1fcce1d5. +▶ Created user with email 'command-test@localhost.dev' successfully, UUID is 0332e376-52e7-46b6-9336-d1b9e258a5f1. diff --git a/docs/commands/token/token-create.md b/docs/commands/token/token-create.md index 9706c8c8..fc8ae2b4 100644 --- a/docs/commands/token/token-create.md +++ b/docs/commands/token/token-create.md @@ -11,7 +11,7 @@ php bin/console token:create --help
Example Command
```bash -php bin/console token:create command-test@localhost.dev 1234 +php bin/console token:create token-create-test@localhost.dev 1234 ``` [](../assets/token-create.html ':include :type=html') diff --git a/src/Command/DatabaseDropCommand.php b/src/Command/DatabaseDropCommand.php index 02d966d6..a71c77c3 100755 --- a/src/Command/DatabaseDropCommand.php +++ b/src/Command/DatabaseDropCommand.php @@ -62,7 +62,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$helper->ask($input, $output, $question)) { $this->io->writeln('Aborted dropping databases.'); - return self::FAILURE; + return Command::FAILURE; } $this->io->newLine(); } diff --git a/src/Command/TokenCreateCommand.php b/src/Command/TokenCreateCommand.php index 20b6bd0f..bfde2ad5 100644 --- a/src/Command/TokenCreateCommand.php +++ b/src/Command/TokenCreateCommand.php @@ -78,7 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $identifier )); - return self::FAILURE; + return Command::FAILURE; } if ($res->count() > 1) { $this->io->finalMessage(sprintf( @@ -87,7 +87,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $identifier )); - return self::FAILURE; + return Command::FAILURE; } $identifier = Uuid::fromString($res->first()->get('id')); } @@ -100,7 +100,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $identifier->toString() )); - return self::FAILURE; + return Command::FAILURE; } $this->io->writeln( @@ -119,7 +119,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $identifier->toString() )); - return self::FAILURE; + return Command::FAILURE; } if (!password_verify($input->getArgument('password'), $user->getProperty('_passwordHash'))) { @@ -128,7 +128,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $identifier->toString() )); - return self::FAILURE; + return Command::FAILURE; } $token = $this->tokenGenerator->createNewToken($identifier); diff --git a/src/Command/TokenRevokeCommand.php b/src/Command/TokenRevokeCommand.php index 414033b5..95b8e025 100644 --- a/src/Command/TokenRevokeCommand.php +++ b/src/Command/TokenRevokeCommand.php @@ -3,15 +3,19 @@ namespace App\Command; use App\Helper\Regex; +use App\Security\AuthProvider; use App\Service\ElementManager; use App\Style\EmberNexusStyle; +use App\Type\TokenRevokeEntry; use App\Type\TokenStateType; use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Types\DateTimeZoneId; +use Predis\Client as RedisClient; use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; use Safe\DateTime; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -20,7 +24,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; -use Symfony\Component\Console\Style\OutputStyle; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; use function Safe\preg_match; @@ -31,7 +34,7 @@ #[AsCommand(name: 'token:revoke', description: 'Revoke tokens.')] class TokenRevokeCommand extends Command { - private OutputStyle $io; + private EmberNexusStyle $io; public const OPTION_FORCE = 'force'; public const OPTION_DRY_RUN = 'dry-run'; @@ -45,10 +48,22 @@ class TokenRevokeCommand extends Command public const NUMBER_OF_TOKENS_TO_DISPLAY = 10; public const TOKEN_REVOKE_BATCH_SIZE = 10; + private bool $isForceActive = false; + private bool $isDryRunActive = false; + private bool $isListAllAffectedTokensActive = false; + private ?UuidInterface $userUuid = null; + private ?UuidInterface $groupUuid = null; + private ?DateTime $issuedBefore = null; + private ?DateTime $issuedAfter = null; + private bool $isFilterIssuedWithoutExpirationDateActive = false; + private bool $isFilterIssuedWithExpirationDateActive = false; + public function __construct( private ElementManager $elementManager, private CypherEntityManager $cypherEntityManager, + private RedisClient $redisClient, private EmberNexusConfiguration $emberNexusConfiguration, + private AuthProvider $authProvider, private LoggerInterface $logger ) { parent::__construct(); @@ -127,38 +142,72 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Token Revocation'); - $filters = []; - $arguments = []; + $this->validateInput($input); + $tokenRevokeEntries = $this->getAffectedTokenRevokeEntries(); - if ( - true === $input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE) - && true === $input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE) - ) { - throw new Exception(sprintf('Using both %s and %s is not possible.', self::OPTION_ISSUED_WITH_EXPIRATION_DATE, self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE)); + if (0 === count($tokenRevokeEntries)) { + $this->io->finalMessage('No active tokens found.'); + + return Command::SUCCESS; } - if (true === $input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE)) { - $filters[] = 't.expirationDate IS NOT NULL'; + + $this->printAffectedTokens($tokenRevokeEntries); + + if (true === $input->getOption(self::OPTION_DRY_RUN)) { + $this->io->finalMessage('Dry run finished.'); + + return Command::SUCCESS; } - if (true === $input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE)) { - $filters[] = 't.expirationDate IS NULL'; + + if (true !== $this->isForceActive) { + /** + * @var QuestionHelper $helper + */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion(sprintf( + ' Are you sure you want to revoke all %d tokens? [y/N]: ', + count($tokenRevokeEntries) + ), false); + if (!$helper->ask($input, $output, $question)) { + $this->io->finalMessage('Aborted revoking tokens.'); + + return Command::FAILURE; + } + $this->io->newLine(); + } + + $this->io->writeln(' Revoking tokens...'); + + $this->revokeTokens($tokenRevokeEntries); + + $this->io->finalMessage('Successfully revoked tokens.'); + + return Command::SUCCESS; + } + + private function validateInput(InputInterface $input): void + { + $this->isForceActive = $input->getOption(self::OPTION_FORCE); + $this->isDryRunActive = $input->getOption(self::OPTION_DRY_RUN); + $this->isListAllAffectedTokensActive = $input->getOption(self::OPTION_LIST_ALL_AFFECTED_TOKENS); + + $this->isFilterIssuedWithExpirationDateActive = $input->getOption(self::OPTION_ISSUED_WITH_EXPIRATION_DATE); + $this->isFilterIssuedWithoutExpirationDateActive = $input->getOption(self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE); + + if ($this->isFilterIssuedWithExpirationDateActive && $this->isFilterIssuedWithoutExpirationDateActive) { + throw new Exception(sprintf('Using both %s and %s is not possible.', self::OPTION_ISSUED_WITH_EXPIRATION_DATE, self::OPTION_ISSUED_WITHOUT_EXPIRATION_DATE)); } - $optionIssuedBefore = null; if (null !== $input->getOption(self::OPTION_ISSUED_BEFORE)) { - $optionIssuedBefore = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_BEFORE)); - $filters[] = 't.created < $issuedBefore'; - $arguments['issuedBefore'] = $optionIssuedBefore; + $this->issuedBefore = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_BEFORE)); } - $optionIssuedAfter = null; if (null !== $input->getOption(self::OPTION_ISSUED_AFTER)) { - $optionIssuedAfter = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_AFTER)); - $filters[] = 't.created > $issuedAfter'; - $arguments['issuedAfter'] = $optionIssuedAfter; + $this->issuedAfter = DateTime::createFromFormat('Y-m-d H:i', $input->getOption(self::OPTION_ISSUED_AFTER)); } - if ($optionIssuedBefore && $optionIssuedAfter) { - if ($optionIssuedBefore->getTimestamp() < $optionIssuedAfter->getTimestamp()) { + if ($this->issuedBefore && $this->issuedAfter) { + if ($this->issuedBefore->getTimestamp() < $this->issuedAfter->getTimestamp()) { throw new Exception(sprintf('%s can not be before %s.', self::OPTION_ISSUED_BEFORE, self::OPTION_ISSUED_AFTER)); } } @@ -166,26 +215,68 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (null !== $input->getOption(self::OPTION_USER)) { $userIdentifier = $input->getOption(self::OPTION_USER); if (preg_match(Regex::UUID_V4, $userIdentifier)) { - $filters[] = 'u.id = $userIdentifier'; + $this->userUuid = Uuid::fromString($userIdentifier); } else { - $filters[] = sprintf( - 'u.%s = $userIdentifier', - $this->emberNexusConfiguration->getRegisterUniqueIdentifier() - ); + $res = $this->cypherEntityManager->getClient()->runStatement(new Statement( + sprintf( + 'MATCH (u:User {%s: $userIdentifier}) RETURN u.id LIMIT 1', + $this->emberNexusConfiguration->getRegisterUniqueIdentifier() + ), + [ + 'userIdentifier' => $userIdentifier, + ] + )); + + if (1 !== count($res)) { + throw new Exception(sprintf('Unable to find user identified by %s.', $userIdentifier)); + } + + $this->userUuid = Uuid::fromString($res[0]['u.id']); } - $arguments['userIdentifier'] = $userIdentifier; } if (null !== $input->getOption(self::OPTION_GROUP)) { + $this->groupUuid = Uuid::fromString($input->getOption(self::OPTION_GROUP)); + } + } + + private function buildGetAffectedTokenRevokeEntriesQuery(): Statement + { + $filters = []; + $arguments = []; + + if ($this->isFilterIssuedWithExpirationDateActive) { + $filters[] = 't.expirationDate IS NOT NULL'; + } + if ($this->isFilterIssuedWithoutExpirationDateActive) { + $filters[] = 't.expirationDate IS NULL'; + } + + if ($this->issuedBefore) { + $filters[] = 't.created < $issuedBefore'; + $arguments['issuedBefore'] = $this->issuedBefore; + } + + if ($this->issuedAfter) { + $filters[] = 't.created > $issuedAfter'; + $arguments['issuedAfter'] = $this->issuedAfter; + } + + if ($this->userUuid) { + $filters[] = 'u.id = $userIdentifier'; + $arguments['userIdentifier'] = $this->userUuid->toString(); + } + + if ($this->groupUuid) { $filters[] = '(t)<-[:OWNS]-(:User)-[:IS_IN_GROUP*1..]->(:Group {id: $groupIdentifier})'; - $arguments['groupIdentifier'] = $input->getOption(self::OPTION_GROUP); + $arguments['groupIdentifier'] = $this->groupUuid->toString(); } $joinedFilters = join("\nAND ", $filters); $finalQuery = sprintf( "MATCH (t:Token {state: '%s'})<-[:OWNS]-(u:User)\n". '%s%s%s'. - "RETURN t.id, t.created, t.expirationDate, u.id, u.%s as userUniqueIdentifier\n". + "RETURN t.id, t.created, t.expirationDate, t.hash, u.id, u.%s as userUniqueIdentifier\n". 'ORDER BY t.created ASC, t.id ASC', TokenStateType::ACTIVE->value, count($filters) > 0 ? 'WHERE ' : '', @@ -194,60 +285,78 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->emberNexusConfiguration->getRegisterUniqueIdentifier() ); - $res = $this->cypherEntityManager->getClient()->runStatement( - new Statement($finalQuery, $arguments) - ); + return new Statement($finalQuery, $arguments); + } - $this->io->isDecorated(); + /** + * @return TokenRevokeEntry[] + */ + private function getAffectedTokenRevokeEntries(): array + { + $queryResultLines = $this->cypherEntityManager->getClient()->runStatement( + $this->buildGetAffectedTokenRevokeEntriesQuery() + ); + $result = []; + foreach ($queryResultLines as $queryResultLine) { + $tokenCreated = $queryResultLine['t.created']; + $expirationDate = $queryResultLine['t.expirationDate']; + /** + * @var DateTimeZoneId $tokenCreated + * @var ?DateTimeZoneId $expirationDate + */ + $result[] = new TokenRevokeEntry( + Uuid::fromString($queryResultLine['t.id']), + $tokenCreated->toDateTime(), + $expirationDate?->toDateTime(), + Uuid::fromString($queryResultLine['u.id']), + $queryResultLine['userUniqueIdentifier'], + $queryResultLine['t.hash'] + ); + } - $countTokensToBeRevoked = count($res); - if (0 === $countTokensToBeRevoked) { - $this->io->finalMessage('No active tokens found.'); + return $result; + } - return Command::SUCCESS; - } + /** + * @param TokenRevokeEntry[] $tokenRevokeEntries + */ + private function printAffectedTokens(array $tokenRevokeEntries): void + { + $countTokenRevokeEntries = count($tokenRevokeEntries); - $showOnlyFirstTokens = $countTokensToBeRevoked > self::NUMBER_OF_TOKENS_TO_DISPLAY - && false === $input->getOption(self::OPTION_LIST_ALL_AFFECTED_TOKENS) - && false === $input->getOption(self::OPTION_DRY_RUN); + $showOnlyFirstTokens = $countTokenRevokeEntries > self::NUMBER_OF_TOKENS_TO_DISPLAY + && false === $this->isListAllAffectedTokensActive + && false === $this->isDryRunActive; - if (true === $input->getOption(self::OPTION_DRY_RUN)) { + if (true === $this->isDryRunActive) { $this->io->writeln(sprintf( ' %d tokens would be affected by revocation:', - $countTokensToBeRevoked + $countTokenRevokeEntries )); } else { if ($showOnlyFirstTokens) { $this->io->writeln(sprintf( ' %d tokens are affected by revocation. First %s are shown:', - $countTokensToBeRevoked, + $countTokenRevokeEntries, self::NUMBER_OF_TOKENS_TO_DISPLAY )); } else { $this->io->writeln(sprintf( ' %d tokens are affected by revocation:', - $countTokensToBeRevoked + $countTokenRevokeEntries )); } } $this->io->newLine(); $rows = []; - foreach ($res as $tokenResult) { - $tokenCreated = $tokenResult['t.created']; - if ($tokenCreated instanceof DateTimeZoneId) { - $tokenCreated = $tokenCreated->toDateTime()->format('Y-m-d H:i:s'); - } - $tokenExpires = $tokenResult['t.expirationDate']; - if ($tokenExpires instanceof DateTimeZoneId) { - $tokenExpires = $tokenExpires->toDateTime()->format('Y-m-d H:i:s'); - } + foreach ($tokenRevokeEntries as $tokenRevokeEntry) { $rows[] = [ - $tokenResult['t.id'], - $tokenResult['u.id'], - $tokenResult['userUniqueIdentifier'], - $tokenCreated, - $tokenExpires ?? '-', + $tokenRevokeEntry->getTokenId()->toString(), + $tokenRevokeEntry->getUserId()->toString(), + $tokenRevokeEntry->getUserUniqueIdentifier(), + $tokenRevokeEntry->getTokenCreated()->format('Y-m-d H:i:s'), + $tokenRevokeEntry->getTokenExpirationDate()?->format('Y-m-d H:i:s') ?? '-', ]; if (count($rows) >= self::NUMBER_OF_TOKENS_TO_DISPLAY && $showOnlyFirstTokens) { break; @@ -265,43 +374,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int $table->setRows($rows); $table->render(); $this->io->newLine(); + } - if (true === $input->getOption(self::OPTION_DRY_RUN)) { - $this->io->finalMessage('Dry run finished.'); - - return self::SUCCESS; - } - - if (true !== $input->getOption(self::OPTION_FORCE)) { - /** - * @var QuestionHelper $helper - */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion(sprintf( - ' Are you sure you want to revoke all %d tokens? [y/N]: ', - $countTokensToBeRevoked - ), false); - if (!$helper->ask($input, $output, $question)) { - $this->io->finalMessage('Aborted revoking tokens.'); - - return self::FAILURE; - } - $this->io->newLine(); - } - - $this->io->writeln(' Revoking tokens...'); - - $progressBar = $this->io->createProgressBarInInteractiveTerminal($countTokensToBeRevoked); + /** + * @param TokenRevokeEntry[] $tokenRevokeEntries + */ + private function revokeTokens(array $tokenRevokeEntries): void + { + $countTokenRevokeEntries = count($tokenRevokeEntries); + $progressBar = $this->io->createProgressBarInInteractiveTerminal($countTokenRevokeEntries); $progressBar?->display(); - foreach ($res as $i => $tokenResult) { - $tokenUuid = Uuid::fromString($tokenResult['t.id']); - $tokenElement = $this->elementManager->getNode($tokenUuid); + $currentItem = 1; + foreach ($tokenRevokeEntries as $tokenRevokeEntry) { + ++$currentItem; + $tokenElement = $this->elementManager->getNode($tokenRevokeEntry->getTokenId()); if (!$tokenElement) { $message = sprintf( 'Unable to revoke token with UUID %s from user with UUID %s, as token could not be fetched from database.', - $tokenResult['t.id'], - $tokenResult['u.id'] + $tokenRevokeEntry->getTokenId()->toString(), + $tokenRevokeEntry->getUserId()->toString() ); $this->io->writeln($message); $this->logger->notice($message); @@ -309,18 +401,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $tokenElement->addProperty('state', TokenStateType::REVOKED->value); $this->elementManager->merge($tokenElement); - if (0 === $i % self::TOKEN_REVOKE_BATCH_SIZE) { + + $this->redisClient->expire( + $this->authProvider->getRedisTokenKeyFromHashedToken($tokenRevokeEntry->getTokenHash()), + 0 + ); + + if (0 === $currentItem % self::TOKEN_REVOKE_BATCH_SIZE) { $this->elementManager->flush(); - $progressBar?->setProgress($i + 1); + $progressBar?->setProgress($currentItem); } } $this->elementManager->flush(); $progressBar?->finish(); $progressBar?->clear(); $this->io->newLine(); - - $this->io->finalMessage('Successfully revoked tokens.'); - - return Command::SUCCESS; } } diff --git a/src/Command/UserCreateCommand.php b/src/Command/UserCreateCommand.php index 242ab129..ee62dd88 100755 --- a/src/Command/UserCreateCommand.php +++ b/src/Command/UserCreateCommand.php @@ -74,7 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->emberNexusConfiguration->getRegisterUniqueIdentifier() )); - return self::FAILURE; + return Command::FAILURE; } $userId = Uuid::uuid4(); diff --git a/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php b/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php index 70691c11..032cd5ea 100644 --- a/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php +++ b/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php @@ -5,7 +5,11 @@ use App\EventSystem\ElementDefragmentize\Event\NodeElementDefragmentizeEvent; use App\EventSystem\ElementDefragmentize\Event\RelationElementDefragmentizeEvent; use App\Helper\ReservedPropertyNameHelper; -use Laudis\Neo4j\Types\DateTimeZoneId; +use Laudis\Neo4j\Types\Date as LaudisDate; +use Laudis\Neo4j\Types\DateTime as LaudisDateTime; +use Laudis\Neo4j\Types\DateTimeZoneId as LaudisDateTimeZoneId; +use Laudis\Neo4j\Types\LocalDateTime as LaudisLocalDateTime; +use Laudis\Neo4j\Types\LocalTime as LaudisLocalTime; class GenericPropertyElementDefragmentizeEventListener { @@ -40,9 +44,21 @@ private function handleEvent(NodeElementDefragmentizeEvent|RelationElementDefrag $cypherProperties = $cypherFragment->getProperties(); $cypherProperties = ReservedPropertyNameHelper::removeReservedPropertyNamesFromArray($cypherProperties); foreach ($cypherProperties as $key => $value) { - if ($value instanceof DateTimeZoneId) { + if ($value instanceof LaudisDateTimeZoneId) { $cypherProperties[$key] = $value->toDateTime(); } + if ($value instanceof LaudisDateTime) { + $cypherProperties[$key] = $value->toDateTime(); + } + if ($value instanceof LaudisDate) { + $cypherProperties[$key] = $value->toDateTime(); + } + if ($value instanceof LaudisLocalDateTime) { + $cypherProperties[$key] = $value->toDateTime(); + } + if ($value instanceof LaudisLocalTime) { + $cypherProperties[$key] = $value->toArray(); + } } $element->addProperties($cypherProperties); foreach ($element->getProperties() as $key => $value) { diff --git a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php index 21be110d..cdcb9be4 100644 --- a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php +++ b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php @@ -6,7 +6,11 @@ use App\EventSystem\ElementFragmentize\Event\RelationElementFragmentizeEvent; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; use DateTimeInterface; -use Laudis\Neo4j\Types\DateTimeZoneId; +use Laudis\Neo4j\Types\Date as LaudisDate; +use Laudis\Neo4j\Types\DateTime as LaudisDateTime; +use Laudis\Neo4j\Types\DateTimeZoneId as LaudisDateTimeZoneId; +use Laudis\Neo4j\Types\LocalDateTime as LaudisLocalDateTime; +use Laudis\Neo4j\Types\LocalTime as LaudisLocalTime; /** * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -50,11 +54,31 @@ private function handleEvent(NodeElementFragmentizeEvent|RelationElementFragment $elasticFragment->addProperty($name, $value->format('Uu')); continue; } - if ($value instanceof DateTimeZoneId) { + if ($value instanceof LaudisDateTimeZoneId) { $cypherFragment->addProperty($name, $value); $elasticFragment->addProperty($name, $value->toDateTime()->format('Uu')); continue; } + if ($value instanceof LaudisDateTime) { + $cypherFragment->addProperty($name, $value); + $elasticFragment->addProperty($name, $value->toDateTime()->format('Uu')); + continue; + } + if ($value instanceof LaudisDate) { + $cypherFragment->addProperty($name, $value); + $elasticFragment->addProperty($name, $value->toDateTime()->format('Uu')); + continue; + } + if ($value instanceof LaudisLocalDateTime) { + $cypherFragment->addProperty($name, $value); + $elasticFragment->addProperty($name, $value->toDateTime()->format('Uu')); + continue; + } + if ($value instanceof LaudisLocalTime) { + $cypherFragment->addProperty($name, $value); + $elasticFragment->addProperty($name, $value->toArray()); + continue; + } if (is_object($value)) { $mongoFragment->addProperty($name, $value); continue; diff --git a/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php b/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php index 7570ac43..3d90d999 100644 --- a/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php +++ b/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php @@ -8,9 +8,11 @@ use App\Type\TokenStateType; use App\Type\UserUuidAndTokenUuidObject; use Laudis\Neo4j\Databags\Statement; -use Predis\Client; +use Laudis\Neo4j\Types\DateTime as LaudisDateTime; +use Predis\Client as RedisClient; use Ramsey\Uuid\Rfc4122\UuidV4; use Ramsey\Uuid\Uuid; +use Safe\DateTime; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; @@ -22,7 +24,7 @@ class ApiKeyCheckOnKernelRequestEventListener public function __construct( private TokenGenerator $tokenGenerator, private CypherEntityManager $cypherEntityManager, - private Client $redisClient, + private RedisClient $redisClient, private AuthProvider $authProvider, private Client401UnauthorizedExceptionFactory $client401UnauthorizedExceptionFactory ) { @@ -58,7 +60,7 @@ private function getUserUuidAndTokenUuidObjectFromTokenFromCypher(string $token) $hashedToken = $this->tokenGenerator->hashToken($token); $res = $this->cypherEntityManager->getClient()->runStatement( Statement::create( - 'MATCH (user:User)-[:OWNS]->(token:Token {hash: $hash, state: $state}) RETURN user.id, token.id', + 'MATCH (user:User)-[:OWNS]->(token:Token {hash: $hash, state: $state}) RETURN user.id, token.id, token.expirationDate', [ 'hash' => $hashedToken, 'state' => TokenStateType::ACTIVE->value, @@ -77,10 +79,20 @@ private function getUserUuidAndTokenUuidObjectFromTokenFromCypher(string $token) $userUuid = Uuid::fromString($res->first()->get('user.id')); $tokenUuid = Uuid::fromString($res->first()->get('token.id')); + $tokenLifetimeInRedis = 60 * 30; // 30 minutes + if ($res->first()->get('token.expirationDate')) { + /** + * @var LaudisDateTime $tokenExpirationDate + */ + $tokenExpirationDate = $res->first()->get('token.expirationDate'); + $secondsUntilTokenExpires = $tokenExpirationDate->toDateTime()->getTimestamp() - (new DateTime())->getTimestamp(); + $tokenLifetimeInRedis = min($tokenLifetimeInRedis, $secondsUntilTokenExpires); + } + $redisKey = $this->authProvider->getRedisTokenKeyFromHashedToken($hashedToken); $this->redisClient->hset($redisKey, 'token', $tokenUuid->toString()); $this->redisClient->hset($redisKey, 'user', $userUuid->toString()); - $this->redisClient->expire($redisKey, 60 * 30); // 30 minutes + $this->redisClient->expire($redisKey, $tokenLifetimeInRedis); return new UserUuidAndTokenUuidObject( $userUuid, diff --git a/src/Type/TokenRevokeEntry.php b/src/Type/TokenRevokeEntry.php new file mode 100644 index 00000000..c24de25f --- /dev/null +++ b/src/Type/TokenRevokeEntry.php @@ -0,0 +1,49 @@ +tokenId; + } + + public function getTokenCreated(): DateTimeInterface + { + return $this->tokenCreated; + } + + public function getTokenExpirationDate(): ?DateTimeInterface + { + return $this->tokenExpirationDate; + } + + public function getUserId(): UuidInterface + { + return $this->userId; + } + + public function getUserUniqueIdentifier(): string + { + return $this->userUniqueIdentifier; + } + + public function getTokenHash(): string + { + return $this->tokenHash; + } +} diff --git a/tests/ExampleGenerationCommand/Token/TokenCreateTest.php b/tests/ExampleGenerationCommand/Token/TokenCreateTest.php index 90052e94..65a5a6a7 100644 --- a/tests/ExampleGenerationCommand/Token/TokenCreateTest.php +++ b/tests/ExampleGenerationCommand/Token/TokenCreateTest.php @@ -19,8 +19,11 @@ public function testTokenCreateHelp(): void public function testTokenCreate(): void { + $this->runCommand( + 'APP_ENV=prod php bin/console user:create token-create-test@localhost.dev 1234' + ); $commandOutput = $this->runCommand(sprintf( - 'APP_ENV=prod VERSION=%s php bin/console token:create --ansi command-test@localhost.dev 1234 | aha -s --black --css "./cli-style.css"', + 'APP_ENV=prod VERSION=%s php bin/console token:create --ansi token-create-test@localhost.dev 1234 | aha -s --black --css "./cli-style.css"', $this->getCurrentVersion() )); $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput( diff --git a/tests/FeatureTests/BaseRequestTestCase.php b/tests/FeatureTests/BaseRequestTestCase.php index 5c9be602..5c0b1ccf 100644 --- a/tests/FeatureTests/BaseRequestTestCase.php +++ b/tests/FeatureTests/BaseRequestTestCase.php @@ -317,6 +317,13 @@ public function assertIsCreatedBy(string $token, string $nodeUuid, string $userU )); } + public function assertIsTokenWithState(ResponseInterface $response, string $state): void + { + $this->assertIsNodeResponse($response, 'Token'); + $tokenBody = $this->getBody($response); + $this->assertSame($state, $tokenBody['data']['state']); + } + public function getUuidFromLocation(ResponseInterface $response): string { $location = $response->getHeader('Location')[0]; diff --git a/tests/FeatureTests/Command/RevokeTokenGroupTest.php b/tests/FeatureTests/Command/RevokeTokenGroupTest.php new file mode 100644 index 00000000..7c3bfe1d --- /dev/null +++ b/tests/FeatureTests/Command/RevokeTokenGroupTest.php @@ -0,0 +1,53 @@ +runGetRequest(sprintf('/%s', self::TOKEN_USER_1_UUID), self::TOKEN_USER_1); + $this->assertIsTokenWithState($response, 'ACTIVE'); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_2_UUID), self::TOKEN_USER_2); + $this->assertIsTokenWithState($response, 'ACTIVE'); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_3_UUID), self::TOKEN_USER_3); + $this->assertIsTokenWithState($response, 'ACTIVE'); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_4_UUID), self::TOKEN_USER_4); + $this->assertIsTokenWithState($response, 'ACTIVE'); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_5_UUID), self::TOKEN_USER_5); + $this->assertIsTokenWithState($response, 'ACTIVE'); + + \Safe\exec(sprintf( + 'php bin/console token:revoke -f --group %s', + self::GROUP_2_UUID + )); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_1_UUID), self::TOKEN_USER_1); + $this->assertIsTokenWithState($response, 'ACTIVE'); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_2_UUID), self::TOKEN_USER_2); + $this->assertIsProblemResponse($response, 401); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_3_UUID), self::TOKEN_USER_3); + $this->assertIsProblemResponse($response, 401); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_4_UUID), self::TOKEN_USER_4); + $this->assertIsTokenWithState($response, 'ACTIVE'); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_USER_5_UUID), self::TOKEN_USER_5); + $this->assertIsTokenWithState($response, 'ACTIVE'); + } +} diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php new file mode 100644 index 00000000..a31932ff --- /dev/null +++ b/tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php @@ -0,0 +1,26 @@ +runGetRequest(sprintf('/%s', self::TOKEN_ISSUED_AFTER_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'ACTIVE'); + + \Safe\exec(sprintf( + 'php bin/console token:revoke -f --user %s --issued-after "2021-01-01 00:00"', + self::USER_UUID + )); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_ISSUED_AFTER_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'REVOKED'); + } +} diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php new file mode 100644 index 00000000..a1156a30 --- /dev/null +++ b/tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php @@ -0,0 +1,26 @@ +runGetRequest(sprintf('/%s', self::TOKEN_ISSUED_BEFORE_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'ACTIVE'); + + \Safe\exec(sprintf( + 'php bin/console token:revoke -f --user %s --issued-before "2022-01-01 00:00"', + self::USER_UUID + )); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_ISSUED_BEFORE_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'REVOKED'); + } +} diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php new file mode 100644 index 00000000..11827f10 --- /dev/null +++ b/tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php @@ -0,0 +1,26 @@ +runGetRequest(sprintf('/%s', self::TOKEN_WITH_EXPIRATION_DATE_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'ACTIVE'); + + \Safe\exec(sprintf( + 'php bin/console token:revoke -f --user %s --issued-with-expiration-date', + self::USER_UUID + )); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_WITH_EXPIRATION_DATE_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'REVOKED'); + } +} diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php new file mode 100644 index 00000000..9c6cee37 --- /dev/null +++ b/tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php @@ -0,0 +1,26 @@ +runGetRequest(sprintf('/%s', self::TOKEN_WITHOUT_EXPIRATION_DATE_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'ACTIVE'); + + \Safe\exec(sprintf( + 'php bin/console token:revoke -f --user %s --issued-without-expiration-date', + self::USER_UUID + )); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_WITHOUT_EXPIRATION_DATE_UUID), self::TOKEN); + $this->assertIsTokenWithState($response, 'REVOKED'); + } +} diff --git a/tests/FeatureTests/Command/RevokeTokenUserTest.php b/tests/FeatureTests/Command/RevokeTokenUserTest.php new file mode 100644 index 00000000..d31ad0b9 --- /dev/null +++ b/tests/FeatureTests/Command/RevokeTokenUserTest.php @@ -0,0 +1,38 @@ +runGetRequest(sprintf('/%s', self::TOKEN_1_USER_1_UUID), self::TOKEN_1_USER_1); + $this->assertIsTokenWithState($response, 'ACTIVE'); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_2_USER_1_UUID), self::TOKEN_2_USER_1); + $this->assertIsTokenWithState($response, 'ACTIVE'); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_1_USER_2_UUID), self::TOKEN_1_USER_2); + $this->assertIsTokenWithState($response, 'ACTIVE'); + + \Safe\exec(sprintf( + 'php bin/console token:revoke -f --user %s', + self::USER_1_UUID + )); + + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_1_USER_1_UUID), self::TOKEN_1_USER_1); + $this->assertIsProblemResponse($response, 401); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_2_USER_1_UUID), self::TOKEN_2_USER_1); + $this->assertIsProblemResponse($response, 401); + $response = $this->runGetRequest(sprintf('/%s', self::TOKEN_1_USER_2_UUID), self::TOKEN_1_USER_2); + $this->assertIsTokenWithState($response, 'ACTIVE'); + } +} From 3caff4341517dcf901628b09466b53e69daecb20 Mon Sep 17 00:00:00 2001 From: Syndesi Date: Sat, 16 Dec 2023 16:51:03 +0100 Subject: [PATCH 5/6] Fix command example generation test. --- tests/ExampleGenerationCommand/Token/TokenRevokeTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php index e24f86e2..7ff90bf0 100644 --- a/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php +++ b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php @@ -32,6 +32,7 @@ public function testTokenRevoke(): void [ 'Found user with identifier', 'Successfully revoked new token:', + 'localhost.dev' ] ); } From b4d35827ea10d7f5487b67de0c477d2ffda9a9c8 Mon Sep 17 00:00:00 2001 From: Syndesi Date: Sat, 16 Dec 2023 17:04:22 +0100 Subject: [PATCH 6/6] Fix command example generation test. --- tests/ExampleGenerationCommand/Token/TokenRevokeTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php index 7ff90bf0..96f29c2e 100644 --- a/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php +++ b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php @@ -32,7 +32,8 @@ public function testTokenRevoke(): void [ 'Found user with identifier', 'Successfully revoked new token:', - 'localhost.dev' + 'localhost.dev', + 'User identifier' ] ); }