mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 04:03:12 +08:00
Fall back API key names to auto-generated keys
This commit is contained in:
@@ -7,6 +7,9 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Model;
|
||||
use Cake\Chronos\Chronos;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
use function sprintf;
|
||||
use function substr;
|
||||
|
||||
final readonly class ApiKeyMeta
|
||||
{
|
||||
/**
|
||||
@@ -14,7 +17,7 @@ final readonly class ApiKeyMeta
|
||||
*/
|
||||
private function __construct(
|
||||
public string $key,
|
||||
public string|null $name,
|
||||
public string $name,
|
||||
public Chronos|null $expirationDate,
|
||||
public iterable $roleDefinitions,
|
||||
) {
|
||||
@@ -34,8 +37,19 @@ final readonly class ApiKeyMeta
|
||||
Chronos|null $expirationDate = null,
|
||||
iterable $roleDefinitions = [],
|
||||
): self {
|
||||
$resolvedKey = $key ?? Uuid::uuid4()->toString();
|
||||
|
||||
// If a name was not provided, fall back to the key
|
||||
if (empty($name)) {
|
||||
// If the key was auto-generated, fall back to a "censored" version of the UUID, otherwise simply use the
|
||||
// plain key as fallback name
|
||||
$name = $key === null
|
||||
? sprintf('%s-****-****-****-************', substr($resolvedKey, offset: 0, length: 8))
|
||||
: $key;
|
||||
}
|
||||
|
||||
return new self(
|
||||
key: $key ?? Uuid::uuid4()->toString(),
|
||||
key: $resolvedKey,
|
||||
name: $name,
|
||||
expirationDate: $expirationDate,
|
||||
roleDefinitions: $roleDefinitions,
|
||||
|
||||
@@ -15,31 +15,30 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Will create provided API key with admin permissions, only if there's no other API keys yet
|
||||
* Will create provided API key with admin permissions, only if no other API keys exist yet
|
||||
*/
|
||||
public function createInitialApiKey(string $apiKey): ApiKey|null
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
return $em->wrapInTransaction(function () use ($apiKey, $em): ApiKey|null {
|
||||
// Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates
|
||||
// Because of that we check if at least one result exists
|
||||
$firstResult = $em->createQueryBuilder()->select('a.id')
|
||||
->from(ApiKey::class, 'a')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->setLockMode(LockMode::PESSIMISTIC_WRITE)
|
||||
->getOneOrNullResult();
|
||||
$firstResult = $em->createQueryBuilder()
|
||||
->select('a.id')
|
||||
->from(ApiKey::class, 'a')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->setLockMode(LockMode::PESSIMISTIC_WRITE)
|
||||
->getOneOrNullResult();
|
||||
|
||||
// Do not create an initial API key if other keys already exist
|
||||
if ($firstResult !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$new = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey));
|
||||
$em->persist($new);
|
||||
$initialApiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey));
|
||||
$em->persist($initialApiKey);
|
||||
$em->flush();
|
||||
|
||||
return $new;
|
||||
return $initialApiKey;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
|
||||
use function hash;
|
||||
|
||||
class ApiKey extends AbstractEntity
|
||||
{
|
||||
/**
|
||||
@@ -42,6 +44,7 @@ class ApiKey extends AbstractEntity
|
||||
*/
|
||||
public static function fromMeta(ApiKeyMeta $meta): self
|
||||
{
|
||||
// $apiKey = new self(self::hashKey($meta->key), $meta->name, $meta->expirationDate);
|
||||
$apiKey = new self($meta->key, $meta->name, $meta->expirationDate);
|
||||
foreach ($meta->roleDefinitions as $roleDefinition) {
|
||||
$apiKey->registerRole($roleDefinition);
|
||||
@@ -50,6 +53,14 @@ class ApiKey extends AbstractEntity
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a hash for provided key, in the way Shlink expects API keys to be hashed
|
||||
*/
|
||||
public static function hashKey(string $key): string
|
||||
{
|
||||
return hash('sha256', $key);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expirationDate !== null && $this->expirationDate->lessThan(Chronos::now());
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Rest\Service;
|
||||
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
final class ApiKeyCheckResult
|
||||
final readonly class ApiKeyCheckResult
|
||||
{
|
||||
public function __construct(public readonly ApiKey|null $apiKey = null)
|
||||
public function __construct(public ApiKey|null $apiKey = null)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,9 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class ApiKeyService implements ApiKeyServiceInterface
|
||||
readonly class ApiKeyService implements ApiKeyServiceInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
public function __construct(private EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -48,11 +46,12 @@ class ApiKeyService implements ApiKeyServiceInterface
|
||||
{
|
||||
$apiKey = $this->getByKey($key);
|
||||
if ($apiKey === null) {
|
||||
throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key));
|
||||
throw new InvalidArgumentException('Provided API key does not exist and can\'t be disabled');
|
||||
}
|
||||
|
||||
$apiKey->disable();
|
||||
$this->em->flush();
|
||||
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
@@ -62,17 +61,14 @@ class ApiKeyService implements ApiKeyServiceInterface
|
||||
public function listKeys(bool $enabledOnly = false): array
|
||||
{
|
||||
$conditions = $enabledOnly ? ['enabled' => true] : [];
|
||||
/** @var ApiKey[] $apiKeys */
|
||||
$apiKeys = $this->em->getRepository(ApiKey::class)->findBy($conditions);
|
||||
return $apiKeys;
|
||||
return $this->em->getRepository(ApiKey::class)->findBy($conditions);
|
||||
}
|
||||
|
||||
private function getByKey(string $key): ApiKey|null
|
||||
{
|
||||
/** @var ApiKey|null $apiKey */
|
||||
$apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([
|
||||
return $this->em->getRepository(ApiKey::class)->findOneBy([
|
||||
// 'key' => ApiKey::hashKey($key),
|
||||
'key' => $key,
|
||||
]);
|
||||
return $apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
|
||||
use function substr;
|
||||
|
||||
class ApiKeyServiceTest extends TestCase
|
||||
{
|
||||
private ApiKeyService $service;
|
||||
@@ -40,12 +42,14 @@ class ApiKeyServiceTest extends TestCase
|
||||
$this->em->expects($this->once())->method('flush');
|
||||
$this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class));
|
||||
|
||||
$key = $this->service->create(
|
||||
ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles),
|
||||
);
|
||||
$meta = ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles);
|
||||
$key = $this->service->create($meta);
|
||||
|
||||
self::assertEquals($date, $key->expirationDate);
|
||||
self::assertEquals($name, $key->name);
|
||||
self::assertEquals(
|
||||
empty($name) ? substr($meta->key, 0, 8) . '-****-****-****-************' : $name,
|
||||
$key->name,
|
||||
);
|
||||
foreach ($roles as $roleDefinition) {
|
||||
self::assertTrue($key->hasRole($roleDefinition->role));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user