🐍

NextjsとDjangoのjwtで認証機能を作る

2021/04/24に公開10

DjangoとNextjsでいいかんじのjwt認証をつくりました。

cookieに入れるjwtをhttpOnlyにしたかったのですが、simplejwtの仕様上cookieに入れる部分は自分でやる必要がありました。(simplejwtのソースコードを完全に読んだわけではないので定かではないですが、、、)
そこで、ない知恵を絞り、少しsimplejwtをカスタマイズして実装しました。
かなり未熟ではありますが、自分なりにはなかなかいいものができたのではないかと思ったので記事を書きました。

参考にした記事や前提

Django

シンプルにDRF(Django rest framework)を使用。
jwtに関しては
https://qiita.com/Syoitu/items/bc32b5e1c2fa891c2f96
この方のように自作するのもありですが、めんどうだったのでsimplejwtを使いました。
https://django-rest-framework-simplejwt.readthedocs.io/en/latest/getting_started.html

Nextjs

グローバルでのログイン状態の管理はcatnoseさんのこの記事を丸パクリ参考にさせていただきました。
https://zenn.dev/catnose99/articles/2169dae14b58b6

仕組み

ログイン処理

ユーザー名とパスワードでログインするとjwtのaccess tokenとrefresh tokenをクッキーにセットする。

catnoseさんの記事のfetchCurrentUserに該当する処理は以下のようになっています。
最初のDOM構築時や、必要に応じてcookieのaccess tokenを利用してユーザーの情報を取ってきてstateに入れる。
access tokenが切れていた場合、refresh tokenを投げてaccess tokenを取得。再度access tokenでユーザー情報の取得を試みる。

実装

cookieにjwtをセットする処理

simplejwtのviews.TokenObtainPairViewをちょいとカスタマイズ
TokenObtainPairViewはPOSTしたユーザー名とパスワードが合っていればaccess tokenとrefresh tokenを返してくれます。今回はそれらをcookieにセットする処理を追加しました。secure属性はmiddlewareを別に作って勝手にsecureにするようにしました。

# Django3

from rest_framework_simplejwt import views as jwt_views
from rest_framework_simplejwt import exceptions as jwt_exp

class TokenObtainView(jwt_views.TokenObtainPairView):
    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.Response(serializer.validated_data, status=status.HTTP_200_OK)
	try:
	    res.delete_cookie("user_token")
	except Exception as e:
	    print(e)  # ここら辺適当すぎる
	
	# httpOnlyなのでtokenの操作は全てdjangoで行う
	res.set_cookie(
	    "user_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,
        )
	return res
	

access tokenからuser情報を取得

cookieからaccess tokenを拾って、それをpythonのjwtでdecodeするところが味噌です

# Django3

import jwt

class UserAPIView(views.APIView):
    def get_object(self, JWT):

        try:
            payload = jwt.decode(
                jwt=JWT, key=settings.SECRET_KEY, algorithms=["HS256"]
            )
	    # DBにアクセスせずuser_idだけの方がjwtの強みが生きるかも
	    # その場合 return payload["user_id"]
            return User.objects.get(id=payload["user_id"])

        except jwt.ExpiredSignatureError:
	    # access tokenの期限切れ
            return "Activations link expired"
        except jwt.exceptions.DecodeError:
            return "Invalid Token"
        except User.DoesNotExist:
            return "user does not exists"

    def get(self, request, format=None):
        JWT = request.COOKIES.get("user_token")
        if not JWT:
            return response.Response(
                {"error": "No token"}, status=status.HTTP_400_BAD_REQUEST
            )
        user = self.get_object(JWT)

        # エラーならstringで帰ってくるので、型で判定
	# ここイケてないな
        if type(user) == str:
            return response.Response(
                {"error": user}, status=status.HTTP_400_BAD_REQUEST
            )

        if user.is_active:
            serializer = UserSerializer(user)
            return response.Response(serializer.data)
        return response.Response(
            {"error": "user is not active"}, status=status.HTTP_400_BAD_REQUEST
        )
	

Nextjs側の処理

access tokenによるユーザー取得を試みる
access tokenの期限切れエラーならrefresh tokenを使ってaccess tokenをリフレッシュして再度上の処理を実行といった流れです。

// Nextjs

export const fetchCurrentUser = async () => {
  try {
    const user = await tokenToUser();
    return user;
  } catch (e) {
    // tokenの有効期限が切れていたら refreshを試みる
    if (e["error"] === "Activations link expired") {
      const refresh = await refreshToken();
      const refreshRet = await newToken(refresh);
      
      if (refreshRet["access"]) {
        // refresh に成功したら再度 access tokenでのユーザー取得を試みる
        const user = await tokenToUser();
        return user;
      }
    }
  }
};

// tokenからuser情報を取得
const tokenToUser = async () => {
  const res = await fetch(`${baseUrl}/api/user/`, {
    credentials: "include",
  });
  const ret = await res.json();
  if (res.status === 400) {
    throw ret;
  }
  return ret;
};

// refresh tokenをもらう
export const getRefreshToken = async () => {
  const res = await fetch(`${baseUrl}/api/user/refresh/`, {
    credentials: "include",
  });
  const ret = await res.json();
  return ret;
};

// refresh tokenから 新しい access tokenを生成
const newToken = async (refresh: any) => {
  const res = await fetch(`${baseUrl}/api/user/refresh/token/`, {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      "X-CSRFToken": csrf["token"],
    },
    body: JSON.stringify(refresh),
  });
  const ret = await res.json();
  destroyCookie(null, "csrftoken");
  return ret;
};

refresh 処理

refresh_getでrefresh tokenを取得しsimplejwtのTokenRefreshViewにPOSTする。
そうしてあげると新しいaccess tokenがcookieに埋め込まれます

# Django3

def refresh_get(request):
    try:
        RT = request.COOKIES["refresh_token"]
        return JsonResponse({"refresh": RT}, safe=False)
    except Exception as e:
        print(e)
        return None
	
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])

        res = response.Response(serializer.validated_data, status=status.HTTP_200_OK)
        res.delete_cookie("user_token")
        res.set_cookie(
            "user_token",
            serializer.validated_data["access"],
            max_age=60 * 24 * 24 * 30,
            httponly=True,
        )
        return res

nginxのcorsを設定してrefresh_getから直接TokenRefreshにrequests.postで投げるのもあり。

以上が実際の処理です。
csrf対策やcookieを入れるためのmiddleware等の作成も今回の実装において行いましたが、本題からそれるので割愛します。

余談

質問やご意見等お待ちしております。

言い訳みたいになってしまいますが、実務1.5カ月の弱弱エンジニアかつ職場でpython及びdjangoは使用しておらずかなり荒い部分があると思うので、ご意見等あれば気軽にコメントください。
質問も気軽にコメントください。

こちらのサイトでこの処理を使っています

興味がある方は是非訪れてみてください

https://iwana.link

Discussion

registerregister

fetchCurrentUserの部分についてなんですが、
apiを叩いてログインしている&トークン確認をするのではなく、
acssess tokenやrefresh tokenのCookieが存在しているかで、
確認することは難しいでしょうか。

access tokenが存在する場合は、ユーザー情報取得のapiのみ。
refresh token だけの場合は、 refreshとユーザー情報取得。
どちらも存在しない場合は何もしない。のようにです。

httponlyの場合はSSRやapi routeを使用しないと難しそうですが、、、
無駄なフェッチを少しでも減らしたいと思っています。
それともセキュリティ面的には毎度APIを叩いて確認するべきでしょうか。

marumaru

コメントありがとうございます。
確かにそういった方法でリクエストを減らすことはできると思います。

ただ、そういった場合javascriptでクッキーの有無を確認しないとなのでhttpOnlyではできないと思います。(定かではないので間違ってたらごめんなさい🙇)
頑張ってやろうとしても、おっしゃっている通りSSRでnookiesを使う等一工夫しないとできないと思います。

リクエストの回数とセキュリティのどちらを選ぶかの問題ではないでしょうか。
自分の場合よりセキュアな方法(とりあえずリクエストを投げる&httpOnly)を選びました。
それに加えたぶんそこまで重いリクエストでもないと思っているので、ある程度無駄にリクエストが走ってもいいやくらいな気持ちでやってます。

registerregister

なるほど確かにそうですね。
仰る通り、後者で進めていこうと思います。
ありがとうございます!

shitikamiyakoshitikamiyako

はじめまして、自分はReact、redux-toolkitとDjangoを使ってSPAでJWT認証をCookie管理してやろうとしている中、この記事を見つけました。
参考にさせてもらって試してみたのですが、Chromeの

この部分に何も反映されないのはsame-site属性のポリシー?の変更にまつわるアレコレで仕様なのかわからないので、maruさんがどうであったかお聞きしたいです。
ChromeがdefaultでLaxになってしまったので、same-siteをNoneにしてSecure属性をつけて、React側はhttpsで起動しています。

一応Request時には

このようにCookieに確認できて、問題なくResponse(上記のリクエストはログインユーザーの情報をResponseしてもらうRequest)が返ってきてはいるのですが。

marumaru

@shitikamiyakoさん

コメントありがとうございます。
レスおそくなってすいません。

クッキーの属性については私の場合settings.pyのdebugで分けています。

  • debugがtrue(開発環境) => samesite: Lax, secure: false
  • debugがfalse(プロダクション環境) => samesite: None, secure: true

としています。

ヘッダーのAccess-Control-Allow-Credentialsがtrueになっているかも少し気になりますね。
あとはjs側のajax系の処理でcredentialsを許可しているかあたりではないでしょうか。

shitikamiyakoshitikamiyako

Access-Control-Allow-Credentialsがtrue
RequestHeaderを見る限りだとtrueにはなっていましたね。

フロント側は以下のようなリクエストで


axios.defaults.withCredentials = true;

export const tokenToUser = async (auth:any) => {
  try {
    const res = await axios.post<JWT>(
      `${process.env.REACT_APP_API_URL}/api/jwtcookie/create`,
      auth,
      {
        headers: { "Content-Type": "application/json" },
      }
    );
    return res.data;
    } catch (e: any) {
      const errorMessage = e.message;
      console.log(errorMessage);
      return errorMessage;
    }
};



Django側の以下のView(記事中にあるもの)を呼び出します。
以下の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,
        )

        return res


このViewには以下のPassをつけています。

path('jwtcookie/create', TokenObtainView.as_view(), name='jwtcreate')

独自の認証とMiddlewareとして以下を設定しています。


class CookieHandlerJWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        # If cookie contains access token, put it inside authorization header
        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)


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

と、一応私もDEBUGで判断するようにしていますが、どちらの場合でもレスしたSSの通りな感じです。

余談なのですが、Logoutする場合はresponse.delete_cookieでTokenを削除する関数必要ですよね?

shitikamiyakoshitikamiyako

調べた結果、解決できたと思います。

StackOverFlow

以上のStackOverFlowにもある通り、DEBUG時 = Laxの状態ではフロントとサーバーで同じドメイン名しないとダメってことでした。
私はDjango側へのRequestをhttp://127.0.0.1:8000で送っていたのですが、これをhttp://localhost:8000に変えたら無事、set_cookieをブラウザ上でも確認できました。
DEBUG = False時には元の通りhttp://127.0.0.1:8000のままでもフロント側をhttpsで立ち上げれば問題なくset_cookieを確認することができました。
こんなところに落とし穴があったとは思わなかったです……お恥ずかしい。
余談ですが、Logoutは以下の通りViewを作ってやってみてます。


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)

marumaru

@shitikamiyakoさん

レスできなくてすみません。
なるほど。勉強になります。
解決されたようで良かったです

httpOnlyなのでおっしゃる通りログアウト用のエンドポイント(クッキーの削除)も必要だと思います。
ログアウトの処理も細かくは見れてませんが問題ないと思いますよ

shitikamiyakoshitikamiyako

ありがとうございます。
去年これをやろうとして全然情報がなくて諦めてハイブリッドアーキテクチャ方向へ活路を見出したので、知見を得られて大変助かりました。
海外の方もStackOverFlowとか見てるとこのあたり難儀しているみたいなんですけど、Djangoとサードパーティは消極的な感じを受けますよね、TokenのCookie管理。

去年辺りからエンジニアとして就職したいと色々やっていてままなりませんが頑張っていきます。

marumaru

最近Django触ってないのであまり正確にはわからないのですが、Django側としてはDjangoと組み合わせてreact(vue)やnext(nuxt)と組み合わせて使うくらいならflaskやbottle, FastApi使えばいいじゃんみたいな風に割り切ってるような気もしますね

応援してます