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 @@ +
+
+
+
+ Form->create() ?> + + Flash->render('auth') ?> + Flash->render() ?> +
+ +

+ + Form->input('code', ['required' => true, 'label' => __d('CakeDC/Users', 'Verification Code')]) ?> +
+ Form->button(__d('CakeDC/Users', ' Verify'), ['class' => 'btn btn-primary']); ?> + Form->end() ?> +
+
+
+
diff --git a/src/View/Helper/AuthLinkHelper.php b/src/View/Helper/AuthLinkHelper.php index 648e64423..2b02c3fcb 100644 --- a/src/View/Helper/AuthLinkHelper.php +++ b/src/View/Helper/AuthLinkHelper.php @@ -18,15 +18,21 @@ class AuthLinkHelper extends HtmlHelper * * @param string $title link's title. * @param string|array|null $url url that the user is making request. - * @param array $options Array with option data. - * @return string + * @param array $options Array with option data. Extra options include + * 'before' and 'after' to quickly inject some html code in the link, like icons etc + * 'allowed' to manage if the link should be displayed, default is null to check isAuthorized + * @return string|bool */ public function link($title, $url = null, array $options = []) { - if ($this->isAuthorized($url)) { - $linkOptions = $options; - unset($linkOptions['before'], $linkOptions['after']); + $linkOptions = $options; + unset($linkOptions['before'], $linkOptions['after'], $linkOptions['allowed']); + $allowed = Hash::get($options, 'allowed'); + if ($allowed === false) { + return false; + } + if ($allowed === true || $this->isAuthorized($url)) { return Hash::get($options, 'before') . parent::link($title, $url, $linkOptions) . Hash::get($options, 'after'); } diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php index 1577a0783..bcbc85458 100644 --- a/tests/Fixture/UsersFixture.php +++ b/tests/Fixture/UsersFixture.php @@ -37,6 +37,8 @@ class UsersFixture extends TestFixture 'token_expires' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 'api_token' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 'activation_date' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'secret' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], + 'secret_verified' => ['type' => 'boolean', 'length' => null, 'null' => true, 'default' => false, 'comment' => '', 'precision' => null], 'tos_date' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 'active' => ['type' => 'boolean', 'length' => null, 'null' => false, 'default' => true, 'comment' => '', 'precision' => null], 'is_superuser' => ['type' => 'boolean', 'length' => null, 'unsigned' => false, 'null' => false, 'default' => false, 'comment' => '', 'precision' => null, 'autoIncrement' => null], @@ -70,6 +72,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2035-06-24 17:33:54', 'api_token' => 'yyy', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'yyy', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => false, 'is_superuser' => true, @@ -88,6 +92,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2015-06-24 17:33:54', 'api_token' => 'xxx', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'xxx', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => true, @@ -106,6 +112,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2030-06-20 17:33:54', 'api_token' => 'xxx', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'xxx', + 'is_superuser' => true, 'tos_date' => '2015-06-24 17:33:54', 'active' => false, 'is_superuser' => true, @@ -124,6 +132,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2030-06-24 17:33:54', 'api_token' => 'Lorem ipsum dolor sit amet', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'is_superuser' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => false, @@ -142,6 +152,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2015-06-24 17:33:54', 'api_token' => '', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => '', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => false, @@ -160,6 +172,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2015-06-24 17:33:54', 'api_token' => '', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => '', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => false, @@ -178,6 +192,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2015-06-24 17:33:54', 'api_token' => 'Lorem ipsum dolor sit amet', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => false, @@ -196,6 +212,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2015-06-24 17:33:54', 'api_token' => 'Lorem ipsum dolor sit amet', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => false, @@ -214,6 +232,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2015-06-24 17:33:54', 'api_token' => 'Lorem ipsum dolor sit amet', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => false, @@ -232,6 +252,8 @@ class UsersFixture extends TestFixture 'token_expires' => '2015-06-24 17:33:54', 'api_token' => 'Lorem ipsum dolor sit amet', 'activation_date' => '2015-06-24 17:33:54', + 'secret' => 'Lorem ipsum dolor sit amet', + 'secret_verified' => false, 'tos_date' => '2015-06-24 17:33:54', 'active' => true, 'is_superuser' => false, diff --git a/tests/TestCase/Auth/ApiKeyAuthenticateTest.php b/tests/TestCase/Auth/ApiKeyAuthenticateTest.php index 90a95c28e..e3ab0ec59 100644 --- a/tests/TestCase/Auth/ApiKeyAuthenticateTest.php +++ b/tests/TestCase/Auth/ApiKeyAuthenticateTest.php @@ -13,6 +13,7 @@ use CakeDC\Users\Auth\ApiKeyAuthenticate; use Cake\Controller\ComponentRegistry; +use Cake\Core\Configure; use Cake\Network\Request; use Cake\Network\Response; use Cake\TestSuite\TestCase; @@ -62,9 +63,9 @@ public function tearDown() */ public function testAuthenticateHappy() { - $request = new Request('/?api_key=yyy'); + $request = new Request('/?api_key=xxx'); $result = $this->apiKey->authenticate($request, new Response()); - $this->assertEquals('user-1', $result['username']); + $this->assertEquals('user-2', $result['username']); } /** @@ -85,6 +86,10 @@ public function testAuthenticateFail() $request = new Request('/?api_key='); $result = $this->apiKey->authenticate($request, new Response()); $this->assertFalse($result); + + $request = new Request('/?api_key=yyy'); + $result = $this->apiKey->authenticate($request, new Response()); + $this->assertFalse($result); } /** @@ -140,10 +145,10 @@ public function testHeaderHappy() $request->expects($this->once()) ->method('header') ->with('api_key') - ->will($this->returnValue('yyy')); + ->will($this->returnValue('xxx')); $this->apiKey->config('type', 'header'); $result = $this->apiKey->authenticate($request, new Response()); - $this->assertEquals('user-1', $result['username']); + $this->assertEquals('user-2', $result['username']); } /** @@ -164,4 +169,45 @@ public function testAuthenticateHeaderFail() $result = $this->apiKey->authenticate($request, new Response()); $this->assertFalse($result); } + + /** + * test + * + * @return void + * @expectedException \BadMethodCallException + * @expectedExceptionMessage Unknown finder method "undefinedInConfig" + */ + public function testAuthenticateFinderConfig() + { + $this->apiKey->config('finder', 'undefinedInConfig'); + $request = new Request('/?api_key=xxx'); + $result = $this->apiKey->authenticate($request, new Response()); + } + + /** + * test + * + * @return void + * @expectedException \BadMethodCallException + * @expectedExceptionMessage Unknown finder method "undefinedFinderInAuth" + */ + public function testAuthenticateFinderAuthConfig() + { + Configure::write('Auth.authenticate.all.finder', 'undefinedFinderInAuth'); + $request = new Request('/?api_key=xxx'); + $result = $this->apiKey->authenticate($request, new Response()); + } + + /** + * test + * + * @return void + */ + public function testAuthenticateDefaultAllFinder() + { + Configure::write('Auth.authenticate.all.finder', null); + $request = new Request('/?api_key=yyy'); + $result = $this->apiKey->authenticate($request, new Response()); + $this->assertEquals('user-1', $result['username']); + } } diff --git a/tests/TestCase/Auth/SimpleRbacAuthorizeTest.php b/tests/TestCase/Auth/SimpleRbacAuthorizeTest.php index 205064230..0d4eb865b 100644 --- a/tests/TestCase/Auth/SimpleRbacAuthorizeTest.php +++ b/tests/TestCase/Auth/SimpleRbacAuthorizeTest.php @@ -14,12 +14,11 @@ use CakeDC\Users\Auth\Rules\Rule; use CakeDC\Users\Auth\SimpleRbacAuthorize; use Cake\Controller\ComponentRegistry; -use Cake\Controller\Controller; -use Cake\Event\EventManager; use Cake\Network\Request; use Cake\Network\Response; use Cake\TestSuite\TestCase; use Cake\Utility\Hash; +use Psr\Log\LogLevel; use ReflectionClass; class SimpleRbacAuthorizeTest extends TestCase @@ -98,8 +97,8 @@ public function testConstruct() public function testLoadPermissions() { $this->simpleRbacAuthorize = $this->getMockBuilder('CakeDC\Users\Auth\SimpleRbacAuthorize') - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $reflectedClass = new ReflectionClass($this->simpleRbacAuthorize); $loadPermissions = $reflectedClass->getMethod('_loadPermissions'); $loadPermissions->setAccessible(true); @@ -136,20 +135,22 @@ protected function assertConstructorPermissions($instance, $config, $permissions */ public function testConstructPermissionsFileHappy() { - $permissions = [[ - 'controller' => 'Test', - 'action' => 'test' - ]]; + $permissions = [ + [ + 'controller' => 'Test', + 'action' => 'test' + ] + ]; $className = 'CakeDC\Users\Auth\SimpleRbacAuthorize'; $this->simpleRbacAuthorize = $this->getMockBuilder($className) - ->setMethods(['_loadPermissions']) - ->disableOriginalConstructor() - ->getMock(); + ->setMethods(['_loadPermissions']) + ->disableOriginalConstructor() + ->getMock(); $this->simpleRbacAuthorize - ->expects($this->once()) - ->method('_loadPermissions') - ->with('permissions-happy') - ->will($this->returnValue($permissions)); + ->expects($this->once()) + ->method('_loadPermissions') + ->with('permissions-happy') + ->will($this->returnValue($permissions)); $this->assertConstructorPermissions($className, ['autoload_config' => 'permissions-happy'], $permissions); } @@ -157,9 +158,9 @@ protected function preparePermissions($permissions) { $className = 'CakeDC\Users\Auth\SimpleRbacAuthorize'; $simpleRbacAuthorize = $this->getMockBuilder($className) - ->setMethods(['_loadPermissions']) - ->disableOriginalConstructor() - ->getMock(); + ->setMethods(['_loadPermissions']) + ->disableOriginalConstructor() + ->getMock(); $simpleRbacAuthorize->config('permissions', $permissions); return $simpleRbacAuthorize; @@ -171,19 +172,7 @@ protected function preparePermissions($permissions) public function testAuthorize($permissions, $user, $requestParams, $expected, $msg = null) { $this->simpleRbacAuthorize = $this->preparePermissions($permissions); - $request = new Request(); - $request->plugin = Hash::get($requestParams, 'plugin'); - $request->controller = $requestParams['controller']; - $request->action = $requestParams['action']; - $prefix = Hash::get($requestParams, 'prefix'); - $request->params = []; - if ($prefix) { - $request->params['prefix'] = $prefix; - } - $extension = Hash::get($requestParams, '_ext'); - if ($extension) { - $request->params['_ext'] = $extension; - } + $request = $this->_requestFromArray($requestParams); $result = $this->simpleRbacAuthorize->authorize($user, $request); $this->assertSame($expected, $result, $msg); @@ -199,6 +188,209 @@ public function providerAuthorize() ->willReturn(true); return [ + 'discard-first' => [ + //permissions + [ + [ + 'role' => 'test', + 'controller' => 'Tests', + 'action' => 'three', // Discard here + function () { + throw new \Exception(); + } + ], + [ + 'plugin' => ['Tests'], + 'role' => ['test'], + 'controller' => ['Tests'], + 'action' => ['one', 'two'], + ], + ], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'plugin' => 'Tests', + 'controller' => 'Tests', + 'action' => 'one' + ], + //expected + true + ], + 'deny-first-discard-after' => [ + //permissions + [ + [ + 'role' => 'test', + 'controller' => 'Tests', + 'action' => 'one', + 'allowed' => function () { + return false; // Deny here since under 'allowed' key + } + ], + [ + // This permission isn't evaluated + function () { + throw new \Exception(); + }, + 'plugin' => ['Tests'], + 'role' => ['test'], + 'controller' => ['Tests'], + 'action' => ['one', 'two'], + ], + ], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'plugin' => 'Tests', + 'controller' => 'Tests', + 'action' => 'one' + ], + //expected + false + ], + 'star-invert' => [ + //permissions + [[ + '*plugin' => 'Tests', + '*role' => 'test', + '*controller' => 'Tests', + '*action' => 'test', + ]], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'something', + ], + //request + [ + 'plugin' => 'something', + 'controller' => 'something', + 'action' => 'something' + ], + //expected + true + ], + 'star-invert-deny' => [ + //permissions + [[ + '*plugin' => 'Tests', + '*role' => 'test', + '*controller' => 'Tests', + '*action' => 'test', + ]], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'something', + ], + //request + [ + 'plugin' => 'something', + 'controller' => 'something', + 'action' => 'test' + ], + //expected + false + ], + 'user-arr' => [ + //permissions + [ + [ + 'username' => 'luke', + 'user.id' => 1, + 'profile.id' => 256, + 'user.profile.signature' => "Hi I'm luke", + 'user.allowed' => false, + 'controller' => 'Tests', + 'action' => 'one' + ], + ], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + 'profile' => [ + 'id' => 256, + 'signature' => "Hi I'm luke" + ], + 'allowed' => false + ], + //request + [ + 'controller' => 'Tests', + 'action' => 'one' + ], + //expected + true + ], + 'evaluate-order' => [ + //permissions + [ + [ + 'allowed' => false, + function () { + throw new \Exception(); + }, + 'controller' => 'Tests', + 'action' => 'one' + ], + ], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'controller' => 'Tests', + 'action' => 'one' + ], + //expected + false + ], + 'multiple-callables' => [ + //permissions + [ + [ + function () { + return true; + }, + clone $trueRuleMock, + function () { + return true; + }, + 'controller' => 'Tests', + 'action' => 'one' + ], + ], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'controller' => 'Tests', + 'action' => 'one' + ], + //expected + true + ], 'happy-strict-all' => [ //permissions [[ @@ -501,7 +693,7 @@ public function providerAuthorize() //expected true ], - 'happy-array' => [ + 'happy-array-deny' => [ //permissions [[ 'plugin' => ['Tests'], @@ -667,23 +859,15 @@ public function providerAuthorize() //expected true ], - 'array-prefix' => [ + 'array-prefix-deny' => [ //permissions - [ - [ - 'role' => ['test'], - 'prefix' => ['one', 'admin'], - 'controller' => '*', - 'action' => 'one', - 'allowed' => false, - ], - [ - 'role' => ['test'], - 'prefix' => ['one', 'admin'], - 'controller' => '*', - 'action' => '*', - ], - ], + [[ + 'role' => ['test'], + 'prefix' => ['one', 'admin'], + 'controller' => '*', + 'action' => 'one', + 'allowed' => false, + ]], //user [ 'id' => 1, @@ -796,23 +980,15 @@ public function providerAuthorize() //expected true ], - 'array-ext' => [ + 'array-ext-deny' => [ //permissions - [ - [ - 'role' => ['test'], - 'extension' => ['csv', 'docx'], - 'controller' => '*', - 'action' => 'one', - 'allowed' => false, - ], - [ - 'role' => ['test'], - 'extension' => ['csv', 'docx'], - 'controller' => '*', - 'action' => '*', - ], - ], + [[ + 'role' => ['test'], + 'extension' => ['csv', 'docx'], + 'controller' => '*', + 'action' => 'one', + 'allowed' => false, + ]], //user [ 'id' => 1, @@ -854,4 +1030,180 @@ public function providerAuthorize() ], ]; } + + /** + * @dataProvider badPermissionProvider + * + * @param array $permissions + * @param array $user + * @param array $requestParams + * @param string $expectedMsg + */ + public function testBadPermission($permissions, $user, $requestParams, $expectedMsg) + { + $simpleRbacAuthorize = $this->getMockBuilder(SimpleRbacAuthorize::class) + ->setMethods(['_loadPermissions', 'log']) + ->disableOriginalConstructor() + ->getMock(); + $simpleRbacAuthorize + ->expects($this->once()) + ->method('log') + ->with($expectedMsg, LogLevel::DEBUG); + + $simpleRbacAuthorize->config('permissions', $permissions); + $request = $this->_requestFromArray($requestParams); + + $simpleRbacAuthorize->authorize($user, $request); + } + + public function badPermissionProvider() + { + return [ + 'no-controller' => [ + //permissions + [[ + 'plugin' => 'Tests', + 'role' => 'test', + //'controller' => 'Tests', + 'action' => 'test', + 'allowed' => true, + ]], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'plugin' => 'Tests', + 'controller' => 'Tests', + 'action' => 'test' + ], + //expected + __d('CakeDC/Users', "Cannot evaluate permission when 'controller' and/or 'action' keys are absent"), + ], + 'no-action' => [ + //permissions + [[ + 'plugin' => 'Tests', + 'role' => 'test', + 'controller' => 'Tests', + //'action' => 'test', + 'allowed' => true, + ]], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'plugin' => 'Tests', + 'controller' => 'Tests', + 'action' => 'test' + ], + //expected + __d('CakeDC/Users', "Cannot evaluate permission when 'controller' and/or 'action' keys are absent"), + ], + 'no-controller-and-action' => [ + //permissions + [[ + 'plugin' => 'Tests', + 'role' => 'test', + //'controller' => 'Tests', + //'action' => 'test', + 'allowed' => true, + ]], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'plugin' => 'Tests', + 'controller' => 'Tests', + 'action' => 'test' + ], + //expected + __d('CakeDC/Users', "Cannot evaluate permission when 'controller' and/or 'action' keys are absent"), + ], + 'no-controller and user-key' => [ + //permissions + [[ + 'plugin' => 'Tests', + 'role' => 'test', + //'controller' => 'Tests', + 'action' => 'test', + 'allowed' => true, + 'user' => 'something', + ]], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'plugin' => 'Tests', + 'controller' => 'Tests', + 'action' => 'test' + ], + //expected + __d('CakeDC/Users', "Cannot evaluate permission when 'controller' and/or 'action' keys are absent"), + ], + 'user-key' => [ + //permissions + [[ + 'plugin' => 'Tests', + 'role' => 'test', + 'controller' => 'Tests', + 'action' => 'test', + 'allowed' => true, + 'user' => 'something', + ]], + //user + [ + 'id' => 1, + 'username' => 'luke', + 'role' => 'test', + ], + //request + [ + 'plugin' => 'Tests', + 'controller' => 'Tests', + 'action' => 'test' + ], + //expected + __d('CakeDC/Users', "Permission key 'user' is illegal, cannot evaluate the permission"), + ], + ]; + } + + /** + * @param array $params + * @return \Cake\Network\Request + */ + protected function _requestFromArray($params) + { + $request = new Request(); + $request->plugin = Hash::get($params, 'plugin'); + $request->controller = $params['controller']; + $request->action = $params['action']; + $prefix = Hash::get($params, 'prefix'); + $request->params = []; + if ($prefix) { + $request->params['prefix'] = $prefix; + } + $extension = Hash::get($params, '_ext'); + if ($extension) { + $request->params['_ext'] = $extension; + } + + return $request; + } } diff --git a/tests/TestCase/Controller/Component/GoogleAuthenticatorComponentTest.php b/tests/TestCase/Controller/Component/GoogleAuthenticatorComponentTest.php new file mode 100644 index 000000000..5a0d094d4 --- /dev/null +++ b/tests/TestCase/Controller/Component/GoogleAuthenticatorComponentTest.php @@ -0,0 +1,128 @@ +backupUsersConfig = Configure::read('Users'); + + Router::reload(); + Plugin::routes('CakeDC/Users'); + Router::connect('/route/*', [ + 'plugin' => 'CakeDC/Users', + 'controller' => 'Users', + 'action' => 'requestResetPassword' + ]); + Router::connect('/notAllowed/*', [ + 'plugin' => 'CakeDC/Users', + 'controller' => 'Users', + 'action' => 'edit' + ]); + + Security::salt('YJfIxfs2guVoUubWDYhG93b0qyJfIxfs2guwvniR2G0FgaC9mi'); + Configure::write('App.namespace', 'Users'); + Configure::write('Users.GoogleAuthenticator.login', true); + + $this->request = $this->getMockBuilder('Cake\Network\Request') + ->setMethods(['is', 'method']) + ->getMock(); + $this->request->expects($this->any())->method('is')->will($this->returnValue(true)); + $this->response = $this->getMockBuilder('Cake\Network\Response') + ->setMethods(['stop']) + ->getMock(); + $this->Controller = new Controller($this->request, $this->response); + $this->Registry = $this->Controller->components(); + $this->Controller->GoogleAuthenticator = new GoogleAuthenticatorComponent($this->Registry); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown() + { + parent::tearDown(); + + $_SESSION = []; + unset($this->Controller, $this->GoogleAuthenticator); + Configure::write('Users', $this->backupUsersConfig); + Configure::write('Users.GoogleAuthenticator.login', false); + } + + /** + * Test initialize + * + */ + public function testInitialize() + { + $this->Registry->unload('GoogleAuthenticator'); + $this->Controller->GoogleAuthenticator = new GoogleAuthenticatorComponent($this->Registry); + $this->assertInstanceOf('CakeDC\Users\Controller\Component\GoogleAuthenticatorComponent', $this->Controller->GoogleAuthenticator); + } + + /** + * test base64 qr-code returned from component + * @return void + */ + public function testgetQRCodeImageAsDataUri() + { + $this->Controller->GoogleAuthenticator->initialize([]); + $result = $this->Controller->GoogleAuthenticator->getQRCodeImageAsDataUri('test@localhost.com', '123123'); + + $this->assertContains('data:image/png;base64', $result); + } + + /** + * Making sure we return secret + * @return void + */ + public function testCreateSecret() + { + $this->Controller->GoogleAuthenticator->initialize([]); + $result = $this->Controller->GoogleAuthenticator->createSecret(); + $this->assertNotEmpty($result); + } + + /** + * Testing code verification in the component + * @return void + */ + public function testVerifyCode() + { + $this->Controller->GoogleAuthenticator->initialize([]); + $secret = $this->Controller->GoogleAuthenticator->createSecret(); + $verificationCode = $this->Controller->GoogleAuthenticator->tfa->getCode($secret); + + $verified = $this->Controller->GoogleAuthenticator->verifyCode($secret, $verificationCode); + $this->assertTrue($verified); + } +} diff --git a/tests/TestCase/View/Helper/AuthLinkHelperTest.php b/tests/TestCase/View/Helper/AuthLinkHelperTest.php index 165423aec..426f2f7f3 100644 --- a/tests/TestCase/View/Helper/AuthLinkHelperTest.php +++ b/tests/TestCase/View/Helper/AuthLinkHelperTest.php @@ -78,6 +78,47 @@ public function testLinkAuthorized() $this->assertSame('before_title_after', $link); } + /** + * Test link + * + * @return void + */ + public function testLinkAuthorizedAllowedTrue() + { + $view = new View(); + $eventManagerMock = $this->getMockBuilder('Cake\Event\EventManager') + ->setMethods(['dispatch']) + ->getMock(); + $view->eventManager($eventManagerMock); + $this->AuthLink = new AuthLinkHelper($view); + $result = new Event('dispatch-result'); + $result->result = true; + $eventManagerMock->expects($this->never()) + ->method('dispatch'); + + $link = $this->AuthLink->link('title', '/', ['allowed' => true, 'before' => 'before_', 'after' => '_after', 'class' => 'link-class']); + $this->assertSame('before_title_after', $link); + } + + /** + * Test link + * + * @return void + */ + public function testLinkAuthorizedAllowedFalse() + { + $view = new View(); + $eventManagerMock = $this->getMockBuilder('Cake\Event\EventManager') + ->setMethods(['dispatch']) + ->getMock(); + $view->eventManager($eventManagerMock); + $this->AuthLink = new AuthLinkHelper($view); + $result = new Event('dispatch-result'); + $eventManagerMock->expects($this->never()) + ->method('dispatch'); + $link = $this->AuthLink->link('title', '/', ['allowed' => false, 'before' => 'before_', 'after' => '_after', 'class' => 'link-class']); + $this->assertFalse($link); + } /** * Test isAuthorized