【Laravel Sanctum】APIトークン認証の実装

Laravel

Laravel Sanctumとは

SPA、モバイルアプリケーション、トークンベースAPIを提供する認証パッケージになります。
Laravelでトークン認証を実装する場合はLaravel PassportLaravel Sanctumがあり、
両社の比較表は下記になります。OAuth2を利用したい場合はLaravel Passportで実装できます。

特徴Laravel SanctumLaravel Passport
主な機能SPA認証
APIトークン認証
※OAuth2.0はサポートしていない
OAuth2.0
メリット、デメリット・実装がシンプル
・リソース消費が少ない
・軽量なパッケージ
・セキュリティは標準的(CSRF保護有)
・実装が複雑
・リソース消費が大きい
・機能が豊富で柔軟なカスタマイズ性
・OAuth2.0準拠のため高いセキュリティ
推奨規模小、中規模なシステム向け大規模なシステム向け

SanctumにはAPIトークン認証SPA認証の2種類あります。

APIトークン認証

AuthorizationヘッダのBearerトークンをサーバ側で保持している値と照合することで、有効なユーザであることを判定します。

SPA認証

Laravelのセッション管理機能を利用した認証で、Cookieベースのセッション認証です。
CSRF対策として、ログイン時にCSRFトークン(XSRF-TOKEN)を付与しCookieに格納します。
サーバ側でCSRFトークンとセッションIDを照合することで、セッションハイジャック対策をしています。
SanctumではCookieにHttpOnly 属性を付与しているため、JavaScriptからセッションIDを読み取ることはできません。また、CSRFトークンとSameSite 属性にはLaxを設定しているためクロスサイトへのCookie送信はある程度防ぐことができます。

APIトークン認証の実装

設定ファイルが少なく実装が楽なのでAPIトークン認証を実装します。

バージョン
“laravel/framework”: “^11.0”
“laravel/sanctum”: “^4.0”

Sunctumインストール

artisanコマンドでSunctumをインストールします。

php artisan install:api

デフォルトで下記マイグレーションファイルが用意されているためそのまま利用します。

database/migrations/yyyy_mm_dd_000001_personal_access_tokens.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('personal_access_tokens', function (Blueprint $table) {
            $table->id();
            $table->morphs('tokenable');
            $table->string('name');
            $table->string('token', 64)->unique();
            $table->text('abilities')->nullable();
            $table->timestamp('last_used_at')->nullable();
            $table->timestamp('expires_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('personal_access_tokens');
    }
};

トークン認証利用設定

APIトークン認証ではHasApiTokensトレイトを使用します。
認証用のModelsにHasApiTokensを追加します。

app\Models\User.php

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;

Sanctumの設定ファイルに有効期限を定義します。
ここでは60分で設定しています。

config\sanctum.php

    /*
    |--------------------------------------------------------------------------
    | Expiration Minutes
    |--------------------------------------------------------------------------
    |
    | This value controls the number of minutes until an issued token will be
    | considered expired. This will override any values set in the token's
    | "expires_at" attribute, but first-party sessions are not affected.
    |
    */

    'expiration' => 60,

APIトークン認証で利用するコントローラをartisanコマンドで作成します。

php artisan make:controller AuthApiController

コントローラにログイン、ログアウト、ユーザ情報を取得できるメソッドを追加します。

app\Http\Controllerss\AuthApiController.php

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Carbon\Carbon;

class ApiController extends Controller
{
    public function login(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'email' => ['required', 'email'],
            'password' => ['required']
        ]);
        if ($validator->fails()) {
            return response()->json($validator->messages(), Response::HTTP_UNPROCESSABLE_ENTITY);
        }
        if (Auth::attempt(['email' => $request->email, 'password' => $request->password])) {
            $user = User::where('email', $request->email)->first();
            $user->tokens()->delete();
            $token = $user->createToken('AccessToken',["*"],Carbon::now()->addMinutes(config('sanctum.expiration')))->plainTextToken;
            return response()->json(['token' => $token], Response::HTTP_OK);
        } else {
            return response()->json('User Unauthorized', Response::HTTP_UNAUTHORIZED);
        }
    }

    public function user(Request $request){
        return response()->json(
            [
                $request->user()->name,
                $request->user()->email,
            ]
        );
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();
        return response()->json(['message' => 'Logout Success!'], Response::HTTP_OK);
    }
}

api.phpにルートを記載します。
ミドルウェアでSanctum認証ガードをアタッチすることでログイン済みであるかを判定します。

route\api.php

use App\Http\Controllers\AuthApiController;

Route::post('/login', [AuthApiController::class, 'login'])->name('login');
Route::group(['middleware' => ['auth:sanctum']], function () {
    Route::get('/user', [AuthApiController::class, 'user']);
    Route::get('/logout', [AuthApiController::class, 'logout']);
});

APIトークン確認

curlコマンドでトークンを取得できることを確認します。
-iを指定することでレスポンスヘッダとボディ全ての表示が可能です。

# curl -i -X POST -H "Content-Type: application/json" -d '{"email":"test@mail.com", "password":"12345678"}'  https://localhost/api/login --insecure
HTTP/2 200
server: nginx/1.25.1
content-type: application/json
x-powered-by: PHP/8.2.8
cache-control: no-cache, private
date: Thu, 10 Nov 2024 5:50:54 GMT
x-ratelimit-limit: 60
x-ratelimit-remaining: 58
access-control-allow-origin: *
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block

{"token":"1|xaNGj7JOig4jJgQ7xeSyWFFtmHlxUpa3mfUdXeQa908a7e53"}

認証成功時は下記のようにDBへ登録されます。

+----+-----------------+--------------+-------------+------------------------------------------------------------------+-----------+--------------+---------------------+---------------------+---------------------+
| id | tokenable_type  | tokenable_id | name        | token                                                            | abilities | last_used_at | expires_at          | created_at          | updated_at          |
+----+-----------------+--------------+-------------+------------------------------------------------------------------+-----------+--------------+---------------------+---------------------+---------------------+
|  1 | App\Models\User |            2 | AccessToken | 83fcb2017389e8d35bd4802248a590f5027f801075525a81f9b572201fff9ecd | ["*"]     | NULL         | 2024-11-10 06:17:47 | 2024-11-10 05:17:47 | 2024-11-10 05:17:47 |
+----+-----------------+--------------+-------------+------------------------------------------------------------------+-----------+--------------+---------------------+---------------------+---------------------+

AuthorizationヘッダにBearerトークンを指定することで、ユーザ情報の取得を確認します。

# curl -X GET -H "Authorization:Bearer 1|xaNGj7JOig4jJgQ7xeSyWFFtmHlxUpa3mfUdXeQa908a7e53" "Content-Type:application/json" https://localhost/api/user --insecure
["user001","test@mail.com"]

有効期限切れトークンの削除

デフォルトでは有効期限が切れたトークンは削除されません。
php artisan sanctum:prune-expiredコマンドを手動実行することでDBから削除することが可能です。

php artisan sanctum:prune-expired

Laravel 11であればScheduleタスクを登録することが可能です。
ここでは24時間間隔で有効期限切れトークンを削除しています。

config\sanctum.php

use Illuminate\Support\Facades\Schedule;

Schedule::command('sanctum:prune-expired --hours=24')->daily();

php artisan schedule:listコマンドでスケジュール一覧を確認できます。

# php artisan schedule:list

  0 0 * * *  php artisan sanctum:prune-expired --hours=24 ..................... Next Due: 44分後

最後に

ネイティブなスマホアプリの認証について考えて構築してみました。
ただデフォルトの状態ではセキュリティが低く、IPアドレスでレート制限を実施してくれないみたいです。
なので本番構築する際はレート制限のカスタマイズやWAFの導入をした方が良さそうですね。