Passed
Push — master ( c0a3a7...3b84a4 )
by Jeroen
58:51
created

engine/classes/Elgg/PersistentLoginService.php (3 issues)

1
<?php
2
namespace Elgg;
3
/**
4
 * \Elgg\PersistentLoginService
5
 *
6
 * If a user selects a persistent login, a long, random token is generated and stored in the cookie
7
 * called "elggperm", and a hash of the token is stored in the DB. If the user's PHP session expires,
8
 * the session boot sequence will try to log the user in via the token in the cookie.
9
 *
10
 * Before Elgg 1.9, the token hashes were stored as "code" in the users_entity table.
11
 *
12
 * In Elgg 1.9, the token hashes are stored as "code" in the users_remember_me_cookies
13
 * table, allowing multiple browsers to maintain persistent logins.
14
 *
15
 * @todo Rename the "code" DB column to "hash"
16
 *
17
 * Legacy notes: This feature used to be called "remember me"; confusingly, both the tokens and the
18
 * hashes were called "codes"; old tokens were hexadecimal and lower entropy; new tokens are
19
 * base64 URL and always begin with the letter "z"; the boot sequence replaces old tokens whenever
20
 * possible.
21
 *
22
 * @package Elgg.Core
23
 *
24
 * @access private
25
 */
26
class PersistentLoginService {
27
28
	/**
29
	 * Constructor
30
	 *
31
	 * @param Database     $db            The DB service
32
	 * @param \ElggSession $session       The Elgg session
33
	 * @param \ElggCrypto  $crypto        The cryptography service
34
	 * @param array        $cookie_config The persistent login cookie settings
35
	 * @param string       $cookie_token  The token from the request cookie
36
	 * @param int          $time          The current time
37
	 */
38 4417
	public function __construct(
39
			Database $db,
40
			\ElggSession $session,
41
			\ElggCrypto $crypto,
42
			array $cookie_config,
43
			$cookie_token,
44
			$time = null) {
45 4417
		$this->db = $db;
46 4417
		$this->session = $session;
47 4417
		$this->crypto = $crypto;
48 4417
		$this->cookie_config = $cookie_config;
49 4417
		$this->cookie_token = $cookie_token;
50
51 4417
		$prefix = $this->db->prefix;
52 4417
		$this->table = "{$prefix}users_remember_me_cookies";
53 4417
		$this->time = is_numeric($time) ? (int) $time : time();
54 4417
	}
55
56
	/**
57
	 * Make the user's login persistent
58
	 *
59
	 * @param \ElggUser $user The user who logged in
60
	 *
61
	 * @return void
62
	 */
63 3
	public function makeLoginPersistent(\ElggUser $user) {
64 3
		$token = $this->generateToken();
65 3
		$hash = $this->hashToken($token);
66
67 3
		$this->storeHash($user, $hash);
68 3
		$this->setCookie($token);
69 3
		$this->setSession($token);
70 3
	}
71
72
	/**
73
	 * Remove the persisted login token from client and server
74
	 *
75
	 * @return void
76
	 */
77 2
	public function removePersistentLogin() {
78 2
		if ($this->cookie_token) {
79 1
			$client_hash = $this->hashToken($this->cookie_token);
80 1
			$this->removeHash($client_hash);
81
		}
82
83 2
		$this->setCookie("");
84 2
		$this->setSession("");
85 2
	}
86
87
	/**
88
	 * Handle a password change
89
	 *
90
	 * @param \ElggUser $subject  The user whose password changed
91
	 * @param \ElggUser $modifier The user who changed the password
92
	 *
93
	 * @return void
94
	 */
95 3
	public function handlePasswordChange(\ElggUser $subject, \ElggUser $modifier = null) {
96 3
		$this->removeAllHashes($subject);
97 3
		if (!$modifier || ($modifier->guid !== $subject->guid) || !$this->cookie_token) {
98 2
			return;
99
		}
100
101 1
		$this->makeLoginPersistent($modifier);
102 1
	}
103
104
	/**
105
	 * Boot the persistent login session, possibly returning the user who should be
106
	 * silently logged in.
107
	 *
108
	 * @return \ElggUser|null
109
	 */
110 4777
	public function bootSession() {
111 4777
		if (!$this->cookie_token) {
112 4777
			return null;
113
		}
114
115
		// is this token good?
116 3
		$cookie_hash = $this->hashToken($this->cookie_token);
117 3
		$user = $this->getUserFromHash($cookie_hash);
118 3
		if ($user) {
119 1
			$this->setSession($this->cookie_token);
120
			// note: if the token is legacy, we don't both replacing it here because
121
			// it will be replaced during the next request boot
122 1
			return $user;
123
		} else {
124 2
			if ($this->isLegacyToken($this->cookie_token)) {
125
				// may be attempt to brute force legacy low-entropy tokens
126 1
				call_user_func($this->_callable_sleep, 1);
127
			}
128 2
			$this->setCookie('');
129
		}
130 2
	}
131
132
	/**
133
	 * Replace the user's token if it's a legacy hexadecimal token
134
	 *
135
	 * @param \ElggUser $logged_in_user The logged in user
136
	 *
137
	 * @return void
138
	 */
139 3
	public function replaceLegacyToken(\ElggUser $logged_in_user) {
140 3
		if (!$this->cookie_token || !$this->isLegacyToken($this->cookie_token)) {
141 2
			return;
142
		}
143
144
		// replace user's old weaker-entropy code with new one
145 1
		$this->removeHash($this->hashToken($this->cookie_token));
146 1
		$this->makeLoginPersistent($logged_in_user);
147 1
	}
148
149
	/**
150
	 * Find a user with the given hash
151
	 *
152
	 * @param string $hash The hashed token
153
	 *
154
	 * @return \ElggUser|null
155
	 */
156 6
	public function getUserFromHash($hash) {
157 6
		if (!$hash) {
158
			return null;
159
		}
160
161 6
		$hash = $this->db->sanitizeString($hash);
0 ignored issues
show
Deprecated Code introduced by Steve Clay
The function Elgg\Database::sanitizeString() has been deprecated: Use query parameters where possible ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

161
		$hash = /** @scrutinizer ignore-deprecated */ $this->db->sanitizeString($hash);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
162 6
		$query = "SELECT guid FROM {$this->table} WHERE code = '$hash'";
163
		try {
164 6
			$user_row = $this->db->getDataRow($query);
165
		} catch (\DatabaseException $e) {
166
			return $this->handleDbException($e);
167
		}
168 6
		if (!$user_row) {
169 3
			return null;
170
		}
171
172 3
		$user = call_user_func($this->_callable_get_user, $user_row->guid);
173 3
		return $user ? $user : null;
174
	}
175
176
	/**
177
	 * Store a hash in the DB
178
	 *
179
	 * @param \ElggUser $user The user for whom we're storing the hash
180
	 * @param string    $hash The hashed token
181
	 *
182
	 * @return void
183
	 */
184 3
	protected function storeHash(\ElggUser $user, $hash) {
185
		// This prevents inserting the same hash twice, which seems to be happening in some rare cases
186
		// and for unknown reasons. See https://github.com/Elgg/Elgg/issues/8104
187 3
		$this->removeHash($hash);
188
189 3
		$time = time();
190 3
		$hash = $this->db->sanitizeString($hash);
0 ignored issues
show
Deprecated Code introduced by Steve Clay
The function Elgg\Database::sanitizeString() has been deprecated: Use query parameters where possible ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

190
		$hash = /** @scrutinizer ignore-deprecated */ $this->db->sanitizeString($hash);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
191
192
		$query = "
193 3
			INSERT INTO {$this->table} (code, guid, timestamp)
194 3
		    VALUES ('$hash', {$user->guid}, $time)
195
		";
196
		try {
197 3
			$this->db->insertData($query);
198
		} catch (\DatabaseException $e) {
199
			$this->handleDbException($e);
200
		}
201 3
	}
202
203
	/**
204
	 * Remove a hash from the DB
205
	 *
206
	 * @param string $hash The hashed token to remove (unused before 1.9)
207
	 * @return void
208
	 */
209 4
	protected function removeHash($hash) {
210 4
		$hash = $this->db->sanitizeString($hash);
0 ignored issues
show
Deprecated Code introduced by Steve Clay
The function Elgg\Database::sanitizeString() has been deprecated: Use query parameters where possible ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

210
		$hash = /** @scrutinizer ignore-deprecated */ $this->db->sanitizeString($hash);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
211
212 4
		$query = "DELETE FROM {$this->table} WHERE code = '$hash'";
213
		try {
214 4
			$this->db->deleteData($query);
215
		} catch (\DatabaseException $e) {
216
			$this->handleDbException($e);
217
		}
218 4
	}
219
220
	/**
221
	 * Swallow a schema not upgraded exception, otherwise rethrow it
222
	 *
223
	 * @param \DatabaseException $exception The exception to handle
224
	 * @param string             $default   The value to return if the table doesn't exist yet
225
	 *
226
	 * @return mixed
227
	 *
228
	 * @throws \DatabaseException
229
	 */
230
	protected function handleDbException(\DatabaseException $exception, $default = null) {
231
		if (false !== strpos($exception->getMessage(), "users_remember_me_cookies' doesn't exist")) {
232
			// schema has not been updated so we swallow this exception
233
			return $default;
234
		} else {
235
			throw $exception;
236
		}
237
	}
238
239
	/**
240
	 * Remove all the hashes associated with a user
241
	 *
242
	 * @param \ElggUser $user The user for whom we're removing hashes
243
	 *
244
	 * @return void
245
	 */
246 3
	protected function removeAllHashes(\ElggUser $user) {
247 3
		$query = "DELETE FROM {$this->table} WHERE guid = '{$user->guid}'";
248
		try {
249 3
			$this->db->deleteData($query);
250
		} catch (\DatabaseException $e) {
251
			$this->handleDbException($e);
252
		}
253 3
	}
254
255
	/**
256
	 * Create a hash from the token
257
	 *
258
	 * @param string $token The token to hash
259
	 *
260
	 * @return string
261
	 */
262 7
	protected function hashToken($token) {
263
		// note: with user passwords, you'd want legit password hashing, but since these are randomly
264
		// generated and long tokens, rainbow tables aren't any help.
265 7
		return md5($token);
266
	}
267
268
	/**
269
	 * Store the token in the client cookie (or remove the cookie)
270
	 *
271
	 * @param string $token Empty string to remove cookie
272
	 *
273
	 * @return void
274
	 */
275 7
	protected function setCookie($token) {
276 7
		$cookie = new \ElggCookie($this->cookie_config['name']);
277 7
		foreach (['expire', 'path', 'domain', 'secure', 'httponly'] as $key) {
278 7
			$cookie->$key = $this->cookie_config[$key];
279
		}
280 7
		$cookie->value = $token;
281 7
		if (!$token) {
282 4
			$cookie->expire = $this->time - (86400 * 30);
283
		}
284 7
		call_user_func($this->_callable_elgg_set_cookie, $cookie);
285 7
	}
286
287
	/**
288
	 * Store the token in the session (or remove it from the session)
289
	 *
290
	 * @param string $token The token to store in session. Empty string to remove.
291
	 *
292
	 * @return void
293
	 */
294 6
	protected function setSession($token) {
295 6
		if ($token) {
296 4
			$this->session->set('code', $token);
297
		} else {
298 2
			$this->session->remove('code');
299
		}
300 6
	}
301
302
	/**
303
	 * Generate a random token (base 64 URL)
304
	 *
305
	 * The first char is always "z" to indicate the value has more entropy than the
306
	 * previously generated ones.
307
	 *
308
	 * @return string
309
	 */
310 3
	protected function generateToken() {
311 3
		return 'z' . $this->crypto->getRandomString(31);
312
	}
313
314
	/**
315
	 * Is the given token a legacy MD5 hash?
316
	 *
317
	 * @param string $token The token to analyze
318
	 *
319
	 * @return bool
320
	 */
321 3
	protected function isLegacyToken($token) {
322 3
		return (isset($token[0]) && $token[0] !== 'z');
323
	}
324
325
	/**
326
	 * @var Database
327
	 */
328
	protected $db;
329
330
	/**
331
	 * @var string
332
	 */
333
	protected $table;
334
335
	/**
336
	 * @var array
337
	 */
338
	protected $cookie_config;
339
340
	/**
341
	 * @var string
342
	 */
343
	protected $cookie_token;
344
345
	/**
346
	 * @var \ElggSession
347
	 */
348
	protected $session;
349
350
	/**
351
	 * @var \ElggCrypto
352
	 */
353
	protected $crypto;
354
355
	/**
356
	 * @var int
357
	 */
358
	protected $time;
359
360
	/**
361
	 * DO NOT USE. For unit test mocking
362
	 * @access private
363
	 */
364
	public $_callable_get_user = 'get_user';
365
366
	/**
367
	 * DO NOT USE. For unit test mocking
368
	 * @access private
369
	 */
370
	public $_callable_elgg_set_cookie = 'elgg_set_cookie';
371
372
	/**
373
	 * DO NOT USE. For unit test mocking
374
	 * @access private
375
	 */
376
	public $_callable_sleep = 'sleep';
377
}
378
379