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
こんな感じです。
ちょこちょこ説明していきたいと思います。
これで初期設定は以上です。次にモデルを定義します。
Userモデルの定義
次に今回使用するUserモデルを定義します。
カラムは以下の感じでいきます。
こんな感じです。
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でユーザーモデルの実装をしました。簡単に実装することができるのでおすすめです。