Passed
Push — master ( 38d4f3...30ab2e )
by Adrien
14:46
created

SignedQueryMiddleware::verifyHash()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

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