mirror of
https://github.com/shlinkio/shlink.git
synced 2024-10-23 04:35:34 +03:00
Feature/name api keys
This commit is contained in:
parent
65f2ab6720
commit
b93b14986e
10 changed files with 163 additions and 40 deletions
45
data/migrations/Version20210306165711.php
Normal file
45
data/migrations/Version20210306165711.php
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20210306165711 extends AbstractMigration
|
||||||
|
{
|
||||||
|
private const TABLE = 'api_keys';
|
||||||
|
private const COLUMN = 'name';
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$apiKeys = $schema->getTable(self::TABLE);
|
||||||
|
$this->skipIf($apiKeys->hasColumn(self::COLUMN));
|
||||||
|
|
||||||
|
$apiKeys->addColumn(
|
||||||
|
self::COLUMN,
|
||||||
|
Types::STRING,
|
||||||
|
[
|
||||||
|
'notnull' => false,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$apiKeys = $schema->getTable(self::TABLE);
|
||||||
|
$this->skipIf(! $apiKeys->hasColumn(self::COLUMN));
|
||||||
|
|
||||||
|
$apiKeys->dropColumn(self::COLUMN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||||
|
*/
|
||||||
|
public function isTransactional(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,6 +42,10 @@ class GenerateKeyCommand extends BaseCommand
|
||||||
|
|
||||||
<info>%command.full_name%</info>
|
<info>%command.full_name%</info>
|
||||||
|
|
||||||
|
You can optionally set its name for tracking purposes with <comment>--name</comment> or <comment>-m</comment>:
|
||||||
|
|
||||||
|
<info>%command.full_name% --name Alice</info>
|
||||||
|
|
||||||
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
|
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
|
||||||
|
|
||||||
<info>%command.full_name% --expiration-date 2020-01-01</info>
|
<info>%command.full_name% --expiration-date 2020-01-01</info>
|
||||||
|
@ -56,6 +60,12 @@ class GenerateKeyCommand extends BaseCommand
|
||||||
$this
|
$this
|
||||||
->setName(self::NAME)
|
->setName(self::NAME)
|
||||||
->setDescription('Generates a new valid API key.')
|
->setDescription('Generates a new valid API key.')
|
||||||
|
->addOption(
|
||||||
|
'name',
|
||||||
|
'm',
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'The name by which this API key will be known.',
|
||||||
|
)
|
||||||
->addOptionWithDeprecatedFallback(
|
->addOptionWithDeprecatedFallback(
|
||||||
'expiration-date',
|
'expiration-date',
|
||||||
'e',
|
'e',
|
||||||
|
@ -82,6 +92,7 @@ class GenerateKeyCommand extends BaseCommand
|
||||||
$expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
|
$expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
|
||||||
$apiKey = $this->apiKeyService->create(
|
$apiKey = $this->apiKeyService->create(
|
||||||
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
|
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
|
||||||
|
$input->getOption('name'),
|
||||||
...$this->roleResolver->determineRoles($input),
|
...$this->roleResolver->determineRoles($input),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -58,6 +58,7 @@ class ListKeysCommand extends BaseCommand
|
||||||
|
|
||||||
// Set columns for this row
|
// Set columns for this row
|
||||||
$rowData = [sprintf($messagePattern, $apiKey)];
|
$rowData = [sprintf($messagePattern, $apiKey)];
|
||||||
|
$rowData[] = $apiKey->name() ?? '-';
|
||||||
if (! $enabledOnly) {
|
if (! $enabledOnly) {
|
||||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
||||||
}
|
}
|
||||||
|
@ -74,10 +75,12 @@ class ListKeysCommand extends BaseCommand
|
||||||
|
|
||||||
ShlinkTable::fromOutput($output)->render(array_filter([
|
ShlinkTable::fromOutput($output)->render(array_filter([
|
||||||
'Key',
|
'Key',
|
||||||
|
'Name',
|
||||||
! $enabledOnly ? 'Is enabled' : null,
|
! $enabledOnly ? 'Is enabled' : null,
|
||||||
'Expiration date',
|
'Expiration date',
|
||||||
'Roles',
|
'Roles',
|
||||||
]), $rows);
|
]), $rows);
|
||||||
|
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,22 +40,43 @@ class GenerateKeyCommandTest extends TestCase
|
||||||
/** @test */
|
/** @test */
|
||||||
public function noExpirationDateIsDefinedIfNotProvided(): void
|
public function noExpirationDateIsDefinedIfNotProvided(): void
|
||||||
{
|
{
|
||||||
$create = $this->apiKeyService->create(null)->willReturn(new ApiKey());
|
$this->apiKeyService->create(
|
||||||
|
null, // Expiration date
|
||||||
|
null, // Name
|
||||||
|
)->shouldBeCalledOnce()
|
||||||
|
->willReturn(new ApiKey());
|
||||||
|
|
||||||
$this->commandTester->execute([]);
|
$this->commandTester->execute([]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
self::assertStringContainsString('Generated API key: ', $output);
|
self::assertStringContainsString('Generated API key: ', $output);
|
||||||
$create->shouldHaveBeenCalledOnce();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function expirationDateIsDefinedIfProvided(): void
|
public function expirationDateIsDefinedIfProvided(): void
|
||||||
{
|
{
|
||||||
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
|
$this->apiKeyService->create(
|
||||||
->willReturn(new ApiKey());
|
Argument::type(Chronos::class), // Expiration date
|
||||||
|
null, // Name
|
||||||
|
)->shouldBeCalledOnce()
|
||||||
|
->willReturn(new ApiKey());
|
||||||
|
|
||||||
$this->commandTester->execute([
|
$this->commandTester->execute([
|
||||||
'--expiration-date' => '2016-01-01',
|
'--expiration-date' => '2016-01-01',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function nameIsDefinedIfProvided(): void
|
||||||
|
{
|
||||||
|
$this->apiKeyService->create(
|
||||||
|
null, // Expiration date
|
||||||
|
Argument::type('string'), // Name
|
||||||
|
)->shouldBeCalledOnce()
|
||||||
|
->willReturn(new ApiKey());
|
||||||
|
|
||||||
|
$this->commandTester->execute([
|
||||||
|
'--name' => 'Alice',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,13 +52,13 @@ class ListKeysCommandTest extends TestCase
|
||||||
[ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')],
|
[ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')],
|
||||||
false,
|
false,
|
||||||
<<<OUTPUT
|
<<<OUTPUT
|
||||||
+-----+------------+-----------------+-------+
|
+-----+------+------------+-----------------+-------+
|
||||||
| Key | Is enabled | Expiration date | Roles |
|
| Key | Name | Is enabled | Expiration date | Roles |
|
||||||
+-----+------------+-----------------+-------+
|
+-----+------+------------+-----------------+-------+
|
||||||
| foo | +++ | - | Admin |
|
| foo | - | +++ | - | Admin |
|
||||||
| bar | +++ | - | Admin |
|
| bar | - | +++ | - | Admin |
|
||||||
| baz | +++ | - | Admin |
|
| baz | - | +++ | - | Admin |
|
||||||
+-----+------------+-----------------+-------+
|
+-----+------+------------+-----------------+-------+
|
||||||
|
|
||||||
OUTPUT,
|
OUTPUT,
|
||||||
];
|
];
|
||||||
|
@ -66,12 +66,12 @@ class ListKeysCommandTest extends TestCase
|
||||||
[ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')],
|
[ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')],
|
||||||
true,
|
true,
|
||||||
<<<OUTPUT
|
<<<OUTPUT
|
||||||
+-----+-----------------+-------+
|
+-----+------+-----------------+-------+
|
||||||
| Key | Expiration date | Roles |
|
| Key | Name | Expiration date | Roles |
|
||||||
+-----+-----------------+-------+
|
+-----+------+-----------------+-------+
|
||||||
| foo | - | Admin |
|
| foo | - | - | Admin |
|
||||||
| bar | - | Admin |
|
| bar | - | - | Admin |
|
||||||
+-----+-----------------+-------+
|
+-----+------+-----------------+-------+
|
||||||
|
|
||||||
OUTPUT,
|
OUTPUT,
|
||||||
];
|
];
|
||||||
|
@ -89,17 +89,37 @@ class ListKeysCommandTest extends TestCase
|
||||||
],
|
],
|
||||||
true,
|
true,
|
||||||
<<<OUTPUT
|
<<<OUTPUT
|
||||||
+------+-----------------+--------------------------+
|
+------+------+-----------------+--------------------------+
|
||||||
| Key | Expiration date | Roles |
|
| Key | Name | Expiration date | Roles |
|
||||||
+------+-----------------+--------------------------+
|
+------+------+-----------------+--------------------------+
|
||||||
| foo | - | Admin |
|
| foo | - | - | Admin |
|
||||||
| bar | - | Author only |
|
| bar | - | - | Author only |
|
||||||
| baz | - | Domain only: example.com |
|
| baz | - | - | Domain only: example.com |
|
||||||
| foo2 | - | Admin |
|
| foo2 | - | - | Admin |
|
||||||
| baz2 | - | Author only |
|
| baz2 | - | - | Author only |
|
||||||
| | | Domain only: example.com |
|
| | | | Domain only: example.com |
|
||||||
| foo3 | - | Admin |
|
| foo3 | - | - | Admin |
|
||||||
+------+-----------------+--------------------------+
|
+------+------+-----------------+--------------------------+
|
||||||
|
|
||||||
|
OUTPUT,
|
||||||
|
];
|
||||||
|
yield 'with names' => [
|
||||||
|
[
|
||||||
|
ApiKey::withKey('abc', null, 'Alice'),
|
||||||
|
ApiKey::withKey('def', null, 'Alice and Bob'),
|
||||||
|
ApiKey::withKey('ghi', null, ''),
|
||||||
|
ApiKey::withKey('jkl', null, null),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
<<<OUTPUT
|
||||||
|
+-----+---------------+-----------------+-------+
|
||||||
|
| Key | Name | Expiration date | Roles |
|
||||||
|
+-----+---------------+-----------------+-------+
|
||||||
|
| abc | Alice | - | Admin |
|
||||||
|
| def | Alice and Bob | - | Admin |
|
||||||
|
| ghi | | - | Admin |
|
||||||
|
| jkl | - | - | Admin |
|
||||||
|
+-----+---------------+-----------------+-------+
|
||||||
|
|
||||||
OUTPUT,
|
OUTPUT,
|
||||||
];
|
];
|
||||||
|
|
|
@ -28,6 +28,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||||
->unique()
|
->unique()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
|
$builder->createField('name', Types::STRING)
|
||||||
|
->columnName('`name`')
|
||||||
|
->nullable()
|
||||||
|
->build();
|
||||||
|
|
||||||
$builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME)
|
$builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||||
->columnName('expiration_date')
|
->columnName('expiration_date')
|
||||||
->nullable()
|
->nullable()
|
||||||
|
|
|
@ -22,14 +22,16 @@ class ApiKey extends AbstractEntity
|
||||||
private bool $enabled;
|
private bool $enabled;
|
||||||
/** @var Collection|ApiKeyRole[] */
|
/** @var Collection|ApiKeyRole[] */
|
||||||
private Collection $roles;
|
private Collection $roles;
|
||||||
|
private ?string $name;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function __construct(?Chronos $expirationDate = null)
|
public function __construct(?Chronos $expirationDate = null, ?string $name = null)
|
||||||
{
|
{
|
||||||
$this->key = Uuid::uuid4()->toString();
|
$this->key = Uuid::uuid4()->toString();
|
||||||
$this->expirationDate = $expirationDate;
|
$this->expirationDate = $expirationDate;
|
||||||
|
$this->name = $name;
|
||||||
$this->enabled = true;
|
$this->enabled = true;
|
||||||
$this->roles = new ArrayCollection();
|
$this->roles = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
@ -45,9 +47,9 @@ class ApiKey extends AbstractEntity
|
||||||
return $apiKey;
|
return $apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function withKey(string $key, ?Chronos $expirationDate = null): self
|
public static function withKey(string $key, ?Chronos $expirationDate = null, ?string $name = null): self
|
||||||
{
|
{
|
||||||
$apiKey = new self($expirationDate);
|
$apiKey = new self($expirationDate, $name);
|
||||||
$apiKey->key = $key;
|
$apiKey->key = $key;
|
||||||
|
|
||||||
return $apiKey;
|
return $apiKey;
|
||||||
|
@ -63,6 +65,11 @@ class ApiKey extends AbstractEntity
|
||||||
return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now());
|
return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function name(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
public function isEnabled(): bool
|
public function isEnabled(): bool
|
||||||
{
|
{
|
||||||
return $this->enabled;
|
return $this->enabled;
|
||||||
|
|
|
@ -21,9 +21,12 @@ class ApiKeyService implements ApiKeyServiceInterface
|
||||||
$this->em = $em;
|
$this->em = $em;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey
|
public function create(
|
||||||
{
|
?Chronos $expirationDate = null,
|
||||||
$key = new ApiKey($expirationDate);
|
?string $name = null,
|
||||||
|
RoleDefinition ...$roleDefinitions
|
||||||
|
): ApiKey {
|
||||||
|
$key = new ApiKey($expirationDate, $name);
|
||||||
foreach ($roleDefinitions as $definition) {
|
foreach ($roleDefinitions as $definition) {
|
||||||
$key->registerRole($definition);
|
$key->registerRole($definition);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
interface ApiKeyServiceInterface
|
interface ApiKeyServiceInterface
|
||||||
{
|
{
|
||||||
public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey;
|
public function create(
|
||||||
|
?Chronos $expirationDate = null,
|
||||||
|
?string $name = null,
|
||||||
|
RoleDefinition ...$roleDefinitions
|
||||||
|
): ApiKey;
|
||||||
|
|
||||||
public function check(string $key): ApiKeyCheckResult;
|
public function check(string $key): ApiKeyCheckResult;
|
||||||
|
|
||||||
|
|
|
@ -35,14 +35,15 @@ class ApiKeyServiceTest extends TestCase
|
||||||
* @dataProvider provideCreationDate
|
* @dataProvider provideCreationDate
|
||||||
* @param RoleDefinition[] $roles
|
* @param RoleDefinition[] $roles
|
||||||
*/
|
*/
|
||||||
public function apiKeyIsProperlyCreated(?Chronos $date, array $roles): void
|
public function apiKeyIsProperlyCreated(?Chronos $date, ?string $name, array $roles): void
|
||||||
{
|
{
|
||||||
$this->em->flush()->shouldBeCalledOnce();
|
$this->em->flush()->shouldBeCalledOnce();
|
||||||
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
|
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
|
||||||
|
|
||||||
$key = $this->service->create($date, ...$roles);
|
$key = $this->service->create($date, $name, ...$roles);
|
||||||
|
|
||||||
self::assertEquals($date, $key->getExpirationDate());
|
self::assertEquals($date, $key->getExpirationDate());
|
||||||
|
self::assertEquals($name, $key->name());
|
||||||
foreach ($roles as $roleDefinition) {
|
foreach ($roles as $roleDefinition) {
|
||||||
self::assertTrue($key->hasRole($roleDefinition->roleName()));
|
self::assertTrue($key->hasRole($roleDefinition->roleName()));
|
||||||
}
|
}
|
||||||
|
@ -50,12 +51,15 @@ class ApiKeyServiceTest extends TestCase
|
||||||
|
|
||||||
public function provideCreationDate(): iterable
|
public function provideCreationDate(): iterable
|
||||||
{
|
{
|
||||||
yield 'no expiration date' => [null, []];
|
yield 'no expiration date or name' => [null, null, []];
|
||||||
yield 'expiration date' => [Chronos::parse('2030-01-01'), []];
|
yield 'expiration date' => [Chronos::parse('2030-01-01'), null, []];
|
||||||
yield 'roles' => [null, [
|
yield 'roles' => [null, null, [
|
||||||
RoleDefinition::forDomain((new Domain(''))->setId('123')),
|
RoleDefinition::forDomain((new Domain(''))->setId('123')),
|
||||||
RoleDefinition::forAuthoredShortUrls(),
|
RoleDefinition::forAuthoredShortUrls(),
|
||||||
]];
|
]];
|
||||||
|
yield 'single name' => [null, 'Alice', []];
|
||||||
|
yield 'multi-word name' => [null, 'Alice and Bob', []];
|
||||||
|
yield 'empty name' => [null, '', []];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in a new issue