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 Xoops\Core\Random;
15: use Xoops\Core\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 <richard@geekwright.com>
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: