Hi and welcome to this article where I will show you the benefit of Laravel's Authorization features and why it is important to stick to it as much as possible for everything related to access control.
Summary
- Authorization preview
- Example: adding another authorization layer
- The mistake: mixing authorization layers
- Conclusion
Authorization preview
Laravel offer an elegant way to perform access control verification within your app.
Let us say you build a tech news platform where your users can contribute by posting articles. Your users can create and edit blog posts.
The route to edit a blog post is GET /post/{post}/edit
and the route to save the changes is PUT /post/{post}
.
Now let us imagine a malicious user have found the id of the blog post of another user, and want to edit it on behalf of the author. You want to prevent this from happening and here is how you can do it with Laravel's policies.
Route::middleware("can:edit,post")->group(function() {
Route::get("post/{post}/edit", [PostController::class, "edit"]);
Route::put("post/{post}", [PostController::class, "update"]);
});
As you can see, we instructed Laravel to protect our routes by checking if the user can edit posts (can:edit,post
). Here can
is a pre-registered middleware. You can view it on your route kernel file at "app/Http/Kernel.php":
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
protected $middlewareAliases = [
// ...
"can" => \Illuminate\Auth\Middleware\Authorize::class,
// ...
];
}
This middleware assumes you either registered a Policy (which attach to an Eloquent model) or a Gate (which can be attached to anything).
Most of the time I write authorization around models, so I like to use Policies as much as possible. Here is the policy that would prevent other person than the author from editing a blog post:
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class PostPolicy
{
public function update(User $user, Post $post): Response
{
if ($post->author->is($user)) {
return $this->allow();
}
return $this->deny("Only the author can edit this post.");
}
}
Finally, policies require you to register them to tell Laravel how it should link them automatically when using the Route middleware syntax.
On your file at "app/Providers/AuthServiceProvider.php":
namespace App\Providers;
use App\Models\Post;
use App\Policies\PostPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
Post::class => PostPolicy::class,
];
}
Example: adding another authorization layer
Now let us imagine your tech news platform have a back office. You can get various statistics about how your app performs, you can view the blog post count, and other anaylitcs.
You only allow your team mates to browse this back office so you decide to use a permission system like spatie/laravel-permission.
For the moment you use two roles, "admin" and "manager". Admins can view all the back office and change the color of the website, but manager can only views pages without ability to change anything.
You protect these routes using the provided "role" middleware of this package:
Route::middleware("permission:view admin")->group(function() {
Route::get("admin", [AdminController::class, "index"]);
]);
Route::middleware("permission:update settings")->group(function() {
Route::get("admin/settings", [AdminSettingsController::class, "edit"]);
Route::put("admin/settings", [AdminSettingsController::class, "update"]);
});
And on your layout, you only want to display the admin button for admins and managers:
<ul id="menu">
@if(user()->hasPermissionTo("view admin"))
<li>
<a href="{{ route("admin.index") }}">Admin</a>
</li>
@endif
</ul>
The mistake: mixing authorization layers
For the moment our system is fine. Now we imagine admins have the ability to suspend accounts. You have added a flag on your user model for this:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean("suspended")->default(false);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn("suspended");
});
}
};
Naturally, you will create a Policy to handle it:
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class UserPolicy
{
public function before(User $user): Response
{
if ($$user->suspended) {
return $this->deny("Your account has been suspended");
}
return null;
}
}
Now we hit a roadblock: our authorization logic is now spread across two systems, permissions and policies. Let us fix this by keeping the UserPolicy and ditching any mention to permissions outside this policy:
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class UserPolicy
{
public function before(User $user): ?Response
{
if ($$user->suspended) {
return $this->deny("Your account has been suspended");
}
return null;
}
public function viewAdmin(User $user): Response
{
if ($user->hasPermissionTo("view admin")) {
return $this->allow();
}
return $this->deny("You do not have the permission to view this page.");
}
public function editSettings(User $user): Response
{
if ($user->hasPermissionTo("edit settings")) {
return $this->allow();
}
return $this->deny("You do not have the permission to edit settings.");
}
}
Then, let us change the routes to rely on Laravel's authorization system instead:
- Route::middleware("permission:view admin")->group(function() {
+ Route::middleware("can:viewAdmin")->group(function() {
Route::get("admin", [AdminController::class, "index"]);
]);
- Route::middleware("permission:update settings")->group(function() {
+ Route::middleware("can:editSettings")->group(function() {
Route::get("admin/settings", [AdminSettingsController::class, "edit"]);
Route::put("admin/settings", [AdminSettingsController::class, "update"]);
});
And finally, let us update the layout to use @can
instead of @if()
<ul id="menu">
- @if(user()->hasPermissionTo("view admin"))
+ @can("viewAdmin")
<li>
<a href="{{ route("admin.index") }}">Admin</a>
</li>
- @endif
+ @endcan
</ul>
Conclusion
By sticking to the built-in capabilities of Laravel, you ensure your app is easy to maintain and to evolve in the future.
If tomorrow you want to replace "spatie/laravel-permission" by another system (or building your own), the only part of the code you will change is the ÙserPolicy
.
In the first part of this article I voluntarily open a nasty security breach by assuming users could discover the blog post edit route. In reality, you want to make this hard and I advice to expose UUIDs or any hard to predict identifier in your URLs instead of /post/34/edit
. I have created a package to help you do the least possible while securing your route. Check it out.
khalyomede / laravel-eloquent-uuid-slug
Use auto generated UUID slugs to identify and retrieve your Eloquent models.
Laravel Eloquent UUID slug
Summary
About
By default, when getting a model from a controller using Route Model Binding, Laravel will try to find a model using the parameter in your route, and associate it to the default identifier of the related table (most of the time, this is the "id" key).
// routes/web.php
use App\Models\Cart;
use Illuminate\Support\Facades\Route;
// --> What you see
Route::get("/cart/{cart}", function(Cart $cart) {
// $cart ready to be used
});
// --> What happens behind the scene
Route::get("/cart/{cart}", function(string $identifier) {
$cart = Cart::findOrFail($identifier);
// $cart ready to be used
});
This means if you…
I hope this article helped you clarify the goal of Laravel Authorization and will help you make your app more robust. Please let me know your thoughts in the comments!
Happy access control!
Cover image by Peter Olexa.
Top comments (0)