Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
ApiSignatureMiddleware
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
5 / 5
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 validateRequest
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 unauthorized
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 hasValidHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Shared\Security;
6
7use App\Domain\Api\Repository\ApiRecordRepository;
8use Psr\Http\Message\ResponseFactoryInterface;
9use Psr\Http\Message\ResponseInterface;
10use Psr\Http\Message\ServerRequestInterface;
11use Psr\Http\Server\MiddlewareInterface;
12use Psr\Http\Server\RequestHandlerInterface;
13
14 class ApiSignatureMiddleware implements MiddlewareInterface
15{
16    public function __construct(
17        private ApiRecordRepository $apiRecordRepository,
18        private ResponseFactoryInterface $responseFactory
19    ) {}
20
21    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
22    {
23        if (!str_starts_with($request->getUri()->getPath(), '/api/v1/')) {
24            return $handler->handle($request);
25        }
26
27        $apiKey = $request->getHeaderLine('X-Api-Key');
28        $timestamp = $request->getHeaderLine('X-Api-Timestamp');
29        $signature = $request->getHeaderLine('X-Api-Signature');
30        $apiRecord = $this->apiRecordRepository->findByHostId($apiKey);
31
32        $errorMsg = $this->validateRequest($request, $apiKey, $timestamp, $signature, $apiRecord);
33
34        if ($errorMsg !== null) {
35            return $this->unauthorized($errorMsg);
36        }
37
38        return $handler->handle($request);
39    }
40
41    private function validateRequest(
42        ServerRequestInterface $request,
43        string $apiKey,
44        string $timestamp,
45        string $signature,
46        ?\App\Domain\Api\Entity\ApiRecord $apiRecord
47    ): ?string {
48        $errorMsg = match (true) {
49            !$this->hasValidHeaders($apiKey, $timestamp, $signature) => 'Missing API authentication headers',
50            abs(time() - (int) $timestamp) > 300 => 'Request timestamp out of bounds',
51            $apiRecord === null || $apiRecord->getStatus() !== 'active' => 'Invalid or inactive API Key',
52            default => null,
53        };
54
55        if ($errorMsg !== null) {
56            return $errorMsg;
57        }
58
59        assert($apiRecord !== null);
60
61        $bodyStream = $request->getBody();
62        $body = (string) $bodyStream;
63        if ($bodyStream->isSeekable()) {
64            $bodyStream->rewind();
65        }
66
67        $payload = $timestamp . $request->getMethod() . $request->getUri()->getPath() . $body;
68        $expectedSignature = hash_hmac('sha256', $payload, $apiRecord->getApiKey());
69
70        return hash_equals($expectedSignature, $signature) ? null : 'Invalid API Signature';
71    }
72
73    private function unauthorized(string $message): ResponseInterface
74    {
75        $response = $this->responseFactory->createResponse(401)
76            ->withHeader('Content-Type', 'application/json');
77        $content = json_encode([
78            'error' => 'Unauthorized',
79            'message' => $message,
80        ]);
81        $response->getBody()->write(is_string($content) ? $content : '{"error":"Unauthorized"}');
82        return $response;
83    }
84
85    private function hasValidHeaders(string $apiKey, string $timestamp, string $signature): bool
86    {
87        return $apiKey !== '' && $timestamp !== '' && $signature !== '';
88    }
89}