Tìm hiểu về Gates và Policies trong Laravel

Chào mọi người, hôm nay mình trở lại với 1 bài blog mới nhưng vẫn về chủ đề Laravel. :D Từ trước đến giờ dùng Laravel mình hay tự custom phần Authorization, tuy nhiên Laravel cũng đã hỗ trợ rất tốt phần này với 2 khái niệm Gates và Policies (các ban có thể đọc tại đây). Mình sẽ cố gắng diễn đạt lại 1 cách khái quát nhất, theo tìm hiển của mình. Hôm nay chúng ta cùng đi tìm hiểu về hai phần này nhé.

Đối tượng bài viết này là những người đã có basic về Laravel và đang muốn tìm hiểu thêm về Authorization có sẵn giống mình. Nếu có sai sót gì mong nhận được ý kiến từ các bạn.

I. Khái niệm cơ bản

Có một vài bạn chắc chưa hiểu rõ về khái niệm Authorization, để tránh bị nhầm lẫn, để mình phân biệt lại 1 lần nữa:

  • Authorization và Authentication là 2 khái niệm hoàn toàn khác nhau. facade Auth mà bạn hay sử dụng trong Laravel là Authentication. Dùng để validate thông tin uỷ quyền (credentials) được lưu trữ trong database. Nếu quá trình validate này thất bại, HTTP status code sẽ là 401.
  • Authorization , trái lại, nghĩa là bạn có được cho phép để thực hiện 1 tác vụ nào đó trong hệ thống hay không. Hiểu đơn giản là chẳng hạn bạn là nhân viên thì bạn không có quyền hạn của sếp cho đơn giản :D  Đấy chính là Authorization. Nếu quá trình authorize thất bại, HTTP status code là 403.
  • Quá trình Authorization luôn luôn diễn ra sau Authentication. Cái này 100% đúng, không có ngoại lệ. Nếu hệ thống không xác thực được chúng ta là ai thì làm sao biêt được chúng ta có đủ quyền để thực hiện tác vụ đó hay không.

II. Các phương thức để thực hiện Authorization

Trong Laravel, có 2 cách để authorize 1 người dùng: closure-based và class-based. Gates là cách sử dụng closure-based và Policies thì ngược lại, sử dụng class-based.

1. Gates (closure-based)

a. Định nghĩa, cách sử dụng

Để dùng Authorization theo Closure-based trong Laravel, chúng ta sử dụng Gates . Các proxy của Gates sau đó sẽ gọi tới Illuminate/Auth/Access/Gate.php. Mặc định thì Gate được khai báo trong method boot của file App\Providers\AuthServiceProvider sử dụng facade Gate. Theo cách này, chúng ta sẽ sử dụng method define để khai báo 1 authorization mới với 2 tham số:  

  • Tham số thứ nhất là ability (khả năng), là một reference để phân quyền người dùng sau này.
  • Tham số thứ hai chính là closure, aka là một anonymous function. Và closure này luôn luôn có thể lấy ra được người dùng đang đăng nhập hiện tại ở tham số của anonymous function:
use Illuminate\Support\Facades\Gate;
...

public function boot()
{
    ....
 
    Gate::define('create-post', function ($user) {
    return $user->id == 1;
});
}

Trong ví dụ trên, chúng ta định nghĩa 1 Gate, trong đó không ai khoác ngoài chỉ user id = 1 mới có quyền để tạo post.

Để check xem người dùng đang đăng nhập có quyền tạo post hay không, ở mọi chỗ, chúng ta chỉ cần gọi:

Gate::allows('create-post');

Hàm này sẽ check và trả về một giá trị boolean xem người dùng có quyền hay không. Rất đơn giản, phải không. Với cách sử dụng Gates này,  chúng ta có thể định nghĩa bao nhiêu Gates mà chúng ta muốn cũng được:

use Illuminate\Support\Facades\Gate;
...

public function boot()
{
    ....
 
    Gate::define('edit-post', function ($user, $post) {
    	return $user->id == $post->user_id;
	});

	Gate::define('delete-post', function ($user, $post) {
    	return $user->id == $post->user_id;
    });
});
}
Gate::allows('delete-post', Post::find(10));
Gate::allows('edit-post', $post)

Khi chúng ta cần  thêm các tham số (ngoài $user) trong closure, Chúng ta cần phải truyền thêm đầy đủ ở bất cứ đâu chúng ta dùng nó. Nếu dùng chỉ 1 tham số $post như ví dụ trên, chúng ta chỉ cần truyền vào 1 tham số. Tuy nhiên, trong trường hợp mà truyền  nhiều hơn 1 tham sốthì hãy sử dụng array nhé.

Gate::allows('gate-name', [$param1, $param2]);
b. Các method cơ bản

Ngoài 2 method defineallows, Gates còn sử dụng nhiều method khác, một số method cơ bản:

  • denies — Ngược lại với allows, check xem người dùng không có quyền hay không.
bool denies(string $ability, array|mixed $arguments = [])
  • check — check 1 group các abilitycó thoả mãn không, nếu thoả mãn hết thì mới được grant quyền.
bool check(iterable|string $abilities, array|mixed $arguments = [])
  • any — check 1 group các ability, chỉ cần thoả mãn 1 gate là pass qua.
bool any(iterable|string $abilities, array|mixed $arguments = [])
  • none — Ngược lại với any, không thoả mãn tất cả trong 1 group ability là sẽ pass.
bool none(iterable|string $abilities, array|mixed $arguments = [])
  • authorize — check xem ability hoặc 1 group ability có được allow không,  trả về 1 Response không thoả mãn sẽ  throw lỗi Illuminate\Auth\Access\AuthorizationException .
Response authorize(string $ability, array|mixed $arguments = [])
c. Mở rộng

Trong nhiều trường hợp, bạn muốn check authorization của 1 user cụ thể, không phải là user đang đăng nhập thì chúng ta chỉ cần sử dụng  method forUser($userId) dưới đây:

Gate::forUser(User::find(10))->allows('edit-post', Post::find(20));

Đoạn code trên sẽ check xem user với id = 10 có quyền sửa post id = 20 hay không.

Nếu bạn gặp phải tình huống khi bạn cần phê duyệt tất cả Authorization cho một người dùng hoặc một người dùng sẽ có quyền đối với ability cụ thể, thì bạn có thể sử dụng phương thức before của Gates. Bạn có thể thêm bao nhiêu callback before cũng được, Nhưng nếu bất kỳ callback  before  của bạn trả về giá trị không phải null, thì nó sẽ lấy callback đó để authorize, và lúc đó Gate chính mà bạn định nghĩa sẽ  không được sử dụng nữa.  

use Illuminate\Support\Facades\Gate;
 
Gate::before(function ($user, $ability) {
    if ($user->isAdministrator()) {
        return true;
    }
});

Ví dụ trên, chúng ta muốn cấp quyền cho user là Admin có đủ mọi quyền luôn, không cần phải check các gate, thì thêm điều kiện trên ở before, nó sẽ dùng kết quả của hàm này chứ không dùng kết quả ở hàm Gate::define nữa.

Giống như before, chúng ta cũng có thêm bao nhiêu callback after cũng được. Nhưng sau khi before hoặc gate chính mà bạn định nghĩa không trả về giá trị nào,  thì after sẽ ghi đè lại giá trị gate đó.

Gate::after(function ($user, $ability, $result, $arguments) {
    if ($user->id == 1) {
        return true;
    }
});

Chẳng hạn sau khi check hết các gate rồi, user mà chúng ta đang check không được cho phép bất cứ quyền nào, tuy nhiên id = 1, là người dùng đầu tiên được ưu tiên gì đó chẳng hạn thì họ vẫn được cấp quyền cho gate đó trong hàm after. :D

Như ở trên, chúng ta define gate theo syntax:

Gate::define('ability', function (User $user){});

Ngoài ra, chúng ta còn có thể định nghĩa theo class, giống như sử dụng Controller trong web.php và api.php, chúng ta định nghĩa như sau:

<?php
namespace App;
class CustomPolicy 
{
    public function editPost (User $user, Post $post) {
        return $user->id === $post->user_id;
    }
}

sau đó add vào app/Providers/AuthServiceProvider.php

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;

class AuthServiceProvider extends ServiceProvider
{
    public function boot () {
      	...
        Gate::define('edit-post', 'App\CustomPolicy@editPost');
    }
}

2. Policies (Class-based)

a. Định nghĩa, cách sử dụng

Nếu Gates được sử dụng với là 1 anonymous function thì Policies là 1 class, trong đó sẽ viết code logic để authorize 1 model hay resource cụ thể.

Chúng ta có thể tạo Policies bằng command:

php artisan make:policy PostPolicy

file PostPolicy.php sẽ được tạo ra trong thư mục app/Policies sau đó chúng ta sẽ phải thêm method bằng tay. Việc này tốn khá nhiều thời gian. Chúng ta có thể định nghĩa thêm model vào câu lênh trên bằng cách thêm flag --model=Post vào câu lệnh trên. Các method sẽ được tự động generate ra. Rất tiện lợi,

Sau khi file PostPolicy.php được tạo ra, đừng quên update biến $policies trong AuthServiceProvider

protected $policies = [
   ...
   Post::class => PostPolicy::class,
];

Tuy nhiên không phải lúc nào chúng ta cũng phải đăng kí policy  một cách thủ công như vậy. Laravel có thể tự động nhận biết được sự liên kết giữa Policy và Model dựa trên tiêu chuẩn đặt tên mà họ định nghĩa ra (naming conventions), chẳng hạn:

  • Model (trong ví dụ của chúng ta là Post) phải ở trong thư mục app.
  • Policy phải nằm trong thư mục app/Policies.
  • Tên Policy đặt theo syntax {Model}Policy, trong ví dụ trên ta có model Post thì Policy phải là PostPolicy.

Cùng nhìn qua 1 ví dụ về file Policy nhé:

<?php

namespace App\Policies;
use App\User;
use App\Post;
class PostPolicy
{
    use HandlesAuthorization;

    public function create (User $user) {
        return true;
    }

    public function edit (User $user, Post $post) {
        return $user->id == $post->user_id;
    }

    public function update (User $user, Post $post) {
        return $user->id == $post->user_id;
    }

    public function delete (User $user, Post $post) {
        return $user->id == $post->user_id;
    }
}

Cũng giống như Gates, trong Policies, các method sẽ tự động lấy được người dùng đang đăng nhập theo mặc định. Bạn sẽ không cần phải truyền vào giá trị của user đó. Sau đó chúng ta sử dụng chíng Gate bằng syntax Gate::* method để check xem user đó có quyền thực hiện tác vụ đó hay không. Để check xem user có quyền edit không thì cú pháp sẽ như sau:

Gate::allows('edit', Post::find(20));

tham số thứ nhất edit chính là tên làm edit trong PostPolicy. và tham số thứ hai chính là param thứ 2 $post của hàm này.

b. Một vài lưu ý
  • Như ví dụ ở trên, trong PostPolicy, ta có method edit. Sau đó chúng ta sử dụng hàm đó bằng cách sử dụng  Gate::allows('edit', Post::find(20));. Có 1 lưu ý ở đây là nếu method của chúng ta viết dưới dạng camelCase, thì chúng ta sẽ  sử dụng camel-case như là tên ability cho Gate. Đó là quy chuẩn tên của policy. Đừng cố thay đổi nó để bug ngập mặt nhé :D
  • Như ở trên, chúng ta có quy chuẩn đặt tên giữa Model và Policy, nếu bạn không theo quy chuẩn đó thì bạn sẽ phải tự đăng kí nó thủ công và điều này rất mất thời gian và thử tưởng tượng đống Policy này nhiều lên xem, sẽ loạn lắm đấy.
  • Gate cũng có hệ thống cấp bậc để xử lý. Đầu tiên nó sẽ ưu tiên hơn cả là các method trong class Policy trước, rồi sau đó đến method được định nghĩa trong class khác (như CustomPolicy ở trong ví dụ phần Gate ở trên) rồi cuối cùng là đến các closure được viết dưới dang anonymous function. Vì vậy hãy ưu tiên cách viết nào phù hợp nhé.
  • nếu 1 ability không được định nghĩa, thì luôn luôn là unauthorize.
c. Mở rộng

Ở ví dụ với policy ở trên, chúng ta sử dụng cách đơn giản nhất là sử dụng Gate::*. ngoài ra còn rất nhiều cách khác.

Nếu chúng ta sử dụng class policy và  các method của nó chỉ cần validate $user object, không cần thêm tham số nào khác thì chúng ta có thể viết :

Gate::allows('create', Post::class);

Chúng ta cũng có thể check permission từ bước routes bằng cách sử dụng middleware:

// ---------------------------
Route::get('/edit/{post}, function (Post $post) {
    // code xử lí, phần này thường được viết trong controller
})->middleware('can:update,post');
// update - là ability
// post - là model, từ route parameter.
// sử dụng 'can' middleware, tham số của route và tham số của method phải giống nhau


// ---------------------------
Route::get('create', function () {
    // code xử lí, phần này thường được viết trong controller
})->middleware('can:create,\App\Post');
// giống ở trên
// dùng hàm create trong PostPolicy

Trong trường hợp Unauthorized,  lỗi sẽ được xử lý bởi app/Exceptions/Handler.php và sử dụng 403.blade.phpmặc định để hiển thị.

Ngoài ra có thể sử dụng cả việc check permission trong file blade.template của view:

@can('update', $post)
<!-- User can update post. -->
<!-- $post is the model, which refers to use PostPolicy::class -->
@endcan

@can('create', \App\Post::class)
<!-- User can create post. -->
<!-- \App\Post::class refers to use PostPolicy::class -->
@endcan

@cannot('create', \App\Post::class)
<!-- User cannot create post. -->
<!-- \App\Post::class refers to use PostPolicy::class -->
@endcannot

Hay là áp dụng trực tiếp vào ORM của Model.

User::find($id)->can('update', Post::find(20));
User::find($id)->cannot('update', Post::find(20));
User::find($id)->cant('edit', Post::find(20));
auth()->user()->can('delete', Post::find(20));

Gọi trực tiếp trong Controller.

$this->authorize('update', $post);

$this->authorize('create', Post::class);

III. Kết luận

Trên đây là 1 vài briefnote mình note lại trong quá trình tìm hiểu về các cơ chế Authorization của Laravel. Bài viết tuy hơi sơ sài nhưng hy vọng nó có thể giúp bạn hiển cơ bản về phân quyền trong ứng dụng Laravel. Hy vọng bạn có thể áp dụng hợp lý cách sử dụng Gates và Policies trong ứng dụng của mình sao cho thật hợp lý :D Cảm ơn các bạn đã đọc bài viết của mình. Hẹn gặp lại.

IV. References

https://laravel.com/docs/9.x/authorization