NAKKA-Kの技術ブログ

技術に関する知見や考え方などを投稿します。

Laravel5.7 [API開発]で権限設定をPolicyに任せて幸せになろう

LaravelでAPIを作っていると、例外周りの処理を良い感じにしてくれないので非常に大変です。 Policyをそのまま使っても403ページが返ってしまいますし、エラーメッセージの変更も困難です。 今回は権限判定をPolicyに移植して、かつ任意のエラーメッセージをjsonで返せるようにした手順をご紹介します。

FormRequestについての記事もあります。 nakka-k.hatenablog.com

Policyとは

Laravelには特定のモデルやリソース、処理に対して権限を判定し認可・非認可する方法があります。 その方法には大きく分けてGateとPolicyの2つがありますが、今回はPolicyについてのみ説明します。

Policyとは、モデルなどのデータに対してユーザーが行った動作を認可・非認可するために使われる仕組みです。 例えばPostモデルを編集する権限はPostを投稿したユーザーのみにしかない、といった権限判定に使われます。

今まではこんな書き方をしていた

ソースコードは一部改変しています

ユーザーそれぞれが持つ本棚に対して設定を変更する処理を書いているコントローラーです。 コントローラー内で編集先のユーザーIDとログインしているユーザーIDを比較して、許可されていない編集だと判定した場合に403のjsonレスポンスを返しています。

class UserBookController extends Controller
{
    public function update(Request $request,  $user, $userBookId)
    {
        // 認可チェック
        if(auth()->guard('api')->id() != $user->id){
            return response()->json(
                [
                    'status' => 403,
                    'errors' => ['自分以外の本棚を編集することはできません。']
                ],
                403
            );
        }

        // success authorized ......
    }
}

コントローラー内でいちいち認可チェックを書いていてはコントローラーが肥大化しますし、コードを間違えて本来許可されていない動作を許してしまう可能性が増えてしまいます。 やはり認可は認可だけ別の場所に移動させてしまいたいですね。

Laravelでモデル操作の認可に関する処理を書く場合はPolicyを使うべし、と公式ドキュメントに書かれていたのでPolicyを作っていきましょう。

Policyを作成する

Policyはモデルと1対1で結びつく物です。 基本的なCRUD操作に対してそれぞれ認可判定を書けるようになっています。

ファイル生成

今回はUserBookというモデルに対してPolicyを生成しますので--modelオプションでモデルを指定しています。 実際に生成されたメソッドを全て使うわけではありませんが、最初に全体を見ていた方が分かりやすいので生成した後に使わないメソッドを消していく手法を取っています。

$ php artisan make:policy UserBookPolicy  --model=UserBook

もちろん--modelオプションは付けずとも作ることができます。

コマンドを実行するとapp/Policies/が作成され、その下にこのようなファイルが作成されます。

思った以上にメソッドが多いですが簡単にまとめると以下のような役割になっています。

  • view: 閲覧
  • create: 作成
  • update: 更新
  • delete: 削除
    • 論理削除を実装している場合、物理削除ではなく論理削除する時
  • restore: 論理削除状態から復元する時
    • 論理削除を実装している場合のみ
  • forceDelete: 物理削除
    • 論理削除を実装している場合のみ

処理記述

今回はupdateを実装していきます。

Policyの各メソッドはBoolean返す必要があります。 認可だとtrue、非認可だとfalseを返すため条件分をそのまま戻り値に書いてあげましょう。 もちろんifで制御しても良いです。

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

次はPolicyをモデルに紐づけて認可チェックをしてくれるように設定しましょう。

Policyを組み込む

まずapp/Providers/AuthServiceProvider.phpを編集します。 このファイルには$policiesという連想配列を持つ変数があります。 そこにモデルとPolicyの対応づけを追加しましょう。

    protected $policies = [
        'App\Model' => 'App\Policies\ModelPolicy',
        'App\UserBook' => 'App\Policies\UserBookPolicy'
    ];

そして追加したPolicyを反映させるために、コントローラーの__constructミドルウェアの設定をします。 今回はコントローラーでミドルェアの設定をしますがどこで設定しても構いません。(参考)

class UserBookController extends Controller
{
    public function __construct(){
      $this->middleware('can:update,userBook')->only('update');
    }

これによりUserBookモデルのDI時にUserBookPolicyの認可チェックを走らせ、場合によって403のレスポンスを返してくれるようになります。

ですがここで問題が発生します。 基本的にLaravelの例外処理はhtmlページを返すようになっています。 これではAPIとして問題がありますので、次はjsonを返すようにしていきましょう。

エラーハンドリングのためにLaravelのコードリーディングをする

はい。まず結論から申しますとPolicyにレスポンスを動的に記述するメソッドは見つかりませんでした。 いろいろ調べてもよく分からなかったので軽くLaravelのコードリーディングをしてみましょう。

Policyのファイルを見てみるとuse HandlesAuthorization;というトレイトをuseしている行があります。 Policyクラスは継承などをしていないようなので、ほぼ全ての動作がこのuse HandlesAuthorization;に集約されていそうです。 このトレイトはIlluminate\Auth\Accessネームスペースのようなので、まずそのファイルを見てみましょう。

gist.github.com

allowdenyという2つのメソッドだけありますね。 なんとなく認可・非認可に使われるメソッドのようですし、失敗した時はdenyにエラーメッセージが渡されているようです。

これだけでは全然わかりませんね。 まずはdenyメソッドの戻り値であるAuthorizationExceptionを見てみましょう。

gist.github.com

なんというか何も書いていませんね。 でもまあException(例外)であることだけは分かります。

ではもう一度HandlesAuthorizationに戻って、次はallowメソッドの戻り値であるIlluminate/Auth/Access/Responseを見ていきましょう。

gist.github.com

戻り値のラッパーでしかなさそうですね。 とりあえずスルーしましょう。

んー、なんだか遡れそうな先がなくなりました。 では同じnamespaceにあるまだ見ていないファイルも見てみたいので同じディレクトリにあるIlluminate/Auth/Access/Gateも読んでみましょう。

(ファイルが大きすぎるのでリンク先でご覧ください。)

とりあえずHandlesAuthorizationで使われていたallowdenyの呼び出しとかがないか調べてみましょう。

なんだかauthorizeというメソッドに使われている痕跡がありますね。 レスポンスの型も同じですね。

    /**
     * Determine if the given ability should be granted for the current user.
     *
     * @param  string  $ability
     * @param  array|mixed  $arguments
     * @return \Illuminate\Auth\Access\Response
     *
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    public function authorize($ability, $arguments = [])
    {
        $result = $this->raw($ability, $arguments);
        if ($result instanceof Response) {
            return $result;
        }
        return $result ? $this->allow() : $this->deny();
    }

ここからもいろいろ遡れますがあまり有益な情報は得られませんでした。 私のLaravelのコードリーディング力の無さが露見しますね。

一番有益な情報もありました。 AuthorizationExceptionにメッセージを渡してthrowすればなんとなく上手くいきそうな気がします。

ではその方針で進めていきます。

エラーをjsonで返すようにする

まずはjsonを返すようにしましょう。 Policyで非認可されればAuthorizationExceptionが帰ってくるようなので、その例外をキャッチしてjsonを返すように処理を書きます。

レスポンスの最終的なエラーハンドリングを変更するにはapp/Exceptions/Handler.phprenderメソッドに追記していきましょう。

    public function render($request, Exception $exception)
        if ($exception instanceof AuthorizationException) {
            return response()->json([
                'errors' => [$exception->getMessage()]
            ], 403);
        }

        return parent::render($request, $exception);
    }

例外である$exceptionの型を判定してAuthorizationExceptionであればjsonを返すように記述しましょう。 jsonにエラーメッセージを含めるために$exception->getMessage()をレスポンスに入れましょう。 ここで返されるメッセージはHandlesAuthorizationdenyメソッドのデフォルト引数だった'This action is unauthorized.'です。

次はエラーメッセージを動的に変更できるようにしましょう。 Policyのファイルを編集します。私の場合app/Policies/UserBookPolicy.phpですね。

以前は条件文を返すだけだったのでBooleanでしたね。 ここでAuthorizationExceptionを返すようにしてしまいます。

    public function update(User $user, UserBook $userBook)
    {
        if ($user->id == $userBook->user_id) {
            return true;
        }
        throw new AuthorizationException('自分以外の本棚を編集することはできません。');
    }

認可する場合は今まで通りtrueを返してあげますが、falseを返していたタイミングではAuthorizationExceptionthrowします。 ここで投げられたAuthorizationExceptionは先ほど書いたエラーハンドリングメソッドであるrenderメソッドでキャッチされます。 AuthorizationExceptionに渡した引数が$exception->getMessage()で呼び出されるメッセージになります。

実際のレスポンスを見てみましょう。

{
    "errors": [
        "自分以外の本棚を編集することはできません。"
   ]
}

想定通りのレスポンスになっていますね! ということはこれで、jsonを返しつつエラーメッセージも動的に変更することができました!!

最終的にできたコード

https://gist.github.com/NAKKA-K/3d296424fd0c2568ceb6f5d91157c8a5

PolicyとFormRequestを併用

今回の記事では殆ど出てきませんでしたがFormRequestと言う、POSTなどで送られてきたデータをバリデーションしてくれる機能があります。 FormRequestとPolicyを併用した場合、しっかりとPolicyで権限をチェックした後にリクエストのバリデーションをしてくれます。 なので安心してFormRequestとPolicyを併用してください。

FormRequestの導入方法は以下の記事をご覧ください。

nakka-k.hatenablog.com

まとめ

やはり権限周りのチェックはかなり重要な機能です。 確実に権限判定の処理を書くためにPolicyに責任分離し、しっかりと実装しておきましょう。

Policyに分離することで責任分離もできますし、コントローラーの記述量がかなり少なくできます。 どんどん使っていきましょう。

参考