315 lines
11 KiB
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']);
|
|
}
|
|
}
|