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

7 min read読了の目安(約6400字

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