88
Php

Laravel – 5.0 ACL Using Middleware

Well, actually building an ACL (Access Control Layer) in Laravel is really very easy than using a third party package and I always prefer my own implementation. In Laravel - 4x we’ve used Route Filter mechanism to build an ACL but in Laravel - 5.0 now we have Middleware instead of Filter and it’s much better, IMO.

The idea behind the ACL is that, we want to protect our routes using user roles and permissions and in this case the underlying mechanism is quite same in both versions but only difference is that, in Laravel - 5.0 the Middleware is the layer (for filtering) between our routes and the application, while in Laravel - 4x the Route Filter was used for filtering the requests before the user gets into the application. In this article, I’ll show how it’s easily possible to implement an ACL from the scratch using Middleware.

It’s possible to implement ACL in different ways but this is what I’ve used in Laravel - 4x and also in Laravel - 5.0 and this could be enhanced or improved but approach may varies but the idea is same, filtering user requests before entering into the application layer.

To implement this, we need 4 tables:

  • 1. users
  • 2. roles
  • 3. permissions
  • 4. permission_role (Pivot Table)

Database migration for users table:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateUsersTable extends Migration {

    /**
    * Run the migrations.
    *
    * @return void
    */
    public function up()
    {
        Schema::create('users', function(Blueprint $table)
        {
            $table->increments('id')->unsigned();
            $table->integer('role_id')->unsigned();
            $table->string('email')->unique();
            $table->string('password');
            $table->string('first_name');
            $table->string('last_name');
            $table->rememberToken();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
    * Reverse the migrations.
    *
    * @return void
    */
    public function down()
    {
        Schema::drop('users');
    }
}

Database migration for roles table:

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateRolesTable extends Migration {

    /**
    * Run the migrations.
    *
    * @return void
    */
    public function up()
    {
        Schema::create('roles', function(Blueprint $table)
        {
            $table->increments('id');
            $table->string('role_title');
            $table->string('role_slug');
        });
    }
    
    /**
    * Reverse the migrations.
    *
    * @return void
    */
    public function down()
    {
        Schema::drop('roles');
    }
}

Database migration for permissions table:

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePermissionsTable extends Migration {

    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('permissions', function(Blueprint $table)
        {
            $table->increments('id');
            $table->string('permission_title');
            $table->string('permission_slug');
            $table->string('permission_description')->nullable();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::drop('permissions');
    }

}

Database migration for permissions_role table:

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePermissionRoleTable extends Migration {

  /**
   * Run the migrations.
   *
   * @return void
   */
   public function up()
   {
       Schema::create('permission_role', function(Blueprint $table)
       {
           $table->increments('id');
           $table->integer('permission_id');
           $table->integer('role_id');
        });
    }

   /**
    * Reverse the migrations.
    *
    * @return void
    */
    public function down()
    {
        Schema::drop('permission_role');
    }
}

These tables are required to build the ACL fields could be changed (add/remove) but to build the relationship between tables we need foreign keys and we can’t remove those fields such as role_id in users table and the pivot table is also necessary as it is.

Now, we need to create the middleware class to check the user permissions and we can create it using php artisan make:middleware CheckPermission from command line/terminal. This will create a skeleton of a middleware class in app/Http/Middleware directory as CheckPermission.php and now we need to edit that class as given below:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Contracts\Routing\Middleware;

class CheckPermission implements Middleware {

  /**
   * Handle an incoming request.
   *
   * @param  \Illuminate\Http\Request  $request
   * @param  \Closure  $next
   * @return mixed
   */
   public function handle($request, Closure $next)
   {
       if ($this->userHasAccessTo($request)) {
           view()->share('currentUser', $request->user());
           return $next($request);
       }

       return redirect()->route('home');
   }

  /* 
  |--------------------------------------------------------------------------
  | Additional helper methods for the handle method
  |--------------------------------------------------------------------------
  */

  /**
   * Checks if user has access to this requested route
   * 
   * @param  \Illuminate\Http\Request  $request
   * @return Boolean true if has permission otherwise false
   */
   protected function userHasAccessTo($request)
   {
      return $this->hasPermission($request);
   }

   /**
    * hasPermission Check if user has requested route permimssion
    * 
    * @param  \Illuminate\Http\Request $request
    * @return Boolean true if has permission otherwise false
    */
    protected function hasPermission($request)
    {
        $required = $this->requiredPermission($request);
        return !$this->forbiddenRoute($request) && $request->user()->can($required);
    }

  /**
   * Extract required permission from requested route
   * 
   * @param  \Illuminate\Http\Request  $request
   * @return String permission_slug connected to the Route
   */
   protected function requiredPermission($request)
   {
       $action = $request->route()->getAction();
       return isset($action['permission']) ? explode('|', $action['permission']) : null;
   }

   /**
    * Check if current route is hidden to current user role
    * 
    * @param  \Illuminate\Http\Request $request
    * @return Boolean true/false
    */
    protected function forbiddenRoute($request)
    {
        $action = $request->route()->getAction();
        if (isset($action['except'])) {
            return $action['except'] == $request->user()->role->role_slug;
        }

        return false;
    }
}

Now, we need to create other classes (Eloquent Model) in app/DB directory. Here, in Laravel - 5.0 the models directory is not available and by default the app directory contains the Eloquent model classes such as User but I’ve created the DB directory to house all of my Eloquent/Fluent classes but it’s not mandatory. Anyways, let’s create those classes (User, Role and Permission) now in app/DB or just in app (The full path of User class must be given in the config/Auth.php file).

namespace App\DB\User;




use Illuminate\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use App\Http\Requests\Auth\RegisterRequest;
use App\DB\User\Traits\UserACL;
use App\DB\User\Traits\UserAccessors;
use App\DB\User\Traits\UserQueryScopes;
use App\DB\User\Traits\UserRelationShips;

class User extends Model implements AuthenticatableContract, CanResetPasswordContract {

    use Authenticatable, CanResetPassword;

    /**
     * Application's Traits (Separation of various types of methods)
     */

     use UserACL, UserRelationShips;
}

The traits are used to separate the code in User class to keep the code clean and easily maintainable, (app/DB/User/Traits/UserACL.php trait):

namespace App\DB\User\Traits;

trait UserACL {

   /**
    * can Checks a Permission
    * 
    * @param  String $perm Name of a permission
    * @return Boolean true if has permission, otherwise false
    */
    public function can($perm = null)
    {
        if($perm) {
            return $this->checkPermission($this->getArray($perm));
        }

        return false;
    }

   /**
    * Make string to array if already not
    * 
    * @param  Mixed $perm String/Array
    * @return Array
    */
   protected function getArray($perm)
   {
       return is_array($perm) ? $perm : explode('|', $perm);
   }

   /**
    * Check if the permission matches with any permission user has
    *
    * @param  Array $perm Name of a permission (one or more separated with |)
    * @return Boolean true if permission exists, otherwise false
    */
   protected function checkPermission(Array $permArray = [])
   {
       $perms = $this->role->permissions->fetch('permission_slug');
       $perms = array_map('strtolower', $perms->toArray());
       return count(array_intersect($perms, $permArray));
   }

   /**
    * hasPermission Checks if has a Permission (Same as 'can')
    * 
    * @param  String $perm [Name of a permission
    * @return Boolean true if has permission, otherwise false
    */
   public function hasPermission($perm = null)
   {
       return $this->can($perm);
    }

   /**
    * Checks if has a role
    * 
    * @param  String $perm [Name of a permission
    * @return Boolean true if has permission, otherwise false
    */
    public function hasRole($role = null)
    {
        if(is_null($role)) return false;
        return strtolower($this->role->role_slug) == strtolower($role);
    }

   /**
    * Check if user has given role
    * 
    * @param  String $role role_slug
    * @return Boolean TRUE or FALSE
    */
    public function is($role)
    {
        return $this->role->role_slug == $role;
    }

    /**
     * Check if user has permission to a route
     * 
     * @param  String $routeName
     * @return Boolean true/false
     */
    public function hasRoute($routeName)
    {
        $route = app('router')->getRoutes()->getByName($routeName);
        if($route) {
            $action = $route->getAction();
            if(isset($action['permission'])) {
                $array = explode('|', $action['permission']);
                return $this->checkPermission($array);
            }
        }

        return false;
    }

   /**
    * Check if a top level menu is visible to user
    * 
    * @param  String $perm
    * @return Boolean true/false
    */
    public function canSeeMenuItem($perm)
    {
        return $this->can($perm) || $this->hasAnylike($perm);
    }

   /**
    * Checks if user has any permission in this group
    * 
    * @param  String $perm Required Permission
    * @param  Array $perms User's Permissions
    * @return Boolean true/false
    */
    protected function hasAnylike($perm)
    {
        $parts = explode('_', $perm);
        $requiredPerm = array_pop($parts);
        $perms = $this->role->permissions->fetch('permission_slug');
       
        foreach ($perms as $perm)
        {
            if(ends_with($perm, $requiredPerm)) return true;
        }
       
        return false;
    }

}

The app/DB/User/Traits/UserRelationShips.php trait (for relationship methods):

namespace App\DB\User\Traits;

trait UserRelationShips {

    /**
     * role() one-to-one relationship method
     * 
     * @return QueryBuilder
     */
     public function role()
     {
         return $this->belongsTo('App\DB\Role');
     }
}

The Role class (app/DB/Role.php):

namespace App\DB;

use Illuminate\Database\Eloquent\Model;

class Role extends Model {

    /**
     * users() one-to-many relationship method
     * 
     * @return QueryBuilder
     */
     public function users()
     {
         return $this->hasMany('App\DB\User\User');
     }

    /**
     * permissions() many-to-many relationship method
     * 
     * @return QueryBuilder
     */
     public function permissions()
     {
         return $this->belongsToMany('App\DB\Permission');
     }
}

The Permission class (app/DB/Permission.php):

namespace App\DB;

use Illuminate\Database\Eloquent\Model;

class Permission extends Model {

    /**
     * roles() many-to-many relationship method
     * 
     * @return QueryBuilder
     */
     public function roles()
     {
         return $this->belongsToMany('App\DB\Role');
     }
}

Now, before we can use our Middleware in any route declaration, we need to add it it in the app/Http/Kernel.php file and by default, there are already other middlewares added in that file by Laravel in the $routeMiddleware array and it looks like this:

/**
 * The application's route middleware.
 *
 * @var array
 */
protected $routeMiddleware = [
    'auth' => 'App\Http\Middleware\Authenticate',
    'auth.basic' => 'Illuminate\Auth\Middleware\AuthenticateWithBasicAuth',
    'guest' => 'App\Http\Middleware\RedirectIfAuthenticated',
];

We’ll just add our middleware at the end of this array like this:

'acl' => 'App\Http\Middleware\CheckPermission',

The acl is the alias which we’ll use in our routes when declaring the routes for limited access, for example take a look at this app/Http/routes.php file:

// Home Page URI (not protected)
$router->get('/', ['uses' => 'HomeController@index', 'as' => 'home']);

// Protected Routes by auth and acl middleware
$router->group(['prefix' => 'admin', 'namespace' => 'Admin', 'middleware' => ['auth', 'acl']], function() use ($router)
{
	$router->get('dashboard', [
		'uses' => 'DashboardController@index',
		'as' => 'dashboard',
		'permission' => 'manage_own_dashboard',
		'menuItem' => ['icon' => 'fa fa-dashboard', 'title' => 'Dashboard']
	]);

    // Group: Users
    $router->group(['prefix' => 'users', 'namespace' => 'User'], function() use ($router)
    {
        $router->get('/{role?}', [
            'uses' => 'UserController@index',
            'as' => 'admin.users',
            'permission' => 'view_user',
            'menuItem' => ['icon' => 'clip-users', 'title' => 'Manage Users']
        ])->where('role', '[a-zA-Z]+');

        $router->get('view/{id}', [
            'uses' => 'UserController@viewUserProfile',
            'as' => 'admin.user.view',
            'permission' => 'view_user'
        ]);
    });
});

In this file, all of our routes are protected and requires the user to stay logged in (auth is used to check whether the user is logged in or not, available by default in Laravel) and the acl will check if the user has a given permission or not, for example, the dashboard url/route requires the permission manage_own_dashboard because it has 'permission' => 'manage_own_dashboard' and in our middleware we’ll check if the route has the key permission in it’s action and if the value of permission key (which is a permission) is available in the currently logged in users role permissions list then we’ll allow the user to access the application, otherwise we’ll disallow the user access.

On the time, when this article is being written, the Laravel-5.0 framework isn’t released and after the final release, things may change so please make sure you check the updates on Laravel website and hoping, in the first week of January’15, Laravel-5.0 will be released.

How it works?

Actually, this is just a demonstration of only the ACL but I didn’t provide any code which provides an user interface that let’s the admin a way to create user roles by attaching the permissions but only the core idea to implement the ACL in the application.

So, a brief idea is that, each user will be assigned a role when user account is created from the front-end or by the admin (a default role could be set from the back-end) and the roles will be created by admin (super user) from the back-end and permissions will be attached to each roles. So, a role for example Moderator which could have a permission as Suspend User (permission title) and suspend_user (permission_slug) so we can attach suspend_user in a Route using something like this:

$router->get('user/suspend/{id}', [
    'uses' => 'UserController@closeUserAccount',
    'as' => 'admin.user.suspend',
    'permission' => 'suspend_user',
    'middleware' => ['acl']
]);

So, this route requires the permission suspend_user and in the acl middleware we can check the user’s permission and can protect the route if the user doesn’t has that permission. That’s it.

In this article, I tried discussed how to organize the tables and classes and how to filter the requests using middleware and what classes could be required but not full implementation of a fully functional system, it just gives an abstract idea to create an ACL functionality using Laravel - 5 Middleware from the scratch (without using any third party package).

Since the Laravel-5.0 is still under development, so I didn’t provide the fully functional code here and once the framework is released, then I’ll write another post, maybe in two parts as a series with fully functional code for building an ACL from the scratch, but for now, that’s all. Thank you.

Update:

In my code example, I’ve used menuItem as a key in route declaration. This key is used to build a dynamic menu and to build the menu I’ve created a class/library which is located at app/Libs/Navigation folder of my project. The menu builder class is given below:

namespace App\Libs\Navigation;

use Illuminate\Support\Arr;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;

class Builder {

    private $menuItems = [];
    private $routes = NULL;
    private $user = NULL;

    public function __construct(Arr $arr, Request $request, Router $router)
    {
        $this->arr = $arr;

        $this->request = $request;

        $this->user = $request->user();

        $this->router = $router;

        $this->routes = $router->getRoutes();
    }

    /**
     * Extract all top level menu items from routes
     * 
     * @return Array A multi-dementional array
     */
     public function build()
     {
         foreach ($this->routes->getIterator() as $it) {

             $action = $it->getAction();

             if(!$this->forbidden($action) && $item = $this->arr->get($action, 'menuItem')) {

                 $routeName = $this->arr->get($action, 'as');

                 $this->menuItems[$routeName] = array(
                     'icon' => @$item['icon'],
                     'title' => @$item['title']
                 );
             }
             
         }
    
         return $this;
     }

    /**
     * Check if current route is hidden to current user role
     * 
     * @param  \Illuminate\Http\Request $request
     * @return Boolean true/false
     */
     protected function forbidden($action)
     {
         if(isset($action['except'])) {
             return $action['except'] == $this->user->roles->fetch('role_slug');
         }

         return false;
     }

    /**
     * Render all HTML li tags
     * 
     * @param  Array $menuItems
     * @param  string $itemView View name to generate a single li
     * @return HTML li items as String
     */
     public function render($itemView = 'admin.layouts.partials.content.navLiTemplate')
     {
         $listElements = [];

         foreach ($this->menuItems as $routeName => $itemArray) {

             $listElements[] = $this->getListItem($routeName, $itemArray);
         }

         return join($listElements, '');
     }

    /**
     * Build a menu item by checking the permissions.
     * 
     * @param  String $routeName Name of the route to generate link
     * @param  Array $itemArray List of route's menuItem array
     * @return HTML li Element as menu item
     */
     protected function getListItem($routeName, $itemArray)
     {
         $action = $this->routes->getByName($routeName)->getAction();

         $permission = $this->arr->get($action, 'permission');

         if((!empty($permission) && $this->user->canSeeMenuItem($permission)) || empty($permission)) {

            $except = $this->arr->get($action, 'except');

            if((!isset($except) || (isset($except) && !count($except))) || (isset($except) && !$this->user->is($except))) {

                $data = [

                    'link' => route($routeName),
                    'active' => $this->isActive($routeName),
                    'title' => $this->getTitle($itemArray['title']),
                    'icon' => $itemArray['icon'],
                    'id' => str_replace(' ', '-', strtolower($itemArray['title']))
                 ];

                return view('admin.layouts.partials.content.navLiTemplate', $data);
             }
         }

    }

    /**
     * Mark active menu item
     * 
     * @param  String $routeName Route Name
     * @return HTML Attribute or NULL
     */
     protected function isActive($routeName)
     {
         return $this->router->getCurrentRoute()->getName() == $routeName ? 'active' : NULL;
      }

}

Further, to invoke the menu builder class I’ve created a helper function in a helper file which is app/Helpers/fucntions.php and the function is given below:

/**
 * Build the top level navigation
 *
 * @return HTML list items (li) as string
 */
function renderMenu()
{
     return app('App\Libs\Navigation\Builder')->build()->render();
}

Finally, I’ve called the function from my view where I wanted to show the menu and the view is a partial of my admin layout which contains following code:



Stay in touch, full (improved code) will be uploaded on github later.


Update: A follow-up of this article for Laravel-5.1.x posted here.

Latest Blog

0
Php

PHP – 8.0 Match Expression

In PHP 8.0 there is a new feature or I should say a new language construct (keyword) going to be introduced,  which has been implemented depending […]