feat(infra): add ApiClient Eloquent model, repository adapter, security services, migrations, seeders and tests
This commit is contained in:
parent
fa86b9c4e6
commit
e7904682b6
30 changed files with 1564 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ return [
|
|||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
'arcadia_store' => env('ARCADIA_CACHE_STORE', env('CACHE_STORE', 'database')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|
|
|
|||
37
database/factories/ApiClientFactory.php
Normal file
37
database/factories/ApiClientFactory.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Application\Security\Ports\Services\ClientCredentialGenerator;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Infra\Database\Models\ApiClient;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @extends Factory<ApiClient>
|
||||
*/
|
||||
final class ApiClientFactory extends Factory
|
||||
{
|
||||
protected $model = ApiClient::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[\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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@ final class EmployeeFactory extends Factory
|
|||
'establishment' => '01',
|
||||
'site' => 'LDCSER',
|
||||
'service' => '097',
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
'updated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
33
database/seeders/ApiClientSeeder.php
Normal file
33
database/seeders/ApiClientSeeder.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Infra\Database\Models\ApiClient;
|
||||
|
||||
final class ApiClientSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
/** @var array{id: string, name: string, identifier: string, secret: string} $devClient */
|
||||
$devClient = Config::array('auth.hmac.dev_client');
|
||||
|
||||
ApiClient::query()->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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ final class DatabaseSeeder extends Seeder
|
|||
$this->call([
|
||||
EmployeeSeeder::class,
|
||||
IncoSheetSeeder::class,
|
||||
ApiClientSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
src/Infra/Adapters/Repositories/ApiClientRepository.php
Normal file
89
src/Infra/Adapters/Repositories/ApiClientRepository.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Repositories;
|
||||
|
||||
use Application\Security\Models\Entities\Client;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Application\Security\Ports\Repositories\ClientRepository;
|
||||
use Infra\Database\Models\ApiClient;
|
||||
use Shared\Paginated;
|
||||
use Shared\Pagination;
|
||||
|
||||
final class ApiClientRepository implements ClientRepository
|
||||
{
|
||||
#[\Override]
|
||||
public function persist(Client $client): void
|
||||
{
|
||||
ApiClient::query()->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/Infra/Adapters/Services/CachedNonceValidator.php
Normal file
75
src/Infra/Adapters/Services/CachedNonceValidator.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\Request\Nonce;
|
||||
use Application\Security\Ports\Services\NonceValidator;
|
||||
use Application\Security\Ports\Services\TimestampValidator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
final readonly class CachedNonceValidator implements NonceValidator
|
||||
{
|
||||
private const string CACHE_KEY_PREFIX = 'nonce:';
|
||||
|
||||
private const int CACHE_TTL_SECONDS = TimestampValidator::ALLOWED_TIMESTAMP_SKEW_SECONDS;
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly CacheInterface $cacheStore,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function validateAndConsume(Nonce $nonce, ClientIdentifier $forClientIdentifier): bool
|
||||
{
|
||||
$key = $this->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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Ports\Services\ClientCredentialGenerator;
|
||||
|
||||
final readonly class ClientCredentialGeneratorImpl implements ClientCredentialGenerator
|
||||
{
|
||||
private const string IDENTIFIER_PREFIX = 'arcadia_id_';
|
||||
|
||||
private const string SECRET_PREFIX = 'arcadia_sec_';
|
||||
|
||||
#[\Override]
|
||||
public function generateIdentifier(): ClientIdentifier
|
||||
{
|
||||
$random = bin2hex(random_bytes(16));
|
||||
|
||||
return new ClientIdentifier(self::IDENTIFIER_PREFIX . $random);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function generateSecret(): ClientSecret
|
||||
{
|
||||
$random = bin2hex(random_bytes(32));
|
||||
$random = base64_encode($random);
|
||||
$random = strtr($random, '+/', '');
|
||||
$random = rtrim($random, '=');
|
||||
|
||||
return new ClientSecret(self::SECRET_PREFIX . $random);
|
||||
}
|
||||
}
|
||||
75
src/Infra/Adapters/Services/HmacSignatureValidatorImpl.php
Normal file
75
src/Infra/Adapters/Services/HmacSignatureValidatorImpl.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Request\Request;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestSignature;
|
||||
use Application\Security\Ports\Services\HmacSignatureValidator;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
final readonly class HmacSignatureValidatorImpl implements HmacSignatureValidator
|
||||
{
|
||||
private const string SHA256_EMPTY_STRING_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
#[\Override]
|
||||
public function validate(Request $request, ClientSecret $withSecret): bool
|
||||
{
|
||||
$payloadToSign = $this->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<string> $signaturePayload */
|
||||
private function calculateSignature(array $signaturePayload, ClientSecret $withSecret): string
|
||||
{
|
||||
$payloadString = implode('|', $signaturePayload);
|
||||
|
||||
return hash_hmac('sha256', $payloadString, $withSecret->value);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
113
src/Infra/Adapters/Services/RequestValidatorImpl.php
Normal file
113
src/Infra/Adapters/Services/RequestValidatorImpl.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Models\Entities\Client;
|
||||
use Application\Security\Models\ValueObjects\Request\Request;
|
||||
use Application\Security\Ports\Repositories\ClientRepository;
|
||||
use Application\Security\Ports\Services\HmacSignatureValidator;
|
||||
use Application\Security\Ports\Services\NonceValidator;
|
||||
use Application\Security\Ports\Services\RequestValidation\RequestValidationResult;
|
||||
use Application\Security\Ports\Services\RequestValidation\RequestValidationStatus;
|
||||
use Application\Security\Ports\Services\RequestValidator;
|
||||
use Application\Security\Ports\Services\TimestampValidator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final readonly class RequestValidatorImpl implements RequestValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly HmacSignatureValidator $signatureValidator,
|
||||
private readonly NonceValidator $nonceValidator,
|
||||
private readonly TimestampValidator $timestampValidator,
|
||||
private readonly ClientRepository $clientRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function validate(Request $request): RequestValidationResult
|
||||
{
|
||||
$client = $this->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
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/Infra/Adapters/Services/TimestampValidatorImpl.php
Normal file
40
src/Infra/Adapters/Services/TimestampValidatorImpl.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Ports\Services\TimestampValidator;
|
||||
|
||||
final readonly class TimestampValidatorImpl implements TimestampValidator
|
||||
{
|
||||
public function __construct(
|
||||
private \DateTimeZone $timezone
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function validate(\DateTimeInterface $timestamp): bool
|
||||
{
|
||||
$allowedSkew = $this->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');
|
||||
}
|
||||
}
|
||||
43
src/Infra/Casts/ClientIdentifierCast.php
Normal file
43
src/Infra/Casts/ClientIdentifierCast.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Casts;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @implements CastsAttributes<ClientIdentifier, string|ClientIdentifier> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
43
src/Infra/Casts/ClientNameCast.php
Normal file
43
src/Infra/Casts/ClientNameCast.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Casts;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientName;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @implements CastsAttributes<ClientName, string|ClientName> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
57
src/Infra/Casts/ClientSecretCast.php
Normal file
57
src/Infra/Casts/ClientSecretCast.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Casts;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/** @implements CastsAttributes<ClientSecret, string|ClientSecret> */
|
||||
final class ClientSecretCast implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
47
src/Infra/Casts/UuidCast.php
Normal file
47
src/Infra/Casts/UuidCast.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
use Shared\Helpers\UuidHelper;
|
||||
|
||||
/** @implements CastsAttributes<\Ramsey\Uuid\UuidInterface, string> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
78
src/Infra/Database/Models/ApiClient.php
Normal file
78
src/Infra/Database/Models/ApiClient.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Database\Models;
|
||||
|
||||
use Database\Factories\ApiClientFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Infra\Casts\ClientIdentifierCast;
|
||||
use Infra\Casts\ClientNameCast;
|
||||
use Infra\Casts\ClientSecretCast;
|
||||
use Infra\Casts\UuidCast;
|
||||
|
||||
/**
|
||||
* @property \Ramsey\Uuid\UuidInterface $id
|
||||
* @property \Application\Security\Models\ValueObjects\ClientName $name
|
||||
* @property \Application\Security\Models\ValueObjects\ClientIdentifier $identifier
|
||||
* @property \Application\Security\Models\ValueObjects\ClientSecret $secret
|
||||
* @property bool $is_active
|
||||
* @property \Illuminate\Support\Carbon $created_at
|
||||
* @property \Illuminate\Support\Carbon $updated_at
|
||||
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
|
||||
*
|
||||
* @method static \Database\Factories\ApiClientFactory factory($count = null, $state = [])
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
final class ApiClient extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<ApiClientFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
use Notifiable;
|
||||
|
||||
protected static function newFactory(): ApiClientFactory
|
||||
{
|
||||
return ApiClientFactory::new();
|
||||
}
|
||||
|
||||
protected $table = 'api_clients';
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'name',
|
||||
'identifier',
|
||||
'secret',
|
||||
'is_active',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'secret',
|
||||
];
|
||||
|
||||
/** @return array<string, string> */
|
||||
#[\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',
|
||||
];
|
||||
}
|
||||
}
|
||||
62
src/Infra/Providers/SecurityServiceProvider.php
Normal file
62
src/Infra/Providers/SecurityServiceProvider.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Providers;
|
||||
|
||||
use Application\Security\Ports\Repositories\ClientRepository;
|
||||
use Application\Security\Ports\Services\ClientCredentialGenerator;
|
||||
use Application\Security\Ports\Services\HmacSignatureValidator;
|
||||
use Application\Security\Ports\Services\NonceValidator;
|
||||
use Application\Security\Ports\Services\RequestValidator;
|
||||
use Application\Security\Ports\Services\TimestampValidator;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Infra\Adapters\Repositories\ApiClientRepository;
|
||||
use Infra\Adapters\Services\CachedNonceValidator;
|
||||
use Infra\Adapters\Services\ClientCredentialGeneratorImpl;
|
||||
use Infra\Adapters\Services\HmacSignatureValidatorImpl;
|
||||
use Infra\Adapters\Services\RequestValidatorImpl;
|
||||
use Infra\Adapters\Services\TimestampValidatorImpl;
|
||||
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
|
||||
use Presentation\Console\Security\ConsoleUserDetailsProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shared\DDD\Security\UserDetailsProvider;
|
||||
|
||||
final class SecurityServiceProvider extends ServiceProvider
|
||||
{
|
||||
#[\Override]
|
||||
public function register(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Input, \Shared\DDD\Usecase\Output> */
|
||||
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<Input, \Shared\DDD\Usecase\Output> $usecase
|
||||
* @param Presenter<\Shared\DDD\Usecase\Output, TView> $presenter
|
||||
* @param Usecase<TInput, TOutput> $usecase
|
||||
* @param TInput $input
|
||||
* @param Presenter<TOutput, TView> $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
tests/Integration/Database/Seeders/ApiClientSeederTest.php
Normal file
31
tests/Integration/Database/Seeders/ApiClientSeederTest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration\Database\Seeders;
|
||||
|
||||
use Database\Seeders\ApiClientSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Infra\Database\Models\ApiClient;
|
||||
use Tests\TestCase;
|
||||
|
||||
covers(ApiClient::class);
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
describe('ApiClientSeeder Integration Test', function (): void {
|
||||
it('seeds default api client records successfully', function (): void {
|
||||
// GIVEN
|
||||
$seeder = new ApiClientSeeder();
|
||||
|
||||
// WHEN
|
||||
$seeder->run();
|
||||
|
||||
// THEN
|
||||
$client = ApiClient::query()
|
||||
->where('name', 'Arcadia Dev Test Client')
|
||||
->first();
|
||||
expect($client)->not->toBeNull()
|
||||
->and($client?->is_active)->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration\Infra\Adapters\Repositories;
|
||||
|
||||
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 DateTimeImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Infra\Adapters\Repositories\ApiClientRepository;
|
||||
use Infra\Database\Models\ApiClient;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Shared\Pagination;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
covers(ApiClientRepository::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
ApiClient::query()->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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\Request\Nonce;
|
||||
use Infra\Adapters\Services\CachedNonceValidator;
|
||||
use Mockery;
|
||||
use Psr\Log\NullLogger;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
covers(CachedNonceValidator::class);
|
||||
|
||||
describe('CachedNonceValidator Unit Tests', function (): void {
|
||||
it('validates and consumes a new 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(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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Infra\Adapters\Services\ClientCredentialGeneratorImpl;
|
||||
|
||||
covers(ClientCredentialGeneratorImpl::class);
|
||||
|
||||
describe('ClientCredentialGeneratorImpl Unit Tests', function (): void {
|
||||
it('generates a valid client identifier', function (): void {
|
||||
// GIVEN
|
||||
$generator = new ClientCredentialGeneratorImpl();
|
||||
|
||||
// WHEN
|
||||
$identifier = $generator->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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Services;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Request\HttpMethod;
|
||||
use Application\Security\Models\ValueObjects\Request\Nonce;
|
||||
use Application\Security\Models\ValueObjects\Request\Request;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestSignature;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Infra\Adapters\Services\HmacSignatureValidatorImpl;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
covers(HmacSignatureValidatorImpl::class);
|
||||
|
||||
describe('HmacSignatureValidatorImpl Unit Tests', function (): void {
|
||||
it('validates a matching HMAC signature', function (): void {
|
||||
// GIVEN
|
||||
$validator = new HmacSignatureValidatorImpl();
|
||||
$secret = new ClientSecret(str_repeat('s', 98));
|
||||
$identifier = new ClientIdentifier(str_repeat('c', 43));
|
||||
$timestamp = new DateTimeImmutable('2026-08-17T12:00:00+00:00');
|
||||
$nonce = new Nonce(Uuid::uuid7());
|
||||
$uri = new Uri('http://localhost/api/v1/test?b=2&a=1');
|
||||
|
||||
$expectedPayload = implode('|', [
|
||||
'GET',
|
||||
'/api/v1/test?a=1&b=2',
|
||||
$timestamp->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();
|
||||
});
|
||||
});
|
||||
199
tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php
Normal file
199
tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Services;
|
||||
|
||||
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 Application\Security\Models\ValueObjects\Request\HttpMethod;
|
||||
use Application\Security\Models\ValueObjects\Request\Nonce;
|
||||
use Application\Security\Models\ValueObjects\Request\Request;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestSignature;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
|
||||
use Application\Security\Ports\Repositories\ClientRepository;
|
||||
use Application\Security\Ports\Services\HmacSignatureValidator;
|
||||
use Application\Security\Ports\Services\NonceValidator;
|
||||
use Application\Security\Ports\Services\RequestValidation\RequestValidationStatus;
|
||||
use Application\Security\Ports\Services\TimestampValidator;
|
||||
use DateTimeImmutable;
|
||||
use Infra\Adapters\Services\RequestValidatorImpl;
|
||||
use Mockery;
|
||||
use Psr\Log\NullLogger;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
covers(RequestValidatorImpl::class);
|
||||
|
||||
describe('RequestValidatorImpl Unit Tests', function (): void {
|
||||
it('returns valid result when all security checks pass', 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('Test Client'),
|
||||
identifier: $clientIdentifier,
|
||||
secret: $clientSecret,
|
||||
isActive: true,
|
||||
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);
|
||||
|
||||
$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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Infra\Adapters\Services\TimestampValidatorImpl;
|
||||
|
||||
covers(TimestampValidatorImpl::class);
|
||||
|
||||
describe('TimestampValidatorImpl Unit Tests', function (): void {
|
||||
it('accepts timestamps within 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)->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',
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue