blog.takurinton.dev

DjangoでUser認証機能を作る

2020-12-04

こんにちは

これです
どうも、僕です。
この記事は KITアドベントカレンダー の4日目の記事になります。
今回はユーザー認証の機能を実装してみようと思います。

やり方

Djangoではデフォルトでユーザーモデルが定義されています。今回はそれを書き換えることでユーザー認証の仕組みを作成して行こうと思います。
最近だと外部の認証に任せるパターンも増えていますが、こっちの方が楽だと感じることもちょこちょこあるのでこっちで実装します。

初期設定からしていく

まずは最初の設定からやっていきます。
今回はプロジェクト名: advent_1204
アプリケーション名: myapp
としてやっていきます。

# settings.py
import os

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = 'jl^vui5i*5c*wxj3ff6jzn&ei(@jnb32m$b_^oof-3749lsh*6'

DEBUG = True

ALLOWED_HOSTS = ['*', ]

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'myapp', 
    'rest_framework', 
    'corsheaders', 
]

MIDDLEWARE = [
    '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',
]

ROOT_URLCONF = 'advent_1204.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'advent_1204.wsgi.application'


# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}


# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/

STATIC_URL = '/static/'

REST_FRAMEWORK = {
    # DRFを使用するための設定
    'DEFAULT_PARSER_CLASSES': (
        'rest_framework.parsers.FormParser',
        'rest_framework.parsers.MultiPartParser', 
        'rest_framework.parsers.JSONParser',
    ), 

    # フィルターの設定
    'DEFAULT_FILTER_BACKENDS': (
        'django_filters.rest_framework.DjangoFilterBackend',
    ),

     #  JWTのための設定
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
    ),
}

# トークンの期限を無限に設定
JWT_AUTH = {
    'JWT_VERIFY_EXPIRATION': False,
}

# デフォルトのユーザーモデルではなく自分で定義したものを使うという宣言
AUTH_USER_MODEL = 'myapp.User'

CORS_ORIGIN_ALLOW_ALL = True

こんな感じです。
ちょこちょこ説明していきたいと思います。

  • REST_FRAMEWORK
    • DEFAULT_PARSER_CLASSES
      • ここではDRFでパーサを使用する際に使うクラスを指定
      • 今回はJSONだけだけどよく使うものも入れておいた
    • DEFAULT_FILTER_BACKENDS
      • フィルターのクラスを指定
    • DEFAULT_PERMISSION_CLASSES
      • 制限をかけるためのクラスを指定
      • 全てのリクエストに認証をかけることを定義している
      • IsAuthenticatedOrReadOnlyを指定するとGETリクエストは認証なしでできるようになる
    • DEFAULT_AUTHENTICATION_CLASSES
      • 認証を行うクラスを指定
      • 認証にJWTを使用することを宣言している
  • JWT_AUTH
    • トークンの寿命を指定できる。Falseを指定すると永続化する。
  • AUTH_USER_MODEL
    • デフォルトで使用するユーザーモデルではなく、このあと自分で定義するUserモデルを使用するという宣言をする
  • CORS_ORIGIN_ALLOW_ALL
    • クロスオリジンのための設定

これで初期設定は以上です。次にモデルを定義します。

Userモデルの定義

次に今回使用するUserモデルを定義します。
カラムは以下の感じでいきます。

  • username
    • 名前が入る
  • password
    • ハッシュ化されたパスワードが入る
  • is_staff
    • adminユーザーかどうか
  • is_status
    • 生きてる?
  • created_at
    • ユーザー作成がされた日、デフォルトで作成時間を入れるようにする

こんな感じです。
Djangoでカスタムのユーザーモデルを作成するときはUserManagerを定義してそれをもとにUserテーブルを作成します。

# models.py

from django.db import models
import datetime

from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.validators import UnicodeUsernameValidator

class UserManager(BaseUserManager):
    # ユーザー作成のためのやつ
    def _create_user(self, username, password, **extra_fields): 
        # ユーザーネームがなかったらエラー
        if not username: 
            raise ValueError('username is requied')

        user = self.model(username=username, **extra_fields) # ユーザーネーム
        user.set_password(password) # パスワード、デフォルトでハッシュになる
        user.save(using=self._db) # トランザクションを終了する
        return user

    # ユーザー作成のためのやつ、adminではないユーザーを保存する _create_user を呼び出して定義
    def create_user(self, username, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(username, password, **extra_fields)

    # ユーザー作成のためのやつ、adminユーザーを保存する _create_user を呼び出して定義
    def create_superuser(self, username, 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, password, **extra_fields)

# ユーザーテーブル
class User(AbstractBaseUser, PermissionsMixin):
    # 不正な文字列が含まれていないかチェックする
    username_validator = UnicodeUsernameValidator()

    # ここから下は通常のテーブル定義を同じ
    username = models.CharField(max_length=150, unique=True, validators=[username_validator])
    is_staff = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField( default=datetime.datetime.now)

    # ここで先ほど定義したクラスを呼び出してデフォルトのユーザーモデルとして定義する
    objects = UserManager()

    # ユーザーネームと必須のフィールドを定義する、ここは重複禁止
    # 重複する場合は REQUIRED_FIELDS はからの配列を渡せば良い
    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = []

まずUserManagerを定義します。

ここでは、BaseUserManagerを継承してユーザーの作成をするためのモデルを上書きしています。
ここでBaseUserManagerのコードを少し見てみましょう。

class BaseUserManager(models.Manager):

    @classmethod
    def normalize_email(cls, email):
        """
        Normalize the email address by lowercasing the domain part of it.
        """
        email = email or ''
        try:
            email_name, domain_part = email.strip().rsplit('@', 1)
        except ValueError:
            pass
        else:
            email = email_name + '@' + domain_part.lower()
        return email

    def make_random_password(self, length=10,
                             allowed_chars='abcdefghjkmnpqrstuvwxyz'
                                           'ABCDEFGHJKLMNPQRSTUVWXYZ'
                                           '23456789'):
        """
        Generate a random password with the given length and given
        allowed_chars. The default value of allowed_chars does not have "I" or
        "O" or letters and digits that look similar -- just to avoid confusion.
        """
        return get_random_string(length, allowed_chars)

    def get_by_natural_key(self, username):
        return self.get(**{self.model.USERNAME_FIELD: username})

こんな感じ。 normalize_emailは今回は使ってませんが、emailの判定をするときとかに便利になってきます。これで囲ってあげるとあら不思議、みたいになります。
また、passwordのハッシュ化などはここで行われています。ここを継承していい感じにしていくことができます。

次にAbstractBaseUserを見ていきたいと思います。

class AbstractBaseUser(models.Model):
    password = models.CharField(_('password'), max_length=128)
    last_login = models.DateTimeField(_('last login'), blank=True, null=True)

    is_active = True

    REQUIRED_FIELDS = []

    # Stores the raw password if set_password() is called so that it can
    # be passed to password_changed() after the model is saved.
    _password = None

    class Meta:
        abstract = True

    def __str__(self):
        return self.get_username()

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        if self._password is not None:
            password_validation.password_changed(self._password, self)
            self._password = None

    def get_username(self):
        """Return the username for this User."""
        return getattr(self, self.USERNAME_FIELD)

    def clean(self):
        setattr(self, self.USERNAME_FIELD, self.normalize_username(self.get_username()))

    def natural_key(self):
        return (self.get_username(),)

    @property
    def is_anonymous(self):
        """
        Always return False. This is a way of comparing User objects to
        anonymous users.
        """
        return False

    @property
    def is_authenticated(self):
        """
        Always return True. This is a way to tell if the user has been
        authenticated in templates.
        """
        return True

    def set_password(self, raw_password):
        self.password = make_password(raw_password)
        self._password = raw_password

    def check_password(self, raw_password):
        """
        Return a boolean of whether the raw_password was correct. Handles
        hashing formats behind the scenes.
        """
        def setter(raw_password):
            self.set_password(raw_password)
            # Password hash upgrades shouldn't be considered password changes.
            self._password = None
            self.save(update_fields=["password"])
        return check_password(raw_password, self.password, setter)

    def set_unusable_password(self):
        # Set a value that will never be a valid hash
        self.password = make_password(None)

    def has_usable_password(self):
        """
        Return False if set_unusable_password() has been called for this user.
        """
        return is_password_usable(self.password)

    def _legacy_get_session_auth_hash(self):
        # RemovedInDjango40Warning: pre-Django 3.1 hashes will be invalid.
        key_salt = 'django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash'
        return salted_hmac(key_salt, self.password, algorithm='sha1').hexdigest()

    def get_session_auth_hash(self):
        """
        Return an HMAC of the password field.
        """
        key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
        return salted_hmac(
            key_salt,
            self.password,
            # RemovedInDjango40Warning: when the deprecation ends, replace
            # with:
            # algorithm='sha256',
            algorithm=settings.DEFAULT_HASHING_ALGORITHM,
        ).hexdigest()

    @classmethod
    def get_email_field_name(cls):
        try:
            return cls.EMAIL_FIELD
        except AttributeError:
            return 'email'

    @classmethod
    def normalize_username(cls, username):
        return unicodedata.normalize('NFKC', username) if isinstance(username, str) else username

ここは長め。
ここはユーザーテーブルの定義をしています。
みなさんおなじみのmodels.Modelを継承して実装しています。
Abstractなのでその名の通り汎用性に長けています。可愛い。
それぞれのメソッドは難しくはないですが、追っていくのはめんどくさいと思います。これは経験者が語っています。
てことでここはパスします(しっかり書いてください。いやです)

リクエストを投げてみる

次に実際にこれがうまく行ってるのかどうかを試します。

ルーティング

まずはプロジェクトのurls.pyを編集します。

# advent_1204/urls.py

from django.contrib import admin
from django.urls import path, include
from rest_framework_jwt.views import obtain_jwt_token

urlpatterns = [
    path('', include('myapp.urls')), 
    path('admin/', admin.site.urls),
]

何の変哲もない。次にアプリケーションです。

# myapp/urls.py

from django.urls import path
from . import views

from rest_framework_jwt.views import obtain_jwt_token

urlpatterns = [
    path('', views.IndexView.as_view()),
    path('login/', obtain_jwt_token),  # JWTを発行する
]

注目すべきは login/についてです。
ここにリクエストを投げるとJWTが発行されてレスポンスとして返ってきます。これは先ほどsettings.pyで永続化させたので1度発行すれば何をしても消えません。便利〜!

レスポンス

次にviews.pyを編集して簡単なHTTP通信をしてみたいと思います。

# views.py

from django.shortcuts import render
from rest_framework.views import APIView
from rest_framework.response import Response

class IndexView(APIView):
    def get(self, request):
        user = request.user
        res = {
            'username': user.username, 
            'is_staff': user.is_staff, 
            'is_active': user.is_active, 
            'created_at': user.created_at, 
        }
        
        return Response(res)

こんな感じ。リクエストを送ってきたユーザーの情報を返すようにしています。
ユーザーの情報は request.user で取得することができます。
DRFを使用しているのでAPIViewでいい感じに返します。

投げる

次にリクエストを実際に投げてみたいと思います。
curlよりもPython派なのでPythonのrequestsを使用してテストのリクエストを投げてレスポンスを確認したいと思います。

まずはログインから。

In [1]: import requests, json

In [2]: data = {'username': 'takurinton', 'password': 'hoge
   ...: hoge'}

In [3]: r = requests.post('http://localhost:8000/login/', d
   ...: ata=data)

In [4]: r.json()
Out[4]: {'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6InRha3VyaW50b24iLCJleHAiOjE2MDY5Njc1MTd9.lQa0jPP2JThv7Vh8ZE1XtDvufa_gpjGmUXh5zKO6iME'}

ログインにはusernameとpasswordを使います。これらをbodyに持たせてあげてpostリクエストを投げます。そうするとJWTが返ってきます!素晴らしい。

次にこのトークンを使用して自分の情報をとってきたいと思います。

In [5]: headers = {'Authorization': 'JWT eyJ0eXAiOiJKV1QiLC
   ...: JhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6
   ...: InRha3VyaW50b24iLCJleHAiOjE2MDY5Njc1MTd9.lQa0jPP2JT
   ...: hv7Vh8ZE1XtDvufa_gpjGmUXh5zKO6iME'}

In [6]: r = requests.get('http://localhost:8000', headers=h
   ...: eaders)

In [7]: r.json()
Out[7]:
{'username': 'takurinton',
 'is_staff': True,
 'is_active': True,
 'created_at': '2020-12-03T01:41:43.217609Z'}

先ほど取得したJWTをヘッダーにのせてリクエストを投げます。そうするとユーザーの情報をとってきてくれました!えらい!
ちなみにJWTなしでリクエストを投げるとこんな感じになります。

In [8]: r = requests.get('http://localhost:8000')

In [9]: r.json()
Out[9]: {'detail': 'Authentication credentials were not provided.'}

認証がないとって言われてしまいました。おけまるですね。よかよか(๑˃̵ᴗ˂̵)

まとめ

今回はDjangoでユーザーモデルの実装をしました。簡単に実装することができるのでおすすめです。