2022-08-14 投稿

Laravel SanctumのSPA認証を使ってReactにユーザー認証を導入する

LaravelにはReactなどのSPA向けに認証機能を提供するSanctum(サンクタム)というパッケージが用意されています。 Laravel Sanctumを使ってReactにユーザー認証を導入してみましょう。

Laravel Sanctumについて

Laravel Sanctumには大きく2つの機能を提供しています。 どちらも認証機能を提供するものですが、その方式がそれぞれかなり異なっているため解説しておきます。

APIトークン認証(ステートレス認証)

LaravelでのAPI開発に利用できます。APIにアクセスするためのトークンを発行し、 APIリクエストのヘッダーにトークンを付加することで認証機能を提供します。

APIトークン認証はステートレス(状態をもたない)認証です。 1回のリクエスト内で認証が完結し、状態は持たないためセッションは不要です。

API Token Authentication

SPA認証(ステートフル認証)

ReactやVueなどのSPAの認証に特化した機能です。従来のサーバーサイドでHTMLをレンダーするアプリケーションのように、 ブラウザのCookieに識別子を保存し、セッションの情報と照合することで認証機能を提供します。

SPA認証はステートフル(状態をもつ)認証です。 セッションを使うことで、認証が一定時間継続します。
この記事ではこちらを解説します。

SPA Authentication

Laravel側での実装

今回はSPA認証によるReactアプリケーションでのユーザーログイン機能の実装を解説します。

Laravel 7以上を使っている場合、デフォルトでSanctumが含まれているので以下のインストールコマンドは不要です。

1composer require laravel/sanctum
2php artisan vendor:publish --provider="Laravel\\Sanctum\\SanctumServiceProvider"

Sanctumの準備

ドキュメントの通り、Kernel.phpのEnsureFrontendRequestAreStateful::classの記述のコメントアウトを外します。 このクラスではHTTPリクエストが含むCookieの暗号化と復号化、レスポンスへのSet-Cookieヘッダー付与、セッションの開始、CSRFトークンの検証を行います。

EnsureFrontendRequestsAreStateful

1'api' => [
2  \\Laravel\\Sanctum\\Http\\Middleware\\EnsureFrontendRequestsAreStateful::class,
3  'throttle:api',
4  \\Illuminate\\Routing\\Middleware\\SubstituteBindings::class,
5],

config/sanctum.phpのstatefulに設定されている通り、 環境変数SANCTUM_STATEFUL_DOMAINSに SPAのドメイン(ローカルの場合はlocalhost)を記載しておきます。

1...
2  'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
3    '%s%s',
4    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
5    env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
6  ))),

さらに、config/session.phpのdomainに設定されている通り、 環境変数SESSION_DOMAINにSPAのドメイン(ローカルの場合はlocalhost)を記載しておきます。

1...
2  'domain' => env('SESSION_DOMAIN'),

/sanctum/csrf-cookieルートの利用

Laravelにはデフォルトでsanctum/csrf-cookieルートが用意されています。

1これはセッションの識別子をブラウザのCookieに保存させるためのものです。
2このAPIを呼ぶことによりユーザーを識別することができます。

認証用ルートの作成

次に、ログイン画面からログイン情報(メールアドレスとパスワード)のリクエストを受ける /login/logoutルートを作成します。

1<?php
2use Illuminate\\Support\\Facades\\Route;
3use App\\Http\\Controllers\\AuthController;
4
5// login、logoutルートを追加
6Route::post('/login', [AuthController::class, 'login']);
7Route::post('/logout', [AuthController::class, 'logout']);
8
9Route::get('/', function () {
10    return view('welcome');
11});

また、ドキュメントを参考に認証処理コントローラーAuthControllerを作成します。

Manually Authenticating Users - Laravel

1<?php
2namespace App\\Http\\Controllers;
3use Illuminate\\Http\\Request;
4
5class AuthController extends Controller
6{
7  public function login()
8  {
9      $credentials = $request->validate([
10          'email' => ['required', 'email'],
11          'password' => ['required'],
12      ]);
13
14      if (Auth::attempt($credentials)) {
15          $request->session()->regenerate();
16          return response()->json([
17              'error' => null,
18          ]);
19      }
20      return response()->json([
21          'error' => 'The provided credentials do not match our records.'
22      ]);
23  }
24
25  public function logout()
26  {
27      $user = Auth::logout();
28      $request->session()->invalidate();
29      $request->session()->regenerateToken();
30      return response()->json([
31          'error' => null,
32      ]);
33  }
34}

auth:sanctumミドルウェア

認証をかけたいルートには、auth:sanctumミドルウェアを通すことでSanctumを適用できます。

1<?php
2use Illuminate\\Support\\Facades\\Route;
3use App\\Http\\Controllers\\AuthController;
4
5Route::post('/login', [AuthController::class, 'login']);
6Route::post('/logout', [AuthController::class, 'logout']);
7
8Route::middleware('auth:sanctum')->group(function () {
9  Route::post('/user', [AuthController::class, 'getLoginUser']);
10});
11
12Route::get('/', function () {
13    return view('welcome');
14});
1<?php
2...
3class AuthController extends Controller
4{
5  ...
6  public function getLoginUser(Request $request)
7  {
8      return response()->json(
9          $request->user()
10      );
11  }
12}

React側での実装

Reactでは例として以下の5つのコンポーネントを定義します。カッコ内に各コンポーネントの役割を記載します。

  • App.tsx(ルーティング)
  • PublicContent.tsx(公開ページ)
  • PrivateContent.tsx(非公開ページ)
  • LoginForm.tsx(ログインページ)
  • AuthProvider.tsx(ユーザー情報の保持、ログイン・ログアウト処理)

ルーティング設定(react-router-dom)をApp.tsxに記載します。 また、公開するPublicContent.tsx、認証が必要なPrivateContent.tsx、 ログインフォームLoginForm.tsxを準備します。 さらに、ステート処理のためのAuthProvider.tsxを作成します。

ルーティングの実装

react-router-domを利用してルーティングを設定しています。
トップは公開ページ(/)、ログインページ(/login)で正しい認証情報を入力すると非公開ページ(/user)に遷移します。

1import React from 'react'
2import ReactDOM from 'react-dom'
3import AuthProvider from './AuthProvider'
4import { BrowserRouter, Routes, Route } from 'react-router-dom'
5import LoginForm from './LoginForm'
6import PrivateContent from './PrivateContent'
7import PublicContent from './PublicContent'
8
9function App(): React.ReactElement {
10  return (
11    <AuthProvider>
12      <BrowserRouter>
13        <Routes>
14          <Route path="/" element={<PublicContent />} />
15          <Route path="/login" element={<LoginForm />} />
16          <Route path="/user" element={<PrivateContent />} />
17        </Routes>
18      </BrowserRouter>
19    </AuthProvider>
20  )
21}
22
23if (document.getElementById('app')) {
24    ReactDOM.render(<App />, document.getElementById('example'))
25}

全体のコンポーネントをAuthProviderコンポーネントで囲みます。 このAuthProviderにユーザー情報を保持し、それを配下のコンポーネントで読み出します。

公開ページの実装

とくに何の変哲もないページです。認証不要で表示できます。

1import React from 'react'
2
3const PublicContent = () => {
4  return (
5    <>
6      <div className="container">
7        <div className="row justify-content-center">
8          <div className="col-md-8">
9            Public Content
10          </div>
11        </div>
12      </div>
13    </>
14  )
15}
16
17export default PublicContent

ログインページの実装

非公開ページにアクセスするためのログインフォームです。
「ログイン」ボタンを押すとhandleLoginイベントが発火し、AuthProviderのloginメソッドを呼びます(①ログイン処理)。

useEffect内で、ユーザー情報がある場合に非公開ページ(/user)にリダイレクトするようにしています(②リダイレクト処理)。

1import React, { useState, useContext, useEffect } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { AuthContext } from './AuthProvider'
4
5interface LoginFormData {
6  email: string,
7  password: string
8}
9const LoginForm = () => {
10    const navigate = useNavigate()
11    const auth = useContext(AuthContext)
12    const [form, setForm] = useState<LoginFormData>({
13      email: '',
14      password: ''
15    })
16    const [loginFailed, setLoginFailed] = useState(false)
17    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
18      setForm({ ...form, [e.target.name]: e.target.value })
19    }
20    // -- ① ログイン処理 --
21    const handleLogin = async () => {
22        const user = await auth.login({
23            email: form.email,
24            password: form.password
25        })
26        if (!user) {
27            setLoginFailed(true)
28        }
29    }
30    // -- ② リダイレクト処理 --
31    useEffect(() => {
32        if (auth.user) {
33            navigate('/user')
34        }
35    })
36    return (
37        (auth.user || auth.user === null) ? <></>
38        : <>
39          <div className="row justify-content-center">
40            <div className="p-4 bg-light" style={{ marginTop: 200, width: 400 }}>
41              <h2 className="h5 text-center my-4">Login</h2>
42              { loginFailed && (
43                <div className="text-danger text-center mb-2">
44                ログインに失敗しました。
45                </div>
46              )}
47              <div className="mb-2">
48                <input type="email"
49                name="email"
50                className="form-control form-control-lg"
51                placeholder="メールアドレス"
52                value={form.email}
53                onChange={handleChange}
54                />
55              </div>
56              <div className="mb-2">
57                <input type="password"
58                name="password"
59                className="form-control form-control-lg"
60                placeholder="パスワード"
61                value={form.password}
62                onChange={handleChange}
63                />
64              </div>
65              <div className="d-grid gap-2 mt-4">
66                <div className="btn btn-primary" onClick={handleLogin}>
67                ログイン
68                </div>
69              </div>
70            </div>
71          </div>
72        </>
73    )
74}
75
76export default LoginForm

非公開ページの実装

renderする際に認証情報があるかどうかを確かめ、 無い場合ログインページにリダイレクトします(③リダイレクト処理)。

1import React, { useContext, useEffect } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { AuthContext } from './AuthProvider'
4
5const PrivateContent = () => {
6  const navigate = useNavigate()
7  const auth = useContext(AuthContext)  // -- ④ AuthContext利用 --
8  // -- ③ リダイレクト処理 --
9  useEffect(() => {
10    if (auth.user === false) {
11      navigate('/login')
12    }
13  })
14  return (
15    auth.user ?
16      <>
17        <div className="container">
18          <div className="row justify-content-center">
19            <div className="col-md-8">
20              Private Content
21              <ul>
22                <li>Name: {auth.user.name}</li>
23                <li>Mail: {auth.user.email}</li>
24              </ul>
25            </div>
26          </div>
27        </div>
28      </>
29    : <></>
30  )
31}
32
33export default PrivateContent

createContextとuseContext

ContextはReactの機能で、下位のコンポーネントで共通して利用したい値がある場合に、 いわゆるバケツリレーをせずに値を共有するための機能です。

今回はユーザー情報をLoginFormやPrivateContentで利用する(④ AuthContext利用)ために、 AuthProviderの中でAuthContextを定義します。

ユーザー情報保持、ログイン・ログアウト処理の実装

Laravel Sanctumで作成した認証用APIを利用するコンポーネントです。

1import React, { useState } from 'react'
2import axios from 'axios'
3
4interface LoginFormData {
5  email: string,
6  password: string
7}
8interface User {
9  id: number
10  name: string
11  email: string
12  email_verified_at: string|null
13  created_at: string|null
14  updated_at: string|null
15}
16// -- ⑤ AuthContext定義 --
17interface AuthContextType {
18  user: User|false|null
19  login: ({email, password}: LoginFormData) => Promise<User|false|null>
20  logout: () => void
21}
22export const AuthContext = React.createContext<AuthContextType>(null!)
23
24const AuthProvider = ({children}: {children: any}) => {
25  const [user, setUser] = useState<User|false>(null!)
26  axios.post('/user').then(res => {
27    if (!user && res.data.error === null) {
28      setUser(res.data.data)
29    }
30  }).catch(() => {
31    setUser(false)
32  })
33  // -- ⑥ Sanctum利用 --
34  const login = async ({ email, password }: LoginFormData) => {
35    await axios.get('/sanctum/csrf-cookie')
36    await axios.post('/login', { email: email, password: password })
37    await axios.post('/user').then(res => { setUser(res.data.data) })
38    return user
39  }
40  const logout = async () => {
41    await axios.post('/logout').then(() => {
42      setUser(null!)
43    })
44  }
45  const value = {user, login, logout}
46  return (
47    <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
48  );
49}
50
51export default AuthProvider

前述の通りAuthContextを定義します(⑤ AuthContext定義)。
ここで定義したuserとlogin、logoutメソッドはAuthProviderが内包するコンポーネントでuseContextで呼び出すことにより利用できます。

また、ここでuserは Userインターフェースの定義以外にfalseまたはnullを取りうるのですが、 falseはSanctumによる認証が失敗した状態、nullは認証処理が始まっていないorレスポンスがまだ返ってきていない状態を表すようにしています。

Laravel Sanctum + SPA認証

LaravelのBladeを利用した認証よりも記述量は増えますが、昨今のフロントエンドではReactなどのフロントエンドフレームワークを用いるのが主流になってきています。 また、サーバーサイドとフロントエンドの責務を分けることができるところは良さそうです。

また、Reduxなどのステート管理パッケージを使わずともContextを用いることでユーザー認証の画面管理を実現できますね。 Laravel+SPAの組み合わせならSanctumを使うと比較的楽に実装できることが判明しました。

外部リンク

参考

SPA Authentication - Laravel React Router | Authentication

実装サンプル

mariebell/fullstack-project at spa_auth