PersistentLoginService.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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. * Constructor
  29. *
  30. * @param Database $db The DB service
  31. * @param \ElggSession $session The Elgg session
  32. * @param \ElggCrypto $crypto The cryptography service
  33. * @param array $cookie_config The persistent login cookie settings
  34. * @param string $cookie_token The token from the request cookie
  35. * @param int $time The current time
  36. */
  37. public function __construct(
  38. Database $db,
  39. \ElggSession $session,
  40. \ElggCrypto $crypto,
  41. array $cookie_config,
  42. $cookie_token,
  43. $time = null) {
  44. $this->db = $db;
  45. $this->session = $session;
  46. $this->crypto = $crypto;
  47. $this->cookie_config = $cookie_config;
  48. $this->cookie_token = $cookie_token;
  49. $prefix = $this->db->getTablePrefix();
  50. $this->table = "{$prefix}users_remember_me_cookies";
  51. $this->time = is_numeric($time) ? (int)$time : time();
  52. }
  53. /**
  54. * Make the user's login persistent
  55. *
  56. * @param \ElggUser $user The user who logged in
  57. *
  58. * @return void
  59. */
  60. public function makeLoginPersistent(\ElggUser $user) {
  61. $token = $this->generateToken();
  62. $hash = $this->hashToken($token);
  63. $this->storeHash($user, $hash);
  64. $this->setCookie($token);
  65. $this->setSession($token);
  66. }
  67. /**
  68. * Remove the persisted login token from client and server
  69. *
  70. * @return void
  71. */
  72. public function removePersistentLogin() {
  73. if ($this->cookie_token) {
  74. $client_hash = $this->hashToken($this->cookie_token);
  75. $this->removeHash($client_hash);
  76. }
  77. $this->setCookie("");
  78. $this->setSession("");
  79. }
  80. /**
  81. * Handle a password change
  82. *
  83. * @param \ElggUser $subject The user whose password changed
  84. * @param \ElggUser $modifier The user who changed the password
  85. *
  86. * @return void
  87. */
  88. public function handlePasswordChange(\ElggUser $subject, \ElggUser $modifier = null) {
  89. $this->removeAllHashes($subject);
  90. if (!$modifier || ($modifier->guid !== $subject->guid) || !$this->cookie_token) {
  91. return;
  92. }
  93. $this->makeLoginPersistent($modifier);
  94. }
  95. /**
  96. * Boot the persistent login session, possibly returning the user who should be
  97. * silently logged in.
  98. *
  99. * @return \ElggUser|null
  100. */
  101. public function bootSession() {
  102. if (!$this->cookie_token) {
  103. return null;
  104. }
  105. // is this token good?
  106. $cookie_hash = $this->hashToken($this->cookie_token);
  107. $user = $this->getUserFromHash($cookie_hash);
  108. if ($user) {
  109. $this->setSession($this->cookie_token);
  110. // note: if the token is legacy, we don't both replacing it here because
  111. // it will be replaced during the next request boot
  112. return $user;
  113. } else {
  114. if ($this->isLegacyToken($this->cookie_token)) {
  115. // may be attempt to brute force legacy low-entropy tokens
  116. call_user_func($this->_callable_sleep, 1);
  117. }
  118. $this->setCookie('');
  119. }
  120. }
  121. /**
  122. * Replace the user's token if it's a legacy hexadecimal token
  123. *
  124. * @param \ElggUser $logged_in_user The logged in user
  125. *
  126. * @return void
  127. */
  128. public function replaceLegacyToken(\ElggUser $logged_in_user) {
  129. if (!$this->cookie_token || !$this->isLegacyToken($this->cookie_token)) {
  130. return;
  131. }
  132. // replace user's old weaker-entropy code with new one
  133. $this->removeHash($this->hashToken($this->cookie_token));
  134. $this->makeLoginPersistent($logged_in_user);
  135. }
  136. /**
  137. * Find a user with the given hash
  138. *
  139. * @param string $hash The hashed token
  140. *
  141. * @return \ElggUser|null
  142. */
  143. public function getUserFromHash($hash) {
  144. if (!$hash) {
  145. return null;
  146. }
  147. $hash = $this->db->sanitizeString($hash);
  148. $query = "SELECT guid FROM {$this->table} WHERE code = '$hash'";
  149. try {
  150. $user_row = $this->db->getDataRow($query);
  151. } catch (\DatabaseException $e) {
  152. return $this->handleDbException($e);
  153. }
  154. if (!$user_row) {
  155. return null;
  156. }
  157. $user = call_user_func($this->_callable_get_user, $user_row->guid);
  158. return $user ? $user : null;
  159. }
  160. /**
  161. * Store a hash in the DB
  162. *
  163. * @param \ElggUser $user The user for whom we're storing the hash
  164. * @param string $hash The hashed token
  165. *
  166. * @return void
  167. */
  168. protected function storeHash(\ElggUser $user, $hash) {
  169. // This prevents inserting the same hash twice, which seems to be happening in some rare cases
  170. // and for unknown reasons. See https://github.com/Elgg/Elgg/issues/8104
  171. $this->removeHash($hash);
  172. $time = time();
  173. $hash = $this->db->sanitizeString($hash);
  174. $query = "
  175. INSERT INTO {$this->table} (code, guid, timestamp)
  176. VALUES ('$hash', {$user->guid}, $time)
  177. ";
  178. try {
  179. $this->db->insertData($query);
  180. } catch (\DatabaseException $e) {
  181. $this->handleDbException($e);
  182. }
  183. }
  184. /**
  185. * Remove a hash from the DB
  186. *
  187. * @param string $hash The hashed token to remove (unused before 1.9)
  188. * @return void
  189. */
  190. protected function removeHash($hash) {
  191. $hash = $this->db->sanitizeString($hash);
  192. $query = "DELETE FROM {$this->table} WHERE code = '$hash'";
  193. try {
  194. $this->db->deleteData($query);
  195. } catch (\DatabaseException $e) {
  196. $this->handleDbException($e);
  197. }
  198. }
  199. /**
  200. * Swallow a schema not upgraded exception, otherwise rethrow it
  201. *
  202. * @param \DatabaseException $exception The exception to handle
  203. * @param string $default The value to return if the table doesn't exist yet
  204. *
  205. * @return mixed
  206. *
  207. * @throws \DatabaseException
  208. */
  209. protected function handleDbException(\DatabaseException $exception, $default = null) {
  210. if (false !== strpos($exception->getMessage(), "users_remember_me_cookies' doesn't exist")) {
  211. // schema has not been updated so we swallow this exception
  212. return $default;
  213. } else {
  214. throw $exception;
  215. }
  216. }
  217. /**
  218. * Remove all the hashes associated with a user
  219. *
  220. * @param \ElggUser $user The user for whom we're removing hashes
  221. *
  222. * @return void
  223. */
  224. protected function removeAllHashes(\ElggUser $user) {
  225. $query = "DELETE FROM {$this->table} WHERE guid = '{$user->guid}'";
  226. try {
  227. $this->db->deleteData($query);
  228. } catch (\DatabaseException $e) {
  229. $this->handleDbException($e);
  230. }
  231. }
  232. /**
  233. * Create a hash from the token
  234. *
  235. * @param string $token The token to hash
  236. *
  237. * @return string
  238. */
  239. protected function hashToken($token) {
  240. // note: with user passwords, you'd want legit password hashing, but since these are randomly
  241. // generated and long tokens, rainbow tables aren't any help.
  242. return md5($token);
  243. }
  244. /**
  245. * Store the token in the client cookie (or remove the cookie)
  246. *
  247. * @param string $token Empty string to remove cookie
  248. *
  249. * @return void
  250. */
  251. protected function setCookie($token) {
  252. $cookie = new \ElggCookie($this->cookie_config['name']);
  253. foreach (array('expire', 'path', 'domain', 'secure', 'httponly') as $key) {
  254. $cookie->$key = $this->cookie_config[$key];
  255. }
  256. $cookie->value = $token;
  257. if (!$token) {
  258. $cookie->expire = $this->time - (86400 * 30);
  259. }
  260. call_user_func($this->_callable_elgg_set_cookie, $cookie);
  261. }
  262. /**
  263. * Store the token in the session (or remove it from the session)
  264. *
  265. * @param string $token The token to store in session. Empty string to remove.
  266. *
  267. * @return void
  268. */
  269. protected function setSession($token) {
  270. if ($token) {
  271. $this->session->set('code', $token);
  272. } else {
  273. $this->session->remove('code');
  274. }
  275. }
  276. /**
  277. * Generate a random token (base 64 URL)
  278. *
  279. * The first char is always "z" to indicate the value has more entropy than the
  280. * previously generated ones.
  281. *
  282. * @return string
  283. */
  284. protected function generateToken() {
  285. return 'z' . $this->crypto->getRandomString(31);
  286. }
  287. /**
  288. * Is the given token a legacy MD5 hash?
  289. *
  290. * @param string $token The token to analyze
  291. *
  292. * @return bool
  293. */
  294. protected function isLegacyToken($token) {
  295. return (isset($token[0]) && $token[0] !== 'z');
  296. }
  297. /**
  298. * @var Database
  299. */
  300. protected $db;
  301. /**
  302. * @var string
  303. */
  304. protected $table;
  305. /**
  306. * @var array
  307. */
  308. protected $cookie_config;
  309. /**
  310. * @var string
  311. */
  312. protected $cookie_token;
  313. /**
  314. * @var \ElggSession
  315. */
  316. protected $session;
  317. /**
  318. * @var \ElggCrypto
  319. */
  320. protected $crypto;
  321. /**
  322. * @var int
  323. */
  324. protected $time;
  325. /**
  326. * DO NOT USE. For unit test mocking
  327. * @access private
  328. */
  329. public $_callable_get_user = 'get_user';
  330. /**
  331. * DO NOT USE. For unit test mocking
  332. * @access private
  333. */
  334. public $_callable_elgg_set_cookie = 'elgg_set_cookie';
  335. /**
  336. * DO NOT USE. For unit test mocking
  337. * @access private
  338. */
  339. public $_callable_sleep = 'sleep';
  340. }