Published: 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回のリクエスト内で認証が完結し、状態は持たないためセッションは不要です。
SPA認証(ステートフル認証)
ReactやVueなどのSPAの認証に特化した機能です。従来のサーバーサイドでHTMLをレンダーするアプリケーションのように、 ブラウザのCookieに識別子を保存し、セッションの情報と照合することで認証機能を提供します。
SPA認証はステートフル(状態をもつ)認証です。 セッションを使うことで、認証が一定時間継続します。
この記事ではこちらを解説します。
Laravel側での実装
今回はSPA認証によるReactアプリケーションでのユーザーログイン機能の実装を解説します。
Laravel 7以上を使っている場合、デフォルトでSanctumが含まれているので以下のインストールコマンドは不要です。
consolecomposer 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ルートが用意されています。
consolephp 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)に遷移します。
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.tsximport 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.tsximport 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.tsximport 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.tsximport 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を使うと比較的楽に実装できることが判明しました。