Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.11% covered (success)
98.11%
104 / 106
88.89% covered (warning)
88.89%
16 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenService
98.11% covered (success)
98.11%
104 / 106
88.89% covered (warning)
88.89%
16 / 18
49
0.00% covered (danger)
0.00%
0 / 1
 clearAll
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 createToken
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 checkToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 checkFormToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 validateToken
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isExpired
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 revokeToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 invalidateExpiredAndSurplus
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 getPoolSize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getTimeLimit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getStore
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 generateTokenPool
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 getClientConfig
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 addFormToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormToken
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getClientStore
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 normaliseToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isTokenValid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
21declare(strict_types=1);
22
23namespace oat\tao\model\security\xsrf;
24
25use common_Exception;
26use common_exception_NoImplementation;
27use common_exception_Unauthorized;
28use oat\oatbox\log\LoggerAwareTrait;
29use oat\oatbox\service\ConfigurableService;
30use oat\oatbox\service\exception\InvalidService;
31use 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 */
44class 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}