Removing the Pain of User Authorization with Sentinel

Younes Rafie
Share

Most non-basic multi-user applications need some roles and permission levels. If you ever used WordPress, you must have noticed that they have a super admin, admin, editor, author, etc. Simplifying the development and integration of a permission system is what Cartalyst’s Sentinel package is trying to accomplish. The package provides an API for dealing with users, groups, permissions, etc. In this article, we’ll use it to create a small demo app.

Cartalyst Sentinel

Environment Setup

For our sample application in this tutorial, we will be using the Slim micro-framework and Vagrant. You can check out the final demo on Github to see the result. Let’s start by first requiring the necessary packages:

composer require slim/slim:~2.0
composer require twig/twig:~1.*
composer require cartalyst/sentinel:2.0.*

Sentinel suggests installing Illuminate Eloquent, Illuminate Events, Symfony Http Foundation and ircmaxell password-compat so let’s add those to the project.

composer require illuminate/database illuminate/events symfony/http-foundation ircmaxell/password-compat

Because working with users, groups and permissions requires some database interaction, we need to create our database with the necessary tables. If you are using Laravel you can install the database using the migrate command.

php artisan vendor:publish --provider="Cartalyst\Sentinel\Laravel\SentinelServiceProvider"
php artisan migrate

If not, you’ll have to do it manually using the following steps. You can read more about database installation in the documentation.

  • Open the vendor/cartalyst/sentinel/schema/mysql.sql, add use DATABASENAME; at the top of the file and save.
  • Feed your file to MySQL using the command mysql -u root -p < vendor/cartalyst/sentinel/schema/mysql.sql and enter the password if necessary, or just import the file using your favorite SQL editor / inspector (like PhpMyAdmin, Sequel Pro, etc).

Finally, let’s bootstrap our app with a public/index.php file:

<?php

require_once __DIR__.'/../vendor/autoload.php';

$app = new \Slim\Slim();
//register bindings

include_once __DIR__.'/../app/bootstrap/container.php';

include_once __DIR__.'/../app/routes.php';

$app->run();

Container Bindings

Inside our app/bootstrap/container.php we will define our container bindings.

$app->container->twigLoader = new Twig_Loader_Filesystem(__DIR__.'/../views');
$app->container->twig = new Twig_Environment($app->container->twigLoader, array(
    'cache' => false,
));

The $app variable is a Slim container. Since we chose to use Eloquent for our database interactions we need to specify the connection configuration.

// app/bootstrap/container.php

$capsule = new \Illuminate\Database\Capsule\Manager();
$capsule->addConnection([
    'driver' => 'mysql',
    'host' => 'localhost',
    'database' => 'capsule',
    'username' => 'root',
    'password' => 'root',
    'charset' => 'utf8',
    'collation' => 'utf8_unicode_ci',
]);
$capsule->bootEloquent();

After booting Eloquent, the only part left is to bind Sentinel to the container.

// app/bootstrap/container.php

$app->container->sentinel = (new \Cartalyst\Sentinel\Native\Facades\Sentinel())->getSentinel();

If you prefer to use static calls, you can use it directly. (Sentinel::authenticate($credentials))

Creating Roles

The first step is to define our permission groups. We have the admin role which gives the user the permission to create, update and delete users. On the other hand, the user role only gives the permission to update users.

$app->container->sentinel->getRoleRepository()->createModel()->create(array(
    'name'          => 'Admin',
    'slug'          => 'admin',
    'permissions'   => array(
        'user.create' => true,
        'user.update' => true,
        'user.delete' => true
    ),
));

$app->container->sentinel->getRoleRepository()->createModel()->create(array(
    'name'          => 'User',
    'slug'          => 'user',
    'permissions'   => array(
        'user.update' => true
    ),
));

This code is temporary, and can be placed at the bottom of index.php, run once, and then removed. Its only purpose is to create the roles in the database.

Sign up Page

We have two options when creating the sign up page. We can either use email confirmation, or directly set the user as validated.

// app/routes.php

$app->get('/', function () use ($app) {
    $app->twig->display('home.html.twig');
});

Our home page contains a form and a set of fields for user details (first name, last name, email, password and password confirmation). Check the source code of the view files here.

Then, let’s define the POST home route.

// app/routes.php

$app->post('/', function () use ($app) {
    // we leave validation for another time
    $data = $app->request->post();

    $role = $app->container->sentinel->findRoleByName('User');

    if ($app->container->sentinel->findByCredentials([
        'login' => $data['email'],
    ])) {
        echo 'User already exists with this email.';

        return;
    }

    $user = $app->container->sentinel->create([
        'first_name' => $data['firstname'],
        'last_name' => $data['lastname'],
        'email' => $data['email'],
        'password' => $data['password'],
        'permissions' => [
            'user.delete' => 0,
        ],
    ]);

    // attach the user to the role
    $role->users()->attach($user);

    // create a new activation for the registered user
    $activation = (new Cartalyst\Sentinel\Activations\IlluminateActivationRepository)->create($user);
    mail($data['email'], "Activate your account", "Click on the link below \n <a href='http://vaprobash.dev/user/activate?code={$activation->code}&login={$user->id}'>Activate your account</a>");

    echo "Please check your email to complete your account registration.";
});

After getting the request data and resolving Sentinel from the container, we select the role that we want to assign to the new user. You can query roles by name, slug or by ID. More about that in the documentation. Next, we test to see if the new user is already registered, then we create a new user using the request data, assign it the User role and we create a new activation. The activation process is totally optional, we will talk more about it later. We skipped the validation process here to keep the function simple.

Permissions

The User role gives users the permission to update. But, what if we wanted to add or override some permissions?

$user = $app->container->sentinel->create([
    'first_name' => $data['firstname'],
    'last_name' => $data['lastname'],
    'email' => $data['email'],
    'password' => $data['password'],
    'permissions' => [
        'user.update' => false,
        'user.create' => true
    ],
]);

Sentinel has two methods for working with permissions: standard or strict. For the standard method, you can give the user the ability to create new users directly ('user.create': true), but when you want to deny a permission to the role, you just need to give it false on the user’s permissions. The strict method will negate the permission if is set to false anywhere. You can read more about permissions in the documentation.

User Activation

After saving the user to the database, you’ll need to send the activation link to the user’s email.

We have two methods to implement the activation process. The first will send the code and the user ID via email, and the second one will only send the activation code.

// app/routes.php

$app->post('/', function () use ($app) {
    //...

    $activation = (new Cartalyst\Sentinel\Activations\IlluminateActivationRepository)->create($user);
    mail($data['email'], "Activate your account", "Click on the link below \n <a href='http://vaprobash.dev/user/activate?code={$activation->code}&login={$user->id}'>Activate your account</a>");

    echo "Please check your email to complete your account registration.";
});

The create method on the IlluminateActivationRepository will return a database record containing the activation code. Now, we need to create a new route to catch the account validation request.

// app/routes.php

$app->get('/user/activate', function () use ($app) {
    $code = $app->request->get('code');
    $login = $app->request->get('login');
    $activation = new Cartalyst\Sentinel\Activations\IlluminateActivationRepository;

    if (!($user = $app->sentinel->findById($login)))
    {
        echo "User not found!";

        return;
    }

    if (!$activation->complete($user, $code))
    {
        if ($activation->completed($user))
        {
            echo 'User is already activated. Try to log in.';

            return;
        }
        echo "Activation error!";

        return;
    }

    echo 'Your account has been activated. Log into your account.';

    return;
});

The first step is to test whether or not the user exists. Then, we attempt to complete the activation. In case of error, we check to see if the user has been already activated.

// app/routes.php

$app->get('/user/activate', function () use ($app) {
    $code = $app->request->get('code');

    $activationRepository = new Cartalyst\Sentinel\Activations\IlluminateActivationRepository;
    $activation = Cartalyst\Sentinel\Activations\EloquentActivation::where("code", $code)->first();

    if (!$activation)
    {
        echo "Activation error!";

        return;
    }

    $user = $app->container->sentinel->findById($activation->user_id);

    if (!$user)
    {
        echo "User not found!";

        return;
    }


    if (!$activationRepository->complete($user, $code))
    {
        if ($activationRepository->completed($user))
        {
            echo 'User is already activated. Try to log in.';

            return;
        }

        echo "Activation error!";

        return;
    }

    echo 'Your account has been activated. Log in to your account.';

    return;
});

The first step is to retrieve the activation record using the code parameter, then we grab the user using the user ID from the activation record, and the last part is similar to the first method. You can read more about activation in the documentation.
I prefer using the second method for the activation process and I always avoid sending unnecessary details to the user.

Login Page

The login page contains an email field, password, and the user can choose to be remembered.

// app/routes.php

$app->get('/login', function () use ($app) {

    $app->twig->display('login.html.twig');
});

After getting the user email and password we can pass them to the authenticate method. It may throw an exception depending on the error (ThrottlingException, NotActivatedException, etc). You can read more about the authenticate method in the documentation. However, you may have a remember me field to automatically log the user in the future. The authenticateAndRemember method is similar to the authenticate method and sets the remember me parameter to true by default.

// app/routes.php

$app->post('/login', function () use ($app) {
    $data = $app->request->post();
    $remember = isset($data['remember']) && $data['remember'] == 'on' ? true : false;

    try {
        if (!$app->container->sentinel->authenticate([
                'email' => $data['email'],
                'password' => $data['password'],
            ], $remember)) {

            echo 'Invalid email or password.';

            return;
        } else {
            echo "You're logged in";

            return;
        }
    } catch (Cartalyst\Sentinel\Checkpoints\ThrottlingException $ex) {
        echo "Too many attempts!";

        return;
    } catch (Cartalyst\Sentinel\Checkpoints\NotActivatedException $ex){
        echo "Please activate your account before trying to log in";

        return;
    }
});

The authenticate method will return the user on success or false on failure, but the important part is that it can throw an exception depending on the enabled checkpoints, which are like middleware that intercept the login process and decide if you can continue or not. In this case, we may have a ThrottlingException in case of abusive login attempts or a NotActivatedException if the user has not been activated yet. You can configure the enabled checkpoints inside vendor/cartalyst/sentinel/src/config in the checkpoints section.

Logout

Logging out the current user is really straightforward.

// app/routes.php

$app->get('/logout', function () use ($app) {
    $app->container->sentinel->logout();

    echo 'Logged out successfully.';
});

Admin Page

Our admin page doesn’t contain any view content. We are just going to test if the user is connected, get the logged in user and then test if he has the required permissions to access the page.

// app/routes.php

$app->get('/admin/users', function () use ($app) {
    $loggedUser = $app->container->sentinel->check();

    if (!$loggedUser) {
        echo 'You need to be logged in to access this page.';

        return;
    }

    if (!$loggedUser->hasAccess('user.*')) {
        echo "You don't have the permission to access this page.";

        return;
    }

    echo 'Welcome to the admin page.';
});

The check method returns the logged in user or false. If we have a user, we call the hasAccess method with the required permissions. The * alias selects any permission. You can use user.update or user.delete depending on the required permission to access the resource.

Wrapping Up

The Sentinel package takes out the pain of user permission and role management. It also offers the ability to add in password reminders, password hashers, etc. Be sure to check the documentation for more details, look at the final product on Github, and if you have any questions or comments, don’t hesitate to post them below.