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); |
|
|
|
|
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
|
|
|
|
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 theid
property of an instance of theAccount
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.