【Laravel + Next.js】Sanctumを使ってSPA認証を実装する

この記事では、バックエンドがLaravelでフロントエンドがNext.jsになっているWebアプリでCSRF対策をするために、LaravelのSanctumという機能を使ってSPA認証を実装する方法を解説していきます。

## 開発環境

* macOS Venture 13.2.1
* Laravel 10.15.0
* Next.js 13.4.16

今回の実装では、Laravelはバージョン10を使っており、Next.jsバージョン13を使っています。また、LaravelのAPIの動作確認はPostmanを使って行います。

## Laravel Sanctumとは？

SanctumはSPAに対する認証機能を提供するLaravelのパッケージです。

以下は[Laravel公式ドキュメントのSanctumに関するページ](https://readouble.com/laravel/10.x/ja/sanctum.html)からの引用です。

> Sanctumは、Laravelを利用したAPIと通信する必要があるシングルページアプリケーション(SPA)を認証する簡単な方法を提供するために存在します。

> SanctumはLaravelの組み込みクッキーベースのセッション認証サービスを使用します。この認証へのアプローチは、CSRF保護、セッション認証の利点を提供するだけでなく、XSSを介した認証資格情報の漏洩から保護します。

つまり、LaravelのSanctumを使うことでCSRF対策ができるということですね。

## Laravelのプロジェクトを作成する

まずは、LaravelとNext.jsのプロジェクトを作成します。先にLaravelの方から作成していきますね。

ターミナルで以下のコマンドを入力してください。

```
curl -s "https://laravel.build/laravel_api" | bash
```

laravelのプロジェクトが作成されたら、プロジェクトのルートディレクトリに移動して、Laravel Sailを起動します。

```
./vendor/bin/sail up
```

以下のような画面が表示されていればOKです。
![スクリーンショット 2023-08-15 16.31.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/7575b706-0fcb-47d1-9dee-5890a9ef4e2e.png)

今回はLaravelの10を使っているので、Sanctumはすでにインストールされています。

もしインストールされていなかった場合は、Laravelのコンテナの中に入って以下の2つのコマンドを実行し、Sanctumをインストールと設定ファイルおよびマイグレーションファイルの作成を行ってください。

```
composer require laravel/sanctum
```

```
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
```

上記のコマンドを実行すると、

* config/sanctum.php

* database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php

という2つのファイルが作成されます。

また、以下のコマンドをコンテナの中に入って実行し、LaravelのプロジェクトにSanctumのエンドポイントが存在するかどうかも確認してみてください。

```
php artisan route:list
```

ターミナルに以下の「GET|HEAD   sanctum/csrf-cooki」といった表示が出ていればOKです。
![スクリーンショット 2023-08-16 11.16.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/80770fff-05d3-434a-7fef-5bd11fddcf95.png)

## Next.jsのプロジェクトを作成する

次はNext.jsのプロジェクトを作成していきます。新しく別のターミナルを開いて以下のコマンドを実行してください。

```
npx create-next-app@latest
```

上記のコマンドを実行するとプロジェクト名を何にするか、TypeScriptを使うかなど色々と聞かれます。

プロジェクト名は好きな名前をつけていただいて大丈夫です。それ以外に関しては、以下のように回答して実装を進めていきます。

* Would you like to use TypeScript?
→ Yes
* Would you like to use ESLint?
→ Yes
* Would you like to use Tailwind CSS?
→ Yes
* Would you like to use `src/` directory?
→ No
* Would you like to use App Router? (recommended)
→ Yes
* Would you like to customize the default import alias?
→ No

![スクリーンショット 2023-08-16 11.46.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/d3fc347a-a07a-2530-fde5-0b7ab80c9aa4.png)
プロジェクトの作成が終わったら、以下のコマンドを実行してNext.jsのプロジェクトを起動してください。

```
npm run dev
```

Next.jsの方も以下のような画面が表示されたらOKです。
![スクリーンショット 2023-08-16 11.31.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/a0c62881-b1a2-40b8-e5cd-6d6970ead9a7.png)

また、今回はTailwind CSSで見た目を整えていきます。デザインの崩れを防ぐためglobals.cssの不要な記述は消しておいてください。

```app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

/* :root {
  --foreground-rgb: 0, 0, 0;
  --background-start-rgb: 214, 219, 220;
  --background-end-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
  :root {
    --foreground-rgb: 255, 255, 255;
    --background-start-rgb: 0, 0, 0;
    --background-end-rgb: 0, 0, 0;
  }
}

body {
  color: rgb(var(--foreground-rgb));
  background: linear-gradient(
      to bottom,
      transparent,
      rgb(var(--background-end-rgb))
    )
    rgb(var(--background-start-rgb));
} */
```

## Laravelでログインと新規登録の機能を作成する

SanctumのSPA認証を実装するために必要になるので、Laravelのプロジェクトでログインとユーザー登録の機能を作成していきます。

まずは、コントローラーを作成して、ログインとユーザー登録の処理を書いていきます。ターミナルで以下のコマンドを実行してください。

```
php artisan make:controller AuthController
```

コントローラーを作成したら、ユーザー登録とログインの関数を作成してください。

```AuthController.php
<?php

namespace App\Http\Controllers;

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

class AuthController extends Controller
{
    // ユーザー登録
    public function register(Request $request) {
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);
        $json = [
            'data' => $user
        ];
        return response()->json( $json, Response::HTTP_OK);
    }

    // ログイン
    public function login(Request $request) {
        if (Auth::attempt(['email' => $request->email, 'password' => $request->password])) {
            $user = User::whereEmail($request->email)->first();
            $user->tokens()->delete();
            $token = $user->createToken("login:user{$user->id}")->plainTextToken;
            //ログインが成功した場合はトークンを返す
            return response()->json(['token' => $token], Response::HTTP_OK);
        }
        return response()->json('Can Not Login.', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
}
```

また、routesディレクトリのapi.phpにログインと新規登録のルーティングを追加します。

```routes/api.php
// ログインと新規登録のルーティング
Route::post('/register', 'App\Http\Controllers\AuthController@register');
Route::post('/login', 'App\Http\Controllers\AuthController@login');
```

ここまでできたらユーザーテーブルを作成するためにマイグレーションを実行します。Laravelのコンテナの中に入り、以下のコマンドを実行してください。

```
php artisan migrate
```
ターミナルに以下のような表示が出ればマイグレーションは完了です。
![スクリーンショット 2023-08-16 13.20.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/dcdf629d-2efa-7920-e8bd-c26069babec4.png)

ここまでできたら、Postmanを使って実際に新規登録とログインができるかどうかを確認していきます。

まずはユーザー登録ができるかどうかを確認していきます。Bodyのform-dataにname,email,passwordの値を設定し、Postmanからユーザー登録のエンドポイントにリクエストを送ってみましょう。
![スクリーンショット 2023-08-16 13.39.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/8d81301d-b8f4-f39d-3874-1d5919bbf186.png)

以下のようなレスポンスが返ってくればユーザーの新規登録は完了しています。
![スクリーンショット 2023-08-16 13.37.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/be631c9b-2412-3598-1814-1925c255d216.png)

続いては、ログインの方も試してみます。emailとpasswordの値を設定し、Postmanからリクエストを送ってみてください。
![スクリーンショット 2023-08-16 13.38.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/0cb4c543-ec21-6fc6-5604-e9a441d855fc.png)

以下のようなレスポンスが返ってくればログインの方もOKです。
![スクリーンショット 2023-08-16 13.38.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/2fa64a2e-b141-695e-88d4-5fe8bd2293fb.png)

## SPA認証の実装 (Laravel側)

ではここからは、LaravelとNext.jsのプロジェクトでSanctumのSPA認証を実装していきます。まずはバックエンドのLaravelの方から実装していきますね。

まず、Kernel.phpのファイルを開くと、protected $middlewareGroupsのapiのところにコメントアウトされている記述があるので、こちらのコメントアウトを解除してください。

```app/Http/Kernel.php
'api' => [
    // コメントアウトされている記述を有効にする
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],
```
この記述を有効にすることで、Sanctumのミドルウェアがapi ミドルウェアグループに追加されます。

これでLaravelのAPIへのリクエストでセッション・Cookieによる認証をすることが可能になります。

次は、configディレクトリにあるcors.phpというファイルを開いて、このファイルの中のsupports_credentialsをtrueに変更してください。

```config/cors.php
// trueに変更する
'supports_credentials' => true,
```

これがfalseのままだとエラーになるので、trueに変更しておいてください。

また、configディレクトリにあるsanctum.phpを開くと、以下のような記述があります。

```config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
    '%s%s',
    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
    Sanctum::currentApplicationUrlWithPort()
))),
```
ここでステートフルな認証を維持する必要があるドメインを指定しています。

今回はローカルホストを使って実装しているので、この部分の変更は不要ですが、本番環境では本番環境のドメインを指定する必要があります。

ドメイン指定の記述の部分を確認したら、routesディレクトリにあるapi.phpにSanctumの認証ガードのための記述も確認しておきましょう。

```routes/api.php
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});
```

上記の記述があればOKです。

これでlaravel側でのSanctumのSPA認証の実装が完了しました。次はNext.js側の実装をおこないます。

## SPA認証の実装 (Next.js側)

まずは、Next.jsのプロジェクトにログインページとログインのコンポーネントを作成していきます。appディレクトリにtestというディレクトリを作成し、その中にpage.tsxを作成してください。page.tsxの中身は後ほど書いていきます。

続いて、ルートディレクトリにcomponentsディレクトリを新しくつくり、その中にTest.tsxというファイルを作成します。

Test.tsxの中身は以下のようにしてください。

```components/Test.tsx
"use client";
import React, { useEffect, useState } from 'react';
import axios from 'axios';

const Test = () => {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');

    const postData = async () => {
        axios.get('http://localhost:8080/sanctum/csrf-cookie').then((res: any) => {
            console.log(res);
        });
    }

    return (
        <div>
            <input
                type="text"
                className='bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 m-3 max-w-sm'
                placeholder='email'
                onChange={(e) => {
                    setEmail(e.target.value);
                }}
            /><br/>
            <input
                type="text"
                className='bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 m-3 max-w-sm'
                placeholder='password'
                onChange={(e) => {
                    setPassword(e.target.value);
                }}
            />
            <div>
                <button
                    className="shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded m-3"
                    onClick={()=>{
                        postData();
                    }}
                >送信</button>
            </div>
        </div>
    );
}

export default Test;
```

ここまでできたら、先ほど作成したapp/test/page.tsxで上記のページコンポーネントを読み込みます。

page.tsxの中身をを以下のように書き換えてください。

```app/test/page.tsx
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import Test from '@/components/Test';

const Page = () => {

    return (
        <div>
            <Test />
        </div>
    );
}

export default Page;
```
実際にこのページを開いてみると、以下のようなログイン情報の入力画面ができていることがわかります。
![スクリーンショット 2023-08-16 15.46.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/c81bd787-133a-9686-e76d-d820467a87dd.png)

では、ここからログインの処理を作成していきます。

ログインの処理が行われたときにSanctumのCSRF保護を初期化する必要があるため、Next.jsのログインの処理に、アプリケーションのCSRF保護を初期化する処理をつけます。

その処理がこちらです。

```components/Test.tsx
axios.get('http://localhost:8080/sanctum/csrf-cookie', { withCredentials: true }).then((res: any) => {
    // ログインの処理
});
```

また、リクエストを送る際にXSRF-TOKENを送信するための設定も必要なので、この設定を追加して、ログインの処理を作成するとTest.tsxは以下のようになります。

```components/Test.tsx
"use client";
import React, { useEffect, useState } from 'react';
import axios from 'axios';

// XSRF-TOKENをリクエスト時に送信するための設定
const http = axios.create({
    baseURL: 'http://localhost:8080',
    withCredentials: true,
});

const Test = () => {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');

    const postData = async () => {
        axios.get('http://localhost:8080/sanctum/csrf-cookie', { withCredentials: true }).then((res: any) => {
            console.log(res);
            // ログイン処理
            http.post('/api/login', {email, password}).then((res: any) => {
                console.log(res);
            })
        });
    }

    return (
        <div>
            <input
                type="text"
                className='bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 m-3 max-w-sm'
                placeholder='email'
                onChange={(e) => {
                    setEmail(e.target.value);
                }}
            /><br/>
            <input
                type="text"
                className='bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 m-3 max-w-sm'
                placeholder='password'
                onChange={(e) => {
                    setPassword(e.target.value);
                }}
            />
            <div>
                <button
                    className="shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded m-3"
                    onClick={()=>{
                        postData();
                    }}
                >送信</button>
            </div>
        </div>
    );
}

export default Test;
```

ここまでできたら、実際に画面を開いてメールアドレスとパスワードを入力して送信ボタンを押すと、コンソールに表示したレスポンスの内容からログインできたことが確認できるはずです。
![スクリーンショット 2023-08-16 15.49.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/8254752f-ef17-caf3-28df-d8b0d9422264.png)

これでLaravelのSanctumを使ったSPA認証の実装は完了です。

## まとめ

LaravelのSanctumという機能を使ってSPA認証を実装することによって、LaravelとNext.jsのWebアプリケーションにCSRF対策を行うことができる。

今回の実装で使ったLaravelとNext.jsのプロジェクトのリポジトリはこちらです。

* Laravel

https://github.com/masakiwakabayashi/next_laravel_sanctum_back-end

* Next.js

https://github.com/masakiwakabayashi/next_laravel_sanctum_front-end