Passed
Push — master ( b978c0...c5c7e6 )
by Adrien
02:35
created

SignedQueryMiddleware::verifyTimestamp()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 8
ccs 7
cts 7
cp 1
rs 10
cc 3
nc 2
nop 1
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Ecodev\Felix\Middleware;
6
7
use Cake\Chronos\Chronos;
8
use Ecodev\Felix\Validator\IPRange;
9
use Exception;
10
use Psr\Http\Message\ResponseInterface;
11
use Psr\Http\Message\ServerRequestInterface;
12
use Psr\Http\Server\MiddlewareInterface;
13
use Psr\Http\Server\RequestHandlerInterface;
14
15
/**
16
 * Validate that the GraphQL query contains a valid signature in the `X-Signature` HTTP header.
17
 *
18
 * The signature payload is the GraphQL operation (or operations in case of batching). That means that the query itself
19
 * and the variables are signed. But it specifically does **not** include uploaded files.
20
 *
21
 * The signature is valid for a limited time only, ~15 minutes.
22
 *
23
 * The signature syntax is:
24
 *
25
 * ```ebnf
26
 * signature = "v1", ".", timestamp, ".", hash
27
 * timestamp = current unix time
28
 * hash = HMAC_SHA256( payload )
29
 * payload = timestamp, graphql operations
30
 * ```
31
 */
32
final class SignedQueryMiddleware implements MiddlewareInterface
33
{
34 31
    public function __construct(
35
        private readonly array $keys,
36
        private readonly array $allowedIps,
37
        private readonly bool $required = true
38
    ) {
39 31
        if ($this->required && !$this->keys) {
40 1
            throw new Exception('Signed queries are required, but no keys are configured');
41
        }
42
    }
43
44 30
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
45
    {
46 30
        if ($this->required) {
47 15
            $this->verify($request);
48
        }
49
50 23
        return $handler->handle($request);
51
    }
52
53 15
    private function verify(ServerRequestInterface $request): void
54
    {
55 15
        $signature = $request->getHeader('X-Signature')[0] ?? '';
56 15
        if (!$signature) {
57 2
            if ($this->isAllowedIp($request)) {
58 1
                return;
59
            }
60
61 1
            throw new Exception('Missing `X-Signature` HTTP header in signed query', 403);
62
        }
63
64 13
        if (preg_match('~^v1\.(?<timestamp>\d{10})\.(?<hash>[0-9a-f]{64})$~', $signature, $m)) {
65 12
            $timestamp = $m['timestamp'];
66 12
            $hash = $m['hash'];
67
68 12
            $this->verifyTimestamp($timestamp);
69 10
            $this->verifyHash($request, $timestamp, $hash);
70
        } else {
71 1
            throw new Exception('Invalid `X-Signature` HTTP header in signed query', 403);
72
        }
73
    }
74
75 12
    private function verifyTimestamp(string $timestamp): void
76
    {
77 12
        $now = Chronos::now()->timestamp;
78 12
        $leeway = 15 * 900; // 15 minutes
79 12
        $past = $now - $leeway;
80 12
        $future = $now + $leeway;
81 12
        if ($timestamp < $past || $timestamp > $future) {
82 2
            throw new Exception('Signed query is expired', 403);
83
        }
84
    }
85
86 10
    private function verifyHash(ServerRequestInterface $request, string $timestamp, string $hash): void
87
    {
88 10
        $operations = $this->getOperations($request);
89 9
        $payload = $timestamp . $operations;
90
91 9
        foreach ($this->keys as $key) {
92 9
            $computedHash = hash_hmac('sha256', $payload, $key);
93 9
            if ($hash === $computedHash) {
94 7
                return;
95
            }
96
        }
97
98 2
        throw new Exception('Invalid signed query', 403);
99
    }
100
101 10
    private function getOperations(ServerRequestInterface $request): mixed
102
    {
103 10
        $contents = $request->getBody()->getContents();
104 10
        if ($contents) {
105 7
            return $contents;
106
        }
107
108 3
        $parsedBody = $request->getParsedBody();
109 3
        if (is_array($parsedBody)) {
110 2
            $operations = $parsedBody['operations'] ?? null;
111 2
            if ($operations) {
112 2
                return $operations;
113
            }
114
        }
115
116 1
        throw new Exception('Could not find GraphQL operations in request', 403);
117
    }
118
119 2
    private function isAllowedIp(ServerRequestInterface $request): bool
120
    {
121 2
        $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
122
123 2
        if (!$remoteAddress || !is_string($remoteAddress)) {
124 1
            return false;
125
        }
126
127 1
        return IPRange::matches($remoteAddress, $this->allowedIps);
128
    }
129
}
130