Passed
Push — master ( 493a15...e3b159 )
by Sebastian
03:38
created

CsrfGuard::getToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 16
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 5
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 16
ccs 6
cts 6
cp 1
crap 1
rs 10
1
<?php
2
3
/**
4
 * Linna Cross-site Request Forgery Guard
5
 *
6
 * @author Sebastian Rapetti <[email protected]>
7
 * @copyright (c) 2020, Sebastian Rapetti
8
 * @license http://opensource.org/licenses/MIT MIT License
9
 */
10
declare(strict_types=1);
11
12
namespace Linna;
13
14
use RuntimeException;
15
use InvalidArgumentException;
16
17
/**
18
 * Cross-site Request Forgery Guard
19
 */
20
class CsrfGuard
21
{
22
    /**
23
     * @var string Token storage key name in session array.
24
     */
25
    public const TOKEN_STORAGE = 'csrf-tokens';
26
27
    /**
28
     * @var array<mixed> Reference to php session data from superglobal.
29
     */
30
    private array $storage;
31
32
    /**
33
     * @var int Max number of tokens stored in session.
34
     */
35
    private int $maxStorage;
36
37
    /**
38
     * @var int Rapresent the lenght of the token in bytes.
39
     */
40
    private int $tokenStrength;
41
42
    /**
43
     * Class constructor.
44
     *
45
     * @param int $maxStorage    Max number of tokens stored in session, work as
46
     *                           FIFO data structure, when maximun capacity is
47
     *                           reached, oldest token be dequeued from storage.
48
     * @param int $tokenStrength Rapresent the lenght of the token in bytes.
49
     *
50
     * @throws RuntimeException  If instance is created without start session
51
     *                           before and if token strenght parameter is
52
     *                           less than 16.
53
     */
54 60
    public function __construct(int $maxStorage, int $tokenStrength = 16)
55
    {
56
        //check if session is started
57 60
        if (\session_status() === PHP_SESSION_NONE) {
58 1
            throw new RuntimeException('Session must be started before create instance.');
59
        }
60
61
        //check for minimun token strenth
62 59
        if ($tokenStrength < 16) {
63 15
            throw new RuntimeException('The minimum CSRF token strength is 16.');
64
        }
65
66
        //initialize session storage
67 44
        $_SESSION[self::TOKEN_STORAGE] = $_SESSION[self::TOKEN_STORAGE] ?? [];
68
69 44
        $this->storage = &$_SESSION[self::TOKEN_STORAGE];
70 44
        $this->maxStorage = $maxStorage;
71 44
        $this->tokenStrength = $tokenStrength;
72 44
    }
73
74
    /**
75
     * Limit number of token stored in session.
76
     *
77
     * @param array<mixed> $array
78
     */
79 43
    private function dequeue(array &$array): void
80
    {
81 43
        if (\count($array) > $this->maxStorage) {
82 17
            \array_shift($array);
83
        }
84 43
    }
85
86
    /**
87
     * Return csrf token as array.
88
     *
89
     * @return array<mixed>
90
     */
91 40
    public function getToken(): array
92
    {
93
        //generate new token
94 40
        $token = $this->generateToken();
95
        //pick the name of the token
96 40
        $name = $token['name'];
97
        //store the token
98 40
        $this->storage[$name] = $token;
99
100
        //storage cleaning!
101
        //warning!! if you get in a page more token of maximun storage,
102
        //will there a leak of token, the firsts generated
103
        //in future I think throw and exception.
104 40
        $this->dequeue($this->storage);
105
106 40
        return $token;
107
    }
108
109
    /**
110
     * Return timed csrf token as array.
111
     *
112
     * @param int $ttl Time to live for the token, default 600 -> 10 minutes.
113
     *
114
     * @return array<mixed>
115
     */
116 3
    public function getTimedToken(int $ttl = 600): array
117
    {
118
        //generate new token
119 3
        $token = $this->generateToken();
120
        //store token expiration
121
        //add new key to token array
122 3
        $token['time'] = \time() + $ttl;
123
        //pick the name of the token
124 3
        $name = $token['name'];
125
        //store the token
126 3
        $this->storage[$name] = $token;
127
128
        //storage cleaning
129 3
        $this->dequeue($this->storage);
130
131 3
        return $token;
132
    }
133
134
    /**
135
     * Generate a random token.
136
     *
137
     * @return array<mixed>
138
     */
139 43
    private function generateToken(): array
140
    {
141
        //generate a random token name
142 43
        $name = 'csrf_'.\bin2hex(\random_bytes(8));
143
        //generate a random token value
144 43
        $value = \bin2hex(\random_bytes($this->tokenStrength));
145
146 43
        return ['name' => $name, 'value' => $value];
147
    }
148
149
    /**
150
     * Validate a csrf token or a csrf timed token.
151
     *
152
     * @param array<mixed> $requestData From request or from superglobal variables $_POST,
153
     *                                  $_GET, $_REQUEST and $_COOKIE.
154
     *
155
     * @return bool
156
     */
157 20
    public function validate(array $requestData): bool
158
    {
159
        //apply matchToken method elements of passed data,
160
        //using this instead of forach for code shortness.
161 20
        $array = \array_filter($requestData, array($this, 'doChecks'), ARRAY_FILTER_USE_BOTH);
162
163 20
        return (bool) \count($array);
164
    }
165
166
    /**
167
     * Tests for valid token.
168
     *
169
     * @param string $value
170
     * @param string $key
171
     *
172
     * @return bool
173
     */
174 20
    private function doChecks(string $value, string $key): bool
175
    {
176 20
        return $this->tokenIsValid($value, $key) &&
177 20
               $this->tokenIsExpired($key)  &&
178 20
               $this->deleteToken($key);
179
    }
180
181
    /**
182
     * Delete token after validation.
183
     *
184
     * @param string $key
185
     *
186
     * @return bool
187
     */
188 19
    private function deleteToken(string &$key): bool
189
    {
190 19
        unset($this->storage[$key]);
191
192 19
        return true;
193
    }
194
195
    /**
196
     * Check if token is valid
197
     *
198
     * @param string $value
199
     * @param string $key
200
     *
201
     * @return bool
202
     */
203 20
    private function tokenIsValid(string &$value, string &$key): bool
204
    {
205
        //if token doesn't exist
206 20
        if (empty($this->storage[$key])) {
207 18
            return false;
208
        }
209
210 20
        return \hash_equals($this->storage[$key]['value'], $value);
211
    }
212
213
    /**
214
     * Check if timed token is expired.
215
     *
216
     * @param string $key
217
     *
218
     * @return bool
219
     */
220 20
    private function tokenIsExpired(string &$key): bool
221
    {
222
        //if timed and if time is valid
223 20
        if (isset($this->storage[$key]['time']) && $this->storage[$key]['time'] < \time()) {
224 1
            return false;
225
        }
226
227 19
        return true;
228
    }
229
230
    /**
231
     * Clean CSRF storage when full.
232
     *
233
     * @param int $preserve Token that will be preserved.
234
     */
235 4
    public function garbageCollector(int $preserve): void
236
    {
237 4
        if ($this->maxStorage === \count($this->storage)) {
238 4
            $this->cleanStorage($preserve);
239
        }
240 2
    }
241
242
    /**
243
     * Clean CSRF storage.
244
     *
245
     * @param int $preserve Token that will be preserved.
246
     */
247 1
    public function clean(int $preserve): void
248
    {
249 1
        $this->cleanStorage($preserve);
250 1
    }
251
252
    /**
253
     * Do the CSRF storage cleand.
254
     *
255
     * @param int $preserve Token that will be preserved.
256
     *
257
     * @throws InvalidArgumentException If arguments lesser than 0 or grater than max storage value.
258
     */
259 5
    private function cleanStorage(int $preserve = 0): void
260
    {
261 5
        if ($preserve < 0) {
262 1
            throw new InvalidArgumentException('Argument value should be grater than zero.');
263
        }
264
265 4
        if ($preserve > $this->maxStorage) {
266 1
            throw new InvalidArgumentException("Argument value should be lesser than max storage value ({$this->maxStorage}).");
267
        }
268
269 3
        $this->storage = \array_splice($this->storage, -$preserve);
270 3
    }
271
}
272