diff --git a/.travis.yml b/.travis.yml index 0350e0b0..7811eccb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,10 +15,10 @@ env: matrix: include: - - php: 5.6 + - php: 7.0 env: PHPCS=1 DEFAULT=0 - - php: 5.6 + - php: 7.0 env: COVERALLS=1 DEFAULT=0 fast_finish: true diff --git a/README.md b/README.md index a1ff6fe3..87e9fe8d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ It is also possible to manage the config files without the need of coding skills Ask yourself: Do you need the overhead and complexity involved with the core CakePHP ACL? See also my post [acl-access-control-lists-revised/](http://www.dereuromark.de/2015/01/06/acl-access-control-lists-revised/). If not, then this plugin could very well be your answer and a super quick solution to your auth problem :) +But even if you don't leverage the authentication or authorization, the available AuthUserComponent and AuthUserHelper +can be very useful when dealing with role based decisions in your controller or view level. They also work standa-alone. + ## Demo See http://sandbox3.dereuromark.de/auth-sandbox @@ -41,6 +44,23 @@ add,edit = user,mod * = admin ``` +### AuthUser component and helper +```php +$currentId = $this->AuthUser->id(); + +$isMe = $this->AuthUser->isMe($userEntity->id); + +if ($this->AuthUser->hasRole('mod') { +} + +if ($this->AuthUser->hasAccess(['action' => 'secretArea'])) { +} + +// Helper only +echo $this->AuthUser->link('Admin Backend', ['prefix' => 'admin', 'action' => 'index']); +echo $this->AuthUser->postLink('Delete', ['action' => 'delete', $id], ['confirm' => 'Sure?']); +``` + ## How to include Installing the plugin is pretty much as with every other CakePHP plugin: diff --git a/docs/Authentication.md b/docs/Authentication.md index a17d39b6..dcf4baf0 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -20,9 +20,7 @@ Authentication is set up in your controller's `initialize` method: public function initialize() { parent::initialize(); - $this->loadComponent('TinyAuth.Auth', [ - 'autoClearCache' => ... - ]); + $this->loadComponent('TinyAuth.Auth'); } ``` @@ -64,6 +62,10 @@ Accounts.Accounts = view, edit Accounts.admin/Accounts = index ``` +### Multiple files and merging +You can specify multiple paths in your config, e.g. when you have plugins and separated the definitions across them. +Make sure you are using each key only once, though. The first definition will be kept and all others for the same key are ignored. + ### Mixing with code It is possible to have mixed INI and code rules. Those will get merged prior to authentication. So in case any of your controllers (or plugin controllers) contain such a statement, it will merge itself with your INI whitelist: @@ -101,7 +103,7 @@ TinyAuth AuthComponent supports the following configuration options. Option | Type | Description :----- | :--- | :---------- autoClearCache|bool|True will generate a new ACL cache file every time. -filePath|string|Full path to the INI file. Defaults to `ROOT . DS . 'config' . DS`. +filePath|string|Full path to the INI file. Can also be an array of paths. Defaults to `ROOT . DS . 'config' . DS`. file|string|Name of the INI file. Defaults to `auth_allow.ini`. cache|string|Cache type. Defaults to `_cake_core_`. cacheKey|string|Cache key. Defaults to `tiny_auth_allow`. diff --git a/docs/Authorization.md b/docs/Authorization.md index df161fbd..80dd662f 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -29,7 +29,7 @@ public function initialize() { $this->loadComponent('TinyAuth.Auth', [ 'authorize' => [ 'TinyAuth.Tiny' => [ - 'autoClearCache' => ... + ... ], ... ] @@ -45,7 +45,7 @@ You can also use the default one, if you only want to use ACL (authorization): $this->loadComponent('Auth', [ 'authorize' => [ 'TinyAuth.Tiny' => [ - 'autoClearCache' => ... + ... ] ] ]); @@ -63,11 +63,11 @@ Define your roles in a Configure array if you want to prevent database role lookups, for example: ```php -// config/app_custom.php +// config/app.php -/** -* Optionally define constants for easy referencing throughout your code -*/ +/* + * Optionally define constants for easy referencing throughout your code + */ define('ROLE_USER', 1); define('ROLE_ADMIN', 2); define('ROLE_SUPERADMIN', 9); @@ -193,6 +193,11 @@ view, edit = user * = admin ``` +### Multiple files and merging +You can specify multiple paths in your config, e.g. when you have plugins and separated the definitions across them. +Make sure you are using each section key only once, though. The first definition will be kept and all others for the same section key are ignored. + + ## Caching TinyAuth makes heavy use of caching to achieve optimal performance. @@ -229,11 +234,140 @@ prefixes|array|A list of authorizeByPrefix handled prefixes. allowUser|bool|True will give authenticated users access to all resources except those using the `adminPrefix` adminPrefix|string|Name of the prefix used for admin pages. Defaults to admin. autoClearCache|bool|True will generate a new ACL cache file every time. -filePath|string|Full path to the acl.ini. Defaults to `ROOT . DS . 'config' . DS`. +filePath|string|Full path to the acl.ini. Can also be an array of multiple paths. Defaults to `ROOT . DS . 'config' . DS`. file|string|Name of the INI file. Defaults to `acl.ini`. cache|string|Cache type. Defaults to `_cake_core_`. cacheKey|string|Cache key. Defaults to `tiny_auth_acl`. -## Auth user data -For reading auth user data take a look at [Tools plugin AuthUser component/helper](https://github.com/dereuromark/cakephp-tools/blob/master/docs/Auth/Auth.md). +## AuthUserComponent +Add the AuthUserComponent and you can easily check permissions inside your controller scope: +```php +$this->loadComponent('TinyAuth.AuthUser'); +``` + +Maybe you only want to redirect to a certain action if that is accessible for this user (role): +```php +if ($this->AuthUser->hasAccess(['action' => 'forModeratorOnly'])) { + return $this->redirect(['action' => 'forModeratorOnly']); +} +// Do something else +``` + +Or if that person is of a certain role in general: +```php +if ($this->AuthUser->hasRole('mod') { // Either by alias or id + // OK, do something now +} +``` + +For any action that get's the user id passed, you can also ask: +```php +$isMe = $this->AuthUser->isMe($userEntity->id); +// This would be equal to +$isMe = $this->AuthUser->id() == $userEntity->id; +``` + +## AuthHelper +The helper assists with the same in the templates. + +Include the helper in your `AppView.php`: +```php +$this->loadHelper('TinyAuth.AuthUser'); +``` + +Note that this helper only works if you also enabled the above component, as it needs some data to be passed down. + +All the above gotchas also are available in the views and helpers now (id, isMe, roles, hasRole, hasRoles, hasAccess). +But on top, if you want to display certain content or a link for specific roles, you can do that, too. + +Let's say we only want to print an admin backend link if the role can access it: +```php +echo $this->AuthUser->link('Admin Backend', ['prefix' => 'admin', 'action' => 'index']); +``` +It will not show anything for all others. + +Let's say we only want to print the delete link if the role is actually allowed to do that: +```php +echo $this->AuthUser->postLink('Delete this', ['action' => 'delete', $id], ['confirm' => 'Sure?']); +``` + +You can also do more complex things: +```php +if ($this->AuthUser->hasAccess(['action' => 'secretArea'])) { + echo 'Only for you: '; + echo $this->Html->link('admin/index', ['action' => 'secretArea']); + echo ' (do not tell anyone else!); +} +``` + +## Tips + +### Use constants instead of magic strings +If you are using the `hasRole()` or `hasRoles()` checks with a DB roles table, it is always better to use the aliases than the IDs (as the IDs can change). +But even so, it is better not to use magic strings like `'moderator'`, but define constants in your bootstrap for each: +````php +// In your bootstrap +define('ROLE_MOD', 'moderator'); + +// In your template +if ($this->AuthUser->hasRole(ROLE_MOD)) { + ... +} +``` +This way, if you ever refactor them, it will be easier to adjust all occurrences, it will also be possible to use auto-completion type-hinting. + +### Leverage session +Especially when working with multi-role setup, it can be useful to not every time read the current user's roles from the database. +When logging in a user you can write the roles to the session right away. +If available here, TinyAuth will use those and will not try to query the roles table (or the `roles_users` pivot table). + +For basic single-role setup, the session is expected to be filled like +```php +'Auth' => [ + 'User' => [ + 'id' => '1', + 'role_id' => '1', + ... + ] +]; +``` +The expected `'role_id'` session key is configurable via `roleColumn` config key. + +For a multi-role setup it can be either the normalized array form +```php +'Auth' => [ + 'User' => [ + 'id' => '1', + ... + 'Roles' => [ + 'id' => '1', + ... + ], + ] +]; +``` +or the simplified numeric list form +```php +'Auth' => [ + 'User' => [ + 'id' => '1', + ... + 'Roles' => [ + '1', + '2', + ... + ] + ] +]; +``` +The expected `'Roles'` session key is configurable via `rolesTable` config key. + +When logging the user in you can have a custom handler modifying your input accordingly, prior to calling +```php +// Modify or add roles in $user +$this->Auth->setUser($user); +``` + +The easiest way here to contain the roles, however, is to have your custom `findAuth()` finder which also fetches those. +See [customizing-find-query](http://book.cakephp.org/3.0/en/controllers/components/authentication.html#customizing-find-query). diff --git a/docs/README.md b/docs/README.md index b100d3cb..c0947eed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ In that case the role of this logged in user decides if that action is allowed t ## Authentication NEW since version 1.4.0 -This is done via TinyAuth Auth Component. +This is done via TinyAuth AuthComponent. The component plays well together with the adapter (see below). If you do not have any roles and either all are logged in or not logged in you can also use this stand-alone to make certain pages public. @@ -21,19 +21,33 @@ If you do not have any roles and either all are logged in or not logged in you c See [Authentication](Authentication.md) docs. ## Authorization -For this we have a TinyAuth Authorize adapter. +For this we have a TinyAuthorize adapter. The adapter plays well together with the component above. But if you prefer to control the action whitelisting for authentication via code and `$this->Auth->allow()` calls, you can also just use this adapter stand-alone for the ACL part of your application. +There is also an AuthUserComponent and AuthUserHelper to assist you in making role based decisions or displaying role based links in your templates. + See [Authorization](Authorization.md) docs. -## Contributing -There are a few guidelines that I need contributors to follow: +## Configuration +Those classes most likely share quite a few configs, in that case you definitely should use Configure to define those centrally: +````php +// in your app.php + 'TinyAuth' => [ + 'multiRole' => true, + ... + ], +``` +This way you keep it DRY. -- Coding standards passing: `vendor/bin/sniff` and `vendor/bin/sniff -f` -- Tests passing for Windows and Unix: `php phpunit.phar` +## Contributing +Feel free to fork and pull request. + +There are a few guidelines: +- Coding standards passing: `./sniff` to check and `./sniff -f` to fix. +- Tests passing: `php phpunit.phar` to run them. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 60a00d4d..31b745d7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -26,13 +26,9 @@ - - ./vendor/ - ./vendor/ - - ./tests/ - ./tests/ - + + ./src + diff --git a/sniff b/sniff new file mode 100644 index 00000000..200770f7 --- /dev/null +++ b/sniff @@ -0,0 +1,18 @@ +#!/bin/bash + +# Make sure this file is executable +# chmod +x sniff + +if [ `echo "$@" | grep '\-\-fix'` ] || [ `echo "$@" | grep '\-f'` ]; then + FIX=1 +else + FIX=0 +fi + +if [ "$FIX" = 1 ]; then + # Sniff and fix + vendor/bin/phpcbf --standard=vendor/fig-r/psr2r-sniffer/PSR2R/ruleset.xml --ignore=cakephp-tinyauth/vendor/,tmp/,logs/,tests/test_files/,config/Migrations/ --extensions=php -v -f ./ +else + # Sniff only + vendor/bin/phpcs --standard=vendor/fig-r/psr2r-sniffer/PSR2R/ruleset.xml --ignore=cakephp-tinyauth/vendor/,tmp/,logs/,tests/test_files/,config/Migrations/ --extensions=php -v ./ +fi diff --git a/src/Auth/AclTrait.php b/src/Auth/AclTrait.php new file mode 100644 index 00000000..c701aa21 --- /dev/null +++ b/src/Auth/AclTrait.php @@ -0,0 +1,409 @@ + 'id', // ID Column in users table + 'roleColumn' => 'role_id', // Foreign key for the Role ID in users table or in pivot table + 'userColumn' => 'user_id', // Foreign key for the User id in pivot table. Only for multi-roles setup + 'aliasColumn' => 'alias', // Name of column in roles table holding role alias/slug + 'rolesTable' => 'Roles', // name of Configure key holding available roles OR class name of roles table + 'usersTable' => 'Users', // name of the Users table + 'pivotTable' => null, // Should be used in multi-roles setups + 'multiRole' => false, // true to enables multirole/HABTM authorization (requires a valid pivot table) + 'superAdminRole' => null, // id of super admin role, which grants access to ALL resources + 'superAdmin' => null, // super admin, which grants access to ALL resources + 'superAdminColumn' => null, // Column of super admin + 'authorizeByPrefix' => false, + 'prefixes' => [], // Whitelisted prefixes (only used when allowAdmin is enabled), leave empty to use all available + 'allowUser' => false, // enable to allow ALL roles access to all actions except prefixed with 'adminPrefix' + 'adminPrefix' => 'admin', // name of the admin prefix route (only used when allowUser is enabled) + 'cache' => '_cake_core_', + 'cacheKey' => 'tiny_auth_acl', + 'autoClearCache' => null, // Set to true to delete cache automatically in debug mode, keep null for auto-detect + 'filePath' => null, // Possible to locate INI file at given path e.g. Plugin::configPath('Admin') + 'file' => 'acl.ini', + ]; + $config = (array)Configure::read('TinyAuth') + $defaults; + + return $config; + } + + /** + * @param array $config + * @throws \Cake\Core\Exception\Exception + * @return array + */ + protected function _prepareConfig(array $config) { + $config += $this->_defaultConfig(); + if (!$config['prefixes'] && !empty($config['authorizeByPrefix'])) { + throw new Exception('Invalid TinyAuthorization setup for `authorizeByPrefix`. Please declare `prefixes`.'); + } + + if (!in_array($config['cache'], Cache::configured())) { + throw new Exception(sprintf('Invalid TinyAuth cache `%s`', $config['cache'])); + } + + if ($config['autoClearCache'] === null) { + $config['autoClearCache'] = Configure::read('debug'); + } + + return $config; + } + + /** + * Checks the URL to the role(s). + * + * Allows single or multi role based authorization + * + * @param array $user User data + * @param array $params Request params + * @return bool Success + * @throws \Cake\Core\Exception\Exception + */ + protected function _check(array $user, array $params) { + if (!$user) { + return false; + } + + if ($this->config('superAdmin')) { + $superAdminColumn = $this->config('superAdminColumn'); + if (!$superAdminColumn) { + $superAdminColumn = $this->config('idColumn'); + } + if (!isset($user[$superAdminColumn])) { + throw new Exception('Missing super Admin Column in user table'); + } + if ($user[$superAdminColumn] === $this->config('superAdmin')) { + return true; + } + } + + // Give any logged in user access to ALL actions when `allowUser` is + // enabled except when the `adminPrefix` is being used. + if ($this->config('allowUser')) { + if (empty($params['prefix'])) { + return true; + } + if ($params['prefix'] !== $this->config('adminPrefix')) { + return true; + } + } + + $userRoles = $this->_getUserRoles($user); + + // Allow access to all prefixed actions for users belonging to + // the specified role that matches the prefix. + if ($this->config('authorizeByPrefix') && !empty($params['prefix'])) { + if (in_array($params['prefix'], $this->config('prefixes'))) { + $roles = $this->_getAvailableRoles(); + $role = isset($roles[$params['prefix']]) ? $roles[$params['prefix']] : null; + if ($role && in_array($role, $userRoles)) { + return true; + } + } + } + + // Allow logged in super admins access to all resources + if ($this->config('superAdminRole')) { + foreach ($userRoles as $userRole) { + if ($userRole === $this->config('superAdminRole')) { + return true; + } + } + } + + if ($this->_acl === null) { + $this->_acl = $this->_getAcl($this->config('filePath')); + } + + // Allow access if user has a role with wildcard access to the resource + $iniKey = $this->_constructIniKey($params); + if (isset($this->_acl[$iniKey]['actions']['*'])) { + $matchArray = $this->_acl[$iniKey]['actions']['*']; + foreach ($userRoles as $userRole) { + if (in_array((string)$userRole, $matchArray)) { + return true; + } + } + } + + // Allow access if user has been granted access to the specific resource + if (isset($this->_acl[$iniKey]['actions'])) { + if (array_key_exists($params['action'], $this->_acl[$iniKey]['actions']) && !empty($this->_acl[$iniKey]['actions'][$params['action']])) { + $matchArray = $this->_acl[$iniKey]['actions'][$params['action']]; + foreach ($userRoles as $userRole) { + if (in_array((string)$userRole, $matchArray)) { + return true; + } + } + } + } + return false; + } + + /** + * Parses INI file and returns the allowed roles per action. + * + * Uses cache for maximum performance. + * Improved speed by several actions before caching: + * - Resolves role slugs to their primary key / identifier + * - Resolves wildcards to their verbose translation + * + * @param string|array|null $path + * @return array Roles + */ + protected function _getAcl($path = null) { + if ($this->config('autoClearCache') && Configure::read('debug')) { + Cache::delete($this->config('cacheKey'), $this->config('cache')); + } + $roles = Cache::read($this->config('cacheKey'), $this->config('cache')); + if ($roles !== false) { + return $roles; + } + + $iniArray = $this->_parseFiles($path, $this->config('file')); + $availableRoles = $this->_getAvailableRoles(); + + $res = []; + foreach ($iniArray as $key => $array) { + $res[$key] = Utility::deconstructIniKey($key); + $res[$key]['map'] = $array; + + foreach ($array as $actions => $roles) { + // Get all roles used in the current INI section + $roles = explode(',', $roles); + $actions = explode(',', $actions); + + foreach ($roles as $roleId => $role) { + $role = trim($role); + if (!$role) { + continue; + } + // Prevent undefined roles appearing in the iniMap + if (!array_key_exists($role, $availableRoles) && $role !== '*') { + unset($roles[$roleId]); + continue; + } + if ($role === '*') { + unset($roles[$roleId]); + $roles = array_merge($roles, array_keys($availableRoles)); + } + } + + foreach ($actions as $action) { + $action = trim($action); + if (!$action) { + continue; + } + + foreach ($roles as $role) { + $role = trim($role); + if (!$role || $role === '*') { + continue; + } + + // Lookup role id by name in roles array + $newRole = $availableRoles[strtolower($role)]; + $res[$key]['actions'][$action][] = $newRole; + } + } + } + } + + Cache::write($this->config('cacheKey'), $res, $this->config('cache')); + return $res; + } + + /** + * Returns the acl.ini file(s) as an array. + * + * @param array|string|null $paths Paths to look for INI file + * @param string $file Full path to the INI file + * @return array List with all available roles + */ + protected function _parseFiles($paths, $file) { + if ($paths === null) { + $paths = ROOT . DS . 'config' . DS; + } + + $list = []; + foreach ((array)$paths as $path) { + $list += Utility::parseFile($path . $file); + } + + return $list; + } + + /** + * Deconstructs an ACL INI section key into a named array with ACL parts. + * + * @param string $key INI section key as found in acl.ini + * @return array Array with named keys for controller, plugin and prefix + */ + protected function _deconstructIniKey($key) { + return Utility::deconstructIniKey($key); + } + + /** + * Constructs an ACL INI section key from a given Request. + * + * @param array $params The request params + * @return string Hash with named keys for controller, plugin and prefix + */ + protected function _constructIniKey($params) { + $res = $params['controller']; + if (!empty($params['prefix'])) { + $res = $params['prefix'] . "/$res"; + } + if (!empty($params['plugin'])) { + $res = $params['plugin'] . ".$res"; + } + return $res; + } + + /** + * Returns a list of all available roles. + * + * Will look for a roles array in + * Configure first, tries database roles table next. + * + * @return array List with all available roles + * @throws \Cake\Core\Exception\Exception + */ + protected function _getAvailableRoles() { + if ($this->_roles !== null) { + return $this->_roles; + } + + $roles = Configure::read($this->_config['rolesTable']); + if (is_array($roles)) { + if ($this->config('superAdminRole')) { + $key = $this->config('superAdmin') ?: 'superadmin'; + $roles[$key] = $this->config('superAdminRole'); + } + return $roles; + } + + $rolesTable = TableRegistry::get($this->_config['rolesTable']); + $roles = $rolesTable->find()->formatResults(function ($results) { + return $results->combine($this->_config['aliasColumn'], 'id'); + })->toArray(); + + if ($this->config('superAdminRole')) { + $key = $this->config('superAdmin') ?: 'superadmin'; + $roles[$key] = $this->config('superAdminRole'); + } + + if (count($roles) < 1) { + throw new Exception('Invalid TinyAuth role setup (roles table `' . $this->_config['rolesTable'] . '` has no roles)'); + } + + $this->_roles = $roles; + + return $roles; + } + + /** + * Returns a list of all roles belonging to the authenticated user. + * + * Lookup in the following order: + * - single role id using the roleColumn in single-role mode + * - direct lookup in the pivot table (to support both Configure and Model + * in multi-role mode) + * + * @param array $user The user to get the roles for + * @return array List with all role ids belonging to the user + * @throws \Cake\Core\Exception\Exception + */ + protected function _getUserRoles($user) {//unset($user['role_id']); + // Single-role from session + if (!$this->_config['multiRole']) { + if (isset($user[$this->_config['roleColumn']])) { + return $this->_mapped([$user[$this->_config['roleColumn']]]); + } + throw new Exception(sprintf('Missing TinyAuth role id field (%s) in user session', 'Auth.User.' . $this->_config['roleColumn'])); + } + + // Multi-role from session + if (isset($user[$this->_config['rolesTable']])) { + $userRoles = $user[$this->_config['rolesTable']]; + if (isset($userRoles[0]['id'])) { + $userRoles = Hash::extract($userRoles, '{n}.id'); + } + return $this->_mapped((array)$userRoles); + } + + // Multi-role from DB: load the pivot table + $pivotTableName = $this->_config['pivotTable']; + if (!$pivotTableName) { + list(, $rolesTableName) = pluginSplit($this->_config['rolesTable']); + list(, $usersTableName) = pluginSplit($this->_config['usersTable']); + $tables = [ + $usersTableName, + $rolesTableName + ]; + asort($tables); + $pivotTableName = implode('', $tables); + } + $pivotTable = TableRegistry::get($pivotTableName); + $roleColumn = $this->_config['roleColumn']; + $roles = $pivotTable->find() + ->select($roleColumn) + ->where([$this->_config['userColumn'] => $user[$this->_config['idColumn']]]) + ->all() + ->extract($roleColumn) + ->toArray(); + + if (!count($roles)) { + throw new Exception('Missing TinyAuth roles for user in pivot table'); + } + + return $this->_mapped($roles); + } + + /** + * @param array $roles + * @return array + */ + protected function _mapped($roles) { + $availableRoles = $this->_getAvailableRoles(); + + $array = []; + foreach ($roles as $role) { + $alias = array_keys($availableRoles, $role); + $alias = array_shift($alias); + if (!$alias || !is_string($alias)) { + throw new RuntimeException('Cannot find role alias for role ' . $role); + } + + $array[$alias] = $role; + } + + return $array; + } + +} diff --git a/src/Auth/AuthUserTrait.php b/src/Auth/AuthUserTrait.php new file mode 100644 index 00000000..73490ed5 --- /dev/null +++ b/src/Auth/AuthUserTrait.php @@ -0,0 +1,163 @@ +config('idColumn'); + + return $this->user($field); + } + + /** + * This check can be used to tell if a record that belongs to some user is the + * current logged in user + * + * @param string|int $userId + * @return bool + */ + public function isMe($userId) { + $field = $this->config('idColumn'); + return $userId && (string)$userId === (string)$this->user($field); + } + + /** + * Get the user data of the current session. + * + * @param string|null $key Key in dot syntax. + * @return mixed Data + */ + public function user($key = null) { + $user = $this->_getUser(); + if ($key === null) { + return $user; + } + return Hash::get($user, $key); + } + + /** + * Get the role(s) of the current session. + * + * It will return the single role for single role setup, and a flat + * list of roles for multi role setup. + * + * @return mixed String or array of roles or null if inexistent. + */ + public function roles() { + $roles = $this->_getUserRoles($this->user()); + if (!is_array($roles)) { + return $roles; + } + + return $roles; + } + + /** + * Check if the current session has this role. + * + * @param mixed $expectedRole + * @param mixed|null $providedRoles + * @return bool Success + */ + public function hasRole($expectedRole, $providedRoles = null) { + if ($providedRoles !== null) { + $roles = (array)$providedRoles; + } else { + $roles = (array)$this->roles(); + } + + if (!$roles) { + return false; + } + + if (array_key_exists($expectedRole, $roles) || in_array($expectedRole, $roles)) { + return true; + } + return false; + } + + /** + * Check if the current session has one of these roles. + * + * You can either require one of the roles (default), or you can require all + * roles to match. + * + * @param mixed $expectedRoles + * @param bool $oneRoleIsEnough (if all $roles have to match instead of just one) + * @param mixed|null $providedRoles + * @return bool Success + */ + public function hasRoles($expectedRoles, $oneRoleIsEnough = true, $providedRoles = null) { + if ($providedRoles !== null) { + $roles = $providedRoles; + } else { + $roles = $this->roles(); + } + + $expectedRoles = (array)$expectedRoles; + if (!$expectedRoles) { + return false; + } + + $count = 0; + foreach ($expectedRoles as $expectedRole) { + if ($this->hasRole($expectedRole, $roles)) { + if ($oneRoleIsEnough) { + return true; + } + $count++; + } else { + if (!$oneRoleIsEnough) { + return false; + } + } + } + + if ($count === count($expectedRoles)) { + return true; + } + return false; + } + +} diff --git a/src/Auth/TinyAuthorize.php b/src/Auth/TinyAuthorize.php index b456c86f..c51fa935 100644 --- a/src/Auth/TinyAuthorize.php +++ b/src/Auth/TinyAuthorize.php @@ -2,26 +2,8 @@ namespace TinyAuth\Auth; use Cake\Auth\BaseAuthorize; -use Cake\Cache\Cache; use Cake\Controller\ComponentRegistry; -use Cake\Core\Configure; -use Cake\Core\Exception\Exception; use Cake\Network\Request; -use Cake\ORM\TableRegistry; -use TinyAuth\Utility\Utility; - -/** - * @deprecated Directly use cache config key - */ -if (!defined('AUTH_CACHE')) { - define('AUTH_CACHE', '_cake_core_'); // use the most persistent cache by default -} -/** - * @deprecated Directly use file config key - */ -if (!defined('ACL_FILE')) { - define('ACL_FILE', 'acl.ini'); // stored in /config/ by default -} /** * Probably the most simple and fastest ACL out there. @@ -43,37 +25,7 @@ */ class TinyAuthorize extends BaseAuthorize { - /** - * @var array|null - */ - protected $_acl = null; - - /** - * @var array - */ - protected $_defaultConfig = [ - 'idColumn' => 'id', // ID Column in users table - 'roleColumn' => 'role_id', // Foreign key for the Role ID in users table or in pivot table - 'userColumn' => 'user_id', // Foreign key for the User id in pivot table. Only for multi-roles setup - 'aliasColumn' => 'alias', // Name of column in roles table holding role alias/slug - 'rolesTable' => 'Roles', // name of Configure key holding available roles OR class name of roles table - 'usersTable' => 'Users', // name of the Users table - 'pivotTable' => null, // Should be used in multi-roles setups - 'multiRole' => false, // true to enables multirole/HABTM authorization (requires a valid pivot table) - 'superAdminRole' => null, // id of super admin role, which grants access to ALL resources - 'superAdmin' => null, // super admin, which grants access to ALL resourc - 'superAdminColumn' => null, // Column of super admin - 'authorizeByPrefix' => false, - 'prefixes' => [], // Whitelisted prefixes (only used when allowAdmin is enabled), leave empty to use all available - 'allowUser' => false, // enable to allow ALL roles access to all actions except prefixed with 'adminPrefix' - 'adminPrefix' => 'admin', // name of the admin prefix route (only used when allowUser is enabled) - 'cache' => '_cake_core_', - 'cacheKey' => 'tiny_auth_acl', - 'autoClearCache' => null, // Set to true to delete cache automatically in debug mode, keep null for auto-detect - 'aclPath' => null, // @deprecated Use filePath - 'filePath' => null, // Possible to locate ini file at given path e.g. Plugin::configPath('Admin') - 'file' => 'acl.ini', - ]; + use AclTrait; /** * @param \Cake\Controller\ComponentRegistry $registry @@ -81,26 +33,9 @@ class TinyAuthorize extends BaseAuthorize { * @throws \Cake\Core\Exception\Exception */ public function __construct(ComponentRegistry $registry, array $config = []) { - $config += (array)Configure::read('TinyAuth'); - $config += $this->_defaultConfig; - if (!$config['prefixes'] && !empty($config['authorizeByPrefix'])) { - throw new Exception('Invalid TinyAuthorization setup for `authorizeByPrefix`. Please declare `prefixes`.'); - } + $config = $this->_prepareConfig($config); parent::__construct($registry, $config); - - if (!in_array($config['cache'], Cache::configured())) { - throw new Exception(sprintf('Invalid TinyAuthorization cache `%s`', $config['cache'])); - } - - if ($this->_config['autoClearCache'] === null) { - $this->_config['autoClearCache'] = Configure::read('debug'); - } - - // BC only - if (isset($this->_config['aclPath'])) { - $this->_config['filePath'] = $this->_config['aclPath']; - } } /** @@ -117,277 +52,7 @@ public function __construct(ComponentRegistry $registry, array $config = []) { * @return bool Success */ public function authorize($user, Request $request) { - if (!empty($this->_config['superAdmin'])) { - if (empty($this->_config['superAdminColumn'])) { - $this->_config['superAdminColumn'] = $this->_config['idColumn']; - } - if (!isset($user[$this->_config['superAdminColumn']])) { - throw new Exception('Missing super Admin Column in user table'); - } - if ($user[$this->_config['superAdminColumn']] === $this->_config['superAdmin']) { - return true; - } - } - return $this->validate($this->_getUserRoles($user), $request); - } - - /** - * Validates the URL to the role(s). - * - * Allows single or multi role based authorization - * - * @param array $userRoles - * @param \Cake\Network\Request $request Request instance - * @return bool Success - */ - public function validate($userRoles, Request $request) { - // Give any logged in user access to ALL actions when `allowUser` is - // enabled except when the `adminPrefix` is being used. - if (!empty($this->_config['allowUser'])) { - if (empty($request->params['prefix'])) { - return true; - } - if ($request->params['prefix'] !== $this->_config['adminPrefix']) { - return true; - } - } - - // Allow access to all prefixed actions for users belonging to - // the specified role that matches the prefix. - if (!empty($this->_config['authorizeByPrefix']) && !empty($request->params['prefix'])) { - if (in_array($request->params['prefix'], $this->_config['prefixes'])) { - $roles = $this->_getAvailableRoles(); - $role = isset($roles[$request->params['prefix']]) ? $roles[$request->params['prefix']] : null; - if ($role && in_array($role, $userRoles)) { - return true; - } - } - } - - // Allow logged in super admins access to all resources - if (!empty($this->_config['superAdminRole'])) { - foreach ($userRoles as $userRole) { - if ($userRole === $this->_config['superAdminRole']) { - return true; - } - } - } - - if ($this->_acl === null) { - $this->_acl = $this->_getAcl($this->_config['filePath']); - } - - // Allow access if user has a role with wildcard access to the resource - $iniKey = $this->_constructIniKey($request); - if (isset($this->_acl[$iniKey]['actions']['*'])) { - $matchArray = $this->_acl[$iniKey]['actions']['*']; - foreach ($userRoles as $userRole) { - if (in_array((string)$userRole, $matchArray)) { - return true; - } - } - } - - // Allow access if user has been granted access to the specific resource - if (isset($this->_acl[$iniKey]['actions'])) { - if (array_key_exists($request->action, $this->_acl[$iniKey]['actions']) && !empty($this->_acl[$iniKey]['actions'][$request->action])) { - $matchArray = $this->_acl[$iniKey]['actions'][$request->action]; - foreach ($userRoles as $userRole) { - if (in_array((string)$userRole, $matchArray)) { - return true; - } - } - } - } - return false; - } - - /** - * Parse ini file and returns the allowed roles per action. - * - * Uses cache for maximum performance. - * Improved speed by several actions before caching: - * - Resolves role slugs to their primary key / identifier - * - Resolves wildcards to their verbose translation - * - * @param string|null $path - * @return array Roles - */ - protected function _getAcl($path = null) { - if ($path === null) { - $path = ROOT . DS . 'config' . DS; - } - - if ($this->_config['autoClearCache'] && Configure::read('debug')) { - Cache::delete($this->_config['cacheKey'], $this->_config['cache']); - } - $roles = Cache::read($this->_config['cacheKey'], $this->_config['cache']); - if ($roles !== false) { - return $roles; - } - - $iniArray = $this->_parseFile($path . $this->_config['file']); - $availableRoles = $this->_getAvailableRoles(); - - $res = []; - foreach ($iniArray as $key => $array) { - $res[$key] = Utility::deconstructIniKey($key); - $res[$key]['map'] = $array; - - foreach ($array as $actions => $roles) { - // Get all roles used in the current ini section - $roles = explode(',', $roles); - $actions = explode(',', $actions); - - foreach ($roles as $roleId => $role) { - $role = trim($role); - if (!$role) { - continue; - } - // Prevent undefined roles appearing in the iniMap - if (!array_key_exists($role, $availableRoles) && $role !== '*') { - unset($roles[$roleId]); - continue; - } - if ($role === '*') { - unset($roles[$roleId]); - $roles = array_merge($roles, array_keys($availableRoles)); - } - } - - foreach ($actions as $action) { - $action = trim($action); - if (!$action) { - continue; - } - - foreach ($roles as $role) { - $role = trim($role); - if (!$role || $role === '*') { - continue; - } - - // Lookup role id by name in roles array - $newRole = $availableRoles[strtolower($role)]; - $res[$key]['actions'][$action][] = $newRole; - } - } - } - } - - Cache::write($this->_config['cacheKey'], $res, $this->_config['cache']); - return $res; - } - - /** - * Returns the acl.ini file as an array. - * - * @param string $ini Full path to the acl.ini file - * @return array List with all available roles - */ - protected function _parseFile($ini) { - return Utility::parseFile($ini); - } - - /** - * Deconstructs an ACL ini section key into a named array with ACL parts. - * - * @param string $key INI section key as found in acl.ini - * @return array Array with named keys for controller, plugin and prefix - */ - protected function _deconstructIniKey($key) { - return Utility::deconstructIniKey($key); - } - - /** - * Constructs an ACL ini section key from a given Request. - * - * @param \Cake\Network\Request $request The request needing authorization. - * @return string Hash with named keys for controller, plugin and prefix - */ - protected function _constructIniKey(Request $request) { - $res = $request->params['controller']; - if (!empty($request->params['prefix'])) { - $res = $request->params['prefix'] . "/$res"; - } - if (!empty($request->params['plugin'])) { - $res = $request->params['plugin'] . ".$res"; - } - return $res; - } - - /** - * Returns a list of all available roles. - * - * Will look for a roles array in - * Configure first, tries database roles table next. - * - * @return array List with all available roles - * @throws \Cake\Core\Exception\Exception - */ - protected function _getAvailableRoles() { - $roles = Configure::read($this->_config['rolesTable']); - if (is_array($roles)) { - return $roles; - } - - $rolesTable = TableRegistry::get($this->_config['rolesTable']); - $roles = $rolesTable->find()->formatResults(function ($results) { - return $results->combine($this->_config['aliasColumn'], 'id'); - })->toArray(); - - if (count($roles) < 1) { - throw new Exception('Invalid TinyAuth role setup (roles table `' . $this->_config['rolesTable'] . '` has no roles)'); - } - return $roles; - } - - /** - * Returns a list of all roles belonging to the authenticated user. - * - * Lookup in the following order: - * - single role id using the roleColumn in single-role mode - * - direct lookup in the pivot table (to support both Configure and Model - * in multi-role mode) - * - * @param array $user The user to get the roles for - * @return array List with all role ids belonging to the user - * @throws \Cake\Core\Exception\Exception - */ - protected function _getUserRoles($user) { - // Single-role - if (!$this->_config['multiRole']) { - if (isset($user[$this->_config['roleColumn']])) { - return [$user[$this->_config['roleColumn']]]; - } - throw new Exception(sprintf('Missing TinyAuth role id (%s) in user session', $this->_config['roleColumn'])); - } - - // Multi-role case : load the pivot table - $pivotTableName = $this->_config['pivotTable']; - if (!$pivotTableName) { - list(, $rolesTableName) = pluginSplit($this->_config['rolesTable']); - list(, $usersTableName) = pluginSplit($this->_config['usersTable']); - $tables = [ - $usersTableName, - $rolesTableName - ]; - asort($tables); - $pivotTableName = implode('', $tables); - } - $pivotTable = TableRegistry::get($pivotTableName); - $roleColumn = $this->_config['roleColumn']; - $roles = $pivotTable->find() - ->select($roleColumn) - ->where([$this->_config['userColumn'] => $user[$this->_config['idColumn']]]) - ->all() - ->extract($roleColumn) - ->toArray(); - - if (!count($roles)) { - throw new Exception('Missing TinyAuth roles for user in pivot table'); - } - return $roles; + return $this->_check($user, $request->params); } } diff --git a/src/Controller/Component/AuthComponent.php b/src/Controller/Component/AuthComponent.php index 7d93ca27..a41fef7a 100644 --- a/src/Controller/Component/AuthComponent.php +++ b/src/Controller/Component/AuthComponent.php @@ -6,15 +6,16 @@ use Cake\Controller\ComponentRegistry; use Cake\Controller\Component\AuthComponent as CakeAuthComponent; use Cake\Core\Configure; -use Cake\Core\Exception\Exception; use Cake\Event\Event; -use TinyAuth\Utility\Utility; +use TinyAuth\Auth\AclTrait; /** * TinyAuth AuthComponent to handle all authentication in a central ini file. */ class AuthComponent extends CakeAuthComponent { + use AclTrait; + /** * @var array */ @@ -35,14 +36,17 @@ public function __construct(ComponentRegistry $registry, array $config = []) { $config += $this->_defaultTinyAuthConfig; parent::__construct($registry, $config); + } - if (!in_array($config['cache'], Cache::configured())) { - throw new Exception(sprintf('Invalid TinyAuth cache `%s`', $config['cache'])); - } - - if ($this->_config['autoClearCache'] === null) { - $this->_config['autoClearCache'] = Configure::read('debug'); - } + /** + * Events supported by this component. + * + * @return array + */ + public function implementedEvents() { + return [ + 'Controller.beforeRender' => 'beforeRender', + ] + parent::implementedEvents(); } /** @@ -55,6 +59,17 @@ public function startup(Event $event) { return parent::startup($event); } + /** + * @param \Cake\Event\Event $event + * @return \Cake\Network\Response|null + */ + public function beforeRender(Event $event) { + $controller = $event->subject(); + + $authUser = (array)$this->user(); + $controller->set('_authUser', $authUser); + } + /** * @return void */ @@ -91,10 +106,6 @@ protected function _prepareAuthentication() { * @return array Actions */ protected function _getAuth($path = null) { - if ($path === null) { - $path = ROOT . DS . 'config' . DS; - } - if ($this->_config['autoClearCache'] && Configure::read('debug')) { Cache::delete($this->_config['cacheKey'], $this->_config['cache']); } @@ -103,11 +114,11 @@ protected function _getAuth($path = null) { return $roles; } - $iniArray = Utility::parseFile($path . $this->_config['file']); + $iniArray = $this->_parseFiles($path, $this->_config['file']); $res = []; foreach ($iniArray as $key => $actions) { - $res[$key] = Utility::deconstructIniKey($key); + $res[$key] = $this->_deconstructIniKey($key); $res[$key]['map'] = $actions; $actions = explode(',', $actions); diff --git a/src/Controller/Component/AuthUserComponent.php b/src/Controller/Component/AuthUserComponent.php new file mode 100644 index 00000000..afb61637 --- /dev/null +++ b/src/Controller/Component/AuthUserComponent.php @@ -0,0 +1,67 @@ +_prepareConfig($config); + + parent::__construct($registry, $config); + } + + /** + * @param \Cake\Event\Event $event + * @return \Cake\Network\Response|null + */ + public function beforeRender(Event $event) { + $controller = $event->subject(); + + $authUser = $this->_getUser(); + $controller->set('_authUser', $authUser); + } + + /** + * @param array $url + * @return bool + */ + public function hasAccess(array $url) { + $url += [ + 'prefix' => !empty($this->request->params['prefix']) ? $this->request->params['prefix'] : null, + 'plugin' => !empty($this->request->params['plugin']) ? $this->request->params['plugin'] : null, + 'controller' => $this->request->params['controller'], + 'action' => 'index', + ]; + + return $this->_check((array)$this->Auth->user(), $url); + } + + /** + * @return array + */ + protected function _getUser() { + return (array)$this->Auth->user(); + } + +} diff --git a/src/View/Helper/AuthUserHelper.php b/src/View/Helper/AuthUserHelper.php new file mode 100644 index 00000000..f9241112 --- /dev/null +++ b/src/View/Helper/AuthUserHelper.php @@ -0,0 +1,121 @@ +_prepareConfig($config); + + parent::__construct($View, $config); + } + + /** + * @param array $url + * @return bool + */ + public function hasAccess(array $url) { + $url += [ + 'prefix' => !empty($this->request->params['prefix']) ? $this->request->params['prefix'] : null, + 'plugin' => !empty($this->request->params['plugin']) ? $this->request->params['plugin'] : null, + 'controller' => $this->request->params['controller'], + 'action' => 'index', + ]; + + if (!isset($this->_View->viewVars['_authUser'])) { + throw new Exception('Variable _authUser containing the user data needs to be passed down. The TinyAuth.Auth component does it automatically, if loaded.'); + } + if (empty($this->_View->viewVars['_authUser'])) { + return false; + } + + return $this->_check($this->_View->viewVars['_authUser'], $url); + } + + /** + * Options: + * - default: Default to show instead, defaults to empty string. + * Set to true to show just title text when not allowed. + * and all other link() options + * + * @param string $title + * @param array $url + * @param array $options + * @return string + */ + public function link($title, array $url, array $options = []) { + if (!$this->hasAccess($url)) { + return $this->_default($title, $options); + } + unset($options['default']); + + return $this->Html->link($title, $url, $options); + } + + /** + * Options: + * - default: Default to show instead, defaults to empty string. + * Set to true to show just title text when not allowed. + * and all other link() options + * + * @param string $title + * @param array $url + * @param array $options + * @return string + */ + public function postLink($title, array $url, array $options = []) { + if (!$this->hasAccess($url)) { + return $this->_default($title, $options); + } + unset($options['default']); + + return $this->Form->postLink($title, $url, $options); + } + + /** + * @param string $title + * @param array $options + * @return string + */ + protected function _default($title, $options) { + $options += [ + 'default' => '', + 'escape' => true, + ]; + + if ($options['default'] === true) { + return $options['escape'] === false ? $title : h($title); + } + + return $options['default']; + } + + /** + * @return array + */ + protected function _getUser() { + if (!isset($this->_View->viewVars['_authUser'])) { + throw new RuntimeException('AuthUser helper needs AuthUser component to function'); + } + return $this->_View->viewVars['_authUser']; + } + +} diff --git a/tests/TestApp/Auth/TestTinyAuthorize.php b/tests/TestApp/Auth/TestTinyAuthorize.php new file mode 100644 index 00000000..b8b31909 --- /dev/null +++ b/tests/TestApp/Auth/TestTinyAuthorize.php @@ -0,0 +1,37 @@ +_getAcl(); + } + + /** + * @param string|null $path + * + * @return array + */ + protected function _getAcl($path = null) { + $path = Plugin::path('TinyAuth') . 'tests' . DS . 'test_files' . DS; + return parent::_getAcl($path); + } + + /** + * @return \Cake\ORM\Table The User table + */ + public function getTable() { + $Users = TableRegistry::get($this->_config['usersTable']); + $Users->belongsTo('Roles'); + + return $Users; + } + +} diff --git a/tests/TestApp/Controller/Component/TestAuthUserComponent.php b/tests/TestApp/Controller/Component/TestAuthUserComponent.php new file mode 100644 index 00000000..29060110 --- /dev/null +++ b/tests/TestApp/Controller/Component/TestAuthUserComponent.php @@ -0,0 +1,37 @@ +_getAcl(); + } + + /** + * @param string|null $path + * + * @return array + */ + protected function _getAcl($path = null) { + $path = Plugin::path('TinyAuth') . 'tests' . DS . 'test_files' . DS; + return parent::_getAcl($path); + } + + /** + * @return \Cake\ORM\Table The User table + */ + public function getTable() { + $Users = TableRegistry::get($this->_config['usersTable']); + $Users->belongsTo('Roles'); + + return $Users; + } + +} diff --git a/tests/TestCase/Auth/TinyAuthorizeTest.php b/tests/TestCase/Auth/TinyAuthorizeTest.php index 1ee70ac0..e9dc703a 100644 --- a/tests/TestCase/Auth/TinyAuthorizeTest.php +++ b/tests/TestCase/Auth/TinyAuthorizeTest.php @@ -5,10 +5,9 @@ use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Network\Request; -use Cake\ORM\TableRegistry; use Cake\TestSuite\TestCase; use ReflectionClass; -use TinyAuth\Auth\TinyAuthorize; +use TestApp\Auth\TestTinyAuthorize; /** * Test case for TinyAuth Authentication @@ -233,6 +232,22 @@ public function testGetAcl() { $this->assertEquals($expected, $res); } + /** + * @expectedException \RuntimeException + * @return void + */ + public function testBasicUserMethodInexistentRole() { + $object = new TestTinyAuthorize($this->collection, [ + 'autoClearCache' => true + ]); + + $user = ['role_id' => 4]; // invalid non-existing role + + //$this->expectException(\RuntimeException::class); + + $object->authorize($user, $this->request); + } + /** * @return void */ @@ -252,10 +267,6 @@ public function testBasicUserMethodDisallowed() { $this->request->params['prefix'] = null; $this->request->params['plugin'] = 'Tags'; - $user = ['role_id' => 4]; // invalid non-existing role - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); - $user = ['role_id' => 1]; // valid role without authorization $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -265,10 +276,6 @@ public function testBasicUserMethodDisallowed() { $this->request->params['prefix'] = null; $this->request->params['plugin'] = null; - $user = ['role_id' => 4]; - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); - $user = ['role_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -278,10 +285,6 @@ public function testBasicUserMethodDisallowed() { $this->request->params['prefix'] = null; $this->request->params['plugin'] = 'Tags'; - $user = ['role_id' => 4]; - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); - $user = ['role_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -291,10 +294,6 @@ public function testBasicUserMethodDisallowed() { $this->request->params['prefix'] = 'admin'; $this->request->params['plugin'] = 'Tags'; - $user = ['role_id' => 4]; - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); - $user = ['role_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -566,7 +565,7 @@ public function testBasicUserMethodAllowedWithLongActionNamesUnderscored() { } /** - * Tests multirole authorization. + * Tests multi-role authorization. * * @return void */ @@ -636,10 +635,6 @@ public function testBasicUserMethodAllowedWildcard() { $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['role_id' => 123]; - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); - // Test *=* for standard controller with /admin prefix $this->request->params['controller'] = 'Posts'; $this->request->params['prefix'] = 'admin'; @@ -649,10 +644,6 @@ public function testBasicUserMethodAllowedWildcard() { $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['role_id' => 123]; - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); - // Test *=* for plugin controller $this->request->params['controller'] = 'Posts'; $this->request->params['prefix'] = null; @@ -662,10 +653,6 @@ public function testBasicUserMethodAllowedWildcard() { $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['role_id' => 123]; - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); - // Test *=* for plugin controller with /admin prefix $this->request->params['controller'] = 'Posts'; $this->request->params['prefix'] = 'admin'; @@ -674,10 +661,6 @@ public function testBasicUserMethodAllowedWildcard() { $user = ['role_id' => 2]; $res = $object->authorize($user, $this->request); $this->assertTrue($res); - - $user = ['role_id' => 123]; - $res = $object->authorize($user, $this->request); - $this->assertFalse($res); } /** @@ -927,12 +910,12 @@ public function testSuperAdminRole() { $object = new TestTinyAuthorize($this->collection, [ 'superAdminRole' => 9 ]); - $res = $object->getAcl(); + $acl = $object->getAcl(); $user = [ 'role_id' => 9 ]; - foreach ($object->getAcl() as $resource) { + foreach ($acl as $resource) { foreach ($resource['actions'] as $action => $allowed) { $this->request->params['controller'] = $resource['controller']; $this->request->params['prefix'] = $resource['prefix']; @@ -955,12 +938,19 @@ public function testIniParsing() { // Make protected function available $reflection = new ReflectionClass(get_class($object)); - $method = $reflection->getMethod('_parseFile'); + $method = $reflection->getMethod('_parseFiles'); $method->setAccessible(true); $res = $method->invokeArgs($object, [ - Plugin::path('TinyAuth') . 'tests' . DS . 'test_files' . DS . 'acl.ini' + [ + Plugin::path('TinyAuth') . 'tests' . DS . 'test_files' . DS, + Plugin::path('TinyAuth') . 'tests' . DS . 'test_files' . DS . 'subfolder' . DS, + ], + 'acl.ini' ]); $this->assertTrue(is_array($res)); + + $this->assertSame(['*' => 'moderator'], $res['Blogs.Blogs']); + $this->assertSame(['index' => 'admin'], $res['Foo']); } /** @@ -976,9 +966,11 @@ public function testIniParsingMissingFileException() { // Make protected function available $reflection = new ReflectionClass(get_class($object)); - $method = $reflection->getMethod('_parseFile'); + $method = $reflection->getMethod('_parseFiles'); $method->setAccessible(true); - $method->invokeArgs($object, [DS . 'non' . DS . 'existent' . DS . 'acl.ini']); + $method->invokeArgs($object, [ + Plugin::path('TinyAuth') . 'non' . DS . 'existent' . DS, + 'acl.ini']); } /** @@ -1228,7 +1220,7 @@ public function testUserRoles() { // Single-role: get role id from roleColumn in user table $user = ['role_id' => 1]; $res = $method->invokeArgs($object, [$user]); - $this->assertEquals([0 => 1], $res); + $this->assertSame(['user' => 1], $res); // Multi-role: lookup roles directly in pivot table $object = new TestTinyAuthorize($this->collection, [ @@ -1238,8 +1230,8 @@ public function testUserRoles() { ]); $user = ['id' => 2]; $expected = [ - 0 => 11, // user - 1 => 13 // admin + 'user' => 11, + 'admin' => 13 ]; $res = $method->invokeArgs($object, [$user]); $this->assertEquals($expected, $res); @@ -1265,8 +1257,8 @@ public function testUserRolesCustomPivotTable() { $user = ['id' => 2]; $expected = [ - 0 => 11, // user - 1 => 13 // admin + 'user' => 11, + 'admin' => 13 ]; $res = $method->invokeArgs($object, [$user]); $this->assertEquals($expected, $res); @@ -1297,8 +1289,8 @@ public function testIdColumnPivotTable() { 'profile_id' => 2 ]; $expected = [ - 0 => 11, // user - 1 => 13 // admin + 'user' => 11, + 'admin' => 13 ]; $res = $method->invokeArgs($object, [$user]); $this->assertEquals($expected, $res); @@ -1308,8 +1300,8 @@ public function testIdColumnPivotTable() { 'profile_id' => 1 ]; $expected = [ - 0 => 11, // user - 1 => 12 // moderator + 'user' => 11, + 'moderator' => 12 ]; $res = $method->invokeArgs($object, [$user]); $this->assertEquals($expected, $res); @@ -1319,8 +1311,8 @@ public function testIdColumnPivotTable() { 'profile_id' => 1 ]; $expected = [ - 0 => 11, // user - 1 => 12 // moderator + 'user' => 11, + 'moderator' => 12 ]; $res = $method->invokeArgs($object, [$user]); $this->assertEquals($expected, $res); @@ -1342,11 +1334,11 @@ public function testSuperAdmin() { 'superAdmin' => 1, ]); - $user = ['id' => 1, 'role_id' => 999]; + $user = ['id' => 1, 'role_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['id' => 2, 'role_id' => 999]; + $user = ['id' => 2, 'role_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -1355,11 +1347,11 @@ public function testSuperAdmin() { 'idColumn' => 'group_id', 'superAdmin' => 1 ]); - $user = ['id' => 100, 'role_id' => 999, 'group_id' => 1]; + $user = ['id' => 100, 'role_id' => 1, 'group_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['id' => 101, 'role_id' => 999, 'group_id' => 2]; + $user = ['id' => 101, 'role_id' => 1, 'group_id' => 2]; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -1369,11 +1361,11 @@ public function testSuperAdmin() { 'superAdminColumn' => 'group_id', 'superAdmin' => 1 ]); - $user = ['id' => 100, 'role_id' => 999, 'group_id' => 1]; + $user = ['id' => 100, 'role_id' => 1, 'group_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['id' => 101, 'role_id' => 999, 'group_id' => 2]; + $user = ['id' => 101, 'role_id' => 1, 'group_id' => 2]; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -1382,11 +1374,11 @@ public function testSuperAdmin() { 'superAdminColumn' => 'group_id', 'superAdmin' => 1 ]); - $user = ['id' => 100, 'role_id' => 999, 'group_id' => 1]; + $user = ['id' => 100, 'role_id' => 1, 'group_id' => 1]; $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['id' => 101, 'role_id' => 999, 'group_id' => 2]; + $user = ['id' => 101, 'role_id' => 1, 'group_id' => 2]; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -1396,15 +1388,15 @@ public function testSuperAdmin() { 'superAdminColumn' => 'group', 'superAdmin' => 'Admin' ]); - $user = ['id' => 100, 'role_id' => 999, 'group' => 'admin']; + $user = ['id' => 100, 'role_id' => 1, 'group' => 'admin']; $res = $object->authorize($user, $this->request); $this->assertFalse($res); - $user = ['id' => 100, 'role_id' => 999, 'group' => 'Admin']; + $user = ['id' => 100, 'role_id' => 1, 'group' => 'Admin']; $res = $object->authorize($user, $this->request); $this->assertTrue($res); - $user = ['id' => 101, 'role_id' => 999, 'group' => 2, 'authors']; + $user = ['id' => 101, 'role_id' => 1, 'group' => 2, 'authors']; $res = $object->authorize($user, $this->request); $this->assertFalse($res); @@ -1551,34 +1543,3 @@ public function testUserRolesUserWithoutPivotRoles() { } } - -class TestTinyAuthorize extends TinyAuthorize { - - /** - * @return array - */ - public function getAcl() { - return $this->_getAcl(); - } - - /** - * @param string|null $path - * - * @return array - */ - protected function _getAcl($path = null) { - $path = Plugin::path('TinyAuth') . 'tests' . DS . 'test_files' . DS; - return parent::_getAcl($path); - } - - /** - * @return \Cake\ORM\Table The User table - */ - public function getTable() { - $Users = TableRegistry::get($this->_config['usersTable']); - $Users->belongsTo('Roles'); - - return $Users; - } - -} diff --git a/tests/TestCase/Controller/Component/AuthComponentTest.php b/tests/TestCase/Controller/Component/AuthComponentTest.php index 3f380cef..b1817ebd 100644 --- a/tests/TestCase/Controller/Component/AuthComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthComponentTest.php @@ -26,6 +26,7 @@ class AuthComponentTest extends TestCase { * @var array */ protected $componentConfig = []; + /** * @return void */ diff --git a/tests/TestCase/Controller/Component/AuthUserComponentTest.php b/tests/TestCase/Controller/Component/AuthUserComponentTest.php new file mode 100644 index 00000000..43fa0983 --- /dev/null +++ b/tests/TestCase/Controller/Component/AuthUserComponentTest.php @@ -0,0 +1,241 @@ +AuthUser = new TestAuthUserComponent($componentRegistry); + $this->AuthUser->Auth = $this->getMockBuilder(AuthComponent::class)->setMethods(['user'])->setConstructorArgs([$componentRegistry])->getMock(); + + Configure::write('Roles', [ + 'user' => 1, + 'moderator' => 2, + 'admin' => 3 + ]); + $this->AuthUser->config('autoClearCache', true); + } + + /** + * @return void + */ + public function testIsAuthorizedValid() { + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->AuthUser->Auth->expects($this->once()) + ->method('user') + ->with(null) + ->will($this->returnValue($user)); + + $request = [ + 'controller' => 'Tags', + 'action' => 'edit', + ]; + $result = $this->AuthUser->hasAccess($request); + $this->assertTrue($result); + } + + /** + * @return void + */ + public function testIsAuthorizedInvalid() { + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->AuthUser->Auth->expects($this->once()) + ->method('user') + ->with(null) + ->will($this->returnValue($user)); + + $request = [ + 'controller' => 'Tags', + 'action' => 'delete', + ]; + $result = $this->AuthUser->hasAccess($request); + $this->assertFalse($result); + } + + /** + * @return void + */ + public function testIsAuthorizedNotLoggedIn() { + $user = [ + ]; + $this->AuthUser->Auth->expects($this->once()) + ->method('user') + ->with(null) + ->will($this->returnValue($user)); + + $request = [ + 'controller' => 'Tags', + 'action' => 'edit', + ]; + $result = $this->AuthUser->hasAccess($request); + $this->assertFalse($result); + } + + /** + * @return void + */ + public function testBeforeRenderSetAuthUser() { + $controller = new Controller(new Request()); + $event = new Event('Controller.beforeRender', $controller); + $this->AuthUser->beforeRender($event); + + $this->assertSame([], $controller->viewVars['_authUser']); + } + + /** + * @return void + */ + public function testEmptyAuthSession() { + $this->assertNull($this->AuthUser->id()); + + $this->assertFalse($this->AuthUser->isMe(null)); + $this->assertFalse($this->AuthUser->isMe('')); + $this->assertFalse($this->AuthUser->isMe(0)); + $this->assertFalse($this->AuthUser->isMe(1)); + } + + /** + * @return void + */ + public function testId() { + $this->AuthUser->Auth->expects($this->once()) + ->method('user') + ->with(null) + ->will($this->returnValue(['id' => '1'])); + + $this->assertSame('1', $this->AuthUser->id()); + } + + /** + * @return void + */ + public function testIsMe() { + $this->AuthUser->Auth->expects($this->any()) + ->method('user') + ->with(null) + ->will($this->returnValue(['id' => '1'])); + + $this->assertFalse($this->AuthUser->isMe(null)); + $this->assertFalse($this->AuthUser->isMe('')); + $this->assertFalse($this->AuthUser->isMe(0)); + + $this->assertTrue($this->AuthUser->isMe('1')); + $this->assertTrue($this->AuthUser->isMe(1)); + } + + /** + * @return void + */ + public function testUser() { + $this->AuthUser->Auth->expects($this->any()) + ->method('user') + ->with(null) + ->will($this->returnValue(['id' => '1', 'username' => 'foo'])); + + $this->assertSame(['id' => '1', 'username' => 'foo'], $this->AuthUser->user()); + $this->assertSame('foo', $this->AuthUser->user('username')); + $this->assertNull($this->AuthUser->user('foofoo')); + } + + /** + * @return void + */ + public function testRoles() { + $this->AuthUser->config('multiRole', true); + + $this->AuthUser->Auth->expects($this->once()) + ->method('user') + ->will($this->returnValue(['id' => '1', 'Roles' => ['1', '2']])); + + $this->assertSame(['user' => '1', 'moderator' => '2'], $this->AuthUser->roles()); + } + + /** + * @return void + */ + public function testRolesDeep() { + $this->AuthUser->config('multiRole', true); + + $this->AuthUser->Auth->expects($this->once()) + ->method('user') + ->with(null) + ->will($this->returnValue(['id' => '1', 'Roles' => [['id' => '1'], ['id' => '2']]])); + + $this->assertSame(['user' => '1', 'moderator' => '2'], $this->AuthUser->roles()); + } + + /** + * @return void + */ + public function testHasRole() { + $this->AuthUser->config('multiRole', true); + + $this->AuthUser->Auth->expects($this->exactly(3)) + ->method('user') + ->with(null) + ->will($this->returnValue(['id' => '1', 'Roles' => [['id' => '1'], ['id' => '2']]])); + + $this->assertTrue($this->AuthUser->hasRole(2)); + $this->assertTrue($this->AuthUser->hasRole('2')); + $this->assertFalse($this->AuthUser->hasRole(3)); + + $this->assertTrue($this->AuthUser->hasRole(3, [1, 3])); + $this->assertFalse($this->AuthUser->hasRole(3, [2, 4])); + } + + /** + * @return void + */ + public function testHasRoles() { + $this->AuthUser->config('multiRole', true); + + $this->AuthUser->Auth->expects($this->exactly(6)) + ->method('user') + ->with(null) + ->will($this->returnValue(['id' => '1', 'Roles' => [['id' => '1'], ['id' => '2']]])); + + $this->assertTrue($this->AuthUser->hasRoles([2])); + $this->assertTrue($this->AuthUser->hasRoles('2')); + $this->assertFalse($this->AuthUser->hasRoles([3, 4])); + $this->assertTrue($this->AuthUser->hasRoles([1, 2], false)); + + $this->assertTrue($this->AuthUser->hasRoles([1, 6], [1, 3, 5])); + $this->assertFalse($this->AuthUser->hasRoles([3, 4], [2, 4])); + + $this->assertFalse($this->AuthUser->hasRoles([1, 3, 5], false, [1, 3])); + $this->assertTrue($this->AuthUser->hasRoles([1, 3, 5], false, [1, 3, 5])); + } + +} diff --git a/tests/TestCase/View/Helper/AuthUserHelperTest.php b/tests/TestCase/View/Helper/AuthUserHelperTest.php new file mode 100644 index 00000000..ae25f8f4 --- /dev/null +++ b/tests/TestCase/View/Helper/AuthUserHelperTest.php @@ -0,0 +1,271 @@ + 1, + 'moderator' => 2, + 'admin' => 3 + ]); + $this->config = [ + 'filePath' => Plugin::path('TinyAuth') . 'tests' . DS . 'test_files' . DS, + 'autoClearCache' => true, + ]; + $this->View = new View(); + $this->AuthUserHelper = new AuthUserHelper($this->View, $this->config); + } + + /** + * @return void + */ + public function testIsAuthorizedValid() { + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->View->set('_authUser', $user); + + $request = [ + 'controller' => 'Tags', + 'action' => 'edit', + ]; + $result = $this->AuthUserHelper->hasAccess($request); + $this->assertTrue($result); + } + + /** + * @return void + */ + public function testIsAuthorizedInvalid() { + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->View->set('_authUser', $user); + + $request = [ + 'controller' => 'Tags', + 'action' => 'delete', + ]; + $result = $this->AuthUserHelper->hasAccess($request); + $this->assertFalse($result); + } + + /** + * @return void + */ + public function testIsAuthorizedNotLoggedIn() { + $user = [ + ]; + $this->View->set('_authUser', $user); + + $request = [ + 'controller' => 'Tags', + 'action' => 'edit', + ]; + $result = $this->AuthUserHelper->hasAccess($request); + $this->assertFalse($result); + } + + /** + * @return void + */ + public function testLinkNotLoggedIn() { + $user = [ + ]; + $this->View->set('_authUser', $user); + + $url = [ + 'controller' => 'Tags', + 'action' => 'edit', + ]; + $result = $this->AuthUserHelper->link('Edit', $url); + $this->assertSame('', $result); + } + + /** + * @return void + */ + public function testLinkValid() { + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->View->set('_authUser', $user); + + $url = [ + 'controller' => 'Tags', + 'action' => 'edit', + ]; + $result = $this->AuthUserHelper->link('Edit', $url); + $this->assertSame('Edit', $result); + } + + /** + * @return void + */ + public function testLinkInvalidDefault() { + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->View->set('_authUser', $user); + + $url = [ + 'controller' => 'Tags', + 'action' => 'delete', + ]; + $result = $this->AuthUserHelper->link('Edit', $url, ['default' => true]); + $this->assertSame('Edit', $result); + } + + /** + * @return void + */ + public function testPostLinkValid() { + $user = [ + 'id' => 1, + 'role_id' => 3 + ]; + $this->View->set('_authUser', $user); + + $url = [ + 'controller' => 'Tags', + 'action' => 'delete', + ]; + $result = $this->AuthUserHelper->postLink('Delete Me', $url); + $this->assertContains('
Delete <b>Me</b>', $result); + } + + /** + * @return void + */ + public function testPostLinkInvalid() { + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->View->set('_authUser', $user); + + $url = [ + 'controller' => 'Tags', + 'action' => 'delete', + ]; + $result = $this->AuthUserHelper->postLink('Delete Me', $url, ['default' => 'Foo']); + $this->assertSame('Foo', $result); + } + + /** + * @return void + */ + public function testPostLinkValidNoEscape() { + $user = [ + 'id' => 1, + 'role_id' => 3 + ]; + $this->View->set('_authUser', $user); + + $url = [ + 'controller' => 'Tags', + 'action' => 'delete', + ]; + $result = $this->AuthUserHelper->postLink('Delete Me', $url, ['escape' => false]); + $this->assertContains('Delete Me', $result); + } + + /** + * ACL links are basically free, they are as fast as normal links. + * + * @return void + */ + public function testLinkSpeed() { + $this->skipIf(env('COVERALLS')); + + $user = [ + 'id' => 1, + 'role_id' => 1 + ]; + $this->View->set('_authUser', $user); + + $url = [ + 'controller' => 'Tags', + 'action' => 'edit', + ]; + + $before = microtime(true); + + for ($i = 0; $i < 1000; $i++) { + $this->AuthUserHelper->link('Foo', $url); + } + + $after = microtime(true); + $diff = round($after - $before, 4); + $this->assertWithinRange(0.3, $diff, 0.3, '1000 iterations should be as fast as around 0.1-0.2 sek, but are ' . number_format($diff, 2) . 'sek.'); + } + + /** + * @return void + */ + public function testIsMe() { + $user = ['id' => '1']; + $this->View->set('_authUser', $user); + + $this->assertFalse($this->AuthUserHelper->isMe(0)); + $this->assertTrue($this->AuthUserHelper->isMe(1)); + } + + /** + * @return void + */ + public function testUser() { + $user = ['id' => '1', 'username' => 'foo']; + $this->View->set('_authUser', $user); + + $this->assertSame(['id' => '1', 'username' => 'foo'], $this->AuthUserHelper->user()); + $this->assertSame('foo', $this->AuthUserHelper->user('username')); + $this->assertNull($this->AuthUserHelper->user('foofoo')); + } + + /** + * @return void + */ + public function testRoles() { + $this->AuthUserHelper->config('multiRole', true); + $user = ['id' => '1', 'Roles' => ['1', '2']]; + $this->View->set('_authUser', $user); + + $this->assertSame(['user' => '1', 'moderator' => '2'], $this->AuthUserHelper->roles()); + } + +} diff --git a/tests/test_files/subfolder/acl.ini b/tests/test_files/subfolder/acl.ini new file mode 100644 index 00000000..2d0fc66d --- /dev/null +++ b/tests/test_files/subfolder/acl.ini @@ -0,0 +1,5 @@ +; ---------------------------------------------------------- +; Merged together with the rest +; ---------------------------------------------------------- +[Foo] +index = admin