Laravel Sail React やろうとしたらまだダメFortify

パクリ40発目!!
とうとう40か、1000までもうすぐだな。

パクリ元

内容

Laravel側便利機能までやったのでJS側もきれいな状態に持っていくことにする。

npm-run-all

とりあえずパクリ元1つ目。Fortifyの時、最後Laravel側変更しても全然反映されない事象が頻発したのでそれを解消する。

  • sail artisan cache:clear
  • sail artisan config:cache
  • sail artisan route:cache
  • sail artisan optimize

上記コマンドを必ず入力するように npm コマンドに組み込む。

~/example-app$ sail npm i npm-run-all --save-dev
・・・
1 moderate severity vulnerability

To address all issues, run:
  npm audit fix

Run `npm audit` for details.

おっと何か出た。

~/example-app$ sail npm audit fix
・・・
found 0 vulnerabilities

良かった。早速 package.json に組み込む。

package.json
・・・
    "scripts": {
        "build": "vite build",
        "dev": "run-s dev:cache:clear dev:config:cache dev:route:cache dev:optimize dev:vite",
        "dev:cache:clear": "php artisan cache:clear",
        "dev:config:cache": "php artisan config:cache",
        "dev:route:cache": "php artisan route:cache",
        "dev:optimize": "php artisan optimize",
        "dev:vite": "vite"
・・・

sail npm run dev コマンドによりキャッシュクリア後に VITE によるローカルサーバーが立ち上がるのを確認。

toast

エラー時等簡単にメッセージを表示したかったのでパクリ元2つ目を参考に toast を導入することに。

~/example-app$ sail npm i react-hot-toast
・・・
found 0 vulnerabilities
resources/ts/index.tsx
・・・
import { Toaster } from "react-hot-toast";

createRoot(document.getElementById('root') as Element).render(
  <StrictMode>
    <BrowserRouter>
      <Toaster />
      <Routes>
・・・
resources/ts/Login.tsx
・・・
import toast from "react-hot-toast";
・・・
                http.post('/api/login', {email, password}).then((res2) => {
・・・
                }).catch((err) => {
                    toast.error(err.response.data.message);
・・・

オー簡単。

フォルダ整理

やったようにフォルダを整理してみる。index.tsx を main.tsx と Router.tsx に役割を分け、各画面に組み込まれていた API 呼び出しを共通化させる。

├── common_components
│   └── レイアウトとかエラーページとか
├── features
│   └── auth
│       └── 認証系とりあえず全部ぶち込む logics/components/types
├── infrastructures
│   └── api
│       ├── _base.ts
│       ├── authApi.ts
│       └── userApi.ts
├── routes
│   └── Router.tsx
├── stores
│   └── これから
├── Dashboard.tsx
└── main.tsx
resources/ts/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from "react-router-dom";
import { Router } from "./routes/Router";
import { Toaster } from "react-hot-toast";

createRoot(document.getElementById('root') as Element).render(
//  <StrictMode>
    <BrowserRouter>
      <Router />
      <Toaster />
    </BrowserRouter>
//  </StrictMode>
);
resources/ts/routes/Router.tsx
import { Route, Routes } from "react-router-dom";
import { Login } from "../features/auth/Login";
import { Dashboard } from "../Dashboard";
import { Register } from "../features/auth/Register";
import { ForgotPassword } from "../features/auth/ForgotPassword";
import { ResetPassword } from "../features/auth/ResetPassword";
import { SentEmail } from "../features/auth/SentEmail";
import { EditProfile } from "../features/auth/EditProfile";
import { EditPassword } from "../features/auth/EditPassword";
import { TwoFactorChallenge } from "../features/auth/TwoFactorChallenge";
import { ConfirmPassword } from "../features/auth/ConfirmPassword";
import { TwoFactorAuthentication } from "../features/auth/TwoFactorAuthentication";

export const Router = () => {
  return (
    <Routes>
        <Route path="/" element={<Login />} />
        <Route path="/register" element={<Register />} />
        <Route path="/forgot_password" element={<ForgotPassword />} />
        <Route path="/reset-password" element={<ResetPassword />} />
        <Route path="/sent_email" element={<SentEmail />} />
        <Route path="/edit_profile" element={<EditProfile />} />
        <Route path="/edit_password" element={<EditPassword />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/two-factor-challenge" element={<TwoFactorChallenge />} />
        <Route path="/confirm_password" element={<ConfirmPassword />} />
        <Route path="/two-factor-authentication" element={<TwoFactorAuthentication />} />
    </Routes>
  );
};

パクリ元3つ目を元に、axios をまとめてみる。

resources/ts/infrastructures/api/_base.ts
import axios from 'axios';
import toast from "react-hot-toast";

export const axiosClient = axios.create({
    baseURL: 'http://localhost', // ###API_URL
    withCredentials: true,
    timeout: 5000,
});

// リクエスト送信前に行いたい処理の定義
axiosClient.interceptors.request.use(req => {
    axios.get('/sanctum/csrf-cookie');
    return req;
  }, err => {
    return Promise.reject(err);
});

// レスポンス受信後に行いたい処理の定義
axiosClient.interceptors.response.use(
  (response) => response, // 成功時の処理 responseを返すだけ
  (error) => {
    return handle(error)
  }
);

const handle = (error: { response: { status: any; data: { message: Renderable | ValueFunction<Renderable, Toast>; }; }; }) => {
  switch (error.response?.status) {
    case 400://INVALID_TOKEN
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
    case 401://UNAUTHORIZED
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
    case 403://FORBIDDEN
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
    case 422://UNPROCESSABLE_ENTITY
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
    case 404://NOT_FOUND
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
    case 500://INTERNAL_SERVER_ERROR
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
    case 502://BAD_GATEWAY
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
    default:
      console.log('UNHANDLED_ERROR');
      toast.error(error.response?.data.message);
      return Promise.reject(error.response?.data);
  }
}
resources/ts/infrastructures/api/authApi.ts
import { axiosClient } from "./_base";

export const login = (email: string, password: string) => {
    return axiosClient.post('/api/login', {email, password});
};

export const logout = () => {
    return axiosClient.post('/api/logout');
};

export const enableTwoFactorAuthenticate = () => {
    return axiosClient.post('/api/user/two-factor-authentication');
};

export const disableTwoFactorAuthenticate = () => {
    return axiosClient.delete('/api/user/two-factor-authentication');
};

export const getTwoFactorQrCode = () => {
    return axiosClient.get('/api/user/two-factor-qr-code');
};

export const getTwoFactorRecoveryCode = () => {
    return axiosClient.get('/api/user/two-factor-recovery-codes');
};

export const challengeTwoFactorAuthenticateByCode = (code : string) => {
    return axiosClient.post('/api/two-factor-challenge', {code});
};

export const challengeTwoFactorAuthenticateByRecoveryCode = (recoveryCode : string) => {
    return axiosClient.post('/api/two-factor-challenge', {recoveryCode});
};

export const confirmPassword = (password : string) => {
    return axiosClient.post('/api/user/confirm-password', {password});
};

export const resetPassword = (password : string, password_confirmation : string, email : string, token : string) => {
    return axiosClient.post('/api/reset-password', {password, password_confirmation, email, token});
};

export const register = (email : string, name : string, password : string, password_confirmation : string) => {
    return axiosClient.post('/api/register', {email, name, password, password_confirmation});
};

export const forgotPassword = (email : string) => {
    return axiosClient.post('/api/forgot-password', {email});
};

export const editProfile = (email : string, name : string) => {
    return axiosClient.put('/api/user/profile-information', {email, name});
};

export const editPassword = (current_password : string, password : string, password_confirmation : string) => {
    return axiosClient.put('/api/user/password', {current_password, password, password_confirmation});
};

上記 Repository のようにまとめたものを各コンポーネントから呼び出す。たとえば Login 。

resources/ts/features/auth/Login.tsx
import {
    Box,
    Button,
    Card,
    CardActions,
    CardContent,
    CardHeader,
    TextField,
 } from "@mui/material";
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
import { login } from '../../infrastructures/api/authApi';
import toast from "react-hot-toast";

const cardStyle = {
    display: "block",
    transitionDuration: "0.3s",
    height: "450px",
    width: "400px",
    variant: "outlined",
};

export const Login = () => {
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const navigate = useNavigate();

    const onClickLogin = async () => {
       try {
            const res = await login(email, password);
            if (res.data.two_factor) {
                toast.success("2要素認証してください。");
                navigate("/two-factor-challenge")
            } else {
                toast.success("ログインしました。");
                navigate("/dashboard");
            }
        } catch (e) {
            console.log(e)
        }
    };

    return (
        <Box
            display="flex"
            alignItems="center"
            justifyContent="center"
            padding={20}
        >
            <Card style={cardStyle}>
                <CardHeader title="ログインページ" />
                <CardContent>
                    <TextField
                        fullWidth
                        id="email"
                        type="email"
                        label="email"
                        placeholder="email"
                        margin="normal"
                        onChange={(e) => setEmail(e.target.value)}
                    />
                    <TextField
                        fullWidth
                        id="password"
                        type="password"
                        label="Password"
                        placeholder="Password"
                        margin="normal"
                        onChange={(e) => setPassword(e.target.value)}
                    />
                </CardContent>
                <CardActions>
                    <Button
                        variant="contained"
                        size="large"
                        color="primary"
                        onClick={onClickLogin}
                    >
                    Login
                    </Button>
                </CardActions>
                <CardActions>
                    <Button
                        variant="contained"
                        size="large"
                        color="secondary"
                        onClick={() => navigate('/register') }
                    >
                    ユーザー登録
                    </Button>
                </CardActions>
                <CardActions>
                    <Button
                        variant="contained"
                        size="large"
                        color="success"
                        onClick={() => navigate('/forgot_password') }
                    >
                    パスワードリセット
                    </Button>
                </CardActions>
            </Card>
        </Box>
    );
};

これでかなり簡素になった。Sanctum 呼ぶのも axios の interceptors 位置に移動したから各コンポーネントからは本当に必要なロジックのみになる。また、then ではなく await で同期処理のようにかけて(少なくともおじんには)見栄えが良い。ログインも問題なく処理されたし、ヨシ、、と思って再度ユーザー登録から試してみることにした。(テストソースなんてないし‥)

ハマりエラー1

しかしながら、ユーザー登録時の認証メールに記載された URL 押下時になぜか Laravel 側でエラーが発生。

ココにはハマった。。いやハマった。。メール認証済みか否かのロジックをログイン時に入れたせいか?とかいろいろ Laravel 側いろいろ見ていたんだが、、一つずつ前に戻すことでなんとか場所判明。どうも上記 interceptors で Sanctum 呼んでいたのが良くなかった様子。何がいけないんだろうねぇ全然気づかなかったがふと思った。await/async ついてないじゃん。つけたらあら不思議ハマったことすら忘れてしまう。

resources/ts/infrastructures/api/_base.ts
・・・
// リクエスト送信前に行いたい処理の定義
axiosClient.interceptors.request.use(async req => {
    await axios.get('/sanctum/csrf-cookie');
    return req;
  }, err => {
    return Promise.reject(err);
});
・・・

ハマりエラー2

さらに登録後にメール認証通していないのに例外になったりならなかったりとか続ハマりポイント!!

まずはユーザー登録する。

メール認証しないでログインする。この場合、サーバー側では認証許可しないのは確認済み。

エラー出しつつも遷移してしまう。

エラーは共通メソッド側で出しているのでいいとして、その後ログイン側のキャッチでなく通常の流れで遷移してしまっている、、ブラウザ側でブレークポイント貼ったところ、、なんかレスポンスおかしい、json じゃないな、

ネットワークタブも確認する。

なるほど、login リクエストは 302 リダイレクトされ、localhost リクエストの 200 で遷移していたのか、再度ログイン画面からログインすると、、

今度はうまく効いている、なぜだ?302 リダイレクトはたぶん Fortify 側の処理でやってるだろうからそれがイケない、パクリ元4つ目を元に修正する。

Illuminate\Auth\Middleware\RedirectIfAuthenticated をコピーして App 側にも作って handle メソッドでは何もせず次へ回す。

app/Http/Middleware/RedirectIfAuthenticated.php
<?php

namespace App\Http\Middleware;
・・・
    public function handle(Request $request, Closure $next, string ...$guards): Response
    {
        return $next($request);
    }
・・・

そして上記ミドルウェアを入れ替えるために、パクリ元5つ目のように修正する。

bootstrap/app.php
・・・
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->statefulApi();
        $middleware->alias([
            'guest' => App\Http\Middleware\RedirectIfAuthenticated::class
        ]);
    })
・・・

さらにパクリ元4つ目からメール認証がない場合のチェックを FortifyServiceProvider に移す。

app/Providers/FortifyServiceProvider
・・・
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\LogoutResponse;
・・・
    public function register(): void
    {
        $this->app->instance(LoginResponse::class, new class implements LoginResponse
        {
            public function toResponse($request)
            {
                $email_verified_at = Auth::user()->email_verified_at;
                if (!isset($email_verified_at)) {
                    Auth::logout();

                    $request->session()->invalidate();
                    $request->session()->regenerateToken();

                    abort(403, 'Your email address is not verified.');
                }

                return response()->json(['two_factor' => false]);
            }
        });

        $this->app->instance(LogoutResponse::class, new class implements LogoutResponse
        {
            public function toResponse($request)
            {
                return response()->json([
                    'code' => 200,
                    'user' => $request->user(),
                ]);
            }
        });
    }
・・・

そしてこないだ急遽差し込んだ routes/api.php の箇所を削除して元に戻す。

routes/api.php
<?php

use App\Http\Controllers\Api\UsersController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum', 'verified')->group(function () {

    Route::get('/user', function (Request $request) {
        return $request->user();
    });

    Route::get('/users', [UsersController::class, 'index'])->name('api.users');

});

おーやっと普通にログインができるようになった気がする。。

ハマりエラー2追加

と思ったけど2要素認証設定時にメール認証関係なく遷移してしまった。一度メール認証ありでログイン後に、メールアドレスを変更したときというケースもあるし確認する。「two_factor」というワードでソースを検索して vendor/laravel/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php がそれと確認。App/Actions/Fortify にコピーして handle メソッドを修正。

app/Actions/Fortify/RedirectIfTwoFactorAuthenticatable.php
・・・
    public function handle($request, $next)
    {
        $user = $this->validateCredentials($request);

        $email_verified_at = $user->email_verified_at;
        if (!isset($email_verified_at)) {
            $this->guard->logout();

            $request->session()->invalidate();
            $request->session()->regenerateToken();

            abort(403, 'Your email address is not verified.');
        }

        if (Fortify::confirmsTwoFactorAuthentication()) {
・・・
    }
・・・

使用する RedirectIfTwoFactorAuthenticatable を vendor から app に変えたいのでパクリ元6つ目を元に修正する。

app/Providers/FortifyServiceProvider
・・・
use Laravel\Fortify\Actions\AttemptToAuthenticate;
use Laravel\Fortify\Actions\CanonicalizeUsername;
use Laravel\Fortify\Actions\EnsureLoginIsNotThrottled;
use Laravel\Fortify\Actions\PrepareAuthenticatedSession;
use App\Actions\Fortify\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Features;
・・・
 public function boot(): void
    {
・・・
        Fortify::authenticateThrough(function (Request $request) {
            return array_filter([
                    config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,
                    config('fortify.lowercase_usernames') ? CanonicalizeUsername::class : null,
                    Features::enabled(Features::twoFactorAuthentication()) ? RedirectIfTwoFactorAuthenticatable::class : null,
                    AttemptToAuthenticate::class,
                    PrepareAuthenticatedSession::class,
            ]);
        });
    }
・・・

やー疲れた。もういいんでないか?

雑感

結局今回も laravel 側でずっとハマってたわい!い~き~なこっと 起こりそうだぜ めッ!