diff --git a/.semver b/.semver index 7048659d7..2b479677e 100644 --- a/.semver +++ b/.semver @@ -1,5 +1,5 @@ --- -:major: 3 -:minor: 2 -:patch: 5 +:major: 4 +:minor: 0 +:patch: 0 :special: '' diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaae87b8..cb47b90f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Changelog Releases for CakePHP 3 ------------- +* 4.0.0 + * Add Google Authenticator + * Add improvements to SimpleRbac, like star to invert rules and `user.` prefix to match values from the user array + * Add `allowed` to manage the AuthLinkHelper when action is allowed + * Add option to configure the api table and finder in ApiKeyAuthenticate + * 3.2.5 * Fixed RegisterBehavior api, make getRegisterValidators public. diff --git a/Docs/Documentation/Installation.md b/Docs/Documentation/Installation.md index 35a6b8967..d303fc9dd 100644 --- a/Docs/Documentation/Installation.md +++ b/Docs/Documentation/Installation.md @@ -34,6 +34,18 @@ composer require google/recaptcha:@stable NOTE: you'll need to configure the reCaptcha key and secret, check the [Configuration](Configuration.md) page for more details. +If you want to use Google Authenticator features... + +``` +composer require robthree/twofactorauth:"^1.5.2" +``` + +NOTE: you'll need to enable `Users.GoogleAuthenticator.login` + +``` +Configure::write('Users.GoogleAuthenticator.login', true); +``` + Creating Required Tables ------------------------ If you want to use the Users tables to store your users and social accounts: diff --git a/Docs/Documentation/SimpleRbacAuthorize.md b/Docs/Documentation/SimpleRbacAuthorize.md index fbcda06e1..8a45eac61 100644 --- a/Docs/Documentation/SimpleRbacAuthorize.md +++ b/Docs/Documentation/SimpleRbacAuthorize.md @@ -52,25 +52,42 @@ The ```default_role``` will be used to set the role of the registered users by d Permission rules syntax ----------------- -* Rules are evaluated top-down, first matching rule will apply -* Each rule is defined: -```php -[ - 'role' => 'REQUIRED_NAME_OF_THE_ROLE_OR_[]_OR_*', - 'prefix' => 'OPTIONAL_PREFIX_USED_OR_[]_OR_*_DEFAULT_NULL', - 'extension' => 'OPTIONAL_PREFIX_USED_OR_[]_OR_*_DEFAULT_NULL', - 'plugin' => 'OPTIONAL_NAME_OF_THE_PLUGIN_OR_[]_OR_*_DEFAULT_NULL', - 'controller' => 'REQUIRED_NAME_OF_THE_CONTROLLER_OR_[]_OR_*' - 'action' => 'REQUIRED_NAME_OF_ACTION_OR_[]_OR_*', - 'allowed' => 'OPTIONAL_BOOLEAN_OR_CALLABLE_OR_INSTANCE_OF_RULE_DEFAULT_TRUE' -] -``` -* If no rule allowed = true is matched for a given user role and url, default return value will be false -* Note for Superadmin access (permission to access ALL THE THINGS in your app) there is a specific Authorize Object provided +* Permissions are evaluated top-down, first matching permission will apply +* Each permission is an associative array of rules with following structure: `'value_to_check' => 'expected_value'` +* `value_to_check` can be any key from user array or one of special keys: + * Routing params: + * `prefix` + * `plugin` + * `extension` + * `controller` + * `action` + * `role` - Alias/shortcut to field defined in `role_field` config value + * `allowed` - see below +* If you have a user field that overlaps with special keys (eg. `$user->allowed`) you can prepend `user.` to key to force matching from user array (eg. `user.allowed`) +* The keys can be placed in any order with exception of `allowed` which must be last one (see below) +* `value_to_check` can be prepended with `*` to match everything except `expected_value` +* `expected_value` can be one of following things: + * `*` will match absolutely everything + * A _string_/_integer_/_boolean_/etc - will match only the specified value + * An _array_ of strings/integers/booleans/etc (can be mixed). The rule will match if real value is equal to any of expected ones + * A callable/object (see below) +* If any of rules fail, the permission is discarded and the next one is evaluated +* A very special key `allowed` exists which has completely different behaviour: + * If `expected_value` is a callable/object then it's executed and the result is casted to boolean + * If `expected_value` is **not** a callable/object then it's simply casted to boolean + * The `*` is checked and if found the result is inverted + * The final boolean value is **the result of permission** checker. This means if it is `false` then no other permissions are checked and the user is denied access. + For this reason the `allowed` key must be placed at the end of permission since no other rules are executed after it -Permission Callbacks ------------------ -You could use a callback in your 'allowed' to process complex authentication, like +**Notes**: + +* For Superadmin access (permission to access ALL THE THINGS in your app) there is a specific Authorize Object provided +* Permissions that do not have `controller` and/or `action` keys (or the inverted versions) are automatically discarded in order to prevent errors. +If you need to match all controllers/actions you can explicitly do `'contoller' => '*'` +* Key `user` (or the inverted version) is illegal (as it's impossible to match an array) and any permission containing it will be discarded +* If the permission is discarded for the reasons stated above, a debug message will be logged + +**Permission Callbacks**: you could use a callback in your 'allowed' to process complex authentication, like - ownership - permissions stored in your database - permission based on an external service API call @@ -78,10 +95,10 @@ You could use a callback in your 'allowed' to process complex authentication, li Example *ownership* callback, to allow users to edit their own Posts: ```php - 'allowed' => function (array $user, $role, Request $request) { - $postId = Hash::get($request->params, 'pass.0'); - $post = TableRegistry::get('Posts')->get($postId); - $userId = Hash::get($user, 'id'); + 'allowed' => function (array $user, $role, \Cake\Network\Request $request) { + $postId = \Cake\Utility\Hash::get($request->params, 'pass.0'); + $post = \Cake\ORM\TableRegistry::get('Posts')->get($postId); + $userId = $user['id']; if (!empty($post->user_id) && !empty($userId)) { return $post->user_id === $userId; } @@ -89,12 +106,36 @@ Example *ownership* callback, to allow users to edit their own Posts: } ``` -Permission Rules ----------------- -You could use an instance of the \CakeDC\Users\Auth\Rules\Rule interface to reuse your custom rules -Examples: +**Permission Rules**: If you see that you are duplicating logic in your callables, you can create rule class to re-use the logic. +For example, the above ownership callback is included in CakeDC\Users as `Owner` rule ```php -'allowed' => new Owner() //will pick by default the post id from the first pass param +'allowed' => new \CakeDC\Users\Auth\Rules\Owner() //will pick by default the post id from the first pass param ``` Check the [Owner Rule](OwnerRule.md) documentation for more details +Creating rule classes +--------------------- + +The only requirement is to implement `\CakeDC\Users\Auth\Rules\Rule` interface which has one method: + +```php +class YourRule implements \CakeDC\Users\Auth\Rules\Rule +{ + /** + * Check the current entity is owned by the logged in user + * + * @param array $user Auth array with the logged in data + * @param string $role role of the user + * @param Request $request current request, used to get a default table if not provided + * @return bool + */ + public function allowed(array $user, $role, Request $request) + { + // Your logic here + } +} +``` + +This logic can be anything: database, external auth, etc. + +Also, if you are using DB, you can choose to extend `\CakeDC\Users\Auth\Rules\AbstractRule` since it provides convenience methods for reading from DB \ No newline at end of file diff --git a/composer.json b/composer.json index 362d13241..0372c618d 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "league/oauth2-instagram": "@stable", "league/oauth2-google": "@stable", "league/oauth2-linkedin": "@stable", - "google/recaptcha": "@stable" + "google/recaptcha": "@stable", + "robthree/twofactorauth": "^1.5.2" }, "suggest": { "league/oauth1-client": "Provides Social Authentication with Twitter", @@ -44,7 +45,8 @@ "league/oauth2-instagram": "Provides Social Authentication with Instagram", "league/oauth2-google": "Provides Social Authentication with Google+", "league/oauth2-linkedin": "Provides Social Authentication with LinkedIn", - "google/recaptcha": "Provides reCAPTCHA validation for registration form" + "google/recaptcha": "Provides reCAPTCHA validation for registration form", + "robthree/twofactorauth": "Provides Google Authenticator functionality" }, "autoload": { "psr-4": { diff --git a/config/Migrations/20161031101316_AddSecretToUsers.php b/config/Migrations/20161031101316_AddSecretToUsers.php new file mode 100644 index 000000000..82f9f44f4 --- /dev/null +++ b/config/Migrations/20161031101316_AddSecretToUsers.php @@ -0,0 +1,33 @@ +table('users'); + /** + * Limiting secret field to 32 chars + * @see https://en.wikipedia.org/wiki/Google_Authenticator#Technical_description + */ + $table->addColumn('secret', 'string', [ + 'after' => 'activation_date', + 'default' => null, + 'limit' => 32, + 'null' => true, + ]); + $table->addColumn('secret_verified', 'boolean', [ + 'after' => 'secret', + 'default' => null, + 'null' => true, + ]); + $table->update(); + } +} diff --git a/config/routes.php b/config/routes.php index c3252f946..97a7aa111 100644 --- a/config/routes.php +++ b/config/routes.php @@ -28,3 +28,5 @@ Router::connect('/profile/*', ['plugin' => 'CakeDC/Users', 'controller' => 'Users', 'action' => 'profile']); Router::connect('/login', ['plugin' => 'CakeDC/Users', 'controller' => 'Users', 'action' => 'login']); Router::connect('/logout', ['plugin' => 'CakeDC/Users', 'controller' => 'Users', 'action' => 'logout']); +Router::connect('/verify', ['plugin' => 'CakeDC/Users', 'controller' => 'Users', 'action' => 'verify']); + diff --git a/config/users.php b/config/users.php index 7d2662ec1..0b8627148 100644 --- a/config/users.php +++ b/config/users.php @@ -56,6 +56,23 @@ //enable social login 'login' => false, ], + 'GoogleAuthenticator' => [ + //enable Google Authenticator + 'login' => false, + 'issuer' => null, + // The number of digits the resulting codes will be + 'digits' => 6, + // The number of seconds a code will be valid + 'period' => 30, + // The algorithm used + 'algorithm' => 'sha1', + // QR-code provider (more on this later) + 'qrcodeprovider' => null, + // Random Number Generator provider (more on this later) + 'rngprovider' => null, + // Key used for encrypting the user credentials, leave this false to use Security.salt + 'encryptionKey' => false + ], 'Profile' => [ //Allow view other users profiles 'viewOthers' => true, @@ -95,13 +112,21 @@ ] ], ], + 'GoogleAuthenticator' => [ + 'verifyAction' => [ + 'plugin' => 'CakeDC/Users', + 'controller' => 'Users', + 'action' => 'verify', + 'prefix' => false, + ], + ], //default configuration used to auto-load the Auth Component, override to change the way Auth works 'Auth' => [ 'loginAction' => [ 'plugin' => 'CakeDC/Users', 'controller' => 'Users', 'action' => 'login', - 'prefix' => null + 'prefix' => false ], 'authenticate' => [ 'all' => [ diff --git a/src/Auth/ApiKeyAuthenticate.php b/src/Auth/ApiKeyAuthenticate.php index 2a7d358bf..68ed80fbf 100644 --- a/src/Auth/ApiKeyAuthenticate.php +++ b/src/Auth/ApiKeyAuthenticate.php @@ -38,6 +38,10 @@ class ApiKeyAuthenticate extends BaseAuthenticate 'field' => 'api_token', //require SSL to pass the token. You should always require SSL to use tokens for Auth 'require_ssl' => true, + //set a specific table for API auth, set as null to use Users.table + 'table' => null, + //set a specific finder for API auth, set as null to use Auth.authenticate.all.finder + 'finder' => null, ]; /** @@ -87,8 +91,8 @@ public function getUser(Request $request) } $this->_config['fields']['username'] = $this->config('field'); - $this->_config['userModel'] = Configure::read('Users.table'); - $this->_config['finder'] = 'all'; + $this->_config['userModel'] = $this->config('table') ?: Configure::read('Users.table'); + $this->_config['finder'] = $this->config('finder') ?: Configure::read('Auth.authenticate.all.finder') ?: 'all'; $result = $this->_query($apiKey)->first(); if (empty($result)) { diff --git a/src/Auth/Rules/AbstractRule.php b/src/Auth/Rules/AbstractRule.php index 17dacc8a5..0517e3d71 100644 --- a/src/Auth/Rules/AbstractRule.php +++ b/src/Auth/Rules/AbstractRule.php @@ -10,7 +10,10 @@ */ namespace CakeDC\Users\Auth\Rules; +use Cake\Core\InstanceConfigTrait; +use Cake\Datasource\ModelAwareTrait; use Cake\Network\Request; +use Cake\ORM\Locator\LocatorAwareTrait; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Utility\Hash; @@ -22,9 +25,9 @@ */ abstract class AbstractRule implements Rule { - use \Cake\Core\InstanceConfigTrait; - use \Cake\Datasource\ModelAwareTrait; - use \Cake\ORM\Locator\LocatorAwareTrait; + use InstanceConfigTrait; + use LocatorAwareTrait; + use ModelAwareTrait; /** * @var array default config @@ -46,7 +49,7 @@ public function __construct($config = []) * @param Request $request request * @param mixed $table table * @return \Cake\ORM\Table - * @throw OutOfBoundsException if table alias is empty + * @throws \OutOfBoundsException if table alias is empty */ protected function _getTable(Request $request, $table = null) { @@ -65,7 +68,7 @@ protected function _getTable(Request $request, $table = null) * * @param Request $request request * @return Table - * @throws OutOfBoundsException if table alias can't be extracted from request + * @throws \OutOfBoundsException if table alias can't be extracted from request */ protected function _getTableFromRequest(Request $request) { @@ -88,7 +91,7 @@ protected function _getTableFromRequest(Request $request) * @param string $role role of the user * @param Request $request current request, used to get a default table if not provided * @return bool - * @throws OutOfBoundsException if table is not found or it doesn't have the expected fields + * @throws \OutOfBoundsException if table is not found or it doesn't have the expected fields */ abstract public function allowed(array $user, $role, Request $request); } diff --git a/src/Auth/Rules/Rule.php b/src/Auth/Rules/Rule.php index 7ba6c438d..9acd999d3 100644 --- a/src/Auth/Rules/Rule.php +++ b/src/Auth/Rules/Rule.php @@ -21,7 +21,6 @@ interface Rule * @param string $role role of the user * @param Request $request current request, used to get a default table if not provided * @return bool - * @throws \OutOfBoundsException if table is not found or it doesn't have the expected fields */ public function allowed(array $user, $role, Request $request); } diff --git a/src/Auth/SimpleRbacAuthorize.php b/src/Auth/SimpleRbacAuthorize.php index d7e022e9f..120bdeaa7 100644 --- a/src/Auth/SimpleRbacAuthorize.php +++ b/src/Auth/SimpleRbacAuthorize.php @@ -120,7 +120,7 @@ public function __construct(ComponentRegistry $registry, array $config = []) parent::__construct($registry, $config); $autoload = $this->config('autoload_config'); if ($autoload) { - $loadedPermissions = $this->_loadPermissions($autoload, 'default'); + $loadedPermissions = $this->_loadPermissions($autoload); $this->config('permissions', $loadedPermissions); } } @@ -166,7 +166,7 @@ public function authorize($user, Request $request) $role = Hash::get($user, $roleField); } - $allowed = $this->_checkRules($user, $role, $request); + $allowed = $this->_checkPermissions($user, $role, $request); return $allowed; } @@ -180,11 +180,11 @@ public function authorize($user, Request $request) * @param Request $request request * @return bool true if there is a match in permissions */ - protected function _checkRules(array $user, $role, Request $request) + protected function _checkPermissions(array $user, $role, Request $request) { $permissions = $this->config('permissions'); foreach ($permissions as $permission) { - $allowed = $this->_matchRule($permission, $user, $role, $request); + $allowed = $this->_matchPermission($permission, $user, $role, $request); if ($allowed !== null) { return $allowed; } @@ -196,42 +196,77 @@ protected function _checkRules(array $user, $role, Request $request) /** * Match the rule for current permission * - * @param array $permission configuration - * @param array $user current user - * @param string $role effective user role - * @param Request $request request - * @return bool if rule matched, null if rule not matched + * @param array $permission The permission configuration + * @param array $user Current user data + * @param string $role Effective user's role + * @param \Cake\Network\Request $request Current request + * + * @return null|bool Null if permission is discarded, boolean if a final result is produced */ - protected function _matchRule($permission, $user, $role, $request) + protected function _matchPermission(array $permission, array $user, $role, Request $request) { - $plugin = $request->plugin; - $controller = $request->controller; - $action = $request->action; - $prefix = null; - $extension = null; - if (!empty($request->params['prefix'])) { - $prefix = $request->params['prefix']; + $issetController = isset($permission['controller']) || isset($permission['*controller']); + $issetAction = isset($permission['action']) || isset($permission['*action']); + $issetUser = isset($permission['user']) || isset($permission['*user']); + + if (!$issetController || !$issetAction) { + $this->log( + __d('CakeDC/Users', "Cannot evaluate permission when 'controller' and/or 'action' keys are absent"), + LogLevel::DEBUG + ); + + return false; } - if (!empty($request->params['_ext'])) { - $extension = $request->params['_ext']; + if ($issetUser) { + $this->log( + __d('CakeDC/Users', "Permission key 'user' is illegal, cannot evaluate the permission"), + LogLevel::DEBUG + ); + + return false; } - if ($this->_matchOrAsterisk($permission, 'role', $role) && - $this->_matchOrAsterisk($permission, 'prefix', $prefix, true) && - $this->_matchOrAsterisk($permission, 'plugin', $plugin, true) && - $this->_matchOrAsterisk($permission, 'extension', $extension, true) && - $this->_matchOrAsterisk($permission, 'controller', $controller) && - $this->_matchOrAsterisk($permission, 'action', $action)) { - $allowed = Hash::get($permission, 'allowed'); - if ($allowed === null) { - //allowed will be true by default - return true; - } elseif (is_callable($allowed)) { - return (bool)call_user_func($allowed, $user, $role, $request); - } elseif ($allowed instanceof Rule) { - return (bool)$allowed->allowed($user, $role, $request); + $permission += ['allowed' => true]; + $userArr = ['user' => $user]; + $reserved = [ + 'prefix' => Hash::get($request->params, 'prefix'), + 'plugin' => $request->plugin, + 'extension' => Hash::get($request->params, '_ext'), + 'controller' => $request->controller, + 'action' => $request->action, + 'role' => $role + ]; + + foreach ($permission as $key => $value) { + $inverse = $this->_startsWith($key, '*'); + if ($inverse) { + $key = ltrim($key, '*'); + } + + if (is_callable($value)) { + $return = (bool)call_user_func($value, $user, $role, $request); + } elseif ($value instanceof Rule) { + $return = (bool)$value->allowed($user, $role, $request); + } elseif ($key === 'allowed') { + $return = (bool)$value; + } elseif (array_key_exists($key, $reserved)) { + $return = $this->_matchOrAsterisk($value, $reserved[$key], true); } else { - return (bool)$allowed; + if (!$this->_startsWith($key, 'user.')) { + $key = 'user.' . $key; + } + + $return = $this->_matchOrAsterisk($value, Hash::get($userArr, $key)); + } + + if ($inverse) { + $return = !$return; + } + if ($key === 'allowed') { + return $return; + } + if (!$return) { + break; } } @@ -241,24 +276,42 @@ protected function _matchRule($permission, $user, $role, $request) /** * Check if rule matched or '*' present in rule matching anything * - * @param string $permission permission configuration - * @param string $key key to retrieve and check in permissions configuration - * @param string $value value to check with (coming from the request) We'll check the DASHERIZED value too - * @param bool $allowEmpty true if we allow + * @param string|array $possibleValues Values that are accepted (from permission config) + * @param string|mixed|null $value Value to check with. We'll check the DASHERIZED value too + * @param bool $allowEmpty If true and $value is null, the rule will pass + * * @return bool */ - protected function _matchOrAsterisk($permission, $key, $value, $allowEmpty = false) + protected function _matchOrAsterisk($possibleValues, $value, $allowEmpty = false) { - $possibleValues = (array)Hash::get($permission, $key); - if ($allowEmpty && empty($possibleValues) && $value === null) { + $possibleArray = (array)$possibleValues; + + if ($allowEmpty && empty($possibleArray) && $value === null) { return true; } - if (Hash::get($permission, $key) === '*' || - in_array($value, $possibleValues) || - in_array(Inflector::camelize($value, '-'), $possibleValues)) { + + if ($possibleValues === '*' || + in_array($value, $possibleArray) || + in_array(Inflector::camelize($value, '-'), $possibleArray) + ) { return true; } return false; } + + /** + * Checks if $heystack begins with $needle + * + * @see http://stackoverflow.com/a/7168986/2588539 + * + * @param string $haystack The whole string + * @param string $needle The beginning to check + * + * @return bool + */ + protected function _startsWith($haystack, $needle) + { + return substr($haystack, 0, strlen($needle)) === $needle; + } } diff --git a/src/Controller/Component/GoogleAuthenticatorComponent.php b/src/Controller/Component/GoogleAuthenticatorComponent.php new file mode 100644 index 000000000..c9c9034a9 --- /dev/null +++ b/src/Controller/Component/GoogleAuthenticatorComponent.php @@ -0,0 +1,72 @@ +tfa = new TwoFactorAuth( + Configure::read('Users.GoogleAuthenticator.issuer'), + Configure::read('Users.GoogleAuthenticator.digits'), + Configure::read('Users.GoogleAuthenticator.period'), + Configure::read('Users.GoogleAuthenticator.algorithm'), + Configure::read('Users.GoogleAuthenticator.qrcodeprovider'), + Configure::read('Users.GoogleAuthenticator.rngprovider'), + Configure::read('Users.GoogleAuthenticator.encryptionKey') + ); + } + } + + /** + * createSecret + * @return base32 shared secret stored in users table + */ + public function createSecret() + { + return $this->tfa->createSecret(); + } + + /** + * verifyCode + * Verifying tfa code with shared secret + * @param string $secret of the user + * @param string $code from verification form + * @return bool + */ + public function verifyCode($secret, $code) + { + return $this->tfa->verifyCode($secret, $code); + } + + /** + * getQRCodeImageAsDataUri + * @return string base64 string containing QR code for shared secret + */ + public function getQRCodeImageAsDataUri($issuer, $secret) + { + return $this->tfa->getQRCodeImageAsDataUri($issuer, $secret); + } +} diff --git a/src/Controller/Component/UsersAuthComponent.php b/src/Controller/Component/UsersAuthComponent.php index 3257e607d..b3ee322fe 100644 --- a/src/Controller/Component/UsersAuthComponent.php +++ b/src/Controller/Component/UsersAuthComponent.php @@ -52,9 +52,23 @@ public function initialize(array $config) $this->_loadRememberMe(); } + if (Configure::read('Users.GoogleAuthenticator.login')) { + $this->_loadGoogleAuthenticator(); + } + $this->_attachPermissionChecker(); } + /** + * Load GoogleAuthenticator object + * + * @return void + */ + protected function _loadGoogleAuthenticator() + { + $this->_registry->getController()->loadComponent('CakeDC/Users.GoogleAuthenticator'); + } + /** * Load Social Auth object * @@ -110,7 +124,8 @@ protected function _initAuth() 'requestResetPassword', 'changePassword', 'endpoint', - 'authenticated' + 'authenticated', + 'verify' ]); } diff --git a/src/Controller/Traits/LoginTrait.php b/src/Controller/Traits/LoginTrait.php index 1aa16833d..a39e58e4e 100644 --- a/src/Controller/Traits/LoginTrait.php +++ b/src/Controller/Traits/LoginTrait.php @@ -17,6 +17,7 @@ use CakeDC\Users\Exception\UserNotActiveException; use CakeDC\Users\Model\Table\SocialAccountsTable; use Cake\Core\Configure; +use Cake\Core\Exception\Exception; use Cake\Event\Event; use Cake\Network\Exception\NotFoundException; use League\OAuth1\Client\Server\Twitter; @@ -155,6 +156,7 @@ public function login() } $socialLogin = $this->_isSocialLogin(); + $googleAuthenticatorLogin = $this->_isGoogleAuthenticator(); if ($this->request->is('post')) { if (!$this->_checkReCaptcha()) { @@ -164,8 +166,9 @@ public function login() } $user = $this->Auth->identify(); - return $this->_afterIdentifyUser($user, $socialLogin); + return $this->_afterIdentifyUser($user, $socialLogin, $googleAuthenticatorLogin); } + if (!$this->request->is('post') && !$socialLogin) { if ($this->Auth->user()) { $msg = __d('CakeDC/Users', 'You are already logged in'); @@ -177,6 +180,96 @@ public function login() } } + /** + * Verify for Google Authenticator + * If Google Authenticator's enabled we need to verify + * authenticated user. To avoid accidental access to + * other URL's we store auth'ed used into temporary session + * to perform code verification. + * + * @return void + */ + public function verify() + { + if (!Configure::read('Users.GoogleAuthenticator.login')) { + $message = __d('CakeDC/Users', 'Please enable Google Authenticator first.'); + $this->Flash->error($message, 'default', [], 'auth'); + + $this->redirect(Configure::read('Auth.loginAction')); + } + + // storing user's session in the temporary one + // until the GA verification is checked + $temporarySession = $this->Auth->user(); + $this->request->session()->delete('Auth.User'); + + if (!empty($temporarySession)) { + $this->request->session()->write('temporarySession', $temporarySession); + } + + if (array_key_exists('secret', $temporarySession)) { + $secret = $temporarySession['secret']; + } + + $secretVerified = $temporarySession['secret_verified']; + + // showing QR-code until shared secret is verified + if (!$secretVerified) { + if (empty($secret)) { + $secret = $this->GoogleAuthenticator->createSecret(); + + // catching sql exception in case of any sql inconsistencies + try { + $query = $this->getUsersTable()->query(); + $query->update() + ->set(['secret' => $secret]) + ->where(['id' => $temporarySession['id']]); + $executed = $query->execute(); + } catch (\Exception $e) { + $this->request->session()->destroy(); + $message = __d('CakeDC/Users', $e->getMessage()); + $this->Flash->error($message, 'default', [], 'auth'); + + return $this->redirect(Configure::read('Auth.loginAction')); + } + } + + $this->set('secretDataUri', $this->GoogleAuthenticator->getQRCodeImageAsDataUri($temporarySession['email'], $secret)); + } + + if ($this->request->is('post')) { + $verificationCode = $this->request->data('code'); + $user = $this->request->session()->read('temporarySession'); + + if (array_key_exists('secret', $user)) { + $codeVerified = $this->GoogleAuthenticator->verifyCode($user['secret'], $verificationCode); + } + + if ($codeVerified) { + unset($user['secret']); + + if (!$user['secret_verified']) { + $this->getUsersTable()->query()->update() + ->set(['secret_verified' => true]) + ->where(['id' => $user['id']]) + ->execute(); + } + + $this->request->session()->delete('temporarySession'); + $this->request->session()->write('Auth.User', $user); + $url = $this->Auth->redirectUrl(); + + return $this->redirect($url); + } else { + $this->request->session()->destroy(); + $message = __d('CakeDC/Users', 'Verification code is invalid. Try again'); + $this->Flash->error($message, 'default', [], 'auth'); + + return $this->redirect(Configure::read('Auth.loginAction')); + } + } + } + /** * Check reCaptcha if enabled for login * @@ -200,15 +293,22 @@ protected function _checkReCaptcha() * @param bool $socialLogin is social login * @return array */ - protected function _afterIdentifyUser($user, $socialLogin = false) + protected function _afterIdentifyUser($user, $socialLogin = false, $googleAuthenticatorLogin = false) { if (!empty($user)) { $this->Auth->setUser($user); + if ($googleAuthenticatorLogin) { + $url = Configure::read('GoogleAuthenticator.verifyAction'); + + return $this->redirect($url); + } + $event = $this->dispatchEvent(UsersAuthComponent::EVENT_AFTER_LOGIN, ['user' => $user]); if (is_array($event->result)) { return $this->redirect($event->result); } + $url = $this->Auth->redirectUrl(); return $this->redirect($url); @@ -256,4 +356,13 @@ protected function _isSocialLogin() return Configure::read('Users.Social.login') && $this->request->session()->check(Configure::read('Users.Key.Session.social')); } + + /** + * Check if we doing Google Authenticator Two Factor auth + * @return bool true if Google Authenticator is enabled + */ + protected function _isGoogleAuthenticator() + { + return Configure::read('Users.GoogleAuthenticator.login'); + } } diff --git a/src/Template/Users/verify.ctp b/src/Template/Users/verify.ctp new file mode 100644 index 000000000..087fdaa6e --- /dev/null +++ b/src/Template/Users/verify.ctp @@ -0,0 +1,20 @@ +