feat(security): derive client createdAt from UUIDv7 and add HasTimestampIdentifier interface
This commit is contained in:
parent
9c28cf2903
commit
4d68a97889
21 changed files with 185 additions and 32 deletions
|
|
@ -34,7 +34,6 @@ final class ApiClientFactory extends Factory
|
|||
'identifier' => $clientIdentifierGenerator->generateIdentifier()->value,
|
||||
'secret' => $clientIdentifierGenerator->generateSecret()->value,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ return new class () extends Migration
|
|||
$table->boolean('is_active')
|
||||
->comment('Defines if the client can be used in order to gain access to the system');
|
||||
|
||||
$table->timestamp('created_at');
|
||||
$table->timestamp('updated_at');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ final class ApiClientSeeder extends Seeder
|
|||
'slug' => Str::slug($devClient['name']),
|
||||
'secret' => $devClient['secret'],
|
||||
'is_active' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ use Application\Security\Models\ValueObjects\ClientName;
|
|||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Shared\DDD\Model\Entity;
|
||||
use Shared\DDD\Model\HasTimestampIdentifier;
|
||||
|
||||
/**
|
||||
* @implements Entity<ClientId>
|
||||
*/
|
||||
final class Client implements Entity
|
||||
final class Client implements Entity, HasTimestampIdentifier
|
||||
{
|
||||
private function __construct(
|
||||
public ClientId $id,
|
||||
|
|
@ -21,7 +22,6 @@ final class Client implements Entity
|
|||
public ClientName $name,
|
||||
public ClientSecret $secret,
|
||||
public bool $isActive,
|
||||
public \DateTimeInterface $createdAt,
|
||||
public \DateTimeInterface $updatedAt,
|
||||
) {
|
||||
}
|
||||
|
|
@ -32,6 +32,12 @@ final class Client implements Entity
|
|||
return $this->id;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function createdAt(): \DateTimeInterface
|
||||
{
|
||||
return $this->id->toDateTime();
|
||||
}
|
||||
|
||||
public static function create(
|
||||
ClientId $id,
|
||||
ClientName $name,
|
||||
|
|
@ -46,7 +52,6 @@ final class Client implements Entity
|
|||
name: $name,
|
||||
secret: $secret,
|
||||
isActive: true,
|
||||
createdAt: $now,
|
||||
updatedAt: $now,
|
||||
);
|
||||
}
|
||||
|
|
@ -57,7 +62,6 @@ final class Client implements Entity
|
|||
ClientIdentifier $identifier,
|
||||
ClientSecret $secret,
|
||||
bool $isActive,
|
||||
\DateTimeInterface $createdAt,
|
||||
\DateTimeInterface $updatedAt,
|
||||
): self {
|
||||
return new self(
|
||||
|
|
@ -66,7 +70,6 @@ final class Client implements Entity
|
|||
name: $name,
|
||||
secret: $secret,
|
||||
isActive: $isActive,
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,4 +19,9 @@ final readonly class ClientId extends EntityId
|
|||
}
|
||||
parent::__construct($identifier->toString());
|
||||
}
|
||||
|
||||
public function toDateTime(): \DateTimeInterface
|
||||
{
|
||||
return UuidHelper::toDateTime($this->identifier);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ final readonly class PaginatedClient implements UsecaseOutput
|
|||
identifier: $client->identifier,
|
||||
name: $client->name,
|
||||
isActive: $client->isActive,
|
||||
createdAt: $client->createdAt,
|
||||
createdAt: $client->createdAt(),
|
||||
updatedAt: $client->updatedAt,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ final class ApiClientRepository implements ClientDirectory, ClientRepository
|
|||
'identifier' => $client->identifier,
|
||||
'secret' => $client->secret,
|
||||
'is_active' => $client->isActive,
|
||||
'created_at' => $client->createdAt,
|
||||
'updated_at' => $client->updatedAt,
|
||||
]
|
||||
);
|
||||
|
|
@ -129,7 +128,6 @@ final class ApiClientRepository implements ClientDirectory, ClientRepository
|
|||
identifier: $apiClient->identifier,
|
||||
secret: $apiClient->secret,
|
||||
isActive: $apiClient->is_active,
|
||||
createdAt: $apiClient->created_at,
|
||||
updatedAt: $apiClient->updated_at,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ use Infra\Casts\UuidCast;
|
|||
* @property \Application\Security\Models\ValueObjects\ClientIdentifier $identifier ENCRYPTED, Unique identifier that should not change
|
||||
* @property \Application\Security\Models\ValueObjects\ClientSecret $secret ENCRYPTED, api secret that can be regenerated
|
||||
* @property bool $is_active Defines if the client can be used in order to gain access to the system
|
||||
* @property \Illuminate\Support\Carbon $created_at
|
||||
* @property \Illuminate\Support\Carbon $updated_at
|
||||
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
|
||||
*
|
||||
|
|
@ -54,7 +53,6 @@ final class ApiClient extends Authenticatable
|
|||
'identifier',
|
||||
'secret',
|
||||
'is_active',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
|
|
@ -73,7 +71,6 @@ final class ApiClient extends Authenticatable
|
|||
'identifier' => ClientIdentifierCast::class,
|
||||
'secret' => ClientSecretCast::class,
|
||||
'is_active' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
10
src/Shared/DDD/Model/HasTimestampIdentifier.php
Normal file
10
src/Shared/DDD/Model/HasTimestampIdentifier.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD\Model;
|
||||
|
||||
interface HasTimestampIdentifier
|
||||
{
|
||||
public function createdAt(): \DateTimeInterface;
|
||||
}
|
||||
|
|
@ -53,4 +53,32 @@ final readonly class UuidHelper
|
|||
throw InvalidUuidException::forValue($uuid->toString());
|
||||
}
|
||||
}
|
||||
|
||||
public static function toDateTime(UuidInterface $uuid): \DateTimeImmutable
|
||||
{
|
||||
if (! self::isV7($uuid)) {
|
||||
throw new InvalidUuidException("Cannot extract datetime from non-V7 UUID: {$uuid->toString()}");
|
||||
}
|
||||
|
||||
$fields = $uuid->getFields();
|
||||
|
||||
if ($fields instanceof Rfc4122FieldsInterface) {
|
||||
$timeMsHex = $fields->getTimeLow()->toString() . $fields->getTimeMid()->toString();
|
||||
} else {
|
||||
$hex = str_replace('-', '', $uuid->toString());
|
||||
$timeMsHex = substr($hex, 0, 12);
|
||||
}
|
||||
|
||||
$timeMs = (int) hexdec($timeMsHex);
|
||||
$sec = intdiv($timeMs, 1000);
|
||||
$ms = $timeMs % 1000;
|
||||
|
||||
$dateTime = \DateTimeImmutable::createFromFormat('U.u', sprintf('%d.%06d', $sec, $ms * 1000));
|
||||
|
||||
if ($dateTime === false) {
|
||||
throw new InvalidUuidException("Failed to extract datetime from UUID: {$uuid->toString()}");
|
||||
}
|
||||
|
||||
return $dateTime;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ describe('ApiClientRepository Integration Tests', function (): void {
|
|||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
isActive: true,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
updatedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -80,7 +79,6 @@ describe('ApiClientRepository Integration Tests', function (): void {
|
|||
identifier: new ClientIdentifier($identifier),
|
||||
secret: new ClientSecret($oldSecret),
|
||||
isActive: true,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
updatedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -173,7 +171,6 @@ describe('ApiClientRepository Integration Tests', function (): void {
|
|||
identifier: new ClientIdentifier($identifier),
|
||||
secret: new ClientSecret($secret),
|
||||
isActive: true,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
updatedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -195,7 +192,6 @@ describe('ApiClientRepository Integration Tests', function (): void {
|
|||
identifier: new ClientIdentifier('arcadia_id_' . str_repeat('z', 32)),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('z', 86)),
|
||||
isActive: true,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
updatedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
|
|||
107
tests/Unit/Application/Security/Models/Entities/ClientTest.php
Normal file
107
tests/Unit/Application/Security/Models/Entities/ClientTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Application\Security\Models\Entities;
|
||||
|
||||
use Application\Security\Models\Entities\Client;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientName;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Shared\DDD\Model\HasTimestampIdentifier;
|
||||
|
||||
covers(Client::class);
|
||||
|
||||
describe('Client Entity', function (): void {
|
||||
it('creates a new client and derives createdAt from ClientId', function (): void {
|
||||
// GIVEN
|
||||
$uuid = Uuid::uuid7();
|
||||
$clientId = new ClientId($uuid);
|
||||
$name = new ClientName('Test Client');
|
||||
$identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
|
||||
$secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86));
|
||||
|
||||
// WHEN
|
||||
$client = Client::create(
|
||||
id: $clientId,
|
||||
name: $name,
|
||||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect($client)->toBeInstanceOf(HasTimestampIdentifier::class)
|
||||
->and($client->id())->toBe($clientId)
|
||||
->and($client->name)->toBe($name)
|
||||
->and($client->identifier)->toBe($identifier)
|
||||
->and($client->secret)->toBe($secret)
|
||||
->and($client->isActive)->toBeTrue()
|
||||
->and($client->createdAt())->toBeInstanceOf(\DateTimeInterface::class)
|
||||
->and($client->createdAt()->getTimestamp())->toBeLessThanOrEqual(time());
|
||||
});
|
||||
|
||||
it('hydrates a client without stored createdAt', function (): void {
|
||||
// GIVEN
|
||||
$uuid = Uuid::uuid7();
|
||||
$clientId = new ClientId($uuid);
|
||||
$name = new ClientName('Hydrated Client');
|
||||
$identifier = new ClientIdentifier('arcadia_id_' . str_repeat('c', 32));
|
||||
$secret = new ClientSecret('arcadia_sec_' . str_repeat('d', 86));
|
||||
$updatedAt = new \DateTimeImmutable('2026-01-01 12:00:00');
|
||||
|
||||
// WHEN
|
||||
$client = Client::hydrate(
|
||||
id: $clientId,
|
||||
name: $name,
|
||||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
isActive: false,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect($client->id())->toBe($clientId)
|
||||
->and($client->isActive)->toBeFalse()
|
||||
->and($client->updatedAt)->toBe($updatedAt)
|
||||
->and($client->createdAt())->toBeInstanceOf(\DateTimeInterface::class)
|
||||
->and($client->createdAt()->getTimestamp())->toBeLessThanOrEqual(time());
|
||||
});
|
||||
|
||||
it('regenerates secret and updates updatedAt', function (): void {
|
||||
// GIVEN
|
||||
$uuid = Uuid::uuid7();
|
||||
$client = Client::create(
|
||||
id: new ClientId($uuid),
|
||||
name: new ClientName('Test Client'),
|
||||
identifier: new ClientIdentifier('arcadia_id_' . str_repeat('e', 32)),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('f', 86)),
|
||||
);
|
||||
$newSecret = new ClientSecret('arcadia_sec_' . str_repeat('g', 86));
|
||||
|
||||
// WHEN
|
||||
$client->regenerateSecret($newSecret);
|
||||
|
||||
// THEN
|
||||
expect($client->secret)->toBe($newSecret);
|
||||
});
|
||||
|
||||
it('sets active status and updates updatedAt', function (): void {
|
||||
// GIVEN
|
||||
$uuid = Uuid::uuid7();
|
||||
$client = Client::create(
|
||||
id: new ClientId($uuid),
|
||||
name: new ClientName('Test Client'),
|
||||
identifier: new ClientIdentifier('arcadia_id_' . str_repeat('h', 32)),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('i', 86)),
|
||||
);
|
||||
|
||||
// WHEN
|
||||
$result = $client->setActive(false);
|
||||
|
||||
// THEN
|
||||
expect($result)->toBeFalse()
|
||||
->and($client->isActive)->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
|
@ -140,7 +140,6 @@ describe('CreateClient Usecase Unit Tests', function (): void {
|
|||
identifier: new ClientIdentifier('arcadia_id_' . str_repeat('e', 32)),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('f', 86)),
|
||||
isActive: true,
|
||||
createdAt: now()->toImmutable(),
|
||||
updatedAt: now()->toImmutable(),
|
||||
);
|
||||
$repository->persist($client);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ describe('FindAllClients Usecase Unit Tests', function (): void {
|
|||
identifier: new ClientIdentifier('arcadia_id_' . str_repeat($identifierSuffix, 32)),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('a', 86)),
|
||||
isActive: true,
|
||||
createdAt: new \DateTimeImmutable('2024-01-01'),
|
||||
updatedAt: new \DateTimeImmutable('2024-01-01'),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ describe('FindClient Usecase Unit Tests', function (): void {
|
|||
identifier: new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('1', 86)),
|
||||
isActive: true,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -48,7 +47,6 @@ describe('FindClient Usecase Unit Tests', function (): void {
|
|||
identifier: new ClientIdentifier('arcadia_id_' . str_repeat('b', 32)),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('2', 86)),
|
||||
isActive: false,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ describe('RegenerateClientSecret Usecase Unit Tests', function (): void {
|
|||
identifier: $clientIdentifier,
|
||||
secret: $oldSecret,
|
||||
isActive: true,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ describe('SetClientStatus Usecase', function (): void {
|
|||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
isActive: true,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -92,7 +91,6 @@ describe('SetClientStatus Usecase', function (): void {
|
|||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
isActive: false,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -146,7 +144,6 @@ describe('SetClientStatus Usecase', function (): void {
|
|||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
isActive: true,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -183,7 +180,6 @@ describe('SetClientStatus Usecase', function (): void {
|
|||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
isActive: true,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -224,7 +220,6 @@ describe('SetClientStatus Usecase', function (): void {
|
|||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
isActive: false,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ describe('ClientId Value Object Instantiation', function (): void {
|
|||
|
||||
// THEN
|
||||
expect($clientId->identifier)->toBe($uuid7)
|
||||
->and($clientId->value)->toBe($uuid7->toString());
|
||||
->and($clientId->value)->toBe($uuid7->toString())
|
||||
->and($clientId->toDateTime())->toBeInstanceOf(\DateTimeInterface::class)
|
||||
->and($clientId->toDateTime()->getTimestamp())->toBeLessThanOrEqual(time());
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ describe('InMemoryClientRepository Unit Tests', function (): void {
|
|||
identifier: new ClientIdentifier($identifierStr),
|
||||
secret: new ClientSecret('arcadia_sec_' . str_repeat('a', 86)),
|
||||
isActive: true,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
updatedAt: new \DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ describe('RequestValidatorImpl Unit Tests', function (): void {
|
|||
identifier: $clientIdentifier,
|
||||
secret: $clientSecret,
|
||||
isActive: true,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
updatedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
@ -162,7 +161,6 @@ describe('RequestValidatorImpl Unit Tests', function (): void {
|
|||
identifier: $clientIdentifier,
|
||||
secret: $clientSecret,
|
||||
isActive: false,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
updatedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use Ramsey\Uuid\Fields\FieldsInterface;
|
|||
use Ramsey\Uuid\Guid\Guid;
|
||||
use Ramsey\Uuid\Nonstandard\Uuid as NonstandardUuid;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Shared\Helpers\Exceptions\InvalidUuidException;
|
||||
use Shared\Helpers\UuidHelper;
|
||||
|
||||
covers(UuidHelper::class);
|
||||
|
|
@ -106,4 +107,26 @@ describe('Uuid Helper Unit Tests', function (): void {
|
|||
expect(UuidHelper::version($uuidValid))->toBe(7)
|
||||
->and(UuidHelper::version($uuidShort))->toBe(0);
|
||||
});
|
||||
|
||||
it('extracts DateTimeImmutable from UUIDv7 correctly', function (): void {
|
||||
// GIVEN
|
||||
$uuidV7 = Uuid::uuid7();
|
||||
|
||||
// WHEN
|
||||
$dateTime = UuidHelper::toDateTime($uuidV7);
|
||||
|
||||
// THEN
|
||||
expect($dateTime)->toBeInstanceOf(\DateTimeImmutable::class)
|
||||
->and($dateTime->getTimestamp())->toBeLessThanOrEqual(time())
|
||||
->and($dateTime->getTimestamp())->toBeGreaterThan(1700000000);
|
||||
});
|
||||
|
||||
it('throws InvalidUuidException when extracting DateTime from non-V7 UUID', function (): void {
|
||||
// GIVEN
|
||||
$uuidV4 = Uuid::uuid4();
|
||||
|
||||
// WHEN & THEN
|
||||
expect(fn () => UuidHelper::toDateTime($uuidV4))
|
||||
->toThrow(InvalidUuidException::class, 'Cannot extract datetime from non-V7 UUID');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue