DjangoとJWT認証

Django

はじめに

続き。
バックエンドのDjangoでJWTを使ってユーザ認証を行う部分です。
これが動けばフロントエンドに取り掛かれます。

コツ

admin.pyでカスタムユーザーを扱えるように記述しないと、Djangoの管理画面で新規登録したユーザのパスワードがハッシュで暗号化されず、平文で保存されるのでJWT認証ができない。

構成

現時点でPython3.11を使うとDjangoが一部のライブラリが読み込めずにinternal Server Errorになるので、Python3.10を使います。

- Debian bullseye 64bit
- Python 3.10.13
    pipenvによる仮想環境
- Django 4.2.6
- djangorestframework 3.14.0
- djangorestframework-simplejwt 5.3.0
- PyJWT 2.8.0

ディレクトリ構成

pipenvで仮想環境(.venv、Pipfile、Pipfile.lock)を作っています。
また、uWSGI(uwsgi.ini)を使ってHTTPで通信します。

.venv
Pipfile
Pipfile.lock
db.sqlite3
uwsgi.ini
manage.py

config
   - asgi.py
   - settings.py
   - urls.py
   - wsgi.py

user
  - admin.py
  - apps.py
  - migrations
  - models.py
  - serializers.py
  - tests.py
  - urls.py
  - views.py

カスタムユーザーのアプリ作成

「user」というカスタムユーザーのアプリを作成します。
設定ファイルはconfigディレクトリに作成します。

mkdir backend
cd backend
django-admin startproject config .
django-admin startapp user

settings.py

「settings.py」に以下を追記します。

INSTALLED_APPS = [
    省略
    'rest_framework',
    'rest_framework_simplejwt',
    'user',   # Custom User
]

# Custom User Model
AUTH_USER_MODEL = 'user.CustomUser'

REST_FRAMEWORK = {
    # JWT認証
    'DEFAULT_AUTHENTICATION_CLASSES': (
        #'rest_framework.authentication.TokenAuthentication',
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    # 認証が必要
    #'DEFAULT_PERMISSION_CLASSES': [
    #    'rest_framework.permissions.IsAuthenticated',
    #],
}

SIMPLE_JWT = {
    # アクセストークン(1時間)
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),
    # リフレッシュトークン(3日)
    'REFRESH_TOKEN_LIFETIME': timedelta(days=3),
    # 認証タイプ
    #'AUTH_HEADER_TYPES': ('JWT', ),
    # 認証トークン
    #'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken', ),
    # トークン再発行にリフレッシュトークンを含める
    'ROTATE_REFRESH_TOKENS': True,
    # 最終ログイン日時を更新
    'UPDATE_LAST_LOGIN': True,
}

models.py

カスタムユーザーモデルを定義します。

from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
import uuid

class CustomUserManager(BaseUserManager):
    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError("Users must have an email address")

        user = self.model(
            email = self.normalize_email(email),
            **extra_fields
        )

        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password=None, **extra_fields):
        user = self.create_user(
            email,
            password=password,
        )
        user.is_staff     = True
        user.is_superuser = True
        user.save(using=self._db)
        return user

class CustomUser(AbstractBaseUser, PermissionsMixin):
    id           = models.UUIDField(verbose_name='UUID', default=uuid.uuid4, primary_key=True, editable=False)
    email        = models.EmailField(verbose_name='メールアドレス', unique=True)
    name         = models.CharField(verbose_name='氏名', max_length=128)
    affiliation  = models.CharField(verbose_name='所属', max_length=256)
    remarks      = models.TextField(verbose_name='備考', blank=True, null=True)
    is_active    = models.BooleanField(verbose_name='アカウント有効', default=True)
    is_staff     = models.BooleanField(verbose_name='スタッフ権限', default=False)
    created_time = models.DateTimeField(verbose_name='作成日時', auto_now_add=True)
    updated_time = models.DateTimeField(verbose_name='更新日時', auto_now=True)

    objects = CustomUserManager()

    USERNAME_FIELD  = 'email'
    REQUIRED_FIELDS = ['password']

    # 他のモデルからChargeTypeをForeignKeyで指定した場合に名前が表示されるようにする。
    def __str__(self):
        return self.email

    # 管理画面の表示名(大項目名)を変更する。
    class Meta:
        verbose_name        = "ユーザー"
        verbose_name_plural = "ユーザー"

serializers.py

シリアライザーを記述します。

from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core import exceptions
from rest_framework import serializers
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from django.contrib.auth.hashers import make_password

#from .models import CustomUser
CustomUser = get_user_model()

class CustomUserCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = CustomUser
        #fields = ['email', 'password']
        #fields = ['id', 'email', 'password']
        fields = '__all__'
        extra_kwargs = {'password': {'write_only': True}}

    def validate(self, data):
        user = CustomUser(**data)
        password = data.get('password')

        try:
            validate_password(password, user)
        except exceptions.ValidationError as e:
            serializer_errors = serializers.as_serializer_error(e)
            raise exceptions.ValidationError(
                {'password': serializer_errors['non_field_errors']}
            )

        return data
    
    def create(self, validated_data):
        user = CustomUser(
        #user = CustomUser.objects.create_user(
            email    = validated_data['email'],
            password = validated_data['password']
        )
        ser.set_password(validated_data['password'])
        user.save()
        return user


class CustomUserSerializer(serializers.ModelSerializer):
    class Meta:
        model = CustomUser

        # ここを変更すると、JWTで取得できる情報の種類を変更可能
        fields = ['email']
        #fields = '__all__'

    #def validate_password(self, value:str) ->str:
    #    # ハッシュ値に変換する
    #    return make_password(value)


class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
    def validate(self, attrs):
        data = super(CustomTokenObtainPairSerializer, self).validate(attrs)
        user = CustomUserSerializer(self.user)
        data.update({'user': user.data})
        return data

views.py

各クラスを定義します。

from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.views import TokenObtainPairView
from .serializers import CustomUserCreateSerializer, CustomUserSerializer, CustomTokenObtainPairSerializer

class Register(APIView):
    def post(self, request):
        data = request.data

        serializer = UserCreateSerializer(data=data)

        if not serializer.is_valid():
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

        user = serializer.create(serializer.validated_data)

        refresh = RefreshToken.for_user(user)

        user = UserSerializer(user)

        data = {
            "refresh": str(refresh),
            "access": str(refresh.access_token),
            "user": user.data
        }

        return Response(data, status=status.HTTP_201_CREATED)


class VerifyUser(APIView):
    permissions_classes = [permissions.IsAuthenticated]

    def get(self, request):
        user = request.user
        user = UserSerializer(user)

        data = {"user": user.data}
        return Response(data, status=status.HTTP_200_OK)


class MyTokenObtainPairView(TokenObtainPairView):
    serializer_class = CustomTokenObtainPairSerializer

urls.py

URLを設定します。

from django.urls import path
from .views import Register, VerifyUser, MyTokenObtainPairView
from rest_framework_simplejwt.views import TokenRefreshView
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    path('signup', Register.as_view()),
    path('login', MyTokenObtainPairView.as_view()),
    path('token/refresh', TokenRefreshView.as_view()),
    path('token/verify', VerifyUser.as_view()),

    # test
    # https://zenn.dev/tanaka_gaku/articles/b2b99acd100792
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]

admin.py

管理画面でカスタムユーザーを扱うために記述します。
これらを追記しないと情報の表示、新規ユーザの追加などが行えません。

from django.contrib import admin
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.admin import UserAdmin as DefaultUserAdmin
from django.contrib.auth import get_user_model

#from .models import CustomUser
CustomUser = get_user_model()
 
class CustomUserChangeForm(UserChangeForm):
    class Meta(UserChangeForm.Meta):
        model  = CustomUser
        fields = (
            'email',
            'name',
            'affiliation',
            'remarks',
            'is_staff',
            'is_active',
        )


class CustomUserAdmin(DefaultUserAdmin):
    # 保存ボタンの配置を上部に変更
    save_on_top = True
    # デフォルトの「保存して1つ追加」を「新規保存」に変更。
    # 追加されているデータを元に新しくデータを保存する。
    save_as = True

    form     = CustomUserChangeForm
    add_form = DefaultUserAdmin.add_form
    model    = CustomUser

    # 一覧で表示する項目
    list_display = (
        'email', 
        'name',
        'affiliation',
        'remarks',
        'is_staff',
        'is_active',
        "is_superuser",
    )

    # CustomUserモデルで定義されている不要なフィールド(usernameなど)を定義しないようにする。
    ordering = ("email",)
 
    filter_horizontal = (
        "groups",
        "user_permissions",
    )

    # ユーザ情報で表示する項目
    fieldsets = (
        (None, {'fields': ('email', 'password',)}),
        ('Personal info', {'fields': ('name', 'affiliation', 'remarks',)}),
        ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions',)}),
        ('Important dates', {'fields': ('last_login',)}),
    )

    # ユーザーの新規追加に必要なフィールド
    # password1、password2は必須。
    # 参考:https://stackoverflow.com/questions/65049506/unknown-fields-username-specified-for-user-check-fields-fieldsets-exclude-a
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': (
                'email',
                'name',
                'affiliation',
                'remarks',
                'password1',
                'password2',
            ),
        }),
    )

admin.site.register(CustomUser, CustomUserAdmin)

データベース反映

データベースに反映させます。

python manage.py makemigrations

python manage.py migrate

ユーザの作成

管理ユーザを作成します。
また、管理画面でテストユーザを作成します。

python manage.py createsuperuser

パスワードのハッシュ化を確認する

データベースの登録状況と、パスワードがハッシュ化されているかを確認します。
ハッシュ化されていないと、curlでユーザ情報を取得できません。
テストなのでSQLiteを使います。

「.tables」でユーザ情報が保存されているテーブル名を取得して、「select * from user_customuser;」で中身を確認します。

sudo apt install sqlite3

python manage.py dbshell

.tables

select * from user_customuser;

PostgreSQLの場合は、「\d」でユーザ情報が保存されているテーブル名を取得して、「select * from user_customuser;」で中身を確認します。

sudo apt install psycopg2

python manage.py dbshell

\d

select * from user_customuser;

JWTの接続テスト

テストサーバーを起動します。
テストサーバなのでローカルのみしかアクセスできませんが、この記事を参考にして、uWSGIをHTTP通信で動かせば外部からもテストできます。

python manage.py runserver

curlで情報を取得できることを確認します。
管理者(kanri@hoge.com、パスワード;abcd)の場合、

curl -X POST "http://127.0.0.1:8000/user/api/token" -H "accept: application/json" -H "Content-Type: application/json" -d '{"email": "kanri@hoge.com", "password": "abcd"}'

curl -X POST "http://127.0.0.1:8000/user/login" -H "accept: application/json" -H "Content-Type: application/json" -d '{"email": "kanri@hoge.com", "password": "abcd"}'

テストユーザ(test@hoge.com、パスワード;abcd)の場合、

curl -X POST "http://127.0.0.1:8000/user/api/token" -H "accept: application/json" -H "Content-Type: application/json" -d '{"email": "test@hoge.com", "password": "abcd"}'

curl -X POST "http://127.0.0.1:8000/user/login" -H "accept: application/json" -H "Content-Type: application/json" -d '{"email": "test@hoge.com", "password": "abcd"}'

Comments