RememberMe   A
last analyzed

Complexity

Total Complexity 38

Size/Duplication

Total Lines 357
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
eloc 104
dl 0
loc 357
rs 9.36
c 0
b 0
f 0
ccs 0
cts 129
cp 0
wmc 38

20 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
A getNewSeries() 0 3 1
A hasSeriesToken() 0 3 1
A clearUserCookie() 0 3 1
A hasSeries() 0 3 1
A getNewToken() 0 3 1
A unsetSeries() 0 3 1
A clearUserTokens() 0 3 1
A writeUserTokens() 0 4 1
A unsetSeriesToken() 0 3 1
A setSeriesToken() 0 3 1
A getSeriesToken() 0 6 2
A writeUserCookie() 0 14 3
A readUserCookie() 0 19 4
A readUserTokens() 0 8 2
A createUserCookie() 0 10 1
A forget() 0 11 2
A recall() 0 40 5
A invalidateAllForUser() 0 5 1
B removeExpiredTokens() 0 15 7
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;
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) can also be of type false. However, the property $userTokens is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
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