【Laravel Sanctum】SPA認証

## この記事の目的
- Laravel9とReact18,axiosを使用したSPAアプリを作成したときの備忘録
- SPA用の認証機能実装時について
- Laravel Sanctumについて

## 環境
以下のバージョンで行なっています
- PHP: 8.2.3
- Laravel: 9.52.4
- Laravel Sanctum: 3.2.1
- React:　18.2.0
- axios:　1.3.4

## 概要
記事の内容は大きく以下の形で記載しています
- laravelの認証機能
- Laravel Sanctumとは
- Laravel Sanctum使い方
- まとめ

## laravelの認証機能
laravelの認証機能には下記のようにいくつか種類があるみたいです。

:::note info
laravelの認証機能
Laravel UI
Laravel Socialite
Laravel Passport
Laravel Sanctum
Laravel Breeze
Laravel Fortify
Laravel Jetstream
:::
詳しい説明については参考にしたサイトを下記に載せているのでそちらを参考に
[Laravelの認証系パッケージを整理する](https://zenn.dev/localdisk/articles/a338175e913410)
こちらの記事によるとSPA作成する際は「Laravel Sanctum」を使用すると良いみたいなのでこちらを実装していきます

## Laravel Sanctumとは
>Laravel Sanctum（サンクタム、聖所）は、SPA(シングルページアプリケーション)、モバイルアプリケーション、およびシンプルなトークンベースのAPIへ、軽い認証システムを提供します。Sanctumを使用すればアプリケーションの各ユーザーは、自分のアカウントに対して複数のAPIトークンを生成できます。これらのトークンには、そのトークンが実行できるアクションを指定するアビリティ／スコープが付与されることもあります。

公式では上記のように書かれています。
ちょっとよくわからないので崩していうと
>モバイルアプリや SPA 作成時に使用されるライブラリ。APIトークン認証（主にモバイルアプリ用）、クッキー認証（主にSPA用）ができる

ちなみにLaravel SanctumはLaravel7から追加された機能みたいです。Laravel7ではLaravel　Airlockという名前だったみたいです。
### 認証の種類は２つ
- APIトークン
- クッキーベースのセッション認証

こちらのどちらかを使用する感じだと思います。


## Laravel Sanctum使い方
- ①Laravel Sanctumインストール（※Laravel8.6以降は標準搭載されているみたいです）
- ②ライブラリに必要な`migrations`と`config`を作成
- ③カーネルにミドルウェアの設定をする（SPAを認証する場合）
- ④設定ファイルの確認
- ⑤ログインユーザーのデータの追加
- ⑥Seederでユーザーデータ作成
- ⑦ルートの設定
- ⑧ログイン/ログアウトのAPI作成
- ⑨動作確認
※本記事ではLaravel Sanctumの使い方をメインとしているのでReact側でAPIを呼ぶ実装についての説明は詳しくは行なっていないです。

### ①Laravel Sanctumインストール
[【公式ドキュメント】Laravel 9.x Laravel Sanctum](https://readouble.com/laravel/9.x/ja/authentication.html)に沿ってインストールをしていきます
```zsh
composer require laravel/sanctum
```
<font color="Red">※Laravel8.6以降からLaravel Sanctumが標準でインストールされるので不要です。</font>
### ②ライブラリに必要な`migrations`と`config`を作成
```zsh
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
```
<font color="Red">※Laravel8.6以降からLaravel Sanctumが標準でインストールされるので不要です。</font>
APIトークンを使用する場合は`php artisan migrate`を実行しますが、今回はSPAを作成するためこちらは実行しません
### ③カーネルにミドルウェアの設定をする（SPAを認証する場合）
```app/Http/Kernel.php
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // 追記した部分
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],
```
※laravel9では`\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,`がコメントアウトだったのでコメントアウトを外すだけで問題なかった。
記載がなければ[【公式ドキュメント】Laravel 9.x Laravel Sanctum](https://readouble.com/laravel/9.x/ja/authentication.html)からコピーして貼り付ける。

### ④設定ファイルの確認
SPAがリクエストを行うドメインを設定を行う
`localhost:3000`の部分を`localhost:8000`に変更
```config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
     '%s%s',
     'localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,::1',
     env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),
```

### ⑤ログインユーザーのデータの追加
`database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php`を削除
（APIトークン認証を使用する際に必要になり今回は不必要なので削除する）
#### seederファイルを作成
```zsh
php artisan make:seeder UserSeeder
```
### ⑥Seederでユーザーデータ作成
#### 認証用のユーザーデータ作成
認証用に決まったユーザーのデータを作成（自分で決めた認証用データ）
```database/seeders/UserSeeder.php
public function run()
{
    \DB::table('users')->insert([
        [
            'name'              => 'admin',
            'email'             => 'admin@example.com',
            'email_verified_at' => now(),
            'password'          => \Hash::make('password1234'),
            'created_at'        => now(),
            'updated_at'        => now(),
        ],
        [
            'name'              => 'pikimaru',
            'email'             => 'pikimaru@example.com',
            'email_verified_at' => now(),
            'password'          => \Hash::make('password1234'),
            'created_at'        => now(),
            'updated_at'        => now(),
        ],
    ]);
}
```
※usersテーブルの構成は異なる場合がありますので、ご自身の作成したusersテーブルに合わせて作成してください。

#### seedersファイルで呼び出し
```database/seeders/DatabaseSeeder.php
public function run()
{
    $this->call(TaskSeeder::class);
    $this->call(UserSeeder::class);
}
```
※TaskSeederについてはTaskも別で設定しているため記載があります。本記事とは関係はないです

#### seedコマンドでユーザー作成
ユーザーのみの作成で良いのでオプションで`UserSeeder`を指定しています
```zsh
php artisan db:seed --class=UserSeeder
```
ユーザーが登録されているかの確認
![スクリーンショット 2023-03-05 22.44.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/462658/c1121f7f-813c-07b3-9c0d-473cb142cbda.png)

### ⑦ルートの設定
認証を必要とする機能を`Route::middleware('auth:sanctum')->get('/user', function (Request $request) {認証を必要とする機能});`の中に記載する。
```routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    //認証を必要とする機能を記載
    Route::apiResource('tasks', 'App\Http\Controllers\TaskController');
    Route::patch('tasks/update-done/{task}', 'App\Http\Controllers\TaskController@updateDone');
    Route::get('user', function (Request $request) {
        return $request->user();
    });
});
```

### ⑧ログイン/ログアウトのAPI作成
下記のコマンドでコントローラー作成
```zsh
$ php artisan make:request Auth/LoginRequest
$ php artisan make:controller -i Auth/LoginController
$ php artisan make:controller -i Auth/LogoutController
```

#### リクエストでバリデーション設定
作成した`app/Http/Requests/Auth/LoginRequest.php`を下記のように編集
```php:app/Http/Requests/Auth/LoginRequest.php
<?php declare(strict_types=1);

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;

final class LoginRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'password' => ['required', 'min:6'],
        ];
    }
}
```
#### ログイン機能実装
作成した`app/Http/Controllers/Auth/LoginController.php `を下記のように編集
```php:app/Http/Controllers/Auth/LoginController.php
<?php
declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;

final class LoginController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private readonly AuthManager $auth,
    ) {
    }

    /**
     * @param LoginRequest $request
     * @return JsonResponse
     * @throws AuthenticationException
     */
    public function __invoke(LoginRequest $request): JsonResponse
    {
        // リクエストからemailとpasswordの値を取得
        $credentials = $request->only(['email', 'password']);

        // 認証開始
        if ($this->auth->guard()->attempt($credentials)) {
            // セッションIDを再生成
            $request->session()->regenerate();

            // レスポンスを返す
            return new JsonResponse([
                'message' => 'Authenticated.',
            ]);
        }

        // 認証エラーが発生した場合に例外を投げる
        throw new AuthenticationException();
    }
}

```

#### ログアウト機能実装
作成した`app/Http/Controllers/Auth/LogoutController.php`を下記のように編集
```php:app/Http/Controllers/Auth/LogoutController.php
<?php
declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class LogoutController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private readonly AuthManager $auth,
    ) {
    }

    /**
     * @param Request $request
     * @return JsonResponse
     */
    public function __invoke(Request $request): JsonResponse
    {
        // ユーザーが認証されていない場合
        if ($this->auth->guard()->guest()) {
            return new JsonResponse([
                'message' => 'Already Unauthenticated.',
            ]);
        }

        // ログアウトする
        $this->auth->guard()->logout();
        // セッションを無効にする
        $request->session()->invalidate();
        // CSRFトークンを再生成する
        $request->session()->regenerateToken();

        return new JsonResponse([
            'message' => 'Unauthenticated.',
        ]);
    }
}

```

#### ログイン/ログアウト機能のルートを通す
```routes/api.php
use App\Http\Controllers\Auth\LoginController; //追記部分
use App\Http\Controllers\Auth\LogoutController; //追記部分
Route::post('/login', LoginController::class)->name('login'); //追記部分
Route::post('/logout', LogoutController::class)->name('logout'); //追記部分

Route::middleware('auth:sanctum')->group(function () {
    //認証を必要とする機能
    Route::apiResource('tasks', 'App\Http\Controllers\TaskController');
    Route::patch('tasks/update-done/{task}', 'App\Http\Controllers\TaskController@updateDone');
    Route::get('user', function (Request $request) {
        return $request->user();
    });
});
```
#### ルート確認
```zsh
php artisan route:list
```
上記コマンドでルートを確認しログインログアウトのルートが追加されているかと思います
![スクリーンショット 2023-03-09 23.52.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/462658/3c4d5f68-a4d5-fb86-d3a2-edffd93238bc.png)


### ⑨動作確認
ここまででLaravel Sanctumを使用したSPA認証の設定は完了したので、画面で動作確認を行います。
![動作確認.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/462658/90be3e50-7a04-9c72-9b79-a21e2289f8ce.gif)

#### 補足
今回Reactでタスク管理アプリを作成し、そこにLaravel Sanctumを使用したSPA認証を追加しています。
追加したログインAPIをaxiosで飛び出している部分について参考としてReact側の該当部分のみ下記にコードを載せています。
<details><summary>ログイン画面resources/ts/pages/login/index.tsx クリックして表示</summary><div>

```tsx:resources/ts/pages/login/index.tsx
import { useState } from 'react';
import { useLogin } from '../../queries/AuthQuery';

export const LoginPage = () => {
    const login = useLogin();
    const [email, setEmail] = useState('admin@example.com');
    const [password, setPassword] = useState('password1234');

    const handleLogin = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        console.log(email);
        console.log(password);
        login.mutate({ email, password });
    }

    return (
        <>
            <div className="login-page">
                <div className="login-panel">
                    <form onSubmit={handleLogin}>
                        <div className="input-group">
                            <label>メールアドレス</label>
                            <input
                                type="email"
                                className="input" 
                                value={email}
                                onChange={e => setEmail(e.target.value)}
                            />
                        </div>
                        <div className="input-group">
                            <label>パスワード</label>
                            <input
                                type="password"
                                className="input"
                                value={password}
                                onChange={e => setPassword(e.target.value)}
                            />
                        </div>
                        <button type="submit" className="btn">ログイン</button>
                    </form>
                </div>
                <div className="links"><a href="#">ヘルプ</a></div>
            </div>
        </>
    );
}
```
</div></details>

<details><summary>ログインAPI呼び出し成功/失敗時の処理記載部分resources/ts/queries/AuthQuery.tsx クリックして表示</summary><div>

```tsx:resources/ts/queries/AuthQuery.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/AuthAPI';
import { toast } from 'react-toastify';
import { AxiosError } from 'axios';

export const useUser = () => {
    return useQuery(['users'], api.getUsers);
};

const useMutationWithToast = (
    mutationFn: (data: any) => Promise<any>,
    successMessage: string,
    errorMessage: string
) => {
    const queryClient = useQueryClient();
    
    return useMutation(mutationFn, {
        onSuccess: () => {
            queryClient.invalidateQueries(['Users']);
            toast.success(successMessage);
        },
        onError: (error: AxiosError) => {
            const { data } = error.response!;
            if (data.errors) {
                Object.values(data.errors).forEach((messages: string[]) => {
                messages.forEach((message: string) => {
                    toast.error(message);
                });
            });
            } else {
                toast.error(errorMessage);
            }
        },
    });
};

export const useLogin = () => {
    return useMutationWithToast(
        api.login,
        'ログインに成功しました',
        'ログインに失敗しました'
    );
};

export const useLogout = () => {
    return useMutationWithToast(
        api.logout,
        'ログアウトに成功しました',
        'ログアウトに失敗しました'
    );
};

```
</div></details>

<details><summary>axiosを使用してログインログアウトAPI呼び出し部分resources/ts/api/AuthAPI.tsx クリックして表示</summary><div>

```tsx:resources/ts/api/AuthAPI.tsx
import axios from 'axios';
import { User } from '../types/User';

// getUser 関数の宣言、非同期関数として定義される
export const getUser = async () =>{
    // api/userからUser型のレスポンスを受け取る
    const { data } = await axios.get<User>('api/user');
    // 取得したデータを返す
    return data;
}

// login 関数の宣言、非同期関数として定義される。引数には email と password の文字列型を指定する
export const login = async ({ email, password }: { email: string, password: string }) =>{
    try {
        // /api/loginにemailとpasswordのデータを送信しUser型のレスポンスを受け取る
        const { data } = await axios.post<User>(
            `/api/login`,{ email, password }
        );
        return data;
    } catch (error) {
        // エラーログを出力し、エラーを投げる
        console.error(error);
        throw error;
    }
}

// logout 関数の宣言、非同期関数として定義される
export const logout = async () =>{
    // /api/logoutにリクエストを送信しUser型のレスポンスを受け取る
    const { data } = await axios.post<User>(`/api/logout`);
    return data;
}

```
</div></details>

## 参考サイト
- [【公式ドキュメント】Laravel 9.x Laravel Sanctum](https://readouble.com/laravel/9.x/ja/authentication.html)
- [Laravelの認証系パッケージを整理する](https://zenn.dev/localdisk/articles/a338175e913410)
- [Laravel Sanctum SPA認証の実装](https://zenn.dev/yudai64/articles/7caaa3c828b828)
- [Laravel API SanctumでSPA認証する](https://qiita.com/ucan-lab/items/3e7045e49658763a9566)