Browse Source

Refactor auth and password shims.

Mark Scherer 11 years ago
parent
commit
c5e1942f19

+ 87 - 0
Controller/Component/Auth/FallbackPasswordHasher.php

@@ -0,0 +1,87 @@
+<?php
+
+App::uses('AbstractPasswordHasher', 'Controller/Component/Auth');
+App::uses('PasswordHasherFactory', 'Tools.Controller/Component/Auth');
+/**
+ * A backport of the 3.x FallbackPasswordHasher class.
+ *
+ * @author Mark Scherer
+ * @license http://opensource.org/licenses/mit-license.php MIT
+ */
+class FallbackPasswordHasher extends AbstractPasswordHasher {
+
+	/**
+	 * Default config for this object.
+	 *
+	 * @var array
+	 */
+	protected $_defaultConfig = ['hashers' => []];
+
+	/**
+	 * Holds the list of password hasher objects that will be used
+	 *
+	 * @var array
+	 */
+	protected $_hashers = [];
+
+	/**
+	 * Constructor
+	 *
+	 * @param array $config configuration options for this object. Requires the
+	 * `hashers` key to be present in the array with a list of other hashers to be
+	 * used
+	 */
+	public function __construct(array $config = []) {
+		$config += $this->_defaultConfig;
+		parent::__construct($config);
+		foreach ($this->_config['hashers'] as $key => $hasher) {
+			if (!is_string($hasher)) {
+				$hasher += ['className' => $key, ];
+			}
+			$this->_hashers[] = PasswordHasherFactory::build($hasher);
+		}
+	}
+
+	/**
+	 * Generates password hash.
+	 *
+	 * Uses the first password hasher in the list to generate the hash
+	 *
+	 * @param string $password Plain text password to hash.
+	 * @return string Password hash
+	 */
+	public function hash($password) {
+		return $this->_hashers[0]->hash($password);
+	}
+
+	/**
+	 * Verifies that the provided password corresponds to its hashed version
+	 *
+	 * This will iterate over all configured hashers until one of them returns
+	 * true.
+	 *
+	 * @param string $password Plain text password to hash.
+	 * @param string $hashedPassword Existing hashed password.
+	 * @return bool True if hashes match else false.
+	 */
+	public function check($password, $hashedPassword) {
+		foreach ($this->_hashers as $hasher) {
+			if ($hasher->check($password, $hashedPassword)) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Returns true if the password need to be rehashed, with the first hasher present
+	 * in the list of hashers
+	 *
+	 * @param string $password The password to verify
+	 * @return bool
+	 */
+	public function needsRehash($password) {
+		return $this->_hashers[0]->needsRehash($password);
+	}
+
+}

+ 6 - 6
Controller/Component/Auth/ModernPasswordHasher.php

@@ -46,7 +46,7 @@ class ModernPasswordHasher extends AbstractPasswordHasher {
 	 * Generates password hash.
 	 *
 	 * @param string $password Plain text password to hash.
-	 * @return string Password hash
+	 * @return string Password hash.
 	 * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#using-bcrypt-for-passwords
 	 */
 	public function hash($password) {
@@ -68,13 +68,13 @@ class ModernPasswordHasher extends AbstractPasswordHasher {
 
 	/**
 	 * Returns true if the password need to be rehashed, due to the password being
-	 * created with anything else than the passwords generated by this class.
+	 * created with anything else than the passwords currently generated by this class.
 	 *
-	 * @param string $password The password to verify
-	 * @return bool
+	 * @param string $password The password hash to verify.
+	 * @return bool True if it needs rehashing.
 	 */
-	public function needsRehash($password) {
-		return password_needs_rehash($password, $this->_config['hashType']);
+	public function needsRehash($currentHash) {
+		return password_needs_rehash($currentHash, $this->_config['hashType']);
 	}
 
 }

+ 40 - 0
Controller/Component/Auth/PasswordHasherFactory.php

@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * Builds password hashing objects
+ *
+ * Backported from 3.x
+ */
+class PasswordHasherFactory {
+
+	/**
+	 * Returns password hasher object out of a hasher name or a configuration array
+	 *
+	 * @param string|array $passwordHasher Name of the password hasher or an array with
+	 * at least the key `className` set to the name of the class to use
+	 * @return \Cake\Auth\AbstractPasswordHasher Password hasher instance
+	 * @throws \RuntimeException If password hasher class not found or
+	 *   it does not extend Cake\Auth\AbstractPasswordHasher
+	 */
+	public static function build($passwordHasher) {
+		$config = [];
+		if (is_string($passwordHasher)) {
+			$class = $passwordHasher;
+		} else {
+			$class = $passwordHasher['className'];
+			$config = $passwordHasher;
+			unset($config['className']);
+		}
+
+		list($plugin, $class) = pluginSplit($class, true);
+		$className = $class . 'PasswordHasher';
+		App::uses($className, $plugin . 'Controller/Component/Auth');
+		if (!class_exists($className)) {
+			throw new CakeException(sprintf('Password hasher class "%s" was not found.', $class));
+		}
+		if (!is_subclass_of($className, 'AbstractPasswordHasher')) {
+			throw new CakeException('Password hasher must extend AbstractPasswordHasher class.');
+		}
+		return new $className($config);
+	}
+}

+ 226 - 0
Test/Case/Controller/Component/Auth/FallbackPasswordHasherTest.php

@@ -0,0 +1,226 @@
+<?php
+/**
+ * FallbackPasswordHasher file
+ *
+ */
+App::uses('FallbackPasswordHasher', 'Tools.Controller/Component/Auth');
+App::uses('MyCakeTestCase', 'Tools.TestSuite');
+App::uses('Controller', 'Controller');
+App::uses('CakeRequest', 'Network');
+App::uses('CakeResponse', 'Network');
+App::uses('Model', 'Model');
+App::uses('CakeSession', 'Model/Datasource');
+
+if (!defined('PASSWORD_BCRYPT')) {
+	require CakePlugin::path('Tools') . 'Lib/Bootstrap/Password.php';
+}
+
+/**
+ * Test case for FallbackPasswordHasher
+ *
+ */
+class FallbackPasswordHasherTest extends MyCakeTestCase {
+
+	public $fixtures = ['plugin.tools.tools_auth_user'];
+
+	public $Controller;
+
+	public $request;
+
+	/**
+	 * Setup
+	 *
+	 * @return void
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		$this->Controller = new TestFallbackPasswordHasherController(new CakeRequest(), new CakeResponse());
+		$this->Controller->constructClasses();
+		$this->Controller->startupProcess();
+
+		// Modern pwd account
+		$this->Controller->TestFallbackPasswordHasherUser->create();
+		$user = array(
+			'username' => 'itisme',
+			'email' => '',
+			'pwd' => 'secure123456'
+		);
+		$res = $this->Controller->TestFallbackPasswordHasherUser->save($user);
+		$this->assertTrue((bool)$res);
+
+		// Old pwd account
+		$this->Controller->TestFallbackPasswordHasherUser->create();
+		$user = array(
+			'username' => 'itwasme',
+			'email' => '',
+			'password' => Security::hash('123456', null, true)
+		);
+		$res = $this->Controller->TestFallbackPasswordHasherUser->save($user);
+		$this->assertTrue((bool)$res);
+
+		CakeSession::delete('Auth');
+
+		//var_dump($this->Controller->TestFallbackPasswordHasherUser->find('all'));
+	}
+
+	public function tearDown() {
+		parent::tearDown();
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testBasics() {
+		$this->Controller->request->data = array(
+			'TestFallbackPasswordHasherUser' => array(
+				'username' => 'itisme',
+				'password' => 'xyz'
+			),
+		);
+		$result = $this->Controller->Auth->login();
+		$this->assertFalse($result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testLogin() {
+		$this->Controller->request->data = array(
+			'TestFallbackPasswordHasherUser' => array(
+				'username' => 'itisme',
+				'password' => 'secure123456'
+			),
+		);
+		$result = $this->Controller->Auth->login();
+		$this->assertTrue($result);
+
+		// This could be done in login() action after successfully logging in.
+		$hash = $this->Controller->TestFallbackPasswordHasherUser->hash('secure123456');
+		$this->assertFalse($this->Controller->TestFallbackPasswordHasherUser->needsRehash($hash));
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testLoginOld() {
+		$this->Controller->request->data = array(
+			'TestFallbackPasswordHasherUser' => array(
+				'username' => 'itwasme',
+				'password' => '123456'
+			),
+		);
+		$result = $this->Controller->Auth->login();
+		$this->assertTrue($result);
+
+		// This could be done in login() action after successfully logging in.
+		$hash = Security::hash('123456', null, true);
+		$this->assertTrue($this->Controller->TestFallbackPasswordHasherUser->needsRehash($hash));
+	}
+
+}
+
+class TestFallbackPasswordHasherController extends Controller {
+
+	public $uses = array('Tools.TestFallbackPasswordHasherUser');
+
+	public $components = array('Auth');
+
+	public function beforeFilter() {
+		parent::beforeFilter();
+
+		$options = array(
+			'className' => 'Tools.Fallback',
+			'hashers' => array(
+				'Tools.Modern', 'Simple'
+				//'Tools.Modern' => array('userModel' => 'Tools.TestFallbackPasswordHasherUser'), 'Simple' => array('userModel' => 'Tools.TestFallbackPasswordHasherUser')
+			)
+		);
+		$this->Auth->authenticate = array(
+			'Form' => array(
+				'passwordHasher' => $options,
+				'fields' => array(
+					'username' => 'username',
+					'password' => 'password'
+				),
+				'userModel' => 'Tools.TestFallbackPasswordHasherUser'
+			)
+		);
+	}
+
+}
+
+class TestFallbackPasswordHasherUser extends Model {
+
+	public $useTable = 'tools_auth_users';
+
+	/**
+	 * TestFallbackPasswordHasherUser::beforeSave()
+	 *
+	 * @param array $options
+	 * @return bool Success
+	 */
+	public function beforeSave($options = array()) {
+		if (!empty($this->data[$this->alias]['pwd'])) {
+			$this->data[$this->alias]['password'] = $this->hash($this->data[$this->alias]['pwd']);
+		}
+		return true;
+	}
+
+	/**
+	 * @param string $pwd
+	 * @return string Hash
+	 */
+	public function hash($pwd) {
+		$options = array(
+			'className' => 'Tools.Fallback',
+			'hashers' => array(
+				'Tools.Modern', 'Simple'
+			)
+		);
+		$passwordHasher = $this->_getPasswordHasher($options);
+		return $passwordHasher->hash($pwd);
+	}
+
+	/**
+	 * @param string $pwd
+	 * @return bool Success
+	 */
+	public function needsRehash($pwd) {
+		$options = array(
+			'className' => 'Tools.Fallback',
+			'hashers' => array(
+				'Tools.Modern', 'Simple'
+			)
+		);
+		$passwordHasher = $this->_getPasswordHasher($options);
+		return $passwordHasher->needsRehash($pwd);
+	}
+
+	/**
+	 * PasswordableBehavior::_getPasswordHasher()
+	 *
+	 * @param mixed $hasher Name or options array.
+	 * @return PasswordHasher
+	 */
+	protected function _getPasswordHasher($hasher) {
+		$class = $hasher;
+		$config = [];
+		if (is_array($hasher)) {
+			$class = $hasher['className'];
+			unset($hasher['className']);
+			$config = $hasher;
+		}
+		list($plugin, $class) = pluginSplit($class, true);
+		$className = $class . 'PasswordHasher';
+		App::uses($className, $plugin . 'Controller/Component/Auth');
+		if (!class_exists($className)) {
+			throw new CakeException(sprintf('Password hasher class "%s" was not found.', $class));
+		}
+		if (!is_subclass_of($className, 'AbstractPasswordHasher')) {
+			throw new CakeException('Password hasher must extend AbstractPasswordHasher class.');
+		}
+		return new $className($config);
+	}
+
+}

+ 181 - 0
Test/Case/Controller/Component/Auth/ModernPasswordHasherTest.php

@@ -0,0 +1,181 @@
+<?php
+/**
+ * ModernPasswordHasher file
+ *
+ */
+App::uses('ModernPasswordHasher', 'Tools.Controller/Component/Auth');
+App::uses('MyCakeTestCase', 'Tools.TestSuite');
+App::uses('Controller', 'Controller');
+App::uses('CakeRequest', 'Network');
+App::uses('CakeResponse', 'Network');
+App::uses('Model', 'Model');
+App::uses('CakeSession', 'Model/Datasource');
+
+if (!defined('PASSWORD_BCRYPT')) {
+	require CakePlugin::path('Tools') . 'Lib/Bootstrap/Password.php';
+}
+
+/**
+ * Test case for ModernPasswordHasher
+ *
+ */
+class ModernPasswordHasherTest extends MyCakeTestCase {
+
+	public $fixtures = ['plugin.tools.tools_auth_user'];
+
+	public $Controller;
+
+	public $request;
+
+	/**
+	 * Setup
+	 *
+	 * @return void
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		$this->Controller = new TestModernPasswordHasherController(new CakeRequest(), new CakeResponse());
+		$this->Controller->constructClasses();
+		$this->Controller->startupProcess();
+
+		// Modern pwd account
+		$this->Controller->TestModernPasswordHasherUser->create();
+		$user = array(
+			'username' => 'itisme',
+			'email' => '',
+			'pwd' => 'secure123456'
+		);
+		$res = $this->Controller->TestModernPasswordHasherUser->save($user);
+		$this->assertTrue((bool)$res);
+
+		CakeSession::delete('Auth');
+	}
+
+	/**
+	 * @return void
+	 */
+	public function tearDown() {
+		parent::tearDown();
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testBasics() {
+		$this->Controller->request->data = array(
+			'TestModernPasswordHasherUser' => array(
+				'username' => 'itisme',
+				'password' => 'xyz'
+			),
+		);
+		$result = $this->Controller->Auth->login();
+		$this->assertFalse($result);
+	}
+
+	/**
+	 * @return void
+	 */
+	public function testLogin() {
+		$this->Controller->request->data = array(
+			'TestModernPasswordHasherUser' => array(
+				'username' => 'itisme',
+				'password' => 'secure123456'
+			),
+		);
+		$result = $this->Controller->Auth->login();
+		$this->assertTrue($result);
+
+		// This could be done in login() action after successfully logging in.
+		$hash = $this->Controller->TestModernPasswordHasherUser->hash('secure123456');
+		$this->assertFalse($this->Controller->TestModernPasswordHasherUser->needsRehash($hash));
+	}
+
+}
+
+class TestModernPasswordHasherController extends Controller {
+
+	public $uses = array('Tools.TestModernPasswordHasherUser');
+
+	public $components = array('Auth');
+
+	/**
+	 * @return void
+	 */
+	public function beforeFilter() {
+		parent::beforeFilter();
+
+		$this->Auth->authenticate = array(
+			'Form' => array(
+				'passwordHasher' => 'Tools.Modern',
+				'fields' => array(
+					'username' => 'username',
+					'password' => 'password'
+				),
+				'userModel' => 'Tools.TestModernPasswordHasherUser'
+			)
+		);
+	}
+
+}
+
+class TestModernPasswordHasherUser extends Model {
+
+	public $useTable = 'tools_auth_users';
+
+	/**
+	 * TestModernPasswordHasherUser::beforeSave()
+	 *
+	 * @param array $options
+	 * @return bool Success
+	 */
+	public function beforeSave($options = array()) {
+		if (!empty($this->data[$this->alias]['pwd'])) {
+			$this->data[$this->alias]['password'] = $this->hash($this->data[$this->alias]['pwd']);
+		}
+		return true;
+	}
+
+	/**
+	 * @param string $pwd
+	 * @return string Hash
+	 */
+	public function hash($pwd) {
+		$passwordHasher = $this->_getPasswordHasher('Tools.Modern');
+		return $passwordHasher->hash($pwd);
+	}
+
+	/**
+	 * @param string $pwd
+	 * @return bool Success
+	 */
+	public function needsRehash($pwd) {
+		$passwordHasher = $this->_getPasswordHasher('Tools.Modern');
+		return $passwordHasher->needsRehash($pwd);
+	}
+
+	/**
+	 * @param mixed $hasher Name or options array.
+	 * @return PasswordHasher
+	 */
+	protected function _getPasswordHasher($hasher) {
+		$class = $hasher;
+		$config = [];
+		if (is_array($hasher)) {
+			$class = $hasher['className'];
+			unset($hasher['className']);
+			$config = $hasher;
+		}
+		list($plugin, $class) = pluginSplit($class, true);
+		$className = $class . 'PasswordHasher';
+		App::uses($className, $plugin . 'Controller/Component/Auth');
+		if (!class_exists($className)) {
+			throw new CakeException(sprintf('Password hasher class "%s" was not found.', $class));
+		}
+		if (!is_subclass_of($className, 'AbstractPasswordHasher')) {
+			throw new CakeException('Password hasher must extend AbstractPasswordHasher class.');
+		}
+		return new $className($config);
+	}
+
+}

+ 36 - 0
Test/Fixture/ToolsAuthUserFixture.php

@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * ToolsUser Fixture
+ */
+class ToolsAuthUserFixture extends CakeTestFixture {
+
+	/**
+	 * Fields
+	 *
+	 * @var array
+	 */
+	public $fields = [
+		'id' => ['type' => 'integer', 'key' => 'primary'],
+		'username' => ['type' => 'string', 'null' => false],
+		'email' => ['type' => 'string', 'null' => false],
+		'password' => ['type' => 'string', 'null' => false],
+		'role_id' => ['type' => 'integer', 'null' => true],
+	];
+
+	/**
+	 * Records property
+	 *
+	 * @var array
+	 */
+	public $records = [
+		[
+			'id' => 1,
+			'username' => 'User 1',
+			'email' => 'myemail@example.com',
+			'password' => '',
+			'role_id' => 1
+		]
+	];
+
+}

+ 0 - 32
Test/Fixture/ToolsUserFixture.php

@@ -1,32 +0,0 @@
-<?php
-
-/**
- * ToolsUser Fixture
- */
-class ToolsUserFixture extends CakeTestFixture {
-
-	/**
-	 * Fields
-	 *
-	 * @var array
-	 */
-	public $fields = [
-		'id' => ['type' => 'integer', 'key' => 'primary'],
-		'name' => ['type' => 'string', 'null' => false],
-		'password' => ['type' => 'string', 'null' => false],
-		'role_id' => ['type' => 'integer', 'null' => true],
-	];
-
-	/**
-	 * Records property
-	 *
-	 * @var array
-	 */
-	public $records = [
-		['id' => 1, 'role_id' => 1, 'password' => '123456', 'name' => 'User 1'],
-		['id' => 2, 'role_id' => 2, 'password' => '123456', 'name' => 'User 2'],
-		['id' => 3, 'role_id' => 1, 'password' => '123456', 'name' => 'User 3'],
-		['id' => 4, 'role_id' => 3, 'password' => '123456', 'name' => 'User 4']
-	];
-
-}

+ 117 - 0
docs/Auth.md

@@ -0,0 +1,117 @@
+# Auth
+
+## ModernPasswordHasher for Authentication
+You are tired of sha1 and other hashing algos that are not designed for hashing passwords and because they
+aren't secure? Use cutting edge 5.5 PHP (and CakePHP 3 core) functionality (shimmed to work even with 5.4) now.
+
+```php
+$this->Auth->authenticate = array(
+	'Form' => array(
+		'passwordHasher' => 'Tools.Modern',
+		'scope' => array('status' => User::STATUS_ACTIVE),
+	)
+);
+```
+
+It can also be used inside of other authentication classes, e.g. when you use FriendsOfCake/Authenticate:
+```php
+$this->Auth->authenticate = array(
+	'Authenticate.MultiColumn' => array(
+		'passwordHasher' => 'Tools.Modern',
+ 		'columns' => array('username', 'email'),
+ 		'userModel' => 'User',
+ 		'scope' => ...,
+ 		'fields' => ...,
+ 	),
+	...
+);
+```
+
+
+### Providing BC for old passwords
+Taking it one step further: We also want to continue supporting the old hashs, and slowly upgrading
+them to the new ones.
+
+This can easily be done using the Fallback hasher class:
+```php
+$this->Auth->authenticate = array(
+	'Authenticate.MultiColumn' => array(
+		'passwordHasher' => array(
+			'className' => 'Tools.Fallback',
+			'hashers' => array(
+				'Tools.Modern', 'Simple'
+			)
+		),
+ 		'columns' => array('username', 'email'),
+ 		'userModel' => 'User',
+ 		'scope' => ...,
+ 		'fields' => ...,
+ 	),
+	...
+);
+```
+
+Inside the login() action we need a little script to re-hash outdated passwords then:
+```php
+if ($this->Auth->login()) {
+	$uid = $this->Auth->user('id');
+	$dbPassword = $this->User->field('password', ...);
+	if ($this->User->needsRehash($dbPassword)) {
+		$newHash = $this->User->hash($this->request->data['User']['password']);
+		// Update this user
+	}
+	...
+}
+```
+
+It uses methods of the User model, which we create for this use case:
+```php
+/**
+ * @param string $pwd
+ * @return bool Success
+ */
+public function needsRehash($pwd) {
+	$options = array(
+		'className' => 'Tools.Fallback',
+		'hashers' => array(
+			'Tools.Modern', 'Simple'
+		)
+	);
+	$passwordHasher = $this->_getPasswordHasher($options); // Implement this on your own
+	return $passwordHasher->needsRehash($pwd);
+}
+
+/**
+ * @param string $pwd
+ * @return string Hash
+ */
+public function hash($pwd) {
+	$options = array(
+		'className' => 'Tools.Fallback',
+		'hashers' => array(
+			'Tools.Modern', 'Simple'
+		)
+	);
+	$passwordHasher = $this->_getPasswordHasher($options); // Implement this on your own
+	return $passwordHasher->hash($pwd);
+}
+```
+
+### Using Passwordable as a clean and DRY wrapper
+When using Passwordable, the following Configure config
+```
+	'Passwordable'  => [
+		'passwordHasher' => ['className' => 'Fallback', 'hashers' => ['Tools.Modern', 'Simple']]
+	],
+```
+will take care of all for both login and user creation.
+No extra model methods and duplicate configs necessary.
+See [docs](http://www.dereuromark.de/2011/08/25/working-with-passwords-in-cakephp/).
+
+
+## TinyAuth for Authorization
+Super-fast super-slim Authorization once you are logged in.
+See [TinyAuth](TinyAuth/TinyAuth.md).
+
+
+See the [CakeFest app](https://github.com/dereuromark/cakefest) for a demo show case around all of the above.

+ 2 - 0
docs/Shims.md

@@ -1,6 +1,8 @@
 ## Shims and CO
 Write cutting edge 2.x code - and prepare for 3.x.
 
+Note that most of the functionality will be moved to a separate [Shim plugin](https://github.com/dereuromark/cakephp-shim).
+
 ### Write smart (future aware) code
 - Drop deprecated stuff early.
 - Upgrade to new ways as soon as possible and while it's still easy to do so (minor changes well testable).