【Laravel11】Sanctumでusers以外のテーブルでもAPI認証がすぐできる方法

みなさま、お疲れ様です。
フロント、サーバーサイドなどWEBでいろんなことをさせていただいております、エンジニアのkuharaです。

3年前にもアドベントカレンダーがあり、そのときは弊社のブログでしたが、
このような記事も書かせていただき、（ブログのシステムの不具合でなければ）不動の1位となっております。自分でもこうなるとは思っていませんでした。
ご覧いただきましたみなさまに熱く御礼を申し上げます。
https://bravesoft.co.jp/blog/archives/15492/

さて、最近すごく忙しく、今回のAdventCalendarもこっそりリスケしておりました。
そのような忙しい間でさえもサクッと、APIの認証が実装できちゃう方法を見つけて実践しましたので、今回のネタとさせていただきます。

# 前提
- DB環境があること（今回はMySQL）
- データベースの作成が済んでいること
- Laravelコマンドが実行できること

# 今回やること
モバイルアプリ向けにSanctumを使って、デフォルトとして準備されているusers以外のテーブルでもAPI認証をトークンで行えるようにします。
ログイン情報をusers以外のテーブルに格納し、それを元に認証ができるようになります。

# 背景
案件の情報になるのでほとんどが秘密事項ですが、例えば
・usersという名前が嫌だ
・usersテーブルを他の認証（例えばSPA、Bladeなど）に使う予定
といった方におすすめできるかと思います。

# 目次
- Laravel環境インストール
- Sanctumインストール
- マイグレーション作成・実行
- コード書き換え
    - app/Http/Controllers/使うController.php
    - app/Models/認証に使いたいテーブルのModel.php
    - config/auth.php
- Middlewareの作成
- DBにアカウント情報を登録
- POSTMANでいざ、実践

# Laravel環境インストール
`$ laravel new your-pj`
`$ cd your-pj`
この後で、.envの設定を行い、ご自分で作られたものに設定しましょう。
`$ php artisan tinker`で、`DB::select("select 1")`でエラーが出なければOKです。
※`your-pj`はお好きなお名前

# Sanctumインストール
`$ php artisan install:api`
これを実行しSanctumをインストールしましょう。

マイグレーションするかどうか聞かれると思いますが、ここではまだやらなくてもいいです。

# マイグレーション作成・実行
今回、メールアドレスなどは無しの、ただIDとパスワードだけの超シンプルなものを実装します。

```php: database/migrations/0001_01_01_000000_create_persons_table.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('persons', function (Blueprint $table) {
            $table->id();
            $table->string('account_id');
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('persons');
    }
};
```
![スクリーンショット 2024-12-07 22.01.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935511/a52fea83-77b1-df8c-942b-802ce3b6a598.png)

もともと、database/migrations/0001_01_01_000000_create_users_table.phpが存在しましたが、これを自分で設定したいテーブル名に変えてしまいます。ここでは"persons"とします。

なお、password_reset_tokensやsessionsも、API認証ではいらないので削除します。

カラムの設定は、
```php:5つの設定項目
$table->id();
$table->string('account_id');
$table->string('password');
$table->rememberToken();
$table->timestamps();
```
これで十分です！

また、Sanctumをインストールしたときに以下のファイルも作成されます。このまま利用します。

```php:database/migrations/2024_12_09_000000_create_personal_access_tokens_table.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');
    }
};
```
- database/migrations/0001_01_01_000000_create_users_table.php
- database/migrations/2024_12_09_000000_create_personal_access_tokens_table.php

この2つのファイルを今回マイグレーションしましょう。
`$ php artisan migrate`

# コード書き換え
あとは、これをコピペでやっていきましょう！

最初に整理しておくと、
- 書き換えるべきファイル
    - database/migrations/0001_01_01_000000_create_persons_table.php （これは前章で書き換えたのでここでは触れません。）
    - app/Http/Controllers/使うController.php
    - app/Models/認証に使いたいテーブルのModel.php
    - config/auth.php
    - app/Http/Middleware/EnsureTokenIsValid.php（Middelware作成で触れます）
    - routes/api.php（Middelware作成で触れます）

- 新規でインストールor書き換えされるが変更しないファイル
    - database/migrations/2024_12_09_000000_create_personal_access_tokens_table.php
    - config/sanctum.php（何も書き換えずにコミットしてください）
    - bootstrap/app.php （sanctumのインストール時、withRoutingに`api: __DIR__.'/../routes/api.php',`が挿入されます。）


```php:app/models/Person.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; //【1】

class Person extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasApiTokens, HasFactory, Notifiable; //【2】

    protected $table = 'persons'; //【3】

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'account_id',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];
}
```
![スクリーンショット 2024-12-07 22.07.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935511/359addc7-c9ae-0a07-c4de-84d04103c8da.png)
デフォルトで入っていたUser.phpを書き直したものですが、ポイントとして、
【1】 `use Laravel\Sanctum\HasApiTokens;`をインポート
【2】クラス内のuseに`HasApiTokens`を追加
【3】使用するテーブル名を指定
あと、肝心のクラス名も使用するテーブルにあわせておきましょう。

`casts()`は・・・・ここではいらないので削除します。

## config/auth.php
```php:config/auth.php
<?php

return [
    'defaults' => [
        'guard' => env('AUTH_GUARD', 'api'), //【1】
        'passwords' => env('AUTH_PASSWORD_BROKER', 'persons'), //【2】
    ],

    'guards' => [
        'api' => [
            'driver' => 'sanctum',//【3】
            'provider' => 'persons',//【4】
        ],
    ],

    'providers' => [
        'persons' => [ //【5】
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\Person::class),//【6】
        ],
    ],
];
```
【1】今回APIなので、`'api'`とします。
【2】personsテーブルを使うので、`'persons'`にしましょう。
【3】Sanctumを使うので、`'sanctum'`にしましょう。
【4】ここと、【5】での指定が一致していれば、`'persons'`でなくても`'hoge'`でも`'foo'`でもいいです。
【6】personsテーブルが紐づいているのはPersonモデルになるので、ここでそれを設定します。


## app/Http/Controllers/使いたいController.php
```php:app/Http/Controllers/APIController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use App\Models\Person;

class APIController extends Controller
{
    public function login(Request $request)
    {
        // `account_id` でユーザーを検索します
        $user = Person::where('account_id', $request->input('account_id'))->first();

        if (!$user || !Hash::check($request->input('password'), $user->password)) {
            return response()->json([
                "message" => "IDまたはパスワードが間違っています。"
            ], 401);
        }
        // トークンを発行します
        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            "message" => "ログインしました",
            "token" => $token
        ]);
    }

    public function logout(Request $request)
    {
        $request->user()->tokens()->delete();

        return response()->json(["message" => "ログアウトしました"]);
    }

    public function person(Request $request)
    {
        return response()->json(Auth::user());
    }
}
```

# Middlewareの作成
ここで、トークンが無効であったときの処理をつくっておきます。
```php:app/Http/Middleware/EnsureTokenIsValid.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class EnsureTokenIsValid
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function handle(Request $request, Closure $next)
    {
        if (!Auth::guard('sanctum')->check()) {
            return response()->json(['message' => 'トークンが間違っています。'], 401);
        }

        return $next($request);
    }
}
```

そして、そのルーティングにおいてミドルウェアを使う範囲を指定しておきます。
Sanctumはもう上記ミドルウェアに入っているため、ここで指定するミドルウェアは`EnsureTokenIsValid`でいいです。
今回login以外は全てトークンがなければ使えない機能になるので、login以外を指定します。
```php:routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\APIController; //使いたいコントローラ

use App\Http\Middleware\EnsureTokenIsValid;

Route::post('/login', [APIController::class, 'login']);

Route::middleware([EnsureTokenIsValid::class])->group(function () {
    Route::post('/logout', [APIController::class, 'logout']);
    Route::get('/person', [APIController::class, 'person']);
});

```
# DBにアカウント情報を登録
パスワードはtinkerで`Hash::make("password");`で得られたものをpersonsテーブルのpasswordカラムに登録します。IDとして使うaccount_idには、お好きなものをどうぞ！

`INSERT INTO persons (account_id, password) VALUES ('ID0001', '$2y$12$GKjBCgslNSYjtKn1NugqG.M7auvoZD8/Rk7xwLB4K0niFQHyQnXIm');`

# POSTMANでいざ、実践！
※作り終えた後ですが、
`$ php artisan key:generate`
忘れないようにしましょう。

ローカルで実験するには、
`$ php artisan serve --port=9876`
でサーバーを立ち上げましょう。ここでは実験なのでポートはお好みで。

まずはログインを試します。

![スクリーンショット 2024-12-08 22.10.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935511/8653ee3e-4f69-3ab4-809e-4a43ebda7806.png)
パスワードが間違っていれば失敗しますが、

![スクリーンショット 2024-12-08 22.11.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935511/594637f9-1a5a-cca8-31ac-5b7f3139f30b.png)
あっていればトークンが発行されます。

![スクリーンショット 2024-12-08 22.11.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935511/258a9ea7-aae2-26d1-519d-3eb2cdb83edf.png)
そのトークンを、次はBearer Tokenに貼り付けて実験します。あえてトークンを間違ってみると、EnsureTokenIsValidで書いた処理が発動します。

![スクリーンショット 2024-12-08 22.12.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935511/ef5625df-6e0f-8383-88e3-51ebab1e1409.png)
正しいトークンなら成功しますね！できました！

ログアウトもきちんとできました！![スクリーンショット 2024-12-08 22.12.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935511/87cea6f4-035d-335f-cd02-650c3af0e54a.png)


# おわりに
いかがでしたでしょうか？
認証周りは結構難しいイメージがあり（本来セキュリティ上そうであるべきだとも思い）ますが、こうして書いている筆者自身ここまで簡潔にできるとは思いませんでした。
ちなみに改めてストップウォッチではかってみたところ、20分程度でした！

ご覧いただきまして、ありがとうございました！