Control User Access to Classes and Methods with Rauth

Share this article

Rauth is SitePoint’s access control package for either granting or restricting access to certain classes or methods, mainly by means of annotations.

Open lock icon. Flat design style eps 10

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:

Routes 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:

Loggedout route fails

While the another/loggedin group will succeed:

logged in route works

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:

loggedout route fail, verbose

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:

Banned route

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!

Bruno SkvorcBruno Skvorc
View Author

Bruno is a blockchain developer and technical educator at the Web3 Foundation, the foundation that's building the next generation of the free people's internet. He runs two newsletters you should subscribe to if you're interested in Web3.0: Dot Leap covers ecosystem and tech development of Web3, and NFT Review covers the evolution of the non-fungible token (digital collectibles) ecosystem inside this emerging new web. His current passion project is RMRK.app, the most advanced NFT system in the world, which allows NFTs to own other NFTs, NFTs to react to emotion, NFTs to be governed democratically, and NFTs to be multiple things at once.

access controlannotationsauthenticationauthorizationBrunoSOOPHPPHPrauth
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week