Passed
Branch b1.3.0 (b9b8d0)
by Sebastian
03:01
created

CsrfGuard::deleteToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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