Control User Access to Classes and Methods with Rauth
Rauth is SitePoint’s access control package for either granting or restricting access to certain classes or methods, mainly by means of annotations.
In this tutorial, we’ll learn how to use it.
Why Rauth
Traditional access control layers (ACLs) only control routes – you set anything starting with /admin
to be only accessible by admins, and so on. This is fine for most cases, but not when:
- you want to control access on the command line (no routes there)
- you want your access layer unchanged even if you change the routes
Rauth was developed to address this need. Naturally, it’ll also work really well alongside any other kind of ACL if its features are insufficient.
Annotations Are Bad ™
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 make it out to be:
-
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 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
-
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
-
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.
Simple Example
For a simple example, let’s create two classes, an index.php
file, and install Rauth:
composer require sitepoint/rauth
<?php
// index.php
use SitePoint\Rauth;
require_once 'vendor/autoload.php';
$r = new Rauth();
$user = [
'groups' => 'admin',
];
$fakeRoutes = [
'admin' => ['One', 'adminOnly'],
'users' => ['One', 'users'],
'banned' => ['One', 'banned'],
'everyone' => ['Two', 'everyone'],
];
foreach ($fakeRoutes as $route) {
require_once $route[0] . '.php';
$class = $route[0];
$method = $route[1];
try {
$r->authorize($class, $route[1], $user);
echo "Success: ";
$class = new $class();
$class->$method();
} catch (Rauth\Exception\AuthException $e) {
echo "Authorizing {$class}::{$route[1]} failed!\n";
echo $e->getMessage() . "\n";
}
}
<?php
// One.php
/**
* Class One
* @auth-groups users
*/
class One
{
/**
* @auth-groups admin
*/
public function adminOnly()
{
echo 'Because the "admin" group is detected, the One::adminOnly method is executed.';
echo "\n";
}
public function users()
{
echo '"Users" can use One::users - it inherited from the class @auth declaration';
echo "\n";
}
/**
* @auth-groups banned
* @auth-mode none
*/
public function banned()
{
echo 'No user with the group "banned" can access One::banned.';
echo "\n";
}
}
<?php
// Two.php
class Two
{
public function everyone()
{
echo "Everyone can access Two::everyone!\n";
}
}
If we now run this in the console with:
php index.php
we’ll get:
vagrant@homestead:~/Code/rauthtest$ php index.php
Success: Because the "admin" group is detected, the One::adminOnly method is executed.
Authorizing One::users failed!
Success: No user with the group "banned" can access One::banned.
Success: Everyone can access Two::everyone!
If we change the group of the $user
array to banned
, watch what happens:
vagrant@homestead:~/Code/rauthtest$ php index.php
Authorizing One::adminOnly failed!
Authorizing One::users failed!
Authorizing One::banned failed!
Success: Everyone can access Two::everyone!
In a nutshell, Rauth uses requirements (everything starting with @auth-
in docblocks) and compares them to attributes (everything we define in the $user
variable). It’s up to you how you’ll actually get to those attributes – maybe you’ll be using some kind of pre-made package like Gatekeeper, or you’ll roll your own authentication system, doesn’t matter. What matters is that Rauth gets the attributes in a format which is comparable to the requirements on the classes / methods.
A note on modes: If a user has a group attribute of “admin”, they can access all methods with @auth-groups admin
. If the mode
is set to @auth-mode OR
(this is the default and can be omitted), then the user will be able to access methods with @auth-groups admin, users, banana
, because they only need to have one of the groups defined. When the mode is AND (@auth-mode AND
), they need to have ALL the groups. If the mode is NONE
as in the banned()
method, then the user MUST NOT have ANY of the groups.
Remember, auth-
can be anything – doesn’t have to be groups
. You can use completely arbitrary values like @auth-banana ripe
and then a user with 'banana' => 'ripe'
in their attributes will be able to access the class/method.
Dependency Injection Example
The above example was a rudimentary demo of the authorization flow. Let us now set up a situation in which a route is resolved, a controller is auto-invoked by a dependency injection container, and an authorization check is made automatically. In other words, a best practice approach for starting a new app – one nofw – the no-framework framework – is based on.
composer require sitepoint/rauth php-di/php-di nikic/fast-route
Let’s also make a Controllers
folder, and put two controllers in there:
<?php
// Controllers/OneController.php
namespace MyApp;
class OneController
{
public function homeAction()
{
echo "This is the home screen!";
}
}
<?php
namespace MyApp;
/**
* Class AnotherController
* @package MyApp
*/
class AnotherController
{
public function indexAction()
{
echo "This is the second controller, index action, accessible to all";
}
/**
* @auth-groups users
*/
public function onlyLoggedInAction()
{
echo "This action can only be accessed by logged in users";
}
/**
* @auth-groups users
* @auth-mode NONE
*/
public function onlyLoggedOutAction()
{
echo "This action can only be accessed by visitors who are not logged in";
}
}
We’ll also need to configure the autoloader to load these controllers:
"autoload": {
"psr-4": {
"MyApp\\": "Controllers"
}
}
And regenerate the autoload files with:
composer du
The next step is to configure the routes and the dependency injection container, as per PHP-DI documentation. Our index.php
file should be modified to look like this:
<?php
// index.php
require_once 'vendor/autoload.php';
use SitePoint\Rauth;
use FastRoute\RouteCollector;
use FastRoute\Dispatcher;
use DI\ContainerBuilder;
$containerBuilder = new ContainerBuilder;
$containerBuilder->addDefinitions([
]);
$container = $containerBuilder->build();
$routeList = [
['GET', '/one-home', ['MyApp\OneController', 'homeAction']],
['GET', '/another-index', ['MyApp\AnotherController', 'indexAction']],
['GET', '/another/loggedin', ['MyApp\AnotherController', 'onlyLoggedInAction']],
['GET', '/another/loggedout', ['MyApp\AnotherController', 'onlyLoggedOutAction']],
];
/** @var Dispatcher $dispatcher */
$dispatcher = FastRoute\simpleDispatcher(
function (RouteCollector $r) use ($routeList) {
foreach ($routeList as $routeDef) {
$r->addRoute($routeDef[0], $routeDef[1], $routeDef[2]);
}
}
);
$route = $dispatcher->dispatch(
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI']
);
switch ($route[0]) {
case FastRoute\Dispatcher::NOT_FOUND:
die('404 not found!');
case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
die('405 method not allowed');
case FastRoute\Dispatcher::FOUND:
$controller = $route[1];
$parameters = $route[2];
$container->call($controller, $parameters);
break;
}
First, we make a new, empty container. Then, we define some routes (this would usually go into an external file like routes.php
). We add these routes to the dispatcher, so it knows what to do with them, and finally we extract a triggered route’s elements and trigger the appropriate controller. Simple enough, right?
Now, the routes should work:
Time to add the Rauth layer to actually limit access to some of those routes.
First, we’ll add a User
definition to the container:
$containerBuilder->addDefinitions(
[
'User' => function () {
return [
'groups' => 'users',
];
},
]
);
Usually this would have some kind of logic, like checking the database for the user’s groups. For the purposes of this tutorial, hardcoding it is enough due to actual user management being outside the scope of this piece.
Then, in the FOUND
case below, we’ll instantiate the Rauth class, and build an array of attributes to feed it when it’s time to do the authorize
check. Notice that we’re defaulting to an empty groups
array if the user hasn’t been defined (i.e. they’re logged out).
// [...]
case FastRoute\Dispatcher::FOUND:
$controller = $route[1];
$parameters = $route[2];
$rauth = new Rauth();
$attributes = $container->get('User') ?: ['groups' => []];
Since Rauth throws an AuthException
when the authorize
call fails, let’s wrap the whole thing into a try/catch
block, too.
try {
$rauth->authorize($controller[0], $controller[1], $attributes);
} catch (Rauth\Exception\AuthException $e) {
die("Authorization failed");
}
If we access the another/loggedout
route now with the default hardcoded user from before, it should fail because we have the users
group on that user:
While the another/loggedin
group will succeed:
Of course, while in development, you might want some more verbose error messages. For that, each AuthException
has a type
and contains some Reason
objects. The type will be something like “none” if the mode was set to “NONE”, of “ban” if the failure happened due to a ban (there is a difference – see next section), etc. Reasons will each have their own failure groups (not to be confused with the arbitrarily named “group” attribute), and a list of all entities in that group it has vs. expects. Perhaps this is best explained on an example. We can replace the die()
call above with:
echo 'Failed due to: ' . $e->getType(). '. ';
/** @var Rauth\Exception\Reason $reason */
foreach ($e->getReasons() as $reason) {
echo 'Blocked by "' . $reason->group . '" when comparing owned "' . implode(
", ", $reason->has
) . '" versus "' . implode(", ", $reason->needs) . '".';
}
die();
You’d want this in a flash message or in a pretty error view in a normal app, but echoing it out like this is fine in this tutorial. This block results in an output like this if we try the loggedout
route again:
In other words, it says: “I’m expecting none of the following in ‘groups’: ‘users’, but I got ‘users’”.
Feel free to change some of the hardcoded groups on the user array, and to alter the annotations on the classes to test some routes out further. Check out the AND
vs OR
mode, too.
Bans
One last thing we failed to mention so far: bans. Rauth has an additional pre-check called for banned entities, and this precedes all others. If a ban is met, then the authorization fails, no matter how many other things match up. For example:
@auth-groups users, admin
@auth-ban-groups banned
The above will block access to the class or method if the user has the banned
group, regardless of whether or not they also have users
or admin
. Like other attributes, ban collections can be arbitrary:
@auth-groups users, admin
@auth-ban-tags banana
Tag a user with banana
and they can’t get in, no matter the rest of the attributes on them.
Ban checks happen BEFORE any other check, so if you just need to block access to some classes or methods based on some attributes, bans will be the most efficient approach. Bans have their own type in the exception:
Conclusion
We took a look at Rauth, SitePoint’s package for controlling access to classes and methods. We implemented it in two simple demos, and showed how easy to use it is.
As Rauth is a SitePoint Open Source effort, we’re actively looking for contributors or just feedback on some of the open issues – we’re determined to make our packages worthy of production use, and we need your help to do so.
Did you give Rauth a shot yet? Would you? Let us know in the comments!