Laravelの認可、ゲートとポリシーを使ったルーティングベースのシンプルな実装

Laravelで開発していて、認証(Authentication)はすぐに理解出来ても、認可(Authorization)はなかなか理解出来ませんでした。
自分の書き方と合ってないのか、どう認可を使えばいいのかわからなかった。
使っていくうちにだんだんと慣れてきましたが、それでも難しい。
そこで今回は、自分の知識の再確認のため、あるAPIを実行出来る権限がログインユーザにあるかどうかを、認可(ゲートとポリシー)を使って実装してみました。

https://laravel.com/docs/6.x/authorization
https://readouble.com/laravel/6.x/ja/authorization.html

使うLaravelのバージョンは6.8.0です。

# システムの仕様

- ユーザは権限を持つ。
- APIは権限に対して認可されないと実行出来ない。
- リレーションは`user -- role -< authorizations`。

# データベース

`users`テーブルには`user_role_id`カラムがある。

`user_roles`テーブル

| id | name |
|:-----:|:-----|
| 1 | 管理者 |
| 2 | スタッフ |

`user_role_authorizations`テーブル

| id | user_role_id | route_name |
|:-----:|-----:|:-----|
| 1 | 1 | user_index |
| 2 | 1 | user_create |
| 3 | 2 | user_index |

# テスト

以下の2つのテストが成功すれば完了とします。
上の表で、スタッフは`user_create`への権限がありません。
したがって`tests/Feature/UserCreateTest.php`において、スタッフで`user_create`にAPIリクエストして403エラーが返ってくれば成功というわけです:thumbsup:

```tests/Feature/UserIndexTest.php 
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
use App\User;
use App\UserRole;

class UserIndexTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seed();
    }

    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function testAdmin()
    {
        $user = factory(User::class)->create(['user_role_id' => UserRole::ADMIN_ROLE_ID]);

        $response = $this->actingAs($user)->getJson('/api/user');

        $response->assertStatus(200);
    }

    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function testStaff()
    {
        $user = factory(User::class)->create(['user_role_id' => UserRole::STAFF_ROLE_ID]);

        $response = $this->actingAs($user)->getJson('/api/user');

        $response->assertStatus(200);
    }
}
```

```tests/Feature/UserCreateTest.php 
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
use App\User;
use App\UserRole;

class UserCreateTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seed();
    }

    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function testAdmin()
    {
        $user = factory(User::class)->create(['user_role_id' => UserRole::ADMIN_ROLE_ID]);

        $response = $this->actingAs($user)->getJson('/api/user/create');

        $response->assertStatus(405);

        $response = $this->actingAs($user)->postJson('/api/user/create');

        $response->assertStatus(200);
    }

    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function testStaff()
    {
        $user = factory(User::class)->create(['user_role_id' => UserRole::STAFF_ROLE_ID]);

        $response = $this->actingAs($user)->postJson('/api/user/create');

        // これが重要
        $response->assertStatus(403);
    }
}
```

# ゲートとポリシー

まずポリシーを作ります。
ここで作る`allow()`メソッドは引数としてログインしたユーザを受け取ります（メソッド名はなんでも大丈夫）。
返り値は`Response::allow()`で許可、`Response::deny()`で拒否です。

```sh
$ php artisan make:policy AllowRequestApi
```

```app/Policies/AllowRequestApi.php 
<?php

namespace App\Policies;

use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
use Route;
use Auth;

class AllowRequestApi
{
    use HandlesAuthorization;

    /**
     * Create a new policy instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * ログインユーザの権限をチェック
     *
     * @param User $user
     * @return Response
     */
    public function allow(User $user): Response
    {
        $route_name = Route::currentRouteName();

        if (!Auth::check()) {
            return Response::deny('権限がありません。');
        }

        $allowed = (bool)Auth::user()->role->authorizations->where('route_name', $route_name)->count();

        if ($allowed) {
            return Response::allow();
        } else {
            return Response::deny('権限がありません。');
        }
    }
}
```

作ったポリシーをサービスプロバイダで登録します。
このときに`Gate::define()`を使います。

```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
{
    /**
     * The policy mappings for the application.
     *
     * @var array
     */
    protected $policies = [
        // 'App\Model' => 'App\Policies\ModelPolicy',
    ];

    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        Gate::define('allow-request-api', 'App\Policies\AllowRequestApi@allow');
    }
}
```

登録したので、次はそのポリシーをルーティングで使います。
ミドルウェアの`'can:allow-request-api'`がそれです。不思議な述語になってしまったのは残念ですが。。。

```routes/web.php 
<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

Route::prefix('api')->middleware(['auth', 'can:allow-request-api'])->group(function () {
    Route::prefix('user')->group(function () {
        Route::get('/', 'UserIndex')->name('user_index');
        Route::post('create', 'UserCreate')->name('user_create');
    });
});
```

APIの説明は省略します。
1ファイル1メソッドのAPI（コントローラ）を作りたい場合は、`--invokable`オプションで作れます。

```sh
$ php artisan make:controller UserIndex --invokable
$ php artisan make:controller UserCreate --invokable
```

# テスト実行

```sh
$ vendor/bin/phpunit tests/Feature/UserIndexTest.php 
PHPUnit 8.5.1 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 597 ms, Memory: 24.00 MB

OK (2 tests, 2 assertions)
$ vendor/bin/phpunit tests/Feature/UserCreateTest.php 
PHPUnit 8.5.1 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 188 ms, Memory: 24.00 MB

OK (2 tests, 3 assertions)
```

成功しました:beer:

# まとめ

認可は他にも様々な書き方があります。
今回はルーティングベースの認可でしたが、モデルベースの認可もあります。
奥が深いです。
最高の認可を求める旅はまだまだ終わりません:bike::red_car::sailboat::airplane::rocket: