React × JWT認証にはaxiosのInterceptors

react_logo全般

はじめに

こんにちは!最近エスプレッソにハマってお金の出費が止まらない青柳です!(誰か止めて、、、)

今回はReactの認証で使用するJWT通信の実装についてご紹介していきます!

JWTの仕組みについては他サイトに頼りますが、認証を行う上で、クライアント側はaccess_tokenを送る必要があります。また、それが無効になっていた場合、refresh_tokenを使用して、新たに有効なaccess_tokenを取得し直すことも必要です。

今回はこれをどのようなタイミングで、どのように実装すれば良いのかということにフォーカスを当てて解説していきます!

前提

  • access_tokenはstateに保持します。(LocalStorageやCookieに保存するケースもあると思いますが今回はStateにします)
  • refresh_tokenはAPIからHttpOnly属性を付与してCookieに保持するように送ります。
  • 今回はAPIの実装は割愛します。

構成

今回は簡単に下記のような構成で実装してみます。

src
 ├ api
 │  └ axios.js                         // axiosをimportしてカスタマイズ
 │
 ├ contexts
 │  └ Auth.jsx.                      // 認証状態を管理するためのContext
 │
 ├ hooks
 │  └ useAuthAxios.js.        // axios.jsでカスタマイズしたaxiosをimportして認証をする
 │  └ useRefreshToken.js.  // refresh_tokenを扱う
 │
 ├ App.jsx                              
 └ text.jsx                             // APIを呼び出してテストするjsxファイル

ソース解説

では早速ソースの解説をしていきます!

// axios.js

import axios from "axios";

const BASE_URL = "http://localhost:3500";

export default axios.create({ baseURL: BASE_URL });

export const authAxios = axios.create({
  baseURL: BASE_URL,
  headers: { "Content-Type": "application/json" },
  withCredentials: true,
});

こちらはライブラリのaxiosをimportして、カスタマイズしています。実際に使用する時はBASE_URL(APIのURL)を変更して使用してください。

主に使用するのはauthAxiosの方で、これは認証情報をAPI側に送る設定をしています。

// Auth.jsx

import React, { createContext, useState } from "react";

export const AuthContext = createContext({});

const AuthProvider = ({ children }) => {
  const [auth, setAuth] = useState({});

  return (
    <AuthContext.Provider value={{ auth, setAuth }}>
      {children}
    </AuthContext.Provider>
  );
};

export default AuthProvider;

これはContextでauth stateに認証情報を保持します。

// App.jsx

import {
  BrowserRouter as Router,
  Route,
  Routes,
  Navigate,
} from "react-router-dom";
import AuthProvider from "./contexts/Auth";
import Test from "./test";

const App = () => {
  return (
    <Router>
      <Routes>
        <Route path="/">
          <Route path="login" element={<Login />} />
          <Route path="404" element={<NotFound />} />
          <Route path="*" element={<Navigate to="404" replace />} />
        </Route>
        {/* ▼ ログイン後ルーティング ▼ */}
        <Route path="/app/*">
          <AuthProvider>
            <Route path="test" element={<Test />} />
          </AuthProvider>
        </Route>
      </Routes>
    </Router>
  );
};

export default App;

こちらがルーティングになります。今回ログインはさわりませんがログインすると/appにアクセスができるようになります。そのため、今回使用するtest.jsxのTestコンポーネントはAuthContextの認証情報を参照することができます。

// useRefreshToken.js

import axios from "../api/axios";
import { useContext } from "react";
import { AuthContext } from "../contexts/Auth";

const useRefreshToken = () => {
  const { setAuth } = useContext(AuthContext);

  const refresh = async () => {
    // cookieに保存されたrefresh_tokenを送付してaccess_tokenを取得する
    const response = await axios.get("/refresh", {
      withCredentials: true,
    });
    setAuth((prev) => {
      // access_tokenを保持する
      return { ...prev, accessToken: response.data.accessToken };
    });

    return response.data.accessToken;
  };

  return refresh;
};

export default useRefreshToken;

このファイルではrefresh_tokenを元にaccess_tokenを取得するロジックが定義されています。/refreshというエンドポイントに対してwithCredentials: trueとすることで、Cookieに保存したrefresh_tokenを送付します。それを元にAPIがaccess_tokenを返すので、AuthContextのsetAuthに保持するのと同時に、return response.data.accessTokenとして、値を返します。

//useAuthAxios.js

import { authAxios } from "../api/axios";
import { useContext, useEffect } from "react";
import useRefreshToken from "./useRefreshToken";
import { AuthContext } from "../contexts/Auth";

const useAuthAxios = () => {
  const refresh = useRefreshToken();
  const { auth } = useContext(AuthContext);

  useEffect(() => {
    // リクエスト前に実行。headerに認証情報を付与する
    const requestIntercept = authAxios.interceptors.request.use(
      (config) => {
        if (!config.headers["Authorization"]) {
          config.headers["Authorization"] = `Bearer ${auth?.accessToken}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

    // レスポンスを受け取った直後に実行。もし認証エラーだった場合、再度リクエストする。
    const responseIntercept = authAxios.interceptors.response.use(
      (response) => response,
      async (error) => {
        const prevRequest = error?.config;
        // 403認証エラー(headerにaccess_tokenがない。もしくはaccess_tokenが無効)
        if (error?.response?.status === 403 && !prevRequest.sent) {
          prevRequest.sent = true;
          // 新しくaccess_tokenを発行する
          const newAccessToken = await refresh();
          prevRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
          // 再度実行する
          return authAxios(prevRequest);
        }
        return Promise.reject(error);
      }
    );

    return () => {
      // 離脱するときにejectする
      authAxios.interceptors.request.eject(requestIntercept);
      authAxios.interceptors.response.eject(responseIntercept);
    };
  }, [auth, refresh]);

  return authAxios;
};

export default useAuthAxios;

今回の最重要のファイルです!このカスタムフックではaxios.jsで作成したauthAxiosにリクエスト直前とレスポンス直後に実行した処理を追加して返す関数になっています。

ではまずはリクエスト直前の処理を見ていきましょう。

    const requestIntercept = authAxios.interceptors.request.use(
      (config) => {
        if (!config.headers["Authorization"]) {
          config.headers["Authorization"] = `Bearer ${auth?.accessToken}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

axios.jsからimportしてきたauthAxiosに対してinterceptorsメソッドを実行しています。これは、thenまたはcatchによって処理がされる前にリクエストまたはレスポンスを挟み込む(まさにインターセプト)ことができるメソッドです。

上記ではinterceptors.requestとしているのでリクエストが実行される前に処理が行われることになります。ここの処理ではhttpヘッダーのAuthorizationにAuthContextに保持しているaccess_tokenを設定します。これが正常に行われるとAPI側でaccess_tokenを検知して認証することができるようになります。既にAuthorizationにaccess_tokenがある場合はそのままconfigを返します。

では続いてレスポンス後の処理を見てみましょう。

    const responseIntercept = authAxios.interceptors.response.use(
      (response) => response,
      async (error) => {
        const prevRequest = error?.config;
        // 403認証エラー(headerにaccess_tokenがない。もしくはaccess_tokenが無効)
        if (error?.response?.status === 403 && !prevRequest.sent) {
          prevRequest.sent = true;
          // 新しくaccess_tokenを発行する
          const newAccessToken = await refresh();
          prevRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
          // 再度実行する
          return authAxios(prevRequest);
        }
        return Promise.reject(error);
      }
    );

こちらはinterceptors.responseとしているためレスポンスの直後に実行します。何事もなければ通常通りレスポンスを返します。もしaccess_tokenのセッション切れ等によってAPIで403エラーを受け取った場合(APIをそのような作りにしている前提)、新たにaccess_tokenを取得します。

const newAccessToken = await refresh();のrefreshはuseRefreshToken フックの関数で新しいaccess_tokenを返してくれます。受け取ったnewAccessTokenprevRequest.headers[“Authorization”] = `Bearer ${newAccessToken}`;のように再度ヘッダーに詰め直して、最後にreturn authAxios(prevRequest)で再度リクエストを実行します。

すると今度は正常なaccess_tokenのため認証が通り、正常にレスポンスが変えるという仕組みです。

// test.jsx

import { useState, useEffect } from "react";
import useAuthAxios from "./hooks/useAuthAxios";

const Test = () => {
  const [value, setValue] = useState();
  const authAxios = useAuthAxios();

  useEffect(() => {
    const getUsers = async () => {
      try {
        const response = await authAxios.get("/users");
        setValue(response.data);
      } catch (error) {
        console.error(error);
      }
    };

    getUsers();
  }, []);

  return <>{value}</>;
};

export default Test;

最後に実際にアクセスしてその結果を出力します。

/usersはユーザーの情報を取得するAPIです。このAPIは認証されていないと情報を取得することができません。そのため先ほど設定したauthAxiosを使用します。authAxiosを使用すれば、もし認証が切れていても自動的にaccess_tokenを更新してくれて正常にレスポンスが返ってくるので、UI側では特に認証を意識せずに実装することができます。(例外処理等は必要です)

まとめ

いかがでしたでしょうか?JWTを使用するとどのタイミングでaccess_tokenの更新をすればいいのか悩んだり、どのように実装すればいいかわからないことも多いかと思います。以前私はこのaxios.interceptorsを知らずに自分でゴリゴリ実装したことがありますが、可読性も悪くあまり納得できるものは作れませんでした。是非こちらを活用して認証周りを整えてみてください。

Interceptors | Axios Docs

コメント