Completed
Push — master ( d14826...5ea3eb )
by Richard
34s queued 16s
created

RememberMe::getSeriesToken()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6
Metric Value
dl 0
loc 7
ccs 0
cts 4
cp 0
rs 9.4285
cc 2
eloc 4
nc 2
nop 2
crap 6
1
<?php
2
/*
3
 You may not change or alter any portion of this comment or credits
4
 of supporting developers from this source code or any supporting source code
5
 which is considered copyrighted (c) material of the original comment or credit authors.
6
7
 This program is distributed in the hope that it will be useful,
8
 but WITHOUT ANY WARRANTY; without even the implied warranty of
9
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
*/
11
12
namespace Xoops\Core\Session;
13
14
use Xmf\Random;
15
use Xmf\Request;
16
use Xoops\Core\HttpRequest;
17
18
/**
19
 * Provide Remember Me functionality to restore a user's login state in a new session
20
 *
21
 * This incorporates ideas from Barry Jaspan's article found here:
22
 * http://jaspan.com/improved_persistent_login_cookie_best_practice
23
 *
24
 * There are problems with most of the published articles on the subject of persitent
25
 * authorization cookies, most specifically when dealing with concurrency issues in the
26
 * modern web. If two or more requests from the same browser instance arrive at the server
27
 * in a short time (i.e. impatient reload, restored tabs) all presenting the same one use
28
 * token in the auth cookie, one will work, and the others will fail.
29
 *
30
 * Using this functionality is a security risk. Ideally, this should only be used over ssl,
31
 * but even then, the possibility of cookie theft still exists. Present that stolen cookie
32
 * and the thief can become the authorized user. The following details the steps taken to
33
 * provide a smooth user experience while minimizing the exposure surface of this risk.
34
 *
35
 * Each time a new persistent auth cookie is requested, a new "series" is started.
36
 * Associated with the series is a one time token, that changes whenever it is used.
37
 * To "debounce" any concurrent requests:
38
 *      Instead of erasing the old token immediately, a short expire time is set.
39
 *      If a cookie is used with the expiring token, it is updated to the new session.
40
 *      After the expire time elapses, the old token is erased.
41
 * If a cookie with an invalid series is presented, it is erased and ignored.
42
 * If a cookie has a valid series, but an unknown token, we treat this as evidence of a stolen
43
 * cookie or hack attempt and clear all stored series/tokens associated with the user.
44
 *
45
 * Additionally, the surrounding application logic is aware that the persistent auth logic
46
 * was used. We only supply a saved id, the application must process that id. That "fact" can
47
 * be saved to require authentication confirmation as appropriate.
48
 *
49
 * @category  Xoops\Core\Session
50
 * @package   RememberMe
51
 * @author    Richard Griffith <[email protected]>
52
 * @copyright 2015 XOOPS Project (http://xoops.org)
53
 * @license   GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html)
54
 * @link      http://xoops.org
55
 */
56
class RememberMe
57
{
58
59
    /**
60
     * @var array
61
     */
62
    protected $userTokens = array();
63
64
    /**
65
     * @var integer
66
     */
67
    protected $userId = 0;
68
69
    /**
70
     * @var \Xoops
71
     */
72
    protected $xoops = null;
73
74
    /**
75
     * @var integer
76
     */
77
    protected $now = 0;
78
79
    /**
80
     * constructor
81
     */
82
    public function __construct()
83
    {
84
        $this->xoops = \Xoops::getInstance();
85
        $this->now = time();
86
    }
87
88
89
    /**
90
     * Recall a user id from the "remember me" cookie.
91
     *
92
     * @return integer|false user id, or false if non-exisiting or invalid cookie
93
     */
94
    public function recall()
95
    {
96
        $this->now = time();
97
        $cookieData = $this->readUserCookie();
98
        if (false === $cookieData) {
99
            return false;   // no or invalid cookie
100
        }
101
        list($userId, $series, $token) = $cookieData;
102
        $this->readUserTokens($userId);
103
        if ($this->hasSeriesToken($series, $token)) {
104
            $values = $this->getSeriesToken($series, $token);
105
            // debounce concurrent requests
106
            if (isset($values['next_token'])) {
107
                // this token was already replaced, use replacement to update cookie
108
                $nextToken = $values['next_token'];
109
            } else {
110
                // issue a new token for this series
111
                $nextToken = $this->getNewToken();
112
                // expire old token, and forward to the new one
113
                $values = array('expires_at' => $this->now + 10, 'next_token' => $nextToken);
114
                $this->setSeriesToken($series, $token, $values);
115
                // register the new token
116
                $values = array('expires_at' => $this->now + 2592000);
117
                $this->setSeriesToken($series, $nextToken, $values);
118
            }
119
            $cookieData = array($userId, $series, $nextToken);
120
            $this->writeUserCookie($cookieData);
121
            $return = $userId;
122
        } else {
123
            // cookie is not valid
124
            if ($this->hasSeries($series)) {
125
                // We have a valid series, but an invalid token.
126
                // Highly possible token was comprimised. Invalidate all saved tokens;
127
                $this->clearUserTokens();
128
            }
129
            $this->clearUserCookie();
130
            $return = false;
131
        }
132
        $this->writeUserTokens($userId);
133
        return $return;
134
    }
135
136
    /**
137
     * Forget a "remember me" cookie. This should be invoked if a user explicitly
138
     * logs out of a session. If a cookie is set for this session, this will clear it
139
     * and remove the associated series tokens.
140
     *
141
     * @return void
142
     */
143
    public function forget()
144
    {
145
        $this->now = time();
146
        $cookieData = $this->readUserCookie();
147
        if (false !== $cookieData) {
148
            list($userId, $series, $token) = $cookieData;
0 ignored issues
show
Unused Code introduced by
The assignment to $token is unused. Consider omitting it like so list($first,,$third).

This checks looks for assignemnts to variables using the list(...) function, where not all assigned variables are subsequently used.

Consider the following code example.

<?php

function returnThreeValues() {
    return array('a', 'b', 'c');
}

list($a, $b, $c) = returnThreeValues();

print $a . " - " . $c;

Only the variables $a and $c are used. There was no need to assign $b.

Instead, the list call could have been.

list($a,, $c) = returnThreeValues();
Loading history...
149
            $this->readUserTokens($userId);
150
            $this->unsetSeries($series);
151
            $this->writeUserTokens($userId);
152
        }
153
        $this->clearUserCookie();
154
    }
155
156
    /**
157
     * Invalidate all existing "remember me" cookie by deleting all the series/tokens
158
     *
159
     * This should be called during a password change.
160
     *
161
     * @param integer $userId id of user associated with the sessions/tokens to be invalidated
162
     *
163
     * @return void
164
     */
165
    public function invalidateAllForUser($userId)
166
    {
167
        $this->readUserTokens($userId);
168
        $this->clearUserTokens();
169
        $this->writeUserTokens($userId);
170
    }
171
172
    /**
173
     * Check if the given series exists
174
     *
175
     * @param string $series series identifier
176
     *
177
     * @return boolean true if series exists, otherwise false
178
     */
179
    protected function hasSeries($series)
180
    {
181
        return isset($this->userTokens[$series]);
182
    }
183
184
    /**
185
     * Unset an entire series
186
     *
187
     * @param string $series series identifier
188
     *
189
     * @return void
190
     */
191
    protected function unsetSeries($series)
192
    {
193
        unset($this->userTokens[$series]);
194
    }
195
196
    /**
197
     * Get the values associated with a given series and token
198
     *
199
     * @param string $series series identifier
200
     * @param string $token  token to check
201
     *
202
     * @return boolean true if series and token combination exists, otherwise false
203
     */
204
    protected function hasSeriesToken($series, $token)
205
    {
206
        return isset($this->userTokens[$series][$token]);
207
    }
208
209
    /**
210
     * Get the values associated with a given series and token
211
     *
212
     * @param string $series series identifier
213
     * @param string $token  token to check
214
     *
215
     * @return array|false
216
     */
217
    protected function getSeriesToken($series, $token)
218
    {
219
        if (isset($this->userTokens[$series][$token])) {
220
            return $this->userTokens[$series][$token];
221
        }
222
        return false;
223
    }
224
225
    /**
226
     * Get the values associated with a given series and token
227
     *
228
     * @param string $series series identifier
229
     * @param string $token  token to check
230
     * @param array  $values valuestoken to check
231
     *
232
     * @return void
233
     */
234
    protected function setSeriesToken($series, $token, $values)
235
    {
236
        $this->userTokens[$series][$token] = $values;
237
    }
238
239
    /**
240
     * Get the values associated with a given series and token
241
     *
242
     * @param string $series series identifier
243
     * @param string $token  token to check
244
     *
245
     * @return void
246
     */
247
    protected function unsetSeriesToken($series, $token)
248
    {
249
        unset($this->userTokens[$series][$token]);
250
    }
251
252
    /**
253
     * read existing user tokens from persistent storage
254
     *
255
     * @param integer $userId id of user to read tokens for
256
     *
257
     * @return void
258
     */
259
    protected function readUserTokens($userId)
260
    {
261
        $key = "user/{$userId}/usercookie";
262
        $this->userTokens = $this->xoops->cache()->read($key);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->xoops->cache()->read($key) of type * is incompatible with the declared type array of property $userTokens.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
263
        if (false === $this->userTokens) {
264
            $this->clearUserTokens();
265
        }
266
        $this->removeExpiredTokens();
267
    }
268
269
    /**
270
     * write the existing user tokens to persistent storage
271
     *
272
     * @param integer $userId id of user to write tokens for
273
     *
274
     * @return void
275
     */
276
    protected function writeUserTokens($userId)
277
    {
278
        $key = "user/{$userId}/usercookie";
279
        $this->xoops->cache()->write($key, $this->userTokens, 2592000);
280
    }
281
282
    /**
283
     * Remove any expired tokens
284
     *
285
     * @return void
286
     */
287
    protected function removeExpiredTokens()
288
    {
289
        $now = $this->now;
290
        $userTokens = $this->userTokens;
291
        foreach ($userTokens as $series => $tokens) {
292
            foreach ($tokens as $token => $values) {
293
                if (isset($values['expires_at']) && $values['expires_at'] < $now) {
294
                    $this->unsetSeriesToken($series, $token);
295
                }
296
            }
297
        }
298
        $userTokens = $this->userTokens;
299
        foreach ($userTokens as $series => $tokens) {
300
            if (empty($tokens)) {
301
                $this->unsetSeries($series);
302
            }
303
        }
304
    }
305
306
    /**
307
     * Clear all tokens for this user
308
     * @return void
309
     */
310
    protected function clearUserTokens()
311
    {
312
        $this->userTokens = array();
313
    }
314
315
    /**
316
     * Generate a new series
317
     *
318
     * @return string a new series key
319
     */
320
    protected function getNewSeries()
321
    {
322
        return Random::generateKey();
323
    }
324
325
    /**
326
     * Generate a new token
327
     *
328
     * @return string a new token
329
     */
330
    protected function getNewToken()
331
    {
332
        return Random::generateOneTimeToken();
333
    }
334
335
    /**
336
     * Create a new user cookie, usually in response to login with "remember me" selected
337
     *
338
     * @param integer $userId id of user to be remembered
339
     *
340
     * @return void
341
     **/
342
    public function createUserCookie($userId)
343
    {
344
        $this->readUserTokens($userId);
345
        $this->now = time();
346
        $series = $this->getNewSeries();
347
        $token = $this->getNewToken();
348
        $cookieData = array($userId, $series, $token);
349
        $this->setSeriesToken($series, $token, array('expires_at' => $this->now + 2592000));
350
        $this->writeUserCookie($cookieData);
351
        $this->writeUserTokens($userId);
352
    }
353
354
    /**
355
     * Update cookie status for current session
356
     *
357
     * @return void
358
     **/
359
    protected function clearUserCookie()
360
    {
361
        $this->writeUserCookie('', -3600);
362
    }
363
364
    /**
365
     * Read the user cookie
366
     *
367
     * @return array|false the cookie data as array(userid, series, token), or
368
     *                     false if cookie does not exist (or not configured)
369
     */
370
    protected function readUserCookie()
371
    {
372
        $usercookie = $this->xoops->getConfig('usercookie');
373
        if (empty($usercookie)) {
374
            return false; // remember me is not configured
375
        }
376
377
        $usercookie = $this->xoops->getConfig('usercookie');
378
        $notFound = 'Nosuchcookie';
379
        $cookieData = Request::getString($usercookie, $notFound, 'COOKIE');
380
        if ($cookieData !== $notFound) {
381
            $temp = explode('-', $cookieData);
382
            if (count($temp) == 3) {
383
                $temp[0] = (integer) $temp[0];
384
                return $temp;
385
            }
386
            $this->clearUserCookie(); // clean up garbage cookie
387
        }
388
        return false;
389
    }
390
391
    /**
392
     * Update cookie status for current session
393
     *
394
     * @param array|string $cookieData usercookie value
395
     * @param integer      $expire     seconds until usercookie expires
396
     *
397
     * @return void
398
     **/
399
    protected function writeUserCookie($cookieData, $expire = 2592000)
400
    {
401
        $usercookie = $this->xoops->getConfig('usercookie');
402
        if (empty($usercookie)) {
403
            return; // remember me is not configured
404
        }
405
        if (is_array($cookieData)) {
406
            $cookieData = implode('-', $cookieData);
407
        }
408
        $httpRequest = HttpRequest::getInstance();
409
        $path = \XoopsBaseConfig::get('cookie-path');
410
        $domain = \XoopsBaseConfig::get('cookie-domain');
411
        $secure = $httpRequest->is('ssl');
412
        setcookie($usercookie, $cookieData, $this->now + $expire, $path, $domain, $secure, true);
413
    }
414
}
415