Laravel × Next.js で ログイン機能を実装する ①トークン発行編

# はじめに
私は2023年10月より、内定直結型エンジニア学習プログラム「アプレンティス」に2期生として参加しています。

https://apprentice.jp/lp

アプレンティスの中で開発したオリジナルプロダクトにおいて、**認証機能**の実装に苦労したので、その内容を以下に分けてまとめようと思います。

① トークン発行編
② トークン管理編
③ Google ログイン編

今回は第１弾の「トークン発行編」となります。

続きの「トークン管理編」はこちらから ↓
※執筆中※


# 目次
[1.オリジナルプロダクト概要](#1-オリジナルプロダクト概要)
[2.使用技術](#2-使用技術)
[3.インフラ構成図](#3-インフラ構成図)
[4.Sanctumの2種類の認証方法](#4-sanctumの2種類の認証方法)
[5.APIトークン認証の流れ](#5-apiトークン認証の流れ)
[6.Userモデルの設定](#6-userモデルの設定)
[7.registerメソッドの実装](#7-registerメソッドの実装)
[8.loginメソッドの実装](#8-loginメソッドの実装)
[9.logoutメソッドの実装](#9-logoutメソッドの実装)
[10.さいごに](#10-さいごに)


# 1. オリジナルプロダクト概要
私自身がオリエンタル・ベジタリアンということもあり、**ベジタリアンのための食の情報サイト**を制作しました。

![site_views.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070527/217575cd-6708-eadb-b38f-177fc1ebc2dc.png)


大きく分けて以下の３大機能があります。
- レシピ機能
- フードアイテム情報機能
- レストランマップ機能

※フードアイテム：市販のベジタリアン向け食品

ユーザーは、レシピやフードアイテム情報の閲覧、投稿や、レストランマップにレストランのレビューを投稿することができます。

プロダクトの詳細はこちらから ↓

https://qiita.com/mosyaneko/items/8a084443ea60d8da9d53

https://qiita.com/mosyaneko/items/f87fa7f27467ef3e4cf3

ログインしていないユーザーは閲覧のみ可能で、ログインすることによって、各種投稿やお気に入り保存などができるようになっています。

# 2. 使用技術
※インフラについては割愛します。
## プログラミング言語
- PHP 8.3.4
- JavaScript
- SQL

## フレームワーク
- Laravel Framework 10.46.0 (PHP)
- Next.js 14.1.4 (React 18.2.0)

## ライブラリ
### データフェッチ関連
- Axios 1.6.8
- SWR ^2.2.5
### UI 全般
- Tailwind CSS 3.3
- CSS Modules
- Shadcn/ui
- React-icons 5.0.1
### フォーム関連
- React Hook From 7.51
- Zod ^3.22.4
### 認証関連
- **Laravel/Sanctum** ^3.3
- **Laravel/Socialite** ^5.12
- **Google OAuth**
### Google Map
- vis.gl/react-google-maps 0.8
他..

## データベース
- Postgre SQL 15.6
拡張機能: PGroonga (日本語全文検索)
GUI: Table Plus

# 3. インフラ構成図
![VegEvery.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070527/77562f2d-9943-6719-c3b7-5f771f95dd0d.png)

# 4. Sanctumの2種類の認証方法
Sanctum には API トークン認証と、SPA 認証の２種類があります。

SPA 認証は、その名の通り、SPA（Single Page Application） の構成になっているアプリケーション用の認証方法です。

今回のオリジナルプロダクトは、Next.js と Laravel API という構成なっていて、オリジンが異なるため、SPA 認証ではなく、API トークン認証を選択しました。

# 5. APIトークン認証の流れ
API トークン認証の簡単な流れは以下の通りです。

1. フロント側で、ログインフォームにユーザーが情報（メールアドレス、パスワード等）を入力する

2. バックエンドにデータを送信

3. データベースのユーザー情報から、該当のユーザーが存在するか確認

4. 該当する場合、トークンを発行（データベースにも保存される）

5. フロントへ、トークンを含んだレスポンスを返す
 
6. 次回以降、ミドルウェアで Sanctum の認証を設定しているルートにリクエストを送る際、リクエストヘッダにトークンを付与して送信する
7. バックエンド側でリクエストヘッダーのトークンと、データベースに保存されたトークンを照合し、該当すればミドルウェアを突破できる

# 6. Userモデルの設定
認証で使うユーザーモデルに、API トークンの記述を追記します。

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
// ① Sanctum の API トークン認証で必要
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
  // ② ここで呼び出す
  use HasApiTokens;
  use HasFactory;
  use Notifiable;

  /**
   *  一括割り当て可能な属性
   *
   * @var array<int, string>
   */
  protected $fillable = [
    // ③ 同時に登録したいカラムを記載
    'account_id',
    'name',
    'password',
    'secret_question',
    'answer_to_secret_question',
    'vegetarian_type',
    'icon_url',
    'icon_storage_path',
  ];

  /**
   * JSONに変換するときに結果に含まれないようにしたいカラム
   *
   * @var array<int, string>
   */
  protected $hidden = [
    'password',
    'answer_to_secret_question',
  ];

  /**
   * 自動的にハッシュ化する
   *
   * @var array<string, string>
   * @return HasMany
   */
  protected $casts = [
    'password' => 'hashed',
    'answer_to_secret_question',
  ];
}
```
### ①② HasApiTokensトレイト
認証に紐づけたいユーザーモデルには、**HasApiTokensトレイト**を読み込ませる必要があります。

### ③ fillable の記載
ユーザー情報を登録する時に、一括で登録したいカラムを記載します。

# 7. registerメソッドの実装
### トークン保存用のテーブルを作成
発行したトークンを保存するためのテーブルをデータベースに作成します。

Laravel/Breeze をインストールしていれば、自動的にマイグレーションファイルが作成されるので、`php artisan migrate` すると `personal_access_tokens` テーブルが作成されます。

database/migrations/日付_create_personal_access_tokens_table.php
```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 設計なので、routes/api.php ファイルに記述します。

routes/api.php
```php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;

// ① prefix で記載
Route::prefix('user')
  ->name('user.')
  // ② 認証用のコントローラーを指定
  ->controller(AuthController::class)
  ->group(function () {
    // ③アカウント登録のルートを作成
    Route::post('/register', 'register')->name('register');
  });
```

### ① prefix
`user` から始まるルートを複数作りたいので、prefix としてまとめて記載しています。

### ② 認証用のコントローラー
`php artisan make:controller AuthController` でファイルを生成しておく。

### ③ アカウント登録のルートを作成
POST メソッドの `/api/user/register` でアクセスできるようにしました。


### AuthController に register メソッドを記述
app/Http/Controllers/AuthController.php
```php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class AuthController extends Controller
{
  public function register(Request $request): JsonResponse
  {
    // ① S3 に画像ファイルを保存し、パスを取得
    $path = Storage::putFile('user/icon', $request->file('iconFile'));
    $url = "<CroudFrontのURL>" . $path;

    // ② ユーザー情報をデータベースに保存
    $user = User::create([
      "account_id" => $request->account_id,
      'name' => $request->name,
      'password' => $request->password,
      'secret_question' => $request->secretQuestion,
      'answer_to_secret_question' => $request->secretAnswer,
      'vegetarian_type' => $request->vegeType,
      'icon_url' => $url,
      'icon_storage_path' => $path
    ]);

    // ③ Sanctum のトークンを発行
    $token = $user->createToken('sanctum_token')->plainTextToken;

    // ④ トークンを返信
    return response()->json(['token' => $token, "user" => $user], 200);
  }
}
```
### ①画像の保存先
今回のプロダクトでは、プロフィールアイコンの画像は AWS の **S3** に保存し、**CroudFront** を介して配信しています。

そのため、画像ファイルそのものは S3 に保存し、そのフォルダのパスを含めた画像の URL をデータベース（今回は **PostgreSQL**）に保存しています。

### ②ユーザー情報の保存
`User` モデルを呼び出し、`create` メソッドで保存しています。
モデルに、`$fillable` の記載をすることで、各項目を同時に登録することができます。

### ③トークンの発行
同時にログイン処理もしています。
Sanctum の `createToken` メソッドでトークンを発行します。
引数（ここでは`'sanctum_token'`）は、トークンの名称になります。

生成されたトークンは SHA-256 ハッシュを使用してハッシュ化され、自動でデータベースに保存されます。デフォルトの保存先は `personal_access_tokens` テーブルになっています。

`plainTextToken` で、トークンをプレーンテキストに変換しています。

### ④トークンを返信
生成したトークンをフロントに返し、フロント側で保存します。
ここでは、ユーザー情報も一緒に返しています。

※フロント側の処理については、②トークン管理編で解説します。

# 8. loginメソッドの実装
### ルートの作成
まずはルートを作成します。
今回は API 設計なので、routes/api.php ファイルに記述します。

routes/api.php
```php
Route::prefix('user')
  ->name('user.')
  ->controller(AuthController::class)
  ->group(function () {
    Route::post('/register', 'register')->name('register');
    // ログインのルートを追記
    Route::post('/login', 'login')->name('login');
  });
```

### AuthController に login メソッドを記述
※ @pop-culture-studio さんにアドバイスをいただき、`Auth` ファサードの `attempt` メソッドに書き換えてみました！
アドバイスありがとうございました！

app/Http/Controllers/AuthController.php
```php
use Illuminate\Support\Facades\Auth;
// ↓修正後削除
// use Illuminate\Support\Facades\Hash;

// 略

public function login(Request $request): JsonResponse
{
    // バリデーション ※$credentials　に代入するよう加筆
    $credentials = $request->validate([
      'account_id' => 'required|string',
      'password' => 'required',
    ]);

    // ↓ 修正前
    // ① フォームに入力された アカウントID でユーザーを検索
    // $user = User::where('account_id', $request->account_id)->first();
    // ② ユーザーが見つからない or 入力されたパスワードとハッシュ化したパスワードが一致しない場合、エラーを返す
    /* if (!$user || !Hash::check($request->password, $user->password)) {
       return response()->json([
        'errors' => [
          'login' => ['IDかパスワードが間違っています']
        ]
      ], 422);
    } */
    
    // ↓ 修正後
    // ① フォームに入力された アカウントID　でユーザーを検索
    // → password と データベースのハッシュ化したパスワードを比較
    // → 一致した場合、認証のセッションを開始する → 認証が成功すると true を返す
    if (Auth::attempt($credentials)) {
    // ② ユーザーを取得
    // ↓　これだと下の createToken が赤線エラーになる（なんでだろう？）
    // $user = Auth::user();
    $user = User::where('account_id', $request->account_id)->first();

    // ③ ユーザーに対してトークンを発行
    $token = $user->createToken('AccessToken')->plainTextToken;

    // ④ トークンとユーザー情報を返す
        return response()->json(['token' => $token], 200);
      }

    // ⑤ ユーザーが見つからない or 入力されたパスワードとハッシュ化したパスワードが一致しない場合、エラーを返す
    return response()->json(['error' => '認証に失敗しました。'], 401);
}
```

### ① アカウントを検索
今回のプロダクトでは、任意の文字列（英数記号）の**アカウントID**と**パスワード**の２点で認証します。
フォームに入力されたアカウントIDを元に、ユーザーを検索します。

（アプレンティスからの指示で、セキュリティの観点から、個人情報をデータベースに保存しないことを勧められたため、メールアドレスは使用しませんでした。）

### ② ユーザーを取得
`Auth::user();` で認証されているユーザーを取得できるはずなのですが、その $user を使って createToken しようとすると
```
Undefined method 'createToken'
```
と赤線エラーが出てしまいました。原因は分かっていません。
そのため、二度手間な気がしますがまたユーザーを検索して取得することにしました。

### ③④ トークンを発行し、返信
エラーがない場合は、トークンが発行され、フロントにトークンを返します。

### ⑤ エラーを返す
ユーザーが見つからないか、パスワードが一致しない場合はエラーを返します。

# 9. logoutメソッドの実装
### ルートの作成
ログアウトのルートを追記します。

routes/api.php
```php
Route::prefix('user')
  ->name('user.')
  ->controller(AuthController::class)
  ->group(function () {
    Route::post('/register', 'register')->name('register');
    Route::post('/login', 'login')->name('login');
    // ① auth ミドルウェアの sanctum ガードを設定
    Route::middleware(['auth:sanctum'])
      ->group(function () {
        // ② ユーザー情報を取得するルートを追記
        Route::get('/', 'getUser')->name('getUser');
        // ③ ログアウトのルートを追記
        Route::post('/logout', 'logout')->name('logout');
      });
  });

```

### ① auth ミドルウェアの sanctum ガードを設定
ログアウトは、ログインしているユーザーのみ可能にするため、**auth ミドルウェア** の **sanctum ガード** をアタッチします。

auth ミドルウェアは、エイリアスとして app/Http/Kernel.php に記載されています。

app/Http/Kernel.php
```php
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{

 // 略

  protected $middlewareAliases = [
    // ↓ ここ！！
    'auth' => \App\Http\Middleware\Authenticate::class,
    'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
    'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
    'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
    'can' => \Illuminate\Auth\Middleware\Authorize::class,
    'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
    'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
    'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
    'signed' => \App\Http\Middleware\ValidateSignature::class,
    'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
    'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
  ];
}

```

App\Http\Middleware\Authenticate.php には以下のように記述されています。

App\Http\Middleware\Authenticate.php
```php
class Authenticate extends Middleware
{
  /**
   * ユーザーが認証されていない場合にリダイレクトされるパスを取得します。
   */
  protected function redirectTo(Request $request): ?string
  {
    return $request->expectsJson() ? null : route('login');
  }
}
```

`$request->expectsJson()` が `true` を返す場合、つまり API として使っている場合は、リダイレクトせずに null を返します。

認証エラーになると、通常は認証エラーを示すHTTPステータスコード（例えば 401 Unauthorized）と共にJSONレスポンスを受け取ります。

Sanctum ではデフォルトで `401 Unauthorized` ステータスコードと共に `{"message": "Unauthenticated."}` のようなJSONレスポンスを返します。
![スクリーンショット 2024-06-02 4.45.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070527/4d3703bc-dbc0-f626-d76d-7d6e788d713d.png)

~~auth ミドルウェアのガード（認証方法の詳細）は、`config/auth.php` で定義します。
今回は `api`ガードを記述します。~~

（※ 2023.6.8 追記）
Sanctum については、ガード等の設定は不要と @pop-culture-studio さんに教えていただきました。
一応、項目の説明は役に立つかもしれないので残しておこうと思います。
<details><summary>ガードについて</summary>
config/auth.php

```php
<?php

return [

// 略

  'guards' => [
    'web' => [
      'driver' => 'session',
      'provider' => 'users',
    ],
    // (1)用途が分かる任意の名前をつける
    'api' => [
      // (2)具体的な方法
      'driver' => 'sanctum',
      // (3)ユーザーレコードにアクセスするためのインターフェース
      'provider' => 'users',
      // (4)ハッシュ化の有無
      'hash' => false,
    ],
  ],

// 略
```
### (1)ガード名
ガードとは、ユーザーの認証情報をどのように取得するかを定義するものです。
用途が分かるように任意の名前を付けます。

### (2)ドライバー
具体的にどのような方法で認証するのかを指定します。
~~今回はここで sanctum を指定します。~~

### (3)プロバイダー
ユーザー情報にアクセスするためのインターフェイスを指定します。
provider は、auth.php 内でガードの次に定義しています。
```php
  'providers' => [
    'users' => [
      'driver' => 'eloquent',
      'model' => App\Models\User::class,
    ],
```
**Eloquent** の `User` モデルを使用する方法を `users` として定義しています。これを使います。

### (4)ハッシュ化の有無
~~ハッシュ化は、元の値を復元できないようにするプロセスです。
APIトークンは元々復元する必要がない一方向のトークンのため、パスワードのようにハッシュ化する必要はないようです。~~
</details>

`auth:sanctum` ミドルウェアがアタッチされたルートにリクエストが来た場合は、以下のチェックが行われます。
- 受け取ったトークンをデータベースに保存されているトークンと照合
- トークンの有効期限の確認
- トークンには特定のスコープが割り当てられている場合があるため、リクエストされた操作がトークンのスコープ内で許可されているかどうかを確認

→上記をパスすると認証され、ミドルウェアを通過できる。

### ② ユーザー情報取得用のルート
ログインしているユーザーのみアクセスできるルートとして、ユーザー情報の取得用のルート（`getUser`）も追記しました。

### ③ ログアウト用のルート
ログアウト用のルートも追記します。


### AuthController に logout メソッドを記述

app/Http/Controllers/AuthController.php
```php
// 略

  public function logout(Request $request): JsonResponse
  {
    // 現在のアクセストークンを削除して認証状態を無効にする
    $request->user()->currentAccessToken()->delete();

    return response()->json(['message' => 'ログアウトしました。'], 200);
  }
```
- `$request->user()` で、現在ログインしているユーザーを返す
- `currentAccessToken()` は Sanctum のメソッドで、ユーザーに関連付けられている現在のアクセストークンのインスタンスを返す
- `->delete()` とすることで、`personal_access_tokens` テーブルから、現在のリクエストに関連付けられているアクセストークンが削除される

これで、次にアクセスした際には、ログアウト状態になるので、認証で弾かれることになります。

# 10. さいごに
ここまで、Sanctum の API トークン認証の実装をまとめてみました。
次回は、発行されたトークンをフロント側でどのように管理するかをまとめたいと思います。

お読みいただき、ありがとうございました。