diff options
-rw-r--r-- | composer.json | 1 | ||||
-rw-r--r-- | docs/authentication.md | 60 | ||||
-rw-r--r-- | migrations/20150202164329_create_auth_token_table.php | 28 | ||||
-rw-r--r-- | src/Psecio/Gatekeeper/AuthTokenModel.php | 63 | ||||
-rw-r--r-- | src/Psecio/Gatekeeper/Gatekeeper.php | 37 | ||||
-rw-r--r-- | src/Psecio/Gatekeeper/Session/RememberMe.php | 263 | ||||
-rw-r--r-- | src/Psecio/Gatekeeper/UserModel.php | 13 |
7 files changed, 462 insertions, 3 deletions
diff --git a/composer.json b/composer.json index 85803de..a1647c8 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "enygma/modler": "1.*", "robmorgan/phinx": "*", "ircmaxell/password-compat": "1.0.4", + "ircmaxell/random-lib": "1.1.0", "vlucas/phpdotenv": "1.1.0" }, "require-dev": { diff --git a/docs/authentication.md b/docs/authentication.md index 23a696b..84b6f41 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -12,4 +12,62 @@ if (Gatekeeper::authenticate($credentials) == true) { echo 'valid!'; } ?> -```
\ No newline at end of file +``` + +# Remember Me + +In most applications there's a concept of session lasting longer than just one login. It's common to see apps allowing a "Remember Me" kind of handling and Gatekeeper includes this functionality in a simple, easy to use way. There's two functions in the main `Gatekeeper` class that take care of the hard work for you: + +```php +<?php +// To set it up and create the tokens based on a user +$user = Gatekeeper::findUserByUsername($credentials['username']); + +if (Gatekeeper::rememberMe($user) === true) { + echo 'this user is now remembered for 14 days!'; +} + +// Then to check when the user comes back in +$user = Gatekeeper::checkRememberMe(); +if ($user !== false) { + echo "Hey, I remember you, ".$user->username; +} + +?> +``` + +Using the `checkRememberMe` method, you can automatically verify the existence of the necessary cookie values and return the user they match. The default timeout for the "remember me" cookies is **14 days**. This can be changed by passing in an `interval` configuration option when the `rememberMe` function is called: + +``` +<?php +$user = Gatekeeper::findUserByUsername($credentials['username']); + +$config = array( + 'interval' => '+4 weeks' +); +if (Gatekeeper::rememberMe($user, $config) === true) { + echo 'this user is now remembered for 14 days!'; +} + +?> +``` + +The `interval` format here is any supported by the [PHP DateTime handling](http://php.net/manual/en/datetime.formats.php) in the constructor. + +#### Remember Me & Authentication + +In addition to the more manual handling of the "remember me" functionality above, you can also have the `authenicate` method kick off the process when the user successfully authenticates with a second optional parameter: + +``` +<?php +$credentials = array( + 'username' => 'ccornutt', + 'password' => 'valid-password' +); +if (Gatekeeper::authenticate($credentials, true) == true) { + echo 'valid!'; +} +?> +``` + +The only difference here is that second parameter, the `true`, that is a switch to turn on the "remember" handling. By default this is disabled, so if you want to use this automatically, you'll need to enable it here. With that enabled, you can then use the `checkRememberMe` method mentioned above to get the user that matches the token. diff --git a/migrations/20150202164329_create_auth_token_table.php b/migrations/20150202164329_create_auth_token_table.php new file mode 100644 index 0000000..5979d06 --- /dev/null +++ b/migrations/20150202164329_create_auth_token_table.php @@ -0,0 +1,28 @@ +<?php + +use Phinx\Migration\AbstractMigration; + +class CreateAuthTokenTable extends AbstractMigration +{ + /** + * Migrate Up. + */ + public function up() + { + $tokens = $this->table('auth_tokens'); + $tokens->addColumn('token', 'string', array('limit' => 100)) + ->addColumn('user_id', 'integer') + ->addColumn('expires', 'datetime') + ->addColumn('created', 'datetime') + ->addColumn('updated', 'datetime', array('default' => null)) + ->save(); + } + + /** + * Migrate Down. + */ + public function down() + { + $this->dropTable('auth_tokens'); + } +}
\ No newline at end of file diff --git a/src/Psecio/Gatekeeper/AuthTokenModel.php b/src/Psecio/Gatekeeper/AuthTokenModel.php new file mode 100644 index 0000000..59bb41e --- /dev/null +++ b/src/Psecio/Gatekeeper/AuthTokenModel.php @@ -0,0 +1,63 @@ +<?php + +namespace Psecio\Gatekeeper; + +class AuthTokenModel extends \Psecio\Gatekeeper\Model\Mysql +{ + /** + * Database table name + * @var string + */ + protected $tableName = 'auth_tokens'; + + /** + * Model properties + * @var array + */ + protected $properties = array( + 'id' => array( + 'description' => 'Token ID', + 'column' => 'id', + 'type' => 'integer' + ), + 'token' => array( + 'description' => 'Token value', + 'column' => 'token', + 'type' => 'varchar' + ), + 'verifier' => array( + 'description' => 'Verifier value', + 'column' => 'verifier', + 'type' => 'varchar' + ), + 'userId' => array( + 'description' => 'User ID', + 'column' => 'user_id', + 'type' => 'integer' + ), + 'user' => array( + 'description' => 'User related to token', + 'type' => 'relation', + 'relation' => array( + 'model' => '\\Psecio\\Gatekeeper\\UserModel', + 'method' => 'findByUserId', + 'local' => 'userId' + ) + ), + 'expires' => array( + 'description' => 'Date Token Expires', + 'column' => 'expires', + 'type' => 'datetime' + ), + 'created' => array( + 'description' => 'Date Created', + 'column' => 'created', + 'type' => 'datetime' + ), + 'updated' => array( + 'description' => 'Date Updated', + 'column' => 'updated', + 'type' => 'datetime' + ), + ); +}
\ No newline at end of file diff --git a/src/Psecio/Gatekeeper/Gatekeeper.php b/src/Psecio/Gatekeeper/Gatekeeper.php index a2fed7e..eb1f65b 100644 --- a/src/Psecio/Gatekeeper/Gatekeeper.php +++ b/src/Psecio/Gatekeeper/Gatekeeper.php @@ -177,10 +177,10 @@ class Gatekeeper * Authenticate a user given the username/password credentials * * @param array $credentials Credential information (must include "username" and "password") - * @param array $config Configuration options [optional] + * @param boolean $rememeber Flag to activate the "remember me" functionality * @return boolean Pass/fail of authentication */ - public static function authenticate(array $credentials, array $config = array()) + public static function authenticate(array $credentials, $remember = false) { $username = $credentials['username']; $user = new UserModel(self::$datasource); @@ -213,6 +213,10 @@ class Gatekeeper if (self::$throttleStatus === true && $result === true) { $instance->model->allow(); + + if ($remember === true) { + self::rememberMe($user); + } } return $result; @@ -483,4 +487,33 @@ class Gatekeeper $instance = new $classNs($config); self::$restrictions[] = $instance; } + + /** + * Enable and set up the "Remember Me" cookie token handling for the given user + * + * @param \Psecio\Gatekeeper\UserModel|string $user User model instance + * @param array $config Set of configuration settings + * @return boolean Success/fail of sesssion setup + */ + public static function rememberMe($user, array $config = array()) + { + if (is_string($user)) { + $user = Gatekeeper::findUserByUsername($user); + } + + $data = array_merge($_COOKIE, $config); + $remember = new Session\RememberMe(self::$datasource, $data, $user); + return $remember->setup(); + } + + /** + * Check the "Remember Me" token information (if it exists) + * + * @return boolean|\Psecio\Gatekeeper\UserModel Success/fail of token validation or User model instance + */ + public static function checkRememberMe() + { + $remember = new Session\RememberMe(self::$datasource, $_COOKIE); + return $remember->verify(); + } }
\ No newline at end of file diff --git a/src/Psecio/Gatekeeper/Session/RememberMe.php b/src/Psecio/Gatekeeper/Session/RememberMe.php new file mode 100644 index 0000000..844dc3e --- /dev/null +++ b/src/Psecio/Gatekeeper/Session/RememberMe.php @@ -0,0 +1,263 @@ +<?php + +namespace Psecio\Gatekeeper\Session; + +class RememberMe +{ + /** + * Token name + * @var string + */ + private $tokenName = 'gktoken'; + + /** + * Default expiration time + * @var string + */ + private $expireInterval = '+14 days'; + + /** + * Data (cookie) for use in token evaluation + * @var array + */ + private $data = array(); + + /** + * User instance to check against + * @var \Psecio\Gatekeeper\UserModel + */ + private $user; + + /** + * Datasource for use in making find//save requests + * @var \Psecio\Gatekeeper\DataSource + */ + private $datasource; + + /** + * Init the object and set up the datasource, data and possibly a user + * + * @param \Psecio\Gatekeeper\DataSource $datasource Data source to use for operations + * @param array $data Data to use in evaluation + * @param \Psecio\Gatekeeper\UserModel|null $user User model instance [optional] + */ + public function __construct(\Psecio\Gatekeeper\DataSource $datasource, array $data, \Psecio\Gatekeeper\UserModel $user = null) + { + $this->datasource = $datasource; + + if (!empty($data)) { + $this->data = $data; + } + if ($user !== null) { + $this->user = $user; + } + if (isset($this->data['interval'])) { + $this->expireInterval = $this->data['interval']; + } + } + + /** + * Setup the "remember me" session and cookies + * + * @param \Psecio\Gatekeeper\UserModel|null $user User model instance [optional] + * @return boolean Success/fail of setting up the session/cookies + */ + public function setup(\Psecio\Gatekeeper\UserModel $user = null) + { + $user = ($user === null) ? $this->user : $user; + $userToken = $this->getUserToken($user); + + if ($userToken->id !== null || $this->isExpired($userToken)) { + return false; + } + $token = $this->generateToken(); + $tokenModel = $this->saveToken($token, $user); + if ($tokenModel === false) { + return false; + } + $this->setCookies($tokenModel, $token); + + return true; + } + + /** + * Verify the token if it exists + * Removes the old token and sets up a new one if valid + * + * @param string $token Token value + * @param string $auth Auth value + * @return boolean Pass/fail result of the validation + */ + public function verify($token = null, $auth = null) + { + // See if we have our cookies + $domain = $_SERVER['HTTP_HOST']; + $https = (isset($_SERVER['HTTPS'])) ? true : false; + + if (!isset($this->data[$this->tokenName])) { + return false; + } + + $tokenParts = explode(':', $this->data[$this->tokenName]); + $token = $this->getById($tokenParts[0]); + if ($token === false) { + return false; + } + + $user = $token->user; + $userToken = $token->token; + + // Remove the token (a new one will be made later) + $this->datasource->delete($token); + + if ($this->hash_equals($this->data[$this->tokenName], $token->id.':'.hash('sha256', $userToken)) === false) { + return false; + } + + $this->setup($user); + return $user; + } + + /** + * Get the token information searching on given token string + * + * @param string $tokenValue Token string for search + * @return boolean|\Psecio\Gatekeeper\AuthTokenModel Instance if no query errors + */ + public function getByToken($tokenValue) + { + $token = new \Psecio\Gatekeeper\AuthTokenModel($this->datasource); + $result = $this->datasource->find($token, array('token' => $tokenValue)); + return $result; + } + + /** + * Get a token by its unique ID + * + * @param integer $tokenId Token ID + * @return boolean|\Psecio\Gatekeeper\AuthTokenModel instance + */ + public function getById($tokenId) + { + $token = new \Psecio\Gatekeeper\AuthTokenModel($this->datasource); + $result = $this->datasource->find($token, array('id' => $tokenId)); + return $result; + } + + /** + * Get the token by user ID + * Also performs evaluation to check if token is expired, returns false if so + * + * @param \Psecio\Gatekeeper\UserModel $user User model instance + * @return boolean|\Psecio\Gatekeeper\AuthTokenModel instance + */ + public function getUserToken(\Psecio\Gatekeeper\UserModel $user) + { + $tokenModel = new \Psecio\Gatekeeper\AuthTokenModel($this->datasource); + return $this->datasource->find($tokenModel, array('userId' => $user->id)); + } + + /** + * Check to see if the token has expired + * + * @param \Psecio\Gatekeeper\AuthTokenModel $token Token model instance + * @param boolean $delete Delete/don't delete the token if expired [optional] + * @return boolean Token expired/not expired + */ + public function isExpired(\Psecio\Gatekeeper\AuthTokenModel $token, $delete = true) + { + if (new \Datetime() > new \DateTime($token->expires)) { + if ($delete === true) { + $this->deleteToken($token->token); + } + return true; + } + return false; + } + + /** + * Save the new token to the data source + * + * @param string $token Token string + * @param \Psecio\Gatekeeper\UserModel $user User model instance + * @return boolean|\Psecio\Gatekeeper\AuthTokenModel Success/fail of token creation or AuthTokenModel instance + */ + public function saveToken($token, \Psecio\Gatekeeper\UserModel $user) + { + $expires = new \DateTime($this->expireInterval); + $tokenModel = new \Psecio\Gatekeeper\AuthTokenModel($this->datasource, array( + 'token' => $token, + 'userId' => $user->id, + 'expires' => $expires->format('Y-m-d H:i:s') + )); + $result = $this->datasource->save($tokenModel); + return ($result === false) ? false : $tokenModel; + } + + /** + * Delete the token by token string + * + * @param string $token Token hash string + * @return boolean Success/fail of token record deletion + */ + public function deleteToken($token) + { + $tokenModel = new \Psecio\Gatekeeper\AuthTokenModel($this->datasource); + $token = $this->datasource->find($tokenModel, array('token' => $token)); + if ($token !== false) { + return $this->datasource->delete($token); + } + return false; + } + + /** + * Generate the token value + * + * @return array Set of two token values (main and auth) + */ + public function generateToken() + { + $factory = new \RandomLib\Factory; + $generator = $factory->getMediumStrengthGenerator(); + + return base64_encode($generator->generate(24)); + } + + /** + * Set the cookies with the main and auth tokens + * + * @param \Psecio\Gatekeeper\AuthTokenModel $tokenModel Auth token model instance + * @param string $token Token hash + * @param boolean $https Enable/disable HTTPS setting on cookies [optional] + * @param string $domain Domain value to set cookies on + */ + public function setCookies(\Psecio\Gatekeeper\AuthTokenModel $tokenModel, $token, $https = false, $domain = null) + { + if ($domain === null && isset($_SERVER['HTTP_HOST'])) { + $domain = $_SERVER['HTTP_HOST']; + } + + $tokenValue = $tokenModel->id.':'.hash('sha256', $token); + $expires = new \DateTime($this->expireInterval); + return setcookie($this->tokenName, $tokenValue, $expires->format('U'), '/', $domain, $https, true); + } + + /** + * Polyfill PHP 5.6.0's hash_equals() feature + */ + public function hash_equals($a, $b) + { + if (\function_exists('hash_equals')) { + return \hash_equals($a, $b); + } + if (\strlen($a) !== \strlen($b)) { + return false; + } + $res = 0; + $len = \strlen($a); + for ($i = 0; $i < $len; ++$i) { + $res |= \ord($a[$i]) ^ \ord($b[$i]); + } + return $res === 0; + } +}
\ No newline at end of file diff --git a/src/Psecio/Gatekeeper/UserModel.php b/src/Psecio/Gatekeeper/UserModel.php index ac5c811..8b7b346 100644 --- a/src/Psecio/Gatekeeper/UserModel.php +++ b/src/Psecio/Gatekeeper/UserModel.php @@ -141,6 +141,19 @@ class UserModel extends \Psecio\Gatekeeper\Model\Mysql } /** + * Find a user by their given ID + * + * @param integer $userId User ID + * @return boolean Success/fail of find operation + */ + public function findByUserId($userId) + { + return $this->getDb()->find( + $this, array('id' => $userId) + ); + } + + /** * Attach a permission to a user account * * @param integer|PermissionModel $perm Permission ID or model isntance |