🌅

[Django REST Framework] [React] JWTによるサイレントリフレッシュ

2022/01/21に公開

実行環境

MacOS BigSur -- 11.2.1
Python3 -- 3.8.2
Django -- 3.1.7
djangorestframework -- 3.12.2
djoser -- 2.1.0
djangorestframework-simplejwt -- 4.6.0
npm -- 6.14.4
react -- 17.0.1
react-dom -- 17.0.1
axios -- 0.21.1

DRFとReactによるアプリケーションでサイレントリフレッシュを実装したい

DRFとReactによるアプリケーションでJWT認証の実装をある程度終えたのですが、ユーザビリティ向上のためサイレントリフレッシュが必要となり、今回はその手順を残したいと思います。サイレントリフレッシュの実装手順に関して、DRFとReactに対応した情報がネット上にほぼ無くて試行錯誤の繰り返しで多くの時間を使いましたが、参考になれば幸いです。

サイレントリフレッシュとは

これについてはネット上で様々な分かりやすい記事がありますが、、
簡単にまとめるとサイレントリフレッシュとは、Access Tokenの有効期限が切れた際、Refresh Tokenの有効期限内であれば、暗に(システム側で)Access Tokenの再取得を行うというものです。

DRFにおけるJWTの各エンドポイント

今回DRFでは、djoserを用いてエンドポイントを用意しており、各エンドポイントは以下の通りです。なお、DRFのルートURLは/api/v1/としてあります。

  • /api/v1/auth/jwt/create
    JWTの発行(認証情報をPOSTすると、Access Token・Refresh Tokenが発行される)

  • /api/v1/auth/jwt/refresh
    JWTのリフレッシュ(Refresh TokenをPOSTすると、新たなAccess Token・Refresh Tokenが発行される)

  • /api/v1/auth/jwt/verify
    JWTの検証(Access Token or Refresh TokenをPOSTすると、有効期限内か検証され200 or 401 statusが返る)

DRF側では、これらのエンドポイントを用意してあれば特に別途実装は不要です。

想定するシチュエーションと対応策

サイレントリフレッシュを実装するにあたり、想定しているシチュエーションは以下のパターンかなと思います。なお、今回はJWTをCookieに保存しています。(Cookieを扱うため結構めんどくさかったです。。。)

  • ユーザーがログイン中(操作中)の場合
    (1) ログイン中にAccess Tokenの有効期限が切れてしまった

  • 再訪ユーザーが再度サイトに訪れた場合
    (2) 再訪ユーザーのAccess Tokenが有効期限内(○)、Refresh Tokenが有効期限内(○)
    (3) 再訪ユーザーのAccess Tokenが有効期限外(✖︎)、Refresh Tokenが有効期限内(○)
    (4) 再訪ユーザーのAccess Tokenが有効期限外(✖︎)、Refresh Tokenが有効期限外(✖︎)

対応策はそれぞれの場合に応じて以下のようにします。
(1) - 各リクエストで401(Unauthorized)が返った時にJWTをリフレッシュする
(2)~(4) - 共通でユーザー再訪時にJWTの検証を行う
(2) - Access Token・Refresh Token ともに有効期限内なのでそのまま
(3) - Refresh Tokenを用いてJWTをリフレッシュする
(4) - Access Token・Refresh Token ともに有効期限外なのでログイン画面に遷移

前提として、私の場合「redux-localstorage-simple」を用いて、Redux Stateを永続化しているため再訪ユーザーのJWTが切れていてもstateではログインしていることになるという問題がありました。これを解決するためにも(2) ~ (4)の処理が必須ということです。

React側の実装

さて、いよいよReact側でのサイレントリフレッシュの実装に入ります。
上記で記述したとおり、「各リクエストで401が返った場合」と「ユーザーが再訪した場合」の2パターンで分けて実装します。具体的な実装手順について、ネット上にはほぼ情報が無く、インターバルを用いたリフレッシュなど様々検討しましたがうまくいかず。。。結局は以下の方法で決めました。

「各リクエストで401が返った場合」については、axiosのインスタンスを作成し、axios.interceptorsで対応します。
「ユーザーが再訪した場合」については、Default.js(ルーティングを行なうトップコンポーネント)で対応します。

axios.interceptorsとは

簡単にいうと、グローバルなエラーハンドリングを可能にしてくれるものです。通常、個別のエラーハンドリングはcatch()節で行うと思いますが、グローバルなエラーハンドリングを行う際に個別でcatch()を書くのは非効率的です。
また、interceptorsを用いることでthen()やcatch()節の前の段階で実行されるので今回のように401が返るとリフレッシュ ⇨ 再度リクエストのような処理をしたい時に最適です。

Axiosインスタンスの作成

import axios from 'axios';

// インスタンスを作る
const ax = axios.create({
  baseURL: 'http://localhost:8000/api/v1/',
})

export default ax;

ここでは、axという名でAxiosインスタンスをexportしています。LocalStorage等にJWTを保存している場合、このインスタンス内でaxios.interceptorsも書くことが可能なようですが、私の場合JWTをCookieに保存しており、useCookiesやuseDispatchを記述できなかったため(Hookのルール違反となりエラーが出る)、基本的なインスタンスのみ作成しています。
*もしかすると上手いやり方があったのかもしれませんが、、、

「再訪ユーザーが訪れた場合」

再訪ユーザーが訪れた場合の対応として、Default.js(ルーティングを行なうトップコンポーネント)に以下の処理を記述しました。

//APIURL
export const apiURL = 'http://localhost:8000/api/v1/';

const Default = () => {
    const history = useHistory();
    //ログインしているか否かのstate
    const isLoggedIn= useSelector(state => state.user.isLoggedIn);
    const [cookies, setCookie, removeCookie] = useCookies();
    const dispatch = useDispatch();

    // AccessToken 更新用
    async function refreshToken(){
      axios.post(apiURL+'auth/jwt/refresh',{
            refresh:cookies.refreshtoken,
          })
          .then(res => {
              // パターン(3)
              console.log("refresh");
              setCookie('accesstoken', res.data.access, { path: '/' }, { httpOnly: true });
              setCookie('refreshtoken', res.data.refresh, { path: '/' }, { httpOnly: true });
          })
          .catch(err => {
              // パターン(4)
              alert("ログインしてください");
              dispatch(isLoggedInOff());
              history.push('/login');
          });
    }

    // AccessToken 検証用
    async function verifyAccessToken(){
      axios.post(apiURL+'auth/jwt/verify',{
            token:cookies.accesstoken,
          }).then(res => {
            // パターン(2)
            console.log("JWT ok");
          })
          .catch(err => {
            if(err.response.status === 401){
              console.log("verify failed")
              // 検証結果が401の場合リフレッシュを試す
              refreshToken();
            }
          });
    }

    useLayoutEffect(() => {
      if(isLoggedIn){
        // isLoggedInがTrueで JWTがある
        if(cookies.accesstoken !== undefined){
          console.log("go verify");
          verifyAccessToken();
        }
        // isLoggedInはTrueだが JWTがない
        else{
          // ログインページへ遷移
          // isLoggedInをfalseに
          console.log("トークンなし");
          alert("ログインしてください");
          dispatch(isLoggedInOff());
          history.push('/login');
        }
      }
      },[]);

//以下省略

コメント内にどのパターンに対応する処理か記述していますが、このような流れで処理を行なっています。
Auth.png

「各リクエストで401が返った場合」

さて、ここまで来ればあと少しです。
各リクエスト共通のグローバルなエラーハンドリングとして、axiosのinterceptorsを使用します。
エラーハンドリングを実装したいコンポーネント内で、先ほどexportしたAxiosインスタンスをimportします。

import React, { useEffect } from 'react';
import  { useHistory } from 'react-router-dom';
import Cookies from 'universal-cookie';
import ax from './Axios';

const cookies = new Cookies();

const FollowInfo = () => {
    const [cookies, setCookie, removeCookie] = useCookies();
    const history = useHistory();

    // レスポンスで401エラー => リロードしてJWTのrefresh
    ax.interceptors.response.use(
      response => {
        return response
      },
      error => {
        if (error.response && error.response.config && error.response.status === 401) {
            history.go(0);
        } else {
          return Promise.reject(error)
        }
      }
    )

useEffect(() => {
    async function fetchData(){
        await ax.get('post_detail/'+id,{
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `JWT ${cookies.accesstoken}`
              }
          })
          // 401が返った場合、thenやcatch()の前にinterceptorsが発火する
          .then(res => {
          })
          .catch(err => {
          });
    }
  fetchData();
  },[id]);

    //以下省略
    return (
    );
  }

  export default FollowInfo;

これにより、axiosでエラーが返った時にinterceptorsが発火し、エラーstatusが401の場合、画面をリロードしています。(ここももっと良い方法があると思いますが。。。)

if (error.response && error.response.config && error.response.status === 401)
   {
      history.go(0);
   }

この中で直接/auth/jwt/refreshにアクセスし、JWTをリフレッシュすることも可能ですが、リフレッシュ後のJWTをCookieに保存し直しても、そのCookieが更新されるのはページがリロードされた後です。つまり、この中でJWTをリフレッシュ・Cookieを更新してもuseEffectを抜けない限り以前の(有効期限切れの)Cookieが参照され、401エラーが再発する現象が起こりました。
これについては、JWTをCookieに保存していない場合はこの中でリフレッシュ・更新を行うことでスムーズに実装できると思います。

以上で今回行ったサイレントリフレッシュの実装は終了です。
情報が少なかったため、今回の方法が果たしてベストプラクティスかどうかは分かりませんが、、、
今後の参考になれば幸いです。

Discussion