Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.11% |
104 / 106 |
|
88.89% |
16 / 18 |
CRAP | |
0.00% |
0 / 1 |
TokenService | |
98.11% |
104 / 106 |
|
88.89% |
16 / 18 |
49 | |
0.00% |
0 / 1 |
clearAll | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
createToken | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
checkToken | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
checkFormToken | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
validateToken | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
isExpired | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
revokeToken | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
invalidateExpiredAndSurplus | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
9.02 | |||
getPoolSize | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
getTimeLimit | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getStore | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
generateTokenPool | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
getClientConfig | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
addFormToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFormToken | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
getClientStore | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
normaliseToken | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
isTokenValid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | |
3 | /** |
4 | * This program is free software; you can redistribute it and/or |
5 | * modify it under the terms of the GNU General Public License |
6 | * as published by the Free Software Foundation; under version 2 |
7 | * of the License (non-upgradable). |
8 | * |
9 | * This program is distributed in the hope that it will be useful, |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | * GNU General Public License for more details. |
13 | * |
14 | * You should have received a copy of the GNU General Public License |
15 | * along with this program; if not, write to the Free Software |
16 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
17 | * |
18 | * Copyright (c) 2017-2020 (original work) Open Assessment Technologies SA ; |
19 | */ |
20 | |
21 | declare(strict_types=1); |
22 | |
23 | namespace oat\tao\model\security\xsrf; |
24 | |
25 | use common_Exception; |
26 | use common_exception_NoImplementation; |
27 | use common_exception_Unauthorized; |
28 | use oat\oatbox\log\LoggerAwareTrait; |
29 | use oat\oatbox\service\ConfigurableService; |
30 | use oat\oatbox\service\exception\InvalidService; |
31 | use oat\tao\model\security\TokenGenerator; |
32 | |
33 | /** |
34 | * This service let's you manage tokens to protect against XSRF. |
35 | * The protection works using this workflow : |
36 | * 1. Token pool gets generated and stored by front-end |
37 | * 2. Front-end adds a token header using the token header "X-CSRF-Token" |
38 | * 3. Back-end verifies the token using \oat\tao\model\security\xsrf\CsrfValidatorTrait |
39 | * |
40 | * @see \oat\tao\model\security\xsrf\CsrfValidatorTrait |
41 | * @author Bertrand Chevrier <bertrand@taotesting.com> |
42 | * @author Martijn Swinkels <martijn@taotesting.com> |
43 | */ |
44 | class TokenService extends ConfigurableService |
45 | { |
46 | use TokenGenerator; |
47 | use LoggerAwareTrait; |
48 | |
49 | public const SERVICE_ID = 'tao/security-xsrf-token'; |
50 | |
51 | // options keys |
52 | public const POOL_SIZE_OPT = 'poolSize'; |
53 | public const TIME_LIMIT_OPT = 'timeLimit'; |
54 | public const VALIDATE_TOKENS_OPT = 'validateTokens'; |
55 | public const OPTION_STORE = 'store'; |
56 | public const OPTION_CLIENT_STORE = 'clientStore'; |
57 | |
58 | public const OPTION_CLIENT_STORE_LOCAL_STORAGE = 'localStorage'; |
59 | public const OPTION_CLIENT_STORE_LOCAL_SESSION_STORAGE = 'sessionStorage'; |
60 | public const OPTION_CLIENT_STORE_LOCAL_SESSION_INDEXED_DB = 'indexedDB'; |
61 | public const OPTION_CLIENT_STORE_MEMORY = 'memory'; |
62 | |
63 | public const CLIENT_STORE_OPTION_VALUES = [ |
64 | self::OPTION_CLIENT_STORE_LOCAL_STORAGE, |
65 | self::OPTION_CLIENT_STORE_LOCAL_SESSION_STORAGE, |
66 | self::OPTION_CLIENT_STORE_LOCAL_SESSION_INDEXED_DB, |
67 | self::OPTION_CLIENT_STORE_MEMORY, |
68 | ]; |
69 | |
70 | public const CSRF_TOKEN_HEADER = 'X-CSRF-Token'; |
71 | public const FORM_TOKEN_NAMESPACE = 'form_token'; |
72 | public const JS_DATA_KEY = 'tokenHandler'; |
73 | public const JS_TOKEN_KEY = 'tokens'; |
74 | public const JS_TOKEN_POOL_SIZE_KEY = 'maxSize'; |
75 | public const JS_TOKEN_TIME_LIMIT_KEY = 'tokenTimeLimit'; |
76 | public const JS_TOKEN_STORE = 'store'; |
77 | |
78 | private const DEFAULT_POOL_SIZE = 6; |
79 | private const DEFAULT_TIME_LIMIT = 0; |
80 | private const DEFAULT_CLIENT_STORE = self::OPTION_CLIENT_STORE_MEMORY; |
81 | |
82 | /** |
83 | * @param int $uSleepInterval Microseconds interval between scan/keys to perform deletion |
84 | * @param int $timeLimit Expiration time for tokens, 0 means, no expiration |
85 | * @return int - The total deleted records |
86 | */ |
87 | public function clearAll(int $uSleepInterval, int $timeLimit): int |
88 | { |
89 | $store = $this->getStore(); |
90 | |
91 | if ($store instanceof TokenStoreKeyValue) { |
92 | return $store->clearAll($uSleepInterval, $timeLimit); |
93 | } |
94 | |
95 | throw new common_exception_NoImplementation( |
96 | sprintf( |
97 | 'There is no implementation of %s to the Store Driver %s', |
98 | __METHOD__, |
99 | get_class($store) |
100 | ) |
101 | ); |
102 | } |
103 | |
104 | /** |
105 | * Generates, stores and return a brand new token |
106 | * Triggers the pool invalidation. |
107 | * |
108 | * @return Token |
109 | * @throws common_Exception |
110 | */ |
111 | public function createToken(): Token |
112 | { |
113 | $store = $this->getStore(); |
114 | $this->invalidateExpiredAndSurplus($store->getAll()); |
115 | |
116 | $token = new Token(); |
117 | $store->setToken($token->getValue(), $token); |
118 | |
119 | return $token; |
120 | } |
121 | |
122 | /** |
123 | * Check if the given token is valid |
124 | * (does not revoke) |
125 | * |
126 | * @param string|Token $token The given token to validate |
127 | * |
128 | * @return boolean |
129 | * @throws InvalidService |
130 | */ |
131 | public function checkToken($token): bool |
132 | { |
133 | $token = $this->normaliseToken($token); |
134 | $savedToken = $this->getStore()->getToken($token); |
135 | |
136 | return $this->isTokenValid($token, $savedToken); |
137 | } |
138 | |
139 | /** |
140 | * Check if form token is valid (does not revoke) |
141 | * |
142 | * @param string|Token $token The given token to validate |
143 | * |
144 | * @return boolean |
145 | * @throws InvalidService |
146 | */ |
147 | public function checkFormToken($token): bool |
148 | { |
149 | $token = $this->normaliseToken($token); |
150 | $savedToken = $this->getStore()->getToken(self::FORM_TOKEN_NAMESPACE); |
151 | |
152 | return $this->isTokenValid($token, $savedToken); |
153 | } |
154 | |
155 | /** |
156 | * Check if the given token is valid and revoke it. |
157 | * |
158 | * @param string |Token $token |
159 | * |
160 | * @return bool Whether or not the token was successfully revoked |
161 | * |
162 | * @throws common_Exception |
163 | * @throws common_exception_Unauthorized In case of an invalid token or missing |
164 | */ |
165 | public function validateToken($token): bool |
166 | { |
167 | $token = $this->normaliseToken($token); |
168 | $storeToken = $this->getStore()->getToken($token); |
169 | |
170 | $result = $this->revokeToken($token); |
171 | |
172 | if ($storeToken === null || $this->isExpired($storeToken)) { |
173 | throw new common_exception_Unauthorized(); |
174 | } |
175 | |
176 | return $result; |
177 | } |
178 | |
179 | /** |
180 | * Check if the given token has expired. |
181 | * |
182 | * @param Token $token |
183 | * @return bool |
184 | */ |
185 | private function isExpired(Token $token): bool |
186 | { |
187 | return $token->isExpired($this->getTimeLimit()); |
188 | } |
189 | |
190 | /** |
191 | * Revokes the given token |
192 | * |
193 | * @param string|Token $token |
194 | * |
195 | * @return true |
196 | * |
197 | * @throws InvalidService |
198 | */ |
199 | public function revokeToken($token): bool |
200 | { |
201 | $token = $this->normaliseToken($token); |
202 | return $this->getStore()->removeToken($token); |
203 | } |
204 | |
205 | /** |
206 | * Invalidate the tokens in the pool : |
207 | * - remove the oldest if the pool raises it's size limit |
208 | * - remove the expired tokens |
209 | * |
210 | * @param Token[] $tokens |
211 | * |
212 | * @return array the invalidated pool |
213 | * |
214 | * @throws InvalidService |
215 | */ |
216 | protected function invalidateExpiredAndSurplus(array $tokens): array |
217 | { |
218 | $timeLimit = $this->getTimeLimit(); |
219 | $poolSize = $this->getPoolSize(); |
220 | |
221 | if ($timeLimit > 0) { |
222 | foreach ($tokens as $key => $token) { |
223 | if ($this->isExpired($token)) { |
224 | $this->getStore()->removeToken($token->getValue()); |
225 | unset($tokens[$key]); |
226 | } |
227 | } |
228 | } |
229 | |
230 | if ($poolSize > 0 && count($tokens) >= $poolSize) { |
231 | uasort($tokens, static function (Token $a, Token $b) { |
232 | if ($a->getCreatedAt() === $b->getCreatedAt()) { |
233 | return 0; |
234 | } |
235 | return $a->getCreatedAt() < $b->getCreatedAt() ? -1 : 1; |
236 | }); |
237 | |
238 | //remove the elements at the beginning to fit the pool size |
239 | for ($i = 0; $i <= count($tokens) - $poolSize; $i++) { |
240 | $this->getStore()->removeToken($tokens[$i]->getValue()); |
241 | } |
242 | } |
243 | |
244 | return $tokens; |
245 | } |
246 | |
247 | /** |
248 | * Get the configured pool size |
249 | * |
250 | * @param bool $withForm - Takes care of the form token |
251 | * |
252 | * @return int the pool size, 10 by default |
253 | * |
254 | * @throws InvalidService |
255 | */ |
256 | public function getPoolSize(bool $withForm = true): int |
257 | { |
258 | $poolSize = self::DEFAULT_POOL_SIZE; |
259 | if ($this->hasOption(self::POOL_SIZE_OPT)) { |
260 | $poolSize = (int)$this->getOption(self::POOL_SIZE_OPT); |
261 | } |
262 | |
263 | if ($withForm && $poolSize > 0 && $this->getStore()->hasToken(self::FORM_TOKEN_NAMESPACE)) { |
264 | $poolSize++; |
265 | } |
266 | |
267 | return $poolSize; |
268 | } |
269 | |
270 | /** |
271 | * Get the configured time limit in seconds |
272 | * |
273 | * @return int the limit |
274 | */ |
275 | protected function getTimeLimit(): int |
276 | { |
277 | $timeLimit = self::DEFAULT_TIME_LIMIT; |
278 | if ($this->hasOption(self::TIME_LIMIT_OPT)) { |
279 | $timeLimit = (int)$this->getOption(self::TIME_LIMIT_OPT); |
280 | } |
281 | return $timeLimit; |
282 | } |
283 | |
284 | /** |
285 | * Get the configured store |
286 | * |
287 | * @return TokenStore the store |
288 | * |
289 | * @throws InvalidService |
290 | */ |
291 | protected function getStore(): TokenStore |
292 | { |
293 | $store = $this->getOption(self::OPTION_STORE); |
294 | if (!$store instanceof TokenStore) { |
295 | throw new InvalidService('Unexpected store for ' . __CLASS__); |
296 | } |
297 | return $this->propagate($store); |
298 | } |
299 | |
300 | /** |
301 | * Generate a token pool, and return it. |
302 | * |
303 | * @return Token[] |
304 | * @throws common_Exception |
305 | */ |
306 | public function generateTokenPool(): array |
307 | { |
308 | $store = $this->getStore(); |
309 | $tokens = $store->getAll(); |
310 | |
311 | if ($this->getTimeLimit() > 0) { |
312 | foreach ($tokens as $key => $token) { |
313 | if ($this->isExpired($token)) { |
314 | $this->revokeToken($token->getValue()); |
315 | unset($tokens[$key]); |
316 | } |
317 | } |
318 | } |
319 | |
320 | $remainingPoolSize = $this->getPoolSize() - count($tokens); |
321 | for ($i = 0; $i < $remainingPoolSize; $i++) { |
322 | $newToken = new Token(); |
323 | $store->setToken($newToken->getValue(), $newToken); |
324 | $tokens[] = $newToken; |
325 | } |
326 | |
327 | return $tokens; |
328 | } |
329 | |
330 | /** |
331 | * Gets the client configuration |
332 | * |
333 | * @return array |
334 | * |
335 | * @throws common_Exception |
336 | */ |
337 | public function getClientConfig(): array |
338 | { |
339 | $storedFormToken = $this->getFormToken(); |
340 | $tokenPool = $this->generateTokenPool(); |
341 | $jsTokenPool = []; |
342 | |
343 | foreach ($tokenPool as $token) { |
344 | if ($storedFormToken && $token->getValue() === $storedFormToken->getValue()) { |
345 | // exclude form token from client configuration |
346 | continue; |
347 | } |
348 | $jsTokenPool[] = $token->getValue(); |
349 | } |
350 | |
351 | return [ |
352 | self::JS_TOKEN_TIME_LIMIT_KEY => $this->getTimeLimit() * 1000, |
353 | self::JS_TOKEN_POOL_SIZE_KEY => $this->getPoolSize(false), |
354 | self::JS_TOKEN_KEY => $jsTokenPool, |
355 | self::VALIDATE_TOKENS_OPT => $this->getOption(self::VALIDATE_TOKENS_OPT), |
356 | self::JS_TOKEN_STORE => $this->getClientStore(), |
357 | ]; |
358 | } |
359 | |
360 | /** |
361 | * Add a token that can be used for forms. |
362 | * @throws common_Exception |
363 | */ |
364 | public function addFormToken(): void |
365 | { |
366 | $this->getStore()->setToken(self::FORM_TOKEN_NAMESPACE, new Token()); |
367 | } |
368 | |
369 | /** |
370 | * Get a token from the pool, which can be used for forms. |
371 | * @return Token |
372 | * @throws common_Exception |
373 | */ |
374 | public function getFormToken(): Token |
375 | { |
376 | $store = $this->getStore(); |
377 | $token = $store->getToken(self::FORM_TOKEN_NAMESPACE); |
378 | |
379 | if ($token === null) { |
380 | $this->addFormToken(); |
381 | |
382 | return $store->getToken(self::FORM_TOKEN_NAMESPACE); |
383 | } |
384 | |
385 | if ($token->isExpired($this->getTimeLimit())) { |
386 | $this->revokeToken($token); |
387 | $this->addFormToken(); |
388 | |
389 | return $store->getToken(self::FORM_TOKEN_NAMESPACE); |
390 | } |
391 | |
392 | return $token; |
393 | } |
394 | |
395 | private function getClientStore(): string |
396 | { |
397 | $store = $this->getOption(self::OPTION_CLIENT_STORE, self::DEFAULT_CLIENT_STORE); |
398 | |
399 | return in_array($store, self::CLIENT_STORE_OPTION_VALUES, true) |
400 | ? $store |
401 | : self::DEFAULT_CLIENT_STORE; |
402 | } |
403 | |
404 | /** |
405 | * @param string|Token $token |
406 | * @return string |
407 | */ |
408 | private function normaliseToken($token): string |
409 | { |
410 | if (is_object($token) && $token instanceof Token) { |
411 | $token = $token->getValue(); |
412 | } |
413 | |
414 | return $token; |
415 | } |
416 | |
417 | /** |
418 | * @param string $tokenToCheck |
419 | * @param Token|null $savedToken |
420 | * @return bool |
421 | */ |
422 | private function isTokenValid(string $tokenToCheck, ?Token $savedToken): bool |
423 | { |
424 | return $savedToken !== null && $savedToken->getValue() === $tokenToCheck && !$this->isExpired($savedToken); |
425 | } |
426 | } |