👻

React と DRF の SPA で JWT を Cookieで管理してみた話

2021/09/21に公開

はじめに

去年、React と DRF を使って Todo アプリを作ってみたものの、認証に関してはどうにもうまく行かずいわゆるハイブリッドアーキテクチャの出来損ないのような形で実装してしまった。
SPA なんだから Token 認証を使えばいいという記事はいくらでも出てくる傍ら、localStorage に無闇やたらに入れるのはあまりにもセキュアでない……(もちろん、今回の HttpOnly の Cookie に入れる手法も完全にセキュアではない)という意見の方が私には腑に落ちたけれども、それ以外の手法をうまいこと自分で消化できずにやむなく上記の経緯で認証を実装してしまった。

で、最近 AWS SAA の資格を取り、他のアソシエイト資格を取ろうと考えていたため、1 つしっかりしたアプリ開発手順がほしいと思ったので、認証を抜きにしても、イマイチ Django と React の連携については納得いかなかったことと合わせてもう一度しっかりベスト・プラクティスのようなものを取り入れられないものかと思っていたところ

[JIRA 編]React Hooks/TypeScript + Django REST API で作るオリジナル JIRA
Nextjs と Django の jwt で認証機能を作る

この 2 つのサイトと講座を見つけたのでこれをモデルにして、色々調べて JWT 認証を React と Django とで Cookie で管理しようと試みた話です。

前提として

これを言ったらお終いとは思うんですけど、現状 Django 及びサードパーティのライブラリ含めてそれらは JWT に限らず、Token 認証を Cookie やセッションで管理するということを想定していないと思います。
色々とライブラリやドキュメントを漁ってみましたがそれを導入するだけで Token を Cookie やセッションで管理できるようになるといった代物は見つかりませんでした。
なので、それらのことをしたい場合は Django やサードパーティのライブラリのソースからコードを引っ張ってきてそれをオーバーライドしないといけません。
勿論、私の調べが足りないだけかもしれないのでもし見つけた方いらっしゃいましたら教えていただけると助かります。

もう 1 つ前提として、Chrome で検証する場合は以下の点に注意しないといけないことです。

新しい Cookie 設定 SameSite=None; Secure の準備を始めましょう

ざっくりいうと SameSite(=同一ドメイン同士の通信)でない場合は、Cookie はサードパーティでは利用できないという設定がデフォルトになったというお話です。
つまり、SPA などのクロスドメインを前提としたものを作る場合はSameSite:Noneを設定して、かつSecure属性(https 通信しか許可しない)を設定した Cookie にしないといけないということになります。

やること

以上の前提と JWT を Cookie で管理する場合は HttpOnly が大前提であることを踏まえると、Cookie の操作はサーバー側(Django)で行うということになります。
よって

  • SimpleJWT 等の JWT 認証ライブラリの Token を作成する View をオーバーライドしたカスタム View を作り、作成した Token をResponse.set_cookieを使って Cookie にセットできるようにする。
  • urls.py に上記の View に対してのパスを設定する(元のライブラリの View は使えないので)
  • CSRFToken を生成する関数を作って、その関数へのパスを設定する(Django は Template を使わない限り基本的には CSRF を無視してしまうため)
  • 独自の認証の Authentication.py と SameSite オプションを DEBUG の状態で変更する Middleware を作成する

ということをやっていく必要があります。
具体的には以下のサイトで書かれていることをまとめた話になるので、私の話はいらないよという方は以下のリンクから元記事を拝見していただければと思います。
Nextjs と Django の jwt で認証機能を作る
Making React and Django play well together - the “single page app” model
Django Djoser Token Authentication not working
DRF でサードパーティクッキーのセッション認証を使おうとして、諦めたけど勉強になった
Django + ReactJS. The Httponly cookie is not saved in the browser at React side. I also gave {withcredentials : true} at both side

作成した View など


class TokenObtainView(jwt_views.TokenObtainPairView):
    # Token発行
    def post(self, request, *args, **kwargs):
        # 任意のSerializerを引っ張ってくる(今回はTokenObtainPairViewで使われているserializers.TokenObtainPairSerializer)
        serializer = self.get_serializer(data=request.data)
        # 検証
        try:
            serializer.is_valid(raise_exception=True)
        # エラーハンドリング
        except jwt_exp.TokenError as e:
            raise jwt_exp.InvalidToken(e.args[0])

        res = Response(serializer.validated_data, status=status.HTTP_200_OK)

        try:
            res.delete_cookie("access_token")
        except Exception as e:
            print(e)

        # CookieヘッダーにTokenをセットする
        res.set_cookie(
            "access_token",
            serializer.validated_data["access"],
            max_age=60 * 60 * 24,
            httponly=True,
        )
        res.set_cookie(
            "refresh_token",
            serializer.validated_data["refresh"],
            max_age=60 * 60 * 24 * 30,
            httponly=True,
        )

        # 最終的にはaccess_tokenとrefresh_tokenを返してもらう
        return res

継承元のView

class TokenViewBase(generics.GenericAPIView):
    permission_classes = ()
    authentication_classes = ()

    serializer_class = None

    www_authenticate_realm = 'api'

    def get_authenticate_header(self, request):
        return '{0} realm="{1}"'.format(
            AUTH_HEADER_TYPES[0],
            self.www_authenticate_realm,
        )

    # こちらのToken作成部分をオーバーライドしている
    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)

        try:
            serializer.is_valid(raise_exception=True)
        except TokenError as e:
            raise InvalidToken(e.args[0])

        return Response(serializer.validated_data, status=status.HTTP_200_OK)

class TokenObtainPairView(TokenViewBase):
    """
    Takes a set of user credentials and returns an access and refresh JSON web
    token pair to prove the authentication of those credentials.
    """
    serializer_class = serializers.TokenObtainPairSerializer


token_obtain_pair = TokenObtainPairView.as_view()


Token を Refresh するための View


# CookieからRefresh_Token取得
# クライアント側からこいつを叩いてから下のクラスへとリクエストを投げる
def refresh_get(request):
    try:
        refresh_token = request.COOKIES["refresh_token"]
        return JsonResponse({"refresh": refresh_token}, safe=False)
    except Exception as e:
        print(e)
        return None


# HTTPRequestのBodyプロパティから送られてきたtokenを受け取る
class TokenRefresh(jwt_views.TokenRefreshView):
    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)

        try:
            serializer.is_valid(raise_exception=True)
        except jwt_exp.TokenError as e:
            raise jwt_exp.InvalidToken(e.args[0])
        # token更新
        res = Response(serializer.validated_data, status=status.HTTP_200_OK)
        # 既存のAccess_Tokenを削除
        res.delete_cookie("user_token")
        # 更新したTokenをセット
        res.set_cookie(
            "user_token",
            serializer.validated_data["access"],
            max_age=60 * 24 * 24 * 30,
            httponly=True,
        )
        return res



class LoginUserView(generics.RetrieveUpdateAPIView):
    serializer_class = UserSerializer
    authentication_classes = (CookieHandlerJWTAuthentication,)

    # お手本ではAPIViewを使ってget_object()をオーバーロードしてTokenの検証をしていた
    # しかし、generics以下のViewでは無理なので、代わりにget()をオーバーライドしてこちらの処理過程にTokenの検証を挿入
    def get(self, request, *args, **kwargs):
        # Set-CookieにしているのでCookieからトークンを入手
        jwt_token = request.COOKIES.get("access_token")
        if not jwt_token:
            return Response(
                {"error": "No Token"}, status=status.HTTP_400_BAD_REQUEST
            )
        # Token検証
        try:
            payload = jwt.decode(
                jwt_token, settings.SECRET_KEY, algorithms=["HS256"]
            )
            # もしくはreturn payload["user_id"]でもありだそうな。
            loginuser = User.objects.get(id=payload["user_id"])
            # オブジェクトで返ってくるのでStringならエラーハンドリング
            if type(loginuser) == str:
                return Response(
                    {"error": " Expecting an Object type, but it returned a String type."},
                    status=status.HTTP_400_BAD_REQUEST
                )
            # アクティブチェック
            if loginuser.is_active:
                # 通常、generics.CreateAPIView系統はこの処理をしなくてもいい
                # しかしtry-exceptの処理かつ、オーバーライドしているせいかResponse()で返せとエラーが出るので以下で処理
                response = UserSerializer(self.request.user)
                return Response(response.data, status=status.HTTP_200_OK)
            return Response(
                {"error": "user is not active"}, status=status.HTTP_400_BAD_REQUEST
            )
        # Token期限切れ
        except jwt.ExpiredSignatureError:
            return "Activations link expired"
        # 不正なToken
        except jwt.exceptions.DecodeError:
            return "Invalid Token"
        # ユーザーが存在しない
        except User.DoesNotExist:
            payload = jwt.decode(
                jwt_token, settings.SECRET_KEY, algorithms=["HS256"]
            )
            return payload["user_id"]

    # PUTメソッドを無効
    def update(self, request, *args, **kwargs):
        response = {"message": "PUT method is not allowed"}
        return Response(response, status=status.HTTP_400_BAD_REQUEST)



class LogoutView(jwt_views.TokenObtainPairView):
    permission_classes = (permissions.IsAuthenticated,)

    # LogoutでCookieからToken削除
    # blacklist()を使って、RefreshTokenを無効にする処理を入れてもよい?
    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)

        try:
            serializer.is_valid(raise_exception=True)
        except jwt_exp.TokenError as e:
            raise jwt_exp.InvalidToken(e.args[0])

        res = Response(serializer.validated_data, status=status.HTTP_200_OK)

        try:
            res.delete_cookie("access_token")
            res.delete_cookie("refresh_token")
        except Exception as e:
            print(e)
            return None

        return Response({"Message": "Logout"}, status=status.HTTP_200_OK)


CSRF


def csrf(request):
    return JsonResponse({'csrfToken': get_token(request)})


この部分については Django の csrfMiddleware をオーバーライドしたものを書いたほうがいいかもしれないけれどひとまず簡易的に。

authentication.py


from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.response import Response
from django.conf import settings


class CookieHandlerJWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        # Cookieヘッダーからaccess_tokenを取得
        access_token = request.COOKIES.get('access_token')
        if not access_token:
            Response({"message": 'no Token'})
        else:
            Response(access_token)

        if access_token:
            request.META['HTTP_AUTHORIZATION'] = '{header_type} {access_token}'.format(
                header_type=settings.SIMPLE_JWT['AUTH_HEADER_TYPES'][0], access_token=access_token)

        return super().authenticate(request)


samesite 属性を DEBUG の状態で判断させる Middleware


class SameSiteMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        from config import settings

        for key in response.cookies.keys():
            response.cookies[key]['samesite'] = 'Lax' if settings.DEBUG else 'None'
            response.cookies[key]['secure'] = not settings.DEBUG
        return response

urls.py



from django.urls import path, include
from rest_framework import routers

from .views import csrf, TokenObtainView, TaskViewSet, CategoryViewSet, ListUserView, LoginUserView, ProfileViewSet, \
    CreateUserView, refresh_get, TokenRefresh, LogoutView

router = routers.DefaultRouter()
router.register('tasks', TaskViewSet)
router.register('category', CategoryViewSet)
router.register('profile', ProfileViewSet)

# もし、並行してサードパーティの認証ライブラリのパスも使用している場合は、必ず独自のViewへのパスはライブラリのそれよりも上位に書いておく
urlpatterns = [
    path('', include(router.urls)),
    path('csrf/create', csrf),
    path('jwtcookie/create', TokenObtainView.as_view(), name='jwtcreate'),
    path('jwtcookie/refresh', refresh_get),
    path('jwtcookie/newtoken', TokenRefresh.as_view(), name='jwtrefresh'),
    path('create/', CreateUserView.as_view(), name='create'),
    path('users/', ListUserView.as_view(), name='users'),
    path('loginuser/', LoginUserView.as_view(), name='loginuser'),
    path('logout/', LogoutView.as_view(), name='logout'),
]


settings.py(抜粋)


DEBUG = True
# DEBUG = False

ALLOWED_HOSTS = ['*']


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'corsheaders',
    'rest_framework',
    'djoser',
    'api.apps.ApiConfig'

]

MIDDLEWARE = [
    # 独自Middleware、Djangoの組み込みのそれより上位の置く
    'config.Middleware.middleware.SameSiteMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# CORS(クロスドメインリクエスト)でCookieを送信することを許可
CORS_ALLOW_CREDENTIALS = True

# django-cors-headersを使う場合Versionによって設定の仕方が違うので注意(私は沼りました)

# django-cors-headers3.4.0以下の場合
CORS_ORIGIN_WHITELIST = [
    "http://localhost:3000",
    "http://localhost:3000",
    "https://127.0.0.1:3000",
    "https://127.0.0.1:3000",
    "http://localhost:8000",
    "http://127.0.0.1:8000"
]

# django-cors-headers3.5.0の場合
# CORS_ALLOWED_ORIGINS = [
#     "http://localhost:3000"
# ]

信頼するホスト名を明記、これをやらないとCORS+DRFでやる場合CSRFエラーが出る
CSRF_TRUSTED_ORIGINS = ['localhost:3000', '127.0.0.1']

SESSION_COOKIE_SAMESITE = 'None' # default='Lax'
SESSION_COOKIE_SECURE = True



REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        # 独自のクラスを作った場合は明記、これでデフォルトになる
        'api.authentication.CookieHandlerJWTAuthentication',
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
}

SIMPLE_JWT = {
    'AUTH_HEADER_TYPES': ('JWT',),
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
}

フロント側

HttpOnly なので Token はフロント側からは参照すらできません。
よって Request する際に自動で Cookie も送信されるようにしないといけないので例えば以下のように Request します。

// axiosの引数にいちいち書きたくないならこれ。ただなんかうまく行ってない気もする。
axios.defaults.withCredentials = true;

export const fetchAsyncLogin = createAsyncThunk(
  "auth/login",
  async (auth: CRED) => {
    try {
      const res = await axios.post<JWT>(
        `${process.env.REACT_APP_API_URL}/api/jwtcookie/create`,
        auth,
        {
          headers: {
            "Content-Type": "application/json; charset=utf-8",
          },
          // これを必ず入れる
          withCredentials: true,
        }
      );
      return res.data;
    } catch (e: any) {
      const errorMessage = e.message;
      console.log(errorMessage);
      return errorMessage;
    }
  }
);

DEBUG=Trueの場合はフロント側のドメインとサーバー側のドメインが一緒でなければ Cookie がセットされません。
React 側は何も弄ってなければ基本的にはhttp://localhost:3000になるはずなので、Request はhttp://localhost:8000~としないといけません。
私はうっかりhttp://127.0.0.1:8000としていたばっかりに、半日犠牲にしました、皆さんはそうならないようにお気をつけください。
ちなみにこれじゃ CORS 設定している意味なくない? と思われるかもしれませんが、そのために React 側で https で起動するような設定も合わせて用意しておきます。


REACT_APP_API_URL1 = http://127.0.0.1:8000
REACT_APP_API_URL = http://localhost:8000
HTTPS=true SSL_CRT_FILE=$(mkcert -CAROOT)/localhost.pem SSL_KEY_FILE=$(mkcert -CAROOT)/localhost-key.pem

React などで https をローカルで利用する手順については以下のサイトに書いてあります。
React、Angular、Node のローカル開発に HTTPS を使用
local 環境で React を https で立てる(WSL)

ここまでやれば無事に

DeveloperTool でこのように確認できるかと思います。

最後に

これは未だエンジニアになれてもいない人間が Google を使って引っ張ってきたアレコレを申し訳程度に自分に合わせて一部改変したものになります。
なので、よりよい手法やリファクタリングがあれば是非ご教授頂けると大変嬉しいです。
ただし、現状あまりにも日本語でこのあたりの情報が少ない上に冒頭の Google の方針転換でそもそも Cookie 自体が今廃止という方向になりつつあるのかというところと、そもそも Django 側があまりにも SPA を実現するためのフロント技術との連携や Token の Localhost 以外での管理等に消極的なところを感じる(個人の感想です)ので、一先ず自分が集めた情報だけでも共有できればと思いこの記事を書きました。
同じようなことで困っている方の助けになれば幸いです。

Discussion