Djangoで会員登録やログイン｜Userモデル拡張のアプリを作りました

# 概要

Djangoには会員管理機能が内包されており、これを利用することである程度サクッとWebサービスを開発することができます。しかし、テンプレートの自作や会員登録部分の実装は必要なので、毎回同じ作業をしなくて済むように雛形を作成して[Github](https://github.com/motoitanigaki/django-account-ja)にアップしました。

### 機能
- 会員登録
- ログイン
- パスワード変更
- パスワードを忘れた時のメール送信からの再発行
- 退会
- マイページ

### デモ

![AwesomeScreenshot-2019-03-23T23-12-19-262Z.gif](https://qiita-image-store.s3.amazonaws.com/0/113133/49dad03e-d918-85e1-89af-549f5b9ebf24.gif)

### こんな人向け
- 簡単な開発をする時に会員管理の実装が面倒
- 見た目はBootstrap4ベースでOK
- 会員認証はE-mailとパスワードで行いたい

# 動かし方

### Githubから取得

- [Github](https://github.com/motoitanigaki/django-account-ja)からダウンロードするか、下記コマンドでクローンしてください。

```terminal
git clone https://github.com/motoitanigaki/django-account-ja.git
```

### 動かす

```terminal
cd django-account-ja
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
python manage.py createsuperuser
```

`runserver`コマンドが成功すると、[http://127.0.0.1:8000/](http://127.0.0.1:8000/)から動作を確認できるようになります。

# 中身の解説

### 全体像

Djangoには`django/contrib/auth`という会員管理のためのパッケージが内包されているため、基本的にはこれを最大限利用しつつ、少しのコードで求めている形を実現できるようにしていきます。
全体像としては下記の感じです。

![無題のプレゼンテーション(1).jpg](https://qiita-image-store.s3.amazonaws.com/0/113133/0a7f0c82-b2c3-51b8-f92c-4eea5f1719df.jpeg)

青い部分が今回作成したアプリ、緑の部分はDjangoが標準で提供している機能です。オレンジの矢印は処理の流れを、オレンジの数字はこの記事での説明の順番と対応しています。
ざっくりの流れは下記です。

1. Userモデルを`AbstractBaseUser`クラスを継承して新たに作成
2. url振り分け設定を追記。元々提供されている機能はそのまま使いつつ、それ以外は新しく作成するViewを利用するようにする。
3. 元々提供されている機能を少し見てみる
4. 会員登録やプロフィール部分は自分で機能を作成
5. Templateは全く用意されていないので、全て作成。元々提供されている機能(View)もここで作成したTemplateを利用することになる

### 1. Userモデルを`AbstractBaseUser`クラスを継承して新たに作成

Djangoのデフォルトの`User`クラスは`AbstractUser`というクラスを継承していて、この`AbstractUser`というクラスは`username`と`e-mail`の両方を利用する作りになっています。
そして、`UserManager`というクラスの`_create_user`というユーザーを作成するための関数内でこの両方を求める作りになっているため、`python manage.py createsuperuser`コマンドでスーパーユーザーを作成すると、`username`と`e-mail`の両方が聞かれます。

今回は`username`は使わず`e-mail`とパスワードのみで認証を行う作りにしたいので、`User`クラスと`UserManager`クラスを自作します。

なお、会員管理機能のカスタマイズは`AbstractUser`と`AbstractBaseUser`のどちらが良いのか、という議論があります。この説明は[こちらの記事](https://qiita.com/okoppe8/items/10ae61808dc3056f9c8e)に譲ります。

自作する`models.py`ファイルは下記のようになります。

```py:account/models.py
from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.mail import send_mail
from django.utils import timezone


class UserManager(UserManager):
    def _create_user(self, email, password, **extra_fields):
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

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

    def create_superuser(self, 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(email, password, **extra_fields)


class User(AbstractBaseUser, PermissionsMixin):

    email = models.EmailField(_('email address'), unique=True)

    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'
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

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

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

    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)
```

`AbstractBaseUser`と比較すると、今回は使わない`username`、`first_name`、`last_name`が削除されています。

ここで注意なのが`USERNAME_FIELD = 'email'`という部分です。
Djangoではログインの認証やメール送信など様々なところで`USERNAME_FIELD`で定義しているフィールドを利用します。通常`username`フィールドを利用するところを代わりに`email`フィールドを利用するようにリプレイスするような書き方になっています。
本来は恐らく`USERNAME_FIELD`経由で参照するのではなく`EMAIL_FIELD`を参照する形で色々な処理を行うべきだと思いますが、`USERNAME_FIELD`は

- `django/auth/contrib/auth/backends.py`
- `django/auth/contrib/forms.py`
- `django/auth/contrib/checks.py`
- などなど

で利用されているため、その深淵へ入り込むことは断念しました。

### 2. url振り分け設定を追記

ちなみに今回作成したプロジェクトは[Two Scoops of Django](https://www.amazon.co.jp/Two-Scoops-Django-1-11-Practices/dp/0692915729)に乗っ取り、`settings.py`や`manage.py`が含まれるアプリを`config`という名前で作成し、ユーザー管理用のアプリを`account`という名前で作成し、設定系の情報とそれ以外の機能を分けています。
なので、このプロジェクトを基本としてWebサービスを作成する場合は`python manage.py startapp [アプリ名]`コマンドで新しくアプリを作成して利用することを推奨します。

Djangoではurl振り分けは各アプリ内に記述する形になりますが、一番最初に参照される`urls.py`はこの基底アプリ（今回だと`config`）です。

```python:config/urls.py
from django.contrib import admin
from django.urls import path, include
from account.views import Top

urlpatterns = [
    path('', Top.as_view(), name='top'),
    path('admin/', admin.site.urls),
    path('accounts/', include('account.urls')), # 2. 自作の機能へ振り分け
    path('accounts/', include('django.contrib.auth.urls')), # 1. 元々提供されている機能へ振り分け
]
```
ポイントは2つあります

1. 元々提供されている機能へ振り分け  
   Djangoではログイン、パスワード変更、パスワード再発行といった機能が`django.contrib.auth`内で提供されてるため、こちらへの振り分けを`include`で記載します。
2. 自作の機能へ振り分け  
   会員登録やプロフィール部分は元々提供されていないので、`account`アプリ内で実装する必要があります。そこへの振り分けを記載します。

### 3. 元々提供されている機能を少し見てみる

会員管理に関してどのあたりの機能が用意されているか、少し見てみます。

```py:django/contrib/auth/urls
urlpatterns = [
    path('login/', views.LoginView.as_view(), name='login'),
    path('logout/', views.LogoutView.as_view(), name='logout'),

    path('password_change/', views.PasswordChangeView.as_view(), name='password_change'),
    path('password_change/done/', views.PasswordChangeDoneView.as_view(), name='password_change_done'),

    path('password_reset/', views.PasswordResetView.as_view(), name='password_reset'),
    path('password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
    path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
    path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
]
```

ログイン、ログアウト、パスワードの変更、パスワード再発行といった機能がそれぞれ用意されていることがわかります。ここで宣言されているurlに則れば、対象の機能が利用できそうです。

例えば`~/accounts/login`というurlが踏まれると、ログインのための`LoginView`が呼ばれます。ちょっと見てみましょう。

```py:django/contrib/auth/views.py
class LoginView(SuccessURLAllowedHostsMixin, FormView):
    """
    Display the login form and handle the login action.
    """
    form_class = AuthenticationForm
    authentication_form = None
    redirect_field_name = REDIRECT_FIELD_NAME
    template_name = 'registration/login.html'
    redirect_authenticated_user = False
    extra_context = None

...
```

フロントにレスポンスする時に用いるTemplateが`template_name = 'registration/login.html'`とすでに宣言されています。このhtmlファイルの実態はまだ存在しないので、後ほどこのディレクトリ構造に従ってファイルを作成していきます。

### 4. 会員登録やプロフィール部分は自分で機能を作成

Djangoが元々提供していない会員登録やプロフィール画面などは自分で実装していきます。
まずurls.pyから作成します。

```py:account/urls.py
from django.urls import path
from django.views.generic import TemplateView
from . import views

app_name = 'accounts'

urlpatterns = [
    path('profile/', views.ProfileView.as_view(), name='profile'),
    path('signup/', views.SignUpView.as_view(), name='signup'),
    path('delete_confirm', TemplateView.as_view(template_name='registration/delete_confirm.html'), name='delete-confirmation'),
    path('delete_complete', views.DeleteView.as_view(), name='delete-complete'),
]
```

続いて対応するViewファイルを作成します。

```py:account/views.py
from django.urls import reverse_lazy
from django.views import generic
from django.shortcuts import render
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth import (
     get_user_model, logout as auth_logout,
)
from .forms import UserCreateForm

User = get_user_model()


class Top(generic.TemplateView):
    template_name = 'top.html'


class SignUpView(generic.CreateView):
    form_class = UserCreateForm
    success_url = reverse_lazy('login')
    template_name = 'registration/signup.html'


class ProfileView(LoginRequiredMixin, generic.View):

    def get(self, *args, **kwargs):
        return render(self.request,'registration/profile.html')


class DeleteView(LoginRequiredMixin, generic.View):

    def get(self, *args, **kwargs):
        user = User.objects.get(email=self.request.user.email)
        user.is_active = False
        user.save()
        auth_logout(self.request)
        return render(self.request,'registration/delete_complete.html')
```

`User = get_user_model()`の記述が無いと、自作した`User`モデルを利用しないので注意が必要です。

また、新しいユーザを作成するための`UserCreationForm`というクラスが提供されていますが、`username`を利用しないため一部変更するために`UserCreateForm`というクラスを作成し、それを`SignUpView`で利用しています。
`UserCreateForm`は下記です。

```py:account/forms.py
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import get_user_model

User = get_user_model()


class UserCreateForm(UserCreationForm):

    class Meta:
        model = User
        if User.USERNAME_FIELD == 'email':
            fields = ('email',)
        else:
            fields = ('username', 'email')

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'
```

### 5. Templateは全く用意されていないので、全て作成

あとは元々提供されている機能、新しく作成した機能ともに利用するためのTemplateファイルをガシガシ作っていきます。
見た目は（めちゃくちゃこだわるというわけではないので）Bootstrap4を利用しています。
また、Formに対して自動的にBootstrap4のクラスをあてがってくれる[django-bootstrap4](https://django-bootstrap4.readthedocs.io/en/latest/)というライブラリがあるので、今回はこれを利用します。
pipでインストールした後の利用上の注意としては

- `settings.py`の`INSTALLED_APPS`に`bootstrap4`を追加
- 利用するテンプレート内に`{% load bootstrap4 %}`を追加

を忘れないことです。

これを使うとTemplateの`<form>`タグがとてもすっきりします。例えばログインのTemplateを見てみると

```html:account/templates/registration/login.html
{% extends "base_centered.html" %}

{% load bootstrap4 %}

{% block content %}

    <h5 class="card-title text-center">ログイン</h5>
    <form action="{% url 'login' %}" method="POST">
        {% bootstrap_form form %}
        <button type="submit" class="btn btn-primary btn-block">ログイン</button>
        <input type="hidden" name="next" value="{{ next }}"/>
        {% csrf_token %}
    </form>
    <a href="{% url 'password_reset' %}">パスワードをお忘れですか?</a>

{% endblock %}
```

という感じになります。

ちなみにTemplateが結構多いので、いくつか分割・部品化をしています。

ディレクトリ構造としては下記の感じで、

```terminal
├── templates
│   ├── base.html
│   ├── base_centered.html
│   ├── registration
│   │   ├── base.html
│   │   ├── delete_complete.html
│   │   ├── delete_confirm.html
│   │   ├── logged_out.html
│   │   ├── login.html
│   │   ├── password_change_done.html
│   │   ├── password_change_form.html
│   │   ├── password_reset_complete.html
│   │   ├── password_reset_confirm.html
│   │   ├── password_reset_done.html
│   │   ├── password_reset_email.html
│   │   ├── password_reset_form.html
│   │   ├── password_reset_subject.txt
│   │   ├── profile.html
│   │   ├── signup.html
│   │   └── subnav.html
│   └── top.html
```

図にするとこんな感じです。

![無題のプレゼンテーション(2).jpg](https://qiita-image-store.s3.amazonaws.com/0/113133/bb5c254e-29e3-d93b-5bab-9d1edf3b1598.jpeg)

会員登録やログインの画面は中央寄せのよくあるデザインで統一しているため、`base_centered.html`というファイルにそのフレームだけ記載し共通化して、前述の`login.html`等中身を埋め込むイメージです。

大体の構造は以上です。
これで個人開発の時にいつもひいひい言っていた会員管理周りが爆速になると思うとテンション爆上がり。

最後に、今回下記ブログやサイトには大変お世話になりました。ありがとうございました。

[Django2 でユーザー認証（ログイン認証）を実装するチュートリアル -2- サインアップとログイン・ログアウト](https://it-engineer-lab.com/archives/544)
[Django における認証処理実装パターン](https://nwpct1.hatenablog.com/entry/django-auth-patterns#%E8%AA%8D%E8%A8%BC%E3%83%90%E3%83%83%E3%82%AF%E3%82%A8%E3%83%B3%E3%83%89%E3%81%AB%E3%82%88%E3%82%8B%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA)
[Djangoで、会員登録機能を自作する](https://narito.ninja/blog/detail/38/)