🗂

Laravel Sailでsanctumを利用したAPIトークン認証を行う

2022/09/02に公開

Laravel Sailでlaravelプロジェクトを起動している前提で進めていきます。

環境

PC

機種 : MacBook Pro 2021(M1 Max)
OS : Monterey(12.2.1)

Laravel Sailのバージョン

PHP version : 8.1.9
Laravel version : 9.25.1

sanctumのインストール

https://readouble.com/laravel/9.x/ja/sanctum.html

$ ./vendor/bin/sail composer require laravel/sanctum
$ ./vendor/bin/sail php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
$ ./vendor/bin/sail php artisan migrate

実行して気づきましたが、Laravel Sailには最初からsanctumが入っているぽいです。

APIトークン発行エンドポイントの作成、実行

トークンを発行するためのエンドポイントを作成していきます。

$ ./vendor/bin/sail php artisan make:controller PostSanctumTokenController
routes/api.php
+Route::post('/sanctum/token', \App\Http\Controllers\PostSanctumTokenController::class);
app/Http/Controllers/PostSanctumTokenController.php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class PostSanctumTokenController extends Controller
{
    public const TOKEN_NAME = 'app_api_token';
    public function __invoke(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            return response()->json([
                    'message' => 'no user'
            ]);
        }

        return response()->json([
            'accessToken' => $user->createToken(self::TOKEN_NAME)->plainTextToken
        ]);
    }
}

ユーザーを作成しておきます。

$ ./vendor/bin/sail php artisan tinker

>use App\Models\User;
>User::create([
    'name' => 'lightkun',
    'email' => 'lightkun@example.com',
    'password' => Hash::make('password'),
]);

postmanを実行してみます。

トークンの1|os7NScDXtLJliVb6ShYLllebBuXjzVn6UoIgu0xPが返ってきました。

認証後に利用可能なAPIの作成、実行

トークンは返ってきたので今度は認証できるかを試してみます。

$ ./vendor/bin/sail php artisan make:controller GetFirstController
app/Http/Controllers/GetFirstController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class GetFirstController extends Controller
{
    public function __invoke(Request $request)
    {
        return response()->json([
            'status' => "OK"
        ]);
    }
}
routes/api.php
+Route::get('/first', \App\Http\Controllers\GetFirstController::class)->middleware('auth:sanctum');

postmanを実行してみます。実行にはAuthorizationヘッダーに"Bearer" + 取得したトークンを設定する必要があります。

成功しました。

Authorizationなしだとエラーになります。未ログインなのでapp/Http/Middleware/Authenticate.phproute('login')にリダイレクトしようとしたが、そんなルーティングないよってエラーになりました(単純に作成していないだけです)。認証は失敗しているので、正しい挙動です。

深堀りしてみる

トークン発行

App\Http\Controllers\PostSanctumTokenController.php$user->createToken(self::TOKEN_NAME)の部分について見てみます。引数のnameはぶっちゃけなんでも良さそうです。
$token = $this->tokens()->create(...);でpersonal_access_tokensテーブルにレコードを新規登録していました

vendor/laravel/sanctum/src/HasApiTokens.php
...
    /**
     * Create a new personal access token for the user.
     *
     * @param  string  $name
     * @param  array  $abilities
     * @param  \DateTimeInterface|null  $expiresAt
     * @return \Laravel\Sanctum\NewAccessToken
     */
    public function createToken(string $name, array $abilities = ['*'], DateTimeInterface $expiresAt = null)
    {
        $token = $this->tokens()->create([
            'name' => $name,
            'token' => hash('sha256', $plainTextToken = Str::random(40)),
            'abilities' => $abilities,
            'expires_at' => $expiresAt,
        ]);

        return new NewAccessToken($token, $token->getKey().'|'.$plainTextToken);
    }
...

postmanのレスポンスのトークンが1|XXXとなっていた|の前の数字はなんなの?と思ったので、$token->getKey()の部分も見てみます。personal_access_tokensテーブルのidじゃないかなと思っていたら、ズバリその通りのようでした。

vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php
...
    /**
     * Get the value of the model's primary key.
     *
     * @return mixed
     */
    public function getKey()
    {
        return $this->getAttribute($this->getKeyName());
    }
...

トークン認証

routes/api.phpの->middleware('auth:sanctum');の部分を見ていきます。:の後ろのsanctumは引数のようですね。

https://readouble.com/laravel/9.x/ja/middleware.html

ミドルウェアのパラメータはルート定義時に、ミドルウェア名とパラメータを「:」で区切って指定します。複数のパラメーターはコンマで区切る必要があります。

まず実行されるauthミドルウェアは以下で登録されています。

app/Http/Kernel.php
...
    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
...

app/Http/Middleware/Authenticate.phpをみてみましたが、redirectTo()しかないので、親クラスのvendor/laravel/framework/src/Illuminate/Auth/Middleware/Authenticate.phpを見に行きます。

ここがメインでゴニョゴニョやっている箇所ではないかと思います。

vendor/laravel/framework/src/Illuminate/Auth/Middleware/Authenticate.php
...
    /**
     * Determine if the user is logged in to any of the given guards.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  array  $guards
     * @return void
     *
     * @throws \Illuminate\Auth\AuthenticationException
     */
    protected function authenticate($request, array $guards)
    {
        if (empty($guards)) {
            $guards = [null];
        }

        foreach ($guards as $guard) {
            if ($this->auth->guard($guard)->check()) {
                return $this->auth->shouldUse($guard);
            }
        }

        $this->unauthenticated($request, $guards);
    }
...

$this->auth->guard($guard)->check()で認証処理をやっているはずです。xdebugでStep実行しながら見ましたが、挙動がいまいち理解できない部分もありました。
理解が100%ではないながらも、結局のところはvendor/laravel/sanctum/src/Guard.php__invoke()が認証処理のメイン部分ぽいです。ここでトークンが有効化をチェックしています。

vendor/laravel/sanctum/src/Guard.php
...
    /**
     * Retrieve the authenticated user for the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return mixed
     */
    public function __invoke(Request $request)
    {
        foreach (Arr::wrap(config('sanctum.guard', 'web')) as $guard) {
            if ($user = $this->auth->guard($guard)->user()) {
                return $this->supportsTokens($user)
                    ? $user->withAccessToken(new TransientToken)
                    : $user;
            }
        }

        if ($token = $this->getTokenFromRequest($request)) {
            $model = Sanctum::$personalAccessTokenModel;

            $accessToken = $model::findToken($token);

            if (! $this->isValidAccessToken($accessToken) ||
                ! $this->supportsTokens($accessToken->tokenable)) {
                return;
            }

            $tokenable = $accessToken->tokenable->withAccessToken(
                $accessToken
            );

            event(new TokenAuthenticated($accessToken));

            if (method_exists($accessToken->getConnection(), 'hasModifiedRecords') &&
                method_exists($accessToken->getConnection(), 'setRecordModificationState')) {
                tap($accessToken->getConnection()->hasModifiedRecords(), function ($hasModifiedRecords) use ($accessToken) {
                    $accessToken->forceFill(['last_used_at' => now()])->save();

                    $accessToken->getConnection()->setRecordModificationState($hasModifiedRecords);
                });
            } else {
                $accessToken->forceFill(['last_used_at' => now()])->save();
            }

            return $tokenable;
        }
    }
...

上記

...
            $accessToken = $model::findToken($token);
...

findToken()をみてみると、リクエストヘッダーに設定したトークンの|より前の部分を取り除いている処理がありました。

vendor/laravel/sanctum/src/PersonalAccessToken.php
...
    /**
     * Find the token instance matching the given token.
     *
     * @param  string  $token
     * @return static|null
     */
    public static function findToken($token)
    {
        if (strpos($token, '|') === false) {
            return static::where('token', hash('sha256', $token))->first();
        }

        [$id, $token] = explode('|', $token, 2);

        if ($instance = static::find($id)) {
            return hash_equals($instance->token, hash('sha256', $token)) ? $instance : null;
        }
    }
...

そのため、トークンは1|os7NScDXtLJliVb6ShYLllebBuXjzVn6UoIgu0xPZ、または1|を取り除いたos7NScDXtLJliVb6ShYLllebBuXjzVn6UoIgu0xPのどちらを設定しても認証できるみたいです。実際にpostmanも1|有り無しの両方とも成功しました。

株式会社ゆめみ

Discussion