├── src ├── Rauth │ ├── Cache.php │ ├── ArrayCache.php │ └── Exception │ │ ├── Reason.php │ │ └── AuthException.php ├── RauthInterface.php └── Rauth.php ├── .editorconfig ├── LICENSE.md ├── composer.json ├── CONTRIBUTING.md └── README.md /src/Rauth/Cache.php: -------------------------------------------------------------------------------- 1 | data = $data; 12 | } 13 | 14 | public function get(string $key) 15 | { 16 | return $this->data[$key] ?? null; 17 | } 18 | 19 | public function set(string $key, $value) : Cache 20 | { 21 | $this->data[$key] = $value; 22 | return $this; 23 | } 24 | 25 | public function has(string $key) : bool 26 | { 27 | return isset($this->data[$key]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Rauth/Exception/Reason.php: -------------------------------------------------------------------------------- 1 | group = $group; 29 | $this->has = $has; 30 | $this->needs = $needs; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Bruno Skvorc 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sitepoint/rauth", 3 | "type": "library", 4 | "description": "A basic annotation-based ACL package", 5 | "keywords": [ 6 | "SitePoint", 7 | "Rauth", 8 | "ACL", 9 | "authorization", 10 | "access control" 11 | ], 12 | "homepage": "https://github.com/SitePoint/Rauth", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Bruno Skvorc", 17 | "email": "bruno@skvorc.me", 18 | "homepage": "https://bitfalls.com", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php" : "~7.0" 24 | }, 25 | "require-dev": { 26 | "symfony/var-dumper": "~3", 27 | "phpunit/phpunit" : "~5", 28 | "scrutinizer/ocular": "~1.1", 29 | "squizlabs/php_codesniffer": "~2.3" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "SitePoint\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "SitePoint\\Rauth\\Test\\": "tests" 39 | } 40 | }, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit", 43 | "cs": "vendor/bin/phpcs --standard=psr2 src/" 44 | }, 45 | "extra": { 46 | "branch-alias": { 47 | "dev-master": "1.0-dev" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/SitePoint/Rauth). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | 31 | 32 | **Happy coding**! 33 | -------------------------------------------------------------------------------- /src/Rauth/Exception/AuthException.php: -------------------------------------------------------------------------------- 1 | setType($type); 15 | } 16 | 17 | /** 18 | * Returns the collection of reasons. Can be empty. 19 | * 20 | * @return array 21 | */ 22 | public function getReasons() : array 23 | { 24 | return $this->reasons; 25 | } 26 | 27 | /** 28 | * Adds a reason to the reason bag in the exception 29 | * 30 | * @param Reason $reason 31 | * @return AuthException 32 | */ 33 | public function addReason(Reason $reason) : AuthException 34 | { 35 | $this->reasons[] = $reason; 36 | return $this; 37 | } 38 | 39 | /** 40 | * Checks if any reasons have been defined in the exception 41 | * 42 | * @return bool 43 | */ 44 | public function hasReasons() : bool 45 | { 46 | return (count($this->reasons) > 0); 47 | } 48 | 49 | /** 50 | * Sets the context in which the exception was thrown 51 | * 52 | * Example "ban" or "and" 53 | * 54 | * @param string $type 55 | * @return AuthException 56 | */ 57 | public function setType(string $type) : AuthException 58 | { 59 | $this->type = $type; 60 | return $this; 61 | } 62 | 63 | /** 64 | * Returns context in which exception occurred. 65 | * 66 | * Example "ban" or "and" 67 | * 68 | * @return string 69 | */ 70 | public function getType() : string 71 | { 72 | return $this->type; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rauth 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Build Status][ico-travis]][link-travis] 6 | [![Coverage Status][ico-scrutinizer]][link-scrutinizer] 7 | [![Quality Score][ico-code-quality]][link-code-quality] 8 | 9 | Rauth is a simple package for parsing the `@auth-*` lines of a docblock. These are then matched with some arbitrary attributes like "groups" or "permissions" or anything else you choose. See basic usage below. 10 | 11 | ## Why? 12 | 13 | I wanted: 14 | 15 | - to be able to define "rules" without strapping my routes to a bunch of regex 16 | - to be able to change routes on a whim and make the rules apply app-wide, API mode or DOM mode, without having to change anything. By putting permissions onto classes and methods, I have more control over an app's setup and its roles. 17 | 18 | ## Annotations Are Bad ™ 19 | 20 | Somewhat "controversially", Rauth defaults to using annotations to control access. No matter which camp you're in regarding annotations in PHP, here's why their use in Rauth's case is nowhere near as wrong as [some](https://www.reddit.com/r/PHP/comments/48m8yo/rauth_simple_annotationbased_package_for_limiting/) make it out to be: 21 | 22 | - as you'll usually control access to controllers and actions in a typical MVC app, hard-coupling them to Rauth like this is not only harmless (controllers almost always need to be [completely discarded and rewritten](http://tldrify.com/fon) if you're changing frameworks or the app's structure in a major way), it also provides you with instant insight into which class / method has which ACL requirements 23 | 24 | - if you don't like annotations, you can feed Rauth a pre-cached or pre-parsed list of permissions and classes they apply to, so the whole annotations issue can be avoided completely 25 | 26 | - there's no more fear of annotations slowing things down because PHP needs to reflect into the classes in question and extract them every time. With OpCache on at all times, this only happens once, and with Rauth's own cache support, this can even be saved elsewhere and the annotation reading pass can be avoided altogether. 27 | 28 | 29 | ## Install 30 | 31 | Via Composer 32 | 33 | ```bash 34 | composer require sitepoint/rauth 35 | ``` 36 | 37 | ## Basic Usage 38 | 39 | Boostrap Rauth somewhere (preferably a bootstrap file, or wherever you configure your DI container) like so: 40 | 41 | ```php 42 | authorize($classInstanceOrName, $methodName, $attributes); 76 | } catch (\SitePoint\Rauth\Exception\AuthException $e) { 77 | $e->getType(); // will be "ban", "and", "or", etc... 78 | $e->getReasons(); // an array of Reason objects with details 79 | } 80 | ``` 81 | 82 | `$attributes` will be an array you build - this depends entirely on your implementation of user attributes. Maybe you're using something like [Gatekeeper](https://github.com/psecio/gatekeeper) and have immediate access to `groups` and/or `permissions` on a `User` entity, and maybe you have a totally custom system. What matters is that you build an array which contains the attributes like so: 83 | 84 | ```php 85 | $attributes = [ 86 | 'groups' => ['admin'] 87 | ]; 88 | ``` 89 | 90 | or maybe something like this: 91 | 92 | ```php 93 | $attributes = [ 94 | 'permissions' => ['post-write', 'post-read'] 95 | ]; 96 | ``` 97 | 98 | or even something like this: 99 | 100 | ```php 101 | $attributes = [ 102 | 'groups' => ['admin', 'reg-user'], 103 | 'permissions' => ['post-write', 'post-read'] 104 | ]; 105 | ``` 106 | 107 | You get the drift. 108 | 109 | > Remember: the `@auth-*` lines are __requirements__, and they are compared against __attributes__ 110 | 111 | Rauth will then parse the `@auth` lines and save the attributes required in an array similar to that, like so: 112 | 113 | ```php 114 | $requirements = [ 115 | 'mode' => RAUTH::OR, 116 | 'groups' => ['admin', 'reg-user'], 117 | 'permissions' => ['post-write', 'post-read'] 118 | ]; 119 | ``` 120 | 121 | `authorize` will return `true` if all is well. 122 | 123 | If the `authorize` check fails, it will throw an `AuthException`. The `AuthException` will have a `getType` getter which will return a string value of the mode in which the failure happened - be it `ban`, `and`, `or`, `none`, or a custom mode altogether (see modes below). It will also have a `getReasons` getter which provides an array of `Reason` objects. Each object has the following public properties: 124 | 125 | - `group`: defines which `@auth-{group}` triggered the exception, e.g. "groups", "permissions", "banana", or whatever else 126 | - `has`: an array of the attributes provided for that group. If none were provided, empty array. 127 | - `needs`: an array of attributes needed / prohibited, and compared against `has`. 128 | 129 | ### Available Modes 130 | 131 | These modes can be used as values for `@auth-mode`: 132 | 133 | #### OR 134 | 135 | The mode `OR` will make `Rauth::authorize()` return `true` if **any** of the attributes matches **any** of the requirements. 136 | 137 | #### AND 138 | 139 | The mode `AND` will make `Rauth::authorize()` return `true` if **all** of the attributes match **all** of the requirements (e.g. user must have ALL the groups and ALL the permissions and ALL the bananas mentioned in the docblock). 140 | 141 | #### NONE 142 | 143 | The mode `NONE` will make `Rauth::authorize()` return `true` only if none of the attributes match the requirements. 144 | 145 | ## Ban 146 | 147 | Another option you can use is the `@auth-ban` tag: 148 | 149 | ```php 150 | /* 151 | * ... 152 | * @auth-ban-groups guest, blocked 153 | * ... 154 | */ 155 | ``` 156 | 157 | This tag will take precedence if a match is found. So in the example above - if a user is an admin, but is a member of the `blocked` group, they will be denied access. All `ban` matches MUST be zero if the user is to proceed, regardless of all other matches. 158 | 159 | > The banhammer wields absolute authority and does not react to `@auth-mode`. Bans must be completely cleared before other permissions are even to be looked at. 160 | 161 | ## Caching 162 | 163 | Rauth accepts in its constructor a Cache object which needs to adhere to the `src/Rauth/Cache.php` interface. It defaults to ArrayCache, which is a fake cache that doesn't really improve speed by any margin and is mainly used during development. 164 | 165 | Note that you *can* pass in a ready-made array into the ArrayCache (constructor accepts data), if you have it. This way, you'd hydrate the cache for Rauth and it wouldn't have to manually parse every class it tries to authorize: 166 | 167 | ```php 168 | $ac = new ArrayCache( 169 | [ 170 | 'SomeClass' => [ 171 | 'mode' => RAUTH::OR, 172 | 'groups' => ['admin', 'reg-user'], 173 | 'permissions' => ['post-write', 'post-read'], 174 | ], 175 | 'SomeClass::someMethod' => [ 176 | 'mode' => RAUTH::AND, 177 | 'groups' => ['admin'], 178 | ], 179 | ] 180 | ); 181 | 182 | $rauth = new Rauth($ac); 183 | ``` 184 | 185 | ## Best Practice 186 | 187 | In order to avoid having to use the `authorize` call manually, it's best to tie it into a Dependency Injection container or a route dispatcher. That way, you can easily put your requirements into the docblocks of a controller, and build the attributes at bootstrapping time, and everything else will be automatic. For an example of this, see the [nofw](https://github.com/Swader/nofw) skeleton. 188 | 189 | @todo This example will be added soon 190 | 191 | ## Testing 192 | 193 | ```bash 194 | composer test 195 | ``` 196 | 197 | ## Contributing 198 | 199 | Please see [CONTRIBUTING](CONTRIBUTING.md). 200 | 201 | ## Credits 202 | 203 | - [Bruno Skvorc][link-author] 204 | 205 | ## License 206 | 207 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 208 | 209 | [ico-version]: https://img.shields.io/packagist/v/SitePoint/Rauth.svg?style=flat-square 210 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 211 | [ico-travis]: https://travis-ci.org/sitepoint/Rauth.svg?branch=master 212 | [ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/SitePoint/Rauth.svg?style=flat-square 213 | [ico-code-quality]: https://img.shields.io/scrutinizer/g/SitePoint/Rauth.svg?style=flat-square 214 | [ico-downloads]: https://img.shields.io/packagist/dt/SitePoint/Rauth.svg?style=flat-square 215 | 216 | [link-packagist]: https://packagist.org/packages/sitepoint/rauth 217 | [link-travis]: https://travis-ci.org/sitepoint/Rauth 218 | [link-scrutinizer]: https://scrutinizer-ci.com/g/sitepoint/Rauth/code-structure 219 | [link-code-quality]: https://scrutinizer-ci.com/g/sitepoint/Rauth 220 | [link-author]: https://github.com/swader 221 | [link-docs]: http://readthedocs.org 222 | -------------------------------------------------------------------------------- /src/Rauth.php: -------------------------------------------------------------------------------- 1 | cache = $c; 34 | } 35 | } 36 | 37 | /** 38 | * Set a default mode for auth blocks without one defined. 39 | * 40 | * Default is MODE_OR 41 | * 42 | * @param string $mode 43 | * @return RauthInterface 44 | */ 45 | public function setDefaultMode(string $mode = null) : RauthInterface 46 | { 47 | if (!in_array($mode, self::MODES)) { 48 | throw new \InvalidArgumentException( 49 | 'Mode ' . $mode . ' not accepted!' 50 | ); 51 | } 52 | $this->defaultMode = $mode; 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Inject Cache instance 59 | * 60 | * @param Cache $c 61 | * @return RauthInterface 62 | */ 63 | public function setCache(Cache $c) : RauthInterface 64 | { 65 | $this->cache = $c; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Only used by the class. 72 | * 73 | * Could have user property directly, but having default cache is convenient 74 | * 75 | * @internal 76 | * @return null|Cache 77 | */ 78 | private function getCache() 79 | { 80 | if ($this->cache === null) { 81 | $this->setCache(new ArrayCache()); 82 | } 83 | 84 | return $this->cache; 85 | } 86 | 87 | /** 88 | * Used to extract the @auth blocks from a class or method 89 | * 90 | * The auth prefix is stripped, and the remaining values are saved as 91 | * key => value pairs. 92 | * 93 | * @param $class 94 | * @param string|null $method 95 | * @return array 96 | */ 97 | public function extractAuth($class, string $method = null) : array 98 | { 99 | if (!is_string($class) && !is_object($class)) { 100 | throw new \InvalidArgumentException( 101 | 'Class must be string or object!' 102 | ); 103 | } 104 | 105 | $className = (is_string($class)) ? $class : get_class($class); 106 | $sig = ($method) ? $className . '::' . $method : $className; 107 | 108 | // Class auths haven't been cached yet 109 | if (!$this->getCache()->has($className)) { 110 | $r = new \ReflectionClass($className); 111 | preg_match_all(self::REGEX, $r->getDocComment(), $matchC); 112 | $this->getCache()->set($className, $this->normalize((array)$matchC)); 113 | } 114 | 115 | // Method auths haven't been cached yet 116 | if (!$this->getCache()->has($sig)) { 117 | $r = new \ReflectionMethod($className, $method); 118 | preg_match_all(self::REGEX, $r->getDocComment(), $matchC); 119 | $this->getCache()->set($sig, $this->normalize((array)$matchC)); 120 | } 121 | 122 | return ($this->getCache()->get($sig) == []) 123 | ? $this->getCache()->get($className) 124 | : $this->getCache()->get($sig); 125 | } 126 | 127 | /** 128 | * Turns a pregexed array of auth blocks into a decent array 129 | * 130 | * Internal use only - @see Rauth::extractAuth 131 | * 132 | * @internal 133 | * @param array $matches 134 | * @return array 135 | */ 136 | private function normalize(array $matches) : array 137 | { 138 | $keys = $matches[1]; 139 | $values = $matches[2]; 140 | 141 | $return = []; 142 | 143 | foreach ($keys as $i => $key) { 144 | $key = strtolower(trim($key)); 145 | 146 | if ($key == 'mode') { 147 | $value = strtolower($values[$i]); 148 | } else { 149 | $value = array_map( 150 | function ($el) { 151 | return trim($el, ', '); 152 | }, 153 | explode(',', $values[$i]) 154 | ); 155 | } 156 | $return[$key] = $value; 157 | } 158 | 159 | return $return; 160 | } 161 | 162 | 163 | /** 164 | * Either passes or fails an authorization attempt. 165 | * 166 | * The first two arguments are the class/method pair to inspect for @auth 167 | * tags, and `$attr` are attributes to compare the @auths against. 168 | * 169 | * Depending on the currently selected mode (either default - for that 170 | * you should @see Rauth::setDefaultMode, or defined in the @auths), it will 171 | * evaluate the arrays against one another and come to a conclusion. 172 | * 173 | * @param $class 174 | * @param string|null $method 175 | * @param array $attr 176 | * @return bool 177 | * @throws \InvalidArgumentException 178 | * @throws AuthException 179 | */ 180 | public function authorize( 181 | $class, 182 | string $method = null, 183 | array $attr = [] 184 | ) : bool { 185 | 186 | $auth = $this->extractAuth($class, $method); 187 | 188 | // Class / method has no rules - allow all 189 | if (empty($auth)) { 190 | return true; 191 | } 192 | 193 | // Store mode, remove from auth array 194 | $mode = $auth['mode'] ?? $this->defaultMode; 195 | $e = new AuthException($mode); 196 | unset($auth['mode']); 197 | 198 | // Handle bans, remove them from auth 199 | $this->handleBans($auth, $attr); 200 | 201 | switch ($mode) { 202 | case self::MODE_AND: 203 | // All values in all arrays must match 204 | foreach ($auth as $set => $values) { 205 | if (!isset($attr[$set])) { 206 | $e->addReason(new Reason( 207 | $set, 208 | [], 209 | $values 210 | )); 211 | } else { 212 | $attr[$set] = (array)$attr[$set]; 213 | sort($values); 214 | sort($attr[$set]); 215 | if ($values != $attr[$set]) { 216 | $e->addReason(new Reason( 217 | $set, 218 | $attr[$set], 219 | $values 220 | )); 221 | } 222 | } 223 | } 224 | 225 | if ($e->hasReasons()) { 226 | throw $e; 227 | } 228 | return true; 229 | case self::MODE_NONE: 230 | // There must be no overlap between any of the array values 231 | 232 | foreach ($auth as $set => $values) { 233 | if (isset($attr[$set]) && count( 234 | array_intersect( 235 | (array)$attr[$set], 236 | $values 237 | ) 238 | ) 239 | ) { 240 | $e->addReason(new Reason( 241 | $set, 242 | (array)($attr[$set] ?? []), 243 | $values 244 | )); 245 | } 246 | } 247 | 248 | if ($e->hasReasons()) { 249 | throw $e; 250 | } 251 | return true; 252 | case self::MODE_OR: 253 | // At least one match must be present 254 | foreach ($auth as $set => $values) { 255 | if (isset($attr[$set]) && count( 256 | array_intersect( 257 | (array)$attr[$set], 258 | $values 259 | ) 260 | ) 261 | ) { 262 | return true; 263 | } 264 | $e->addReason(new Reason( 265 | $set, 266 | (array)($attr[$set] ?? []), 267 | $values 268 | )); 269 | } 270 | 271 | throw $e; 272 | default: 273 | throw new \InvalidArgumentException('Durrrr'); 274 | } 275 | 276 | } 277 | 278 | private function handleBans(&$auth, $attr) 279 | { 280 | foreach ($auth as $set => $values) { 281 | if (strpos($set, 'ban-') === 0) { 282 | $key = str_replace('ban-', '', $set); 283 | if (isset($attr[$key]) && array_intersect( 284 | (array)$attr[$key], 285 | $values 286 | ) 287 | ) { 288 | $exception = new AuthException('ban'); 289 | throw $exception->addReason(new Reason( 290 | $key, 291 | (array)$attr[$key], 292 | $values 293 | )); 294 | } 295 | unset($auth[$set]); 296 | } 297 | } 298 | 299 | } 300 | } 301 | --------------------------------------------------------------------------------