Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
41 / 41
94.87% covered (success)
94.87%
37 / 39
60.87% covered (warning)
60.87%
14 / 23
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Csrf
100.00% covered (success)
100.00%
41 / 41
94.87% covered (success)
94.87%
37 / 39
60.87% covered (warning)
60.87%
14 / 23
100.00% covered (success)
100.00%
10 / 10
47.42
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 token
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 refresh
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 remove
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verify
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
8 / 8
n/a
0 / 0
100.00% covered (success)
100.00%
1 / 1
5
 set
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 savedToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 initStorage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 tokens
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
8 / 8
60.00% covered (warning)
60.00%
3 / 5
100.00% covered (success)
100.00%
1 / 1
5.02
 serverHeader
100.00% covered (success)
100.00%
4 / 4
77.78% covered (warning)
77.78%
7 / 9
25.00% covered (danger)
25.00%
2 / 8
100.00% covered (success)
100.00%
1 / 1
3.69
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Session;
6
7/** @api */
8class Csrf
9{
10    /**
11     * @param non-empty-string $key
12     * @param non-empty-string $field
13     * @param non-empty-string $header
14     */
15    public function __construct(
16        private readonly Session $session,
17        private readonly string $key = 'celemas_csrf_tokens',
18        private readonly string $field = '_token',
19        private readonly string $header = 'X-CSRF-Token',
20    ) {
21        $this->initStorage();
22    }
23
24    public function token(string $page = 'default'): string
25    {
26        $tokens = $this->tokens();
27        $token = $tokens[$page] ?? null;
28
29        if (is_string($token)) {
30            return $token;
31        }
32
33        return $this->set($page);
34    }
35
36    public function refresh(string $page = 'default'): string
37    {
38        return $this->set($page);
39    }
40
41    public function remove(string $page = 'default'): void
42    {
43        $tokens = $this->tokens();
44        unset($tokens[$page]);
45        $this->session->set($this->key, $tokens);
46    }
47
48    public function verify(
49        string $page = 'default',
50        #[\SensitiveParameter]
51        ?string $token = null,
52    ): bool {
53        $token ??= $_POST[$this->field] ?? $_SERVER[$this->serverHeader()] ?? null;
54
55        if (!is_string($token)) {
56            return false;
57        }
58
59        if (hash_equals('', $token) || hash_equals('0', $token)) {
60            return false;
61        }
62
63        $savedToken = $this->savedToken($page);
64
65        if ($savedToken === null) {
66            return false;
67        }
68
69        return hash_equals($savedToken, $token);
70    }
71
72    protected function set(string $page = 'default'): string
73    {
74        $tokens = $this->tokens();
75        $token = base64_encode(random_bytes(32));
76        $tokens[$page] = $token;
77        $this->session->set($this->key, $tokens);
78
79        return $token;
80    }
81
82    private function savedToken(string $page): ?string
83    {
84        $tokens = $this->tokens();
85        $token = $tokens[$page] ?? null;
86
87        return is_string($token) ? $token : null;
88    }
89
90    private function initStorage(): void
91    {
92        if (!$this->session->has($this->key)) {
93            $this->session->set($this->key, []);
94        }
95    }
96
97    /** @return array<array-key, string> */
98    private function tokens(): array
99    {
100        /** @psalm-suppress MixedAssignment */
101        $tokens = $this->session->get($this->key, []);
102
103        if (!is_array($tokens)) {
104            return [];
105        }
106
107        $valid = [];
108
109        /** @psalm-suppress MixedAssignment */
110        foreach ($tokens as $page => $token) {
111            if (is_string($token)) {
112                $valid[$page] = $token;
113            }
114        }
115
116        return $valid;
117    }
118
119    /** @return non-empty-string */
120    private function serverHeader(): string
121    {
122        $header = strtoupper(strtr($this->header, '-', '_'));
123
124        if (str_starts_with($header, 'HTTP_')) {
125            return $header;
126        }
127
128        return 'HTTP_' . $header;
129    }
130}