72
74

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Django REST framework に JWTログイン機能を実装する

Last updated at Posted at 2020-05-04

前回までの記事

1.Django REST framework + Vue.js「SEIYU風のECサイトを作りましょう」全6回(予定)
2.SEIYU風のECサイトを作りましょう(1)要求分析とプロジェクト初期化
3.SEIYU風のECサイトを作りましょう(2)Xadminを使って管理画面を一新します
4.SEIYU風のECサイトを作りましょう(3)Django REST frameworkで爆速APIを作りましょう
5.SEIYU風のECサイトを作りましょう(4) Vue.js プロジェクト初期化 TypeScript使用

今までの内容観てなくとも今回の内容わかりますので、最後までお付き合いして頂けると幸いです。:relaxed:

JWTとは何か

JSON Web Tokenの略称になります、トークン内に任意の情報(クレーム)を保持することが可能であり、例えばサーバーはクライアントに対して「管理者としてログイン済」という情報を含んだトークンを生成することができる。クライアントはそのトークンを、自身が管理者としてログイン済であることの証明に使用することができる。 --[Wikipedia]
(https://ja.wikipedia.org/wiki/JSON_Web_Token)

手書きで実装

django-rest-framework-jwtというライブラリ使用しても、簡単にJWTログイン実装可能ですが、
直接書いた方が色々とカスタマイズしやすいので、直接書きます。
*使い方知りたい方がいれば、また追記いたします。:point_up_tone1:

新規でDjango REST frameworkのプロジェクトを作ります

pip install Django
pip install djangorestframework
pip install markdown
pip install django-filter
django-admin startproject jwttest
cd jwttest
python manage.py runserver

サーバー起動して、正常にロケット見れたらOKです。

初期設定

新規appを作ります。

python manage.py startapp api

rest_frameworkと一緒に INSTALLED_APPSに追加します。

jwttest/settings.py
INSTALLED_APPS = [
    ...
    'rest_framework',
    'api'
]

ログイン用のユーザーモデルを作ります。

jwtest/api/models.py
from django.db import models


class UserInfo(models.Model):
    username = models.CharField(max_length=50, unique=True, db_index=True)
    password = models.CharField(max_length=100, db_index=True)
    info = models.CharField(max_length=200)

DBマイグレーションを実行します。

python manage.py makemigrations
python manage.py migrate

ログイン機能を実装します

JWTtoken生成用のライブラリをインストールします。

pip install pyjwt

ログイン用のclassを追加します、
apiディレクトリの配下にutilsフォルダを追加し、中にauth.pyファイルを新規追加します。

api/utils/auth.py
import time
import jwt
from jwttest.settings import SECRET_KEY
from rest_framework.authentication import BaseAuthentication
from rest_framework import exceptions

from api.models import UserInfo

class NormalAuthentication(BaseAuthentication):
    def authenticate(self, request):
        username = request._request.POST.get("username")
        password = request._request.POST.get("password")
        user_obj = UserInfo.objects.filter(username=username).first()
        if not user_obj:
            raise exceptions.AuthenticationFailed('認証失敗')
        elif user_obj.password != password:
            raise exceptions.AuthenticationFailed('パスワードあってません')
        token = generate_jwt(user_obj)
        return (token, None)

    def authenticate_header(self, request):
        pass

# 先程インストールしたjwtライブラリでTokenを生成します
# Tokenの内容はユーザーの情報とタイムアウトが含まれてます
# タイムアウトのキーはexpであることは固定してます
# ドキュメント: https://pyjwt.readthedocs.io/en/latest/usage.html?highlight=exp
def generate_jwt(user):
    timestamp = int(time.time()) + 60*60*24*7
    return jwt.encode(
        {"userid": user.pk, "username": user.username, "info": user.info, "exp": timestamp},
        SECRET_KEY).decode("utf-8")

ログイン用のviewも追加します。
もしログイン成功したら、JWTを返す仕組みです。
authentication_classesに先程作ったNormalAuthentication を追加します。

api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response

from .utils.auth import NormalAuthentication


class Login(APIView):

    authentication_classes = [NormalAuthentication,]

    def post(self, request, *args, **kwargs):
        return Response({"token": request.user})

urlを追加します。

jwttest/urls.py
...
from api.views import Login

urlpatterns = [
    ...
    path('login/', Login.as_view()),
]

ユーザー情報一つ追加して、B2DC6C55-949F-4FB9-ADCB-ED3C0F894B1E_4_5005_c.jpeg

サーバー立ち上げて、実際ログインしてみます。
82BFEFD1-D012-4F3A-80D8-5CC828590481.jpeg
返されたJWTをhttps://jwt.io/で解析してみます。
BE755CEB-725D-4833-A954-0300EF1A4E0B.jpeg
JWTにちゃんと指定した情報が含まれています。
ログインしてないと見れないviewを作って、このToken使ってアクセスしてみよう。
まずapi/utils/auth.pyにJWT用の認証用classを追加します。

api/utils/auth.py
...
class JWTAuthentication(BaseAuthentication):
    keyword = 'JWT'
    model = None

    def authenticate(self, request):
        auth = get_authorization_header(request).split()

        if not auth or auth[0].lower() != self.keyword.lower().encode():
            return None

        if len(auth) == 1:
            msg = "Authorization 無効"
            raise exceptions.AuthenticationFailed(msg)
        elif len(auth) > 2:
            msg = "Authorization 無効 スペースはない"
            raise exceptions.AuthenticationFailed(msg)

        try:
            jwt_token = auth[1]
            jwt_info = jwt.decode(jwt_token, SECRET_KEY)
            userid = jwt_info.get("userid")
            try:
                user = UserInfo.objects.get(pk=userid)
                user.is_authenticated = True
                return (user, jwt_token)
            except:
                msg = "ユーザー存在しません"
                raise exceptions.AuthenticationFailed(msg)
        except jwt.ExpiredSignatureError:
            msg = "tokenはタイムアウトしました"
            raise exceptions.AuthenticationFailed(msg)

    def authenticate_header(self, request):
        pass
...

ログインしてないとアクセスできないviewをapi/views.pyに追加。

api/views.py
...
from rest_framework.permissions import IsAuthenticated
...
class Something(APIView):
    authentication_classes = [JWTAuthentication, ]
    # ログインしてるユーザーだけアクセスできるようにします。
    permission_classes = [IsAuthenticated, ]

    def get(self, request, *args, **kwargs):
        return Response({"data": "中身です"})
...

urlを足したらアクセスしてみます。

jwttest:urls.py
    path('data/', Something.as_view())

まずはTokenない状態でアクセスします。
認証資格情報が提供されませんでしたと返ってきました。
FFA8B8F1-6250-40FF-9FA9-6DDEB186B3EB_4_5005_c.jpeg

Tokenを追加しますと、アクセスできました。
528923C5-B32E-44B4-A33F-2ED3F4564042_4_5005_c.jpeg

以上で終わりですが、Django REST frameworkのログイン関連のソースコードの分析も多少いれたいと思います。
興味ある方は読んでください。:relaxed:

DRFのログインソースコード分析

使用する認証classとのリンク

先程書いたコードを元に分析して行きます、
CBV(Class-based views)使用する場合、dispatchは実行されます。
それを入り口として、ソースコード見ていきます。
見方としては、先程書いたログイン用のclassにself.dispatch()を追加します。

api/view.py
...
class Login(APIView):
    authentication_classes = [NormalAuthentication, ]
    def post(self, request, *args, **kwargs):
        # 追加して、ソースコード辿っていきます
        # PyCharm使用する場合
        # macではcommand+クリック
        # winではAlt+クリックのはず
        self.dispatch() 
        return Response({"token": request.user})
...

行き先はrest_framework/views.pyの481行のdef dispatch(self, request, *args, **kwargs):になります。
488行目のinitialize_request関数の中身をみていきます。

rest_framework/views.py
    def dispatch(self, request, *args, **kwargs):
        ...
        # この関数の中身を見てきます
        request = self.initialize_request(request, *args, **kwargs)
        self.request = request

中身の定義はrest_framework/views.pyの381行目にあります。

rest_framework/views.py
    def initialize_request(self, request, *args, **kwargs):
        """
        Returns the initial request object.
        """
        parser_context = self.get_parser_context(request)

        return Request(
            request,
            parsers=self.get_parsers(),
            # ここ 
            authenticators=self.get_authenticators(),
            negotiator=self.get_content_negotiator(),
            parser_context=parser_context
        )

そして390行にあるself.get_authenticators()の中身を見にいきますと、
以下のコードがあります、該当CBV使用してる認証classは何なのか、self.authentication_classesから取ってきてます。

rest_framework/views.py
    def get_authenticators(self):
        """
        Instantiates and returns the list of authenticators that this view can use.
        """
        return [auth() for auth in self.authentication_classes]

self.authentication_classesの定義を追っていきますと、
rest_framework/views.pyの109行目では、以下の定義があります。

rest_framework/views.py
class APIView(View):
   ...
   authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES

よって、認証class何を使用するのか、直接定義出来る箇所は二つありまして、

  1. APIView継承したCBVclassの内部
api/views.py
...
class Login(APIView):
    # ここ
    authentication_classes = [NormalAuthentication, ]

    def post(self, request, *args, **kwargs):
        return Response({"token": request.user})
...

2.settings.pyREST_FRAMEWORK用設定の内部

REST_FRAMEWORK = {
  'DEFAULT_AUTHENTICATION_CLASSES': ['api.utils.auth.NormalAuthentication']
}

CBVと認証用classの関連性を把握したら、次は認証用classの機能を見ていきます。
またdispatch から辿っていきます、rest_framework/views.pyの493行のself.initial(request, *args, **kwargs)

rest_framework/views.py
    def dispatch(self, request, *args, **kwargs):
        ...

        try:
            self.initial(request, *args, **kwargs)
        ...

それの定義を追っていきます。rest_framework/views.pyの395行目。
perform_authenticationがあります、さらに辿っていきます。

rest_framework.py/views.py
...
    def initial(self, request, *args, **kwargs):
        ...
        self.perform_authentication(request)
        ...

その先にrequest.userがあります。

rest_framework/views.py
...
    def perform_authentication(self, request):
        """
        Perform authentication on the incoming request.

        Note that if you override this and simply 'pass', then authentication
        will instead be performed lazily, the first time either
        `request.user` or `request.auth` is accessed.
        """
        request.user
...

それの定義は rest_framework/request.pyの213行にあります。

rest_framework/request.py
...
    @property
    def user(self):
        """
        Returns the user associated with the current request, as authenticated
        by the authentication classes provided to the request.
        """
        if not hasattr(self, '_user'):
            with wrap_attributeerrors():
                self._authenticate()
        return self._user
...

self._authenticate()の定義を見てみます。 rest_framework/request.pyの366行目。
内容としては、CBVの認証classリストから、classを取り出して、そのclassのauthenticateメソッドを実行します。
実行した結果は二つの要素が入ってるtupleである、tupleの一番目はself.userになる、二番目はself.authになります。

rest_framework/request.py
    def _authenticate(self):
        """
        Attempt to authenticate the request using each authentication instance
        in turn.
        """
        for authenticator in self.authenticators:
            try:
                user_auth_tuple = authenticator.authenticate(self)
            except exceptions.APIException:
                self._not_authenticated()
                raise

            if user_auth_tuple is not None:
                self._authenticator = authenticator
                self.user, self.auth = user_auth_tuple
                return

        self._not_authenticated()

それを踏まえて、先程定義したJWT用の認証classを見てみます。

api/utils/auth.py
class JWTAuthentication(BaseAuthentication):
    keyword = 'JWT'
    model = None

    def authenticate(self, request):
        auth = get_authorization_header(request).split()

        if not auth or auth[0].lower() != self.keyword.lower().encode():
            return None

        if len(auth) == 1:
            msg = "Authorization 無効"
            raise exceptions.AuthenticationFailed(msg)
        elif len(auth) > 2:
            msg = "Authorization 無効 スペースはない"
            raise exceptions.AuthenticationFailed(msg)

        try:
            jwt_token = auth[1]
            jwt_info = jwt.decode(jwt_token, SECRET_KEY)
            userid = jwt_info.get("userid")
            try:
                user = UserInfo.objects.get(pk=userid)
                user.is_authenticated = True
                return (user, jwt_token)
            except:
                msg = "ユーザー存在しません"
                raise exceptions.AuthenticationFailed(msg)
        except jwt.ExpiredSignatureError:
            msg = "tokenはtimeout"
            raise exceptions.AuthenticationFailed(msg)

    def authenticate_header(self, request):
        pass

classにauthenticateメソッド含まれています。
認証成功すれば、ユーザー情報含むtupleを返します。
ログイン関連ソースの解析は以上となります。

最後まで付き合って頂きありがとうございます。:raised_hand_tone1:

72
74
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
72
74

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?