Masayan tech blog.

  1. ブログ記事一覧>
  2. 【Next.jsとLaravelのJamstackなWebサービス】Part3:認証

【Next.jsとLaravelのJamstackなWebサービス】Part3:認証

公開日

環境

  • windows10
  • DockerDesktop for Win 3.5.x
  • Laravel 8.x
  • PHP 8.x
  • node 16.13.1
  • npm 8.1.2
  • TypeScript 4.5
  • React 17.0.1
  • Next.js 12.0.7
  • Recoil 0.5.2
  • VsCode
  • gitbash 2.32.0.1

構成

API

Laravel Sanctumを用いたクッキーベースの認証APIを作成する

Frontend

Next.jsからaxiosでAPIへHttpリクエストを送る

処理の流れ

Csrfトークンの初期化

認証(サインイン)のリクエスト

セッション切れの場合

実装

API構築

  1. Authコントローラー(api\laravel-api\app\Http\Controllers\Auth\AuthController.php)の作成
    ・loginはリクエストでメールアドレスとパスワードを受け取り、認証に成功したらuser情報を返却する
    ・logoutは、認証状態をログアウトし、セッションを再生成する
    ・registerは、ユーザー名とメールアドレスとパスワードを受け取ってユーザーをログインさせ、ユーザー情報を返却する
    <?php
    
    namespace App\Http\Controllers\Auth;
    
    use App\Http\Controllers\Controller;
    use App\Models\ModelUser;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\Hash;
    
    class AuthController extends Controller
    {
        public function login(Request $request)
        {
            $credentials = $request->validate([
                "email" => ["required", "email"],
                "password" => ["required"],
            ]);
    
            if (Auth::attempt($credentials)) {
                $request->session()->regenerate();
                return response()->json(Auth::user());
            }
    
            return response()->json([], 401);
        }
    
        public function logout(Request $request)
        {
            Auth::logout();
    
            $request->session()->invalidate();
            $request->session()->regenerateToken();
    
            return response()->json(true);
        }
    
        public function register(Request $request)
        {
            $user = ModelUser::create([
                "name" => $request->username,
                "email" => $request->email,
                "password" => Hash::make($request->password),
            ]);
    
            Auth::login($user);
            return response()->json($user, 200);
        }
    }
  2. Apiを作成する
    ・ガード不要として、それぞれのルートを設定する
    <?php
    
    use App\Http\Controllers\Auth\AuthController;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Route;
    
    Route::post("/login", [AuthController::class, "login"]);
    Route::post("/logout", [AuthController::class, "logout"]);
    Route::post("/register", [AuthController::class, "register"]);
    
    Route::middleware("auth:sanctum")->get("/user", function (Request $request) {
        return $request->user();
    });
  3. sanctumのセットアップ
    laravelでクッキーベースの認証を行う際はsanctumを使用した認証が可能です。詳細はこちらの記事にまとめていますので参照のうえ、初期設定を行ってください

    LaravelでSPA開発が可能なLaravel Sanctumの概要と導入手順を紹介する

認証のリクエスト処理

  1. カスタムフックを作成する(frontend\next-web\src\components\shared\function\Auth\AuthLisnter.tsx)
    ・ログイン、ログアウト、新規登録、認証のリッスン用のそれぞれの関数を用意する
    import React, { useCallback, useState, useEffect, useContext } from 'react'
    import axios from 'libs/axios'
    import { useRouter } from 'next/router'
    import { useRecoilState } from 'recoil'
    import { IUserState, userState } from 'components/store/atom/auth'
    import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
    import userData from '../../../../types/user/user.json'
    import Route from '../../variable/Route'
    
    type USER = typeof userData
    
    export const useHandleLogin = () => {
      const [user, setUser] = useRecoilState<IUserState>(userState)
      const router = useRouter()
    
      const handleLogin: (email: string, password: string) => void = (email, password) => {
        const options: AxiosRequestConfig = {
          url: '/api/login',
          method: 'POST',
          params: {
            email,
            password
          }
        }
    
        axios.get('/sanctum/csrf-cookie').then((res: AxiosResponse) => {
          axios(options)
            .then((res: AxiosResponse<USER>) => {
              const user = res.data
              setUser({
                id: user.id,
                name: user.name,
                email: user.email,
                isSignedIn: true
              })
              router.push('/')
            })
            .catch((error: AxiosError) => {
              console.log(error)
            })
        })
      }
    
      return {
        handleLogin
      }
    }
    
    export const useHandleLogout = () => {
      const [user, setUser] = useRecoilState<IUserState>(userState)
      const router = useRouter()
      const options: AxiosRequestConfig = {
        url: '/api/logout',
        method: 'POST'
      }
    
      return () => {
        axios.get('/sanctum/csrf-cookie').then((res) => {
          axios(options)
            .then((res: AxiosResponse<boolean>) => {
              setUser({
                id: null,
                name: '',
                email: '',
                isSignedIn: false
              })
              router.push('/login')
            })
            .catch((error: AxiosError) => {
              console.log(error)
            })
        })
      }
    }
    
    export const useHandleRegister = () => {
      const [user, setUser] = useRecoilState<IUserState>(userState)
      const router = useRouter()
      const handleRegister = (userName: string, email: string, password: string): void => {
        const options: AxiosRequestConfig = {
          url: '/api/register',
          method: 'POST',
          params: {
            username: userName,
            email: email,
            password: password
          }
        }
    
        axios.get('/sanctum/csrf-cookie').then((res) => {
          axios(options)
            .then((res: AxiosResponse<USER>) => {
              const user = res.data
              setUser({
                id: user.id,
                name: user.name,
                email: user.email,
                isSignedIn: true
              })
              router.push('/')
            })
            .catch((error: AxiosError) => {
              console.log(error)
            })
        })
      }
    
      return {
        handleRegister
      }
    }
    
    export const useListenAuthState = () => {
      const [user, setUser] = useRecoilState<IUserState>(userState)
      const router = useRouter()
    
      const listenAuthState = (): void => {
        const options: AxiosRequestConfig = {
          url: '/api/user',
          method: 'GET'
        }
    
        axios(options)
          .then((res: AxiosResponse<USER>) => {
            const user = res.data
            console.log(user)
    
            // 認証状態で認証不要ルートに遷移した場合
            if (Route.noGuardedRoutes().includes(router.pathname)) {
              router.push('/')
            }
    
            setUser({
              id: user.id,
              name: user.name,
              email: user.email,
              isSignedIn: true
            })
          })
          .catch((error: AxiosError) => {
            setUser({
              id: null,
              name: '',
              email: '',
              isSignedIn: false
            })
            // TODO: セッションタイムアウト(401)時の処理
            if (Route.noGuardedRoutes().includes(router.pathname)) {
              router.push(router.pathname)
            } else {
              router.push('/login')
            }
          })
      }
    
      return {
        listenAuthState
      }
    }
  2. ログイン(frontend\next-web\src\components\atomic\Pages\Auth\LoginPage.tsx)
    ・ログインボタン押下でhandleLoginメソッドが実行されるように
    import { TextInput, Spacer } from 'components/atomic/Atoms'
    import { useHandleLogin } from 'components/shared/function/Auth/AuthLisnter'
    import BasicButton from 'components/atomic/Atoms/Button/BasicButton'
    import BasicContainer from 'components/atomic/Atoms/Container/BasicContainer'
    import React, { useCallback, useState } from 'react'
    import BasicHeadingThree from 'components/atomic/Atoms/Heading/BasicHeadingThree'
    import BasicLink from 'components/atomic/Atoms/Link/BasicLink'
    
    const LoginPage = () => {
      const [email, setEmail] = useState('')
      const [password, setPassword] = useState('')
    
      const { handleLogin } = useHandleLogin()
      const inputEmail = useCallback(
        (event: React.ChangeEvent<HTMLInputElement>) => {
          setEmail(event.target.value)
        },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [email]
      )
    
      const inputPassword = useCallback(
        (event: React.ChangeEvent<HTMLInputElement>) => {
          setPassword(event.target.value)
        },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [password]
      )
    
      return (
        <BasicContainer className={'flex flex-col h-1/2 space-y-6 w-1/2 mx-auto mt-32 max-w-sm'}>
          <BasicHeadingThree text="ログイン画面" className=" text-center" />
          <TextInput
            onChange={inputEmail}
            placeholder="メールアドレス"
            required={true}
            type={'email'}
            value={email}
          />
          <TextInput
            onChange={inputPassword}
            placeholder="パスワード"
            required={true}
            type={'password'}
            value={password}
          />
          <Spacer className="h-20" />
          <BasicButton
            bg="BLUE"
            buttonName="ログイン"
            className=""
            onClick={() => handleLogin(email, password)}
          />
          <BasicButton
            bg="RED"
            buttonName="Googleアカウントでログイン"
            className=""
            onClick={() => handleLogin(email, password)}
          />
          <Spacer className="h-20" />
          <BasicLink
            className=" text-blue-400 text-xs text-center"
            href={{
              pathname: '/register',
              query: null
            }}
            name={'アカウントをお持ちでない方はこちら'}
          />
        </BasicContainer>
      )
    }
    
    export default LoginPage
  3. 新規会員登録(frontend\next-web\src\components\atomic\Pages\Auth\RegisterPage.tsx)
    ・登録するボタンを押下でhandleRegisterメソッドが実行されるように
    import BasicLink from 'components/atomic/Atoms/Link/BasicLink'
    import React, { useCallback, useState, useEffect, useContext } from 'react'
    import { useHandleRegister } from 'components/shared/function/Auth/AuthLisnter'
    import { Spacer, TextInput } from 'components/atomic/Atoms'
    import BasicContainer from 'components/atomic/Atoms/Container/BasicContainer'
    import BasicHeadingThree from 'components/atomic/Atoms/Heading/BasicHeadingThree'
    import BasicButton from 'components/atomic/Atoms/Button/BasicButton'
    
    const RegisterPage = () => {
      const [userName, setUserName] = useState('')
      const [email, setEmail] = useState('')
      const [password, setPassword] = useState('')
    
      const { handleRegister } = useHandleRegister()
    
      const inputUserName = useCallback(
        (event) => {
          setUserName(event.target.value)
        },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [userName]
      )
    
      const inputEmail = useCallback(
        (event) => {
          setEmail(event.target.value)
        },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [email]
      )
    
      const inputPassword = useCallback(
        (event) => {
          setPassword(event.target.value)
        },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [password]
      )
      return (
        <BasicContainer className={'flex flex-col h-1/2 space-y-6 w-1/2 mx-auto mt-32 max-w-sm'}>
          <BasicHeadingThree text="会員登録画面" className=" text-center" />
          <TextInput
            type={'text'}
            placeholder="ユーザー名"
            value={userName}
            className={''}
            onChange={inputUserName}
            required={true}
          />
          <TextInput
            onChange={inputEmail}
            placeholder="メールアドレス"
            required={true}
            type={'email'}
            value={email}
          />
          <TextInput
            onChange={inputPassword}
            placeholder="パスワード"
            required={true}
            type={'password'}
            value={password}
          />
          <Spacer className="h-15" />
          <BasicButton
            bg="BLUE"
            buttonName="登録する"
            className=""
            onClick={() => handleRegister(userName, email, password)}
          />
          <Spacer className="h-20" />
          <BasicLink
            className=" text-blue-400 text-xs text-center"
            href={{
              pathname: '/login',
              query: null
            }}
            name={'アカウントをお持ちの方はこちら'}
          />
        </BasicContainer>
      )
    }
    
    export default RegisterPage
  4. ログアウト(ナビゲーションバー等の任意の場所に配置して呼び出す)
    import { useHandleLogout } from '../../shared/function/Auth/AuthLisnter'
    
    const handleLogout = useHandleLogout()
    
    ..
    
    <span
      className={'block px-4 py-2 text-sm text-gray-700 cursor-pointer'}
      onClick={handleLogout}
    >
    ログアウト
    </span>
  5. 認証のリッスン(frontend\next-web\src\pages\_app.tsx)
    ・共通の処理なので、AuthProviderコンポーネントを作成し、<Component {...pageProps} />をラップする
    import React from 'react'
    import { RecoilRoot } from 'recoil'
    import AuthProvider from '../components/provider/AuthProvider'
    import ErrorBoundary from 'components/shared/error/ErrorBoundary'
    import NavBars from 'components/atomic/Organisms/NavBars'
    
    const MyApp = ({ Component, pageProps }) => {
      return (
        <ErrorBoundary>
          <RecoilRoot>
            <AuthProvider>
              <NavBars />
              <div className="container mx-auto h-full">
                <Component {...pageProps} />
              </div>
            </AuthProvider>
          </RecoilRoot>
        </ErrorBoundary>
      )
    }
    
    export default MyApp

    AuthProviderコンポーネント(frontend\next-web\src\components\provider\AuthProvider.tsx)
    ・useEffectで第二引数を[]で指定し、初回レンダリング(ブラウザリロード)時のみlistenAuthStateメソッドが実行されるように指定

    import React, { useCallback, useState, useEffect } from 'react'
    import { useListenAuthState } from 'components/shared/function/Auth/AuthLisnter'
    
    const AuthProvider = ({ children }) => {
      const { listenAuthState } = useListenAuthState()
    
      useEffect(() => {
        listenAuthState()
      }, [])
      return children
    }
    
    export default AuthProvider

以上です

まとめ

いかがでしたでしょうか。本記事では、Next.jsの開発において、SSR時の初回レンダリングでqueryがundefinedになる際の対処法について紹介しています