Skip to content

Commit

Permalink
Add command token:revoke, closes #59.
Browse files Browse the repository at this point in the history
Rename command `user:token:create` to `token:create`, related to #59.
  • Loading branch information
Syndesi committed Dec 1, 2023
1 parent 5876e9c commit 973b438
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 96 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -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.17
REFERENCE_DATASET_VERSION=0.0.19
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ 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.
- Add command `token:revoke`, closes #59.
### Changed
- Rename command `user:token:create` to `token:create`, related to #59.
- Rename `_PartialUnifiedCollection` to `_PartialElementCollection`, closes #187.

## 0.0.37 - 2023-11-24
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
148 changes: 64 additions & 84 deletions src/Command/TokenRevokeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -125,98 +123,89 @@ 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;
}

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);
}

$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:' : ':'
));
Expand All @@ -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;
Expand All @@ -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)) {
Expand All @@ -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.');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
]
)
);
Expand Down
3 changes: 2 additions & 1 deletion src/Security/TokenGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 0 additions & 8 deletions src/Type/TokenStateType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
31 changes: 31 additions & 0 deletions tests/FeatureTests/Security/TokenStateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\tests\FeatureTests\Security;

use App\Tests\FeatureTests\BaseRequestTestCase;

class TokenStateTest extends BaseRequestTestCase
{
public const TOKEN_ACTIVE = 'secret-token:VWPtCCQskD6uQnf0CHdNjY';
public const TOKEN_REVOKED = 'secret-token:63b9ULc3WYvEOaIaXEfCNc';
public const TOKEN_EXPIRED = 'secret-token:JHU2FrEe7jshvFtrD6BRb3';
public const DATA_UUID = '048ecc31-0807-463c-a8db-989721c73f26';

public function testAccessingApiWithActiveTokenWorks(): void
{
$indexResponse = $this->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);
}
}

0 comments on commit 973b438

Please sign in to comment.