JWT認証【Django】

# はじめに
この記事ではDjangoの中でJWT認証を行う方法、今回はSimpleJWTを取り上げます。
また、Djangoについての基本的な知識があることを前提としています。
Django初心者の方向けに記事を貼っておきます。

https://qiita.com/kaki_k/items/511611cadac1d0c69c54

# JWT認証とは
詳細はこちらの記事に譲る

https://qiita.com/asagohan2301/items/cef8bcb969fef9064a5c

# DjangoでJWT認証を使う
DjangoRestFramework(以下、DRF)公式で紹介されているSimpleJWTを用いた認証を実装していく


## セットアップ
まず、パッケージをインストールする
```
$ pip install djangorestframework-simplejwt
```
`settings.py`にライブラリを使用する設定を追記する
下はREST APIの認証時にデフォルトでJWTAuthenticationを使う設定の追加
```settings.py
REST_FRAMEWORK = {

    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES' : ['rest_framework.permissions.IsAuthenticated']
}
```
## URLマッピング
`urls.py`にトークンの発行・再発行を行うビューを登録する
```urls.py
from restframework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [

    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
```

:::note info
SimpleJWTの組み込みViewを使用しているので、View.pyへの追加は不要
:::

## ユーザ情報の追加
認証に使用するユーザ情報を作成する。ここでは、独自のユーザテーブルは作成せず、Djangoの組み込みユーザテーブルを使用する。
```
$ python manage.py createsuperuser
```

## トークン発行確認
`http://localhost:8000/.../token/`にアクセスし、認証情報をPOSTしたときに`access`と`refresh`のトークンが得られるはず
もし`{ "detail": "No active account found with the given credentials" }`のようなレスポンスであれば、POSTしたユーザ情報が正しいか見直そう

:::note info
- アクセストークン
通常はリクエストヘッダのAuthorizationフィールドにBearerトークンとしてセットして、リクエストに付与する。
サーバはこのトークンを検証して、有効であればアクセスを許可する。
- リフレッシュトークン
アクセストークンの有効期限が切れた場合に再発行するためのトークン
リフレッシュトークンの有効期限はアクセストークンよりも長く設定される
:::

## トークンのカスタマイズ
トークンの有効時間や含める情報を設定する
```settings.py
from timedelta import datetime
from pathlib import path

SIMPLE_JWT = {

    'ACCESS_TOKEN_LIFETIME': datetime.timedelta(minutes=10), # 有効時間10分
    'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=30), # 有効時間30日
    'UPDATE_LAST_LOGIN': True, # ログイン時にauth_userテーブルにlast_loginフィールドを更新する
}
```
timedeltaがインストールされていない場合は以下でインストールできる
```
$ pip install timedelta
```

## 認証の制御（Viewの実装）
```views.py
from rest_framework import generics, status, views, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.views import APIView

class ExampleView(APIView):

    # 認証クラスの指定
    authentication_classes = [ JWTAuthentication ]

    # アクセス許可の指定
    permission_classes = [ IsAuthenticated ]

    def get(self, request):
        pass

    def post(self, request):
        pass
```
:::note info
アクセス許可指定

- 認証済みのリクエストのみ許可するとき
`authentication_classes = [ JWTAuthentication ]`
`permission_classes = [ IsAuthenticated ]`

- すべてのユーザにアクセス許可するとき（ログイン処理など）
`authentication_classes = [ ]`
`permission_classes = [ ]`
:::

## 認証トークンの保存
### アクセストークンの自動セット
:::note info
よくある実装
フロントエンドからのリクエストの際、にヘッダーに認証トークンを付与する
:::
今回は、バックエンドで自動的にセットする認証機構を取りあげる

アプリディレクトリ配下に`authentication.py`を作成する
```python:認証トークンハンドリング(authentication.py)
from rest_framework_simplejwt.authentication import JWTAuthentication

class CustomJWTAuthentication(JWTAuthentication):

    def get_header(self, request):

        token = request.COOKIES.get('access')
        request.META['HTTP_AUTHORIZATION'] = '{header_type} {access_token}'.format(header_type="Bearer", access_token=token)

        refresh = request.COOKIES.get('refresh')
        request.META['HTTP_REFRESH_TOKEN'] = refresh

        return super().get_header(request)
```

:::note warn
ブラウザがサードパーティクッキー拒否設定になっていると認証ができないことがある
:::

## ログイン処理
### トークンをCookieに保存
認証の結果、取得したトークンをCookieに保存するLoginViewクラスを追加する
```python:views.py
from django.conf import settings
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework.views import APIView

class LoginView(APIView):

    """ユーザのログイン処理
    
    Args:
        APIView (class): rest_framework.viewのAPIViewを受け取る
    """

    authentication_classes = [ JWTAuthentication ]
    permission_classes = [ ]

    def post(self, request):

        serializer = TokenObtainPairSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        access = serializer.validated_data.get("access", None)
        refresh = serializer.validated_data.get("refresh", None)

        if access:
            response = Response(status=status.HTTP_200_OK)
            max_age = settings.COOKIE_TIME
            response.set_cookie('access', access, httponly=True, max_age=max_age)
            response.set_cookie('refresh', refresh, httponly=True, max_age=max_age)

            return response

        return Response({'errMsg': 'ユーザの認証に失敗しました'}, status=status.HTTP_401_UNAUTHORIZED)
```
`LoginView`をURLマッピングに追加する
```python:urls.py（ログインビューの追加）
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from . import views

urlpatterns = [

    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("login/", views.LoginView.as_view()), # 追加
]
```
カスタムJWTとクッキーの有効期限を設定する
```python:settings.py（追記）
REST_FRAMEWORK = {

    'DEFAULT_AUTHENTICATION_CLASSES': (
        'xxx.authentication.CustomJWTAuthentication', # 追加 (xxx: authentication.pyを作成したディレクトリ)
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES' : ['rest_framework.permissions.IsAuthenticated']
}

# クッキーの有効期限の設定
COOKIE_TIME = 60 * 60 * 12 # 追加
```

## トークン再発行
```python:views.py（追記）
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, TokenRefreshSerializer
from rest_framework.views import APIView

class RetryView(APIView):

    authentication_classes = [ JWTAuthentication ]
    permission_classes = []

    def post(self, request):

        request.data['refresh'] = request.META.get('HTTP_REFRESH_TOKEN')
        serializer = TokenRefreshSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        access = serializer.validated_data.get('access', None)
        refresh = serializer.validated_data.get('refresh', None)

        if access:
            response = Response(status=status.HTTP_200_OK)
            max_age = settings.COOKIE_TIME
            response.cookie_set('access', access, httponly=True, max_age=max_age)
            response.cookie_set('refresh', refresh, httponly=True, max_age=max_age)

            return response

        return Response({'errMsg': 'ユーザの認証に失敗しました'}, status=status.HTTP_401_UNAUTHORIZED)
```
```python:urls.py
from restframework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from . import views

urlpatterns = [

    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("login/", views.LoginView.as_view()),
    path("retry/", views.RetryView.as_view()), # 追加
]
```

## ログアウト処理
```python:views.py（追記）
class LogoutView(APIVIew):

    authentication_classes = []
    permission_classes = []

    def post(self, request, *args):

        response = Response(status=status.HTTP_200_OK)
        response.delete_cookie('access')
        response.delete_cookie('refresh')

        return response
```
```python:urls.py
from restframework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from . import views

urlpatterns = [

    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("login/", views.LoginView.as_view()),
    path("retry/", views.RetryView.as_view()), 
    path("logout/", views.LogoutView.as_view()), # 追加
]
```

## 全体コード
```python:settings.py（追記部抜粋）
from timedelta import datetime
from pathlib import path

REST_FRAMEWORK = {

    'DEFAULT_AUTHENTICATION_CLASSES': (
        'xxx.authentication.CustomJWTAuthentication', # (xxx: authentication.pyを作成したディレクトリ)
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES' : ['rest_framework.permissions.IsAuthenticated']
}

# クッキーの有効期限の設定
COOKIE_TIME = 60 * 60 * 12

SIMPLE_JWT = {

    'ACCESS_TOKEN_LIFETIME': datetime.timedelta(minutes=10), # 有効時間10分
    'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=30), # 有効時間30日
    'UPDATE_LAST_LOGIN': True, # ログイン時にauth_userテーブルにlast_loginフィールドを更新する
}
```
```python:views.py
from django.conf import settings
from rest_framework import generics, status, views, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, TokenRefreshSerializer
from rest_framework.views import APIView

class LoginView(APIView):

    """ユーザのログイン処理
    
    Args:
        APIView (class): rest_framework.viewのAPIViewを受け取る
    """

    authentication_classes = [ JWTAuthentication ]
    permission_classes = [ ]

    def post(self, request):

        serializer = TokenObtainPairSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        access = serializer.validated_data.get("access", None)
        refresh = serializer.validated_data.get("refresh", None)

        if access:
            response = Response(status=status.HTTP_200_OK)
            max_age = settings.COOKIE_TIME
            response.set_cookie('access', access, httponly=True, max_age=max_age)
            response.set_cookie('refresh', refresh, httponly=True, max_age=max_age)

            return response

        return Response({'errMsg': 'ユーザの認証に失敗しました'}, status=status.HTTP_401_UNAUTHORIZED)



class RetryView(APIView):

    authentication_classes = [ JWTAuthentication ]
    permission_classes = []

    def post(self, request):

        request.data['refresh'] = request.META.get('HTTP_REFRESH_TOKEN')
        serializer = TokenRefreshSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        access = serializer.validated_data.get('access', None)
        refresh = serializer.validated_data.get('refresh', None)

        if access:
            response = Response(status=status.HTTP_200_OK)
            max_age = settings.COOKIE_TIME
            response.cookie_set('access', access, httponly=True, max_age=max_age)
            response.cookie_set('refresh', refresh, httponly=True, max_age=max_age)

            return response

        return Response({'errMsg': 'ユーザの認証に失敗しました'}, status=status.HTTP_401_UNAUTHORIZED)



class LogoutView(APIVIew):

    authentication_classes = []
    permission_classes = []

    def post(self, request, *args):

        response = Response(status=status.HTTP_200_OK)
        response.delete_cookie('access')
        response.delete_cookie('refresh')

        return response
```
```python:authentication.py
from rest_framework_simplejwt.authentication import JWTAuthentication

class CustomJWTAuthentication(JWTAuthentication):

    def get_header(self, request):

        token = request.COOKIES.get('access')
        request.META['HTTP_AUTHORIZATION'] = '{header_type} {access_token}'.format(header_type="Bearer", access_token=token)

        refresh = request.COOKIES.get('refresh')
        request.META['HTTP_REFRESH_TOKEN'] = refresh

        return super().get_header(request)
```
```python:urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from . import views

urlpatterns = [

    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("login/", views.LoginView.as_view()),
    path("retry/", views.RetryView.as_view()), 
    path("logout/", views.LogoutView.as_view()),
]
```
:::note warn
以上の実装後、ユーザ定義関数・クラスでの認証は以下のように`CustomJWTAuthentication`を使用する

```python:ユーザ定義
class ExampleView(APIView):

    # 認証クラスの指定
    authentication_classes = [ CustomJWTAuthentication ]

    # アクセス許可の指定
    permission_classes = [ IsAuthenticated ]

    def get(self, request):
        pass

    def post(self, request):
        pass
```
:::