ldc-arcadia-api/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php

315 lines
11 KiB
PHP

<?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']);
}
}