NextjsとDjangoのjwtで認証機能を作る
DjangoとNextjsでいいかんじのjwt認証をつくりました。
cookieに入れるjwtをhttpOnlyにしたかったのですが、simplejwtの仕様上cookieに入れる部分は自分でやる必要がありました。(simplejwtのソースコードを完全に読んだわけではないので定かではないですが、、、)
そこで、ない知恵を絞り、少しsimplejwtをカスタマイズして実装しました。
かなり未熟ではありますが、自分なりにはなかなかいいものができたのではないかと思ったので記事を書きました。
参考にした記事や前提
Django
シンプルにDRF(Django rest framework)を使用。
jwtに関しては
この方のように自作するのもありですが、めんどうだったのでsimplejwtを使いました。
Nextjs
グローバルでのログイン状態の管理はcatnoseさんのこの記事を丸パクリ参考にさせていただきました。
仕組み
ログイン処理
ユーザー名とパスワードでログインすると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は使用しておらずかなり荒い部分があると思うので、ご意見等あれば気軽にコメントください。
質問も気軽にコメントください。
こちらのサイトでこの処理を使っています
興味がある方は是非訪れてみてください
Discussion
fetchCurrentUserの部分についてなんですが、
apiを叩いてログインしている&トークン確認をするのではなく、
acssess tokenやrefresh tokenのCookieが存在しているかで、
確認することは難しいでしょうか。
access tokenが存在する場合は、ユーザー情報取得のapiのみ。
refresh token だけの場合は、 refreshとユーザー情報取得。
どちらも存在しない場合は何もしない。のようにです。
httponlyの場合はSSRやapi routeを使用しないと難しそうですが、、、
無駄なフェッチを少しでも減らしたいと思っています。
それともセキュリティ面的には毎度APIを叩いて確認するべきでしょうか。
コメントありがとうございます。
確かにそういった方法でリクエストを減らすことはできると思います。
ただ、そういった場合javascriptでクッキーの有無を確認しないとなのでhttpOnlyではできないと思います。(定かではないので間違ってたらごめんなさい🙇)
頑張ってやろうとしても、おっしゃっている通りSSRでnookiesを使う等一工夫しないとできないと思います。
リクエストの回数とセキュリティのどちらを選ぶかの問題ではないでしょうか。
自分の場合よりセキュアな方法(とりあえずリクエストを投げる&httpOnly)を選びました。
それに加えたぶんそこまで重いリクエストでもないと思っているので、ある程度無駄にリクエストが走ってもいいやくらいな気持ちでやってます。
なるほど確かにそうですね。
仰る通り、後者で進めていこうと思います。
ありがとうございます!
はじめまして、自分は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)が返ってきてはいるのですが。
@shitikamiyakoさん
コメントありがとうございます。
レスおそくなってすいません。
クッキーの属性については私の場合settings.pyのdebugで分けています。
としています。
ヘッダーのAccess-Control-Allow-Credentialsがtrueになっているかも少し気になりますね。
あとはjs側のajax系の処理でcredentialsを許可しているかあたりではないでしょうか。
フロント側は以下のようなリクエストで
Django側の以下のView(記事中にあるもの)を呼び出します。
以下のViewには
このViewには以下のPassをつけています。
独自の認証とMiddlewareとして以下を設定しています。
と、一応私もDEBUGで判断するようにしていますが、どちらの場合でもレスしたSSの通りな感じです。
余談なのですが、Logoutする場合はresponse.delete_cookieでTokenを削除する関数必要ですよね?
調べた結果、解決できたと思います。
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を作ってやってみてます。
@shitikamiyakoさん
レスできなくてすみません。
なるほど。勉強になります。
解決されたようで良かったです
httpOnlyなのでおっしゃる通りログアウト用のエンドポイント(クッキーの削除)も必要だと思います。
ログアウトの処理も細かくは見れてませんが問題ないと思いますよ
ありがとうございます。
去年これをやろうとして全然情報がなくて諦めてハイブリッドアーキテクチャ方向へ活路を見出したので、知見を得られて大変助かりました。
海外の方もStackOverFlowとか見てるとこのあたり難儀しているみたいなんですけど、Djangoとサードパーティは消極的な感じを受けますよね、TokenのCookie管理。
去年辺りからエンジニアとして就職したいと色々やっていてままなりませんが頑張っていきます。
最近Django触ってないのであまり正確にはわからないのですが、Django側としてはDjangoと組み合わせてreact(vue)やnext(nuxt)と組み合わせて使うくらいならflaskやbottle, FastApi使えばいいじゃんみたいな風に割り切ってるような気もしますね
応援してます