Django、ユーザー登録ページとメールでのアクティベーション

2018-10-21 / PythonDjangoBootstrap4

概要

Djangoで、会員登録機能を自作するシリーズの1つです。前回、ログインページを作りました。今回はユーザー登録機能と、仮登録後にメールが届き、それにアクセスさせると本登録する、といった機能を実装します。

ログインページに、会員登録用のリンクが増えました。
会員登録用のリンクが増え

メールアドレスと、パスワードを入力します。
メールアドレスと、パスワードを入力

この状態ではまだ仮登録です。
まだ仮登録

このようなメールが届きます。本登録用のURLがついていますね。
本登録用のURL

そのURLクリックで、本登録です。
URLクリックで、本登録

メールの設定

今回、メールで本登録用のURLを送付します。とりあえずは、メール内容をコンソールに表示することにします。

# メールをコンソールに表示する
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

Gmail等を使ったり、メール送信の詳しい方法は、Djangoで、メールを送信にまとめています。

URL定義の追加

アプリケーション側のurls.pyに、いくつか追加します。前回から増えた部分だけ書いています。

from django.urls import path
from . import views

app_name = 'register'

urlpatterns = [
...
...
    # この3つが増えた
    path('user_create/', views.UserCreate.as_view(), name='user_create'),
    path('user_create/done', views.UserCreateDone.as_view(), name='user_create_done'),
    path('user_create/complete/<token>/', views.UserCreateComplete.as_view(), name='user_create_complete'),
]

ビュー

こちらも、前回から増えた分を書いています。

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import (
    LoginView, LogoutView
)
from django.contrib.sites.shortcuts import get_current_site
from django.core.signing import BadSignature, SignatureExpired, loads, dumps
from django.http import Http404, HttpResponseBadRequest
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.views import generic
from .forms import (
    LoginForm, UserCreateForm
)


User = get_user_model()
...
...
...
class UserCreate(generic.CreateView):
    """ユーザー仮登録"""
    template_name = 'register/user_create.html'
    form_class = UserCreateForm

    def form_valid(self, form):
        """仮登録と本登録用メールの発行."""
        # 仮登録と本登録の切り替えは、is_active属性を使うと簡単です。
        # 退会処理も、is_activeをFalseにするだけにしておくと捗ります。
        user = form.save(commit=False)
        user.is_active = False
        user.save()

        # アクティベーションURLの送付
        current_site = get_current_site(self.request)
        domain = current_site.domain
        context = {
            'protocol': self.request.scheme,
            'domain': domain,
            'token': dumps(user.pk),
            'user': user,
        }

        subject = render_to_string('register/mail_template/create/subject.txt', context)
        message = render_to_string('register/mail_template/create/message.txt', context)

        user.email_user(subject, message)
        return redirect('register:user_create_done')


class UserCreateDone(generic.TemplateView):
    """ユーザー仮登録したよ"""
    template_name = 'register/user_create_done.html'


class UserCreateComplete(generic.TemplateView):
    """メール内URLアクセス後のユーザー本登録"""
    template_name = 'register/user_create_complete.html'
    timeout_seconds = getattr(settings, 'ACTIVATION_TIMEOUT_SECONDS', 60*60*24)  # デフォルトでは1日以内

    def get(self, request, **kwargs):
        """tokenが正しければ本登録."""
        token = kwargs.get('token')
        try:
            user_pk = loads(token, max_age=self.timeout_seconds)

        # 期限切れ
        except SignatureExpired:
            return HttpResponseBadRequest()

        # tokenが間違っている
        except BadSignature:
            return HttpResponseBadRequest()

        # tokenは問題なし
        else:
            try:
                user = User.objects.get(pk=user_pk)
            except User.DoesNotExist:
                return HttpResponseBadRequest()
            else:
                if not user.is_active:
                    # 問題なければ本登録とする
                    user.is_active = True
                    user.save()
                    return super().get(request, **kwargs)

        return HttpResponseBadRequest()

get_user_model関数は、そのプロジェクトで使用しているUserモデルを取得します。つまりデフォルトのUserか、カスタムしたUserが帰ります。汎用的な処理が書けるようになりますので、ユーザーをインポートするときはget_user_model関数を使うようにしましょう。

from django.contrib.auth import get_user_model
...
...
User = get_user_model()
# from django.contrib.auth.models import User これはあまりうまくない

ユーザーの仮登録用のビューです。

class UserCreate(generic.CreateView):
    """ユーザー仮登録"""
    template_name = 'register/user_create.html'
    form_class = UserCreateForm

    def form_valid(self, form):
        """仮登録と本登録用メールの発行."""
        # 仮登録と本登録の切り替えは、is_active属性を使うと簡単です。
        # 退会処理も、is_activeをFalseにするだけにしておくと捗ります。
        user = form.save(commit=False)
        user.is_active = False
        user.save()

        # アクティベーションURLの送付
        current_site = get_current_site(self.request)
        domain = current_site.domain
        context = {
            'protocol': self.request.scheme,
            'domain': domain,
            'token': dumps(user.pk),
            'user': user,
        }

        subject = render_to_string('register/mail_template/create/subject.txt', context)
        message = render_to_string('register/mail_template/create/message.txt', context)

        user.email_user(subject, message)
        return redirect('register:user_create_done')

まず、is_active=Falseにすることで仮登録にすることにしました(今回、デフォルトがTrueの設定でした)。

    def form_valid(self, form):
        """仮登録と本登録用メールの発行."""
        # 仮登録と本登録の切り替えは、is_active属性を使うと簡単です。
        # 退会処理も、is_activeをFalseにするだけにしておくと捗ります。
        user = form.save(commit=False)
        user.is_active = False
        user.save()

django.core.signing.dumpを使うことで、tokenを生成しています。これはsettings.pySECRET_KEYの値等から生成される文字列で、第三者が推測しずらい文字列です。この文字列をもとに、本登録用のURLを作成し、そのURLをメールで伝えるという流れです。

            'token': dumps(user.pk),

UserCreateDoneビューは特に言うことはないです。仮登録したよと表示するだけのものです。

こちらは、本登録用のURLでアクセスしたきた人を検証するビューです。

class UserCreateComplete(generic.TemplateView):
    """メール内URLアクセス後のユーザー本登録"""
    template_name = 'register/user_create_complete.html'
    timeout_seconds = getattr(settings, 'ACTIVATION_TIMEOUT_SECONDS', 60*60*24)  # デフォルトでは1日以内

    def get(self, request, **kwargs):
        """tokenが正しければ本登録."""
        token = kwargs.get('token')
        try:
            user_pk = loads(token, max_age=self.timeout_seconds)

        # 期限切れ
        except SignatureExpired:
            return HttpResponseBadRequest()

        # tokenが間違っている
        except BadSignature:
            return HttpResponseBadRequest()

        # tokenは問題なし
        else:
            try:
                user = User.objects.get(pk=user_pk)
            except User.DoesNotExist:
                return HttpResponseBadRequest()
            else:
                if not user.is_active:
                    # 問題なければ本登録とする
                    user.is_active = True
                    user.save()
                    return super().get(request, **kwargs)

        return HttpResponseBadRequest()

django.core.signing.dumps(user.pk)として作成したトークンは、django.core.signing.loads(token)としてuserのpkに復号化できます。 max_ageで有効期限の設定が可能です。

    def get(self, request, **kwargs):
        """tokenが正しければ本登録."""
        token = kwargs.get('token')
        try:
            user_pk = loads(token, max_age=self.timeout_seconds)

アクティベーションURLの期限は、settings.pyにてACTIVATION_TIMEOUT_SECONDS = 60*60*24等と書いてもいいですし、timeout_seconds属性を書き換えてても動作します。 60*60*24は、1分×60×24 の一日です。

class UserCreateComplete(generic.TemplateView):
    """メール内URLアクセス後のユーザー本登録"""
    template_name = 'register/user_create_complete.html'
    timeout_seconds = getattr(settings, 'ACTIVATION_TIMEOUT_SECONDS', 60*60*24)  # デフォルトでは1日以内

SignatureExpired例外は期限切れ、デフォルトならば1日過ぎてからアクティベーションリンクを踏んだということです。 BadSignatureは、そもそもトークンを適当に入力していたりする場合です。

        # 期限切れ
        except SignatureExpired:
            return HttpResponseBadRequest()

        # tokenが何かおかしいとき
        except BadSignature:
            return HttpResponseBadRequest()

問題なければis_active=Trueで本登録とします。

        # tokenは問題なし
        else:
            try:
                user = User.objects.get(pk=user_pk)
            except User.DoesNotExist:
                return HttpResponseBadRequest()
            else:
                if not user.is_active:
                    # 問題なければ本登録とする
                    user.is_active = True
                    user.save()
                    return super().get(request, **kwargs)

        return HttpResponseBadRequest()

ユーザー登録フォームの作成

増えたのはユーザー登録用フォームのUserCreateFormです。Djangoで用意されたUserCreationフォームを継承すればすぐですが、UserCreationフォームではpassword1password2が単純なフィールドとして定義されていることに注意です。つまり、Meta内でwidget=...といった上書きはできないことを意味します。なので、forms.pyにてこれらのcssのclassをいじりたい場合__init__メソッドやクラスのフィールドとして定義しなおす必要があります。

from django import forms
from django.contrib.auth.forms import (
    AuthenticationForm, UserCreationForm
)
from django.contrib.auth import get_user_model

User = get_user_model()


class LoginForm(AuthenticationForm):
    """ログインフォーム"""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'
            field.widget.attrs['placeholder'] = field.label  # placeholderにフィールドのラベルを入れる


class UserCreateForm(UserCreationForm):
    """ユーザー登録用フォーム"""

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

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

    def clean_email(self):
        email = self.cleaned_data['email']
        User.objects.filter(email=email, is_active=False).delete()
        return email

仮登録をしたけど本登録を忘れていて、アクティベーションリンクの期限を超えた場合や、メールアドレスの入力間違いなどがあって、自分のメールアドレスが仮登録済みの場合もあり得ます。その際はもう一度会員登録をするはずですが、「このメールアドレスは使われています」と表示されて登録ができないと良くありません。そこで、clean_email()メソッドは同じメールアドレスで仮登録段階のアカウントを消去しています。

メールのテンプレートファイル

register/mail_template/create/subject.txt として、仮登録メールで使う題名となるテンプレートファイルを作成しておきます。mail_templateのように専用のディレクトリを作っておくとわかりやすいです。

ほにゃらら - 会員登録

また、register/mail_template/create/message.txtとして、メールの本文となるテンプレートファイルも作っておきます。

{{ user.email }} 様 会員登録手続きを行っていただき、ありがとうございます。

下記URLよりサイトにアクセスの上、引き続き会員登録をお願いいたします。
まだ会員登録手続きは完了しておりませんので、ご注意ください。

本登録用URL
{{ protocol}}://{{ domain }}{% url 'register:user_create_complete' token %}

ほにゃらら

ログインテンプレートの編集

login.htmlですが、前回から少しかわっています。右側に会員登録リンクが増えていましたね。

{% extends "register/base.html" %}
{% block content %}
<div class="row">
    <!-- 左側、ログインエリア -->
    <div class="card col-md-6">
        <div class="card-body">
            <form action="{% url 'register:login' %}" method="POST">
                {{ form.non_field_errors }}
                {% for field in form %}
                    {{ field }}
                    {{ field.errors }}
                    <hr>
                {% endfor %}
                <button type="submit" class="btn btn-success btn-lg btn-block" >ログイン</button>
                <input type="hidden" name="next" value="{{ next }}" />
                {% csrf_token %}
            </form>
        </div>
    </div><!-- 左側、ログインエリアおわり -->

    <!-- 右側、会員登録エリア -->
    <div class="card col-md-6">
        <div class="card-body">
            <a href="{% url 'register:user_create' %}" class="btn btn-success btn-lg btn-block" >会員登録</a>
        </div>
    </div><!-- 右側、会員登録エリアおわり -->
</div>
{% endblock %}

ユーザー登録テンプレートの作成

ユーザー登録ページとなる、user_create.htmlです。よくある、汎用的なフォームの書き方です。

{% extends "register/base.html" %}
{% block content %}
<form action="" method="POST">
    {{ form.non_field_errors }}
    {% for field in form %}
    <div class="form-group">
        <label for="{{ field.id_for_label }}">{{ field.label_tag }}</label>
        {{ field }}
        {{ field.errors }}
    </div>
    {% endfor %}
    {% csrf_token %}
    <button type="submit" class="btn btn-primary btn-lg">送信</button>
</form>
{% endblock %}

そして、user_create_done.htmlです。こちらは仮登録したよーページです。

{% extends "register/base.html" %}
{% block content %}
<p>
    会員登録確認メールを送信しました。まだ、会員登録は完了していません。<br>
    確認メールを送信しましたので、確認メールに記載されているリンクから本登録を行ってください。
</p>
{% endblock %}

そして最後、user_create_complte.htmlです。本登録おわったよーページです。

{% extends "register/base.html" %}
{% block content %}
<p>
    本登録が完了しました。<br>
    登録されたメールアドレスとパスワードでログインしてください。<br>
    <a class="btn btn-primary btn-lg" href="{% url 'register:login' %}">ログイン</a>
</p>
{% endblock %}

本登録時にログインさせてしまう場合

is_activeをTrueにした後にでも、下記のように書きます。もし独自の認証バックエンドをsettings.pyで指定していれば、それを指定します。

from django.contrib.auth import login
...
...
            login(request, user, backend='django.contrib.auth.backends.ModelBackend'))

この記事の関連記事

Djangoで会員登録機能を自作するシリーズ

2018-10-19 / PythonDjangoシリーズ・まとめ

- Djangoで会員登録機能を自作していきます。メールアドレスをユーザー名として使うようにし、ログイン画面、仮登録、メールクリックで本登録、ユーザー情報変更ページ、パスワード変更ページ、パスワードを忘れた際の再設定...などなど、よくある一連の機能を実装します。

コメント欄

記事にコメントする

まめぞう

こんにちは!初めまして! いつもブログを拝見させていただいております。

一つ僕が詰まったところで気づいたのですが、 views.py

"""メール内URLアクセス後のユーザー本登録"""
...
# tokenは問題なし
        else:
            try:
                user = User.objects.get(pk=user_pk)
            except User.DoesNotExist:
                return HttpResponseBadRequest()

ここの except User.DoesNotExist: ですが、 「Does」の場合だとエラーが発生し、なりとさんのGitHubに乗っているコードの「Doen」だと正常に動作しました。

これってDjangoのミスなのでしょうか??

お忙しいところ申し訳ありませんが、ご返信いただけると幸いです。

コメントに返信する

なりと

User.DoesNotExist が正しいです。

「Does」の場合だとエラーが発生

どういったエラーが出ますか。

まめぞう

ご返信ありがとうございます。 エラー画面は忘れてしまったのですが、 元々は「Doen」で問題なく作動しておりました。

先ほど「Does」で試すと問題なく作動しました。 原因は分からずに終わってしまいました。

なお、なりとさんのGitHubソースコードは「Doen」だったかと思います。

なりと

Githubも修正しました、ご連絡ありがとうございました。