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