sagara.inkITエンジニアのまとめノート

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が含まれているので以下のインストールコマンドは不要です。

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

Sanctumの準備

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

EnsureFrontendRequestsAreStateful

app/Http/Kernel.php
'api' => [
  \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
  'throttle:api',
  \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

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

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',
    env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
  ))),

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

config/session.php
  ...
  'domain' => env('SESSION_DOMAIN'),

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

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

console
php artisan route:list
  ...
  GET|HEAD   sanctum/csrf-cookie .............. Laravel\Sanctum > CsrfCookieController@show

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

認証用ルートの作成

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

app/routes.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;

// login、logoutルートを追加
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout']);

Route::get('/', function () {
    return view('welcome');
});

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

Manually Authenticating Users - Laravel

app/Http/AuthController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;

class AuthController extends Controller
{
  public function login()
  {
      $credentials = $request->validate([
          'email' => ['required', 'email'],
          'password' => ['required'],
      ]);

      if (Auth::attempt($credentials)) {
          $request->session()->regenerate();
          return response()->json([
              'error' => null,
          ]);
      }
      return response()->json([
          'error' => 'The provided credentials do not match our records.'
      ]);
  }

  public function logout()
  {
      $user = Auth::logout();
      $request->session()->invalidate();
      $request->session()->regenerateToken();
      return response()->json([
          'error' => null,
      ]);
  }
}

auth:sanctumミドルウェア

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

app/routes.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;

Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout']);

Route::middleware('auth:sanctum')->group(function () {
  Route::post('/user', [AuthController::class, 'getLoginUser']);
});

Route::get('/', function () {
    return view('welcome');
});
app/Http/AuthController.php
<?php
...
class AuthController extends Controller
{
  ...
  public function getLoginUser(Request $request)
  {
      return response()->json(
          $request->user()
      );
  }
}

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)に遷移します。

resources/js/components/App.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import AuthProvider from './AuthProvider'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import LoginForm from './LoginForm'
import PrivateContent from './PrivateContent'
import PublicContent from './PublicContent'

function App(): React.ReactElement {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<PublicContent />} />
          <Route path="/login" element={<LoginForm />} />
          <Route path="/user" element={<PrivateContent />} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  )
}

if (document.getElementById('app')) {
    ReactDOM.render(<App />, document.getElementById('example'))
}

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

公開ページの実装

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

resources/js/components/PublicContent.tsx
import React from 'react'

const PublicContent = () => {
  return (
    <>
      <div className="container">
        <div className="row justify-content-center">
          <div className="col-md-8">
            Public Content
          </div>
        </div>
      </div>
    </>
  )
}

export default PublicContent

ログインページの実装

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

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

resources/js/components/LoginForm.tsx
import React, { useState, useContext, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { AuthContext } from './AuthProvider'

interface LoginFormData {
  email: string,
  password: string
}
const LoginForm = () => {
    const navigate = useNavigate()
    const auth = useContext(AuthContext)
    const [form, setForm] = useState<LoginFormData>({
      email: '',
      password: ''
    })
    const [loginFailed, setLoginFailed] = useState(false)
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      setForm({ ...form, [e.target.name]: e.target.value })
    }
    // -- ① ログイン処理 --
    const handleLogin = async () => {
        const user = await auth.login({
            email: form.email,
            password: form.password
        })
        if (!user) {
            setLoginFailed(true)
        }
    }
    // -- ② リダイレクト処理 --
    useEffect(() => {
        if (auth.user) {
            navigate('/user')
        }
    })
    return (
        (auth.user || auth.user === null) ? <></>
        : <>
          <div className="row justify-content-center">
            <div className="p-4 bg-light" style={{ marginTop: 200, width: 400 }}>
              <h2 className="h5 text-center my-4">Login</h2>
              { loginFailed && (
                <div className="text-danger text-center mb-2">
                ログインに失敗しました。
                </div>
              )}
              <div className="mb-2">
                <input type="email"
                name="email"
                className="form-control form-control-lg"
                placeholder="メールアドレス"
                value={form.email}
                onChange={handleChange}
                />
              </div>
              <div className="mb-2">
                <input type="password"
                name="password"
                className="form-control form-control-lg"
                placeholder="パスワード"
                value={form.password}
                onChange={handleChange}
                />
              </div>
              <div className="d-grid gap-2 mt-4">
                <div className="btn btn-primary" onClick={handleLogin}>
                ログイン
                </div>
              </div>
            </div>
          </div>
        </>
    )
}

export default LoginForm

非公開ページの実装

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

resources/js/components/PrivateContent.tsx
import React, { useContext, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { AuthContext } from './AuthProvider'

const PrivateContent = () => {
  const navigate = useNavigate()
  const auth = useContext(AuthContext)  // -- ④ AuthContext利用 --
  // -- ③ リダイレクト処理 --
  useEffect(() => {
    if (auth.user === false) {
      navigate('/login')
    }
  })
  return (
    auth.user ?
      <>
        <div className="container">
          <div className="row justify-content-center">
            <div className="col-md-8">
              Private Content
              <ul>
                <li>Name: {auth.user.name}</li>
                <li>Mail: {auth.user.email}</li>
              </ul>
            </div>
          </div>
        </div>
      </>
    : <></>
  )
}

export default PrivateContent

createContextとuseContext

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

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

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

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

resources/js/components/AuthProvider.tsx
import React, { useState } from 'react'
import axios from 'axios'

interface LoginFormData {
  email: string,
  password: string
}
interface User {
  id: number
  name: string
  email: string
  email_verified_at: string|null
  created_at: string|null
  updated_at: string|null
}
// -- ⑤ AuthContext定義 --
interface AuthContextType {
  user: User|false|null
  login: ({email, password}: LoginFormData) => Promise<User|false|null>
  logout: () => void
}
export const AuthContext = React.createContext<AuthContextType>(null!)

const AuthProvider = ({children}: {children: any}) => {
  const [user, setUser] = useState<User|false>(null!)
  axios.post('/user').then(res => {
    if (!user && res.data.error === null) {
      setUser(res.data.data)
    }
  }).catch(() => {
    setUser(false)
  })
  // -- ⑥ Sanctum利用 --
  const login = async ({ email, password }: LoginFormData) => {
    await axios.get('/sanctum/csrf-cookie')
    await axios.post('/login', { email: email, password: password })
    await axios.post('/user').then(res => { setUser(res.data.data) })
    return user
  }
  const logout = async () => {
    await axios.post('/logout').then(() => {
      setUser(null!)
    })
  }
  const value = {user, login, logout}
  return (
    <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
  );
}

export 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