Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
39 / 39
94.44% covered (success)
94.44%
34 / 36
59.09% covered (warning)
59.09%
13 / 22
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Csrf
100.00% covered (success)
100.00%
39 / 39
94.44% covered (success)
94.44%
34 / 36
59.09% covered (warning)
59.09%
13 / 22
100.00% covered (success)
100.00%
10 / 10
47.39
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
 clear
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
 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
 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 Celema\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 = 'celema_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 clear(): void
49    {
50        $this->session->set($this->key, []);
51    }
52
53    public function verify(
54        string $page = 'default',
55        #[\SensitiveParameter]
56        ?string $token = null,
57    ): bool {
58        $token ??= $_POST[$this->field] ?? $_SERVER[$this->serverHeader()] ?? null;
59
60        if (!is_string($token)) {
61            return false;
62        }
63
64        if (hash_equals('', $token) || hash_equals('0', $token)) {
65            return false;
66        }
67
68        $savedToken = $this->tokens()[$page] ?? null;
69
70        if ($savedToken === null) {
71            return false;
72        }
73
74        return hash_equals($savedToken, $token);
75    }
76
77    protected function set(string $page = 'default'): string
78    {
79        $tokens = $this->tokens();
80        $token = base64_encode(random_bytes(32));
81        $tokens[$page] = $token;
82        $this->session->set($this->key, $tokens);
83
84        return $token;
85    }
86
87    private function initStorage(): void
88    {
89        if (!$this->session->has($this->key)) {
90            $this->session->set($this->key, []);
91        }
92    }
93
94    /** @return array<array-key, string> */
95    private function tokens(): array
96    {
97        /** @psalm-suppress MixedAssignment */
98        $tokens = $this->session->get($this->key, []);
99
100        if (!is_array($tokens)) {
101            return [];
102        }
103
104        $valid = [];
105
106        /** @psalm-suppress MixedAssignment */
107        foreach ($tokens as $page => $token) {
108            if (is_string($token)) {
109                $valid[$page] = $token;
110            }
111        }
112
113        return $valid;
114    }
115
116    /** @return non-empty-string */
117    private function serverHeader(): string
118    {
119        $header = strtoupper(strtr($this->header, '-', '_'));
120
121        if (str_starts_with($header, 'HTTP_')) {
122            return $header;
123        }
124
125        return 'HTTP_' . $header;
126    }
127}