🦁

Laravel×Next.jsでの認証方法 ~スターターキットとテンプレートの中身をみる~

2024/03/28に公開

やりたいこと

バックエンドのLaravelと、フロントエンドのNext.jsを切り離して開発したい。
その際の、ユーザー認証の仕組みをざっくり把握したい。
それをするための、Laravelのスターターキットと、Next.jsのテンプレートがあるので、
それの中身を理解したい。

背景

これまでInertia.jsを使って、同一のリポジトリ内でバックエンドとフロントエンドを同居させて開発してきたが、そういうモノリスの開発より、フロントエンドとバックエンドが疎結合な方が多そうだったから、大枠を把握したかった。

バージョン

Laravel11
Next.js14.1

下準備

Laravel

このスターターキットを使う。
https://laravel.com/docs/11.x/starter-kits#breeze-and-next
これを使うことで、corsの設定などもやっておいてくれる。

このスターターキットはLaravel SanctumのSPA認証を使っている。
Laravel SanctumのSPA認証は、Cookieを使ったログイン認証。

composer create-project laravel/laravel:^11.0 example-app
composer require laravel/breeze --dev
php artisan breeze:install

※Laravel11は、デフォルトでDBはsqliteになっており、デフォルトのmigrationファイルは、自動的にphp artisan migrateされるはず。

.envが

APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000

になっていることを確認。
APP_URL=http://127.0.0.1:8000
だと、Laravel自体は開けるが、Next.jsとの通信で419になってしまう。
上述のスターターキットにも、

In addition, you should ensure that your APP_URL is set to http://localhost:8000, which is the default URL used by the serve Artisan command.

と書かれているので、おとなしく従う。

php artisan serve

で、http://localhost:8000にアクセスすると、
下記のようになる。

これは、
web.phpの、

Route::get('/', function () {
    return ['Laravel' => app()->version()];
});

のレスポンスが表示されている。

Next.js

上述のスターターキットでも言及されているこちらを使用。
https://github.com/laravel/breeze-next
Laravelとは、別のディレクトリで、

git clone https://github.com/laravel/breeze-next.git
npm run dev

.env.exampleをコピーして、
.env.localを作成する。

NEXT_PUBLIC_BACKEND_URL=http://localhost:8000

http://localhost:3000
にアクセス。

このままで、新規登録やログイン機能は使える。

認証の仕組みを調査

大筋の流れ

  • Next.js側の各ページで、useAuth()というフック(src/hooks/auth.js)を呼び出し。
    • useAuthの引数として、{middleware:guest}を渡すと、認証不要なページ
    • {middleware:auth}を渡すと、認証が必要なページ
  • useAuthは、/api/userにGETでリクエスト。
  • /api/userは、api.phpで定義されており、$request->user()で、ログイン中のアカウントを返す。
    • /api/userは、'auth:sanctum'ミドルウェアが適用されており、ログイン中のみuser情報が返るようになっている。
  • userが返ってきたら、認証、返ってこなければ、認証なし、として判断
    という仕組みになっている。

CSRF保護の通過

では、CSRF保護はどう通過したらいいのか。
/registerなどのルートはweb.phpに書かれておりCSRF保護が適用されている。
そのため、一工夫が必要。

POSTをするケースである新規登録を行う場合を見ていく。

新規登録を行うページは以下。
src\app(auth)\register\page.js

const { register } = useAuth({
    middleware: 'guest',
    redirectIfAuthenticated: '/dashboard',
})

ここで、useAuthを呼び出している。middleware:guestなので、registerページは認証不要(それはそう)。

じゃあ、registerは何を行っているのか。
src\hooks\auth.js

const csrf = () => axios.get('/sanctum/csrf-cookie')

const register = async ({ setErrors, ...props }) => {
    await csrf()

    setErrors([])

    axios
        .post('/register', props)
        .then(() => mutate())
        .catch(error => {
            if (error.response.status !== 422) throw error

            setErrors(error.response.data.errors)
        })
}

/registerというルートにPOSTしている。

そのために重要なのは、これ。

await csrf()

このcsrf()で、/sanctum/csrf-cookieにGETでリクエストしている。
これによって、Laravelが、CSRFトークンを含むXSRF-TOKEN cookieをセットしてくれる。
これを、axiosが自動的にリクエストのヘッダーにいれてくれることによって、
「私はちゃんとCSRFトークンを持っているので偽物ではありません」
と証明している。
/sanctum/csrf-cookieのルートは、Sanctumが用意してくれている。

なお、ここでのaxiosは、
src\lib\axios.js
で定義されており、ライブラリからそのまま持ってきただけではダメなことに注意。

const axios = Axios.create({
    baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
    headers: {
        'X-Requested-With': 'XMLHttpRequest',
    },
    withCredentials: true,
    withXSRFToken: true
})

そして、CSRFトークン付きで、/registerにPOSTされ、会員登録がされる。
RegisteredUserController::storeで、会員登録の処理がされているが、

return response()->noContent();

で、なにも返しておらず、その後の遷移はNext.js側で行っている。

上記のreturnがされると、useAuthが再度呼び出され、今度は

const { data: user, error, mutate } = useSWR('/api/user', () =>
        axios
            .get('/api/user')
            .then(res => res.data)
            .catch(error => {
                if (error.response.status !== 409) throw error

                router.push('/verify-email')
            }),
    )

でuserが取得可能になる。
userの値が変わるため、src/hooks/auth.js内の、

useEffect(() => {
        //認証不要なページを開いていて、userが取得できたときは、
        //redirectIfAuthenticatedで定義されたページへ遷移する。
        if (middleware === 'guest' && redirectIfAuthenticated && user)
            router.push(redirectIfAuthenticated)

        //認証メール送信ページを開いていて、認証が完了し、
        //user?.email_verified_atが取得できた場合は、
        //redirectIfAuthenticatedで定義されたページへ遷移する。
        if (
            window.location.pathname === '/verify-email' &&
            user?.email_verified_at
        )
            router.push(redirectIfAuthenticated)

        //認証が必要なページを開いているときに、なんらかuseAuthでエラーが起きたら
        //logout
        if (middleware === 'auth' && error) logout()
    }, [user, error])

が呼び出される。
未ログイン時は、どの条件にも合致していなかったが、会員登録を経てログイン状態になっているので、userが取得できるようになっている。
そのため、

if (middleware === 'guest' && redirectIfAuthenticated && user)

に該当し、useAuthに引数として渡している{edirectIfAuthenticated:/dashboard}に従って、
dashboardページへリダイレクトされている。

メールアドレス認証をしたい場合

api.phpで、
'auth:sanctum'に加えて、'verified'をミドルウェアとして追加。

Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::get('/user', function (Request $request) {
        return $request->user();
    });
});

'verified'は、app\Http\Middleware\EnsureEmailIsVerified.php
で、定義しておいてくれている。

加えて、
app\Models\User.phpで、

use Illuminate\Contracts\Auth\MustVerifyEmail;
class User extends Authenticatable implements MustVerifyEmail
{
以下略

とする。

これでOK。
この場合、Next.js側で、useAuthが呼び出され、/api/userにアカウント情報がリクエストされたとき、EnsureEmailIsVerifiedミドルウェアが作動し、メール認証がされていないと409を返す。
そうすると、
src\hooks\auth.jsで、router.push('/verify-email')が実行され、メールアドレス認証画面に遷移する。

const { data: user, error, mutate } = useSWR('/api/user', () =>
        axios
            .get('/api/user')
            .then(res => res.data)
            .catch(error => {
                if (error.response.status !== 409) throw error

                router.push('/verify-email')
            }),
    )

認証済みのユーザーだけがアクセスできるルートを増やしたい

routes\web.phpで、

Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});

このように、middlewareを設定すればOK。
(Postは、私が適当に追加したModel,Controller)

なお、web.phpに書いたルートはCSRFトークンがあるかのチェックがmiddlewareで入るので、
上述のCSRF対策で記載しているように、
この/postsにアクセスする前に、
axios.get('/sanctum/csrf-cookie')で、CSRFトークンを取得する必要がある。

export const usePost = () => {
    const csrf = () => axios.get('/sanctum/csrf-cookie')

    const { data, error, mutate } = useSWR('/post', async () => {
        await csrf()
        return axios.get('/posts').then(res => res.data)
    })

    return {
        data,
        error,
        mutate,
    }
}

※あちこちで、axios.get('/sanctum/csrf-cookie')を呼び出すなら、これはどこかに切り出した方がいいかもしれない。

まとめ

  • ログイン有無によるroutingの切り替えはNext.js側で基本行う
  • 認証の扱い方自体は通常のLaravelでの認証と大きく変わらないが、CSRF保護を通過するための処理が必要
  • その他、corsの設定等も必要だが、上述のスターターキットとテンプレートを活用すれば、かなり楽に行える
  • 小規模な開発の場合、Inertia.jsで作った方が楽な気はするけど、Next.jsが使い慣れてるならこれもあり。

Discussion