feat(presentation): implement HMAC authentication middleware, security providers and feature tests
This commit is contained in:
parent
e7904682b6
commit
726375c00d
14 changed files with 888 additions and 12 deletions
|
|
@ -6,8 +6,13 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||
use Presentation\Api\V1\Http\Middlewares\EnsureApiV1AcceptsJson;
|
||||
use Presentation\Api\V1\Http\Middlewares\SetApiV1Etag;
|
||||
use Presentation\Api\V1\Http\Middlewares\TransformApiV1Response;
|
||||
use Presentation\Api\V1\Http\Middlewares\VerifyHmacSignature;
|
||||
|
||||
return function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'auth.hmac' => VerifyHmacSignature::class,
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
'throttle:web',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -35,16 +35,18 @@ Route::get('/openapi', function () {
|
|||
);
|
||||
})->name('openapi');
|
||||
|
||||
// * EMPLOYEES
|
||||
Route::prefix('employees')->group(function (): void {
|
||||
Route::get('/', EmployeesIndexController::class)->name('employees.index');
|
||||
Route::get('/{employeeId}', EmployeeShowController::class)->name('employees.show');
|
||||
});
|
||||
Route::middleware('auth.hmac')->group(function (): void {
|
||||
// * EMPLOYEES
|
||||
Route::prefix('employees')->group(function (): void {
|
||||
Route::get('/', EmployeesIndexController::class)->name('employees.index');
|
||||
Route::get('/{employeeId}', EmployeeShowController::class)->name('employees.show');
|
||||
});
|
||||
|
||||
// * INCO SHEETS
|
||||
Route::prefix('inco-sheets')->group(function (): void {
|
||||
Route::get('/', IncoSheetsIndexController::class)->name('inco-sheets.index');
|
||||
Route::get('/search', IncoSheetsSearchController::class)->name('inco-sheets.search');
|
||||
Route::get('/{incoId}', IncoSheetsShowController::class)->name('inco-sheets.show');
|
||||
Route::get('/{incoId}/composition', IncoCompositionShowController::class)->name('inco-sheets.composition');
|
||||
// * INCO SHEETS
|
||||
Route::prefix('inco-sheets')->group(function (): void {
|
||||
Route::get('/', IncoSheetsIndexController::class)->name('inco-sheets.index');
|
||||
Route::get('/search', IncoSheetsSearchController::class)->name('inco-sheets.search');
|
||||
Route::get('/{incoId}', IncoSheetsShowController::class)->name('inco-sheets.show');
|
||||
Route::get('/{incoId}/composition', IncoCompositionShowController::class)->name('inco-sheets.composition');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
11
src/Presentation/Api/Exceptions/AuthorizationException.php
Normal file
11
src/Presentation/Api/Exceptions/AuthorizationException.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Api\Exceptions;
|
||||
|
||||
use Shared\DDD\Exception\SecurityException;
|
||||
|
||||
final class AuthorizationException extends SecurityException
|
||||
{
|
||||
}
|
||||
53
src/Presentation/Api/V1/Dto/ApiClientUserDetails.php
Normal file
53
src/Presentation/Api/V1/Dto/ApiClientUserDetails.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Api\V1\Dto;
|
||||
|
||||
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 Presentation\Shared\Models\ValueObjects\ClientType;
|
||||
use Shared\DDD\Security\UserDetails;
|
||||
use Shared\DDD\Security\UserType;
|
||||
|
||||
final readonly class ApiClientUserDetails implements UserDetails
|
||||
{
|
||||
public function __construct(
|
||||
private ClientId $clientId,
|
||||
private ClientIdentifier $clientIdentifier,
|
||||
private ClientName $clientName,
|
||||
private ClientSecret $clientSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function id(): string
|
||||
{
|
||||
return $this->clientId->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function name(): string
|
||||
{
|
||||
return $this->clientName->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function type(): UserType
|
||||
{
|
||||
return ClientType::API;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function identifier(): string
|
||||
{
|
||||
return $this->clientIdentifier->value;
|
||||
}
|
||||
|
||||
public function secret(): ClientSecret
|
||||
{
|
||||
return $this->clientSecret;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
final class EnsureApiV1AcceptsJson
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// * Bypass for OpenAPI YAML spec route
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
final class SetApiV1Etag
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
/** @var Response $response */
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
final class TransformApiV1Response
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
|
@ -19,7 +22,6 @@ final class TransformApiV1Response
|
|||
return $response->present();
|
||||
}
|
||||
|
||||
/** @var Response $response */
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
315
src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php
Normal file
315
src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Api\V1\Http\Middlewares;
|
||||
|
||||
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\HttpBodyHash;
|
||||
use Application\Security\Models\ValueObjects\Request\HttpMethod;
|
||||
use Application\Security\Models\ValueObjects\Request\Nonce;
|
||||
use Application\Security\Models\ValueObjects\Request\Request as SecurityRequestVo;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestSignature;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
|
||||
use Application\Security\Ports\Services\RequestValidator;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Presentation\Api\V1\Contracts\ApiResponse;
|
||||
use Presentation\Api\V1\Dto\ApiClientUserDetails;
|
||||
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
final readonly class VerifyHmacSignature
|
||||
{
|
||||
private const string CLIENT_IDENTIFIER_HEADER = 'X-Client-Identifier';
|
||||
|
||||
private const string SIGNATURE_HEADER = 'X-Signature';
|
||||
|
||||
private const string TIMESTAMP_HEADER = 'X-Timestamp';
|
||||
|
||||
private const string NONCE_HEADER = 'X-Nonce';
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private RequestValidator $requestValidator,
|
||||
private ApiUserDetailsProvider $userDetailsProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($this->shouldBypassHmac()) {
|
||||
return $this->handleBypassedRequest($request, $next);
|
||||
}
|
||||
|
||||
$headersOrError = $this->extractAndValidateHeaders($request);
|
||||
|
||||
if ($headersOrError instanceof Response) {
|
||||
return $headersOrError;
|
||||
}
|
||||
|
||||
$securityRequest = $this->buildSecurityRequest($request, $headersOrError);
|
||||
$validationResult = $this->requestValidator->validate($securityRequest);
|
||||
|
||||
if (! $validationResult->isValid()) {
|
||||
$this->logger->warning('HMAC authentication failed', [
|
||||
'reason' => $validationResult->status->name,
|
||||
'uri' => $request->getUri(),
|
||||
'method' => $request->getMethod(),
|
||||
]);
|
||||
|
||||
return $this->generateUnauthorizedResponse($request, 'Authentication failed.');
|
||||
}
|
||||
|
||||
$client = $validationResult->client;
|
||||
if ($client === null) {
|
||||
throw new \LogicException('Client cannot be null for a valid HMAC validation result.');
|
||||
}
|
||||
|
||||
$userDetails = new ApiClientUserDetails(
|
||||
clientId: $client->id,
|
||||
clientIdentifier: $client->identifier,
|
||||
clientName: $client->name,
|
||||
clientSecret: $client->secret,
|
||||
);
|
||||
|
||||
$this->userDetailsProvider->setCurrentUser($userDetails);
|
||||
$request->attributes->set('user_details', $userDetails);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ($response instanceof ApiResponse) {
|
||||
return $response->present();
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function shouldBypassHmac(): bool
|
||||
{
|
||||
if (! App::environment('local', 'testing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Config::boolean('auth.hmac.allow_dev_bypass', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
private function handleBypassedRequest(Request $request, Closure $next): Response
|
||||
{
|
||||
$this->logger->debug('HMAC verification bypassed in local/testing environment.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
]);
|
||||
|
||||
/** @var array{id: string, name: string, identifier: string, secret: string} $devClientConfig */
|
||||
$devClientConfig = Config::array('auth.hmac.dev_client');
|
||||
|
||||
$devUserDetails = new ApiClientUserDetails(
|
||||
clientId: new ClientId(Uuid::fromString($devClientConfig['id'])),
|
||||
clientIdentifier: new ClientIdentifier($devClientConfig['identifier']),
|
||||
clientName: new ClientName($devClientConfig['name']),
|
||||
clientSecret: new ClientSecret($devClientConfig['secret']),
|
||||
);
|
||||
|
||||
$this->userDetailsProvider->setCurrentUser($devUserDetails);
|
||||
$request->attributes->set('user_details', $devUserDetails);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ($response instanceof ApiResponse) {
|
||||
return $response->present();
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{identifier: ClientIdentifier, timestamp: RequestTimestamp, signature: RequestSignature, nonce: Nonce}|Response
|
||||
*/
|
||||
private function extractAndValidateHeaders(Request $request): array | Response
|
||||
{
|
||||
$clientIdentifier = $this->getClientIdentifierFromHeader($request);
|
||||
if ($clientIdentifier === null) {
|
||||
return $this->generateUnauthorizedResponse(
|
||||
$request,
|
||||
'Missing or invalid client identifier in the request headers.'
|
||||
);
|
||||
}
|
||||
|
||||
$timestamp = $this->getTimestampFromHeader($request);
|
||||
if ($timestamp === null) {
|
||||
return $this->generateUnauthorizedResponse(
|
||||
$request,
|
||||
'Missing or invalid timestamp in the request headers.'
|
||||
);
|
||||
}
|
||||
|
||||
$signature = $this->getSignatureFromHeader($request);
|
||||
if ($signature === null) {
|
||||
return $this->generateUnauthorizedResponse(
|
||||
$request,
|
||||
'Missing or invalid HMAC signature in the request headers.'
|
||||
);
|
||||
}
|
||||
|
||||
$nonce = $this->getNonceFromHeader($request);
|
||||
if ($nonce === null) {
|
||||
return $this->generateUnauthorizedResponse(
|
||||
$request,
|
||||
'Missing or invalid nonce in the request headers.'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'identifier' => $clientIdentifier,
|
||||
'timestamp' => $timestamp,
|
||||
'signature' => $signature,
|
||||
'nonce' => $nonce,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{identifier: ClientIdentifier, timestamp: RequestTimestamp, signature: RequestSignature, nonce: Nonce} $headers
|
||||
*/
|
||||
private function buildSecurityRequest(Request $request, array $headers): SecurityRequestVo
|
||||
{
|
||||
$uri = new Uri($request->getUri());
|
||||
$method = HttpMethod::from($request->getMethod());
|
||||
$bodyHash = $request->getContent() !== '' ? hash('sha256', $request->getContent()) : null;
|
||||
|
||||
return new SecurityRequestVo(
|
||||
uri: $uri,
|
||||
method: $method,
|
||||
bodyHash: $bodyHash !== null ? new HttpBodyHash($bodyHash) : null,
|
||||
signature: $headers['signature'],
|
||||
nonce: $headers['nonce'],
|
||||
identifier: $headers['identifier'],
|
||||
timestamp: $headers['timestamp']
|
||||
);
|
||||
}
|
||||
|
||||
private function getClientIdentifierFromHeader(Request $request): ?ClientIdentifier
|
||||
{
|
||||
$clientIdentifierHeader = $request->header(self::CLIENT_IDENTIFIER_HEADER);
|
||||
|
||||
if ($clientIdentifierHeader === null || trim($clientIdentifierHeader) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new ClientIdentifier(trim($clientIdentifierHeader));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->logger->debug('Invalid client identifier in the request headers.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getTimestampFromHeader(Request $request): ?RequestTimestamp
|
||||
{
|
||||
$timestampHeader = $request->header(self::TIMESTAMP_HEADER);
|
||||
|
||||
if ($timestampHeader === null || trim($timestampHeader) === '') {
|
||||
$this->logger->debug('Missing timestamp in the request headers.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$timestampHeader = trim($timestampHeader);
|
||||
$timestamp = new \DateTimeImmutable($timestampHeader);
|
||||
|
||||
return new RequestTimestamp($timestamp);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->debug('Invalid timestamp in the request headers.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getSignatureFromHeader(Request $request): ?RequestSignature
|
||||
{
|
||||
$signatureHeader = $request->header(self::SIGNATURE_HEADER);
|
||||
|
||||
if ($signatureHeader === null || trim($signatureHeader) === '') {
|
||||
$this->logger->debug('Missing HMAC signature in the request headers.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new RequestSignature(trim($signatureHeader));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->logger->debug('Invalid HMAC signature in the request headers.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getNonceFromHeader(Request $request): ?Nonce
|
||||
{
|
||||
$nonceHeader = $request->header(self::NONCE_HEADER);
|
||||
|
||||
if ($nonceHeader === null || trim($nonceHeader) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$nonceHeader = trim($nonceHeader);
|
||||
$validatedNonce = Uuid::fromString($nonceHeader);
|
||||
|
||||
return new Nonce($validatedNonce);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->logger->debug('Invalid nonce in the request headers.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateUnauthorizedResponse(Request $request, string $detail): Response
|
||||
{
|
||||
return response()->json([
|
||||
'type' => $request->schemeAndHttpHost() . '/errors/401',
|
||||
'title' => 'Unauthorized',
|
||||
'status' => 401,
|
||||
'detail' => $detail,
|
||||
'instance' => $request->getPathInfo(),
|
||||
], 401, ['Content-Type' => 'application/problem+json']);
|
||||
}
|
||||
}
|
||||
24
src/Presentation/Api/V1/Security/ApiUserDetailsProvider.php
Normal file
24
src/Presentation/Api/V1/Security/ApiUserDetailsProvider.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Api\V1\Security;
|
||||
|
||||
use Shared\DDD\Security\UserDetails;
|
||||
use Shared\DDD\Security\UserDetailsProvider;
|
||||
|
||||
final class ApiUserDetailsProvider implements UserDetailsProvider
|
||||
{
|
||||
private ?UserDetails $currentUser = null;
|
||||
|
||||
public function setCurrentUser(?UserDetails $user): void
|
||||
{
|
||||
$this->currentUser = $user;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function currentUser(): ?UserDetails
|
||||
{
|
||||
return $this->currentUser;
|
||||
}
|
||||
}
|
||||
42
src/Presentation/Console/Security/ConsoleUserDetails.php
Normal file
42
src/Presentation/Console/Security/ConsoleUserDetails.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Console\Security;
|
||||
|
||||
use Presentation\Shared\Models\ValueObjects\ClientType;
|
||||
use Shared\DDD\Security\UserDetails;
|
||||
use Shared\DDD\Security\UserType;
|
||||
|
||||
final readonly class ConsoleUserDetails implements UserDetails
|
||||
{
|
||||
public function __construct(
|
||||
private string $identifier = 'console',
|
||||
private string $name = 'CLI Console User',
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function id(): string
|
||||
{
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function type(): UserType
|
||||
{
|
||||
return ClientType::CONSOLE;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function identifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Console\Security;
|
||||
|
||||
use Shared\DDD\Security\UserDetails;
|
||||
use Shared\DDD\Security\UserDetailsProvider;
|
||||
|
||||
final readonly class ConsoleUserDetailsProvider implements UserDetailsProvider
|
||||
{
|
||||
public function __construct(
|
||||
private ?UserDetails $user = null,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function currentUser(): UserDetails
|
||||
{
|
||||
return $this->user ?? new ConsoleUserDetails();
|
||||
}
|
||||
}
|
||||
25
src/Presentation/Shared/Models/ValueObjects/ClientType.php
Normal file
25
src/Presentation/Shared/Models/ValueObjects/ClientType.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Shared\Models\ValueObjects;
|
||||
|
||||
use Shared\DDD\Security\UserType;
|
||||
|
||||
enum ClientType: string implements UserType
|
||||
{
|
||||
case API = 'api';
|
||||
case CONSOLE = 'console';
|
||||
|
||||
#[\Override]
|
||||
public function type(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function equals(mixed $other): bool
|
||||
{
|
||||
return $other instanceof ClientType && $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
368
tests/Feature/Api/V1/VerifyHmacSignatureTest.php
Normal file
368
tests/Feature/Api/V1/VerifyHmacSignatureTest.php
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Infra\Database\Models\ApiClient;
|
||||
use Presentation\Api\V1\Dto\ApiClientUserDetails;
|
||||
use Presentation\Api\V1\Http\Middlewares\VerifyHmacSignature;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $body
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function generateHmacHeaders(
|
||||
string $method,
|
||||
string $pathAndQuery,
|
||||
string $clientIdentifier,
|
||||
string $clientSecret,
|
||||
array | string | null $body = null,
|
||||
?DateTimeImmutable $timestamp = null,
|
||||
?string $nonce = null,
|
||||
): array {
|
||||
$timestamp ??= new DateTimeImmutable('now');
|
||||
$nonce ??= Uuid::uuid7()->toString();
|
||||
|
||||
$timestampFormatted = $timestamp->format(DateTimeInterface::ATOM);
|
||||
|
||||
$bodyString = match (true) {
|
||||
is_array($body) => json_encode($body) ?: '[]',
|
||||
is_string($body) => $body,
|
||||
default => '',
|
||||
};
|
||||
|
||||
$bodyHash = $bodyString !== ''
|
||||
? hash('sha256', $bodyString)
|
||||
: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
// Parse and sort query parameters
|
||||
$parsed = parse_url($pathAndQuery);
|
||||
$path = $parsed['path'] ?? '/';
|
||||
$query = $parsed['query'] ?? null;
|
||||
|
||||
if ($query !== null && $query !== '') {
|
||||
parse_str($query, $queryParams);
|
||||
ksort($queryParams);
|
||||
$sortedQuery = http_build_query($queryParams);
|
||||
$path = $path . '?' . $sortedQuery;
|
||||
}
|
||||
|
||||
$payload = implode('|', [
|
||||
strtoupper($method),
|
||||
$path,
|
||||
$timestampFormatted,
|
||||
$nonce,
|
||||
$bodyHash,
|
||||
]);
|
||||
|
||||
$signature = hash_hmac('sha256', $payload, $clientSecret);
|
||||
|
||||
return [
|
||||
'X-Client-Identifier' => $clientIdentifier,
|
||||
'X-Timestamp' => $timestampFormatted,
|
||||
'X-Nonce' => $nonce,
|
||||
'X-Signature' => $signature,
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
Config::set('auth.hmac.allow_dev_bypass', false);
|
||||
|
||||
Route::middleware(VerifyHmacSignature::class)->get('/api/v1/test-hmac-auth', function () {
|
||||
/** @var ApiClientUserDetails|null $user */
|
||||
$user = request()->attributes->get('user_details');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'ok',
|
||||
'client' => $user?->name(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
|
||||
it('rejects request with missing client identifier', function (): void {
|
||||
// GIVEN / WHEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
||||
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
|
||||
'X-Nonce' => Uuid::uuid7()->toString(),
|
||||
'X-Signature' => str_repeat('a', 64),
|
||||
]);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Missing or invalid client identifier in the request headers.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects request with missing timestamp', function (): void {
|
||||
// GIVEN / WHEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
||||
'X-Client-Identifier' => str_repeat('c', 43),
|
||||
'X-Nonce' => Uuid::uuid7()->toString(),
|
||||
'X-Signature' => str_repeat('a', 64),
|
||||
]);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Missing or invalid timestamp in the request headers.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects request with missing signature', function (): void {
|
||||
// GIVEN / WHEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
||||
'X-Client-Identifier' => str_repeat('c', 43),
|
||||
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
|
||||
'X-Nonce' => Uuid::uuid7()->toString(),
|
||||
]);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Missing or invalid HMAC signature in the request headers.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects request with missing nonce', function (): void {
|
||||
// GIVEN / WHEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
||||
'X-Client-Identifier' => str_repeat('c', 43),
|
||||
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
|
||||
'X-Signature' => str_repeat('a', 64),
|
||||
]);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Missing or invalid nonce in the request headers.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects request when client does not exist in database', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$headers = generateHmacHeaders(
|
||||
method: 'GET',
|
||||
pathAndQuery: '/api/v1/test-hmac-auth',
|
||||
clientIdentifier: str_repeat('c', 43),
|
||||
clientSecret: str_repeat('s', 98),
|
||||
body: []
|
||||
);
|
||||
|
||||
// WHEN
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Authentication failed.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects request when client is inactive or suspended', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
||||
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
||||
|
||||
ApiClient::query()->create([
|
||||
'id' => Uuid::uuid7()->toString(),
|
||||
'name' => 'Inactive Client',
|
||||
'identifier' => $identifier,
|
||||
'secret' => $secret,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$headers = generateHmacHeaders(
|
||||
method: 'GET',
|
||||
pathAndQuery: '/api/v1/test-hmac-auth',
|
||||
clientIdentifier: $identifier,
|
||||
clientSecret: $secret,
|
||||
body: []
|
||||
);
|
||||
|
||||
// WHEN
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Authentication failed.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects request when timestamp is outside skew window', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
||||
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
||||
|
||||
ApiClient::query()->create([
|
||||
'id' => Uuid::uuid7()->toString(),
|
||||
'name' => 'Active Client',
|
||||
'identifier' => $identifier,
|
||||
'secret' => $secret,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$oldTimestamp = new DateTimeImmutable('-10 minutes');
|
||||
$headers = generateHmacHeaders(
|
||||
method: 'GET',
|
||||
pathAndQuery: '/api/v1/test-hmac-auth',
|
||||
clientIdentifier: $identifier,
|
||||
clientSecret: $secret,
|
||||
body: [],
|
||||
timestamp: $oldTimestamp
|
||||
);
|
||||
|
||||
// WHEN
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Authentication failed.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects request when HMAC signature does not match', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
||||
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
||||
|
||||
ApiClient::query()->create([
|
||||
'id' => Uuid::uuid7()->toString(),
|
||||
'name' => 'Active Client',
|
||||
'identifier' => $identifier,
|
||||
'secret' => $secret,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$headers = generateHmacHeaders(
|
||||
method: 'GET',
|
||||
pathAndQuery: '/api/v1/test-hmac-auth',
|
||||
clientIdentifier: $identifier,
|
||||
clientSecret: $secret,
|
||||
body: []
|
||||
);
|
||||
$headers['X-Signature'] = hash('sha256', 'wrong_signature_payload');
|
||||
|
||||
// WHEN
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Authentication failed.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts request and authenticates client with valid HMAC credentials', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
||||
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
||||
|
||||
ApiClient::query()->create([
|
||||
'id' => Uuid::uuid7()->toString(),
|
||||
'name' => 'Valid API Client',
|
||||
'identifier' => $identifier,
|
||||
'secret' => $secret,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$headers = generateHmacHeaders(
|
||||
method: 'GET',
|
||||
pathAndQuery: '/api/v1/test-hmac-auth',
|
||||
clientIdentifier: $identifier,
|
||||
clientSecret: $secret,
|
||||
body: []
|
||||
);
|
||||
|
||||
// WHEN
|
||||
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
||||
|
||||
// THEN
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'message' => 'ok',
|
||||
'client' => 'Valid API Client',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects replayed request with already used nonce', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
||||
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
||||
|
||||
ApiClient::query()->create([
|
||||
'id' => Uuid::uuid7()->toString(),
|
||||
'name' => 'Valid API Client',
|
||||
'identifier' => $identifier,
|
||||
'secret' => $secret,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$headers = generateHmacHeaders(
|
||||
method: 'GET',
|
||||
pathAndQuery: '/api/v1/test-hmac-auth',
|
||||
clientIdentifier: $identifier,
|
||||
clientSecret: $secret,
|
||||
body: []
|
||||
);
|
||||
|
||||
// First request succeeds
|
||||
$firstResponse = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
||||
$firstResponse->assertStatus(200);
|
||||
|
||||
// WHEN (Second identical request with same nonce)
|
||||
$secondResponse = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
||||
|
||||
// THEN
|
||||
$secondResponse->assertStatus(401)
|
||||
->assertHeader('Content-Type', 'application/problem+json')
|
||||
->assertJson([
|
||||
'status' => 401,
|
||||
'title' => 'Unauthorized',
|
||||
'detail' => 'Authentication failed.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@ BypassFinals::denyPaths([
|
|||
'*/Dto/*',
|
||||
'*/Models/*',
|
||||
'*/Shared/DDD/*',
|
||||
'*/ValueObjects/*',
|
||||
]);
|
||||
|
||||
pest()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue