db = $db; $this->session = $session; $this->crypto = $crypto; $this->cookie_config = $cookie_config; $this->cookie_token = $cookie_token; $prefix = $this->db->getTablePrefix(); $this->table = "{$prefix}users_remember_me_cookies"; $this->time = is_numeric($time) ? (int)$time : time(); } /** * Make the user's login persistent * * @param \ElggUser $user The user who logged in * * @return void */ public function makeLoginPersistent(\ElggUser $user) { $token = $this->generateToken(); $hash = $this->hashToken($token); $this->storeHash($user, $hash); $this->setCookie($token); $this->setSession($token); } /** * Remove the persisted login token from client and server * * @return void */ public function removePersistentLogin() { if ($this->cookie_token) { $client_hash = $this->hashToken($this->cookie_token); $this->removeHash($client_hash); } $this->setCookie(""); $this->setSession(""); } /** * Handle a password change * * @param \ElggUser $subject The user whose password changed * @param \ElggUser $modifier The user who changed the password * * @return void */ public function handlePasswordChange(\ElggUser $subject, \ElggUser $modifier = null) { $this->removeAllHashes($subject); if (!$modifier || ($modifier->guid !== $subject->guid) || !$this->cookie_token) { return; } $this->makeLoginPersistent($modifier); } /** * Boot the persistent login session, possibly returning the user who should be * silently logged in. * * @return \ElggUser|null */ public function bootSession() { if (!$this->cookie_token) { return null; } // is this token good? $cookie_hash = $this->hashToken($this->cookie_token); $user = $this->getUserFromHash($cookie_hash); if ($user) { $this->setSession($this->cookie_token); // note: if the token is legacy, we don't both replacing it here because // it will be replaced during the next request boot return $user; } else { if ($this->isLegacyToken($this->cookie_token)) { // may be attempt to brute force legacy low-entropy tokens call_user_func($this->_callable_sleep, 1); } $this->setCookie(''); } } /** * Replace the user's token if it's a legacy hexadecimal token * * @param \ElggUser $logged_in_user The logged in user * * @return void */ public function replaceLegacyToken(\ElggUser $logged_in_user) { if (!$this->cookie_token || !$this->isLegacyToken($this->cookie_token)) { return; } // replace user's old weaker-entropy code with new one $this->removeHash($this->hashToken($this->cookie_token)); $this->makeLoginPersistent($logged_in_user); } /** * Find a user with the given hash * * @param string $hash The hashed token * * @return \ElggUser|null */ public function getUserFromHash($hash) { if (!$hash) { return null; } $hash = $this->db->sanitizeString($hash); $query = "SELECT guid FROM {$this->table} WHERE code = '$hash'"; try { $user_row = $this->db->getDataRow($query); } catch (\DatabaseException $e) { return $this->handleDbException($e); } if (!$user_row) { return null; } $user = call_user_func($this->_callable_get_user, $user_row->guid); return $user ? $user : null; } /** * Store a hash in the DB * * @param \ElggUser $user The user for whom we're storing the hash * @param string $hash The hashed token * * @return void */ protected function storeHash(\ElggUser $user, $hash) { // This prevents inserting the same hash twice, which seems to be happening in some rare cases // and for unknown reasons. See https://github.com/Elgg/Elgg/issues/8104 $this->removeHash($hash); $time = time(); $hash = $this->db->sanitizeString($hash); $query = " INSERT INTO {$this->table} (code, guid, timestamp) VALUES ('$hash', {$user->guid}, $time) "; try { $this->db->insertData($query); } catch (\DatabaseException $e) { $this->handleDbException($e); } } /** * Remove a hash from the DB * * @param string $hash The hashed token to remove (unused before 1.9) * @return void */ protected function removeHash($hash) { $hash = $this->db->sanitizeString($hash); $query = "DELETE FROM {$this->table} WHERE code = '$hash'"; try { $this->db->deleteData($query); } catch (\DatabaseException $e) { $this->handleDbException($e); } } /** * Swallow a schema not upgraded exception, otherwise rethrow it * * @param \DatabaseException $exception The exception to handle * @param string $default The value to return if the table doesn't exist yet * * @return mixed * * @throws \DatabaseException */ protected function handleDbException(\DatabaseException $exception, $default = null) { if (false !== strpos($exception->getMessage(), "users_remember_me_cookies' doesn't exist")) { // schema has not been updated so we swallow this exception return $default; } else { throw $exception; } } /** * Remove all the hashes associated with a user * * @param \ElggUser $user The user for whom we're removing hashes * * @return void */ protected function removeAllHashes(\ElggUser $user) { $query = "DELETE FROM {$this->table} WHERE guid = '{$user->guid}'"; try { $this->db->deleteData($query); } catch (\DatabaseException $e) { $this->handleDbException($e); } } /** * Create a hash from the token * * @param string $token The token to hash * * @return string */ protected function hashToken($token) { // note: with user passwords, you'd want legit password hashing, but since these are randomly // generated and long tokens, rainbow tables aren't any help. return md5($token); } /** * Store the token in the client cookie (or remove the cookie) * * @param string $token Empty string to remove cookie * * @return void */ protected function setCookie($token) { $cookie = new \ElggCookie($this->cookie_config['name']); foreach (array('expire', 'path', 'domain', 'secure', 'httponly') as $key) { $cookie->$key = $this->cookie_config[$key]; } $cookie->value = $token; if (!$token) { $cookie->expire = $this->time - (86400 * 30); } call_user_func($this->_callable_elgg_set_cookie, $cookie); } /** * Store the token in the session (or remove it from the session) * * @param string $token The token to store in session. Empty string to remove. * * @return void */ protected function setSession($token) { if ($token) { $this->session->set('code', $token); } else { $this->session->remove('code'); } } /** * Generate a random token (base 64 URL) * * The first char is always "z" to indicate the value has more entropy than the * previously generated ones. * * @return string */ protected function generateToken() { return 'z' . $this->crypto->getRandomString(31); } /** * Is the given token a legacy MD5 hash? * * @param string $token The token to analyze * * @return bool */ protected function isLegacyToken($token) { return (isset($token[0]) && $token[0] !== 'z'); } /** * @var Database */ protected $db; /** * @var string */ protected $table; /** * @var array */ protected $cookie_config; /** * @var string */ protected $cookie_token; /** * @var \ElggSession */ protected $session; /** * @var \ElggCrypto */ protected $crypto; /** * @var int */ protected $time; /** * DO NOT USE. For unit test mocking * @access private */ public $_callable_get_user = 'get_user'; /** * DO NOT USE. For unit test mocking * @access private */ public $_callable_elgg_set_cookie = 'elgg_set_cookie'; /** * DO NOT USE. For unit test mocking * @access private */ public $_callable_sleep = 'sleep'; }