diff --git a/.env.example b/.env.example index 95ca9e9..76f1d2a 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,7 @@ FILESYSTEM_DISK=local QUEUE_CONNECTION=database CACHE_STORE=database +ARCADIA_CACHE_STORE=redis # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 @@ -83,3 +84,5 @@ VITE_APP_NAME="${APP_NAME}" THROTTLE_WEB=120 THROTTLE_API_V1=60 + +ARCADIA_AUTH_DEV_BYPASS=false diff --git a/.env.testing b/.env.testing index 86eff9b..807a73a 100644 --- a/.env.testing +++ b/.env.testing @@ -29,10 +29,13 @@ BROADCAST_CONNECTION=null FILESYSTEM_DISK=local QUEUE_CONNECTION=sync CACHE_STORE=array -MAIL_MAILER=array +MAIL_MAILER= + +ARCADIA_CACHE_STORE=array SPEC_PATH=documentation/OpenApi PULSE_ENABLED=false TELESCOPE_ENABLED=false NIGHTWATCH_ENABLED=false +ARCADIA_AUTH_DEV_BYPASS=true diff --git a/bootstrap/providers.php b/bootstrap/providers.php index e8feb77..8b714a0 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -4,8 +4,10 @@ declare(strict_types=1); use Infra\Providers\AppServiceProvider; use Infra\Providers\DomainServiceProvider; +use Infra\Providers\SecurityServiceProvider; return [ - DomainServiceProvider::class, AppServiceProvider::class, + DomainServiceProvider::class, + SecurityServiceProvider::class, ]; diff --git a/config/auth.php b/config/auth.php index 5710a70..18290dc 100644 --- a/config/auth.php +++ b/config/auth.php @@ -116,4 +116,24 @@ return [ 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + /* + |-------------------------------------------------------------------------- + | HMAC Authentication Options + |-------------------------------------------------------------------------- + | + | allow_dev_bypass: When enabled in local or testing environments, allows + | requests without HMAC signatures to proceed using a default dev client. + | + */ + + 'hmac' => [ + 'allow_dev_bypass' => (bool) env('ARCADIA_AUTH_DEV_BYPASS', false), + 'dev_client' => [ + 'id' => env('ARCADIA_DEV_CLIENT_ID', '01915ec0-0000-7000-8000-000000000001'), + 'name' => env('ARCADIA_DEV_CLIENT_NAME', 'Arcadia Dev Test Client'), + 'identifier' => env('ARCADIA_DEV_CLIENT_IDENTIFIER', 'arcadia_id_00000000000000000000000000000001'), + 'secret' => env('ARCADIA_DEV_CLIENT_SECRET', 'arcadia_sec_00000000000000000000000000000000000000000000000000000000000000000000000000000000000001'), + ], + ], + ]; diff --git a/config/cache.php b/config/cache.php index a28f5fa..c201d1b 100644 --- a/config/cache.php +++ b/config/cache.php @@ -19,6 +19,8 @@ return [ 'default' => env('CACHE_STORE', 'database'), + 'arcadia_store' => env('ARCADIA_CACHE_STORE', env('CACHE_STORE', 'database')), + /* |-------------------------------------------------------------------------- | Cache Stores diff --git a/database/factories/ApiClientFactory.php b/database/factories/ApiClientFactory.php new file mode 100644 index 0000000..d9559ab --- /dev/null +++ b/database/factories/ApiClientFactory.php @@ -0,0 +1,37 @@ + + */ +final class ApiClientFactory extends Factory +{ + protected $model = ApiClient::class; + + /** + * @return array + */ + #[\Override] + public function definition(): array + { + $clientIdentifierGenerator = resolve(ClientCredentialGenerator::class); + + return [ + 'id' => Uuid::uuid7()->toString(), + 'name' => fake()->company(), + 'identifier' => $clientIdentifierGenerator->generateIdentifier()->value, + 'secret' => $clientIdentifierGenerator->generateSecret()->value, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } +} diff --git a/database/factories/EmployeeFactory.php b/database/factories/EmployeeFactory.php index c29b27b..db46758 100644 --- a/database/factories/EmployeeFactory.php +++ b/database/factories/EmployeeFactory.php @@ -30,6 +30,8 @@ final class EmployeeFactory extends Factory 'establishment' => '01', 'site' => 'LDCSER', 'service' => '097', + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), ]; } } diff --git a/database/migrations/2026_08_10_145347_create_api_clients_table.php b/database/migrations/2026_08_10_145347_create_api_clients_table.php index fe0d6a1..c29e463 100644 --- a/database/migrations/2026_08_10_145347_create_api_clients_table.php +++ b/database/migrations/2026_08_10_145347_create_api_clients_table.php @@ -18,10 +18,11 @@ return new class () extends Migration $table->string('name'); $table->string('identifier')->unique(); - $table->string('secret'); + $table->text('secret'); $table->boolean('is_active'); - $table->timestamps(); + $table->timestamp('created_at'); + $table->timestamp('updated_at'); }); } diff --git a/database/seeders/ApiClientSeeder.php b/database/seeders/ApiClientSeeder.php new file mode 100644 index 0000000..4a7bcda --- /dev/null +++ b/database/seeders/ApiClientSeeder.php @@ -0,0 +1,33 @@ +updateOrCreate( + ['identifier' => $devClient['identifier']], + [ + 'id' => $devClient['id'], + 'name' => $devClient['name'], + 'secret' => $devClient['secret'], + 'is_active' => true, + ] + ); + + $this->command?->info('API client seeded successfully.'); + $this->command?->info('Client ID: ' . $devClient['id']); + $this->command?->info('Client Identifier: ' . $devClient['identifier']); + $this->command?->info('Client Secret: ' . $devClient['secret']); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 08417dd..f00efe6 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ final class DatabaseSeeder extends Seeder $this->call([ EmployeeSeeder::class, IncoSheetSeeder::class, + ApiClientSeeder::class, ]); } } diff --git a/src/Infra/Adapters/Repositories/ApiClientRepository.php b/src/Infra/Adapters/Repositories/ApiClientRepository.php new file mode 100644 index 0000000..df3159d --- /dev/null +++ b/src/Infra/Adapters/Repositories/ApiClientRepository.php @@ -0,0 +1,89 @@ +updateOrCreate( + ['id' => $client->id->value], + [ + 'name' => $client->name, + 'identifier' => $client->identifier, + 'secret' => $client->secret, + 'is_active' => $client->isActive, + ] + ); + } + + #[\Override] + public function findByIdentifier(ClientIdentifier $identifier): ?Client + { + return transform( + ApiClient::query() + ->where('identifier', $identifier->value) + ->first(), + fn (ApiClient $apiClient) => $this->toDomain($apiClient) + ); + } + + #[\Override] + public function find(ClientId $id): ?Client + { + return transform( + ApiClient::query()->where('id', $id->value)->first(), + fn (ApiClient $apiClient) => $this->toDomain($apiClient) + ); + } + + #[\Override] + public function findAll(Pagination $pagination): Paginated + { + $query = ApiClient::query(); + $total = $query->count(); + $items = $query->forPage( + $pagination->pageNumber(), + $pagination->pageSize() + )->get(); + + return new Paginated( + pagination: $pagination, + items: $items->map(fn (ApiClient $apiClient) => $this->toDomain($apiClient))->all(), + total: $total, + ); + } + + #[\Override] + public function delete(Client $client): void + { + $deleted = ApiClient::query()->where('id', $client->id->value)->delete(); + if ($deleted === 0) { + throw new \LogicException('Client not found'); + } + } + + private function toDomain(ApiClient $apiClient): Client + { + return Client::hydrate( + id: new ClientId($apiClient->id), + name: $apiClient->name, + identifier: $apiClient->identifier, + secret: $apiClient->secret, + isActive: $apiClient->is_active, + createdAt: $apiClient->created_at, + updatedAt: $apiClient->updated_at, + ); + } +} diff --git a/src/Infra/Adapters/Services/CachedNonceValidator.php b/src/Infra/Adapters/Services/CachedNonceValidator.php new file mode 100644 index 0000000..4300a7a --- /dev/null +++ b/src/Infra/Adapters/Services/CachedNonceValidator.php @@ -0,0 +1,75 @@ +getCacheKey($forClientIdentifier, $nonce); + + if ($this->isKeyConsumed($key)) { + $this->logger->debug( + 'Nonce already used for client: {clientIdentifier}, nonce: {nonce}', + [ + 'clientIdentifier' => $forClientIdentifier->value, + 'nonce' => $nonce->value, + ] + ); + + return false; + } + + $this->consumeKey($key); + + return true; + } + + private function isKeyConsumed(string $key): bool + { + return $this->cacheStore->has($key); + } + + private function consumeKey(string $key): void + { + $this->cacheStore->set( + $key, + 'used', + self::CACHE_TTL_SECONDS + ); + + $this->logger->debug( + 'Nonce consumed for key: {key}', + ['key' => $key] + ); + } + + private function getCacheKey(ClientIdentifier $forClientIdentifier, Nonce $nonce): string + { + return sprintf( + self::CACHE_KEY_PREFIX . '%s:%s', + $forClientIdentifier->value, + $nonce->value + ); + } +} diff --git a/src/Infra/Adapters/Services/ClientCredentialGeneratorImpl.php b/src/Infra/Adapters/Services/ClientCredentialGeneratorImpl.php new file mode 100644 index 0000000..966764a --- /dev/null +++ b/src/Infra/Adapters/Services/ClientCredentialGeneratorImpl.php @@ -0,0 +1,35 @@ +compileDataToSign($request); + + $expectedSignature = $this->calculateSignature( + $payloadToSign, + $withSecret + ); + + return $this->compareSignatures( + $expectedSignature, + $request->signature + ); + } + + private function compareSignatures(string $expectedSignature, RequestSignature $providedSignature): bool + { + // NOTE: Use hash_equals to prevent timing attacks when comparing signatures. + return hash_equals($expectedSignature, $providedSignature->value); + } + + /** @param list $signaturePayload */ + private function calculateSignature(array $signaturePayload, ClientSecret $withSecret): string + { + $payloadString = implode('|', $signaturePayload); + + return hash_hmac('sha256', $payloadString, $withSecret->value); + } + + /** @return list */ + private function compileDataToSign(Request $request): array + { + return [ + $request->method->name, + $this->getOrdonatedPathAndQuery($request->uri), + $request->timestamp->value->format(\DateTimeInterface::ATOM), + $request->nonce->value->toString(), + $request->bodyHash->value ?? self::SHA256_EMPTY_STRING_HASH, + ]; + } + + private function getOrdonatedPathAndQuery(Uri $fromUri): string + { + $path = $fromUri->getPath(); + $query = $fromUri->getQuery(); + + if ($query === null || $query === '') { + return $path; + } + + // NOTE: Sort query parameters to ensure consistent signature generation + parse_str($query, $queryParams); + ksort($queryParams); + $sortedQuery = http_build_query($queryParams); + + return $path . '?' . $sortedQuery; + } +} diff --git a/src/Infra/Adapters/Services/RequestValidatorImpl.php b/src/Infra/Adapters/Services/RequestValidatorImpl.php new file mode 100644 index 0000000..ad79d40 --- /dev/null +++ b/src/Infra/Adapters/Services/RequestValidatorImpl.php @@ -0,0 +1,113 @@ +findClientFrom($request); + $firstFailure = null; + + if ($client === null) { + $this->logger->debug( + 'Client not found for identifier: {identifier}', + ['identifier' => $request->identifier] + ); + $firstFailure = RequestValidationStatus::CLIENT_NOT_FOUND; + } + + if ($client !== null && ! $client->isActive) { + $this->logger->debug( + 'Client is disabled for identifier: {identifier}', + ['identifier' => $request->identifier] + ); + $firstFailure ??= RequestValidationStatus::CLIENT_NOT_AUTHORIZED; + } + + if (! $this->validateTimestampFrom($request)) { + $this->logger->debug( + 'Invalid timestamp: {timestamp}', + ['timestamp' => $request->timestamp->value->format(\DateTimeInterface::ATOM)] + ); + $firstFailure ??= RequestValidationStatus::INVALID_TIMESTAMP; + } + + if ($client !== null && ! $this->validateSignatureFrom($request, $client)) { + $this->logger->debug( + 'Invalid signature for client: {clientId}', + ['clientId' => $request->identifier] + ); + $firstFailure ??= RequestValidationStatus::INVALID_SIGNATURE; + } + + if ($firstFailure === null && ! $this->validateNonceFrom($request)) { + $this->logger->debug( + 'Invalid nonce: {nonce}', + ['nonce' => $request->nonce] + ); + + return RequestValidationResult::failed(RequestValidationStatus::INVALID_NONCE); + } + + if ($firstFailure !== null) { + return RequestValidationResult::failed($firstFailure); + } + + /** @var Client $client */ + return RequestValidationResult::valid($client); + } + + private function findClientFrom(Request $request): ?Client + { + return $this->clientRepository->findByIdentifier( + identifier: $request->identifier + ); + } + + private function validateNonceFrom(Request $request): bool + { + return $this->nonceValidator->validateAndConsume( + nonce: $request->nonce, + forClientIdentifier: $request->identifier + ); + } + + private function validateTimestampFrom(Request $request): bool + { + return $this->timestampValidator->validate( + timestamp: $request->timestamp->value + ); + } + + private function validateSignatureFrom(Request $request, Client $forClient): bool + { + return $this->signatureValidator->validate( + request: $request, + withSecret: $forClient->secret + ); + } +} diff --git a/src/Infra/Adapters/Services/TimestampValidatorImpl.php b/src/Infra/Adapters/Services/TimestampValidatorImpl.php new file mode 100644 index 0000000..4983ccd --- /dev/null +++ b/src/Infra/Adapters/Services/TimestampValidatorImpl.php @@ -0,0 +1,40 @@ +getAllowedSkew(); + + return $this->isTimestampInSkew($timestamp, $allowedSkew); + } + + private function isTimestampInSkew(\DateTimeInterface $timestamp, \DateInterval $allowedSkew): bool + { + $now = $this->getCurrentTime(); + + return ($timestamp >= $now->sub($allowedSkew)) && ($timestamp <= $now->add($allowedSkew)); + } + + private function getCurrentTime(): \DateTimeImmutable + { + return new \DateTimeImmutable('now', $this->timezone); + } + + private function getAllowedSkew(): \DateInterval + { + return new \DateInterval('PT' . TimestampValidator::ALLOWED_TIMESTAMP_SKEW_SECONDS . 'S'); + } +} diff --git a/src/Infra/Casts/ClientIdentifierCast.php b/src/Infra/Casts/ClientIdentifierCast.php new file mode 100644 index 0000000..f21f699 --- /dev/null +++ b/src/Infra/Casts/ClientIdentifierCast.php @@ -0,0 +1,43 @@ + */ +final class ClientIdentifierCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ClientIdentifier + { + if ($value === null) { + throw new \InvalidArgumentException('ClientIdentifier value cannot be null'); + } + + if (! is_string($value)) { + throw new \InvalidArgumentException('Value must be a string'); + } + + return new ClientIdentifier($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + if ($value === null) { + throw new \InvalidArgumentException('ClientIdentifier value cannot be null'); + } + + if ($value instanceof ClientIdentifier) { + return $value->value; + } + + if (is_string($value)) { + return (new ClientIdentifier($value))->value; + } + + throw new \InvalidArgumentException('Value must be a string or instance of ClientIdentifier'); + } +} diff --git a/src/Infra/Casts/ClientNameCast.php b/src/Infra/Casts/ClientNameCast.php new file mode 100644 index 0000000..2a04c03 --- /dev/null +++ b/src/Infra/Casts/ClientNameCast.php @@ -0,0 +1,43 @@ + */ +final class ClientNameCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ClientName + { + if ($value === null) { + throw new \InvalidArgumentException('ClientName value cannot be null'); + } + + if (! is_string($value)) { + throw new \InvalidArgumentException('Value must be a string'); + } + + return new ClientName($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + if ($value === null) { + throw new \InvalidArgumentException('ClientName value cannot be null'); + } + + if ($value instanceof ClientName) { + return $value->value; + } + + if (is_string($value)) { + return (new ClientName($value))->value; + } + + throw new \InvalidArgumentException('Value must be a string or instance of ClientName'); + } +} diff --git a/src/Infra/Casts/ClientSecretCast.php b/src/Infra/Casts/ClientSecretCast.php new file mode 100644 index 0000000..4563446 --- /dev/null +++ b/src/Infra/Casts/ClientSecretCast.php @@ -0,0 +1,57 @@ + */ +final class ClientSecretCast implements CastsAttributes +{ + /** + * @param array $attributes + */ + #[\Override] + public function get(Model $model, string $key, mixed $value, array $attributes): ClientSecret + { + if ($value === null) { + throw new \InvalidArgumentException('ClientSecret value cannot be null'); + } + + if (! is_string($value)) { + throw new \InvalidArgumentException('Value must be a string'); + } + + try { + $decrypted = Crypt::decryptString($value); + } catch (DecryptException) { + $decrypted = $value; + } + + return new ClientSecret($decrypted); + } + + /** + * @param array $attributes + */ + #[\Override] + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + if ($value === null) { + throw new \InvalidArgumentException('ClientSecret value cannot be null'); + } + + $plainSecret = match (true) { + $value instanceof ClientSecret => $value->value, + is_string($value) => (new ClientSecret($value))->value, + default => throw new \InvalidArgumentException('Value must be a string or instance of ClientSecret'), + }; + + return Crypt::encryptString($plainSecret); + } +} diff --git a/src/Infra/Casts/UuidCast.php b/src/Infra/Casts/UuidCast.php new file mode 100644 index 0000000..9627292 --- /dev/null +++ b/src/Infra/Casts/UuidCast.php @@ -0,0 +1,47 @@ + */ +final class UuidCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): UuidInterface + { + if ($value === null) { + throw new \InvalidArgumentException('UUID value cannot be null'); + } + + if (! is_string($value)) { + throw new \InvalidArgumentException('Value must be a string'); + } + + return Uuid::fromString($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + if ($value === null) { + throw new \InvalidArgumentException('UUID value cannot be null'); + } + + if (is_string($value)) { + $value = Uuid::fromString($value); + } + + if (! $value instanceof UuidInterface) { + throw new \InvalidArgumentException('Value must be a string or instance of UuidInterface'); + } + + UuidHelper::validateUuid($value); + + return (string) $value; + } +} diff --git a/src/Infra/Database/Models/ApiClient.php b/src/Infra/Database/Models/ApiClient.php new file mode 100644 index 0000000..2d55bf8 --- /dev/null +++ b/src/Infra/Database/Models/ApiClient.php @@ -0,0 +1,78 @@ + $notifications + * + * @method static \Database\Factories\ApiClientFactory factory($count = null, $state = []) + * + * @mixin \Eloquent + */ +final class ApiClient extends Authenticatable +{ + /** @use HasFactory */ + use HasFactory; + + use HasUuids; + use Notifiable; + + protected static function newFactory(): ApiClientFactory + { + return ApiClientFactory::new(); + } + + protected $table = 'api_clients'; + + public $timestamps = true; + + /** @var list */ + protected $fillable = [ + 'id', + 'name', + 'identifier', + 'secret', + 'is_active', + 'created_at', + 'updated_at', + ]; + + /** @var list */ + protected $hidden = [ + 'secret', + ]; + + /** @return array */ + #[\Override] + protected function casts(): array + { + return [ + 'id' => UuidCast::class, + 'name' => ClientNameCast::class, + 'identifier' => ClientIdentifierCast::class, + 'secret' => ClientSecretCast::class, + 'is_active' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/src/Infra/Providers/SecurityServiceProvider.php b/src/Infra/Providers/SecurityServiceProvider.php new file mode 100644 index 0000000..85a1337 --- /dev/null +++ b/src/Infra/Providers/SecurityServiceProvider.php @@ -0,0 +1,62 @@ +app->bind(ClientCredentialGenerator::class, ClientCredentialGeneratorImpl::class); + $this->app->bind(ClientRepository::class, ApiClientRepository::class); + $this->app->bind(HmacSignatureValidator::class, HmacSignatureValidatorImpl::class); + + $this->app->bind(NonceValidator::class, function (Application $app): CachedNonceValidator { + $storeName = Config::string('cache.arcadia_store'); + /** @var \Psr\SimpleCache\CacheInterface $cache */ + $cache = Cache::store($storeName); + + return new CachedNonceValidator( + logger: $app->make(LoggerInterface::class), + cacheStore: $cache, + ); + }); + + $this->app->bind(TimestampValidator::class, function () { + $timezone = new \DateTimeZone(Config::string('app.timezone')); + + return new TimestampValidatorImpl($timezone); + }); + $this->app->bind(RequestValidator::class, RequestValidatorImpl::class); + + if ($this->app->runningInConsole()) { + $this->app->bind(UserDetailsProvider::class, ConsoleUserDetailsProvider::class); + } else { + $this->app->scoped(ApiUserDetailsProvider::class); + $this->app->bind(UserDetailsProvider::class, ApiUserDetailsProvider::class); + } + } +} diff --git a/src/Infra/Usecase/LaravelExecutor.php b/src/Infra/Usecase/LaravelExecutor.php index 78625bf..2d262f1 100644 --- a/src/Infra/Usecase/LaravelExecutor.php +++ b/src/Infra/Usecase/LaravelExecutor.php @@ -6,55 +6,70 @@ namespace Infra\Usecase; use Illuminate\Database\DatabaseManager; use Psr\Log\LoggerInterface; +use Shared\DDD\Security\UserDetailsProvider; use Shared\DDD\Usecase\Builder\UsecaseExecutor; use Shared\DDD\Usecase\Input; use Shared\DDD\Usecase\Presenter; use Shared\DDD\Usecase\Usecase; -/** @implements UsecaseExecutor */ final readonly class LaravelExecutor implements UsecaseExecutor { public function __construct( private DatabaseManager $database, private LoggerInterface $logger, + private ?UserDetailsProvider $userDetailsProvider = null, ) { } /** + * @template TInput of Input + * @template TOutput of \Shared\DDD\Usecase\Output * @template TView of object * - * @param Usecase $usecase - * @param Presenter<\Shared\DDD\Usecase\Output, TView> $presenter + * @param Usecase $usecase + * @param TInput $input + * @param Presenter $presenter * @return TView */ #[\Override] public function execute(Usecase $usecase, Input $input, Presenter $presenter): object { + $currentUser = $this->userDetailsProvider?->currentUser(); + $context = [ 'usecase' => $usecase::class, 'input' => $input::class, 'presenter' => $presenter::class, + 'user' => $currentUser ? [ + 'id' => $currentUser->id(), + 'name' => $currentUser->name(), + 'type' => $currentUser->type()->type(), + 'identifier' => $currentUser->identifier(), + ] : null, ]; - $this->logger->debug('Usecase execution started', $context); + $this->logger->debug('Starting usecase execution', $context); try { - /** @var TView $result */ $result = $this->database->transaction(function () use ($usecase, $input, $presenter): object { $output = $usecase->execute($input); return $presenter->present($output); }); - $this->logger->info('Usecase execution succeeded', $context); + $this->logger->info('Usecase executed successfully', $context); + /** @var TView */ return $result; - } catch (\Throwable $exception) { - $this->logger->error('Usecase execution failed', $context + [ - 'exception' => $exception, + } catch (\Throwable $e) { + $this->logger->error('Usecase execution failed: ' . $e->getMessage(), [ + ...$context, + 'exception' => $e::class, + 'file' => $e->getFile(), + 'line' => $e->getLine(), ]); - throw $exception; + throw $e; } } } diff --git a/tests/Integration/Database/Seeders/ApiClientSeederTest.php b/tests/Integration/Database/Seeders/ApiClientSeederTest.php new file mode 100644 index 0000000..9210cfd --- /dev/null +++ b/tests/Integration/Database/Seeders/ApiClientSeederTest.php @@ -0,0 +1,31 @@ +run(); + + // THEN + $client = ApiClient::query() + ->where('name', 'Arcadia Dev Test Client') + ->first(); + expect($client)->not->toBeNull() + ->and($client?->is_active)->toBeTrue(); + }); +}); diff --git a/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php b/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php new file mode 100644 index 0000000..de9280b --- /dev/null +++ b/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php @@ -0,0 +1,208 @@ +delete(); +}); + +describe('ApiClientRepository Integration Tests', function (): void { + it('persists a new client into database', function (): void { + // GIVEN + $repository = new ApiClientRepository(); + $clientId = new ClientId(Uuid::uuid7()); + $identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86)); + + $client = Client::hydrate( + id: $clientId, + name: new ClientName('New API Client'), + identifier: $identifier, + secret: $secret, + isActive: true, + createdAt: new DateTimeImmutable(), + updatedAt: new DateTimeImmutable(), + ); + + // WHEN + $repository->persist($client); + + // THEN + $found = $repository->find($clientId); + expect($found)->not->toBeNull() + ->and($found->id->value)->toBe($clientId->value) + ->and($found->name->value)->toBe('New API Client') + ->and($found->identifier->value)->toBe($identifier->value) + ->and($found->secret->value)->toBe($secret->value) + ->and($found->isActive)->toBeTrue(); + }); + + it('updates an existing client in database on persist', function (): void { + // GIVEN + $repository = new ApiClientRepository(); + $rawId = Uuid::uuid7()->toString(); + $identifier = 'arcadia_id_' . str_repeat('a', 32); + $oldSecret = 'arcadia_sec_' . str_repeat('1', 86); + $newSecret = 'arcadia_sec_' . str_repeat('2', 86); + + ApiClient::query()->create([ + 'id' => $rawId, + 'name' => 'Initial Client', + 'identifier' => $identifier, + 'secret' => $oldSecret, + 'is_active' => true, + ]); + + $clientId = new ClientId(Uuid::fromString($rawId)); + $client = Client::hydrate( + id: $clientId, + name: new ClientName('Initial Client'), + identifier: new ClientIdentifier($identifier), + secret: new ClientSecret($oldSecret), + isActive: true, + createdAt: new DateTimeImmutable(), + updatedAt: new DateTimeImmutable(), + ); + + $client->regenerateSecret(new ClientSecret($newSecret)); + + // WHEN + $repository->persist($client); + + // THEN + $found = $repository->find($clientId); + expect($found)->not->toBeNull() + ->and($found->secret->value)->toBe($newSecret); + }); + + it('finds a client by its ClientId', function (): void { + // GIVEN + $repository = new ApiClientRepository(); + $rawId = Uuid::uuid7()->toString(); + $identifier = 'arcadia_id_' . str_repeat('a', 32); + $secret = 'arcadia_sec_' . str_repeat('b', 86); + + ApiClient::query()->create([ + 'id' => $rawId, + 'name' => 'Existing Client', + 'identifier' => $identifier, + 'secret' => $secret, + 'is_active' => true, + ]); + + $clientId = new ClientId(Uuid::fromString($rawId)); + + // WHEN + $client = $repository->find($clientId); + + // THEN + expect($client)->not->toBeNull() + ->and($client->id->value)->toBe($rawId) + ->and($client->name->value)->toBe('Existing Client') + ->and($client->identifier->value)->toBe($identifier); + }); + + it('finds a client by its ClientIdentifier', function (): void { + // GIVEN + $repository = new ApiClientRepository(); + $rawId = Uuid::uuid7()->toString(); + $identifier = 'arcadia_id_' . str_repeat('a', 32); + $secret = 'arcadia_sec_' . str_repeat('b', 86); + + ApiClient::query()->create([ + 'id' => $rawId, + 'name' => 'Searchable Client', + 'identifier' => $identifier, + 'secret' => $secret, + 'is_active' => true, + ]); + + $clientIdentifier = new ClientIdentifier($identifier); + + // WHEN + $client = $repository->findByIdentifier($clientIdentifier); + + // THEN + expect($client)->not->toBeNull() + ->and($client->identifier->value)->toBe($identifier) + ->and($client->name->value)->toBe('Searchable Client'); + }); + + it('deletes an existing client from database', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + $repository = new ApiClientRepository(); + $rawId = Uuid::uuid7()->toString(); + $identifier = 'arcadia_id_' . str_repeat('a', 32); + $secret = 'arcadia_sec_' . str_repeat('b', 86); + + ApiClient::query()->create([ + 'id' => $rawId, + 'name' => 'Client to Delete', + 'identifier' => $identifier, + 'secret' => $secret, + 'is_active' => true, + ]); + + $client = Client::hydrate( + id: new ClientId(Uuid::fromString($rawId)), + name: new ClientName('Client to Delete'), + identifier: new ClientIdentifier($identifier), + secret: new ClientSecret($secret), + isActive: true, + createdAt: new DateTimeImmutable(), + updatedAt: new DateTimeImmutable(), + ); + + // WHEN + $repository->delete($client); + + // THEN + $this->assertDatabaseMissing('api_clients', [ + 'id' => $rawId, + ]); + }); + + it('finds all clients with pagination', function (): void { + // GIVEN + $repository = new ApiClientRepository(); + + for ($i = 1; $i <= 5; $i++) { + ApiClient::query()->create([ + 'id' => Uuid::uuid7()->toString(), + 'name' => "Client $i", + 'identifier' => 'arcadia_id_' . str_pad((string) $i, 32, '0', STR_PAD_LEFT), + 'secret' => 'arcadia_sec_' . str_pad((string) $i, 86, '0', STR_PAD_LEFT), + 'is_active' => true, + ]); + } + + $pagination = Pagination::ofPage(pageNumber: 1, pageSize: 2); + + // WHEN + $paginated = $repository->findAll($pagination); + + // THEN + expect($paginated->getTotal())->toBe(5) + ->and(count($paginated->getItems()))->toBe(2) + ->and($paginated->getPagination()->pageNumber())->toBe(1); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Services/CachedNonceValidatorTest.php b/tests/Unit/Infra/Adapters/Services/CachedNonceValidatorTest.php new file mode 100644 index 0000000..51e42e3 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Services/CachedNonceValidatorTest.php @@ -0,0 +1,70 @@ +value . ':' . $nonce->value->toString(); + + $cacheStore->expects('has') + ->once() + ->with($expectedKey) + ->andReturn(false); + + $cacheStore->expects('set') + ->once() + ->with($expectedKey, 'used', 300) + ->andReturn(true); + + // WHEN + $isValid = $validator->validateAndConsume($nonce, $clientIdentifier); + + // THEN + expect($isValid)->toBeTrue(); + }); + + it('rejects an already consumed nonce', function (): void { + // GIVEN + $cacheStore = Mockery::mock(CacheInterface::class); + $logger = new NullLogger(); + $validator = new CachedNonceValidator($logger, $cacheStore); + + $clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $nonce = new Nonce(Uuid::uuid7()); + + $expectedKey = 'nonce:' . $clientIdentifier->value . ':' . $nonce->value->toString(); + + $cacheStore->expects('has') + ->once() + ->with($expectedKey) + ->andReturn(true); + + $cacheStore->shouldNotReceive('set'); + + // WHEN + $isValid = $validator->validateAndConsume($nonce, $clientIdentifier); + + // THEN + expect($isValid)->toBeFalse(); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Services/ClientCredentialGeneratorImplTest.php b/tests/Unit/Infra/Adapters/Services/ClientCredentialGeneratorImplTest.php new file mode 100644 index 0000000..69066a7 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Services/ClientCredentialGeneratorImplTest.php @@ -0,0 +1,55 @@ +generateIdentifier(); + + // THEN + expect($identifier)->toBeInstanceOf(ClientIdentifier::class) + ->and($identifier->value)->toStartWith('arcadia_id_') + ->and(strlen($identifier->value))->toBe(43); + }); + + it('generates a valid client secret', function (): void { + // GIVEN + $generator = new ClientCredentialGeneratorImpl(); + + // WHEN + $secret = $generator->generateSecret(); + + // THEN + expect($secret)->toBeInstanceOf(ClientSecret::class) + ->and($secret->value)->toStartWith('arcadia_sec_') + ->and(strlen($secret->value))->toBeGreaterThanOrEqual(86) + ->and(strlen($secret->value))->toBeLessThanOrEqual(120); + }); + + it('generates unique credentials on subsequent calls', function (): void { + // GIVEN + $generator = new ClientCredentialGeneratorImpl(); + + // WHEN + $id1 = $generator->generateIdentifier(); + $id2 = $generator->generateIdentifier(); + $sec1 = $generator->generateSecret(); + $sec2 = $generator->generateSecret(); + + // THEN + expect($id1->value)->not->toBe($id2->value) + ->and($sec1->value)->not->toBe($sec2->value); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Services/HmacSignatureValidatorImplTest.php b/tests/Unit/Infra/Adapters/Services/HmacSignatureValidatorImplTest.php new file mode 100644 index 0000000..8a25a5b --- /dev/null +++ b/tests/Unit/Infra/Adapters/Services/HmacSignatureValidatorImplTest.php @@ -0,0 +1,59 @@ +format(DateTimeInterface::ATOM), + $nonce->value->toString(), + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ]); + + $signatureHash = hash_hmac('sha256', $expectedPayload, $secret->value); + $signature = new RequestSignature($signatureHash); + + $request = new Request( + uri: $uri, + method: HttpMethod::GET, + bodyHash: null, + signature: $signature, + nonce: $nonce, + identifier: $identifier, + timestamp: new RequestTimestamp($timestamp), + ); + + // WHEN + $isValid = $validator->validate($request, $secret); + + // THEN + expect($isValid)->toBeTrue(); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php b/tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php new file mode 100644 index 0000000..3c059b7 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php @@ -0,0 +1,199 @@ +expects('findByIdentifier') + ->with($clientIdentifier) + ->andReturn($client); + + $timestampValidator->expects('validate') + ->once() + ->andReturn(true); + + $signatureValidator->expects('validate') + ->once() + ->with($request, $clientSecret) + ->andReturn(true); + + $nonceValidator->expects('validateAndConsume') + ->once() + ->with($request->nonce, $clientIdentifier) + ->andReturn(true); + + // WHEN + $result = $validator->validate($request); + + // THEN + expect($result->isValid())->toBeTrue() + ->and($result->status)->toBe(RequestValidationStatus::VALID) + ->and($result->client)->toBe($client); + }); + + it('returns client not found when client does not exist', function (): void { + // GIVEN + $logger = new NullLogger(); + $signatureValidator = Mockery::mock(HmacSignatureValidator::class); + $nonceValidator = Mockery::mock(NonceValidator::class); + $timestampValidator = Mockery::mock(TimestampValidator::class); + $clientRepository = Mockery::mock(ClientRepository::class); + + $validator = new RequestValidatorImpl( + $logger, + $signatureValidator, + $nonceValidator, + $timestampValidator, + $clientRepository + ); + + $clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $request = new Request( + uri: new Uri('http://localhost/api/v1/test'), + method: HttpMethod::GET, + bodyHash: null, + signature: new RequestSignature(str_repeat('a', 64)), + nonce: new Nonce(Uuid::uuid7()), + identifier: $clientIdentifier, + timestamp: new RequestTimestamp(new DateTimeImmutable()), + ); + + $clientRepository->expects('findByIdentifier') + ->with($clientIdentifier) + ->andReturn(null); + + $timestampValidator->expects('validate') + ->once() + ->andReturn(true); + + // WHEN + $result = $validator->validate($request); + + // THEN + expect($result->isValid())->toBeFalse() + ->and($result->status)->toBe(RequestValidationStatus::CLIENT_NOT_FOUND) + ->and($result->client)->toBeNull(); + }); + + it('returns client not authorized when client is inactive', function (): void { + // GIVEN + $logger = new NullLogger(); + $signatureValidator = Mockery::mock(HmacSignatureValidator::class); + $nonceValidator = Mockery::mock(NonceValidator::class); + $timestampValidator = Mockery::mock(TimestampValidator::class); + $clientRepository = Mockery::mock(ClientRepository::class); + + $validator = new RequestValidatorImpl( + $logger, + $signatureValidator, + $nonceValidator, + $timestampValidator, + $clientRepository + ); + + $clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $clientSecret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86)); + $client = Client::hydrate( + id: new ClientId(Uuid::uuid7()), + name: new ClientName('Inactive Client'), + identifier: $clientIdentifier, + secret: $clientSecret, + isActive: false, + createdAt: new DateTimeImmutable(), + updatedAt: new DateTimeImmutable(), + ); + + $request = new Request( + uri: new Uri('http://localhost/api/v1/test'), + method: HttpMethod::GET, + bodyHash: null, + signature: new RequestSignature(str_repeat('a', 64)), + nonce: new Nonce(Uuid::uuid7()), + identifier: $clientIdentifier, + timestamp: new RequestTimestamp(new DateTimeImmutable()), + ); + + $clientRepository->expects('findByIdentifier') + ->with($clientIdentifier) + ->andReturn($client); + + $timestampValidator->expects('validate') + ->once() + ->andReturn(true); + + $signatureValidator->expects('validate') + ->once() + ->with($request, $clientSecret) + ->andReturn(true); + + // WHEN + $result = $validator->validate($request); + + // THEN + expect($result->isValid())->toBeFalse() + ->and($result->status)->toBe(RequestValidationStatus::CLIENT_NOT_AUTHORIZED); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Services/TimestampValidatorImplTest.php b/tests/Unit/Infra/Adapters/Services/TimestampValidatorImplTest.php new file mode 100644 index 0000000..138c148 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Services/TimestampValidatorImplTest.php @@ -0,0 +1,52 @@ +validate($timestamp); + + // THEN + expect($isValid)->toBeTrue(); + })->with([ + 'now', + '-2 minutes', + '+2 minutes', + '-299 seconds', + '+299 seconds', + ]); + + it('rejects timestamps outside the 300 seconds skew window', function (string $modifier): void { + // GIVEN + $timezone = new DateTimeZone('UTC'); + $validator = new TimestampValidatorImpl($timezone); + $timestamp = new DateTimeImmutable($modifier, $timezone); + + // WHEN + $isValid = $validator->validate($timestamp); + + // THEN + expect($isValid)->toBeFalse(); + })->with([ + '-6 minutes', + '+6 minutes', + '-301 seconds', + '+301 seconds', + '-1 day', + '+1 day', + ]); +});