PasswordableBehavior.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. <?php
  2. App::uses('ModelBehavior', 'Model');
  3. App::uses('Security', 'Utility');
  4. App::uses('PasswordHasherFactory', 'Shim.Controller/Component/Auth');
  5. // @deprecated Use Configure settings instead.
  6. if (!defined('PWD_MIN_LENGTH')) {
  7. define('PWD_MIN_LENGTH', 6);
  8. }
  9. if (!defined('PWD_MAX_LENGTH')) {
  10. define('PWD_MAX_LENGTH', 20);
  11. }
  12. /**
  13. * A CakePHP2 behavior to work with passwords the easy way
  14. * - complete validation
  15. * - hashing of password
  16. * - requires fields (no tempering even without security component)
  17. * - usable for edit forms (require=>false for optional password update)
  18. *
  19. * Usage: Do NOT add it via $actAs = array()
  20. * attach it dynamically in only those actions where you actually change the password like so:
  21. * $this->User->Behaviors->load('Tools.Passwordable', array(SETTINGSARRAY));
  22. * as first line in any action where you want to allow the user to change his password
  23. * also add the two form fields in the form (pwd, pwd_confirm)
  24. * the rest is cake automagic :)
  25. *
  26. * Also note that you can apply global settings via Configure key 'Passwordable', as well,
  27. * if you don't want to manually pass them along each time you use the behavior. This also
  28. * keeps the code clean and lean.
  29. *
  30. * Now also is capable of:
  31. * - Require current password prior to altering it (current=>true)
  32. * - Don't allow the same password it was before (allowSame=>false)
  33. * - Support different auth types and password hashing algorythms
  34. * - PasswordHasher support
  35. * - Tools.Modern PasswordHasher and password_hash()/password_verify() support
  36. * - Option to use complex validation rule (regex)
  37. *
  38. * @version 1.9
  39. * @author Mark Scherer
  40. * @link http://www.dereuromark.de/2011/08/25/working-with-passwords-in-cakephp
  41. * @license http://opensource.org/licenses/mit-license.php MIT
  42. */
  43. class PasswordableBehavior extends ModelBehavior {
  44. /**
  45. * @var array
  46. */
  47. protected $_defaultConfig = [
  48. 'field' => 'password',
  49. 'confirm' => true, // Set to false if in admin view and no confirmation (pwd_repeat) is required
  50. 'require' => true, // If a password change is required (set to false for edit forms, leave it true for pure password update forms)
  51. 'allowEmpty' => false, // Deprecated, do NOT use anymore! Use require instead!
  52. 'current' => false, // Enquire the current password for security purposes
  53. 'formField' => 'pwd',
  54. 'formFieldRepeat' => 'pwd_repeat',
  55. 'formFieldCurrent' => 'pwd_current',
  56. 'userModel' => null, // Defaults to User
  57. 'hashType' => null, // Only for authType Form [Cake2.3]
  58. 'hashSalt' => true, // Only for authType Form [Cake2.3]
  59. 'auth' => null, // Which component (defaults to AuthComponent),
  60. 'authType' => 'Form', // Which type of authenticate (Form, Blowfish, ...) [Cake2.4+]
  61. 'passwordHasher' => null, // If a custom pwd hasher is been used [Cake2.4+]
  62. 'allowSame' => true, // Don't allow the old password on change
  63. 'minLength' => PWD_MIN_LENGTH,
  64. 'maxLength' => PWD_MAX_LENGTH,
  65. 'customValidation' => null // Custom validation rule(s) for the formField
  66. ];
  67. /**
  68. * @var array
  69. */
  70. protected $_validationRules = [];
  71. /**
  72. * Adding validation rules
  73. * also adds and merges config settings (direct + configure)
  74. *
  75. * @return void
  76. */
  77. public function setup(Model $Model, $config = []) {
  78. $this->_validationRules = [
  79. 'formField' => [
  80. 'between' => [
  81. 'rule' => ['between', PWD_MIN_LENGTH, PWD_MAX_LENGTH],
  82. 'message' => __d('tools', 'valErrBetweenCharacters %s %s', PWD_MIN_LENGTH, PWD_MAX_LENGTH),
  83. 'allowEmpty' => null,
  84. 'last' => true,
  85. ]
  86. ],
  87. 'formFieldRepeat' => [
  88. 'validateNotEmpty' => [
  89. 'rule' => ['notBlank'],
  90. 'message' => __d('tools', 'valErrPwdRepeat'),
  91. 'allowEmpty' => true,
  92. 'last' => true,
  93. ],
  94. 'validateIdentical' => [
  95. 'rule' => ['validateIdentical', 'formField'],
  96. 'message' => __d('tools', 'valErrPwdNotMatch'),
  97. 'allowEmpty' => null,
  98. 'last' => true,
  99. ],
  100. ],
  101. 'formFieldCurrent' => [
  102. 'notBlank' => [
  103. 'rule' => ['notBlank'],
  104. 'message' => __d('tools', 'valErrProvideCurrentPwd'),
  105. 'allowEmpty' => null,
  106. 'last' => true,
  107. ],
  108. 'validateCurrentPwd' => [
  109. 'rule' => 'validateCurrentPwd',
  110. 'message' => __d('tools', 'valErrCurrentPwdIncorrect'),
  111. 'allowEmpty' => null,
  112. 'last' => true,
  113. ]
  114. ]
  115. ];
  116. $defaults = $this->_defaultConfig;
  117. if ($configureDefaults = Configure::read('Passwordable')) {
  118. $defaults = $configureDefaults + $defaults;
  119. }
  120. $this->settings[$Model->alias] = $config + $defaults;
  121. // BC comp
  122. if ($this->settings[$Model->alias]['allowEmpty']) {
  123. $this->settings[$Model->alias]['require'] = false;
  124. }
  125. $formField = $this->settings[$Model->alias]['formField'];
  126. $formFieldRepeat = $this->settings[$Model->alias]['formFieldRepeat'];
  127. $formFieldCurrent = $this->settings[$Model->alias]['formFieldCurrent'];
  128. if ($formField === $this->settings[$Model->alias]['field']) {
  129. throw new CakeException('Invalid setup - the form field must to be different from the model field (' . $this->settings[$Model->alias]['field'] . ').');
  130. }
  131. $rules = $this->_validationRules;
  132. foreach ($rules as $field => $fieldRules) {
  133. foreach ($fieldRules as $key => $rule) {
  134. $rule['allowEmpty'] = !$this->settings[$Model->alias]['require'];
  135. if ($key === 'between') {
  136. $rule['rule'] = ['between', $this->settings[$Model->alias]['minLength'], $this->settings[$Model->alias]['maxLength']];
  137. $rule['message'] = __d('tools', 'valErrBetweenCharacters %s %s', $this->settings[$Model->alias]['minLength'], $this->settings[$Model->alias]['maxLength']);
  138. }
  139. $fieldRules[$key] = $rule;
  140. }
  141. $rules[$field] = $fieldRules;
  142. }
  143. // Add the validation rules if not already attached
  144. if (!isset($Model->validate[$formField])) {
  145. $Model->validator()->add($formField, $rules['formField']);
  146. }
  147. if (!isset($Model->validate[$formFieldRepeat])) {
  148. $ruleSet = $rules['formFieldRepeat'];
  149. $ruleSet['validateIdentical']['rule'][1] = $formField;
  150. $Model->validator()->add($formFieldRepeat, $ruleSet);
  151. }
  152. if ($this->settings[$Model->alias]['current'] && !isset($Model->validate[$formFieldCurrent])) {
  153. $Model->validator()->add($formFieldCurrent, $rules['formFieldCurrent']);
  154. if (!$this->settings[$Model->alias]['allowSame']) {
  155. $Model->validator()->add($formField, 'validateNotSame', [
  156. 'rule' => ['validateNotSame', $formField, $formFieldCurrent],
  157. 'message' => __d('tools', 'valErrPwdSameAsBefore'),
  158. 'allowEmpty' => !$this->settings[$Model->alias]['require'],
  159. 'last' => true,
  160. ]);
  161. }
  162. } elseif (!isset($Model->validate[$formFieldCurrent])) {
  163. // Try to match the password against the hash in the DB
  164. if (!$this->settings[$Model->alias]['allowSame']) {
  165. $Model->validator()->add($formField, 'validateNotSame', [
  166. 'rule' => ['validateNotSameHash', $formField],
  167. 'message' => __d('tools', 'valErrPwdSameAsBefore'),
  168. 'allowEmpty' => !$this->settings[$Model->alias]['require'],
  169. 'last' => true,
  170. ]);
  171. }
  172. }
  173. // Add custom rule(s) if configured
  174. if ($this->settings[$Model->alias]['customValidation']) {
  175. $Model->validator()->add($formField, $this->settings[$Model->alias]['customValidation']);
  176. }
  177. }
  178. /**
  179. * Preparing the data
  180. *
  181. * @return bool Success
  182. */
  183. public function beforeValidate(Model $Model, $options = []) {
  184. $formField = $this->settings[$Model->alias]['formField'];
  185. $formFieldRepeat = $this->settings[$Model->alias]['formFieldRepeat'];
  186. $formFieldCurrent = $this->settings[$Model->alias]['formFieldCurrent'];
  187. // Make sure fields are set and validation rules are triggered - prevents tempering of form data
  188. if (!isset($Model->data[$Model->alias][$formField])) {
  189. $Model->data[$Model->alias][$formField] = '';
  190. }
  191. if ($this->settings[$Model->alias]['confirm'] && !isset($Model->data[$Model->alias][$formFieldRepeat])) {
  192. $Model->data[$Model->alias][$formFieldRepeat] = '';
  193. }
  194. if ($this->settings[$Model->alias]['current'] && !isset($Model->data[$Model->alias][$formFieldCurrent])) {
  195. $Model->data[$Model->alias][$formFieldCurrent] = '';
  196. }
  197. // Check if we need to trigger any validation rules
  198. if (!$this->settings[$Model->alias]['require']) {
  199. $current = !empty($Model->data[$Model->alias][$formFieldCurrent]);
  200. $new = !empty($Model->data[$Model->alias][$formField]) || !empty($Model->data[$Model->alias][$formFieldRepeat]);
  201. if (!$new && !$current) {
  202. //$Model->validator()->remove($formField); // tmp only!
  203. //unset($Model->validate[$formField]);
  204. unset($Model->data[$Model->alias][$formField]);
  205. if ($this->settings[$Model->alias]['confirm']) {
  206. //$Model->validator()->remove($formFieldRepeat); // tmp only!
  207. //unset($Model->validate[$formFieldRepeat]);
  208. unset($Model->data[$Model->alias][$formFieldRepeat]);
  209. }
  210. if ($this->settings[$Model->alias]['current']) {
  211. //$Model->validator()->remove($formFieldCurrent); // tmp only!
  212. //unset($Model->validate[$formFieldCurrent]);
  213. unset($Model->data[$Model->alias][$formFieldCurrent]);
  214. }
  215. return true;
  216. }
  217. // Make sure we trigger validation if allowEmpty is set but we have the password field set
  218. if ($new) {
  219. if ($this->settings[$Model->alias]['confirm'] && empty($Model->data[$Model->alias][$formFieldRepeat])) {
  220. $Model->invalidate($formFieldRepeat, __d('tools', 'valErrPwdNotMatch'));
  221. }
  222. }
  223. }
  224. // Update whitelist
  225. $this->_modifyWhitelist($Model);
  226. return true;
  227. }
  228. /**
  229. * Hashing the password and whitelisting
  230. *
  231. * @param Model $Model
  232. * @return bool Success
  233. */
  234. public function beforeSave(Model $Model, $options = []) {
  235. $formField = $this->settings[$Model->alias]['formField'];
  236. $field = $this->settings[$Model->alias]['field'];
  237. $type = $this->settings[$Model->alias]['hashType'];
  238. $salt = $this->settings[$Model->alias]['hashSalt'];
  239. if ($this->settings[$Model->alias]['authType'] === 'Blowfish') {
  240. $type = 'blowfish';
  241. $salt = false;
  242. }
  243. if (isset($Model->data[$Model->alias][$formField])) {
  244. if ($type === 'blowfish' && function_exists('password_hash') && !empty($this->settings[$Model->alias]['passwordHasher'])) {
  245. $cost = !empty($this->settings[$Model->alias]['hashCost']) ? $this->settings[$Model->alias]['hashCost'] : 10;
  246. $options = ['cost' => $cost];
  247. $PasswordHasher = $this->_getPasswordHasher($this->settings[$Model->alias]['passwordHasher']);
  248. $Model->data[$Model->alias][$field] = $PasswordHasher->hash($Model->data[$Model->alias][$formField], $options);
  249. } else {
  250. $Model->data[$Model->alias][$field] = Security::hash($Model->data[$Model->alias][$formField], $type, $salt);
  251. }
  252. if (!$Model->data[$Model->alias][$field]) {
  253. return false;
  254. }
  255. unset($Model->data[$Model->alias][$formField]);
  256. if ($this->settings[$Model->alias]['confirm']) {
  257. $formFieldRepeat = $this->settings[$Model->alias]['formFieldRepeat'];
  258. unset($Model->data[$Model->alias][$formFieldRepeat]);
  259. }
  260. if ($this->settings[$Model->alias]['current']) {
  261. $formFieldCurrent = $this->settings[$Model->alias]['formFieldCurrent'];
  262. unset($Model->data[$Model->alias][$formFieldCurrent]);
  263. }
  264. }
  265. // Update whitelist
  266. $this->_modifyWhitelist($Model, true);
  267. return true;
  268. }
  269. /**
  270. * Checks if the PasswordHasher class supports this and if so, whether the
  271. * password needs to be rehashed or not.
  272. * This is mainly supported by Tools.Modern (using Bcrypt) yet.
  273. *
  274. * @param Model $Model
  275. * @param string $hash Currently hashed password.
  276. * @return bool Success
  277. */
  278. public function needsPasswordRehash(Model $Model, $hash) {
  279. if (empty($this->settings[$Model->alias]['passwordHasher'])) {
  280. return false;
  281. }
  282. $PasswordHasher = $this->_getPasswordHasher($this->settings[$Model->alias]['passwordHasher']);
  283. if (!method_exists($PasswordHasher, 'needsRehash')) {
  284. return false;
  285. }
  286. return $PasswordHasher->needsRehash($hash);
  287. }
  288. /**
  289. * If not implemented in AppModel
  290. *
  291. * Note: requires the used Auth component to be App::uses() loaded.
  292. * It also reqires the same Auth setup as in your AppController's beforeFilter().
  293. * So if you set up any special passwordHasher or auth type, you need to provide those
  294. * with the settings passed to the behavior:
  295. *
  296. * 'authType' => 'Blowfish', 'passwordHasher' => array(
  297. * 'className' => 'Simple',
  298. * 'hashType' => 'sha256'
  299. * )
  300. *
  301. * @throws CakeException
  302. * @param Model $Model
  303. * @param array $data
  304. * @return bool Success
  305. */
  306. public function validateCurrentPwd(Model $Model, $data) {
  307. if (is_array($data)) {
  308. $pwd = array_shift($data);
  309. } else {
  310. $pwd = $data;
  311. }
  312. $uid = null;
  313. if ($Model->id) {
  314. $uid = $Model->id;
  315. } elseif (!empty($Model->data[$Model->alias]['id'])) {
  316. $uid = $Model->data[$Model->alias]['id'];
  317. } else {
  318. trigger_error('No user id given');
  319. return false;
  320. }
  321. return $this->_validateSameHash($Model, $pwd);
  322. }
  323. /**
  324. * If not implemented in AppModel
  325. *
  326. * @param Model $Model
  327. * @param array $data
  328. * @param string $compareWith String to compare field value with
  329. * @return bool Success
  330. */
  331. public function validateIdentical(Model $Model, $data, $compareWith = null) {
  332. if (is_array($data)) {
  333. $value = array_shift($data);
  334. } else {
  335. $value = $data;
  336. }
  337. $compareValue = $Model->data[$Model->alias][$compareWith];
  338. return ($compareValue === $value);
  339. }
  340. /**
  341. * If not implemented in AppModel
  342. *
  343. * @return bool Success
  344. */
  345. public function validateNotSame(Model $Model, $data, $field1, $field2) {
  346. $value1 = $Model->data[$Model->alias][$field1];
  347. $value2 = $Model->data[$Model->alias][$field2];
  348. return ($value1 !== $value2);
  349. }
  350. /**
  351. * If not implemented in AppModel
  352. *
  353. * @return bool Success
  354. */
  355. public function validateNotSameHash(Model $Model, $data, $formField) {
  356. $field = $this->settings[$Model->alias]['field'];
  357. $type = $this->settings[$Model->alias]['hashType'];
  358. $salt = $this->settings[$Model->alias]['hashSalt'];
  359. if ($this->settings[$Model->alias]['authType'] === 'Blowfish') {
  360. $type = 'blowfish';
  361. $salt = false;
  362. }
  363. if (!isset($Model->data[$Model->alias][$Model->primaryKey])) {
  364. return true;
  365. }
  366. $primaryKey = $Model->data[$Model->alias][$Model->primaryKey];
  367. if ($type === 'blowfish' && function_exists('password_hash') && !empty($this->settings[$Model->alias]['passwordHasher'])) {
  368. $value = $Model->data[$Model->alias][$formField];
  369. } else {
  370. $value = Security::hash($Model->data[$Model->alias][$formField], $type, $salt);
  371. }
  372. $dbValue = $Model->fieldByConditions($field, [$Model->primaryKey => $primaryKey]);
  373. if (!$dbValue) {
  374. return true;
  375. }
  376. if ($type === 'blowfish' && function_exists('password_hash') && !empty($this->settings[$Model->alias]['passwordHasher'])) {
  377. $PasswordHasher = $this->_getPasswordHasher($this->settings[$Model->alias]['passwordHasher']);
  378. return !$PasswordHasher->check($value, $dbValue);
  379. }
  380. return ($value !== $dbValue);
  381. }
  382. /**
  383. * PasswordableBehavior::_validateSameHash()
  384. *
  385. * @param Model $Model
  386. * @param string $pwd
  387. * @return bool Success
  388. */
  389. protected function _validateSameHash(Model $Model, $pwd) {
  390. $field = $this->settings[$Model->alias]['field'];
  391. $type = $this->settings[$Model->alias]['hashType'];
  392. $salt = $this->settings[$Model->alias]['hashSalt'];
  393. if ($this->settings[$Model->alias]['authType'] === 'Blowfish') {
  394. $type = 'blowfish';
  395. $salt = false;
  396. }
  397. $primaryKey = $Model->data[$Model->alias][$Model->primaryKey];
  398. $record = $Model->find('first', ['conditions' => [$Model->primaryKey => $primaryKey]]);
  399. if (empty($record[$Model->alias][$field]) && $pwd) {
  400. return false;
  401. }
  402. $dbValue = $record[$Model->alias][$field];
  403. if ($type === 'blowfish' && function_exists('password_hash') && !empty($this->settings[$Model->alias]['passwordHasher'])) {
  404. $value = $pwd;
  405. } else {
  406. if ($type === 'blowfish') {
  407. $salt = $dbValue;
  408. }
  409. $value = Security::hash($pwd, $type, $salt);
  410. }
  411. if ($type === 'blowfish' && function_exists('password_hash') && !empty($this->settings[$Model->alias]['passwordHasher'])) {
  412. $PasswordHasher = $this->_getPasswordHasher($this->settings[$Model->alias]['passwordHasher']);
  413. return $PasswordHasher->check($value, $dbValue);
  414. }
  415. return $value === $dbValue;
  416. }
  417. /**
  418. * PasswordableBehavior::_getPasswordHasher()
  419. *
  420. * @param mixed $hasher Name or options array.
  421. * @return PasswordHasher
  422. */
  423. protected function _getPasswordHasher($hasher) {
  424. return PasswordHasherFactory::build($hasher);
  425. $class = $hasher;
  426. $config = [];
  427. if (is_array($hasher)) {
  428. $class = $hasher['className'];
  429. unset($hasher['className']);
  430. $config = $hasher;
  431. }
  432. list($plugin, $class) = pluginSplit($class, true);
  433. $className = $class . 'PasswordHasher';
  434. App::uses($className, $plugin . 'Controller/Component/Auth');
  435. if (!class_exists($className)) {
  436. throw new CakeException(sprintf('Password hasher class "%s" was not found.', $class));
  437. }
  438. if (!is_subclass_of($className, 'AbstractPasswordHasher')) {
  439. throw new CakeException('Password hasher must extend AbstractPasswordHasher class.');
  440. }
  441. return new $className($config);
  442. }
  443. /**
  444. * Modify the model's whitelist.
  445. *
  446. * Since 2.5 behaviors can also modify the whitelist for validate, thus this behavior can now
  447. * (>= CakePHP 2.5) add the form fields automatically, as well (not just the password field itself).
  448. *
  449. * @param Model $Model
  450. * @return void
  451. */
  452. protected function _modifyWhitelist(Model $Model, $onSave = false) {
  453. $fields = [];
  454. if ($onSave) {
  455. $fields[] = $this->settings[$Model->alias]['field'];
  456. } else {
  457. $fields[] = $this->settings[$Model->alias]['formField'];
  458. if ($this->settings[$Model->alias]['confirm']) {
  459. $fields[] = $this->settings[$Model->alias]['formFieldRepeat'];
  460. }
  461. if ($this->settings[$Model->alias]['current']) {
  462. $fields[] = $this->settings[$Model->alias]['formFieldCurrent'];
  463. }
  464. }
  465. foreach ($fields as $field) {
  466. if (!empty($Model->whitelist) && !in_array($field, $Model->whitelist)) {
  467. $Model->whitelist = array_merge($Model->whitelist, [$field]);
  468. }
  469. }
  470. }
  471. }