PasswordableBehavior.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. <?php
  2. namespace Tools\Model\Behavior;
  3. use ArrayObject;
  4. use Cake\Auth\PasswordHasherFactory;
  5. use Cake\Core\Configure;
  6. use Cake\Event\Event;
  7. use Cake\ORM\Behavior;
  8. use Cake\ORM\Entity;
  9. use Cake\ORM\Table;
  10. use Exception;
  11. if (!defined('PWD_MIN_LENGTH')) {
  12. define('PWD_MIN_LENGTH', 6);
  13. }
  14. if (!defined('PWD_MAX_LENGTH')) {
  15. define('PWD_MAX_LENGTH', 50);
  16. }
  17. /**
  18. * A CakePHP behavior to work with passwords the easy way
  19. * - complete validation
  20. * - hashing of password
  21. * - requires fields (no tempering even without security component)
  22. * - usable for edit forms (require=>false for optional password update)
  23. *
  24. * Also capable of:
  25. * - Require current password prior to altering it (current=>true)
  26. * - Don't allow the same password it was before (allowSame=>false)
  27. *
  28. * Usage: See docs
  29. *
  30. * @author Mark Scherer
  31. * @link http://www.dereuromark.de/2011/08/25/working-with-passwords-in-cakephp
  32. * @license MIT
  33. */
  34. class PasswordableBehavior extends Behavior {
  35. /**
  36. * @var array
  37. */
  38. protected $_defaultConfig = [
  39. 'field' => 'password',
  40. 'confirm' => true, // Set to false if in admin view and no confirmation (pwd_repeat) is required
  41. 'require' => true, // If a password change is required (set to false for edit forms, leave it true for pure password update forms)
  42. 'current' => false, // Enquire the current password for security purposes
  43. 'formField' => 'pwd',
  44. 'formFieldRepeat' => 'pwd_repeat',
  45. 'formFieldCurrent' => 'pwd_current',
  46. 'passwordHasher' => 'Default', // If a custom pwd hasher is been used
  47. 'allowSame' => true, // Don't allow the old password on change
  48. 'minLength' => PWD_MIN_LENGTH,
  49. 'maxLength' => PWD_MAX_LENGTH,
  50. 'validator' => 'default',
  51. 'customValidation' => null // Custom validation rule(s) for the formField
  52. ];
  53. /**
  54. * @var array
  55. */
  56. protected $_validationRules = [
  57. 'formField' => [
  58. 'between' => [
  59. 'rule' => ['lengthBetween', PWD_MIN_LENGTH, PWD_MAX_LENGTH],
  60. 'message' => ['valErrBetweenCharacters {0} {1}', PWD_MIN_LENGTH, PWD_MAX_LENGTH],
  61. 'last' => true,
  62. //'provider' => 'table'
  63. ]
  64. ],
  65. 'formFieldRepeat' => [
  66. 'validateIdentical' => [
  67. 'rule' => ['validateIdentical', ['compare' => 'formField']],
  68. 'message' => 'valErrPwdNotMatch',
  69. 'last' => true,
  70. 'provider' => 'table'
  71. ],
  72. ],
  73. 'formFieldCurrent' => [
  74. 'notBlank' => [
  75. 'rule' => ['notBlank'],
  76. 'message' => 'valErrProvideCurrentPwd',
  77. 'last' => true,
  78. ],
  79. 'validateCurrentPwd' => [
  80. 'rule' => 'validateCurrentPwd',
  81. 'message' => 'valErrCurrentPwdIncorrect',
  82. 'last' => true,
  83. 'provider' => 'table'
  84. ]
  85. ],
  86. ];
  87. /**
  88. * Password hasher instance.
  89. *
  90. * @var \Cake\Auth\AbstractPasswordHasher
  91. */
  92. protected $_passwordHasher;
  93. /**
  94. * Adding validation rules
  95. * also adds and merges config settings (direct + configure)
  96. *
  97. * @param \Cake\ORM\Table $table
  98. * @param array $config
  99. */
  100. public function __construct(Table $table, array $config = []) {
  101. $defaults = $this->_defaultConfig;
  102. $configureDefaults = Configure::read('Passwordable');
  103. if ($configureDefaults) {
  104. $defaults = $configureDefaults + $defaults;
  105. }
  106. $config += $defaults;
  107. parent::__construct($table, $config);
  108. }
  109. /**
  110. * Constructor hook method.
  111. *
  112. * Implement this method to avoid having to overwrite
  113. * the constructor and call parent.
  114. *
  115. * @param array $config The configuration array this behavior is using.
  116. * @return void
  117. */
  118. public function initialize(array $config) {
  119. $formField = $this->_config['formField'];
  120. $formFieldRepeat = $this->_config['formFieldRepeat'];
  121. $formFieldCurrent = $this->_config['formFieldCurrent'];
  122. if ($formField === $this->_config['field']) {
  123. throw new Exception('Invalid setup - the form field must to be different from the model field (' . $this->_config['field'] . ').');
  124. }
  125. $rules = $this->_validationRules;
  126. foreach ($rules as $field => $fieldRules) {
  127. foreach ($fieldRules as $key => $rule) {
  128. //$rule['allowEmpty'] = !$this->_config['require'];
  129. if ($key === 'between') {
  130. $rule['rule'][1] = $this->_config['minLength'];
  131. $rule['message'][1] = $this->_config['minLength'];
  132. $rule['rule'][2] = $this->_config['maxLength'];
  133. $rule['message'][2] = $this->_config['maxLength'];
  134. }
  135. if (is_array($rule['message'])) {
  136. $message = array_shift($rule['message']);
  137. $rule['message'] = __d('tools', $message, $rule['message']);
  138. } else {
  139. $rule['message'] = __d('tools', $rule['message']);
  140. }
  141. $fieldRules[$key] = $rule;
  142. }
  143. $rules[$field] = $fieldRules;
  144. }
  145. $validator = $this->_table->validator($this->_config['validator']);
  146. // Add the validation rules if not already attached
  147. if (!count($validator->field($formField))) {
  148. $validator->add($formField, $rules['formField']);
  149. $validator->allowEmpty($formField, !$this->_config['require']);
  150. }
  151. if (!count($validator->field($formFieldRepeat))) {
  152. $ruleSet = $rules['formFieldRepeat'];
  153. $ruleSet['validateIdentical']['rule'][1] = $formField;
  154. $validator->add($formFieldRepeat, $ruleSet);
  155. $require = $this->_config['require'];
  156. $validator->allowEmpty($formFieldRepeat, function ($context) use ($require, $formField) {
  157. if (!$require && !empty($context['data'][$formField])) {
  158. return false;
  159. }
  160. return !$require;
  161. });
  162. }
  163. if ($this->_config['current'] && !count($validator->field($formFieldCurrent))) {
  164. $validator->add($formFieldCurrent, $rules['formFieldCurrent']);
  165. $validator->allowEmpty($formFieldCurrent, !$this->_config['require']);
  166. if (!$this->_config['allowSame']) {
  167. $validator->add($formField, 'validateNotSame', [
  168. 'rule' => ['validateNotSame', ['compare' => $formFieldCurrent]],
  169. 'message' => __d('tools', 'valErrPwdSameAsBefore'),
  170. 'last' => true,
  171. 'provider' => 'table'
  172. ]);
  173. }
  174. } elseif (!count($validator->field($formFieldCurrent))) {
  175. // Try to match the password against the hash in the DB
  176. if (!$this->_config['allowSame']) {
  177. $validator->add($formField, 'validateNotSame', [
  178. 'rule' => ['validateNotSameHash'],
  179. 'message' => __d('tools', 'valErrPwdSameAsBefore'),
  180. //'allowEmpty' => !$this->_config['require'],
  181. 'last' => true,
  182. 'provider' => 'table'
  183. ]);
  184. $validator->allowEmpty($formField, !$this->_config['require']);
  185. }
  186. }
  187. // Add custom rule(s) if configured
  188. if ($this->_config['customValidation']) {
  189. $validator->add($formField, $this->_config['customValidation']);
  190. }
  191. }
  192. /**
  193. * Preparing the data
  194. *
  195. * @param \Cake\Event\Event $event
  196. * @param \ArrayObject $data
  197. * @param \ArrayObject $options
  198. * @return void
  199. */
  200. public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options) {
  201. $formField = $this->_config['formField'];
  202. $formFieldRepeat = $this->_config['formFieldRepeat'];
  203. $formFieldCurrent = $this->_config['formFieldCurrent'];
  204. // Make sure fields are set and validation rules are triggered - prevents tempering of form data
  205. if (!isset($data[$formField])) {
  206. $data[$formField] = '';
  207. }
  208. if ($this->_config['confirm'] && !isset($data[$formFieldRepeat])) {
  209. $data[$formFieldRepeat] = '';
  210. }
  211. if ($this->_config['current'] && !isset($data[$formFieldCurrent])) {
  212. $data[$formFieldCurrent] = '';
  213. }
  214. // Check if we need to trigger any validation rules
  215. if (!$this->_config['require']) {
  216. $new = !empty($data[$formField]) || !empty($data[$formFieldRepeat]);
  217. // Make sure we trigger validation if allowEmpty is set but we have the password field set
  218. if ($new) {
  219. if ($this->_config['confirm'] && empty($data[$formFieldRepeat])) {
  220. //$entity->errors($formFieldRepeat, __d('tools', 'valErrPwdNotMatch'));
  221. }
  222. }
  223. }
  224. }
  225. /**
  226. * Preparing the data
  227. *
  228. * @param \Cake\Event\Event $event
  229. * @param \Cake\ORM\Entity $entity
  230. * @param \ArrayObject $options
  231. * @param string $operation
  232. * @return void
  233. */
  234. public function beforeRules(Event $event, Entity $entity, ArrayObject $options, $operation) {
  235. $formField = $this->_config['formField'];
  236. $formFieldRepeat = $this->_config['formFieldRepeat'];
  237. $formFieldCurrent = $this->_config['formFieldCurrent'];
  238. // Check if we need to trigger any validation rules
  239. if (!$this->_config['require']) {
  240. $current = $entity->get($formFieldCurrent);
  241. $new = $entity->get($formField) || $entity->get($formFieldRepeat);
  242. if (!$new && !$current) {
  243. //$validator->remove($formField); // tmp only!
  244. //unset($Model->validate[$formField]);
  245. $entity->unsetProperty($formField);
  246. if ($this->_config['confirm']) {
  247. //$validator->remove($formFieldRepeat); // tmp only!
  248. //unset($Model->validate[$formFieldRepeat]);
  249. $entity->unsetProperty($formFieldRepeat);
  250. }
  251. if ($this->_config['current']) {
  252. //$validator->remove($formFieldCurrent); // tmp only!
  253. //unset($Model->validate[$formFieldCurrent]);
  254. $entity->unsetProperty($formFieldCurrent);
  255. }
  256. return;
  257. }
  258. }
  259. }
  260. /**
  261. * Hashing the password and whitelisting
  262. *
  263. * @param \Cake\Event\Event $event
  264. * @param \Cake\ORM\Entity $entity
  265. * @throws \Exception
  266. * @return void
  267. */
  268. public function beforeSave(Event $event, Entity $entity) {
  269. $formField = $this->_config['formField'];
  270. $field = $this->_config['field'];
  271. if ($entity->get($formField) !== null) {
  272. $cost = !empty($this->_config['hashCost']) ? $this->_config['hashCost'] : 10;
  273. $options = ['cost' => $cost];
  274. /** @var \Cake\Auth\AbstractPasswordHasher $PasswordHasher */
  275. $PasswordHasher = $this->_getPasswordHasher($this->_config['passwordHasher'], $options);
  276. $entity->set($field, $PasswordHasher->hash($entity->get($formField)));
  277. if (!$entity->get($field)) {
  278. throw new Exception('Empty field');
  279. }
  280. $entity->unsetProperty($formField);
  281. //$entity->set($formField, null);
  282. if ($this->_config['confirm']) {
  283. $formFieldRepeat = $this->_config['formFieldRepeat'];
  284. $entity->unsetProperty($formFieldRepeat);
  285. //unset($Model->data[$table->alias()][$formFieldRepeat]);
  286. }
  287. if ($this->_config['current']) {
  288. $formFieldCurrent = $this->_config['formFieldCurrent'];
  289. $entity->unsetProperty($formFieldCurrent);
  290. //unset($Model->data[$table->alias()][$formFieldCurrent]);
  291. }
  292. }
  293. }
  294. /**
  295. * Checks if the PasswordHasher class supports this and if so, whether the
  296. * password needs to be rehashed or not.
  297. * This is mainly supported by Tools.Modern (using Bcrypt) yet.
  298. *
  299. * @param string $hash Currently hashed password.
  300. * @return bool Success
  301. */
  302. public function needsPasswordRehash($hash) {
  303. $PasswordHasher = $this->_getPasswordHasher($this->_config['passwordHasher']);
  304. if (!method_exists($PasswordHasher, 'needsRehash')) {
  305. return false;
  306. }
  307. return $PasswordHasher->needsRehash($hash);
  308. }
  309. /**
  310. * If not implemented in Table class
  311. *
  312. * Note: requires the used Auth component to be App::uses() loaded.
  313. * It also reqires the same Auth setup as in your AppController's beforeFilter().
  314. * So if you set up any special passwordHasher or auth type, you need to provide those
  315. * with the settings passed to the behavior:
  316. *
  317. * 'passwordHasher' => array(
  318. * 'className' => 'Simple',
  319. * 'hashType' => 'sha256'
  320. * )
  321. *
  322. * @param string $pwd
  323. * @param array $context
  324. * @return bool Success
  325. */
  326. public function validateCurrentPwd($pwd, $context) {
  327. $uid = null;
  328. if (!empty($context['data'][$this->_table->primaryKey()])) {
  329. $uid = $context['data'][$this->_table->primaryKey()];
  330. } else {
  331. trigger_error('No user id given');
  332. return false;
  333. }
  334. return $this->_validateSameHash($pwd, $context);
  335. }
  336. /**
  337. * If not implemented in Table class
  338. *
  339. * @param string $value
  340. * @param array $options
  341. * @param array $context
  342. * @return bool Success
  343. */
  344. public function validateIdentical($value, $options, $context) {
  345. if (!is_array($options)) {
  346. $options = ['compare' => $options];
  347. }
  348. $compareValue = $context['data'][$options['compare']];
  349. return $compareValue === $value;
  350. }
  351. /**
  352. * If not implemented in Table class
  353. *
  354. * @param string $data
  355. * @param array $options
  356. * @param array $context
  357. * @return bool Success
  358. */
  359. public function validateNotSame($data, $options, $context) {
  360. if (!is_array($options)) {
  361. $options = ['compare' => $options];
  362. }
  363. $compareValue = $context['data'][$options['compare']];
  364. return $compareValue !== $data;
  365. }
  366. /**
  367. * If not implemented in Table class
  368. *
  369. * @param string $data
  370. * @param array $context
  371. * @return bool Success
  372. */
  373. public function validateNotSameHash($data, $context) {
  374. $field = $this->_config['field'];
  375. if (empty($context['data'][$this->_table->primaryKey()])) {
  376. return true;
  377. }
  378. $primaryKey = $context['data'][$this->_table->primaryKey()];
  379. $value = $context['data'][$context['field']];
  380. $dbValue = $this->_table->find()->where([$this->_table->alias() . '.' . $this->_table->primaryKey() => $primaryKey])->first();
  381. if (!$dbValue) {
  382. return true;
  383. }
  384. $dbValue = $dbValue[$field];
  385. if (!$dbValue) {
  386. return true;
  387. }
  388. $PasswordHasher = $this->_getPasswordHasher($this->_config['passwordHasher']);
  389. return !$PasswordHasher->check($value, $dbValue);
  390. }
  391. /**
  392. * PasswordableBehavior::_validateSameHash()
  393. *
  394. * @param string $pwd
  395. * @param array $context
  396. * @return bool Success
  397. */
  398. protected function _validateSameHash($pwd, $context) {
  399. $field = $this->_config['field'];
  400. $primaryKey = $context['data'][$this->_table->primaryKey()];
  401. $dbValue = $this->_table->find()->where([$this->_table->alias() . '.' . $this->_table->primaryKey() => $primaryKey])->first();
  402. if (!$dbValue) {
  403. return false;
  404. }
  405. $dbValue = $dbValue[$field];
  406. if (!$dbValue && $pwd) {
  407. return false;
  408. }
  409. $PasswordHasher = $this->_getPasswordHasher($this->_config['passwordHasher']);
  410. return $PasswordHasher->check($pwd, $dbValue);
  411. }
  412. /**
  413. * PasswordableBehavior::_getPasswordHasher()
  414. *
  415. * @param string|array $hasher Name or options array.
  416. * @param array $options
  417. * @return \Cake\Auth\AbstractPasswordHasher
  418. */
  419. protected function _getPasswordHasher($hasher, array $options = []) {
  420. if ($this->_passwordHasher) {
  421. return $this->_passwordHasher;
  422. }
  423. $config = [];
  424. if (is_string($hasher)) {
  425. $class = $hasher;
  426. } else {
  427. $class = $hasher['className'];
  428. $config = $hasher;
  429. unset($config['className']);
  430. }
  431. $config['className'] = $class;
  432. $config += $options;
  433. return $this->_passwordHasher = PasswordHasherFactory::build($config);
  434. }
  435. }