123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- <?php
- namespace Elgg;
- /**
- * \Elgg\PersistentLoginService
- *
- * If a user selects a persistent login, a long, random token is generated and stored in the cookie
- * called "elggperm", and a hash of the token is stored in the DB. If the user's PHP session expires,
- * the session boot sequence will try to log the user in via the token in the cookie.
- *
- * Before Elgg 1.9, the token hashes were stored as "code" in the users_entity table.
- *
- * In Elgg 1.9, the token hashes are stored as "code" in the users_remember_me_cookies
- * table, allowing multiple browsers to maintain persistent logins.
- *
- * @todo Rename the "code" DB column to "hash"
- *
- * Legacy notes: This feature used to be called "remember me"; confusingly, both the tokens and the
- * hashes were called "codes"; old tokens were hexadecimal and lower entropy; new tokens are
- * base64 URL and always begin with the letter "z"; the boot sequence replaces old tokens whenever
- * possible.
- *
- * @package Elgg.Core
- *
- * @access private
- */
- class PersistentLoginService {
- /**
- * Constructor
- *
- * @param Database $db The DB service
- * @param \ElggSession $session The Elgg session
- * @param \ElggCrypto $crypto The cryptography service
- * @param array $cookie_config The persistent login cookie settings
- * @param string $cookie_token The token from the request cookie
- * @param int $time The current time
- */
- public function __construct(
- Database $db,
- \ElggSession $session,
- \ElggCrypto $crypto,
- array $cookie_config,
- $cookie_token,
- $time = null) {
- $this->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';
- }
|