Djangoなサインアップ機能の実装方法

Python,TIPSDjango

Djangoは標準で様々な便利機能を提供していますが、プロジェクトによって要件が異なりやすい領域については敢えてテンプレートを提供していません。
ログイン・ログアウト機能は適当なテンプレートも用意されていますが、サインアップ機能は必要項目がプロジェクト毎に異なる場合が多いので自分で実装する必要があります。

今回はDjangoが提供しているUserCreationFormを使用し実施に実装していきます。

追記:2019/7/5
Googleなどを使用したソーシャル認証(ログイン・サインアップ)は以下投稿を参照して下さい。

Defaultなサインアップ方法

まずは一番簡単な方法です。 UserCreationFormをそのまま使用します。
前回紹介したカスタムUserModelを使用しておらず、デフォルトのユーザー名 + パスワードで認証するケースに適合します。

カスタムUserModelについてはこちらを参照してください。

urls.py

from django.contrib import admin
from django.urls import path
from default_signup import views as default_views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('default-signup/', default_views.signup, name='default_signup'),
]

views.py

from django.contrib.auth import login, authenticate
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render, redirect

def signup(request):
    if request.method == 'POST':
        form = UserCreationForm(request.POST)
        if form.is_valid():
            form.save()
            username = form.cleaned_data.get('username')
            raw_password = form.cleaned_data.get('password1')
            user = authenticate(username=username, password=raw_password)
            login(request, user)
            return redirect('index')
    else:
        form = UserCreationForm()
    return render(request, 'signup.html', {'form': form})

default_signup.html

{% extends 'base.html' %}

{% block content %}
  <h2>Default SignUp</h2>
  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">ユーザー登録</button>
  </form>
{% endblock %}

基本的なUserCreationFormの実装です。
views側でフォームに入力された値を受け取るとform.save()でデータベースへ登録します。
今回はフォームの値を元にDjango側でauthenticate関数で認証を行いリダイレクトさせていますが、ログインページへリダイレクトさせユーザー自身でログインさせるパターンもよく見かけます。

POSTされてくる値はハッシュ化されており、これを直接認証に使用することはできません。
そこでauthenticate()関数を使用します。
この関数は引数で渡したユーザーIDとPWが一致していた場合、そのユーザーインスタンスを返します。
最後に返されたインスタンスをlogin()に渡すことでユーザーはログイン状態になります。
後はトップページなどにユーザーをリダイレクトするだけです。

また、以下の様にループさせるとよりフォーム生成を制御できます。

default_signup.html

{% extends 'base.html' %}

{% block content %}
  <h2>Default SignUp</h2>
  <form method="post">
    {% csrf_token %}
    {% for field in form %}
      <p>
        {{ field.label_tag }}<br>
        {{ field }}
        {% if field.help_text %}
          <small style="color: grey">{{ field.help_text }}</small>
        {% endif %}
        {% for error in field.errors %}
          <p style="color: red">{{ error }}</p>
        {% endfor %}
      </p>
    {% endfor %}
    <div>
    <button type="submit">ユーザー登録</button>
    </div>
    <a href="{% url 'index' %}">戻る</a>
  </form>
{% endblock %}

こんな感じでフォームが生成されます。

項目を追加したサインアップ方法

次はデフォルトのユーザーID / パスワード だけでなくEメールアドレスやフルネームなどを追加してサインアップする方法です。
この方法はデフォルトのUserModelもしくはAbstractUser・AbstractBaseUserを使用している場合採用可能です。

前回はUserCreationFormをそのまま使用しましたが今回は拡張する必要があります。

forms.py

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User


class SignUpForm(UserCreationForm):
    last_name = forms.CharField(
        max_length=30,
        required=False,
        help_text='オプション',
        label='苗字'
    )
    first_name = forms.CharField(
        max_length=30,
        required=False,
        help_text='オプション',
        label='名前'
    )
    email = forms.EmailField(
        max_length=254,
        help_text='必須 有効なメールアドレスを入力してください。',
        label='Eメールアドレス'
    )

    class Meta:
        model = User
        fields = ('username', 'last_name', 'first_name',  'email', 'password1', 'password2', )

次にviews側でデフォルトのUserCreationFormから先程作成したSignUpFormを使用する様に定義します。

views.py

from django.shortcuts import render
from django.contrib.auth import login, authenticate
from django.shortcuts import render, redirect
from .forms import SignUpForm

def signup(request):
    if request.method == 'POST':
        form = SignUpForm(request.POST)
        if form.is_valid():
            form.save()
            username = form.cleaned_data.get('username')
            raw_password = form.cleaned_data.get('password1')
            user = authenticate(username=username, password=raw_password)
            login(request, user)
            return redirect('index')
    else:
        form = SignUpForm()
    return render(request, 'signup.html', {'form': form})

signup.htmlは先程のDefault Signupで使用したテンプレートをそのまま使用します。

こんな感じにフォームが生成されます。

プロファイルを使用したサインアップ方法

少し調整は必要ですがサインアップにプロファイルModelを活用する事もできます。
まずは以下の様なModelを定義してみます。

models.py

from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    location = models.CharField(max_length=30, blank=True)
    birth_date = models.DateField(null=True, blank=True)

@receiver(post_save, sender=User)
def update_user_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)
    instance.profile.save()

あまり使う機会は少ないと思いますが今回はSignalを使用していきます。
Signalはハンドラとして、指定したイベントの発生に応じて事前に定義した処理を呼び出せる機能です。
Signalを活用しない実装も可能ですが基本的にはこの方式で実装することをオススメします。

次にフォームを作成していきます。

forms.py

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User

class SignUpForm(UserCreationForm):
    birth_date = forms.DateField(
        help_text='必須: YYYY-MM-DD 形式で入力して下さい。',
        label='誕生日',
    )

    class Meta:
        model = User
        fields = ('username', 'birth_date', 'password1', 'password2', )

このフォームはUserCreationFormを継承していますが、form.save()を実行してもDjango側で保存してくれません。
よってviews側で追加で保存する処理を用意してやる必要があります。

views.py

from django.contrib.auth import login, authenticate
from django.shortcuts import render, redirect
from .forms import SignUpForm

def signup(request):
    if request.method == 'POST':
        form = SignUpForm(request.POST)
        if form.is_valid():
            user = form.save()
            user.refresh_from_db()
            user.profile.birth_date = form.cleaned_data.get('birth_date')
            user.save()
            raw_password = form.cleaned_data.get('password1')
            user = authenticate(username=user.username, password=raw_password)
            login(request, user)
            return redirect('index')
    else:
        form = SignUpForm()
    return render(request, 'signup.html', {'form': form})

ポイントは user.refresh_from_db() の部分です。
Signalがプロファイルインスタンスを作成するために同期させる必要があります。
そこでこのメソッドを呼び出すことで前の通りDBからModelの情報をリロードさせインスタンスを取得させています。

もしuser.refresh_from_db を呼び出さずにuser.profileへアクセスをするとNoneが返されます。
これはform.save() で更新されるのはサーバサイドのみであり、クライアント側のModelインスタンスには反映されないからです。
以上の理由からform.save() の後に user.refresh_from_db() を呼び出してModelインスタンスを手動で更新させてあげているわけです。

UserModelを更新した後はcleaned_dataにかけた値をフィールドにセットしuser.save() を呼び出して保存します。
なぜ user.profile.save() ではなくuser.save() を呼び出しているかですが、これは user.save() の時点でProfile側の保存もされるためです。

今回は誕生日のフィールドをviews内で定義していますがlocationの様な他のフォールドも同時に扱いたい場合はformを2種類用意してやり、それをviewsで同時に処理することをオススメします。
例としては以下の様なイメージです。

views.py

from django.db import transaction

@transaction.atomic
def create_user(request):
    if request.method == 'POST':
        user_form = UserCreationForm(request.POST)
        profile_form = ProfileForm(request.POST)
        if user_form.is_valid() and profile_form.is_valid():
            user = user_form.save()
            user.refresh_from_db()
            profile_form = ProfileForm(request.POST, instance=user.profile)
            profile_form.full_clean()
            profile_form.save()
    else:
        user_form = UserCreationForm()
        profile_form = ProfileForm()

    return render(request, 'user_form.html', {
        'user_form': user_form,
        'profile_form': profile_form
    })

ザックリとですが、まず前述した通り user.refresh_from_db() の部分でプロファイルをロードしてきます。
次に profile_form = ProfileForm(request.POST, instance=user.profile) の部分でフォームをインスタンスの値で定義しています。

そして profile_form.full_clean() の部分で is_valid() を呼び出してから保存しています。

メールを使用したユーザー登録方法

メールアドレスの有効性を担保したい場合などユーザー登録の際にその場ではアカウントを有効化にせず、アクティベート用のワンタイムリンクをメールで配信したいケースがあると思います。

今回はテスト環境での検証方法として EMAIL_BACKEND を使用して実装しましょう。
この設定をすると実際にメールは送信されませんがコマンドラインにメール情報が表示されるので確認することができます。

settings.py

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

まずはメールが正常に配信されたかを確認するためのフィールドを用意します。

models.py

from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    email_confirmed = models.BooleanField(default=False)

@receiver(post_save, sender=User)
def update_user_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)
    instance.profile.save()

次に有効期限付きのリンクを生成するためのモジュールを作成します。

tokens.py

from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.utils import six

class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
    def _make_hash_value(self, user, timestamp):
        return (
            six.text_type(user.pk) + six.text_type(timestamp) +
            six.text_type(user.profile.email_confirmed)
        )

account_activation_token = AccountActivationTokenGenerator()

PasswordResetTokenGenerator を拡張し、メールアドレスが有効かどうかを判定するためのトークンジェネレータを作成しました。

次に登録時にメールアドレスの登録を強制する必要があります。

forms.py

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User


class SignUpForm(UserCreationForm):
    email = forms.EmailField(
        max_length=254,
        help_text='必須: 有効なEメールアドレスを入力してください。',
        label='Eメールアドレス',
    )

    class Meta:
        model = User
        fields = ('username', 'email', 'password1', 'password2', )

そしてviews側ですが、signup関数ではユーザー認証は行わずメールの配信のみを行います。
実際の認証処理はactivate関数で行います。
この関数は認証URLにアクセスした際に呼び出され認証します。

views.py

from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.contrib.sites.shortcuts import get_current_site
from django.shortcuts import render, redirect
from django.utils.encoding import force_bytes, force_text
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.template.loader import render_to_string

from .forms import SignUpForm
from .tokens import account_activation_token

def signup(request):
    if request.method == 'POST':
        form = SignUpForm(request.POST)
        if form.is_valid():
            user = form.save(commit=False)
            user.is_active = False
            user.save()
            current_site = get_current_site(request)
            subject = 'アクティベートする必要があります'
            message = render_to_string('email_body.html', {
                'user': user,
                'domain': current_site.domain,
                'uid': urlsafe_base64_encode(force_bytes(user.pk)),
                'token': account_activation_token.make_token(user),
            })
            user.email_user(subject, message)
            return redirect('activation_request')
    else:
        form = SignUpForm()
    return render(request, 'signup.html', {'form': form})

def activation_request(request):
    return render(request, 'activation_request.html')

def activate(request, uidb64, token):
    try:
        uid = force_text(urlsafe_base64_decode(uidb64))
        user = User.objects.get(pk=uid)
    except (TypeError, ValueError, OverflowError, User.DoesNotExist):
        user = None

    if user is not None and account_activation_token.check_token(user, token):
        user.is_active = True
        user.profiles.email_confirmed = True
        user.save()
        login(request, user)
        return redirect('index')
    else:
        return render(request, 'activated.html')

この方式はパスワードリセット時などにも使用できる方法です。
user.is_active = False を行うことでユーザーはメールアドレスの有効性を証明するまでログインできなくしています。

適当にメール本文用のテンプレートを用意します。

email_body.html

{% autoescape off %}
こんにちは {{ user.username }} さん!

ユーザー登録ありがとうございます。
{{ user.username }} さんのアカウントを有効化するために以下のリンクをクリックして下さい。

http://{{ domain }}{% url 'activate' uidb64=uid token=token %}
{% endautoescape %}

最後に有効化用ルートの定義をしましょう。

urls.py

path('activation_request/', email_views.activation_request, name='activation_request'),
path('activate/<uidb64>/<token>/', email_views.activate, name='activate'),

動作イメージ

ユーザー情報を登録するとまずこの画面に推移します。

そして配信されるメール本文はこの様な感じです。

最後に生成されたリンクへアクセスし認証に成功するとindexページへリダイレクトされます。
認証に失敗した場合は以下のメッセージをレンダリングします。


少し長くなってしまいましたが以上が4種類のサインアップ方法となります。
今度は本番環境でのメール送信の実装方法やソーシャルネットワークを使用したサインアップ方法などを紹介したいと思います。

今回作成したソースコードはGitHubへアップロードしてあります。
https://github.com/mila411/django-4way-signup

Python,TIPSDjango

Posted by Kenny