2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

認証済みユーザークラスを取得時にカスタマイズする方法【Laravel】

Posted at

対象

  • Laravelフレームワークで認証・認可機構を作ろうと思っている方
  • 認可の際に出てくる細かい権限分岐に困っている方

環境

  • PHP: 8.2
  • Laravel: 9.46.0
  • MySQL: 8.0

はじめに

異なるステータスを持つユーザークラスが存在するケースってあるかと思います。

例えば以下のように、できることが異なる管理者区分があって、あるデータに対するアクションをそれぞれ管理者区分ごとに制限しなければならないケースを考えます。

  • 全体管理者(superuser):閲覧・編集できる
  • リーダー(leader):閲覧権限のみ持つ
  • メンバー(member):なにもできない

このニーズを処理するための割と一般的なテーブル構造として、以下のように一つのテーブルの中にそれぞれこのような形で管理者区分(role)を持つ管理者レコードが入っているとします。

id name role
1 '田中' 'superuser'
2 '鈴木' 'leader'
3 '山田' 'member'

上記のケースを素直に実装すると、(認証ミドルウェア設定の)auth.phpとコントローラーはこのような感じになるかと思います。

# auth.php
~~~
'guards' => [
    'admin' => [
        'driver' => 'session',
        'provider' => 'admins',
    ]
],
~~~
'providers' => [
    'admins' => [
        'driver' => 'eloquent',
        'model' => App\Models\Admin::class
    ],
],
~~~
# コントローラー
final class SecretDataAccessController extends Controller
{
    public function __construct()
    {
        $this->middleware('admin');
    }

    /**
      データ一覧取得
    */
    public function index(Request $request)
    {
        $admin = $request->user();
        if ($admin->role === 'superuser') {
            return getData();
        else if ($admin->role === 'leader') {
            return getData();
        else if ($admin->role === 'member') {
            throw new Exception('メンバーはアクセスできません');
        else {
            throw new Exception('不明な管理者です');
        }
    }

    /**
      データ更新
    */
    public function update(Request $request)
    {
        $admin = $request->user();
        if ($admin->role === 'superuser') {
            updateData();
        else if ($admin->role === 'leader') {
            throw new Exception('リーダーは編集できません');
        else if ($admin->role === 'member') {
            throw new Exception('メンバーは編集できません');
        else {
            throw new Exception('不明な管理者です');
        }
    }
}

(if文を関数として切り出す等、ソース自体はもうちょっと綺麗にできるはずではありますが)
$request->user()で取り出した管理者モデルがそれぞれ同じクラス(App\Models\Admin)であるため、

  1. $request->user()->roleを見て、どの管理者区分であるかを判定 → 誰であるか
  2. 特定した管理者区分から、リソースに対するアクションを実行できるかどうか判定 → できるか否か

という2種類の判定(if自体は1種類)をいたるところで行う必要があります。

この実装では例えば、

  1. 部門管理者という区分が新設される
  2. やっぱりリーダーも編集できた方がいいよね

といった要件の変更のたびにif文を全部書き直す必要がでてきます。
大変手間ですし、コントローラーに手が入るのでテストも大変です。

この誰であるかという判定は管理者モデル(クラス)自身に持たせたいところです。
そうすれば、コントローラーの中ではできるか否かの判定さえすれば良くなるので、管理者区分が増えようが権限が変わろうが、権限に起因するロジックは一切変更する必要がなくなります。

というわけで以下から本題です。

本題:異なるモデル(クラス)として受け取る方法

$request->user()で受け取るモデルをroleごとに異なるクラスで受け取ります。

まずは、データモデルとしてのAdminを継承したSuperuserLeaderMemberクラスを作成していきます。
その際、ついでにできるか否かを返すメソッドが欲しいので、インターフェースも作って同時に継承させます。

まずはAdminクラスです。これはDBから取得した値を格納するためのモデルになります。Illuminate\Foundation\Auth\User as Authenticatableを継承した公式ドキュメント通りのものになります。

class Admin extends Authenticatable
{
    use HasFactory;
    use Notifiable;

    protected $guarded = [];
}

次にインターフェースを定義します。

interface IBAdmin {
    public function canRead(): bool;
    public function canEdit(): bool;
    public static function cast(?Admin $obj): self;
}

定義内容はシンプルです。

  1. データを閲覧できるか
  2. データを編集できるか

をそれぞれcanRead()canEdit()でbool値として返します。
また、ダウンキャストを行う必要があるのでcast()メソッドも用意します。

そして具象クラスとして、

// 全体管理者:閲覧・編集可能
class Superuser extends Admin implements IBAdmin
{
    public function canRead(): bool
    {
        return true;
    }
    public function canEdit(): bool
    {
        return true;
    }
    public function cast(?Admin $obj): self
    {
        // 後述
    }
}

// リーダー:閲覧のみ可能
class Leader extends Admin implements IBAdmin
{
    public function canRead(): bool
    {
        return true;
    }
    public function canEdit(): bool
    {
        return false;
    }

    public function cast(?Admin $obj): self
    {
        // 後述
    }
}

// メンバー:閲覧も編集も不可
class Member extends Admin implements IBAdmin
{
    public function canRead(): bool
    {
        return false;
    }
    public function canEdit(): bool
    {
        return false;
    }
    public function cast(?Admin $obj): self
    {
        // 後述
    }
}


// cast(?Admin $obj)の中身(※長くなるのでここにまとめて記載)
public function cast(?Admin $obj): self
{
    if (is_null($obj)) {
        return null;
    }
    $className = self::class;
    $classNameLength = strlen($className);
    return unserialize(
        preg_replace(
            '/^O:\d+:"[^"]++"/',
            "O:$classNameLength:\"$className\"",
            serialize( $obj )
        )
    );
}

モデルクラスはこんなところです。

次にauth.phpAuthServiceProvider.phpを編集し、さらにUserProviderを継承したAdminProviderというクラスを作成します。

# auth.php
~~~
'providers' => [
    'admins' => [
        'driver' => 'custom_admin_provider',
    ],
],
~~~


# AuthServiceProvider.php
use Illuminate\Support\Facades\Auth;
class AuthServiceProvider extends ServiceProvider
{
    ~~~
    public function boot()
    {
        ~~~
        Auth::provider('custom_admin_provider', function ($app, array $config) {
            return $app->make(AdminProvider::class);
        });
    }
}
# AdminProvider.php
class AdminProvider implements UserProvider
{
    /** NOTE: morph以外は全てオーバーライド */

    public function retrieveById($identifier): ?IBAdmin
    {
        $user = Admin::find($identifier);
        if (is_null($user)) {
            return null;
        }
        return $this->morph($user);
    }

    public function retrieveByToken($identifier, $token): ?IBAdmin
    {
        $user = Admin::select()->where(['id' => $identifier, 'remember_token' => $token])->first();
        if (is_null($user)) {
            return null;
        }
        return $this->morph($user);
    }

    public function updateRememberToken(Authenticatable $user, $token)
    {
        $user = Admin::where('id', $user->getAuthIdentifier())->update(['remember_token' => $token,]);
        if (is_null($user)) {
            return null;
        }
        return $this->morph($user);
    }

    public function retrieveByCredentials(array $credentials): ?IBAdmin
    {
        # NOTE: ID認証の場合。メールアドレスの場合は適宜変更
        if (!isset($credentials['login_id'])) {
            return null;
        }
        $user = Admin::find($credentials['login_id']);
        if (is_null($user)) {
            return null;
        }
        return $this->morph($user);
    }

    public function validateCredentials(Authenticatable $user, array $credentials)
    {
        return Hash::check($credentials['password'], $user->getAuthPassword());
    }

    /** これだけ独自に定義 */
    private function morph(?Admin $user): IBAdmin  // ← アップキャスト
    {
        $role = $user->role;
        return match ($role) {
            'superuser' => Superuser::cast($user),
            'leader' => Leader::cast($user),
            'member' => Member::cast($user),
            default => throw new Exception("Invalid record. : role = $role"),
        };
    }
}

これで完成です。

以上の記述をすれば、$request->user();で取得されるユーザークラスがmorph(?Admin $user): IBAdmin内の分岐によって SuperuserLeaderMemberクラスのどれかになり、それぞれのcanReadなどの返り値が各クラスに即した返り値となります。
そのため、以下のようにロジックがかなり綺麗になり、保守性がぐんと上がります。

final class SecretDataAccessController extends Controller
{
    public function __construct()
    {
        $this->middleware('admin');
    }

    /**
      データ一覧取得
    */
    public function index(Request $request)
    {
        $admin = $request->user();
        if ($admin->canRead()) {
            return getData();
        else {
            throw new Exception('閲覧権限がありません');
        }
    }

    /**
      データ更新
    */
    public function update(Request $request)
    {
        $admin = $request->user();
        if ($admin->canEdit()) {
            updateData();
        } else {
            throw new Exception('編集権限がありません');
        }
    }
}

このようにAdminクラスを派生させてIBAdminの具象クラスとして取得できれば、誰であるかの判定はいりません。コントローラーの中はできるか否かの判定だけでよくなります。

部門管理者という区分がもしできたとしても、部門管理者のモデルを作ってAdminProviderクラスのmorphメソッドのmatch条件に一つ加えればそれでOKです。
リーダーに編集もさせたいという要件修正もLeaderクラスのcanEdit()の返り値をtrueにすればそれだけで完了です。

修正範囲はかなり小さくなります。

最後に

今回は(良い例が思いつかなかったので)複数の管理者区分を持つ管理者モデルを例にしましたが、なんらかのステータスを持つ一般ユーザーモデルでももちろん同じことができます。

canPostCreate canPostDelete等、細かく処理の制限をかけることも簡単に可能です。

また、Eloquentモデルのアクセサを用いるなど、ステータスによる分岐ロジックをきれいにする方法は他にも考えられますが、
今回の方法であれば、いつも通りのやり方($request->user())で取得してそのままいつもと同じように使用できるため、他の開発者は内部構造を意識せずに分岐できるようになります。モデルクラス自体もシンプルな実装を保てます。

参考文献

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?