React と DRF の SPA で JWT を Cookieで管理してみた話
はじめに
去年、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 など
Token を set_cookie する 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
上 2 つと合わせて、認証成功したらログインユーザーを返す View とログアウト時に Cookie から Token を削除する View
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