DEV Community

Cover image for Rely on Laravel's Authorization as much as possible
Anwar
Anwar

Posted on • Edited on

Rely on Laravel's Authorization as much as possible

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

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"]);
});
Enter fullscreen mode Exit fullscreen mode

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,
    // ...
  ];
}
Enter fullscreen mode Exit fullscreen mode

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.");
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  ];
}
Enter fullscreen mode Exit fullscreen mode

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"]);
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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");
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.");
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]);
  ]);
Enter fullscreen mode Exit fullscreen mode
- 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"]);
  });
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo 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
});
Enter fullscreen mode Exit fullscreen mode

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)