diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..90bf8e14 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: + +The issue tracker is not a support forum. Please keep issues to bug reports and +enhancement proposals. For general CakePHP support, see +. + +## How to Contribute + +1. Fork the repository on GitHub and create a feature branch from `master`. +2. Add tests for any new functionality or bugfix (regression test). +3. Make sure the quality gates pass (see below). +4. Submit a pull request with a clear description of what changed and why. + +## Development Setup + +```bash +composer install + +# Run the plugin migrations +bin/cake migrations migrate -p TinyAuth +``` + +## Quality Gates + +Please run these before submitting (the same checks CI runs): + +```bash +composer test # PHPUnit +composer stan # PHPStan (run `composer stan-setup` once first) +composer cs-check # Coding standards (`composer cs-fix` to auto-fix) +``` + +## Coding Standards + +This plugin follows the PSR2R coding standards. Please make sure +`composer cs-check` is green before opening a pull request. + +## Updating the Locale POT File + +If you change any translatable strings, refresh the plugin's POT file by +running this from your application directory: + +```bash +bin/cake i18n extract --plugin TinyAuth --extract-core=no --merge=no --overwrite +``` + +## Pull Request Guidelines + +- Write clear, descriptive commit messages. +- Keep each pull request focused on a single feature or bug fix. +- Update the README/docs when you change user-facing behavior. + +## Questions? + +Open an issue for discussion if anything is unclear. diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index c19745ff..d36d3a70 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -23,19 +23,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 cache: npm cache-dependency-path: docs/package-lock.json - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Install dependencies run: npm ci @@ -46,7 +46,7 @@ jobs: working-directory: docs - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: docs/.vitepress/dist @@ -59,4 +59,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 586bf4c9..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,8 +0,0 @@ -# Contributing - -Feel free to fork and pull request. - -There are a few guidelines: - -- Coding standards passing: `composer cs-check` to check and `composer cs-fix` to fix. -- Tests passing: `composer test` to run them. diff --git a/composer.json b/composer.json index d79b3d65..ee91d7d8 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "role": "Maintainer" } ], - "homepage": "https://github.com/dereuromark/cakephp-tinyauth", + "homepage": "https://dereuromark.github.io/cakephp-tinyauth/", "support": { "source": "https://github.com/dereuromark/cakephp-tinyauth", "docs": "https://dereuromark.github.io/cakephp-tinyauth/" @@ -31,7 +31,7 @@ "cakephp/cakephp": "^5.1.1" }, "require-dev": { - "cakephp/authentication": "^3.0.1", + "cakephp/authentication": "^3.0.1 || ^4.0", "cakephp/authorization": "^3.0.1", "cakephp/debug_kit": "^5.0.1", "composer/semver": "^3.0", diff --git a/config/app.example.php b/config/app.example.php new file mode 100644 index 00000000..61554f1e --- /dev/null +++ b/config/app.example.php @@ -0,0 +1,56 @@ + [ + // allow (public access) configuration + 'allowAdapter' => IniAllowAdapter::class, // Adapter resolving public/allowed actions + 'allowFilePath' => null, // Path to the allow INI file, e.g. Plugin::configPath('Admin'); filePath is also honored for shared config + 'allowFile' => 'auth_allow.ini', // File name of the allow rules + 'allowNonPrefixed' => false, // true allows all non-prefixed controller actions as public access + 'allowPrefixes' => [], // Prefixes whitelisted as public access + + // acl (authorization) configuration + 'aclAdapter' => IniAclAdapter::class, // Adapter resolving ACL rules + 'idColumn' => 'id', // ID column in the users table + 'roleColumn' => 'role_id', // Foreign key for the role id in users table or pivot table + 'userColumn' => 'user_id', // Foreign key for the user id in the pivot table (multi-role setups only) + 'aliasColumn' => 'alias', // Column in roles table holding the role alias/slug + 'roleIdColumn' => 'id', // Primary key column in roles table (use 'uuid' for UUID-based systems) + 'rolesTable' => 'Roles', // Configure key holding available roles OR class name of the roles table + 'usersTable' => 'Users', // Name of the users table + 'pivotTable' => null, // Pivot table name (multi-role setups only) + 'multiRole' => false, // true enables multi-role/HABTM authorization (requires a valid pivot table) + 'superAdminRole' => null, // Id of the super admin role granting access to ALL resources + 'superAdmin' => null, // Super admin value granting access to ALL resources + 'superAdminColumn' => null, // Column of the super admin + 'authorizeByPrefix' => false, // true for 1:1 prefix-to-role matching, or a list of [prefix => role(s)] + 'allowLoggedIn' => false, // true grants logged-in users access to all actions except those under 'protectedPrefix' + 'protectedPrefix' => 'Admin', // Prefix name (or array) blacklisted when 'allowLoggedIn' is enabled + 'autoClearCache' => null, // true to auto-delete cache in debug mode; null auto-detects (uses Configure debug) + 'aclFilePath' => null, // Path to the ACL INI file, e.g. Plugin::configPath('Admin'); filePath is also honored for shared config + 'aclFile' => 'auth_acl.ini', // File name of the ACL rules + 'includeAuthentication' => false, // true to include public auth access into hasAccess() checks (requires Configure configuration) + + // Used by the sync/add commands (src/Sync/Syncer.php and src/Sync/Adder.php) + // to restrict which route prefixes are scanned. Null scans all. + // 'prefixes' => null, + ], +]; diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 808383d7..bdd59d2c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -56,6 +56,7 @@ export default defineConfig({ title: 'cakephp-tinyauth', description: 'INI-based authentication and authorization for CakePHP — a thin wrapper over the official Authentication and Authorization plugins.', base: '/cakephp-tinyauth/', + cleanUrls: true, lastUpdated: true, sitemap: { hostname: 'https://dereuromark.github.io/cakephp-tinyauth/', diff --git a/resources/schema/schema.dbml b/resources/schema/schema.dbml new file mode 100644 index 00000000..79b377d5 --- /dev/null +++ b/resources/schema/schema.dbml @@ -0,0 +1,35 @@ +// cakephp-tinyauth — database schema snapshot (DBML) +// +// A merged snapshot of all plugin migrations under config/Migrations/. +// Hand-maintained: update it when you add a migration that changes these tables. +// Paste into https://dbdiagram.io (or any DBML tool) to view/diagram. +// DBML reference: https://dbml.dbdiagram.io/docs +// Need it as a migration or raw SQL? Convert this DBML at https://toolbox.dereuromark.de/dbml + +Project cakephp_tinyauth { + database_type: 'Generic' + Note: 'TinyAuth sample tables only (multi-role example schema).' +} + +Table roles { + id integer [pk, increment] + name varchar(64) [not null] + description varchar(255) [not null] + alias varchar(20) [not null] + created datetime [null] + modified datetime [null] +} + +Table users { + id integer [pk, increment] + username varchar(255) [null] + password varchar(255) [null] + created datetime [null] + modified datetime [null] +} + +Table roles_users { + id integer [pk, increment] + user_id int [null] + role_id int [null] +} diff --git a/src/Auth/AclAdapter/AbstractAclAdapter.php b/src/Auth/AclAdapter/AbstractAclAdapter.php index 315205eb..804b9fb2 100644 --- a/src/Auth/AclAdapter/AbstractAclAdapter.php +++ b/src/Auth/AclAdapter/AbstractAclAdapter.php @@ -79,7 +79,7 @@ public function getAcl(array $availableRoles, array $config): array { } foreach ($roles as $role) { - $role = trim($role); + $role = trim((string)$role); if (!$role) { continue; } diff --git a/src/Auth/AclTrait.php b/src/Auth/AclTrait.php index 9adadad2..10d0aa3e 100644 --- a/src/Auth/AclTrait.php +++ b/src/Auth/AclTrait.php @@ -136,7 +136,7 @@ protected function _checkUser(ArrayAccess|array $user, array $params): bool { */ protected function _isProtectedPrefix($prefix, array $protectedPrefixes) { foreach ($protectedPrefixes as $protectedPrefix) { - if ($prefix === $protectedPrefix || strpos($prefix, $protectedPrefix . '/') === 0) { + if ($prefix === $protectedPrefix || str_starts_with((string)$prefix, $protectedPrefix . '/')) { return true; } } @@ -144,6 +144,33 @@ protected function _isProtectedPrefix($prefix, array $protectedPrefixes) { return false; } + /** + * Compare a request-side route slot value (plugin/prefix) against a rule-side value. + * + * Both `null` and the empty string are treated as "no plugin / no prefix". Anything + * else must match exactly. The previous code used `!empty()` which coerced `'0'` and + * other PHP-falsy values to "no plugin", which is too lossy for an authorization + * matcher. Using an explicit null/empty-string check is more honest about the input + * space and easier to audit. + * + * @param mixed $request The value from the current request's routing params. + * @param mixed $rule The value declared on the ACL rule. + * @return bool True when both sides describe the same slot. + */ + protected function _matchesRouteSlot($request, $rule): bool { + $requestEmpty = $request === null || $request === ''; + $ruleEmpty = $rule === null || $rule === ''; + + if ($requestEmpty && $ruleEmpty) { + return true; + } + if ($requestEmpty xor $ruleEmpty) { + return false; + } + + return $request === $rule; + } + /** * @param array $userRoles * @param array $params @@ -228,11 +255,7 @@ protected function _prefixMap(array $roles): array { return []; } - if ($prefixMap === true) { - $prefixMap = $this->_prefixesFromRoles($roles); - } else { - $prefixMap = $this->_normalizePrefixes($prefixMap); - } + $prefixMap = $prefixMap === true ? $this->_prefixesFromRoles($roles) : $this->_normalizePrefixes($prefixMap); $this->_prefixMap = $prefixMap; @@ -315,23 +338,16 @@ protected function _isPublic(array $params) { $authentication = $this->_getAuth(); foreach ($authentication as $rule) { - if (!empty($params['plugin'])) { - if ($params['plugin'] !== $rule['plugin']) { - continue; - } - } else { - if (!empty($rule['plugin'])) { - continue; - } + // Match plugin and prefix slots using `null`-equivalent semantics: + // a routing param that is missing, null, or an empty string is treated as + // "no plugin / no prefix". Previously `!empty()` swallowed the literal + // strings `'0'` and any value PHP coerces to false (rare for routes, but + // the explicit `null` check is more honest about what the param can be). + if (!$this->_matchesRouteSlot($params['plugin'] ?? null, $rule['plugin'] ?? null)) { + continue; } - if (!empty($params['prefix'])) { - if ($params['prefix'] !== $rule['prefix']) { - continue; - } - } else { - if (!empty($rule['prefix'])) { - continue; - } + if (!$this->_matchesRouteSlot($params['prefix'] ?? null, $rule['prefix'] ?? null)) { + continue; } if ($params['controller'] !== $rule['controller']) { continue; @@ -536,7 +552,7 @@ protected function _getUserRoles(ArrayAccess|array $user) { // Single-role from session if (!$this->getConfig('multiRole')) { $roleColumn = $this->getConfig('roleColumn'); - if (!$roleColumn) { + if (!is_string($roleColumn) || $roleColumn === '') { throw new CakeException('Invalid TinyAuth config, `roleColumn` config missing.'); } diff --git a/src/Auth/AllowTrait.php b/src/Auth/AllowTrait.php index dab9d46a..b8b64768 100644 --- a/src/Auth/AllowTrait.php +++ b/src/Auth/AllowTrait.php @@ -30,23 +30,15 @@ protected function _getAllowRule(array $params) { $allowDefaults = $this->_getAllowDefaultsForCurrentParams($params); foreach ($rules as $rule) { - if (isset($params['plugin'])) { - if ($params['plugin'] !== $rule['plugin']) { - continue; - } - } else { - if (!empty($rule['plugin'])) { - continue; - } + // Treat null and empty-string the same on both sides (request and rule); see + // AclTrait::_matchesRouteSlot for the rationale. Inlining the check here + // rather than calling the AclTrait method because this trait lives next to + // AclTrait but is not guaranteed to be composed alongside it. + if (!$this->_matchesAllowSlot($params['plugin'] ?? null, $rule['plugin'] ?? null)) { + continue; } - if (isset($params['prefix'])) { - if ($params['prefix'] !== $rule['prefix']) { - continue; - } - } else { - if (!empty($rule['prefix'])) { - continue; - } + if (!$this->_matchesAllowSlot($params['prefix'] ?? null, $rule['prefix'] ?? null)) { + continue; } if ($params['controller'] !== $rule['controller']) { continue; @@ -65,6 +57,30 @@ protected function _getAllowRule(array $params) { ]; } + /** + * Compare a request-side route slot value (plugin/prefix) against an allow-rule value. + * + * Treats null and the empty string as the "no plugin / no prefix" sentinel; anything + * else must match exactly. See AclTrait::_matchesRouteSlot for the broader rationale. + * + * @param mixed $request + * @param mixed $rule + * @return bool + */ + protected function _matchesAllowSlot($request, $rule): bool { + $requestEmpty = $request === null || $request === ''; + $ruleEmpty = $rule === null || $rule === ''; + + if ($requestEmpty && $ruleEmpty) { + return true; + } + if ($requestEmpty xor $ruleEmpty) { + return false; + } + + return $request === $rule; + } + /** * @param array $rule * @param string $action @@ -81,11 +97,7 @@ protected function _isActionAllowed(array $rule, $action) { return false; } - if (!in_array($action, $rule['allow'], true) && !in_array('*', $rule['allow'], true)) { - return false; - } - - return true; + return in_array($action, $rule['allow'], true) || in_array('*', $rule['allow'], true); } /** @@ -107,7 +119,7 @@ protected function _getAllowDefaultsForCurrentParams(array $params) { $result = []; if ($allowedPrefixes) { foreach ($allowedPrefixes as $allowedPrefix) { - if ($params['prefix'] === $allowedPrefix || strpos($params['prefix'], $allowedPrefix . '/') === 0) { + if ($params['prefix'] === $allowedPrefix || str_starts_with((string)$params['prefix'], $allowedPrefix . '/')) { return ['*']; } } diff --git a/src/Auth/AuthUserTrait.php b/src/Auth/AuthUserTrait.php index a92c4bdc..5a0a5d5a 100644 --- a/src/Auth/AuthUserTrait.php +++ b/src/Auth/AuthUserTrait.php @@ -96,9 +96,7 @@ public function roles(): array { return []; } - $roles = $this->_getUserRoles($user); - - return $roles; + return $this->_getUserRoles($user); } /** @@ -117,11 +115,7 @@ public function roles(): array { * @return bool Success */ public function hasRole($expectedRole, $providedRoles = null) { - if ($providedRoles !== null) { - $roles = (array)$providedRoles; - } else { - $roles = $this->roles(); - } + $roles = $providedRoles !== null ? (array)$providedRoles : $this->roles(); if (!$roles) { return false; @@ -161,11 +155,7 @@ public function hasRole($expectedRole, $providedRoles = null) { * @return bool Success */ public function hasRoles($expectedRoles, $oneRoleIsEnough = true, $providedRoles = null): bool { - if ($providedRoles !== null) { - $roles = $providedRoles; - } else { - $roles = $this->roles(); - } + $roles = $providedRoles ?? $this->roles(); $expectedRoles = (array)$expectedRoles; if (!$expectedRoles) { @@ -179,18 +169,12 @@ public function hasRoles($expectedRoles, $oneRoleIsEnough = true, $providedRoles return true; } $count++; - } else { - if (!$oneRoleIsEnough) { - return false; - } + } elseif (!$oneRoleIsEnough) { + return false; } } - if ($count === count($expectedRoles)) { - return true; - } - - return false; + return $count === count($expectedRoles); } /** diff --git a/src/Authenticator/PrimaryKeySessionAuthenticator.php b/src/Authenticator/PrimaryKeySessionAuthenticator.php index 5db9854e..fd0cbc86 100644 --- a/src/Authenticator/PrimaryKeySessionAuthenticator.php +++ b/src/Authenticator/PrimaryKeySessionAuthenticator.php @@ -62,7 +62,7 @@ public function authenticate(ServerRequestInterface $request): ResultInterface { } } - $user = $this->_identifier->identify([$this->getConfig('identifierKey') => $userId]); + $user = $this->getIdentifier()->identify([$this->getConfig('identifierKey') => $userId]); if (!$user) { return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND); } diff --git a/src/Filesystem/Folder.php b/src/Filesystem/Folder.php deleted file mode 100644 index abc73b23..00000000 --- a/src/Filesystem/Folder.php +++ /dev/null @@ -1,946 +0,0 @@ - - */ - protected array $_fsorts = [ - self::SORT_NAME => 'getPathname', - self::SORT_TIME => 'getCTime', - ]; - - /** - * Holds messages from last method. - * - * @var array - */ - protected array $_messages = []; - - /** - * Holds errors from last method. - * - * @var array - */ - protected array $_errors = []; - - /** - * Constructor. - * - * @param string|null $path Path to folder - * @param bool $create Create folder if not found - * @param int|null $mode Mode (CHMOD) to apply to created folder, false to ignore - */ - public function __construct(?string $path = null, bool $create = false, ?int $mode = null) { - if (!$path) { - $path = TMP; - } - if ($mode) { - $this->mode = $mode; - } - - if (!file_exists($path) && $create === true) { - $this->create($path, $this->mode); - } - if (!static::isAbsolute($path)) { - $path = realpath($path); - } - if (!empty($path)) { - $this->cd($path); - } - } - - /** - * Return current path. - * - * @return string|null Current path - */ - public function pwd(): ?string { - return $this->path; - } - - /** - * Change directory to $path. - * - * @param string $path Path to the directory to change to - * @return string|false The new path. Returns false on failure - */ - public function cd(string $path) { - $path = $this->realpath($path); - if ($path !== false && is_dir($path)) { - return $this->path = $path; - } - - return false; - } - - /** - * Returns an array of the contents of the current directory. - * The returned array holds two arrays: One of directories and one of files. - * - * @param string|bool $sort Whether you want the results sorted, set this and the sort property - * to `false` to get unsorted results. - * @param array|bool $exceptions Either an array or boolean true will not grab dot files - * @param bool $fullPath True returns the full path - * @return array> Contents of current directory as an array, an empty array on failure - */ - public function read($sort = self::SORT_NAME, $exceptions = false, bool $fullPath = false): array { - $dirs = $files = []; - - if (!$this->pwd()) { - return [$dirs, $files]; - } - if (is_array($exceptions)) { - $exceptions = array_flip($exceptions); - } - $skipHidden = isset($exceptions['.']) || $exceptions === true; - - try { - $iterator = new DirectoryIterator((string)$this->path); - } catch (Exception $e) { - return [$dirs, $files]; - } - - if (!is_bool($sort) && isset($this->_fsorts[$sort])) { - $methodName = $this->_fsorts[$sort]; - } else { - $methodName = $this->_fsorts[static::SORT_NAME]; - } - - foreach ($iterator as $item) { - if ($item->isDot()) { - continue; - } - $name = $item->getFilename(); - if ($skipHidden && $name[0] === '.' || isset($exceptions[$name])) { - continue; - } - if ($fullPath) { - $name = $item->getPathname(); - } - - if ($item->isDir()) { - $dirs[$item->{$methodName}()][] = $name; - } else { - $files[$item->{$methodName}()][] = $name; - } - } - - if ($sort || $this->sort) { - ksort($dirs); - ksort($files); - } - - if ($dirs) { - $dirs = array_merge(...array_values($dirs)); - } - - if ($files) { - $files = array_merge(...array_values($files)); - } - - return [$dirs, $files]; - } - - /** - * Returns an array of all matching files in current directory. - * - * @param string $regexpPattern Preg_match pattern (Defaults to: .*) - * @param string|bool $sort Whether results should be sorted. - * @return array Files that match given pattern - */ - public function find(string $regexpPattern = '.*', $sort = false): array { - [, $files] = $this->read($sort); - - return array_values(preg_grep('/^' . $regexpPattern . '$/i', $files) ?: []); - } - - /** - * Returns an array of all matching files in and below current directory. - * - * @param string $pattern Preg_match pattern (Defaults to: .*) - * @param bool $sort Whether results should be sorted. - * @return array Files matching $pattern - */ - public function findRecursive(string $pattern = '.*', bool $sort = false): array { - if (!$this->pwd()) { - return []; - } - $startsOn = (string)$this->path; - $out = $this->_findRecursive($pattern, $sort); - $this->cd($startsOn); - - return $out; - } - - /** - * Private helper function for findRecursive. - * - * @param string $pattern Pattern to match against - * @param bool $sort Whether results should be sorted. - * @return array Files matching pattern - */ - protected function _findRecursive(string $pattern, bool $sort = false): array { - [$dirs, $files] = $this->read($sort); - $found = []; - - foreach ($files as $file) { - if (preg_match('/^' . $pattern . '$/i', $file)) { - $found[] = static::addPathElement((string)$this->path, $file); - } - } - $start = (string)$this->path; - - foreach ($dirs as $dir) { - $this->cd(static::addPathElement($start, $dir)); - $found = array_merge($found, $this->findRecursive($pattern, $sort)); - } - - return $found; - } - - /** - * Returns true if given $path is a Windows path. - * - * @param string $path Path to check - * @return bool true if windows path, false otherwise - */ - public static function isWindowsPath(string $path): bool { - return preg_match('/^[A-Z]:\\\\/i', $path) || substr($path, 0, 2) === '\\\\'; - } - - /** - * Returns true if given $path is an absolute path. - * - * @param string $path Path to check - * @return bool true if path is absolute. - */ - public static function isAbsolute(string $path): bool { - if (!$path) { - return false; - } - - return $path[0] === '/' || - preg_match('/^[A-Z]:\\\\/i', $path) || - substr($path, 0, 2) === '\\\\' || - static::isRegisteredStreamWrapper($path); - } - - /** - * Returns true if given $path is a registered stream wrapper. - * - * @param string $path Path to check - * @return bool True if path is registered stream wrapper. - */ - public static function isRegisteredStreamWrapper(string $path): bool { - return preg_match('/^[^:\/]+?(?=:\/\/)/', $path, $matches) && - in_array($matches[0], stream_get_wrappers(), true); - } - - /** - * Returns a correct set of slashes for given $path. (\\ for Windows paths and / for other paths.) - * - * @param string $path Path to transform - * @return string Path with the correct set of slashes ("\\" or "/") - */ - public static function normalizeFullPath(string $path): string { - $to = static::correctSlashFor($path); - $from = ($to === '/' ? '\\' : '/'); - - return str_replace($from, $to, $path); - } - - /** - * Returns a correct set of slashes for given $path. (\\ for Windows paths and / for other paths.) - * - * @param string $path Path to check - * @return string Set of slashes ("\\" or "/") - */ - public static function correctSlashFor(string $path): string { - return static::isWindowsPath($path) ? '\\' : '/'; - } - - /** - * Returns $path with added terminating slash (corrected for Windows or other OS). - * - * @param string $path Path to check - * @return string Path with ending slash - */ - public static function slashTerm(string $path): string { - if (static::isSlashTerm($path)) { - return $path; - } - - return $path . static::correctSlashFor($path); - } - - /** - * Returns $path with $element added, with correct slash in-between. - * - * @param string $path Path - * @param array|string $element Element to add at end of path - * @return string Combined path - */ - public static function addPathElement(string $path, $element): string { - $element = (array)$element; - array_unshift($element, rtrim($path, DIRECTORY_SEPARATOR)); - - return implode(DIRECTORY_SEPARATOR, $element); - } - - /** - * Returns true if the Folder is in the given path. - * - * @param string $path The absolute path to check that the current `pwd()` resides within. - * @param bool $reverse Reverse the search, check if the given `$path` resides within the current `pwd()`. - * @throws \InvalidArgumentException When the given `$path` argument is not an absolute path. - * @return bool - */ - public function inPath(string $path, bool $reverse = false): bool { - if (!static::isAbsolute($path)) { - throw new InvalidArgumentException('The $path argument is expected to be an absolute path.'); - } - - $dir = static::slashTerm($path); - $current = static::slashTerm((string)$this->pwd()); - - if (!$reverse) { - $return = preg_match('/^' . preg_quote($dir, '/') . '(.*)/', $current); - } else { - $return = preg_match('/^' . preg_quote($current, '/') . '(.*)/', $dir); - } - - return (bool)$return; - } - - /** - * Change the mode on a directory structure recursively. This includes changing the mode on files as well. - * - * @param string $path The path to chmod. - * @param int|null $mode Octal value, e.g. 0755. - * @param bool $recursive Chmod recursively, set to false to only change the current directory. - * @param array $exceptions Array of files, directories to skip. - * @return bool Success. - */ - public function chmod(string $path, ?int $mode = null, bool $recursive = true, array $exceptions = []): bool { - if (!$mode) { - $mode = $this->mode; - } - - if ($recursive === false && is_dir($path)) { - // phpcs:disable - if (@chmod($path, intval($mode, 8))) { - // phpcs:enable - $this->_messages[] = sprintf('%s changed to %s', $path, $mode); - - return true; - } - - $this->_errors[] = sprintf('%s NOT changed to %s', $path, $mode); - - return false; - } - - if (is_dir($path)) { - $paths = $this->tree($path); - - foreach ($paths as $type) { - foreach ($type as $fullpath) { - $check = explode(DIRECTORY_SEPARATOR, $fullpath); - $count = count($check); - - if (in_array($check[$count - 1], $exceptions, true)) { - continue; - } - - // phpcs:disable - if (@chmod($fullpath, intval($mode, 8))) { - // phpcs:enable - $this->_messages[] = sprintf('%s changed to %s', $fullpath, $mode); - } else { - $this->_errors[] = sprintf('%s NOT changed to %s', $fullpath, $mode); - } - } - } - - if (!$this->_errors) { - return true; - } - } - - return false; - } - - /** - * Returns an array of subdirectories for the provided or current path. - * - * @param string|null $path The directory path to get subdirectories for. - * @param bool $fullPath Whether to return the full path or only the directory name. - * @return array Array of subdirectories for the provided or current path. - */ - public function subdirectories(?string $path = null, bool $fullPath = true): array { - if (!$path) { - $path = (string)$this->path; - } - $subdirectories = []; - - try { - $iterator = new DirectoryIterator($path); - } catch (Exception $e) { - return []; - } - - /** @var \DirectoryIterator<\SplFileInfo> $item */ - foreach ($iterator as $item) { - if (!$item->isDir() || $item->isDot()) { - continue; - } - $subdirectories[] = $fullPath ? $item->getRealPath() : $item->getFilename(); - } - - return $subdirectories; - } - - /** - * Returns an array of nested directories and files in each directory - * - * @param string|null $path the directory path to build the tree from - * @param array|bool $exceptions Either an array of files/folder to exclude - * or boolean true to not grab dot files/folders - * @param string|null $type either 'file' or 'dir'. Null returns both files and directories - * @return array Array of nested directories and files in each directory - */ - public function tree(?string $path = null, $exceptions = false, ?string $type = null): array { - if (!$path) { - $path = (string)$this->path; - } - $files = []; - $directories = [$path]; - - if (is_array($exceptions)) { - $exceptions = array_flip($exceptions); - } - $skipHidden = false; - if ($exceptions === true) { - $skipHidden = true; - } elseif (isset($exceptions['.'])) { - $skipHidden = true; - unset($exceptions['.']); - } - - $directory = $iterator = null; - try { - $directory = new RecursiveDirectoryIterator( - $path, - RecursiveDirectoryIterator::KEY_AS_PATHNAME | RecursiveDirectoryIterator::CURRENT_AS_SELF, - ); - $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); - } catch (Exception $e) { - unset($directory, $iterator); - - if ($type === null) { - return [[], []]; - } - - return []; - } - - /** - * @var string $itemPath - * @var \RecursiveDirectoryIterator $fsIterator - */ - foreach ($iterator as $itemPath => $fsIterator) { - if ($skipHidden) { - $subPathName = $fsIterator->getSubPathname(); - if ($subPathName[0] === '.' || strpos($subPathName, DIRECTORY_SEPARATOR . '.') !== false) { - unset($fsIterator); - - continue; - } - } - /** @var \FilesystemIterator $item */ - $item = $fsIterator->current(); - if (!empty($exceptions) && isset($exceptions[$item->getFilename()])) { - unset($fsIterator, $item); - - continue; - } - - if ($item->isFile()) { - $files[] = $itemPath; - } elseif ($item->isDir() && !$item->isDot()) { - $directories[] = $itemPath; - } - - // inner iterators need to be unset too in order for locks on parents to be released - unset($fsIterator, $item); - } - - // unsetting iterators helps releasing possible locks in certain environments, - // which could otherwise make `rmdir()` fail - unset($directory, $iterator); - - if ($type === null) { - return [$directories, $files]; - } - if ($type === 'dir') { - return $directories; - } - - return $files; - } - - /** - * Create a directory structure recursively. - * - * Can be used to create deep path structures like `/foo/bar/baz/shoe/horn` - * - * @param string $pathname The directory structure to create. Either an absolute or relative - * path. If the path is relative and exists in the process' cwd it will not be created. - * Otherwise, relative paths will be prefixed with the current pwd(). - * @param int|null $mode octal value 0755 - * @return bool Returns TRUE on success, FALSE on failure - */ - public function create(string $pathname, ?int $mode = null): bool { - if (is_dir($pathname) || empty($pathname)) { - return true; - } - - if (!static::isAbsolute($pathname)) { - $pathname = static::addPathElement((string)$this->pwd(), $pathname); - } - - if (!$mode) { - $mode = $this->mode; - } - - if (is_file($pathname)) { - $this->_errors[] = sprintf('%s is a file', $pathname); - - return false; - } - $pathname = rtrim($pathname, DIRECTORY_SEPARATOR); - $nextPathname = substr($pathname, 0, (int)strrpos($pathname, DIRECTORY_SEPARATOR)); - - if ($this->create($nextPathname, $mode)) { - if (!file_exists($pathname)) { - $old = umask(0); - if (mkdir($pathname, $mode, true)) { - $this->_messages[] = sprintf('%s created', $pathname); - umask($old); - - return true; - } - $this->_errors[] = sprintf('%s NOT created', $pathname); - umask($old); - - return false; - } - } - - return false; - } - - /** - * Returns the size in bytes of this Folder and its contents. - * - * @return int size in bytes of current folder - */ - public function dirsize(): int { - $size = 0; - $directory = static::slashTerm((string)$this->path); - $stack = [$directory]; - $count = count($stack); - for ($i = 0, $j = $count; $i < $j; $i++) { - if (is_file($stack[$i])) { - $size += filesize($stack[$i]); - } elseif (is_dir($stack[$i])) { - $dir = dir($stack[$i]); - if ($dir) { - while (($entry = $dir->read()) !== false) { - if ($entry === '.' || $entry === '..') { - continue; - } - $add = $stack[$i] . $entry; - - if (is_dir($stack[$i] . $entry)) { - $add = static::slashTerm($add); - } - $stack[] = $add; - } - $dir->close(); - } - } - $j = count($stack); - } - - return $size; - } - - /** - * Recursively Remove directories if the system allows. - * - * @param string|null $path Path of directory to delete - * @return bool Success - */ - public function delete(?string $path = null): bool { - if (!$path) { - $path = $this->pwd(); - } - if (!$path) { - return false; - } - $path = static::slashTerm($path); - if (is_dir($path)) { - $directory = $iterator = null; - try { - $directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::CURRENT_AS_SELF); - $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::CHILD_FIRST); - } catch (Exception $e) { - unset($directory, $iterator); - - return false; - } - - foreach ($iterator as $item) { - $filePath = $item->getPathname(); - if ($item->isFile() || $item->isLink()) { - // phpcs:disable - if (@unlink($filePath)) { - // phpcs:enable - $this->_messages[] = sprintf('%s removed', $filePath); - } else { - $this->_errors[] = sprintf('%s NOT removed', $filePath); - } - } elseif ($item->isDir() && !$item->isDot()) { - // phpcs:disable - if (@rmdir($filePath)) { - // phpcs:enable - $this->_messages[] = sprintf('%s removed', $filePath); - } else { - $this->_errors[] = sprintf('%s NOT removed', $filePath); - - unset($directory, $iterator, $item); - - return false; - } - } - - // inner iterators need to be unset too in order for locks on parents to be released - unset($item); - } - - // unsetting iterators helps releasing possible locks in certain environments, - // which could otherwise make `rmdir()` fail - unset($directory, $iterator); - - $path = rtrim($path, DIRECTORY_SEPARATOR); - // phpcs:disable - if (@rmdir($path)) { - // phpcs:enable - $this->_messages[] = sprintf('%s removed', $path); - } else { - $this->_errors[] = sprintf('%s NOT removed', $path); - - return false; - } - } - - return true; - } - - /** - * Recursive directory copy. - * - * ### Options - * - * - `from` The directory to copy from, this will cause a cd() to occur, changing the results of pwd(). - * - `mode` The mode to copy the files/directories with as integer, e.g. 0775. - * - `skip` Files/directories to skip. - * - `scheme` Folder::MERGE, Folder::OVERWRITE, Folder::SKIP - * - `recursive` Whether to copy recursively or not (default: true - recursive) - * - * @param string $to The directory to copy to. - * @param array $options Array of options (see above). - * @return bool Success. - */ - public function copy(string $to, array $options = []): bool { - if (!$this->pwd()) { - return false; - } - $options += [ - 'from' => $this->path, - 'mode' => $this->mode, - 'skip' => [], - 'scheme' => static::MERGE, - 'recursive' => true, - ]; - - $fromDir = $options['from']; - $toDir = $to; - $mode = $options['mode']; - - if (!$this->cd($fromDir)) { - $this->_errors[] = sprintf('%s not found', $fromDir); - - return false; - } - - if (!is_dir($toDir)) { - $this->create($toDir, $mode); - } - - if (!is_writable($toDir)) { - $this->_errors[] = sprintf('%s not writable', $toDir); - - return false; - } - - $exceptions = array_merge(['.', '..', '.svn'], $options['skip']); - // phpcs:disable - if ($handle = @opendir($fromDir)) { - // phpcs:enable - while (($item = readdir($handle)) !== false) { - $to = static::addPathElement($toDir, $item); - if (($options['scheme'] !== static::SKIP || !is_dir($to)) && !in_array($item, $exceptions, true)) { - $from = static::addPathElement($fromDir, $item); - if (is_file($from) && (!is_file($to) || $options['scheme'] !== static::SKIP)) { - if (copy($from, $to)) { - chmod($to, intval($mode, 8)); - touch($to, filemtime($from) ?: null); - $this->_messages[] = sprintf('%s copied to %s', $from, $to); - } else { - $this->_errors[] = sprintf('%s NOT copied to %s', $from, $to); - } - } - - if (is_dir($from) && file_exists($to) && $options['scheme'] === static::OVERWRITE) { - $this->delete($to); - } - - if (is_dir($from) && $options['recursive'] === false) { - continue; - } - - if (is_dir($from) && !file_exists($to)) { - $old = umask(0); - if (mkdir($to, $mode, true)) { - umask($old); - $old = umask(0); - chmod($to, $mode); - umask($old); - $this->_messages[] = sprintf('%s created', $to); - $options = ['from' => $from] + $options; - $this->copy($to, $options); - } else { - $this->_errors[] = sprintf('%s not created', $to); - } - } elseif (is_dir($from) && $options['scheme'] === static::MERGE) { - $options = ['from' => $from] + $options; - $this->copy($to, $options); - } - } - } - closedir($handle); - } else { - return false; - } - - return empty($this->_errors); - } - - /** - * Recursive directory move. - * - * ### Options - * - * - `from` The directory to copy from, this will cause a cd() to occur, changing the results of pwd(). - * - `mode` The mode to copy the files/directories with as integer, e.g. 0775. - * - `skip` Files/directories to skip. - * - `scheme` Folder::MERGE, Folder::OVERWRITE, Folder::SKIP - * - `recursive` Whether to copy recursively or not (default: true - recursive) - * - * @param string $to The directory to move to. - * @param array $options Array of options (see above). - * @return bool Success - */ - public function move(string $to, array $options = []): bool { - $options += ['from' => $this->path, 'mode' => $this->mode, 'skip' => [], 'recursive' => true]; - - if ($this->copy($to, $options) && $this->delete($options['from'])) { - return (bool)$this->cd($to); - } - - return false; - } - - /** - * get messages from latest method - * - * @param bool $reset Reset message stack after reading - * @return array - */ - public function messages(bool $reset = true): array { - $messages = $this->_messages; - if ($reset) { - $this->_messages = []; - } - - return $messages; - } - - /** - * get error from latest method - * - * @param bool $reset Reset error stack after reading - * @return array - */ - public function errors(bool $reset = true): array { - $errors = $this->_errors; - if ($reset) { - $this->_errors = []; - } - - return $errors; - } - - /** - * Get the real path (taking ".." and such into account) - * - * @param string $path Path to resolve - * @return string|false The resolved path - */ - public function realpath($path) { - if (strpos($path, '..') === false) { - if (!static::isAbsolute($path)) { - $path = static::addPathElement((string)$this->path, $path); - } - - return $path; - } - $path = str_replace('/', DIRECTORY_SEPARATOR, trim($path)); - $parts = explode(DIRECTORY_SEPARATOR, $path); - $newparts = []; - $newpath = ''; - if ($path[0] === DIRECTORY_SEPARATOR) { - $newpath = DIRECTORY_SEPARATOR; - } - - while (($part = array_shift($parts)) !== null) { - if ($part === '.' || $part === '') { - continue; - } - if ($part === '..') { - if (!empty($newparts)) { - array_pop($newparts); - - continue; - } - - return false; - } - $newparts[] = $part; - } - $newpath .= implode(DIRECTORY_SEPARATOR, $newparts); - - return static::slashTerm($newpath); - } - - /** - * Returns true if given $path ends in a slash (i.e. is slash-terminated). - * - * @param string $path Path to check - * @return bool true if path ends with slash, false otherwise - */ - public static function isSlashTerm(string $path): bool { - $lastChar = $path[strlen($path) - 1]; - - return $lastChar === '/' || $lastChar === '\\'; - } - -} diff --git a/src/Panel/AuthPanel.php b/src/Panel/AuthPanel.php index 04f3a759..648009e4 100644 --- a/src/Panel/AuthPanel.php +++ b/src/Panel/AuthPanel.php @@ -92,11 +92,7 @@ public function shutdown(EventInterface $event): void { $access = []; foreach ($availableRoles as $role => $id) { - if ($user) { - $tmpUser = $this->_injectRole($user, $role, $id); - } else { - $tmpUser = $this->_generateUser($role, $id); - } + $tmpUser = $user ? $this->_injectRole($user, $role, $id) : $this->_generateUser($role, $id); $access[$role] = $this->_checkUser($tmpUser, $params); } diff --git a/src/Sync/Adder.php b/src/Sync/Adder.php index 9ef9e0a9..b41d414f 100644 --- a/src/Sync/Adder.php +++ b/src/Sync/Adder.php @@ -7,7 +7,6 @@ use Cake\Core\App; use Cake\Core\Configure; use Cake\Core\Plugin; -use TinyAuth\Filesystem\Folder; use TinyAuth\Utility\Utility; /** @@ -67,7 +66,7 @@ public function addAcl(string $controller, string $action, array $roles, Argumen if (isset($content[$controller][$action]) || isset($content[$controller]['*'])) { $mappedRoles = $content[$controller][$action] ?? $content[$controller]['*']; - if (strpos($mappedRoles, ',') !== false) { + if (str_contains($mappedRoles, ',')) { $mappedRoles = array_map('trim', explode(',', $mappedRoles)); } $this->checkRoles($roles, (array)$mappedRoles, $io); @@ -125,7 +124,7 @@ protected function _getControllers($plugin) { * @return array */ protected function _parseControllers($folder, $plugin, $prefix = null) { - $folderContent = (new Folder($folder))->read(Folder::SORT_NAME, true); + $folderContent = $this->_listDirectory($folder); $controllers = []; foreach ($folderContent[1] as $file) { @@ -159,6 +158,44 @@ protected function _parseControllers($folder, $plugin, $prefix = null) { return $controllers; } + /** + * Read directory entries split into [folders, files], both name-sorted ascending. + * + * See {@see \TinyAuth\Sync\Syncer::_listDirectory()} for the rationale — this is the + * same replacement for the previously-vendored legacy `Folder::read()` call, kept + * local to this class to avoid introducing a shared base class purely for two + * near-clone implementations. Hidden entries (names starting with `.`) are + * skipped to preserve the previous behavior of not descending into `.git`, + * `.svn`, `.DS_Store` etc. when scanning controller directories. + * + * @param string $folder Directory path (trailing DS optional) + * @return array{0: array, 1: array} + */ + protected function _listDirectory(string $folder): array { + if (!is_dir($folder)) { + return [[], []]; + } + + $folder = rtrim($folder, DS) . DS; + $folders = []; + $files = []; + foreach (scandir($folder) ?: [] as $entry) { + if ($entry === '' || $entry[0] === '.') { + continue; + } + $path = $folder . $entry; + if (is_dir($path)) { + $folders[] = $entry; + } elseif (is_file($path)) { + $files[] = $entry; + } + } + sort($folders); + sort($files); + + return [$folders, $files]; + } + /** * @param string $name * @param string|null $plugin @@ -166,7 +203,7 @@ protected function _parseControllers($folder, $plugin, $prefix = null) { * @return bool */ protected function _noAuthenticationNeeded($name, $plugin, $prefix) { - if (!isset($this->authAllow)) { + if ($this->authAllow === null) { $this->authAllow = $this->_parseAuthAllow(); } @@ -175,12 +212,8 @@ protected function _noAuthenticationNeeded($name, $plugin, $prefix) { return false; } - if ($this->authAllow[$key] === '*') { - return true; - } - //TODO: specific actions? - return false; + return $this->authAllow[$key] === '*'; } /** diff --git a/src/Sync/Syncer.php b/src/Sync/Syncer.php index 36007a68..6aafb64c 100644 --- a/src/Sync/Syncer.php +++ b/src/Sync/Syncer.php @@ -7,7 +7,6 @@ use Cake\Core\App; use Cake\Core\Configure; use Cake\Core\Plugin; -use TinyAuth\Filesystem\Folder; use TinyAuth\Utility\Utility; class Syncer { @@ -115,7 +114,7 @@ protected function _getControllers($plugin) { * @return array */ protected function _parseControllers($folder, $plugin, $prefix = null) { - $folderContent = (new Folder($folder))->read(Folder::SORT_NAME, true); + $folderContent = $this->_listDirectory($folder); $controllers = []; foreach ($folderContent[1] as $file) { @@ -149,6 +148,46 @@ protected function _parseControllers($folder, $plugin, $prefix = null) { return $controllers; } + /** + * Read directory entries split into [folders, files], both name-sorted ascending. + * + * Replaces the previously-vendored `Folder::read()` from the legacy CakePHP + * filesystem package. The shape matches what `Folder::read()` returned so the + * existing `$folderContent[0]` (subfolders) / `$folderContent[1]` (files) + * unpacking keeps working. Hidden entries (names starting with `.`) are + * skipped to match the previous behavior — important because the legacy + * Folder::read() didn't descend into `.git`, `.svn`, `.DS_Store`, etc. when + * scanning controller directories. Missing or unreadable directories yield + * two empty arrays rather than fataling. + * + * @param string $folder Directory path (trailing DS optional) + * @return array{0: array, 1: array} + */ + protected function _listDirectory(string $folder): array { + if (!is_dir($folder)) { + return [[], []]; + } + + $folder = rtrim($folder, DS) . DS; + $folders = []; + $files = []; + foreach (scandir($folder) ?: [] as $entry) { + if ($entry === '' || $entry[0] === '.') { + continue; + } + $path = $folder . $entry; + if (is_dir($path)) { + $folders[] = $entry; + } elseif (is_file($path)) { + $files[] = $entry; + } + } + sort($folders); + sort($files); + + return [$folders, $files]; + } + /** * @param string $name * @param string|null $plugin @@ -156,7 +195,7 @@ protected function _parseControllers($folder, $plugin, $prefix = null) { * @return bool */ protected function _noAuthenticationNeeded($name, $plugin, $prefix) { - if (!isset($this->authAllow)) { + if ($this->authAllow === null) { $this->authAllow = $this->_parseAuthAllow(); } @@ -165,12 +204,8 @@ protected function _noAuthenticationNeeded($name, $plugin, $prefix) { return false; } - if ($this->authAllow[$key] === '*') { - return true; - } - //TODO: specific actions? - return false; + return $this->authAllow[$key] === '*'; } /** diff --git a/src/Utility/Cache.php b/src/Utility/Cache.php index e30aa5a1..d4c0af8a 100644 --- a/src/Utility/Cache.php +++ b/src/Utility/Cache.php @@ -80,8 +80,16 @@ public static function read(string $type): ?array { } /** + * Resolve the cache key for a given type (acl/allow), prepending the configured prefix. + * + * Two TinyAuth installs that share a Cake cache pool would previously collide on the + * bare `acl` / `allow` keys because the `cachePrefix` option was declared but never + * applied. Multi-tenant or test environments where Cache pools survive across plugin + * boots could read another install's permissions data. The prefix is now applied so + * the on-disk key is e.g. `tiny_auth_acl` / `tiny_auth_allow` by default. + * * @param string $type - *@throws \Cake\Core\Exception\CakeException + * @throws \Cake\Core\Exception\CakeException * @return string */ public static function key(string $type): string { @@ -89,12 +97,14 @@ public static function key(string $type): string { static::assertValidCacheSetup($config); - $key = $type . 'CacheKey'; - if (empty($config[$key])) { - throw new CakeException(sprintf('Invalid TinyAuth cache key `%s`', $key)); + $keyConfigName = $type . 'CacheKey'; + if (empty($config[$keyConfigName])) { + throw new CakeException(sprintf('Invalid TinyAuth cache key `%s`', $keyConfigName)); } - return $config[$key]; + $prefix = isset($config['cachePrefix']) ? (string)$config['cachePrefix'] : ''; + + return $prefix . $config[$keyConfigName]; } /** diff --git a/src/Utility/TinyAuth.php b/src/Utility/TinyAuth.php index 275d70f3..dab91817 100644 --- a/src/Utility/TinyAuth.php +++ b/src/Utility/TinyAuth.php @@ -30,9 +30,7 @@ public function __construct(array $config = []) { * @return array */ public function getAvailableRoles() { - $roles = $this->_getAvailableRoles(); - - return $roles; + return $this->_getAvailableRoles(); } } diff --git a/tests/TestCase/Auth/AclTraitRouteSlotMatcherTest.php b/tests/TestCase/Auth/AclTraitRouteSlotMatcherTest.php new file mode 100644 index 00000000..9b86f555 --- /dev/null +++ b/tests/TestCase/Auth/AclTraitRouteSlotMatcherTest.php @@ -0,0 +1,106 @@ +aclHost = new class { + + use AclTrait; + + public function call(mixed $request, mixed $rule): bool { + return $this->_matchesRouteSlot($request, $rule); + } + + }; + + $this->allowHost = new class { + + use AllowTrait; + + public function call(mixed $request, mixed $rule): bool { + return $this->_matchesAllowSlot($request, $rule); + } + + }; + } + + /** + * @return array + */ + public static function slotProvider(): array { + return [ + // Both empty → match (no plugin/prefix on either side) + [null, null, true], + [null, '', true], + ['', null, true], + ['', '', true], + // Only one side empty → no match (we are protecting against the + // old !empty()-mixed-with-isset() confusion) + [null, 'Admin', false], + ['Admin', null, false], + ['', 'Admin', false], + ['Admin', '', false], + // Both populated → strict equality (no loose juggling) + ['Admin', 'Admin', true], + ['Admin', 'Public', false], + ['0', '0', true], + ['0', 0, false], // distinct types must not collapse + ]; + } + + /** + * @param mixed $request + * @param mixed $rule + * @param bool $expected + * @return void + */ + #[DataProvider('slotProvider')] + public function testAclMatcher(mixed $request, mixed $rule, bool $expected): void { + $this->assertSame($expected, $this->aclHost->call($request, $rule)); + } + + /** + * @param mixed $request + * @param mixed $rule + * @param bool $expected + * @return void + */ + #[DataProvider('slotProvider')] + public function testAllowMatcher(mixed $request, mixed $rule, bool $expected): void { + $this->assertSame($expected, $this->allowHost->call($request, $rule)); + } + + /** + * Reflection sanity check: the protected method is still named and visible + * for downstream subclasses that may want to override matching semantics. + * + * @return void + */ + public function testMatcherIsProtectedAndOverridable(): void { + $ref = new ReflectionMethod($this->aclHost, '_matchesRouteSlot'); + $this->assertTrue($ref->isProtected()); + $this->assertFalse($ref->isFinal()); + } + +} diff --git a/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php b/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php index 2746f9ff..c3da1b82 100644 --- a/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php +++ b/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php @@ -5,7 +5,8 @@ use ArrayObject; use Authentication\Authenticator\Result; -use Authentication\Identifier\IdentifierCollection; +use Authentication\Identifier\PasswordIdentifier; +use Authentication\Identifier\TokenIdentifier; use Cake\Http\Exception\UnauthorizedException; use Cake\Http\Response; use Cake\Http\ServerRequestFactory; @@ -26,9 +27,9 @@ class PrimaryKeySessionAuthenticatorTest extends TestCase { ]; /** - * @var \Authentication\IdentifierCollection + * @var \Authentication\Identifier\IdentifierInterface */ - protected $identifiers; + protected $identifier; /** * @var \Cake\Http\Session&\PHPUnit\Framework\MockObject\MockObject @@ -41,9 +42,7 @@ class PrimaryKeySessionAuthenticatorTest extends TestCase { public function setUp(): void { parent::setUp(); - $this->identifiers = new IdentifierCollection([ - 'Authentication.Password', - ]); + $this->identifier = new PasswordIdentifier(); $this->sessionMock = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() @@ -66,18 +65,16 @@ public function testAuthenticateSuccess() { $request = $request->withAttribute('session', $this->sessionMock); - $this->identifiers = new IdentifierCollection([ - 'Authentication.Token' => [ - 'tokenField' => 'id', - 'dataField' => 'key', - 'resolver' => [ - 'className' => 'Authentication.Orm', - 'finder' => 'active', - ], + $this->identifier = new TokenIdentifier([ + 'tokenField' => 'id', + 'dataField' => 'key', + 'resolver' => [ + 'className' => 'Authentication.Orm', + 'finder' => 'active', ], ]); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $result = $authenticator->authenticate($request); $this->assertInstanceOf(Result::class, $result); @@ -106,18 +103,16 @@ public function testAuthenticateSuccessCustomFinder() { $request = $request->withAttribute('session', $this->sessionMock); - $this->identifiers = new IdentifierCollection([ - 'Authentication.Token' => [ - 'tokenField' => 'id', - 'dataField' => 'key', - 'resolver' => [ - 'className' => 'Authentication.Orm', - 'finder' => 'active', - ], + $this->identifier = new TokenIdentifier([ + 'tokenField' => 'id', + 'dataField' => 'key', + 'resolver' => [ + 'className' => 'Authentication.Orm', + 'finder' => 'active', ], ]); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers, []); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier, []); $result = $authenticator->authenticate($request); $this->assertInstanceOf(Result::class, $result); @@ -142,7 +137,7 @@ public function testAuthenticateFailure() { $request = $request->withAttribute('session', $this->sessionMock); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $result = $authenticator->authenticate($request); $this->assertInstanceOf(Result::class, $result); @@ -164,7 +159,7 @@ public function testVerifyByDatabaseFailure() { $request = $request->withAttribute('session', $this->sessionMock); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers, []); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier, []); $result = $authenticator->authenticate($request); $this->assertInstanceOf(Result::class, $result); @@ -180,7 +175,7 @@ public function testPersistIdentity() { $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); $request = $request->withAttribute('session', $this->sessionMock); $response = new Response(); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $data = new ArrayObject(['id' => 1]); @@ -222,7 +217,7 @@ public function testClearIdentity() { $request = $request->withAttribute('session', $this->sessionMock); $response = new Response(); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $this->sessionMock->expects($this->once()) ->method('delete') @@ -250,7 +245,7 @@ public function testImpersonate() { $request = $request->withAttribute('session', $this->sessionMock); $response = new Response(); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $usersTable = $this->fetchTable('Users'); $impersonator = $usersTable->newEntity([ 'username' => 'mariano', @@ -290,7 +285,7 @@ public function testImpersonateAlreadyImpersonating() { $request = $request->withAttribute('session', $this->sessionMock); $response = new Response(); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $impersonator = new ArrayObject([ 'username' => 'mariano', 'password' => 'password', @@ -323,7 +318,7 @@ public function testStopImpersonating() { $request = $request->withAttribute('session', $this->sessionMock); $response = new Response(); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $impersonator = new ArrayObject([ 'username' => 'mariano', @@ -369,7 +364,7 @@ public function testStopImpersonatingNotImpersonating() { $request = $request->withAttribute('session', $this->sessionMock); $response = new Response(); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $this->sessionMock->expects($this->once()) ->method('check') @@ -405,7 +400,7 @@ public function testIsImpersonating() { $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); $request = $request->withAttribute('session', $this->sessionMock); - $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifier); $this->sessionMock->expects($this->once()) ->method('check') diff --git a/tests/TestCase/Command/AddCommandTest.php b/tests/TestCase/Command/AddCommandTest.php index 2fc93a5b..6e4a440e 100644 --- a/tests/TestCase/Command/AddCommandTest.php +++ b/tests/TestCase/Command/AddCommandTest.php @@ -6,7 +6,8 @@ use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; use Cake\TestSuite\TestCase; -use TinyAuth\Filesystem\Folder; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; class AddCommandTest extends TestCase { @@ -37,8 +38,7 @@ public function testAdd() { Configure::write('TinyAuth.aclFilePath', TESTS . 'test_files/subfolder/'); Configure::write('TinyAuth.allowFilePath', TESTS . 'test_files/'); - $folder = new Folder(); - $folder->copy('/tmp' . DS . 'src' . DS . 'Controller' . DS, ['from' => TESTS . 'test_app' . DS . 'Controller' . DS]); + $this->copyDirectory(TESTS . 'test_app' . DS . 'Controller' . DS, '/tmp' . DS . 'src' . DS . 'Controller' . DS); $this->exec('tiny_auth add Some action foo,bar -d -v'); @@ -47,4 +47,65 @@ public function testAdd() { $this->assertOutputContains('action = foo, bar'); } + /** + * Hidden directories (e.g. `.git`, `.svn`, `.DS_Store`) must not be descended into + * during controller discovery — that was the behavior of the legacy `Folder::read` + * we replaced, and the scan would otherwise spend time recursing through editor / + * VCS metadata and possibly choke on weird filenames. + * + * @return void + */ + public function testAddSkipsHiddenDirectories() { + Configure::write('TinyAuth.aclFilePath', TESTS . 'test_files/subfolder/'); + Configure::write('TinyAuth.allowFilePath', TESTS . 'test_files/'); + + $target = '/tmp' . DS . 'src' . DS . 'Controller' . DS; + $this->copyDirectory(TESTS . 'test_app' . DS . 'Controller' . DS, $target); + + // Drop a hidden directory containing a "controller-shaped" file. If the scan + // recursed into it, the file would show up in the output. With dotfile skip + // in place the entry is invisible to Adder. + $hiddenDir = $target . '.git'; + if (!is_dir($hiddenDir)) { + mkdir($hiddenDir, 0777, true); + } + file_put_contents($hiddenDir . DS . 'HiddenController.php', "exec('tiny_auth add Some action foo,bar -d -v'); + + $this->assertExitCode(Command::CODE_SUCCESS); + $this->assertOutputNotContains('Hidden'); + } + + /** + * Recursively copy $source into $target, creating directories as needed. + * + * Drop-in replacement for the previously-used `Folder::copy()` from the now-removed + * vendored legacy CakePHP filesystem package. Test-only helper. + * + * @param string $source + * @param string $target + * @return void + */ + protected function copyDirectory(string $source, string $target): void { + if (!is_dir($target)) { + mkdir($target, 0777, true); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($iterator as $item) { + $dest = $target . DS . $iterator->getSubPathName(); + if ($item->isDir()) { + if (!is_dir($dest)) { + mkdir($dest, 0777, true); + } + } else { + copy($item->getPathname(), $dest); + } + } + } + } diff --git a/tests/TestCase/Command/SyncCommandTest.php b/tests/TestCase/Command/SyncCommandTest.php index 105a13b6..0fde2de7 100644 --- a/tests/TestCase/Command/SyncCommandTest.php +++ b/tests/TestCase/Command/SyncCommandTest.php @@ -6,7 +6,8 @@ use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; use Cake\TestSuite\TestCase; -use TinyAuth\Filesystem\Folder; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; class SyncCommandTest extends TestCase { @@ -37,8 +38,7 @@ public function testSync() { Configure::write('TinyAuth.aclFilePath', TESTS . 'test_files/subfolder/'); Configure::write('TinyAuth.allowFilePath', TESTS . 'test_files/'); - $folder = new Folder(); - $folder->copy('/tmp' . DS . 'src' . DS . 'Controller' . DS, ['from' => TESTS . 'test_app' . DS . 'Controller' . DS]); + $this->copyDirectory(TESTS . 'test_app' . DS . 'Controller' . DS, '/tmp' . DS . 'src' . DS . 'Controller' . DS); $this->exec('tiny_auth sync foo,bar -d -v'); @@ -48,4 +48,62 @@ public function testSync() { $this->assertOutputContains('* = foo, bar'); } + /** + * Hidden directories (e.g. `.git`, `.svn`, `.DS_Store`) must not be descended into + * during controller discovery — that was the behavior of the legacy `Folder::read` + * we replaced, and the scan would otherwise spend time recursing through editor / + * VCS metadata and possibly choke on weird filenames. + * + * @return void + */ + public function testSyncSkipsHiddenDirectories() { + Configure::write('TinyAuth.aclFilePath', TESTS . 'test_files/subfolder/'); + Configure::write('TinyAuth.allowFilePath', TESTS . 'test_files/'); + + $target = '/tmp' . DS . 'src' . DS . 'Controller' . DS; + $this->copyDirectory(TESTS . 'test_app' . DS . 'Controller' . DS, $target); + + $hiddenDir = $target . '.svn'; + if (!is_dir($hiddenDir)) { + mkdir($hiddenDir, 0777, true); + } + file_put_contents($hiddenDir . DS . 'SecretController.php', "exec('tiny_auth sync foo,bar -d -v'); + + $this->assertExitCode(Command::CODE_SUCCESS); + $this->assertOutputNotContains('Secret'); + } + + /** + * Recursively copy $source into $target, creating directories as needed. + * + * Drop-in replacement for the previously-used `Folder::copy()` from the now-removed + * vendored legacy CakePHP filesystem package. Test-only helper. + * + * @param string $source + * @param string $target + * @return void + */ + protected function copyDirectory(string $source, string $target): void { + if (!is_dir($target)) { + mkdir($target, 0777, true); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($iterator as $item) { + $dest = $target . DS . $iterator->getSubPathName(); + if ($item->isDir()) { + if (!is_dir($dest)) { + mkdir($dest, 0777, true); + } + } else { + copy($item->getPathname(), $dest); + } + } + } + } diff --git a/tests/TestCase/Controller/Component/AuthUserComponentTest.php b/tests/TestCase/Controller/Component/AuthUserComponentTest.php index e2272bff..4f4bad11 100644 --- a/tests/TestCase/Controller/Component/AuthUserComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthUserComponentTest.php @@ -365,7 +365,7 @@ public function can(?IdentityInterface $user, string $action, mixed $resource, . * @return \Authorization\Policy\ResultInterface */ public function canResult(?IdentityInterface $user, string $action, mixed $resource, ...$optionalArgs): ResultInterface { - $x = new class implements ResultInterface { + return new class implements ResultInterface { /** * @return bool */ @@ -380,8 +380,6 @@ public function getReason(): ?string { return null; } }; - - return $x; } /** diff --git a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php index 4523ce79..095c6bae 100644 --- a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php @@ -5,7 +5,6 @@ use Cake\Controller\ComponentRegistry; use Cake\Controller\Controller; use Cake\Core\Plugin; -use Cake\Event\Event; use Cake\Http\ServerRequest; use Cake\TestSuite\TestCase; use TinyAuth\Controller\Component\AuthenticationComponent; @@ -52,8 +51,7 @@ public function testValid() { $config = []; $this->component->initialize($config); - $event = new Event('Controller.startup', $controller); - $response = $this->component->startup($event); + $response = $this->component->startup(); $this->assertNull($response); } diff --git a/tests/TestCase/Utility/CacheTest.php b/tests/TestCase/Utility/CacheTest.php index ccdcafab..1525c1bb 100644 --- a/tests/TestCase/Utility/CacheTest.php +++ b/tests/TestCase/Utility/CacheTest.php @@ -35,4 +35,37 @@ public function testCache() { $this->assertNull($result); } + /** + * Two installs sharing a Cache pool must not collide on `acl` / `allow` because + * the configured `cachePrefix` is now applied. The default prefix is `tiny_auth_`. + * + * @return void + */ + public function testKeyAppliesConfiguredPrefix() { + Configure::delete('TinyAuth.cachePrefix'); + + $this->assertSame('tiny_auth_acl', Cache::key(Cache::KEY_ACL)); + $this->assertSame('tiny_auth_allow', Cache::key(Cache::KEY_ALLOW)); + } + + /** + * @return void + */ + public function testKeyCustomPrefix() { + Configure::write('TinyAuth.cachePrefix', 'tenant_42_'); + + $this->assertSame('tenant_42_acl', Cache::key(Cache::KEY_ACL)); + $this->assertSame('tenant_42_allow', Cache::key(Cache::KEY_ALLOW)); + } + + /** + * @return void + */ + public function testKeyEmptyPrefixYieldsBareKey() { + Configure::write('TinyAuth.cachePrefix', ''); + + $this->assertSame('acl', Cache::key(Cache::KEY_ACL)); + $this->assertSame('allow', Cache::key(Cache::KEY_ALLOW)); + } + } diff --git a/tests/schema.php b/tests/schema.php index 3b99fa06..7fc5b2cb 100644 --- a/tests/schema.php +++ b/tests/schema.php @@ -18,7 +18,7 @@ $class = 'TinyAuth\\Test\\Fixture\\' . $name . 'Fixture'; try { $object = (new ReflectionClass($class))->getProperty('fields'); - } catch (ReflectionException $e) { + } catch (ReflectionException) { continue; }