👮‍♀️

LaravelのauthorizeResourceとその認可について調べた

2021/06/13に公開

こんにちは。
今日は、LaravelのControllerなどで呼び出すことが出来るauthorizeResourceメソッドについて調べてみました。

今回のバージョン

途中までは7.xのものを見ていたのですが、Gate辺りから8.xのソースを見てます。

処理を見てみる

実際のメソッドを見てみましょう。

/**
 * Authorize a resource action based on the incoming request.
 *
 * @param  string  $model
 * @param  string|null  $parameter
 * @param  array  $options
 * @param  \Illuminate\Http\Request|null  $request
 * @return void
 */
public function authorizeResource($model, $parameter = null, array $options = [], $request = null)
{
    $parameter = $parameter ?: Str::snake(class_basename($model)); // パラメータが指定されてなかったらクラス名をスネークケースで設定

    $middleware = [];

    foreach ($this->resourceAbilityMap() as $method => $ability) { // (1)
        $modelName = in_array($method, $this->resourceMethodsWithoutModels()) ? $model : $parameter; // (2)

        $middleware["can:{$ability},{$modelName}"][] = $method; // (3)
    }

    foreach ($middleware as $middlewareName => $methods) {
        $this->middleware($middlewareName, $options)->only($methods);
    }
}

いくつかよくわからない部分があったので抜き出してみます。

(1)の部分に関して

foreach ($this->resourceAbilityMap() as $method => $ability) {

resourceAbilityMapはコントローラのメソッド名=>アビリティ名で配列が定義されています。

protected function resourceAbilityMap()
{
    return [
        'index' => 'viewAny',
        'show' => 'view',
        'create' => 'create',
        'store' => 'create',
        'edit' => 'update',
        'update' => 'update',
        'destroy' => 'delete',
    ];
}

アビリティ名は
https://readouble.com/laravel/8.x/ja/authorization.html
にあるように、Gate::defineで指定するものと、Policyで指定するものがあります。

大抵の場合はPolicyでの定義だと思います。

(2)の部分

$modelName = in_array($method, $this->resourceMethodsWithoutModels()) ? $model : $parameter;
protected function resourceMethodsWithoutModels()
{
    return ['index', 'create', 'store'];
}

どうやら、メソッド名がindex,create,storeの場合だけモデルを指定するようです。
この時点ではなぜこの場合だけこうするのかわかっていません。(先にネタバレしておくとroute parameterがつくかどうかでモデルクラスかモデルオブジェクトで判断するかが変わるため)

(3)の部分

$middleware["can:{$ability},{$modelName}"][] = $method;

middlewareに設定するためのデータを指定しているようです。
これまでの流れから、

"can:viewAny,Post" => ["index"]
"can:view,post" => ["show"]
"can:create,Post" => ["create", "store"]
"can:update,post" => ["edit", "update"]
"can:delete,post" => ["delete"]

となりますね。

$this->middleware($middlewareName, $options)->only($methods);

こちらの把握も大体できましたね。
これからは、実際に実行されるmiddlewareの方を見てみます。

canに関して

canIlluminate\Auth\Middleware\Authorizeクラスにて使用されます。

定義はApp\Http\Kernel$routeMiddlewareに存在します。

'can' => \Illuminate\Auth\Middleware\Authorize::class,

Authorizeクラスの実行handleを見てみましょう。

/**
 * Handle an incoming request.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Closure  $next
 * @param  string  $ability
 * @param  array|null  ...$models
 * @return mixed
 *
 * @throws \Illuminate\Auth\AuthenticationException
 * @throws \Illuminate\Auth\Access\AuthorizationException
 */
public function handle($request, Closure $next, $ability, ...$models)
{
    $this->gate->authorize($ability, $this->getGateArguments($request, $models));

    return $next($request);
}

$this->gate->authorizeを見る前に引数で使われているgetGateArgumentsを見ます。

/**
 * Get the arguments parameter for the gate.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  array|null  $models
 * @return \Illuminate\Database\Eloquent\Model|array|string
 */
protected function getGateArguments($request, $models)
{
    if (is_null($models)) {
        return [];
    }

    return collect($models)->map(function ($model) use ($request) {
        return $model instanceof Model ? $model : $this->getModel($request, $model);
    })->all();
}

getModelメソッドも見ましょう。

protected function getModel($request, $model)
{
    if ($this->isClassName($model)) { // \が存在したらクラスと判定している
        return trim($model);
    } else {
        return $request->route($model, null) ?:
            ((preg_match("/^['\"](.*)['\"]$/", trim($model), $matches)) ? $matches[1] : null);
    }
}

modelクラスのほうはわかりやすいので、後者をちゃんと確認します。
ちなみに、postという文字列が$modelに入ってるとしましょう。(当初parameterとして指定した文字列です)

$request->route($model, null)はシンタックスシュガーであるため、これは$request->route()->parameter($model, null)と訳すことができます。

すでに取得される値は、route-model-binding後の結果の値になっているので、ちゃんとしていればmodelオブジェクトが返されると思います。

以下ドキュメントの内容です。パラメータと同じ名前でメソッドパラメータ(+タイプヒント)が指定されていた場合にReflectionを経由して暗黙のバインディングが発生することと、ModelクラスのgetRouteKeyNameメソッドで自動的に引っ張れること、事前にRoute::modelRoute::bindで返却値を調整できることを覚えておくと良いでしょう。
https://laravel.com/docs/8.x/routing#route-model-binding

ということで

$this->gate->authorize($ability, $this->getGateArguments($request, $models));

$this->gate->authorize($ability, [Postクラスオブジェクトまたはパラメータの値]);

とみなすことができます。

Gateのauthorizeメソッドは何をやっている?

では、$this-gate->authorizeメソッドを見ていきます。

public function authorize($ability, $arguments = [])
{
    return $this->inspect($ability, $arguments)->authorize();
}

どうやらinspectというメソッドを通したあとにauthorizeメソッドを呼び出すようです。
なお、通したあとのauthorizeメソッドは拒否されたかどうかを確認して、拒否されてたら例外をスローするようになっているので、inspectメソッドがわかれば全容がわかりそうです。

public function inspect($ability, $arguments = [])
{
    try {
        $result = $this->raw($ability, $arguments);

        if ($result instanceof Response) {
            return $result;
        }

        return $result ? Response::allow() : Response::deny();
    } catch (AuthorizationException $e) {
        return $e->toResponse();
    }
}

rawメソッド以外はときにそれといって一般的なことをやってるのでrawを見てみます。

public function raw($ability, $arguments = [])
{
	$arguments = Arr::wrap($arguments); // 必ずarrayが返されるようにする

	$user = $this->resolveUser(); // アクセスしてきた認証ユーザーを取得する

	// Gate::beforeで定義したコールバック関数が実行される
	$result = $this->callBeforeCallbacks(
		$user, $ability, $arguments
	);

	if (is_null($result)) { // Gate::beforeのチェックで通らなかったら
		$result = $this->callAuthCallback($user, $ability, $arguments);
	}

	return tap($this->callAfterCallbacks( // Gate::afterを通したあとに
		$user, $ability, $arguments, $result
	), function ($result) use ($user, $ability, $arguments) {
		$this->dispatchGateEvaluatedEvent($user, $ability, $arguments, $result);
	});
}

どうやらメインとなってる処理は$this->callAuthCallbackのようですので見てみます。

protected function callAuthCallback($user, $ability, array $arguments)
{
    $callback = $this->resolveAuthCallback($user, $ability, $arguments);

    return $callback($user, ...$arguments);
}

resolveAuthCallbackを見ます。
結構掘り進んでしまったので忘れているかもですが、$abilityにはviewAnyやviewやcreateやdeleteといったauthorizeResourceで定義された値が入っていると今回はみなします。
argumentsには対象となっているモデルオブジェクトが入ってきてるだろうと考えます。(うまくやれなかったときはパラメータの値がそのまま入ってきています)

protected function resolveAuthCallback($user, $ability, array $arguments)
{
    // 今回はこの部分だけです(ポリシークラスが存在する場合)
    if (isset($arguments[0]) &&
        ! is_null($policy = $this->getPolicyFor($arguments[0])) &&
        $callback = $this->resolvePolicyCallback($user, $ability, $arguments, $policy)) {
        return $callback;
    }

    // Gate::define(アビリティ名, 文字列)で書かれた場合。雰囲気的にも、対象のクラスのメソッドを叩くように指定するパターンでしょうね
    if (isset($this->stringCallbacks[$ability])) {
        [$class, $method] = Str::parseCallback($this->stringCallbacks[$ability]);

        if ($this->canBeCalledWithUser($user, $class, $method ?: '__invoke')) {
            return $this->abilities[$ability];
        }
    }

    // こっちはdefineの際にコールバックで書かれた場合でしょうね
    if (isset($this->abilities[$ability]) &&
        $this->canBeCalledWithUser($user, $this->abilities[$ability])) {
        return $this->abilities[$ability];
    }

    return function () {
        //
    };
}

結構重要そうなメソッドが出てきました。
$this->getPolicyForメソッドを見てみます。

public function getPolicyFor($class)
{
    if (is_object($class)) {
        $class = get_class($class);
    }

    if (! is_string($class)) {
        return;
    }

    if (isset($this->policies[$class])) { // Policyクラスで対応付けされている場合
        return $this->resolvePolicy($this->policies[$class]);
    }

    // app/Policies/クラス名Policyで作られていた場合に自動でポリシーを見に行く
    // この取得ルールはGate::guessPolicyNamesUsingで上書き可能
    foreach ($this->guessPolicyName($class) as $guessedPolicy) {
        if (class_exists($guessedPolicy)) {
            return $this->resolvePolicy($guessedPolicy);
        }
    }

    // クラス名のPolicyが無かったときに、そのサブクラスのポリシーがあった場合はそれを見る
    // 主にはinterfeceの実装としてPolicyを定義していたときとかだろうか
    foreach ($this->policies as $expected => $policy) {
        if (is_subclass_of($class, $expected)) {
            return $this->resolvePolicy($policy);
        }
    }
}

基本的に問題なければresolvePolicyに行ってるのでそちらを見ましょう。ここにはクラスの完全修飾名か、おそらくコールバックのオブジェクトが渡されるようです。

public function resolvePolicy($class)
{
    return $this->container->make($class);
}

ということで、単にgetPolicyForは対象のポリシーオブジェクトがあればそれを取得するメソッドのようです。

getPolicyForのあとの
$callback = $this->resolvePolicyCallback($user, $ability, $arguments, $policy)
を見てみます。

protected function resolvePolicyCallback($user, $ability, array $arguments, $policy)
{
    // 対象のアビリティメソッドが呼び出し可能であるか
    if (! is_callable([$policy, $this->formatAbilityToMethod($ability)])) {
        return false;
    }

    return function () use ($user, $ability, $arguments, $policy) {
        // Policyクラスにbeforeメソッドがあったらそれを発火
        $result = $this->callPolicyBefore(
            $policy, $user, $ability, $arguments
        );

        if (! is_null($result)) {
            return $result;
        }

	// 実行するメソッド名を整形
        $method = $this->formatAbilityToMethod($ability);

	// ポリシーのメソッドを実行する
        return $this->callPolicyMethod($policy, $method, $user, $arguments);
    };
}
protected function callPolicyMethod($policy, $method, $user, array $arguments)
{
    // arguments[0]はルートパラメータのバインドがされずにただの値の文字列で渡っている場合が
    // あるためここで取り除く(通常はモデルクラスの対象オブジェクトが入ってます)
    // 例:posts/{post}と書いてあったとして、postというパラメータが解決できなければ
    // 結果としてその{post}の部分の値が入ってしまう
    // 一覧や新規作成ページには行けるけど、詳細や編集に行けない場合とかがそうっぽい?
    if (isset($arguments[0]) && is_string($arguments[0])) {
        array_shift($arguments);
    }

    if (! is_callable([$policy, $method])) {
        return;
    }

    if ($this->canBeCalledWithUser($user, $policy, $method)) {
        return $policy->{$method}($user, ...$arguments);
    }
}

やっと実際にポリシークラスのメソッドを叩くところがありました。
Illuminate/Auth/Access/Gate.phpresolvePolicyCallbackメソッドということで、Gateクラスの規模の大きさも感じつつなソースリーディングとなりました。

Gateクラスの辺りからは実際の認可処理のコードなのでそれほど読む必要は無かったのですが興味本位で読んだ感じですね。

最後に

ソースを読んだこともあって、認可に関する処理が深まりました。
良かったと思います。

認可、うまく使っていきましょう。

Discussion