├── README.md ├── composer.json └── src └── gburtini ├── ACL ├── ACL.php ├── Authenticator.php ├── Exceptions │ ├── InvalidAssertionException.php │ └── InvalidLoginException.php ├── SimpleAuthenticator.php └── User.php └── AuthenticatedStorage ├── AESAuthenticatedCookie.php ├── AuthenticatedCookie.php └── MACComputer.php /README.md: -------------------------------------------------------------------------------- 1 | PHP-ACL: Simple Access Control Lists 2 | ==================================== 3 | 4 | _A simple, dependency-free (in use) user/login/cookie management, role and user-level access control system._ 5 | 6 | This is a very straightforward, simple and easy to use user system, ready to be extended for any purpose. 7 | 8 | The ACL component is based on Nette\Security which was itself based on Zend_Acl. 9 | 10 | Installation 11 | ------------ 12 | You can clone the repository and work with the files directly, but everything is set up for composer, which makes it simple: 13 | 14 | composer require gburtini/acl 15 | 16 | Usage 17 | ----- 18 | There are three parts to using this package. 19 | 20 | * Implementing an Authenticator for your use-case 21 | * Developing the access control lists. 22 | * Integrating the User class. 23 | 24 | Each can be complex or simple depending on your use case. 25 | 26 | An Authenticator is a class that implements the method ``->authenticate($username, $password[, $roles])``, verifies the users name and password (and if specified, requested roles), and returns a unique identifier for the user and a set of roles that belong to him in the format ['id' => 123, 'roles' => ['administrator']]. Some notes are provided in Authenticator.php on *some* but not all the considerations necessary to write a good authentication system. A SimpleAuthenticator is provided in SimpleAuthenticator.php for demonstration purposes 27 | ````php 28 | users = $userpasses; 34 | } 35 | 36 | public function authenticate($user, $password, $roles=null) { 37 | if($this->users[$user] === $password) 38 | return ['id' => $user, 'roles' => $roles]; // in reality, you want to pick/confirm roles for this user. 39 | return false; 40 | } 41 | } 42 | ```` 43 | 44 | This is not a good authenticator, as it gives users any roles they request (note that requesting roles is optional, you can ignore that parameter and simply return the list of valid roles for this user) and stores usernames and passwords in totality and in plain text. A better Authenticator will interact with your users table or other datastore. 45 | 46 | Developing the access control list, requires using the class ACL. An example follows. 47 | ````php 48 | $acl = new ACL(); 49 | $acl->addRole("administrator"); 50 | $acl->addRole("manager"); 51 | $acl->addRole("client"); 52 | $acl->addRole("guest"); 53 | // or, $acl->addRole("administrator", ["manager"]);, indicating that administrator inherits manager's permissions. 54 | 55 | $acl->addResource("files"); 56 | $acl->addResource("client_lists"); 57 | 58 | $acl->deny("guest", ["files", "client_lists"]); 59 | $acl->allow(['administrator', 'manager'], ['files', 'client_lists'], ['read', 'write'], function($acl, $testing_role, $testing_resource, $testing_privilege) { 60 | // this function is an assertion that returns true/false if the rule should apply. 61 | $arguments = $acl->getQueriedOtherArguments(); 62 | if($arguments['user_id'] == 4) // we can pass in user ID and indeed file/list ID via the other arguments system. 63 | return false; 64 | })); 65 | ```` 66 | Note that you can call ``serialize()`` on the ``$acl`` object and will get a version you can store in your database. For more information in how inheritance and role/resources work, the Nette\Security and Zend_Acl documentation applies almost directly to this code. 67 | 68 | Finally, to integrate a User class to tie it all together. We can use the built in User or we can extend it to provide some of our own functionality (in particular, storing information other than the identifier about the user). For this demonstration, we'll use the provided User class (in User.php) 69 | 70 | ````php 71 | // provide HMAC and AES keys... note that as of PHP 5.6 invalid length AES keys are not acceptable. 72 | // we use the WordPress Secret Key API to generate the HMAC key: https://api.wordpress.org/secret-key/1.1/salt/ 73 | // if you wish and trust me, you can generate keys here: http://giuseppe.ca/aes.php - pass ?size=12 to force a particular size (bytes) output key. 74 | $user = new User('SNgsHsd#T$DaN R*Ol~O6z+a+[v}@3)6%-X0nHH|%#ag+hYV 5f|zs}6;T|wM?3+', 'ALPHb92wzIamFw39VHLTiv6rY8i6EiEU8Plghvbhu547iPlgqlHSy76F'); 75 | $user->setAuthenticator(new SimpleAuthenticator([ 76 | 'johnny' => 'apples33d', 77 | 'thelma' => 'J!JHndmTivE' 78 | ])); 79 | $user->setExpiration("30 days"); 80 | $user->setACL($acl); // from our previous set up 81 | // if the user is not signed in, his only role is 'guest' and his ID is null. 82 | // but you can check with $user->isLoggedIn(), $user->whoAmI() or $user->roles() 83 | 84 | // if the user has a login stored in his cookies, he will be already authenticated. If he weren't, you can try to authenticate him with $user->login($username, $password); -- this will throw an exception if the login fails. 85 | 86 | if($user->can("files", "view", 1)) { 87 | echo "I'm allowed to view file #1"; 88 | } 89 | ```` 90 | 91 | You're done. That's the whole system. 92 | 93 | Note: strong key selection is important. [My website](http://giuseppe.ca/aes.php) provides some code which generates keys for you if you trust me and my server to not be compromised (note: you shouldn't, you should inspect and run the code yourself in the ideal case), fundamentally it is not a lot more sophisicated than a call to [openssl_random_pseudo_bytes](http://php.net/manual/en/function.openssl-random-pseudo-bytes.php): 94 | 95 | ```` 96 | $key = openssl_random_pseudo_bytes($length_bytes, $boolean); 97 | if($boolean === false) die("This is not a good key. Something bad happened."); 98 | ```` 99 | 100 | Future Work 101 | ----------- 102 | There is much that can be done, but nothing that I need immediately. Pull requests are invited. 103 | 104 | * Change all exceptions to throw different classes so that reasons can be caught cleanly. 105 | * Implement some other authenticators, user classes. 106 | * Document how to extend the user class for your own implementation. 107 | * Verify and extract the crypto required for User.php in to its own dependent package. 108 | * Integrate with gburtini/Hooks to allow events to occur on user instances. 109 | * Document every method in this file (README.md). 110 | * Add "token authentication" system that allows temporary (regular expression or role based?) authentication to be generated. For example, for changing passwords in a recovery your password system. 111 | 112 | There is further work that I would prefer to keep *out* of this package for simplicity, but would be of value to many users of the package: 113 | 114 | * User storage functionality (users, including their permissions, written to the database). 115 | * Further user identity functionality (right now, the User class is intended to be extended to provide this). 116 | 117 | License 118 | ------- 119 | As parts of the code are derived from New BSD licensed code, we have followed in the spirit and this package itself is released under the New BSD. 120 | 121 | Novel contributions. Copyright (c) 2015 Giuseppe Burtini. 122 | 123 | Zend_Acl original code. Copyright (c) 2005-2015, Zend Technologies USA, Inc. All rights reserved. 124 | 125 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 126 | 127 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 128 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 129 | * Neither the name of Zend Technologies USA, Inc. or Giuseppe Burtini nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. 130 | 131 | _THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE._ 132 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gburtini/acl", 3 | "type": "library", 4 | "description": "Dependency free, simple access control lists for PHP.", 5 | "keywords": ["access control", "access", "acl", "security", "login", "user"], 6 | "homepage": "http://github.com/gburtini/PHP-ACL", 7 | "license": "GPL", 8 | "authors": [ 9 | { 10 | "name": "Giuseppe Burtini", 11 | "email": "joe@truephp.com", 12 | "homepage": "http://giuseppeburtini.com/", 13 | "role": "Developer" 14 | } 15 | ], 16 | "require": { 17 | "php": ">=5.5.0" 18 | }, 19 | "autoload": { 20 | "psr-0": { 21 | "gburtini\\ACL": "src", 22 | "gburtini\\AuthenticatedStorage": "src" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/gburtini/ACL/ACL.php: -------------------------------------------------------------------------------- 1 | getQueriedOtherArguments(): returns an array of other arguments passed to the rule evaluator. 19 | * In our user system, this includes the resource's identifier (resources are taken to be "datatypes") 20 | * and the user's specific identifier, but any data can be passed in when checking isAllowed(). 21 | * 22 | * ->getQueriedRole(): returns the originally queried role (not the role selected by inheritance, passed as $testing_role) 23 | * 24 | * ->getQueriedResource(): returns the originally queried resource (not the inherited) 25 | */ 26 | class ACL implements \Serializable { 27 | const DENY = false; 28 | const ALLOW = true; 29 | 30 | protected $roles = []; 31 | protected $resources = []; 32 | protected $rules = array( 33 | 'allResources' => array( 34 | 'allRoles' => array( 35 | 'allPrivileges' => array( 36 | 'type' => self::DENY, 37 | 'assert' => NULL, 38 | ), 39 | 'byPrivilege' => array(), 40 | ), 41 | 'byRole' => array(), 42 | ), 43 | 'byResource' => array(), 44 | ); 45 | 46 | 47 | const ALL = null; 48 | 49 | public function serialize() { 50 | return json_encode([ 51 | 'roles' => $this->roles, 52 | 'resources' => $this->resources, 53 | 'rules' => $this->rules 54 | ]); 55 | } 56 | public function unserialize($string) { 57 | $data = json_decode($string, true); 58 | $this->roles = $data['roles']; 59 | $this->resources = $data['resources']; 60 | $this->rules = $data['rules']; 61 | } 62 | 63 | /** 64 | * Adds a Role to the list. The most recently added parent 65 | * takes precedence over parents that were previously added. 66 | * @param string 67 | * @param string|array 68 | * @return self 69 | */ 70 | public function addRole($role, $parents = null) { 71 | $this->checkRole($role, FALSE); 72 | if (isset($this->roles[$role])) { 73 | throw new \InvalidArgumentException("Role '$role' already exists in the list."); 74 | } 75 | $roleParents = array(); 76 | if ($parents !== NULL) { 77 | if (!is_array($parents)) { 78 | $parents = array($parents); 79 | } 80 | foreach ($parents as $parent) { 81 | $this->checkRole($parent); 82 | $roleParents[$parent] = TRUE; 83 | $this->roles[$parent]['children'][$role] = TRUE; 84 | } 85 | } 86 | $this->roles[$role] = array( 87 | 'parents' => $roleParents, 88 | 'children' => array(), 89 | ); 90 | return $this; 91 | } 92 | /** 93 | * Returns TRUE if the Role exists in the list. 94 | * @param string 95 | * @return bool 96 | */ 97 | public function hasRole($role) 98 | { 99 | $this->checkRole($role, FALSE); 100 | return isset($this->roles[$role]); 101 | } 102 | /** 103 | * Checks whether Role is valid and exists in the list. 104 | * @param string 105 | * @param bool 106 | * @return void 107 | */ 108 | private function checkRole($role, $need = true) { 109 | if (!is_string($role) || $role === '') { 110 | throw new \InvalidArgumentException("Role must be a nonempty string."); 111 | } elseif($need && !isset($this->roles[$role])) { 112 | throw new \InvalidArgumentException("Role '$role' does not exist."); 113 | } 114 | } 115 | /** 116 | * Returns all Roles. 117 | * @return array 118 | */ 119 | public function getRoles() 120 | { 121 | return array_keys($this->roles); 122 | } 123 | /** 124 | * Returns existing Role's parents ordered by ascending priority. 125 | * @param string 126 | * @return array 127 | */ 128 | public function getRoleParents($role) 129 | { 130 | $this->checkRole($role); 131 | return array_keys($this->roles[$role]['parents']); 132 | } 133 | /** 134 | * Returns TRUE if $role inherits from $inherit. If $onlyParents is TRUE, 135 | * then $role must inherit directly from $inherit. 136 | * @param string 137 | * @param string 138 | * @param bool 139 | * @return bool 140 | */ 141 | public function roleInheritsFrom($role, $inherit, $onlyParents = FALSE) 142 | { 143 | $this->checkRole($role); 144 | $this->checkRole($inherit); 145 | $inherits = isset($this->roles[$role]['parents'][$inherit]); 146 | if ($inherits || $onlyParents) { 147 | return $inherits; 148 | } 149 | foreach ($this->roles[$role]['parents'] as $parent => $foo) { 150 | if ($this->roleInheritsFrom($parent, $inherit)) { 151 | return TRUE; 152 | } 153 | } 154 | return FALSE; 155 | } 156 | 157 | /** 158 | * Removes the Role from the list. 159 | * 160 | * @param string 161 | * @return self 162 | */ 163 | public function removeRole($role) 164 | { 165 | $this->checkRole($role); 166 | foreach ($this->roles[$role]['children'] as $child => $foo) { 167 | unset($this->roles[$child]['parents'][$role]); 168 | } 169 | foreach ($this->roles[$role]['parents'] as $parent => $foo) { 170 | unset($this->roles[$parent]['children'][$role]); 171 | } 172 | unset($this->roles[$role]); 173 | foreach ($this->rules['allResources']['byRole'] as $roleCurrent => $rules) { 174 | if ($role === $roleCurrent) { 175 | unset($this->rules['allResources']['byRole'][$roleCurrent]); 176 | } 177 | } 178 | foreach ($this->rules['byResource'] as $resourceCurrent => $visitor) { 179 | if (isset($visitor['byRole'])) { 180 | foreach ($visitor['byRole'] as $roleCurrent => $rules) { 181 | if ($role === $roleCurrent) { 182 | unset($this->rules['byResource'][$resourceCurrent]['byRole'][$roleCurrent]); 183 | } 184 | } 185 | } 186 | } 187 | return $this; 188 | } 189 | /** 190 | * Removes all Roles from the list. 191 | * 192 | * @return self 193 | */ 194 | public function removeAllRoles() 195 | { 196 | $this->roles = array(); 197 | foreach ($this->rules['allResources']['byRole'] as $roleCurrent => $rules) { 198 | unset($this->rules['allResources']['byRole'][$roleCurrent]); 199 | } 200 | foreach ($this->rules['byResource'] as $resourceCurrent => $visitor) { 201 | foreach ($visitor['byRole'] as $roleCurrent => $rules) { 202 | unset($this->rules['byResource'][$resourceCurrent]['byRole'][$roleCurrent]); 203 | } 204 | } 205 | return $this; 206 | } 207 | 208 | 209 | /** 210 | * Adds a Resource having an identifier unique to the list. 211 | * 212 | * @param string 213 | * @param string 214 | * @return self 215 | */ 216 | public function addResource($resource, $parent = NULL) 217 | { 218 | $this->checkResource($resource, FALSE); 219 | if (isset($this->resources[$resource])) { 220 | throw new \InvalidArgumentException("Resource '$resource' already exists in the list."); 221 | } 222 | if ($parent !== NULL) { 223 | $this->checkResource($parent); 224 | $this->resources[$parent]['children'][$resource] = TRUE; 225 | } 226 | $this->resources[$resource] = array( 227 | 'parent' => $parent, 228 | 'children' => array() 229 | ); 230 | return $this; 231 | } 232 | /** 233 | * Returns TRUE if the Resource exists in the list. 234 | * @param string 235 | * @return bool 236 | */ 237 | public function hasResource($resource) 238 | { 239 | $this->checkResource($resource, FALSE); 240 | return isset($this->resources[$resource]); 241 | } 242 | /** 243 | * Checks whether Resource is valid and exists in the list. 244 | * @param string 245 | * @param bool 246 | * @return void 247 | */ 248 | private function checkResource($resource, $need = TRUE) 249 | { 250 | if (!is_string($resource) || $resource === '') { 251 | throw new \InvalidArgumentException('Resource must be a non-empty string.'); 252 | } elseif ($need && !isset($this->resources[$resource])) { 253 | throw new \InvalidArgumentException("Resource '$resource' does not exist."); 254 | } 255 | } 256 | /** 257 | * Returns all Resources. 258 | * @return array 259 | */ 260 | public function getResources() 261 | { 262 | return array_keys($this->resources); 263 | } 264 | /** 265 | * Returns TRUE if $resource inherits from $inherit. If $onlyParents is TRUE, 266 | * then $resource must inherit directly from $inherit. 267 | * 268 | * @param string 269 | * @param string 270 | * @param bool 271 | * @return bool 272 | */ 273 | public function resourceInheritsFrom($resource, $inherit, $onlyParent = FALSE) 274 | { 275 | $this->checkResource($resource); 276 | $this->checkResource($inherit); 277 | if ($this->resources[$resource]['parent'] === NULL) { 278 | return FALSE; 279 | } 280 | $parent = $this->resources[$resource]['parent']; 281 | if ($inherit === $parent) { 282 | return TRUE; 283 | } elseif ($onlyParent) { 284 | return FALSE; 285 | } 286 | while ($this->resources[$parent]['parent'] !== NULL) { 287 | $parent = $this->resources[$parent]['parent']; 288 | if ($inherit === $parent) { 289 | return TRUE; 290 | } 291 | } 292 | return FALSE; 293 | } 294 | /** 295 | * Removes a Resource and all of its children. 296 | * 297 | * @param string 298 | * @return self 299 | */ 300 | public function removeResource($resource) 301 | { 302 | $this->checkResource($resource); 303 | $parent = $this->resources[$resource]['parent']; 304 | if ($parent !== NULL) { 305 | unset($this->resources[$parent]['children'][$resource]); 306 | } 307 | $removed = array($resource); 308 | foreach ($this->resources[$resource]['children'] as $child => $foo) { 309 | $this->removeResource($child); 310 | $removed[] = $child; 311 | } 312 | foreach ($removed as $resourceRemoved) { 313 | foreach ($this->rules['byResource'] as $resourceCurrent => $rules) { 314 | if ($resourceRemoved === $resourceCurrent) { 315 | unset($this->rules['byResource'][$resourceCurrent]); 316 | } 317 | } 318 | } 319 | unset($this->resources[$resource]); 320 | return $this; 321 | } 322 | /** 323 | * Removes all Resources. 324 | * @return self 325 | */ 326 | public function removeAllResources() 327 | { 328 | foreach ($this->resources as $resource => $foo) { 329 | foreach ($this->rules['byResource'] as $resourceCurrent => $rules) { 330 | if ($resource === $resourceCurrent) { 331 | unset($this->rules['byResource'][$resourceCurrent]); 332 | } 333 | } 334 | } 335 | $this->resources = array(); 336 | return $this; 337 | } 338 | 339 | /** 340 | * Allows one or more Roles access to [certain $privileges upon] the specified Resource(s). 341 | * If $assertion is provided, then it must return TRUE in order for rule to apply. 342 | * 343 | * @param string|array|Permission::ALL roles 344 | * @param string|array|Permission::ALL resources 345 | * @param string|array|Permission::ALL privileges 346 | * @param callable assertion 347 | * @return self 348 | */ 349 | public function allow($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL, $assertion = NULL) 350 | { 351 | $this->setRule(TRUE, self::ALLOW, $roles, $resources, $privileges, $assertion); 352 | return $this; 353 | } 354 | /** 355 | * Denies one or more Roles access to [certain $privileges upon] the specified Resource(s). 356 | * If $assertion is provided, then it must return TRUE in order for rule to apply. 357 | * 358 | * @param string|array|Permission::ALL roles 359 | * @param string|array|Permission::ALL resources 360 | * @param string|array|Permission::ALL privileges 361 | * @param callable assertion 362 | * @return self 363 | */ 364 | public function deny($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL, $assertion = NULL) 365 | { 366 | $this->setRule(TRUE, self::DENY, $roles, $resources, $privileges, $assertion); 367 | return $this; 368 | } 369 | /** 370 | * Removes "allow" permissions from the list in the context of the given Roles, Resources, and privileges. 371 | * 372 | * @param string|array|Permission::ALL roles 373 | * @param string|array|Permission::ALL resources 374 | * @param string|array|Permission::ALL privileges 375 | * @return self 376 | */ 377 | public function removeAllow($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL) 378 | { 379 | $this->setRule(FALSE, self::ALLOW, $roles, $resources, $privileges); 380 | return $this; 381 | } 382 | /** 383 | * Removes "deny" restrictions from the list in the context of the given Roles, Resources, and privileges. 384 | * 385 | * @param string|array|Permission::ALL roles 386 | * @param string|array|Permission::ALL resources 387 | * @param string|array|Permission::ALL privileges 388 | * @return self 389 | */ 390 | public function removeDeny($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL) 391 | { 392 | $this->setRule(FALSE, self::DENY, $roles, $resources, $privileges); 393 | return $this; 394 | } 395 | /** 396 | * Performs operations on Access Control List rules. 397 | * @param bool operation add? 398 | * @param bool type 399 | * @param string|array|Permission::ALL roles 400 | * @param string|array|Permission::ALL resources 401 | * @param string|array|Permission::ALL privileges 402 | * @param callable assertion 403 | * @return self 404 | */ 405 | protected function setRule($toAdd, $type, $roles, $resources, $privileges, $assertion = NULL) 406 | { 407 | // ensure that all specified Roles exist; normalize input to array of Roles or NULL 408 | if ($roles === self::ALL) { 409 | $roles = array(self::ALL); 410 | } else { 411 | if (!is_array($roles)) { 412 | $roles = array($roles); 413 | } 414 | foreach ($roles as $role) { 415 | $this->checkRole($role); 416 | } 417 | } 418 | // ensure that all specified Resources exist; normalize input to array of Resources or NULL 419 | if ($resources === self::ALL) { 420 | $resources = array(self::ALL); 421 | } else { 422 | if (!is_array($resources)) { 423 | $resources = array($resources); 424 | } 425 | foreach ($resources as $resource) { 426 | $this->checkResource($resource); 427 | } 428 | } 429 | // normalize privileges to array 430 | if ($privileges === self::ALL) { 431 | $privileges = array(); 432 | } elseif (!is_array($privileges)) { 433 | $privileges = array($privileges); 434 | } 435 | if ($toAdd) { // add to the rules 436 | foreach ($resources as $resource) { 437 | foreach ($roles as $role) { 438 | $rules = & $this->getRules($resource, $role, TRUE); 439 | if (count($privileges) === 0) { 440 | $rules['allPrivileges']['type'] = $type; 441 | $rules['allPrivileges']['assert'] = $assertion; 442 | if (!isset($rules['byPrivilege'])) { 443 | $rules['byPrivilege'] = array(); 444 | } 445 | } else { 446 | foreach ($privileges as $privilege) { 447 | $rules['byPrivilege'][$privilege]['type'] = $type; 448 | $rules['byPrivilege'][$privilege]['assert'] = $assertion; 449 | } 450 | } 451 | } 452 | } 453 | } else { // remove from the rules 454 | foreach ($resources as $resource) { 455 | foreach ($roles as $role) { 456 | $rules = & $this->getRules($resource, $role); 457 | if ($rules === NULL) { 458 | continue; 459 | } 460 | if (count($privileges) === 0) { 461 | if ($resource === self::ALL && $role === self::ALL) { 462 | if ($type === $rules['allPrivileges']['type']) { 463 | $rules = array( 464 | 'allPrivileges' => array( 465 | 'type' => self::DENY, 466 | 'assert' => NULL 467 | ), 468 | 'byPrivilege' => array() 469 | ); 470 | } 471 | continue; 472 | } 473 | if ($type === $rules['allPrivileges']['type']) { 474 | unset($rules['allPrivileges']); 475 | } 476 | } else { 477 | foreach ($privileges as $privilege) { 478 | if (isset($rules['byPrivilege'][$privilege]) && 479 | $type === $rules['byPrivilege'][$privilege]['type'] 480 | ) { 481 | unset($rules['byPrivilege'][$privilege]); 482 | } 483 | } 484 | } 485 | } 486 | } 487 | } 488 | return $this; 489 | } 490 | 491 | 492 | /** 493 | * Returns TRUE if and only if the Role has access to [certain $privileges upon] the Resource. 494 | * 495 | * This method checks Role inheritance using a depth-first traversal of the Role list. 496 | * The highest priority parent (i.e., the parent most recently added) is checked first, 497 | * and its respective parents are checked similarly before the lower-priority parents of 498 | * the Role are checked. 499 | * 500 | * @param string|Permission::ALL|IRole role 501 | * @param string|Permission::ALL|IResource resource 502 | * @param string|Permission::ALL privilege 503 | * @param object other_arguments {id: 12, user_id=4} for instance, to be passed in to the assertion. 504 | * @return bool 505 | */ 506 | protected $otherArguments = null; 507 | public function isAllowed($role = self::ALL, $resource = self::ALL, $privilege = self::ALL, $other_arguments = null) 508 | { 509 | $this->otherArguments = $other_arguments; 510 | $this->queriedRole = $role; 511 | if ($role !== self::ALL) { 512 | $this->checkRole($role); 513 | } 514 | $this->queriedResource = $resource; 515 | if ($resource !== self::ALL) { 516 | $this->checkResource($resource); 517 | } 518 | do { 519 | // depth-first search on $role if it is not 'allRoles' pseudo-parent 520 | if ($role !== NULL && NULL !== ($result = $this->searchRolePrivileges($privilege === self::ALL, $role, $resource, $privilege))) { 521 | break; 522 | } 523 | if ($privilege === self::ALL) { 524 | if ($rules = $this->getRules($resource, self::ALL)) { // look for rule on 'allRoles' psuedo-parent 525 | foreach ($rules['byPrivilege'] as $privilege => $rule) { 526 | if (self::DENY === ($result = $this->getRuleType($resource, NULL, $privilege))) { 527 | break 2; 528 | } 529 | } 530 | if (NULL !== ($result = $this->getRuleType($resource, NULL, NULL))) { 531 | break; 532 | } 533 | } 534 | } else { 535 | if (NULL !== ($result = $this->getRuleType($resource, NULL, $privilege))) { // look for rule on 'allRoles' pseudo-parent 536 | break; 537 | } elseif (NULL !== ($result = $this->getRuleType($resource, NULL, NULL))) { 538 | break; 539 | } 540 | } 541 | $resource = $this->resources[$resource]['parent']; // try next Resource 542 | } while (TRUE); 543 | $this->queriedRole = $this->queriedResource = NULL; 544 | $this->otherArguments = null; 545 | return $result; 546 | } 547 | /** 548 | * Returns any other arguments passed to the permission set. In our application, this usually includes ID, and is used in the assertion. 549 | * To allow resources with specific ID asserts. 550 | * @return mixed 551 | */ 552 | public function getQueriedOtherArguments() { 553 | return $this->otherArguments; 554 | } 555 | /** 556 | * Returns real currently queried Role. Use by assertion. 557 | * @return mixed 558 | */ 559 | public function getQueriedRole() 560 | { 561 | return $this->queriedRole; 562 | } 563 | /** 564 | * Returns real currently queried Resource. Use by assertion. 565 | * @return mixed 566 | */ 567 | public function getQueriedResource() 568 | { 569 | return $this->queriedResource; 570 | } 571 | 572 | /** 573 | * Performs a depth-first search of the Role DAG, starting at $role, in order to find a rule 574 | * allowing/denying $role access to a/all $privilege upon $resource. 575 | * @param bool all (true) or one? 576 | * @param string 577 | * @param string 578 | * @param string only for one 579 | * @return mixed NULL if no applicable rule is found, otherwise returns ALLOW or DENY 580 | */ 581 | private function searchRolePrivileges($all, $role, $resource, $privilege) 582 | { 583 | $dfs = array( 584 | 'visited' => array(), 585 | 'stack' => array($role), 586 | ); 587 | while (NULL !== ($role = array_pop($dfs['stack']))) { 588 | if (isset($dfs['visited'][$role])) { 589 | continue; 590 | } 591 | if ($all) { 592 | if ($rules = $this->getRules($resource, $role)) { 593 | foreach ($rules['byPrivilege'] as $privilege2 => $rule) { 594 | if (self::DENY === $this->getRuleType($resource, $role, $privilege2)) { 595 | return self::DENY; 596 | } 597 | } 598 | if (NULL !== ($type = $this->getRuleType($resource, $role, NULL))) { 599 | return $type; 600 | } 601 | } 602 | } else { 603 | if (NULL !== ($type = $this->getRuleType($resource, $role, $privilege))) { 604 | return $type; 605 | } elseif (NULL !== ($type = $this->getRuleType($resource, $role, NULL))) { 606 | return $type; 607 | } 608 | } 609 | $dfs['visited'][$role] = TRUE; 610 | foreach ($this->roles[$role]['parents'] as $roleParent => $foo) { 611 | $dfs['stack'][] = $roleParent; 612 | } 613 | } 614 | return NULL; 615 | } 616 | /** 617 | * Returns the rule type associated with the specified Resource, Role, and privilege. 618 | * @param string|Permission::ALL 619 | * @param string|Permission::ALL 620 | * @param string|Permission::ALL 621 | * @return mixed NULL if a rule does not exist or assertion fails, otherwise returns ALLOW or DENY 622 | */ 623 | private function getRuleType($resource, $role, $privilege, $other_arguments = null) 624 | { 625 | if (!$rules = $this->getRules($resource, $role)) { 626 | return NULL; 627 | } 628 | if ($privilege === self::ALL) { 629 | if (isset($rules['allPrivileges'])) { 630 | $rule = $rules['allPrivileges']; 631 | } else { 632 | return NULL; 633 | } 634 | } elseif (!isset($rules['byPrivilege'][$privilege])) { 635 | return NULL; 636 | } else { 637 | $rule = $rules['byPrivilege'][$privilege]; 638 | } 639 | 640 | if(isset($rule['assert'])) 641 | $assertion = $rule['assert']; 642 | else 643 | $assertion = null; 644 | if ($assertion === null) { 645 | return $rule['type']; 646 | } elseif(!is_callable($assertion)) { 647 | throw new gburtini\ACL\Exceptions\InvalidAssertionException("Assertion isn't callable for this rule."); 648 | } elseif($assertion($this, $role, $resource, $privilege) == true) { 649 | return $rule['type']; 650 | } elseif ($resource !== self::ALL || $role !== self::ALL || $privilege !== self::ALL) { 651 | return NULL; 652 | } elseif (self::ALLOW === $rule['type']) { 653 | return self::DENY; 654 | } else { 655 | return self::ALLOW; 656 | } 657 | } 658 | /** 659 | * Returns the rules associated with a Resource and a Role, or NULL if no such rules exist. 660 | * If the $create parameter is TRUE, then a rule set is first created and then returned to the caller. 661 | * @param string|Permission::ALL 662 | * @param string|Permission::ALL 663 | * @param bool 664 | * @return array|NULL 665 | */ 666 | private function & getRules($resource, $role, $create = FALSE) 667 | { 668 | $null = NULL; 669 | if ($resource === self::ALL) { 670 | $visitor = & $this->rules['allResources']; 671 | } else { 672 | if (!isset($this->rules['byResource'][$resource])) { 673 | if (!$create) { 674 | return $null; 675 | } 676 | $this->rules['byResource'][$resource] = array(); 677 | } 678 | $visitor = & $this->rules['byResource'][$resource]; 679 | } 680 | if ($role === self::ALL) { 681 | if (!isset($visitor['allRoles'])) { 682 | if (!$create) { 683 | return $null; 684 | } 685 | $visitor['allRoles']['byPrivilege'] = array(); 686 | } 687 | return $visitor['allRoles']; 688 | } 689 | if (!isset($visitor['byRole'][$role])) { 690 | if (!$create) { 691 | return $null; 692 | } 693 | $visitor['byRole'][$role]['byPrivilege'] = array(); 694 | } 695 | return $visitor['byRole'][$role]; 696 | } 697 | } 698 | 699 | ?> 700 | -------------------------------------------------------------------------------- /src/gburtini/ACL/Authenticator.php: -------------------------------------------------------------------------------- 1 | 56 | -------------------------------------------------------------------------------- /src/gburtini/ACL/Exceptions/InvalidAssertionException.php: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/gburtini/ACL/Exceptions/InvalidLoginException.php: -------------------------------------------------------------------------------- 1 | users = $userpasses; 8 | } 9 | 10 | public function authenticate($user, $password, $roles=null) { 11 | if($this->users[$user] === $password) 12 | return ['id' => $user, 'roles' => $roles]; // in reality, you want to pick/confirm roles for this user. 13 | return false; 14 | } 15 | } 16 | ?> 17 | -------------------------------------------------------------------------------- /src/gburtini/ACL/User.php: -------------------------------------------------------------------------------- 1 | cookie = new AuthenticatedCookie(self::COOKIE_NAME, $hmackey, $this->expiration); 38 | } else { 39 | $this->cookie = new AESAuthenticatedCookie(self::COOKIE_NAME, $hmackey, $aeskey, $this->expiration); 40 | } 41 | 42 | $this->internalLogin(); 43 | } 44 | 45 | public function isLoggedIn() { 46 | return $this->id !== null; 47 | } 48 | public function whoAmI() { 49 | return $this->id; 50 | } 51 | public function roles() { return $this->roles; } 52 | 53 | // NOTE: you can override these methods to use another method of storing logins. 54 | protected function internalLogin() { 55 | // check if a login exists. 56 | $message = $this->cookie->get(); 57 | if($message === false) 58 | return false; 59 | else { 60 | if(time() > $message['now'] && time() < $message['expires']) { 61 | $this->id = $message['id']; 62 | $this->roles = $message['roles']; 63 | } 64 | else { 65 | return false; 66 | } 67 | } 68 | } 69 | 70 | protected function setInternalLogin() { 71 | $expires = $this->computeExpiration(); 72 | $message = ['id' => $this->id, 'roles' => $this->roles, 'now' => time(), 'expires' => $expires]; 73 | $this->cookie->set($message); 74 | } 75 | 76 | 77 | /** 78 | * Passed in roles are solely optional here. It allows you to "request" roles 79 | * and have the authenticator determine whether it wishes to dole them out. 80 | * 81 | * NOTE: in the current implementation, roles are overwritten here IF you changed ID. 82 | * If you want to use this code in a way where you iteratively request more and more 83 | * permission roles that is acceptable as long as the authenticator returns the same ID 84 | */ 85 | public function login($username, $password, $roles=null) { 86 | if(($response = $this->authenticator->authenticate($username, $password, $roles)) !== false) { 87 | // good, set login parameters. 88 | if($response['id'] != $this->id) 89 | $this->roles = $response['roles']; 90 | else 91 | $this->roles = array_unique(array_merge($this->roles, $response['roles'])); 92 | 93 | // NOTE: I am not super comfortable with this. Consider attack vectors that involve changing users mid-authentication. 94 | $this->id = $response['id']; 95 | $this->setInternalLogin(); 96 | 97 | return $response; 98 | } else { 99 | throw new Exceptions\InvalidLoginException("Invalid login."); 100 | } 101 | } 102 | 103 | public function logout() { 104 | // delete the cookies by setting them to blank (which won't auth.) and expiring them one second in the future. 105 | $this->cookie->clear(); 106 | 107 | // clear the current settings... note that extending classes should be wary of a call to logout in this sense. 108 | $this->id = null; 109 | $this->roles = ["guest"]; 110 | } 111 | 112 | /** 113 | * Set the cookie expiration time in relation to when login is called. 114 | */ 115 | public function setExpiration($time = "30 days") { 116 | $this->cookie->setExpiration($time); 117 | $this->expiration = $time; 118 | } 119 | 120 | // computes the cookie expiration time, it needs to be here because it is validated both by expiring the cookie client side 121 | // and by writing a tamper proof timestamp to the cookie. 122 | protected function computeExpiration() { 123 | return strtotime("+" . $this->expiration, time()); 124 | } 125 | 126 | /** 127 | * Set the authenticator to be used. See SimpleAuthenticator.php for more information. 128 | */ 129 | public function setAuthenticator($authenticator) { 130 | $this->authenticator = $authenticator; 131 | } 132 | 133 | /** 134 | * Set the ACL set to be used. An instance of the ACL class. 135 | */ 136 | public function setACL($acl) { 137 | $this->acl = $acl; 138 | } 139 | 140 | /** 141 | * Returns true or false if you can access the resource, action, ID set. 142 | * Note for implementors that when you're setting up the ACL, you have the option to 143 | * specify an assertion function. The assertion function will receive $id and the calling 144 | * user's user_id to allow user-level permissions. 145 | */ 146 | public function can($resource, $action, $id=null) { 147 | if($this->acl === null) 148 | throw new \RuntimeException("You haven't set an ACL."); 149 | 150 | // a user can do something if any of his roles can do it. 151 | foreach($this->roles as $role) { 152 | if($this->acl->isAllowed($role, $resource, $action, [ 153 | 'id' => $id, 154 | 'user_id' => $this->id 155 | // NOTE: any other arguments to the ACL assertions here. 156 | ]) == true) 157 | return true; 158 | } 159 | return false; 160 | } 161 | } 162 | ?> 163 | -------------------------------------------------------------------------------- /src/gburtini/AuthenticatedStorage/AESAuthenticatedCookie.php: -------------------------------------------------------------------------------- 1 | key_aes = $key_aes; 12 | parent::__construct($name, $key_hmac, $expiration); 13 | } 14 | 15 | /** 16 | * A reminder that this code has absolutely no warranty express or implied, not even the 17 | * implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. Don't roll 18 | * your own crypto. 19 | **/ 20 | protected function preparePlaintext($plaintext) { 21 | $iv_size = static::ivSize(); 22 | if($iv_size === false) 23 | throw new RuntimeException("Cowardly refusing to return ciphertext when IV creation failed (size calculation is false?)."); 24 | 25 | $iv = mcrypt_create_iv($iv_size, MCRYPT_RAND); 26 | if($iv === false) 27 | throw new RuntimeException("Cowardly refusing to return ciphertext when IV creation failed."); 28 | 29 | $ciphertext = mcrypt_encrypt(MCRYPT_RIJNDAEL_128, $this->key_aes, $plaintext, MCRYPT_MODE_CBC, $iv); 30 | if($ciphertext === false) 31 | throw new RuntimeException("Cowardly refusing to return ciphertext when Rijndael call failed."); 32 | 33 | $ciphertext = $iv . $ciphertext; 34 | $ciphertext_base64 = base64_encode($ciphertext); 35 | return $ciphertext_base64; 36 | } 37 | 38 | protected function prepareCiphertext($ciphertext) { 39 | $iv_size = static::ivSize(); 40 | if($iv_size === false) 41 | throw new RuntimeException("Cowardly refusing to decrypt when IV size is unknown (do not want to run cryptoprimitives on unknown input)."); 42 | 43 | $iv_dec = substr($ciphertext, 0, $iv_size); 44 | if($iv_dec === false) 45 | throw new RuntimeException("Cowardly refusing to decrypt when IV cannot be found."); 46 | 47 | $ciphertext_dec = substr($cipher, $iv_size); 48 | $plaintext_dec = mcrypt_decrypt(MCRYPT_RIJNDAEL_128, $this->key_aes, $ciphertext_dec, MCRYPT_MODE_CBC, $iv_dec); 49 | if($plaintext_dec === false) 50 | throw new RuntimeException("Cowardly refusing to continue decryption when Rijndael failed."); 51 | $plaintext_dec = rtrim($plaintext_dec, "\0\4"); // this is scary, but mcrypt_encrypt padded with zeros. 52 | return $plaintext_dec; 53 | } 54 | 55 | protected static function ivSize() { 56 | return mcrypt_get_iv_size(MCRYPT_RIJNDAEL_128, MCRYPT_MODE_CBC); 57 | } 58 | } 59 | ?> 60 | -------------------------------------------------------------------------------- /src/gburtini/AuthenticatedStorage/AuthenticatedCookie.php: -------------------------------------------------------------------------------- 1 | name = $name; 15 | $this->key_hmac = $key_hmac; 16 | 17 | if(!is_string($key_hmac) || $key_hmac == "") { 18 | throw new UnexpectedValueException("HMAC key must be specified as a string (preferably a long random string!)."); 19 | } 20 | 21 | $this->expiration = $expiration; 22 | 23 | $this->readCookie(); 24 | $this->updateCookie(); 25 | } 26 | 27 | public function setExpiration($expiration) { 28 | $this->expiration = $expiration; 29 | $this->updateCookie(); 30 | } 31 | 32 | public function set($value) { 33 | $this->value = $value; 34 | $this->updateCookie(); 35 | } 36 | 37 | public function get() { 38 | return $this->value; 39 | } 40 | 41 | public function clear() { 42 | $this->value = null; 43 | } 44 | 45 | protected function readCookie() { 46 | if(isset($_COOKIE[$this->name])) { 47 | $cookie = $_COOKIE[$this->name]; 48 | 49 | // 88 = ceil(64 (simple hash length) / 3) * 4 for the base64 encoded hash size. 50 | if(strlen($cookie) <= 88) // nothing to read here, its not a valid cookie. 51 | return false; 52 | 53 | $hash = substr($cookie, 0, 88); 54 | $message = substr($cookie, 88); 55 | 56 | $message = $this->readMessage($message, $hash); 57 | $this->value = $message; 58 | } else { $this->value = null; } 59 | } 60 | 61 | protected function updateCookie() { 62 | $prepared = $this->prepareMessage($this->value); 63 | 64 | // TODO: take in all the other parameters somewhere for this. 65 | setcookie($this->name, $prepared['hash'] . $prepared['message'], $this->computeExpiration(), "/"); 66 | } 67 | 68 | public function __toString() { 69 | echo "AuthenticatedCookie: " . $this->value; 70 | } 71 | 72 | protected function computeExpiration() { 73 | return strtotime("+" . $this->expiration, time()); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/gburtini/AuthenticatedStorage/MACComputer.php: -------------------------------------------------------------------------------- 1 | key_hmac being the key. This should be more clear/flexible. 13 | */ 14 | trait MACComputer { 15 | protected function prepareCiphertext($ciphertext) { 16 | return $ciphertext; 17 | } 18 | 19 | protected function preparePlaintext($plaintext) { 20 | return $plaintext; 21 | } 22 | 23 | /* 24 | * Implements encrypt-then-authenticate as described by Moxie Marlinspike. 25 | * Reads a message and returns the message as it was saved from prepareMessage. 26 | */ 27 | protected function readMessage($message, $hash) { 28 | // NOTE: empty has some strange behavior if you store "0". It should probably be discarded here. 29 | // Perhaps not that important since we JSON encode. 30 | if($message === false || $hash === false || empty($message) || empty($hash)) 31 | return false; 32 | 33 | // always authenticate as a first step, exit if it doesn't pass: http://www.thoughtcrime.org/blog/the-cryptographic-doom-principle/ 34 | // this should be the step that any user modified messages get dumped. if anything bad happens after this, we must assume it is 35 | // a security risk. 36 | if(static::validateHash($message, $hash, $this->key_hmac) === false) { 37 | return false; 38 | } 39 | 40 | $message = $this->prepareCiphertext($message); 41 | 42 | return json_decode($message, true); 43 | } 44 | 45 | /* 46 | * Implements encrypt-then-authenticate if there were encryption specified. 47 | * See: http://www.thoughtcrime.org/blog/the-cryptographic-doom-principle/ 48 | * 49 | * Takes in a message and returns a dictionary of 'message' and 'hash' strings 50 | * where 'message' is the (possibly encrypted) plaintext and 'hash' is the validation 51 | * string to be passed in to readMessage. 52 | */ 53 | protected function prepareMessage($message) { 54 | $plaintext = json_encode($message); 55 | 56 | $ciphertext = $this->preparePlaintext($plaintext); 57 | 58 | $hash = static::hash($ciphertext, $this->key_hmac); 59 | if($hash === false) 60 | throw new RuntimeException("Cowardly refusing to return ciphertext when hash calculation fails. Check that the appropriate HMAC algorithm is available."); 61 | 62 | return ['message' => $ciphertext, 'hash' => $hash]; 63 | } 64 | 65 | /* 66 | * Validates a hash in a timing attack aware manner. 67 | */ 68 | protected static function validateHash($message, $hash, $key) { 69 | if(!hash_equals(static::hash($message, $key), $hash)) 70 | return false; 71 | return true; 72 | } 73 | 74 | /* 75 | * Computes a hash as a base64 encoded sha256. 76 | */ 77 | protected static function hash($message, $secret) { 78 | $hash = hash_hmac('sha256', $message, $secret); 79 | 80 | $hash = base64_encode($hash); // we do not decode the hash ever at this point, only compare as base64 encoded hashes. 81 | if($hash === false) // but base64_encode has a note in the documentation that it may return false. 82 | throw new RuntimeException("Failed to compute hash because base64 failed."); 83 | 84 | return $hash; 85 | } 86 | } 87 | 88 | 89 | // NOTE: function exists seems to consider the namespace, meaning we always define this. 90 | if(!function_exists('hash_equals')) { 91 | function hash_equals($str1, $str2) { 92 | if(strlen($str1) != strlen($str2)) { 93 | return false; 94 | } else { 95 | $res = $str1 ^ $str2; 96 | $ret = 0; 97 | for($i = strlen($res) - 1; $i >= 0; $i--) $ret |= ord($res[$i]); 98 | return !$ret; 99 | } 100 | } 101 | } 102 | ?> 103 | --------------------------------------------------------------------------------