PageRenderTime 77ms CodeModel.GetById 49ms RepoModel.GetById 1ms app.codeStats 0ms

/modules/auth_profiles/models/auth_login.php

https://github.com/mozilla/plugindir
PHP | 729 lines | 433 code | 74 blank | 222 comment | 62 complexity | e2b112ce6e403baeac08b803ea4d3818 MD5 | raw file
Possible License(s): MPL-2.0-no-copyleft-exception, BSD-3-Clause
  1. <?php
  2. /**
  3. * Login model
  4. *
  5. * @package auth_profiles
  6. * @subpackage models
  7. * @author l.m.orchard <l.m.orchard@pobox.com>
  8. */
  9. class Auth_Login_Model extends ORM implements Zend_Acl_Resource_Interface
  10. {
  11. // {{{ Model attributes
  12. // Titles for named columns
  13. public $table_column_titles = array(
  14. 'id' => 'ID',
  15. 'login_name' => 'Login name',
  16. 'active' => 'Active',
  17. 'email' => 'Email',
  18. 'password' => 'Password',
  19. 'last_login' => 'Last login',
  20. 'modified' => 'Modified',
  21. 'created' => 'Created',
  22. );
  23. public $has_and_belongs_to_many = array('profiles');
  24. protected $_table_name_password_reset_token =
  25. 'login_password_reset_tokens';
  26. protected $_table_name_email_verification_token =
  27. 'login_email_verification_tokens';
  28. // }}}
  29. /**
  30. * One-way hash a plaintext password, both for storage and comparison
  31. * purposes.
  32. *
  33. * @param string cleartext password
  34. * @return string encrypted password
  35. */
  36. public function hash_password($password, $salt=null, $algo='SHA-256')
  37. {
  38. if ('SHA-256' == $algo) {
  39. if (null === $salt) {
  40. // Generate a new random salt, if none provided.
  41. $salt = substr(md5(uniqid(mt_rand(), true)), 0,
  42. Kohana::config('auth_profiles.salt_length'));
  43. }
  44. return '{SHA-256}'.$salt.'-'.hash('sha256', $salt.$password);
  45. } else {
  46. return md5($password);
  47. }
  48. }
  49. /**
  50. * Accept a database password hash and attempt to parse it into algo,
  51. * salt, and hash values.
  52. *
  53. * @param string $str full DB hash
  54. * @return array algo, salt, hash
  55. */
  56. public function parse_password_hash($str)
  57. {
  58. $m = array();
  59. if (1 === preg_match('/^\{([\w-]+)\}(\w+)-(\w+)$/', $str, $m)) {
  60. // This is a hash in {ALGO}SALT-HASH form.
  61. return array( $m[1], $m[2], $m[3] );
  62. } else {
  63. // Assume this is a legacy MD5 password hash.
  64. return array( 'MD5', null, $str );
  65. }
  66. }
  67. /**
  68. * Check a given plaintext password against a full DB hash.
  69. *
  70. * @param string $password plaintext password
  71. * @param string $db_full_hash full hash string from DB
  72. * @return boolean
  73. */
  74. public function check_password($password, $db_full_hash)
  75. {
  76. list($algo, $salt, $hash) = $this->parse_password_hash($db_full_hash);
  77. $password_full_hash = $this->hash_password($password, $salt, $algo);
  78. return ($password_full_hash === $db_full_hash);
  79. }
  80. /**
  81. * Returns the unique key for a specific value. This method is expected
  82. * to be overloaded in models if the model has other unique columns.
  83. *
  84. * If the key used in a find is a non-numeric string, search 'login_name' column.
  85. *
  86. * @param mixed unique value
  87. * @return string
  88. */
  89. public function unique_key($id)
  90. {
  91. if (!empty($id) && is_string($id) && !ctype_digit($id)) {
  92. return 'login_name';
  93. }
  94. return parent::unique_key($id);
  95. }
  96. /**
  97. * Before saving, update created/modified timestamps and generate a UUID if
  98. * necessary.
  99. *
  100. * @chainable
  101. * @return ORM
  102. */
  103. public function save()
  104. {
  105. // Never allow password changes without going through change_password()
  106. unset($this->password);
  107. return parent::save();
  108. }
  109. /**
  110. * Perform anything necessary for login on the model side.
  111. *
  112. * @param array|Validation Form data (if any) used in login
  113. * @return boolean
  114. */
  115. public function login($data=null)
  116. {
  117. $this->failed_login_count = 0;
  118. $this->last_login = gmdate('c');
  119. $this->save();
  120. return true;
  121. }
  122. /**
  123. * Record a failed login, for eventual purposes of account lockout.
  124. *
  125. * @chainable
  126. * @return Auth_Login_Model
  127. */
  128. public function record_failed_login()
  129. {
  130. $was_locked_out = $this->is_locked_out();
  131. $this->failed_login_count = $this->failed_login_count + 1;
  132. $this->last_failed_login = gmdate('c');
  133. $this->save();
  134. if (!$was_locked_out && $this->is_locked_out()) {
  135. $profile = $this->find_default_profile_for_login();
  136. if ($profile->loaded && 'admin' == $profile->role) {
  137. cef_logging::log(
  138. cef_logging::ADMIN_ACCOUNT_LOCKED,
  139. 'Admin Account Locked', 9,
  140. array( 'suser' => $this->login_name )
  141. );
  142. } else {
  143. cef_logging::log(
  144. cef_logging::ACCOUNT_LOCKED,
  145. 'Account Locked', 5,
  146. array( 'suser' => $this->login_name )
  147. );
  148. }
  149. }
  150. return $this;
  151. }
  152. /**
  153. * Determine whether the login is locked out per the configured
  154. * threshold of failed logins and the lockout period with respect
  155. * to the last failed login.
  156. *
  157. * @return boolean
  158. */
  159. public function is_locked_out()
  160. {
  161. // HACK: Switch to UTC for date parsing since all MySQL times
  162. // should be in UTC
  163. $old_tz = date_default_timezone_get();
  164. date_default_timezone_set('UTC');
  165. $time_now =
  166. time();
  167. $last_failed_login =
  168. strtotime($this->last_failed_login, $time_now);
  169. $lockout_threshold =
  170. Kohana::config('auth_profiles.max_failed_logins');
  171. $lockout_period =
  172. Kohana::config('auth_profiles.account_lockout_period');
  173. $is_locked_out =
  174. $this->failed_login_count >= $lockout_threshold &&
  175. $time_now < ( $last_failed_login + $lockout_period );
  176. // HACK: Restore original time zone default.
  177. date_default_timezone_set($old_tz);
  178. return $is_locked_out;
  179. }
  180. /**
  181. * Find the default profile for this login, usually the first registered.
  182. * @TODO: Change point for future multiple profiles per login
  183. */
  184. public function find_default_profile_for_login()
  185. {
  186. if (!$this->loaded) return null;
  187. $profiles = $this->profiles;
  188. return $profiles[0];
  189. }
  190. /**
  191. * Set the password reset token for a given login and return the value
  192. * used.
  193. *
  194. * @param string login ID
  195. * @return string password reset string
  196. */
  197. public function set_password_reset_token()
  198. {
  199. if (!$this->loaded) return;
  200. $token = md5(uniqid(mt_rand(), true));
  201. $this->db->delete(
  202. $this->_table_name_password_reset_token,
  203. array( 'login_id' => $this->id )
  204. );
  205. $rv = $this->db->insert(
  206. $this->_table_name_password_reset_token,
  207. array(
  208. 'login_id' => $this->id,
  209. 'token' => $token
  210. )
  211. );
  212. return $token;
  213. }
  214. /**
  215. * Change password for a login.
  216. * The password reset token, if any, is cleared as well.
  217. *
  218. * @param string login id
  219. * @param string new password value
  220. * @return boolean whether or not a password was changed
  221. */
  222. public function change_password($new_password)
  223. {
  224. if (!$this->loaded) return;
  225. $full_hash = $this->hash_password($new_password);
  226. $this->db->delete(
  227. $this->_table_name_password_reset_token,
  228. array( 'login_id' => $this->id )
  229. );
  230. $rows = $this->db->update(
  231. 'logins',
  232. array('password'=>$full_hash),
  233. array('id'=>$this->id)
  234. );
  235. return !empty($rows);
  236. }
  237. /**
  238. * Set the password reset token for a given login and return the value
  239. * used.
  240. *
  241. * @param string login ID
  242. * @return string password reset string
  243. */
  244. public function generate_email_verification_token($email=null)
  245. {
  246. if (!$this->loaded) return;
  247. if (empty($email)) $email = $this->email;
  248. $token = md5(uniqid(mt_rand(), true));
  249. $rv = $this->db->insert(
  250. $this->_table_name_email_verification_token,
  251. array(
  252. 'login_id' => $this->id,
  253. 'token' => $token,
  254. 'value' => $email,
  255. )
  256. );
  257. return $token;
  258. }
  259. /**
  260. * Return the value and token for a pending email verification, if any.
  261. *
  262. * @return object Object with properties value and token.
  263. */
  264. public function get_email_verification($email=null)
  265. {
  266. $q = $this->db
  267. ->select('value, token')
  268. ->from($this->_table_name_email_verification_token)
  269. ->where('login_id', $this->id);
  270. if (null === $email) {
  271. $q->where('value <>', $this->email);
  272. }
  273. $row = $q->get()->current();
  274. return (empty($row)) ? null : $row;
  275. }
  276. /**
  277. * Change email for a login, using a verification token.
  278. *
  279. * @param string login id
  280. * @param string new email value
  281. * @return boolean whether or not a email was changed
  282. */
  283. public function change_email_with_verification_token($token)
  284. {
  285. list($login, $new_email, $token_id) =
  286. $this->find_by_email_verification_token($token);
  287. if (empty($login) || empty($new_email)) {
  288. return array(null, null, null);
  289. }
  290. $old_email = $login->email;
  291. $login->email = $new_email;
  292. $login->save();
  293. // Delete this token, and all tokens created after this one.
  294. // HACK: Relies on auto-increment ID column.
  295. $this->db
  296. ->where(array(
  297. 'login_id' => $login->id,
  298. 'id >=' => $token_id
  299. ))
  300. ->delete($this->_table_name_email_verification_token);
  301. cef_logging::log('000', 'Email changed', 5, array(
  302. 'suser' => $login->login_name,
  303. 'email_old' => $old_email,
  304. 'email_new' => $new_email
  305. ));
  306. return array($login, $new_email, $token_id);
  307. }
  308. /**
  309. * Find by password reset token
  310. *
  311. * @param string token value
  312. * @return Login_Model
  313. */
  314. public function find_by_password_reset_token($token)
  315. {
  316. return ORM::factory('login')
  317. ->join(
  318. $this->_table_name_password_reset_token,
  319. "{$this->_table_name_password_reset_token}.login_id",
  320. "{$this->table_name}.id"
  321. )
  322. ->where(
  323. "{$this->_table_name_password_reset_token}.token",
  324. $token
  325. )
  326. ->find();
  327. }
  328. /**
  329. * Find by email verification token
  330. *
  331. * @param string token value
  332. * @return Login_Model
  333. */
  334. public function find_by_email_verification_token($token)
  335. {
  336. $row = $this->db
  337. ->select('value, login_id, id')
  338. ->from($this->_table_name_email_verification_token)
  339. ->where('token', $token)
  340. ->get()->current();
  341. if (!$row) {
  342. return array(null, null, null);
  343. } else {
  344. return array(
  345. ORM::factory('login', $row->login_id),
  346. $row->value,
  347. $row->id
  348. );
  349. }
  350. }
  351. /**
  352. * Replace incoming data with login validator and return whether
  353. * validation was successful.
  354. *
  355. * Build and return a validator for the login form
  356. *
  357. * @param array Form data to validate
  358. * @return boolean Validation success
  359. */
  360. public function validate_login(&$data)
  361. {
  362. // Validate the login form data itself.
  363. $data = Validation::factory($data)
  364. ->pre_filter('trim')
  365. ->add_rules('crumb', 'csrf_crumbs::validate')
  366. ->add_rules('login_name', 'required', 'length[3,64]',
  367. 'valid::alpha_dash', array($this, 'is_login_name_registered'))
  368. ->add_rules('password', 'required')
  369. ->add_callbacks('password', array($this, 'is_password_correct'))
  370. ;
  371. $is_valid = $data->validate();
  372. // Try loading the login object if possible
  373. $login = ORM::factory('login', $data['login_name']);
  374. if ($login->loaded) {
  375. if (!$login->active) {
  376. // Flag a disabled account.
  377. $data->add_error('inactive', 'inactive');
  378. $is_valid = false;
  379. }
  380. if (empty($login->email)) {
  381. // Flag unverified login.
  382. $data->add_error('email_unverified', 'email_unverified');
  383. $is_valid = false;
  384. }
  385. if ($data->errors('password')) {
  386. // If the form data wasn't valid, record a failed login.
  387. $login->record_failed_login();
  388. }
  389. if ($login->is_locked_out()) {
  390. // If the login has been locked out after failed logins, flag
  391. // as invalid no matter what.
  392. $data->add_error('locked_out', 'locked_out');
  393. $is_valid = false;
  394. }
  395. if (!$is_valid) {
  396. $profile = $login->find_default_profile_for_login();
  397. if ($profile->loaded && 'admin' == $profile->role) {
  398. cef_logging::log(
  399. cef_logging::ACCESS_CONTROL_VIOLATION,
  400. 'Admin Invalid Login', 5,
  401. array( 'suser' => $login->login_name )
  402. );
  403. } else {
  404. cef_logging::log(
  405. cef_logging::ACCESS_CONTROL_VIOLATION,
  406. 'Invalid Login', 7,
  407. array( 'suser' => $login->login_name )
  408. );
  409. }
  410. }
  411. }
  412. return $is_valid;
  413. }
  414. /**
  415. * Replace incoming data with change password validator and return whether
  416. * validation was successful, using old password.
  417. *
  418. * @param array Form data to validate
  419. * @return boolean Validation success
  420. */
  421. public function validate_change_email(&$data)
  422. {
  423. $data = Validation::factory($data)
  424. ->pre_filter('trim')
  425. ->add_rules('crumb', 'csrf_crumbs::validate')
  426. ->add_callbacks('password',
  427. array($this, 'is_password_correct'))
  428. ->add_rules('new_email',
  429. 'required', 'length[3,255]', 'valid::email',
  430. array($this, 'is_email_available'))
  431. ;
  432. return $data->validate();
  433. }
  434. /**
  435. * Replace incoming data with change password validator and return whether
  436. * validation was successful, using old password.
  437. *
  438. * @param array Form data to validate
  439. * @return boolean Validation success
  440. */
  441. public function validate_change_password(&$data)
  442. {
  443. $data = Validation::factory($data)
  444. ->pre_filter('trim')
  445. ->add_rules('crumb', 'csrf_crumbs::validate')
  446. ->add_rules('old_password', 'required')
  447. ->add_callbacks('old_password',
  448. array($this, 'is_password_correct'))
  449. ->add_rules('new_password',
  450. 'required', 'length[8,255]',
  451. array($this, 'is_password_acceptable'))
  452. ->add_rules('new_password_confirm',
  453. 'required', 'matches[new_password]')
  454. ;
  455. return $data->validate();
  456. }
  457. /**
  458. * Replace incoming data with change password validator and return whether
  459. * validation was successful, using old password.
  460. *
  461. * @param array Form data to validate
  462. * @return boolean Validation success
  463. */
  464. public function validate_force_change_password(&$data)
  465. {
  466. $data = Validation::factory($data)
  467. ->pre_filter('trim')
  468. ->add_rules('crumb', 'csrf_crumbs::validate')
  469. ->add_rules('new_password',
  470. 'required', 'length[8,255]',
  471. array($this, 'is_password_acceptable'))
  472. ->add_rules('new_password_confirm',
  473. 'required', 'matches[new_password]')
  474. ;
  475. return $data->validate();
  476. }
  477. /**
  478. * Replace incoming data with change password validator and return whether
  479. * validation was successful, using forgot password token.
  480. *
  481. * @param array Form data to validate
  482. * @return boolean Validation success
  483. */
  484. public function validate_change_password_with_token(&$data)
  485. {
  486. $data = Validation::factory($data)
  487. ->pre_filter('trim')
  488. ->add_rules('password_reset_token',
  489. array($this, 'is_password_reset_token_valid'))
  490. ->add_rules('new_password',
  491. 'required', 'length[8,255]',
  492. array($this, 'is_password_acceptable'))
  493. ->add_rules('new_password_confirm',
  494. 'required', 'matches[new_password]')
  495. ;
  496. return $data->validate();
  497. }
  498. /**
  499. * Replace incoming data with forgot password validator and return whether
  500. * validation was successful.
  501. *
  502. * @param array Form data to validate
  503. * @return boolean Validation success
  504. */
  505. public function validate_forgot_password(&$data)
  506. {
  507. $data = Validation::factory($data)
  508. ->pre_filter('trim')
  509. ->add_rules('crumb', 'csrf_crumbs::validate')
  510. ->add_rules('login_name', 'length[3,64]', 'valid::alpha_dash')
  511. ->add_rules('email', 'valid::email')
  512. ->add_callbacks('login_name', array($this, 'need_login_name_or_email'))
  513. ->add_callbacks('email', array($this, 'need_login_name_or_email'))
  514. ;
  515. return $data->validate();
  516. }
  517. /**
  518. * Check to see whether a login name is available, for use in form
  519. * validator.
  520. */
  521. public function is_login_name_available($login_name)
  522. {
  523. if ($this->loaded && $login_name == $this->login_name) {
  524. return true;
  525. }
  526. $count = $this->db
  527. ->where('login_name', $login_name)
  528. ->count_records($this->table_name);
  529. return (0==$count);
  530. }
  531. /**
  532. * Check to see whether a login name has been registered, for use in form
  533. * validator.
  534. */
  535. public function is_login_name_registered($name)
  536. {
  537. return !($this->is_login_name_available($name));
  538. }
  539. /**
  540. * Check to see whether a given email address has been registered to a
  541. * login, for use in form validation.
  542. */
  543. public function is_email_available($email) {
  544. if ($this->loaded && $email == $this->email) {
  545. return true;
  546. }
  547. $count = $this->db
  548. ->where('email', $email)
  549. ->count_records($this->table_name);
  550. return (0==$count);
  551. }
  552. /**
  553. * Check to see whether a given email address has been registered to a
  554. * login, for use in form validation.
  555. */
  556. public function is_email_registered($email) {
  557. return !($this->is_email_available($email));
  558. }
  559. /**
  560. * Check to see whether password is acceptably strong enough for us.
  561. */
  562. public function is_password_acceptable($passwd)
  563. {
  564. if (strlen($passwd) < 8) {
  565. // greater than 8 characters
  566. return false;
  567. }
  568. if (preg_match('/\d/', $passwd) == 0) {
  569. // includes at least 1 number
  570. return false;
  571. }
  572. if (preg_match('/\W/', $passwd) == 0) {
  573. // require at least one character not in [0-9a-zA-Z_]
  574. return false;
  575. }
  576. return true;
  577. }
  578. /**
  579. * Check to see whether a password is correct, for use in form
  580. * validator.
  581. */
  582. public function is_password_correct($valid, $field)
  583. {
  584. $login_name = (isset($valid['login_name'])) ?
  585. $valid['login_name'] : authprofiles::get_login('login_name');
  586. $row = $this->db
  587. ->select('password')
  588. ->from($this->table_name)
  589. ->where('login_name', $login_name)
  590. ->get()->current();
  591. if (empty($row->password)) {
  592. // No password found for this login name
  593. $valid->add_error($field, 'invalid');
  594. } else if (!$this->check_password($valid[$field], $row->password)) {
  595. // Password for this login name is invalid.
  596. $valid->add_error($field, 'invalid');
  597. } else {
  598. // Password is correct, but does it need to be migrated?
  599. // TODO: Disable this with a config setting?
  600. list($algo, $salt, $hash) =
  601. $this->parse_password_hash($row->password);
  602. if ('MD5' == $algo) {
  603. // Migrate the legacy MD5 password to new-style salted hash.
  604. ORM::factory('login', $login_name)
  605. ->change_password($valid[$field]);
  606. }
  607. }
  608. }
  609. /**
  610. * Check whether the given password token is valid.
  611. *
  612. * @param string password reset token
  613. * @return boolean
  614. */
  615. public function is_password_reset_token_valid($token)
  616. {
  617. $count = $this->db
  618. ->where('token', $token)
  619. ->count_records($this->_table_name_password_reset_token);
  620. return !(0==$count);
  621. }
  622. /**
  623. * Enforce that either an existing login name or email address is supplied
  624. * in forgot password validation.
  625. */
  626. public function need_login_name_or_email($valid, $field)
  627. {
  628. $login_name = (isset($valid['login_name'])) ?
  629. $valid['login_name'] : null;
  630. $email = (isset($valid['email'])) ?
  631. $valid['email'] : null;
  632. if (empty($login_name) && empty($email)) {
  633. return $valid->add_error($field, 'either');
  634. }
  635. if ('login_name' == $field && !empty($login_name)) {
  636. if (!$this->is_login_name_registered($login_name)) {
  637. $valid->add_error($field, 'default');
  638. }
  639. }
  640. if ('email' == $field && !empty($email)) {
  641. if (!$this->is_email_registered($email)) {
  642. $valid->add_error($field, 'default');
  643. }
  644. }
  645. }
  646. /**
  647. * Identify this model as a resource for Zend_ACL
  648. *
  649. * @return string
  650. */
  651. public function getResourceId()
  652. {
  653. return 'login';
  654. }
  655. }