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
.
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.
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.