djoserを使ったDjango REST Frameworkでのカスタムユーザーモデル認証機能の実装

djoserとは

djoserとはDjango REST Framework上での基本的なユーザー認証や登録などの認証周りをサポートしてくれるライブラリです。
カスタムモデルに対しても使え、Djangoのコードを再利用するような形をとるのではなく、Single Page Application(以下SPA)によりフィットするようなアーキテクチャを目指して作られています。

今回はdjoserの最もシンプルな認証機能の実装について書きます。
なお、この認証はセキュリティの面などから実際に使用するべきではなく、以下のJWT認証のようなより強固なセキュリティの設定があります。
あくまでお手軽な認証として紹介します。

ソースコードはこちら

なお、以下の全てが導入後にエンドポイントとして使えます。

/users/
/users/me/
/users/confirm/
/users/resend_activation/
/users/set_password/
/users/reset_password/
/users/reset_password_confirm/
/users/set_username/
/users/reset_username/
/users/reset_username_confirm/
/token/login/ (Token Based Authentication)
/token/logout/ (Token Based Authentication)
/jwt/create/ (JSON Web Token Authentication)
/jwt/refresh/ (JSON Web Token Authentication)
/jwt/verify/ (JSON Web Token Authentication)
Getting started

djoser関連の記事を他にも書いています。

なお、今回はJWT認証を用いたユーザー認証を実装することとします。

使い方

途中までの流れはJWT認証の回と一緒で、大きく変わるのは自身でmodels.pyを書くことと、settings.pyが一部増えることです。

$ pip install -U djoser

JWT認証を使うので、simple_jwtも合わせて使う必要があります。

$ pip install -U djangorestframework_simplejwt

まずはプロジェクトを作り、

$ django-admin startproject djoser_customuser

プロジェクト内に移動し、

$ cd djoser_customuser

アプリを作成します。

$ django-admin startapp accounts

次にDjangoの設定を行なっていきます。


from datetime import timedelta # add

   .........

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework', # add
    'djoser', # add
    'accounts' # add
]

# add
SIMPLE_JWT = {
    #トークンをJWTに設定
    'AUTH_HEADER_TYPES':('JWT'),
    #トークンの持続時間の設定
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60)
}

# add
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
}

# add
AUTH_USER_MODEL = 'user.accounts'

from django.contrib import admin
from django.urls import path,include #add

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/auth/',include('djoser.urls')), #add
    path('api/auth/',include('djoser.urls.jwt')), #add
]

ここからJWT認証の記事と大きく変わる部分についてで、カスタムユーザーモデルを書いていきます。
なぜデフォルトのものではなくカスタムユーザーモデルを使うものを使うのかという点についてですが、一般的にデフォルトで用意されているユーザーモデルでは自身の望んだWebアプリを作る時に十分でない、かつ開発が進んだのちにユーザーモデルを改修したいと思っても難しいという点から予め要件を満たすようなカスタムユーザーモデルを設計しておくことが望ましいとされています。

さらに付け加えておくと、Djangoにはデフォルトで付属している「AbstractUser」クラスと「AbstractBaseUser」の二つがあります。

  • AbstractUser

Userクラスに似ているために、コーディング量が少ないため使いやすいという特徴があります。
そのため、属性を追加するのみ等の場合に使用することが多いです。

  • AbstractBaseUser

ユーザー名やその他の属性を書く必要があるため、コーディング量は多くなりますが、AbstractUserより柔軟性があるという特徴があります。

一般的には以上の特徴があります。
今回は「AbstactBaseUser」を使用します。

from django.db import models

# add
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.utils import timezone
from django.core.mail import send_mail
from django.contrib.auth.base_user import BaseUserManager

# add
class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, username, email, password, **extra_fields):
        """
        Create and save a user with the given username, email, and password.
        """
        if not username:
            raise ValueError('The given username must be set')
        email = self.normalize_email(email)
        username = self.model.normalize_username(username)
        user = self.model(username=username, email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, username, email=None, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(username, email, password, **extra_fields)

    def create_superuser(self, username, email, password, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)

        if extra_fields.get('is_staff') is not True:
            raise ValueError('Superuser must have is_staff=True.')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Superuser must have is_superuser=True.')

        return self._create_user(username, email, password, **extra_fields)

class User(AbstractBaseUser, PermissionsMixin):
    """
    An abstract base class implementing a fully featured User model with
    admin-compliant permissions.
    Username and password are required. Other fields are optional.
    """
    username_validator = UnicodeUsernameValidator()

    username = models.CharField(
        _('username'),
        max_length=150,
        unique=True,
        help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'),
        validators=[username_validator],
        error_messages={
            'unique': _("A user with that username already exists."),
        },
    )
    first_name = models.CharField(_('first name'), max_length=30, blank=True)
    last_name = models.CharField(_('last name'), max_length=150, blank=True)
    email = models.EmailField(_('email address'), blank=True,unique=True)
    profile = models.CharField(_('profile'), max_length=255, blank=True) # add
    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_('Designates whether the user can log into this admin site.'),
    )
    is_active = models.BooleanField(
        _('active'),
        default=True,
        help_text=_(
            'Designates whether this user should be treated as active. '
            'Unselect this instead of deleting accounts.'
        ),
    )
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)


    objects = UserManager()

    EMAIL_FIELD = 'email' # fix
    USERNAME_FIELD = 'email' # fix
    REQUIRED_FIELDS = ['username'] # fix

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')

    def clean(self):
        super().clean()
        self.email = self.__class__.objects.normalize_email(self.email)

    def get_full_name(self):
        """
        Return the first_name plus the last_name, with a space in between.
        """
        full_name = '%s %s' % (self.first_name, self.last_name)
        return full_name.strip()

    def get_short_name(self):
        """Return the short name for the user."""
        return self.first_name

    def email_user(self, subject, message, from_email=None, **kwargs):
        """Send an email to this user."""
        send_mail(subject, message, from_email, [self.email], **kwargs)

これ以降はJWTの記事と全く同じ流れです。ただし、違いがあり、まずmodels.pyで

EMAIL_FIELD = 'email' # fix
USERNAME_FIELD = 'email' # fix
REQUIRED_FIELDS = ['username'] # fix

としているため、今まではユーザーを作成する際にユーザー名が必須項目になっていたのに対して今回はEmailが必須項目となっています。
実際に何かしらのWebサービスでも利用する際にもusernameよりemailの方が必須項目にすることが多いはずです。
こういったところもカスタムユーザーを使う利点です。

マイグレーションし、スーパーユーザーを作成します。

$ python manage.py makemigrations
$ python manage.py migrate
$ python manage.py createsuperuser
Email address: xxx@email.com
Username: Admin
Password:*********** 

ローカルホストで起動し、所定の
http://localhost:8000/api/auth
にアクセスします。

$ python manage.py runserver

スクリーンショット 2020-04-21 0.41.16.png

そして
http://localhost:8000/api/auth/users/
にアクセスすると

スクリーンショット 2020-04-21 0.41.25.png

やはり認証されていないがためにユーザー情報を取得できません。
そこで前回と同様、トークンを取得するために
http://localhost:8000/api/auth/jwt/create/
にアクセスし、先ほど登録したスーパーユーザーの値を入れ、右下のPOSTを押下します。

スクリーンショット 2020-04-21 0.41.41.png

すると、以下のようなトークンが発行され、下の方のアクセスのトークンをコピーします。

スクリーンショット 2020-04-21 0.42.10.png

実際にユーザー情報をcurlコマンドを使って取得してみましょう。

curl -LX GET http://127.0.0.1:8000/api/auth/users/me/ -H 'Authorization: JWT xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

(xxxxにはトークンを入力)

すると、

{"username":"Admin","id":1,"email":"xxx@email.com"}

先ほど登録したユーザー情報が返ってきます。

コメント

このブログの人気の投稿

Braveブラウザ(iPhone,iPad)にオフラインでもYouTubeの動画が視聴可能なPlaylist機能が追加されていたので使い方をまとめてみた。

自作のChrome Extensionをインポートした時に "Invalid value for 'content_scripts[0].matches[0]': Empty path."というエラーが出たので解決した

Braveブラウザの同期機能をiPhoneで設定した話。