Python Django 入門トップページ


カスタムユーザ認証

  1. プロジェクトの概要
  2. プロジェクトの作成と初期設定
  3. Users アプリケーションの作成と有効化
  4. 認証にカスタムユーザモデルを利用する
  5. モデルの作成
  6. マイグレーション
  7. ユーザの登録
  8. 管理ユーザの登録
  9. 管理サイトの作成
  10. Comments アプリケーションの作成
  11. ページ雛形の作成
  12. ログイン・ログアウトの実装
  13. Navbar の設置
  14. Comments アプリケーションのユーザ認証
  15. ユーザ一覧ページ
  16. ユーザ詳細情報の表示
  17. ユーザ情報の更新
  18. パスワードの変更
  19. Gmail 2段階認証の設定とアプリパスワードの取得
  20. メールの設定と送信
  21. パスワードのリセット
  22. ユーザ登録機能の実装
  23. ユーザ登録時に氏名も登録
  24. ユーザ登録時にメールアドレスも登録
  25. ユーザ登録してもログインできないように
  26. ユーザ登録後にメールを送信
  27. メール検証によるアカウントの有効化
  28. トークン有効期限の変更
  29. ログアウト後に top へリダイレクト
  30. 検証メールの再送信
  31. 未検証ユーザのログインエラーメッセージ
  32. メールに有効期限を表示
  33. フラッシュメッセージの変更
  34. 未検証ユーザのパスワードリセット

カスタムユーザ認証

ユーザ登録時にメールアドレスも登録

ここでは,前のページと同じ方な方法でユーザ登録時に電子メールアドレスも設定できるようにします.ただし,電子メールは他のユーザと重複してはならないはずであるので,そのあたりの調整も同時に行いましょう.

メールアドレスも登録

まず,ユーザ登録時にメールアドレスも登録されるように修正します.

users/forms.py
class UserCreationForm(forms.ModelForm):
    """
    A form that creates a user, with no privileges, from the given username and
    password.
    """

    error_messages = {
        "password_mismatch": _("The two password fields didn’t match."),
    }
    password1 = forms.CharField(
        label=_("Password"),
        strip=False,
        widget=forms.PasswordInput(attrs={"autocomplete": "new-password", 'class': 'form-control'}),
        help_text=password_validation.password_validators_help_text_html(),
    )
    password2 = forms.CharField(
        label=_("Password confirmation"),
        widget=forms.PasswordInput(attrs={"autocomplete": "new-password", 'class': 'form-control'}),
        strip=False,
        help_text=_("Enter the same password as before, for verification."),
    )

    class Meta:
        model = UserModel
        fields = ("username", 'first_name', 'last_name', 'email')
        field_classes = {"username": UsernameField}
        widgets = {
            'first_name': forms.TextInput(attrs={
                'class': 'form-control',
                'required' : True
            }),
            'last_name': forms.TextInput(attrs={
                'class': 'form-control',
                'required' : True
            }),
            'email': forms.EmailInput(attrs={
                "autocomplete": "email",
                'class': 'form-control',
                'required' : True
            }),
        }
        labels = {
            'first_name': 'First Name',
            'last_name': 'Last Name',
            'email': 'Email',
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self._meta.model.USERNAME_FIELD in self.fields:
            self.fields[self._meta.model.USERNAME_FIELD].widget.attrs[
                "autofocus"
            ] = True

    def clean(self):
        data = super().clean()
        first_name = data.get('first_name')
        last_name = data.get('last_name')
        if len(first_name) == 0:
            msg = "First Name が入力されていません"
            self.add_error('first_name', msg)
        elif len(first_name) > 20:
            msg = "First Name の最大文字数は20文字です"
            self.add_error('first_name', msg)
        if len(last_name) == 0:
            msg = "Last Name が入力されていません"
            self.add_error('last_name', msg)
        elif len(last_name) > 20:
            msg = "Last Name の最大文字数は20文字です"
            self.add_error('last_name', msg)

    def clean_password2(self):
        password1 = self.cleaned_data.get("password1")
        password2 = self.cleaned_data.get("password2")
        if password1 and password2 and password1 != password2:
            raise ValidationError(
                self.error_messages["password_mismatch"],
                code="password_mismatch",
            )
        return password2

    def _post_clean(self):
        super()._post_clean()
        # Validate the password after self.instance is updated with form data
        # by super().
        password = self.cleaned_data.get("password2")
        if password:
            try:
                password_validation.validate_password(password, self.instance)
            except ValidationError as error:
                self.add_error("password2", error)

    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user

この修正によって,メールアドレスもユーザ登録時に設定できるようになりました.しかしながら,他のユーザと同じメールアドレスを登録できてしまうという問題が残っています.

django2022-00344

ユーザ情報を一旦削除

データベースでのユーザのテーブルを修正する前に,追加したユーザを一旦削除しておきます.とくに,メールアドレスが重複しているユーザがあればそれは削除しておくべきです.なお,削除するときには id だけでなく,username などでも削除するレコード(行)を指定できます.

(py39) C:\Users\lecture\Documents\django\custom_auth_project>sqlite3 db.sqlite3 ⏎
SQLite version 3.38.2 2022-03-26 13:51:10
Enter ".help" for usage hints.
sqlite> .headers ON ⏎
sqlite> select id, username, email from users_user; ⏎
id|username|email
1|user_a|a@sample.com
2|user_b|b@sample.com
3|user_c|c@sample.com
4|root|root@example.com
7|user_e|
8|user_f|a@sample.com
sqlite> delete from users_user where id=7; ⏎
sqlite> delete from users_user where username="user_f"; ⏎
sqlite> select id, username, email from users_user; ⏎
id|username|email
1|user_a|a@sample.com
2|user_b|b@sample.com
3|user_c|c@sample.com
4|root|root@example.com
sqlite>

モデルの変更とマイグレーション

次に,メールアドレスの重複を避けるために,データベースのテーブルの設計を変更します.現時点では email フィールド(列)に UNIQUE が設定されていないことを確認します.なお,username フィールドには UNIQUE が設定されていることも確認します.

sqlite> .schema users_user ⏎
CREATE TABLE IF NOT EXISTS "users_user" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "password" varchar(128) NOT NULL,
    "last_login" datetime NULL,
    "is_superuser" bool NOT NULL,
    "username" varchar(150) NOT NULL UNIQUE,
    "first_name" varchar(150) NOT NULL,
    "last_name" varchar(150) NOT NULL,
    "email" varchar(254) NOT NULL,
    "is_staff" bool NOT NULL,
    "is_active" bool NOT NULL,
    "date_joined" datetime NOT NULL
);
sqlite>

テーブルの設計を変更するために models.py を修正します.

users/models.py
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

class User(AbstractBaseUser, PermissionsMixin):
    """
    An abstract base class implementing a fully featured User model with
    admin-compliant permissions.

    Username and password are required. Other fields are optional.
    """

    username_validator = UnicodeUsernameValidator()

    username = models.CharField(
        _("username"),
        max_length=150,
        unique=True,
        help_text=_(
            "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
        ),
        validators=[username_validator],
        error_messages={
            "unique": _("A user with that username already exists."),
        },
    )
    first_name = models.CharField(_("first name"), max_length=150, blank=True)
    last_name = models.CharField(_("last name"), max_length=150, blank=True)
    # email = models.EmailField(_("email address"), blank=True)
    email = models.EmailField(
        _("email address"),
        # blank=True,
        unique=True,
        error_messages={
            "unique": _("A user with that email already exists."),
        },
    )
    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 = "username"
    REQUIRED_FIELDS = ["email"]

    class Meta:
        verbose_name = _("user")
        verbose_name_plural = _("users")
        # abstract = True

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

    def get_full_name(self):
        """
        Return the first_name plus the last_name, with a space in between.
        """
        full_name = "%s %s" % (self.first_name, self.last_name)
        return full_name.strip()

    def get_short_name(self):
        """Return the short name for the user."""
        return self.first_name

マイグレーションの実行状況を確認します.まだモデルの変更を行うためのマイグレーションが生成されていないこともわかります.

(py39) C:\Users\lecture\Documents\django\custom_auth_project>python manage.py showmigrations ⏎
admin
 [X] 0001_initial
 [X] 0002_logentry_remove_auto_add
 [X] 0003_logentry_add_action_flag_choices
auth
 [X] 0001_initial
 [X] 0002_alter_permission_name_max_length
 [X] 0003_alter_user_email_max_length
 [X] 0004_alter_user_username_opts
 [X] 0005_alter_user_last_login_null
 [X] 0006_require_contenttypes_0002
 [X] 0007_alter_validators_add_error_messages
 [X] 0008_alter_user_username_max_length
 [X] 0009_alter_user_last_name_max_length
 [X] 0010_alter_group_name_max_length
 [X] 0011_update_proxy_permissions
 [X] 0012_alter_user_first_name_max_length
comments
 (no migrations)
contenttypes
 [X] 0001_initial
 [X] 0002_remove_content_type_name
sessions
 [X] 0001_initial
users
 [X] 0001_initial

(py39) C:\Users\lecture\Documents\django\custom_auth_project>

マイグレーションファイルを生成します.

(py39) C:\Users\lecture\Documents\django\custom_auth_project>python manage.py makemigrations ⏎
Migrations for 'users':
  users\migrations\0002_alter_user_email.py
    - Alter field email on user

(py39) C:\Users\lecture\Documents\django\custom_auth_project>

いま生成されたマイグレーションファイルの中身を確認します.

users/migrations/0002_alter_user_email.py
# Generated by Django 4.0.6 on 2022-08-14 10:50

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('users', '0001_initial'),
    ]

    operations = [
        migrations.AlterField(
            model_name='user',
            name='email',
            field=models.EmailField(error_messages={'unique': 'A user with that email already exists.'}, max_length=254, unique=True, verbose_name='email address'),
        ),
    ]

もう一度マイグレーションの実行状況を確認します.いま生成したマイグレーションがまだ実行されていない(つまり,データベースの設計変更が反映されていない)ことがわかります.

(py39) C:\Users\lecture\Documents\django\custom_auth_project>python manage.py showmigrations ⏎
admin
 [X] 0001_initial
 [X] 0002_logentry_remove_auto_add
 [X] 0003_logentry_add_action_flag_choices
auth
 [X] 0001_initial
 [X] 0002_alter_permission_name_max_length
 [X] 0003_alter_user_email_max_length
 [X] 0004_alter_user_username_opts
 [X] 0005_alter_user_last_login_null
 [X] 0006_require_contenttypes_0002
 [X] 0007_alter_validators_add_error_messages
 [X] 0008_alter_user_username_max_length
 [X] 0009_alter_user_last_name_max_length
 [X] 0010_alter_group_name_max_length
 [X] 0011_update_proxy_permissions
 [X] 0012_alter_user_first_name_max_length
comments
 (no migrations)
contenttypes
 [X] 0001_initial
 [X] 0002_remove_content_type_name
sessions
 [X] 0001_initial
users
 [X] 0001_initial
 [ ] 0002_alter_user_email

(py39) C:\Users\lecture\Documents\django\custom_auth_project>

マイグレーションを実行します.この作業によって実際にデータベースの設計が変更されます.

(py39) C:\Users\lecture\Documents\django\custom_auth_project>python manage.py migrate ⏎
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, users
Running migrations:
  Applying users.0002_alter_user_email... OK

(py39) C:\Users\lecture\Documents\django\custom_auth_project>

マイグレーションの実行状況をもう一度確認します.

(py39) C:\Users\lecture\Documents\django\custom_auth_project>python manage.py showmigrations ⏎
admin
 [X] 0001_initial
 [X] 0002_logentry_remove_auto_add
 [X] 0003_logentry_add_action_flag_choices
auth
 [X] 0001_initial
 [X] 0002_alter_permission_name_max_length
 [X] 0003_alter_user_email_max_length
 [X] 0004_alter_user_username_opts
 [X] 0005_alter_user_last_login_null
 [X] 0006_require_contenttypes_0002
 [X] 0007_alter_validators_add_error_messages
 [X] 0008_alter_user_username_max_length
 [X] 0009_alter_user_last_name_max_length
 [X] 0010_alter_group_name_max_length
 [X] 0011_update_proxy_permissions
 [X] 0012_alter_user_first_name_max_length
comments
 (no migrations)
contenttypes
 [X] 0001_initial
 [X] 0002_remove_content_type_name
sessions
 [X] 0001_initial
users
 [X] 0001_initial
 [X] 0002_alter_user_email

(py39) C:\Users\lecture\Documents\django\custom_auth_project>

データベースのテーブル設計を確認すると,email フィールドに UNIQUE 特性が追加されていることがわかります.また,データベースのロールバックを行っていないので,以前のデータはそのまま残っていることも確認してください.

sqlite> .schema users_user ⏎
CREATE TABLE IF NOT EXISTS "users_user" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "password" varchar(128) NOT NULL,
    "last_login" datetime NULL,
    "is_superuser" bool NOT NULL,
    "username" varchar(150) NOT NULL UNIQUE,
    "first_name" varchar(150) NOT NULL,
    "last_name" varchar(150) NOT NULL,
    "is_staff" bool NOT NULL,
    "is_active" bool NOT NULL,
    "date_joined" datetime NOT NULL,
    "email" varchar(254) NOT NULL UNIQUE
);
sqlite>

すでに登録されているメールアドレスでユーザ登録を行おうとした場合に正しくエラーメッセージが表示されることがわかりました.

django2022-00345

目次に戻る