【React + Laravel】SPAで認証機能と権限管理(fortify+sanctum)

Blog

こんにちは。あっきーです。

家の前の自販機が補充された1時間後に商品の1つが売り切れになってて「僕の周りは経済を回しているな」と感じた、フリーランスWebエンジニアです。

今回は、React.jsとLaravelをSPAの認証機能とアクセス管理をやっていきたいと思います。
ちょっと複雑な話なんですが、これができると今後のアプリにも応用が効くのでぜひ理解を深めてほしいと思います。

ここで学べることはこんな感じ。

  • APIを利用した認証方法が分かる
  • 管理画面のような認証ユーザーのみがアクセスできるページを作れる

SPAについて

まず、SPAについて簡単にまとめておくと、SPAは”Single Page Application”のことで、「1ページでできたアプリ」です。

HTMLファイルは1つのみで、そこにurlに応じて変更すべき部分のみを変えるという設計でできています。

通常なら、画面遷移のたびに1ページ丸々レンダリングしないといけないんですが、SPAは必要部分だけを更新するだけなのでパフォーマンスが良くなります。

ただ、SPAの認証はAPIを叩いて行うので少し複雑になります。
が、頑張って理解していきましょう。

Laravelプロジェクトを作成

まずはLaravelプロジェクトを作成しましょう。

composer create-project laravel/laravel my-app

パッケージのインストール

次にパッケージをいくつかインストールします。
package.jsonの中身はこんな感じです。


{
    "private": true,
    "scripts": {
        "dev": "npm run development",
        "development": "mix",
        "watch": "mix watch",
        "watch-poll": "mix watch -- --watch-options-poll=1000",
        "hot": "mix watch --hot",
        "prod": "npm run production",
        "production": "mix --production"
    },
    "devDependencies": {
        "@emotion/react": "^11.4.1",
        "@emotion/styled": "^11.3.0",
        "@mui/icons-material": "^5.0.4",
        "@mui/lab": "^5.0.0-alpha.51",
        "@mui/material": "^5.0.3",
        "@types/react": "^17.0.29",
        "@types/react-dom": "^17.0.9",
        "@types/react-router-dom": "^5.3.1",
        "autoprefixer": "^10.3.7",
        "axios": "^0.21",
        "import-glob-loader": "^1.1.0",
        "laravel-mix": "^6.0.6",
        "lodash": "^4.17.19",
        "postcss": "^8.1.14",
        "postcss-import": "^14.0.2",
        "react": "^17.0.2",
        "react-dom": "^17.0.2",
        "react-hook-form": "^7.17.3",
        "react-router": "^5.2.1",
        "react-router-dom": "^5.3.0",
        "resolve-url-loader": "^4.0.0",
        "sass": "^1.42.1",
        "sass-loader": "^12.2.0",
        "tailwindcss": "^2.2.16",
        "ts-loader": "^9.2.6",
        "typescript": "^4.4.4"
    },
}

npm installでこれらをインストールします。
また、tailwindcssを使うので以下のコマンドを実行します。

npx tailwindcss init

react系のパッケージは必須です。
sasstailwindcssmaterial-uiは任意ですが、便利なので使ってみることをオススメします。
ここではtypescriptで書くのでこちらもインストールしています。

React.jsやSassの設定

Reactjsやtypescriptなどを使うために、laravel-mixの設定します。
ここは本題とは逸れるので、詳しい説明は省きます。
webpack.mix.jsを開き、以下のように修正。


const mix = require('laravel-mix');
const tailwindcss = require('tailwindcss'); //追加

/*
 |--------------------------------------------------------------------------
 | Mix Asset Management
 |--------------------------------------------------------------------------
 |
 | Mix provides a clean, fluent API for defining some Webpack build steps
 | for your Laravel applications. By default, we are compiling the CSS
 | file for the application as well as bundling up all the JS files.
 |
 */
mix.webpackConfig({ // 追加 -> sassファイルを一括コンパイルする
  devtool: "source-map",
  module: {
    rules: [
      {
        test: /\.scss/,
        loader: 'import-glob-loader'
      }
    ]
  }
});

//いくつか変更
mix.ts('resources/ts/app.tsx', 'public/js')
    .js('resources/js/app.js', 'public/js')
    .sass('resources/sass/app.scss', 'public/css')
    .options({
      processCssUrls: false,
      postCss: [tailwindcss('./tailwind.config.js')],
    });

また、resourcesフォルダの中にsassフォルダを作り、この下にapp.scssを作ります。中身は以下の通り。


@use 'tailwindcss/base';
@use 'tailwindcss/components';
@use 'tailwindcss/utilities';

これでtailwindcssが使えます。

typescriptを使うため、tsconfig.jsonを作り編集します。


{
  "compilerOptions": {
      "target": "es5",
      "module": "commonjs",
      "jsx": "react",
      "strict": true,
      "esModuleInterop": true
  }
}

また、resourcesフォルダの中にtsフォルダを作成しておきましょう。
以降、react.jsのコードはこのフォルダに書いていきます。

FortifyとSanctumのインストール

Laravelの認証パッケージとして, FortifyとSanctumを使います。
以下二つのコマンドをそれぞれ実行してインストールします。


composer require laravel/fortify

composer require laravel/sanctum

Fortifyの設定

以下のコマンドを実行。

php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"

config/fortify.phpを開き以下の部分を編集。



'prefix' => 'api',

'views' => false,

'features' => [
    Features::registration(),
    Features::resetPasswords(),
    // Features::emailVerification(),
    Features::updateProfileInformation(),
    Features::updatePasswords(),
    // Features::twoFactorAuthentication([
    //     'confirmPassword' => true,
    // ]),
],

config.app.phpを編集します。


'providers' => [
//いろいろある
App\Providers\FortifyServiceProvider::class, //追加
]

Sanctumの設定

以下のコマンドを実行。

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

app/Http/Kernel.phpを開き、以下の部分を編集


'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

routes/api.phpを開き、以下のように編集


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

ルーティング、ビューの設定

SPAのためのルーティングとビューの設定をしていきます。

routes/web.phpを開き、以下のように編集します。


<?php

use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/{any}', function() {
  return view('app');
})->where('any', '.*');

これは、どのURLでアクセスした場合でもappビューを表示するというものです。
SPAは1ページしか用意しないので、そのページにアクセスされるように設定しています。

次にresources/views/app.blade.phpを作成し以下のように編集。


<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="{{ csrf_token() }}">
        <title>{{config('app.name')}}</title>
        <link href="{{ asset('css/app.css') }}" rel="stylesheet">
    </head>
    <body>
        <div id="app"><div>
    </body>
    <script src="{{ mix('js/app.js') }}"></script>
</html>

最後に、以下のコマンドを実行してMySQLにテーブルを作成しましょう。

php artisan migrate

以上で初期設定は終了です。長くなりましたが、ここから本題です。

React + Laravelで認証処理を行う

  • 以降はresources/tsの中でファイル作成・編集していきます。
  • npm run watchを実行し、tsxファイル等が常にコンパイルされる状態にしておいてください

まず、TOP, 登録画面とログイン画面、ホーム画面をreactで作ります。
viewsフォルダを作成し、この下に
Top.tsxRegister.tsxLogin.tsxHome.tsxをそれぞれ作成して編集します。

Top.tsx


import React from "react";
import { Link } from "react-router-dom";

const Top = () => {
  return (
    <div className="p-4">
      <ul>
        <li><Link to="/register">登録</Link></li>
        <li><Link to="/login">ログイン</Link></li>
        <li><Link to="/home">ホーム</Link></li>
      </ul>
    </div>
  )
}

export default Top;

Register.tsx


import React, { useState } from "react";
import { Link } from "react-router-dom";
import axios from "axios"
import { useForm } from "react-hook-form";
import TextField from '@mui/material/TextField';
import { LoadingButton } from '@mui/lab';
import { useHistory } from "react-router-dom";

interface EmailAndPasswordData {
  email: string,
  password: string,
  password_confirmation: string
}

const Register = () => {
  const { register, handleSubmit, setError, formState: { errors } } = useForm();
  const history = useHistory()
  const [loading, setLoading] = useState(false);

  const onSubmit = (data: EmailAndPasswordData) => {
    setLoading(true)
    axios.get('/sanctum/csrf-cookie').then(() => {
      axios.post('/api/register', data).then(() => {
        history.push('home')
      }).catch(error => {
        console.log(error)
        setError('submit', {
          type: 'manual',
          message: '登録に失敗しました。再度登録をしてください'
        })
        setLoading(false)
      })
    })
  }

  return (
    <div className="p-4 max-w-screen-sm mx-auto">
        <h1 className="text-center text-xl font-bold">アカウント作成</h1>
        <p className="text-center"><Link to="/login" className="text-sm c-link">アカウントを持っている方はこちら</Link></p>
        <form className="py-4" onSubmit={handleSubmit(onSubmit)}>
          <div className="py-4">
            <TextField 
              fullWidth
              variant="outlined" 
              label="名前" 
              {...register('name', {
                required: '入力してください'
              })} 
            />
            {errors.name && <span className="block text-red-400">{errors.name.message}</span>}
          </div>
          <div className="py-4">
            <TextField 
              fullWidth
              variant="outlined" 
              label="メールアドレス" 
              {...register('email', {
                required: '入力してください'
              })} 
            />
            {errors.email && <span className="block text-red-400">{errors.email.message}</span>}
          </div>
          <div className="py-4">
            <TextField 
              fullWidth
              id="password"
              type="password" 
              variant="outlined" 
              label="パスワード" 
              {...register('password', {
                required: '入力してください',
                minLength: {
                  value: 8,
                  message: '8文字以上で入力してください'
                }
              })} 
            />
            {errors.password && <span className="block text-red-400">{errors.password.message}</span>}
          </div>
          <div className="py-4">
            <TextField 
              fullWidth
              type="password" 
              variant="outlined" 
              label="パスワード確認" 
              {...register('password_confirmation', {
                required: '入力してください',
                validate: {
                  match: value => value === (document.getElementById('password') as HTMLInputElement).value || 'パスワードが一致しません'
                }
              })} 
            />
            {errors.password_confirmation && <span className="block text-red-400">{errors.password_confirmation.message}</span>}
          </div>
          <div>
            <LoadingButton type="submit" loading={loading} variant="contained" fullWidth>アカウントを作成する</LoadingButton>
            {errors.submit && <span className="block text-red-400">{errors.submit.message}</span>}
          </div>
        </form>
      </div>
  )
}

export default Register

Login.tsx


import React, { useState } from "react";
import { Link } from "react-router-dom";
import axios from "axios"
import { useForm } from "react-hook-form";
import TextField from '@mui/material/TextField';
import { LoadingButton } from '@mui/lab';
import { useHistory } from "react-router";

interface LoginData {
  email: string,
  password: string,
}

const Login = () => {
  const { register, handleSubmit, setError, clearErrors, formState: { errors } } = useForm();
  const history = useHistory()
  const [loading, setLoading] = useState(false);

  const onSubmit = (data: LoginData) => {
    setLoading(true)
    axios.get('/sanctum/csrf-cookie').then(() => {
      axios.post('/api/login', data).then(() => {
        history.push('home')
      }).catch(error => {
        console.log(error)
        setError('submit', {
          type: 'manual',
          message: 'ログインに失敗しました'
        })
        setLoading(false)
      })
    })
  }

  return (
    <div className="p-4 max-w-screen-sm mx-auto">
      <h1 className="text-center text-xl font-bold pb-4">ログイン</h1>
      <p className="text-center"><Link to="/register" className="text-sm c-link">アカウントを持っていない方はこちら</Link></p>
      <form className="py-4" onSubmit={e => {clearErrors(); handleSubmit(onSubmit)(e)}}>
        <div className="py-4">
          <TextField 
            fullWidth
            variant="outlined" 
            label="メールアドレス" 
            {...register('email', {
              required: '入力してください'
            })} 
          />
          {errors.email && <span className="block text-red-400">{errors.email.message}</span>}
        </div>
        <div className="py-4">
          <TextField 
            fullWidth
            id="password"
            type="password" 
            variant="outlined" 
            label="パスワード" 
            {...register('password', {
              required: '入力してください'
            })} 
          />
          {errors.password && <span className="block text-red-400">{errors.password.message}</span>}
        </div>
        <div className="text-center">
          <div>
            <LoadingButton loading={loading} type="submit" variant="contained" fullWidth>Login</LoadingButton>
          </div>
          {errors.submit && <span className="block text-red-400">{errors.submit.message}</span>}
        </div>
      </form>
    </div>
  )
}

export default Login

Home.tsx


import { Button } from "@mui/material";
import axios from "axios";
import React from "react";
import { useHistory } from "react-router";

const Home = () => {
  const history = useHistory();
  const logout = () => {
    axios.get('/sanctum/csrf-cookie').then(() => {
      axios.post('/api/logout', {}).then(() => {
        history.push('/login');
      })
    })
  }
  return (
    <div className="p-4">
      <h1>Home</h1>
      <Button variant="contained" onClick={logout}>ログアウト</Button>
      {/* // アカウント情報を書く */}
    </div>
  )
}

export default Home;

またapp.tsxを作成し、以下を追記


import React from 'react'
import ReactDOM from 'react-dom'
import {BrowserRouter, Route, Switch} from 'react-router-dom'
import Home from './views/Home'
import Login from './views/Login'
import Register from './views/Register'
import Top from './views/Top'
 
const App = () => {
    return (
        <BrowserRouter>
          <div>
            <Switch>
                <Route path="/" exact component={TOP} />
                <Route path="/register" exact component={Register} />
                <Route path="/login" exact component={Login} />
                <Route path="/home" exact component={Home} />
            </Switch>
          </div>
        </BrowserRouter>
    )
}
 
ReactDOM.render(
    <App />,
    document.getElementById('app')
)

ローカルサーバーを立ち上げてhttp://127.0.0.1:8000/registerにアクセスしたときに、以下のようになっていればOKです。

登録、ログインもできるので確認してみてください!

React + Laravelでアクセス制限をする

ここからがかなり重要なので1つ1つ確認しつつお願いします。

認証が必要なアプリではほとんどがユーザーによってアクセス制限をかけています。
例えば、管理画面は認証ユーザーのみが入れるようにしていますし、有料・無料プランでアクセスできるページが異なることも多いです。

普通にLaravelで作った場合はBlade TemplateでAuth::check()等が使えるので割と簡単にできます。

SPAだと初回以外はブラウザによるローディングがありませんので、代わりにページに飛んだらAPIを叩いて認証済みかどうかを判定する必要があります。

今回の場合、以下のようにAPIを呼んでユーザーが取得できたら認証済みか判断できます。


axios.get('/api/user').then((res) => {
  //ユーザー情報が取得できる
  console.log(res.data)
}).catch((error) => {
  //ログインしていない場合はログイン画面に移動させる
  history.push('/login');
})

ただ、各ページでAPIを叩いてこれをやるのはちょっと問題があります。

  • 各ページでAPIを叩くのは無駄 -> ユーザー情報は多くのページで使うから初回で取得して使いまわしたい
  • データ取得まで時間がかかることがあり、その間画面表示ができない -> アクセスした段階でログイン画面にリダイレクトするか否かを判断したい
  • そもそも毎回この処理を書くのがダルイ -> 1つにまとめたい
  • ユーザー情報を更新した際に、リロードするまで反映されない -> 更新した情報も同時に取得したい

ということで、ユーザー認証に関連する処理を1つにまとめてしまえばすべて解決します。

ひとまず答えを出しておきます。

まずは結論から、
AuthContext.tsxを作成。


import axios, { AxiosResponse } from "axios";
import React, {useContext, createContext, useState, ReactNode, useEffect} from "react"
import {Route, Redirect, useHistory} from "react-router-dom"

interface User {
  id: number
  name: string
  email: string
  email_verified_at: string | null
  two_factor_recovery_codes: string | null
  two_factor_secret: string | null
  created_at: string
  updated_at: string | null
}
interface LoginData {
  email: string,
  password: string,
}
interface RegisterData {
  email: string,
  password: string,
  password_confirmation: string,
}
interface ProfileData {
  name?: string,
  email?: string
}
interface authProps {
  user: User | null;
  register: (registerData: RegisterData) => Promise<void>
  signin: (loginData: LoginData) => Promise<void>;
  signout: () => Promise<void>;
  saveProfile: (formData: FormData | ProfileData) => Promise<void>;
}
interface Props {
  children: ReactNode
}
interface RouteProps {
  children: ReactNode,
  path: string,
  exact?: boolean
}
interface From {
  from: Location
}

const authContext = createContext<authProps | null>(null)

const ProvideAuth = ({children}: Props) => {
  const auth = useProvideAuth();
  return (
    <authContext.Provider value={auth}>
      {children}
    </authContext.Provider>
  )
}
export default ProvideAuth

export const useAuth = () => {
  return useContext(authContext)
}

const useProvideAuth = () => {
  const [user, setUser] = useState<User | null>(null);

  const register = (registerData: RegisterData) => {
    return axios.post('/api/register', registerData).then((res) => {
      axios.get('api/user').then((res) => {
        setUser(res.data)
      })
    })
  }

  const signin = async (loginData: LoginData) => {
    try {
      const res = await axios.post('/api/login', loginData);
    } catch (error) {
      throw error;
    }

    return axios.get('/api/user').then((res) => {
      setUser(res.data)
    }).catch((error) => {
      setUser(null)
    })
  }

  const signout = () => {
    return axios.post('/api/logout', {}).then(() => {
      setUser(null)
    })
  }

  const saveProfile = async (formData: FormData | ProfileData) => {
    const res = await axios.post(
      '/api/user/profile-information', 
      formData, 
      {headers: {'X-HTTP-Method-Override': 'PUT'}}
    )
    .catch((error) => {
      throw error;
    })
    if(res?.status == 200) {
      return axios.get('/api/user').then((res) => {
        setUser(res.data)
      }).catch((error) => {
        setUser(null)
      })
    }
  }

  useEffect(() => {
    axios.get('/api/user').then((res) => {
      setUser(res.data)
    }).catch((error) => {
      setUser(null)
    })
  }, [])

  return {
    user,
    register,
    signin,
    signout,
    saveProfile
  }
}

/**
 * 認証済みのみアクセス可能
 */
export const PrivateRoute = ({children, path, exact = false}: RouteProps) => {
  const auth = useAuth()
  return (
    <Route
      path={path}
      exact={exact}
      render={({ location }) => {
        if(auth?.user == null) {
          return <Redirect to={{ pathname: "/login", state: { from: location }}}/>
        } else {
          return children
        }
      }}
    />
  )
}


/**
 * 認証していない場合のみアクセス可能(ログイン画面など)
 */
export const PublicRoute = ({children, path, exact = false}: RouteProps) => {
  const auth = useAuth()
  const history = useHistory()
  return (
    <Route
      path={path}
      exact={exact}
      render={({ location }) => {
        if(auth?.user == null) {
          return children
        } else {
          return <Redirect to={{pathname: (history.location.state as From) ? (history.location.state as From).from.pathname : '/' , state: { from: location }}}/>
        }
      }}
    />
  )
}

App.tsxを編集


import React from 'react'
import ReactDOM from 'react-dom'
import {BrowserRouter, Route, Switch} from 'react-router-dom'
import Home from './views/Home'
import Login from './views/Login'
import Register from './views/Register'
import Top from './views/Top'
import ProvideAuth, { PrivateRoute, PublicRoute } from './AuthContext' //追加
 
const App = () => { // 編集
    return (
      <ProvideAuth>
        <BrowserRouter>
          <div>
            <Switch>
                <Route path="/" exact><Top /></Route>
                <PublicRoute path="/register" exact><Register/></PublicRoute>
                <PublicRoute path="/login" exact><Login/></PublicRoute>
                <PrivateRoute path="/home" exact><Home/></PrivateRoute>
            </Switch>
          </div>
        </BrowserRouter>
      </ProvideAuth>
    )
}
 
ReactDOM.render(
    <App />,
    document.getElementById('app')
)

Register.tsxを編集


import {useAuth} from "../AuthContext"; // 追加

const Register = () => {
  const { register, handleSubmit, setError, formState: { errors } } = useForm();
  const history = useHistory()
  const [loading, setLoading] = useState(false);
  const auth = useAuth() // 追加

  const onSubmit = (data: EmailAndPasswordData) => {
    setLoading(true)
    axios.get('/sanctum/csrf-cookie').then(() => {
      auth?.register(data).then(() => { //編集
        history.push('home')
      }).catch((error) => {
        setError('submit', {
          type: 'manual',
          message: '登録に失敗しました。再度登録をしてください'
        })
        setLoading(false)
      })
    })
  }

//以下省略

Login.tsxを編集


import React, { useState } from "react";
import { Link } from "react-router-dom";
import axios from "axios"
import { useForm } from "react-hook-form";
import TextField from '@mui/material/TextField';
import { LoadingButton } from '@mui/lab';
import { useHistory } from "react-router";
import {useAuth} from "../AuthContext"; //追加

interface LoginData {
  email: string,
  password: string,
}

const Login = () => {
  const { register, handleSubmit, setError, clearErrors, formState: { errors } } = useForm();
  const history = useHistory()
  const [loading, setLoading] = useState(false);
  const auth = useAuth(); //追加

  const onSubmit = (data: LoginData) => {
    setLoading(true)
    axios.get('/sanctum/csrf-cookie').then(() => { //編集
      auth?.signin(data).then(() => {
        history.push('home')
      }).catch(error => {
        console.log(error)
        setError('submit', {
          type: 'manual',
          message: 'ログインに失敗しました'
        })
        setLoading(false)
      })
    })
  }

Home.tsxを編集


import { Button } from "@mui/material";
import axios from "axios";
import React from "react";
import { useHistory } from "react-router";
import { useAuth } from "../AuthContext"; //追加

const Home = () => {
  const history = useHistory();
  const auth = useAuth(); // 追加

  const logout = () => {
    axios.get('/sanctum/csrf-cookie').then(() => {
      auth?.signout().then(() => { // 編集
        history.push('/login');
      })
    })
  }
  return (
    <div className="p-4">
      <h1>Home</h1>
      <p>Hello! {auth?.user?.name}</p> // 追加
      <Button variant="contained" onClick={logout}>ログアウト</Button>
    </div>
  )
}
export default Home;

これで、アクセス制限ができます。

認証とアクセス制限の解説

コードの解説をしていきたいと思います。

AuthContext.tsxについて

このファイルで「SPA認証」と「アクセス制限」をしています。

ProvideAuthでアクセス制限を行う

ProvideAuth関数の内容はこうなってます。


const ProvideAuth = ({children}: Props) => {
  const auth = useProvideAuth();
  return (
    <authContext.Provider value={auth}>
      {children}
    </authContext.Provider>
  )
}
export default ProvideAuth

authは認証ユーザーのデータを持っています。useProvideAuth()はまだ定義していないですが、後程やります。

また、ここではReact Contextを使っています。これはざっくり説明すると「下のコンポーネントに値を渡す機能」です。

Contextを利用するために、createContextで新規作成する必要があります。

const authContext = createContext<authProps | null>(null)

これの意味はこんな感じです。

  • 以降authContextを使った中では、どのコンポーネントでも値を渡すことができる
  • 渡す値の型はauthPropsまたはnullで初期値はnullauthPropsは自分で定義しています。)

authContext.Providerタグで囲った中ではどのコンポーネントからでもauthが使えるということです。


// childrenにはコンポーネントが入り、それらすべてがauthという値を使うことができる
    <authContext.Provider value={auth}>
      {children}
    </authContext.Provider>

useAuthはユーザー情報を取得す

useAuth関数はこうなっています。


export const useAuth = () => {
  return useContext(authContext)
}

非常にシンプルです。これは各コンポーネントでauthの値を取得する処理です(フックです)。
さっき見たContextが出てきていますね。Contextによって渡された値を使うにはuseContextを使う必要があります。

今示した2つが、さっきの問題にもあった「認証ユーザ情報を使いまわしたい」というのを実現しています。

useProvideAuthはユーザー情報の処理を行う

useProvideAuthには登録、ログイン、ユーザー情報取得といった、ユーザー情報に関連した処理を一式まとめています。


const [user, setUser] = useState<User | null>(null);

最初にこんなコードがありますが、これはReact State(状態管理)を利用しています。

ユーザー情報(名前やメールアドレスなど)がuserに入ります。


  const register = (registerData: RegisterData) => {

  }

  const signin = async (loginData: LoginData) => {

  }

  const signout = () => {

  }
  const saveProfile = async (formData: FormData | ProfileData) => {
  }

これらはそれぞれ、登録、ログイン、ログアウト、ユーザー情報更新を行っています。
最初にRegister.tsxなどに書いた処理を、ここで行っています。

書く処理でsetUser(res.data)のようにすることで、先ほどみたuserにユーザー情報が入ります。

これがあることで、リロードしなくてもユーザー情報が更新されるので、SPAのようなリロードしないアプリでもユーザー情報を引き継ぐことができます。


  useEffect(() => {
    axios.get('/api/user').then((res) => {
      setUser(res.data)
    }).catch((error) => {
      setUser(null)
    })
  }, [])

ここでは、初回アクセス時にユーザーの情報を取得しています。
useEffectはReactフックの一つで、「レンダリングされた後に処理を行う」というものです。

上記のように書くことで「初回だけユーザー情報を取得する」ということができます。

これは最初に上げた問題「初回だけユーザー情報を取得して、サーバーの負荷を減らす」というのを解決しています。

ちなみに、「値を取得するだけならuseEffectいらないんじゃね?」と思うかもですが、これを使わないと、無限ループしてしまい何度もユーザー情報を取得することになってしまします。


  return {
    user,
    register,
    signin,
    signout,
    saveProfile
  }

最後にユーザー情報処理、およびユーザー情報を返します。

さっきみたProvideAuthで以下のコードがありました。


const auth = useProvideAuth();

もうわかりますね。authはユーザー処理関数とユーザー情報が入っています。

PrivateRoutePublicRouteでアクセス制限をする

最後にPrivateRoutePublicRouteです。
PrivateRouteでは「認証ユーザーのみアクセス可能」としています。


export const PrivateRoute = ({children, path, exact = false}: RouteProps) => {
  const auth = useAuth()
  return (
    <Route
      path={path}
      exact={exact}
      render={({ location }) => {
        if(auth?.user == null) {
          return <Redirect to={{ pathname: "/login", state: { from: location }}}/>
        } else {
          return children
        }
      }}
    />
  )
}

まず、authにユーザー情報を入れています。
そして、auth.user == nullつまり、ユーザー情報がない場合は認証されていないので、ログイン画面にリダイレクトします。

そうでなければ、本来の画面を表示します。

PublicRouteでは「非認証ユーザーのみがアクセス可能」を表しています。
これはさっきの逆です。

PrivateRouteは管理画面などに使えて、PublicRouteはログイン画面などに使えます。

App.tsxについて

App.tsxではルーティングを行っているので、このファイル内で先ほどのアクセス制限をやっていきます。


import ProvideAuth, { PrivateRoute, PublicRoute } from './AuthContext'
 
const App = () => {
    return (
      <ProvideAuth>
        <BrowserRouter>
          <div>
            <Switch>
                <Route path="/" exact><Top /></Route>
                <PublicRoute path="/register" exact><Register/></PublicRoute>
                <PublicRoute path="/login" exact><Login/></PublicRoute>
                <PrivateRoute path="/home" exact><Home/></PrivateRoute>
            </Switch>
          </div>
        </BrowserRouter>
      </ProvideAuth>
    )
}

まず全体をProvideAuthで囲っています。
復習すると、ProvideAuthはContextを利用しており、「auth(ユーザー処理関数とユーザー情報を持つ)をProvideAuth内のコンポーネントすべてで使える」ようにしています。

各ページのルーティングですが以下の通りです。

  • topは誰でもOKなので、Route
  • registerloginは非認証のみなので、PublicRoute
  • homeは認証のみなので、PrivateRoute

これで、アクセス制限ができました。試しに、ログインしてない状態でHomeに行こうとしてください。
ログイン画面にリダイレクトされるはずです。

登録、ログイン、ログアウトの処理を編集

最後です。
先ほどのAuthContext.tsxに書いた認証処理を各ページで呼び出して使いましょう。

Register.tsx


import {useAuth} from "../AuthContext"; // useAuthはユーザーに関する処理をまとめたもの

  const onSubmit = (data: EmailAndPasswordData) => {
    setLoading(true)
    axios.get('/sanctum/csrf-cookie').then(() => {
      auth?.register(data).then(() => { // authの中のregisterを実行しアカウント作成
        history.push('home')
      }).catch((error) => {
        setError('submit', {
          type: 'manual',
          message: '登録に失敗しました。再度登録をしてください'
        })
        setLoading(false)
      })
    })
  }

Login.tsx


import {useAuth} from "../AuthContext"; // 追加

  const onSubmit = (data: LoginData) => {
    setLoading(true)
    axios.get('/sanctum/csrf-cookie').then(() => {
      auth?.signin(data).then(() => { // ここ編集
        history.push('home')
      }).catch(error => {
        console.log(error)
        setError('submit', {
          type: 'manual',
          message: 'ログインに失敗しました'
        })
        setLoading(false)
      })
    })
  }

Home.tsx


import { useAuth } from "../AuthContext"; // 追加

const Home = () => {
  const history = useHistory();
  const auth = useAuth(); // 追加
  const logout = () => {
    axios.get('/sanctum/csrf-cookie').then(() => {
      auth?.signout().then(() => { // 編集
        history.push('/login');
      })
    })
  }
  return (
    <div className="p-4">
      <h1>Home</h1>
      <p>Hello! {auth?.user?.name}</p> // 追加
      <Button variant="contained" onClick={logout}>ログアウト</Button>
    </div>
  )
}

export default Home;

これで完成です。

まとめ

お疲れ様でした。めっちゃ長くなりましたが、これは最初だけ理解できればだいたいのアプリに応用できます。
今回はLaravelでしたが、Firebaseなどでも同じです。登録やログインの処理を少し変えるだけで同様のことができます。

最後にGitHubにコード例を置いとくので困ったときは見てください。
https://github.com/S-Akinori/laravel-react-auth

それでは。

スポンサードサーチ

オススメ英語学習用SNS "Our Dictionary"

人気記事英語学習用SNSをLaravelで作ってみた【システム解説あり】