Laravel 8のAPI認証に認可を追加してみる - Policy - お試し
はじめに
経緯
Laravel 6で組んでいるAPIサーバーをLaravel 8で組みなおしてみるという一連の実証です。
こちらまでの流れで、Laravel Sanctumを使った、API認証の仕組みを組んでみました。
今回はこちらに、認可の仕組みを組んでみたいと思います。
認可について
ひとまず、認可について少しだけ。
と、その前に認証から。
組んでみたAPI認証ですが、少し細かく記述すると以下のような流れになります。
- 利用者は、ID・パスワードでユーザー認証を実施
- システムは、認証成功ユーザーに、APIトークンを発行し返信
- 利用者は、受け取ったAPIトークンを付与し、APIコール
- システムは、付与されたAPIトークンの持ち主(発行先ユーザー)を特定
- システムは、ユーザーを特定できた場合のみ、APIの処理を行い結果を返信
「認証」とは、いわば本人確認です。
上記の1では、ユーザーIDと本人しか知らないはずのパスワードで、本人確認を行いました。
一度本人確認ができれば、以降のやり取りは、何らかの情報でユーザーを特定し続けることで、毎回本人確認を行わなくてよくなります。
上記の流れは、ユーザーを特定し続ける情報として、APIトークンを発行(2)し、APIコールの都度、本人しか知らないAPIトークンを付与(3)してもらい、APIトークンによって本人確認を行う(4)、というものでした。
ここで、「本人確認は済んだから、後はご自由にどうぞ!」とはならず、ユーザーごとに「やっていいこと」、「やってはいけないこと」などを判断する必要が出てきます。
そこで「認可」です。
「認可」とは、いわば権限確認です。
上記の5では、ユーザーの本人確認ができたというだけでコールされたAPIの処理を行っています。ユーザーが、そのAPIを利用する権限を持っているか否かを判断していない状態、つまり認可を行っていない、というわけです。
実際のサービスでは、ほとんどの処理で認可を行う必要がでてきますので、認可の仕組みを不備なく実装しなければならなくなります。
Laravelでは、認可の仕組みを、ゲートとポリシーという仕組みによって実装できるようになっているそうです。そうLaravel 8.x 認可に記載があります。
というわけで、以降でLaravelのポリシーを使って認可を組んでみたいと思います。
環境
環境は、こちらの記事で作成したものを利用し、これを修正していきます。
手順概要
以下のとおり進めていきます。
- 確認用のAPIを追加
- 認可なしでAPIコール
- 認可を実装
- 認可が考慮されたAPIをコール
では、いきます。
1.確認用のAPIを追加
まずは、動作確認用に、APIを追加します。
(1) AnyControllerに処理を追加
AnyControllerに、一覧データ返却処理と、個別データ返却処理の2つを追加します。
:
use App\Models\User;
:
public function indexUsers(Request $request)
{
// $this->authorize('viewAny', User::class);
return User::all();
}
public function showUser(Request $request, $id)
{
$user = User::findOrFail($id);
// $this->authorize('view', $user);
return $user;
}
すでに認可に関する処理をコメントアウト状態で記載しています。
後でコメントアウトを外して使います。
(2) ルート設定を追加
続いて、ルート設定に先ほど追加した処理の定義を追記します。
Route::middleware('auth:sanctum')->group(function () {
Route::get('/users', [AnyController::class, 'indexUsers']);
Route::get('/user/{id}', [AnyController::class, 'showUser']);
});
2.認可なしでAPIコール
Insomniaを使って、先ほど追加した2つのAPIの動作確認を行います。
ルート設定でRoute::middleware('auth:sanctum')
を適用しているので、APIトークンを付与して呼び出す必要があります。APIトークンの発行方法は、こちらのとおりです。
(1) ユーザーID:1での確認
認可の確認のため、異なる権限を持つ2つのユーザーで確認をしておきます。
まずは、ユーザーID:1で、先ほどのAPIにアクセスします。
(ユーザーID:1で認証し発行されたAPIトークンを付与してAPIコール)
特に認可の処理を行っていないので、すべて処理が行われデータが返されます。
- (A) 一覧表示
http://localhost/app01/api/users
- (B) ユーザーID:1のユーザー情報を表示
http://localhost/app01/api/user/1
- (C) ユーザーID:2のユーザー情報を表示
http://localhost/app01/api/user/2
(2) ユーザーID:2での確認
続いてユーザーID:2で、先ほどと同様にAPIにアクセスします。
(ユーザーID:2で認証し発行されたAPIトークンを付与してAPIコール)
こちらも、すべて処理が行われデータが返されます。
- (A) 一覧表示
http://localhost/app01/api/users
- (B) ユーザーID:1のユーザー情報を表示
http://localhost/app01/api/user/1
- (C) ユーザーID:2のユーザー情報を表示
http://localhost/app01/api/user/2
3.認可を実装
それでは、LaravelのPolicyを使って、先ほどの2つのAPIに認可を実装していきます。
補足:認可内容
まずは、どのような認可を行うのか明確にしておきます。
権限としては、「管理権限」と「一般権限」の2つを定義します。
ユーザーID:1が「管理権限」を持つものとします。
ユーザーID:2が「一般権限」を持つものとします。
そして、先ほどの(A)、(B)、(C)のアクセスに関連して、以下のようなルールを定義しておきます。
- 一般権限は、ユーザー自身のユーザー情報のみ閲覧できる。
- 管理権限は、すべてのユーザーのユーザー情報を閲覧できる。
といわけで、アクセス可否としてまとめると以下のようになります。
APIコール | ユーザーID:1 | ユーザーID:2 |
---|---|---|
(A) 一覧表示 | アクセス可 | アクセス不可 |
(B) ユーザーID:1 情報表示 | アクセス可 | アクセス不可 |
(C) ユーザーID:2 情報表示 | アクセス可 | アクセス可 |
補足:認可に関連する仕組み
SanctumのAPI認証では、APIトークンにアビリティ(abilities
)という情報があり、これによりAPIトークンが利用できる機能を指定できます。これを利用します。
アビリティは機能単位の指定であり、機能一つ一つを権限として定義するには少し細かすぎます。そこで、権限をまとめた役割(Role)を作成して管理しようと思います。
先ほどの認可内容で定義した権限を基に、以下のように役割を定義します。
役割 | 権限 |
---|---|
管理者 | 管理権限 |
一般ユーザー | 一般権限 |
で、この役割情報をどこに保持するか、という課題があります。
シンプルなシステムならユーザーテーブルに直接持つのが簡単、と思います。
が、組織の概念を含むシステムになると、組織ごとに役割が必要であったり、異動に伴う役割変更や、役割兼務など、あれや、これやで考慮点が多くなり、ユーザーテーブルに直接役割を持つやり方には限界が来ます。
そういったことを考えると、役割をユーザーテーブルに直接持つというのは、システムの柔軟性に欠ける、という言い方もできるかと思います。
といっても、この記事はお試しなので、簡単に済ませます。かつ、柔軟性を持っている雰囲気を醸し出した感じに、権限管理用のテーブルを作ってユーザーモデルにリレーションを定義するという方法で行きます。リレーションについては、Eloquent:リレーションを参照のこと。
前置きが長くなりましたが、ここから実際に組み込みしていきます。
(1) 権限管理テーブル(user_roles)、モデル(UserRole)追加
最初に、権限管理テーブルとそのモデルを追加します。
php artisanでモデルとマイグレーションを作成
Artisanコマンドで、モデルとマイグレーションを同時に作成します。
php artisan make:model UserRole --migration
権限管理テーブル
権限管理テーブルの内容は以下のようにします。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserRolesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_roles', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('user_id')->unsigned()->comment('ユーザーID');
$table->text('roles')->nullable()->comment('ユーザー権限');
$table->timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP'))->comment('作成日時');
$table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP'))->comment('更新日時');
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_roles');
}
}
UserRoleモデル
UserRoleモデルには、管理権限を持つかを判定するisAdmin()
メソッドを追加します。
また、roles
カラムの内容を使いやすくするため、json
へのキャスト定義を追加しておきます。これにより、roles
カラムに格納されたJSON形式の文字列を自動でjson_decode
してくれます。
結果、UserRoleの内容は以下のようになりました。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserRole extends Model
{
use HasFactory;
protected $casts = [
'roles' => 'json',
];
public function isAdmin()
{
return in_array('admin', $this->roles, true);
}
}
(2) ユーザーモデルにUserRoleのリレーションを追加
作成したUserRoleに、ユーザーモデルからアクセスできるようにします。
すでにテーブル定義でリレーションが考慮されているため、以下の定義を追加するだけです。
:
public function userRole()
{
return $this->hasOne(UserRole::class);
}
:
詳しく知りたい方は詳細はこちらへ。
(3) ユーザーモデルのポリシーを追加
認可の根幹、ポリシーを追加します。ユーザーモデルに対応するポリシーです。
ポリシーの追加は、Artisanコマンドのmake:policyで行えます。--model
をオプション指定することで、標準的なメソッドのスケルトンを含んだPolicyが作成されます。
実際のコマンドは以下です。
php artisan make:policy UserPolicy --model=User
UserPolicyに作成されたメソッドのうち、今回は一覧表示のviewAny
と、個別表示のview
に、利用可否の判定処理を記載します。
この時の利用可否の判定処理で、Laravel SanctumのtokenCan
メソッドを使います。
具体的には以下のようにしています。
:
public function viewAny(User $user)
{
return $user->tokenCan('all');
}
:
public function view(User $user, User $model)
{
if ($user->tokenCan('all')) {
return true;
}
return $user->tokenCan('read') && ($model->id == $user->id);
}
:
補足:tokenCan('all') について
tokenCan('all')
ですが、アビリティの'*'でしかマッチしない定義として指定しています。
本記事のように、APIトークンを都度発行するような使い方をした場合のベストプラクティスがまだ分からないので、苦肉の策のようなものです。
(4) 認可の呼び出し
先ほどのUserPolicy
の認可判定処理を、コントローラーのメソッドから呼び出すようにします。
AnyController
に追加した2つのメソッドでコメントアウトしてあったauthorize()
を有効にします。
:
public function indexUsers(Request $request)
{
$this->authorize('viewAny', User::class);
return User::all();
}
public function showUser(Request $request, $id)
{
$user = User::findOrFail($id);
$this->authorize('view', $user);
return $user;
}
(5) APIトークン発行処理に権限付与を追加
本記事で利用している環境では、AuthControllerでAPIトークンを都度発行しています。
その際、アビリティは常に['*']
が割り当てられており、全員管理者状態となります。
これを、権限に応じて適切なアビリティを生成し、APIトークン発行時に設定するようにします。
権限に応じて適切なアビリティを生成するメソッドとしてgenerateUserAbilities
を追加します。use
宣言の追加も必要です。
:
use App\Models\User;
:
private function generateUserAbilities(User $user)
{
$userRole = $user->userRole;
return ($userRole && $userRole->isAdmin()) ? ['*'] : ['read', 'write'];
}
そして、authenticate(Request $request)
のuser()->createToken()
の第2引数に、generateUserAbilities()
で生成したアビリティを指定します。
内容はこんな感じ。
:app/Http/Controllers/Api/AuthController.php
:
if (Auth::attempt($credentials)) {
$user = $request->user();
$token = $user->createToken('token-name', $this->generateUserAbilities($user));
return response()->json(['api_token' => $token->plainTextToken,
'expires_at' => $token->accessToken->token_expires_at], 200);
}
:
(6) データベースのマイグレート&シード
ユーザーID:1に、管理権限を付与するデータを登録するため、Seederを用意します。
php artisan make:seeder UserRoleTableSeeder
追記する内容は以下のとおり。
:
use Illuminate\Support\Facades\DB;
:
public function run()
{
$record = [
'user_id' => 1,
'roles' => '["admin"]',
];
DB::table('user_roles')->insert($record);
}
DatabaseSeederに、UserRoleTableSeederの実行処理を追加します。
:
public function run()
{
\App\Models\User::factory(10)->create();
$this->call(UserRoleTableSeeder::class);
}
:
そして、マイグレートとシードの実行です。
php artisan migrate:fresh --seed
4.認可が考慮されたAPIをコール
施した認可処理が正しく動作するのか確認します。
データベースをマイグレートし直しているため、再度APIトークンを発行する必要があるので注意です。
「2.認可なしでAPIコール」と同じことをしますが、ユーザーID:1は認可考慮後も変わらないため、ユーザーID:2から確認します。
(1) ユーザーID:2での確認
一般権限のユーザーID:2で以下にアクセスします。
- (A) 一覧表示
http://localhost/app01/api/users
- (B) ユーザーID:1のユーザー情報を表示
http://localhost/app01/api/user/1
- (C) ユーザーID:2のユーザー情報を表示
http://localhost/app01/api/user/2
認可の組み込みがうまくいっていれば、
(A) 一覧表示 は403 Forbidden
(b) ユーザーID:1のユーザー情報を表示 は403 Forbidden
(c) ユーザーID:2のユーザー情報を表示 は、正常にデータ返却
という結果になります。
(2) ユーザーID:1での確認
続いて、管理権限のユーザーID:1で以下にアクセスします。
- (A) 一覧表示
http://localhost/app01/api/users
- (B) ユーザーID:1のユーザー情報を表示
http://localhost/app01/api/user/1
- (C) ユーザーID:2のユーザー情報を表示
http://localhost/app01/api/user/2
こちらは、すべて正常にデータが返却されるはずです。
以上で、すべての確認は完了です。
まとめ
ポリシーについて
Laravelポリシーのポイントは、ポリシークラスと、「(4) 認可の呼び出し」で行っているauthorize()
の呼び出しあたりですね。
この方法は、「モデル経由のアクションの認可」というようです(参考)。
SPAとAPIサーバーを連携させるなら、リソースコントローラー主体にして、「コントローラー経由のアクションの認可」の方が総合的にはシンプルになる気もします(参考)。
そして、Laravel SanctumのAPI認証のアビリティを利用し、ポリシークラスでの判定処理をtokenCan()
で組むことで、API認証+Laravelポリシーによる認可が実現できました。
認可の設計的なお話
ここまでがこの記事のメインのお話で、このポリシーの仕組みだけを技術的に試してみるだけなら、そこまで難しいことではなさそうに感じます。
どちらかというと、認可の管理をどのように行うか、それをどう組み込むか、といった、認可の設計的なお話の方が難しいのではないでしょうか。
結果、Laravelの認可を試すと、「技術的には分かるけど、実際にシステムに組み込むイメージが沸かない」なんていうことになりそうな気がします。
実際に、APIトークンのアビリティをどのように定義するのかが悩ましいと感じました。
アビリティは機能単位が基本のようですが、本記事で利用している環境のようにAPIトークンを使うなら、いっそアビリティにロールを設定した方が使いやすいのでは?とも思います。
あるいは、ロールと権限をつなぐもう一層の何かを作るか、、、。
この辺りは、要件に基づいてしっかり設計し、また実証をしないと何が良いのか理解できなそうです。
おわりに
Laravelのポリシーを利用して、Laravel SanctumによるAPI認証に認可を追加してみました。
それの感想というか所感は前述のとおり。
で、これで、SPAから接続するAPIサーバーを構築する上で最低限必要な機能について、Laravelのパッケージで気になっていたものはおおよそ試せた気がします。
もともと、「Laravel 6で組んでいるAPIサーバーをLaravel 8で組みなおしてみる」という題材だったのですが、何をするのかのイメージは沸きましたので、この題材はここまでで一区切りにします。
というわけで、今回はこれにて。おつかれさまでした。
Discussion