Djangoでログイン認証する

# この記事について


Djangoの認証機能の使い方メモで
以下記事の手順に多少追記したものです
https://qiita.com/ryo-keima/items/e6ce42bb4c11432ea829


# Djangoの認証機能


公式ドキュメントが以下にあります
https://docs.djangoproject.com/ja/5.1/topics/auth/


## 仕組み


Djangoモデル (DB) にユーザー情報を保持しておいて、ユーザー名とパスワードで認証・認可する機能です。ユーザーモデルに権限情報を書いておけば、認証しているユーザーのプロパティとして簡単に取得できますので確実かつ簡単に扱えます


ログインするとセッションIDをユーザーブラウザ側のCookieに保持することでログイン状態を維持することができるという仕組みです


デフォルトではセッションCookieを使用しますが有効期限付きの永続Cookieに変更することも可能ですし、セッション情報をどこに保持するか設定したり、セッションタイムアウトを設定したりなどの詳しい設定もsettings.pyから指定可能です


## できないこと


公式ドキュメントによると、Djangoの認証機能は一般的に求められる機能に限って提供するものであるので以下の機能については提供がないようです


・パスワード強度のチェック
・ログイン試行数の制限
・サードパーティに対する認証 (OAuthなど)
・オブジェクトレベルのパーミッション


上記についてはサードパーティのパッケージで実装する想定のようです


# やり方


認証用のFormとViewを使うことで簡単に機能が実装できます


1. ユーザーモデルをつくる
2. UserCreationFormとCreateViewでユーザー登録機能をつくる
3. AuthenticationFormとBaseLoginViewでログイン機能をつくる
4. BaseLogoutViewでログアウト機能をつくる


## 完成品


完成したコードをgithubに置いています
https://github.com/haneya-studio/django_auth_sample


フォルダ構成は以下のようになりました


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/1c3f40d5-70f9-41cc-93fd-be4448c42dae.png" width=25%>


## 1. プロジェクトの作成


お試しで認証機能を付ける為のサンプルプロジェクトを作ります
既にアプリがあるならそちらで作ってもらっても大丈夫


### 1-1. プロジェクトをつくる


プロジェクトを作りたいディレクトリに移動してプロジェクトを作ります


```terminal:terminal
django-admin startproject myapp .
```


### 1-2. migrateする


```terminal:terminal
cd myapp
python manage.py migrate
```


### 1-3. 試しに起動


```terminal:terminal
python manage.py runserver
```


http://127.0.0.1:8000
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/92cbb8da-b078-f858-e257-897fcfe7dd34.png" width=50%>


## 2. ユーザーモデルをつくる


### 2-1. ユーザーモデルの選択肢


認証する為にユーザー情報を持つ必要があります
ユーザー情報を保持するモデルとして以下の3つが選べます


・Djangoデフォルトのユーザーモデルを使う
・カスタムユーザーモデルをつくる (AbstractUser)
・カスタムユーザーモデルをつくる (AbstractBaseUser, PermissionsMixin)


#### デフォルトのユーザーモデル


django.contrib.auth.models.Userで定義されているUserモデルをそのまま使います


Djangoにはデフォルトのユーザーモデルが定義されていて、ユーザー情報を登録することができるように以下のFieldが使用できます


| フィールド名 | 型 | 説明 |
|---|---|---|
| username | CharField | ユーザー名（デフォルトでは一意制約あり） |
| email | EmailField | メールアドレス |
| first_name | CharField | 名（オプション） |
| last_name | CharField | 姓（オプション） |
| password | CharField | ハッシュ化されたパスワード |
| is_staff | BooleanField | 管理サイトにログイン可能か |
| is_active | BooleanField | ユーザーが有効かどうか |
| is_superuser | BooleanField | 管理者権限を持つか |
| last_login | DateTimeField | 最終ログイン日時 |
| date_joined | DateTimeField | ユーザー登録日時 |


これでも問題はないようですが、後日変更を入れたくなるケースが想定されるのでカスタムユーザーモデルをつくっておくのが推奨されているようです


#### カスタムユーザーモデル (AbstractUser)


デフォルトのUserモデルにFieldを追加して使いたい場合にはAbstractUserを選択します。自由度は下がりますが、足す方向で良いならこれを選択する方が簡単にできるようです


#### カスタムユーザーモデル (AbstractBaseUser)


最低限の機能だけ継承してFieldの設計からやりたい場合はAbstractBaseUserとPermissionsMixinを継承して作成します。機能は継承元クラスに既にありますので、Field設計だけやれば完成です


また、Field構造に応じてユーザーオブジェクトの作成方法も変更しなければならないので、BaseUserManagerをoverrideしてcreate_userメソッドとcreate_superuserメソッドも変更してやります


### 2-2. 作成手順


#### Djangoアプリを作成


アカウント情報を持つDjangoアプリを追加します


```terminal:terminal
python manage.py startapp accounts
```


#### setting.pyに追加


AUTH_USER_MODELで認証用のユーザーモデルに指定します


```python:myapp/setting.py
INSTALLED_APPS = [
    ...
    'accounts'  # 追加
]

AUTH_USER_MODEL = "accounts.User"  # 認証に使うカスタムユーザーモデルを指定
```


#### カスタムユーザーモデルの作成


今回はAbstractBaseUserとPermissionsMixinを使って作成します


UserクラスにField構成を定義して
UserManagerにオブジェクト作成時の処理を書いています


```python:accounts/models.py
from django.db import models
from django.contrib.auth.models import (BaseUserManager, AbstractBaseUser, PermissionsMixin)
from django.utils.translation import gettext_lazy as _

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

        return user

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

    def create_superuser(self, email, account_id, password, **extra_fields):
        extra_fields['is_active'] = True
        extra_fields['is_staff'] = True
        extra_fields['is_superuser'] = True
        return self._create_user(
            email=email,
            account_id=account_id,
            password=password,
            **extra_fields,
        )

class User(AbstractBaseUser, PermissionsMixin):
    account_id = models.CharField(verbose_name=_("account_id"), unique=True, max_length=10)
    email = models.EmailField(verbose_name=_("email"), unique=True)
    first_name = models.CharField(verbose_name=_("first_name"), max_length=150, null=True, blank=False)
    last_name = models.CharField(verbose_name=_("last_name"), max_length=150, null=True, blank=False)
    birth_date = models.DateField(verbose_name=_("birth_date"), blank=True, null=True)
    is_superuser = models.BooleanField(verbose_name=_("is_superuer"), default=False)
    is_staff = models.BooleanField(verbose_name=_('staff status'), default=False)
    is_active = models.BooleanField(verbose_name=_('active'), default=True)
    created_at = models.DateTimeField(verbose_name=_("created_at"), auto_now_add=True)
    updated_at = models.DateTimeField(verbose_name=_("updateded_at"), auto_now=True)

    objects = UserManager()

    USERNAME_FIELD = 'account_id'  # ログイン時、ユーザー名の代わりにaccount_idを使用
    REQUIRED_FIELDS = ['email']  # スーパーユーザー作成時にemailも設定する

    def __str__(self):
        return self.account_id
```


#### マイグレーションする


```python:terminal
python manage.py makemigrations
python manage.py migrate
```


#### adminページで編集できるようにする


```python:accounts/admin.py
from django.contrib import admin
from django.contrib.auth.models import Group
from .models import User

@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    pass

admin.site.unregister(Group)  # Groupは要らないので非表示にする
```


#### superuserをつくる


admin編集用のsuperuserをつくります
ID, passwordを求められるので入力します


```python:terminal
python manage.py createsuperuser
```


#### adminページでユーザーを登録


作成できているか確認しましょう


```python:terminal
python manage.py runserver
```


http://127.0.0.1:8000/admin
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/f24841ab-ff92-4a81-ba44-30c893d57f4d.png" width=70%>


SUPERUSERでログインしたままにすると後のログイン動作確認でSUPERUSERでログインしている扱いになってしまいますのでログアウトしておきます


## 3. 画面をつくる


### 3-1. ユーザー登録画面をつくる


#### formをつくる


```python:accounts/forms.py
from django.contrib.auth.forms import UserCreationForm
from .models import User

class SignUpForm(UserCreationForm):
    class Meta:
        model = User
        fields = (
            "account_id",
            "email",
            "first_name",
            "last_name",
            "birth_date",
        )
```


#### viewをつくる


indexとsignupのViewをつくります


```python:accounts/views.py
from django.contrib.auth import login, authenticate
from django.views.generic import TemplateView, CreateView
from django.urls import reverse_lazy
from .forms import SignUpForm

class IndexView(TemplateView):
    """ ホームビュー """
    template_name = "index.html"

class SignupView(CreateView):
    """ ユーザー登録用ビュー """
    form_class = SignUpForm # 作成した登録用フォームを設定
    template_name = "accounts/signup.html" 
    success_url = reverse_lazy("accounts:index")  # ユーザー作成後のリダイレクト先ページ

    def form_valid(self, form):
        # ユーザー作成後にそのままログイン状態にする設定
        response = super().form_valid(form)
        account_id = form.cleaned_data.get("account_id")
        password = form.cleaned_data.get("password1")
        user = authenticate(account_id=account_id, password=password)
        login(self.request, user)
        return response
```


#### templateをつくる


共通部分をbase.htmlとしてつくります


```python:templates/base.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- Bootstrap5のCDNを設定 -->
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
    crossorigin="anonymous"></script>
  <title>Account</title>
</head>

<body>
  <div class="container mx-auto text-center">
    {% block title %}
    {% endblock %}
    {% block content %}
    {% endblock %}
  </div>
</body>

</html>
```


最初の画面をつくります


userが唐突に登場していますが、これは現在アクセスしているユーザーのユーザーオブジェクトです。django.contrib.auth.context_processors.authがCookieを使ってなんか上手いことやってくれるようです。user.is_authenticatedはDjangoのSUPERUSERでログインしていてもTrueになってしまうので、実用のコードではもう少し記載が必要だと思います。その場合、デフォルトのユーザーモデルにuser.first_nameとuser.last_nameがないので「None None」と表示されてしまいます


```python:templates/index.html
{% extends 'base.html' %}

{% block title %}
{% if user.is_authenticated %}
<div class="h1">MyPage</div>
{% else %}
<div class="h1">Main</div>
{% endif %}
{% endblock %}

{% block content %}
{% if user.is_authenticated %}
<div class="h2">Welcome {{ user.first_name }} {{ user.last_name }}</div>
{% else %}
<a href="{% url 'accounts:signup' %}" class="btn btn-primary">Signup</a>
{% endif %}
{% endblock %}
```


ユーザー登録画面をつくります


```python:templates/accounts/signup.html
{% extends 'base.html' %}

{% block title %}
<div class="h1">Create account</div>
{% endblock %}

{% block content %}
<div>
  <br>
  <form method="POST">
    {% csrf_token %}
    {{ form.non_field_errors }}
    {% for field in form %}
    {{ field.label }}
    {{ field }}
    {{ field.errors }}
    <br>
    {% endfor %}
    <div class="mt-3">
      <button type="submit" class="btn btn-primary">Create</button>
      <a href="{% url 'accounts:index' %}" class="btn btn-secondary">Back</a>
    </div>
  </form>
</div>
{% endblock %}
```


テンプレートのディレクトリをsettings.pyに追加


```python:myapp/settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],  # templatesディレクトリを設定
        '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',
            ],
        },
    },
]
```


#### urls.pyにviewを登録


```python:myapp/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include("accounts.urls")) # accounts.urls.pyを読み込むための設定を追加
]
```


```python:accounts/urls.py
from django.urls import path
from . import views

app_name = "accounts"

urlpatterns = [
    path("", views.IndexView.as_view(), name="index"),
    path('signup/', views.SignupView.as_view(), name="signup"),
]
```


#### 動かしてみる


```python:terminal
python manage.py runserver
```


こんな感じで記入します


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/217e12aa-76f8-4c6e-9e2f-0062b816b3b2.png" width=50%>


formsを利用して入力フォームをつくると入力バリデーションしてくれます


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/ca6a31e8-e49b-4a7f-8f96-a067a147971c.png" width=50%>


ユーザー登録すると以下のような表示になります


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/224309b1-8e29-44b0-9f36-c10956fb9f07.png" width=80%>


### 3-2. ログイン画面をつくる


サインアップして登録した情報でログインする画面をつくります


#### formをつくる


LoginFormを追加します


AuthenticationFormを継承してFormをつくると、modelは指定しなくてもデフォルトでsettings.pyのAUTH_USER_MODELで指定されているユーザーモデルを使って、ユーザー名とパスワードでログインするフォームが作成されます


```python:
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm  # 追加

class SignUpForm(UserCreationForm):
    class Meta:
        model = User
        fields = (
            "account_id",
            "email",
            "first_name",
            "last_name",
            "birth_date",
        )

class LoginFrom(AuthenticationForm):
    pass
```


ログインIDとしてemailを使いたい場合は以下のようにバリデーション処理中に置き換えて扱えば出来るようです


```python:emailとパスワードで認証するフォーム
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth import get_user_model
from django import forms

User = get_user_model()

class EmailLoginForm(AuthenticationForm):
    username = forms.EmailField(label="Email", widget=forms.EmailInput(attrs={"autofocus": True}))

    def clean(self):
        email = self.cleaned_data.get("username")  # username フィールドを email として扱う
        password = self.cleaned_data.get("password")
        if email and password:
            try:
                user = User.objects.get(email=email)
                self.user_cache = user
                self.confirm_login_allowed(user)
            except User.DoesNotExist:
                raise forms.ValidationError("このメールアドレスのユーザーは存在しません。")
        return self.cleaned_data
```


#### viewをつくる


LoginViewを追加します


```python:accounts/views.pyに追加
from django.contrib.auth.views import LoginView as BaseLoginView

class LoginView(BaseLoginView):
    form_class = LoginFrom
    template_name = "accounts/login.html"
```


#### templateをつくる


ログイン画面のテンプレートを作成します


```python:templates/accounts/login.html
{% extends 'base.html' %}

{% block title %}
<div class="h1">Login</div>
{% endblock %}
{% block content %}
<form method="post">
    {% csrf_token %}
    {{ form.non_field_errors }}
    {% for field in form %}
    {{ field.label }}
    {{ field }}
    {{ field.errors }}
    <br>
    {% endfor %}
    <div class="mt-3">
        <button type="submit" class="btn btn-primary">Login</button>
        <a href="{% url 'accounts:index' %}" class="btn btn-secondary">Back</a>
    </div>
</form>
{% endblock %}
```


#### urlsにviewを登録


追加したloginページをルーティングに追加


```python:
from django.urls import path
from . import views

app_name = "accounts"

urlpatterns = [
    path("", views.IndexView.as_view(), name="index"),
    path('signup/', views.SignupView.as_view(), name="signup"),
    path('login/', views.LoginView.as_view(), name="login"),
]
```


#### settings.pyにリダイレクト先を登録


ログイン後にリダイレクトされるURLを指定します


```python:settings.py
LOGIN_REDIRECT_URL = "accounts:index"
```


### 3-3. ログアウト画面をつくる


#### viewをつくる


LogoutViewを追加します


BaseLogoutViewを継承したViewクラスをつくっておいて、ボタンクリックから呼んでやればログアウト処理とログアウト後の画面表示をやってくれます


```python:accounts/views.pyに追加
from django.contrib.auth.views import LogoutView as BaseLogoutView

class LogoutView(BaseLogoutView):
    success_url = reverse_lazy("accounts:index")
```


#### templateにログアウトボタンを追加


ログアウト処理はCSRF対策の関係でPOSTメソッドで送るのが推奨されているようです。その為、BaseLogoutViewはGETリクエストを405エラーではじいてしまいますので、ちょっと面倒ですがPOSTメソッドで呼ぶようにしてやります


```python:templates/index.html
{% extends 'base.html' %}

{% block title %}
{% if user.is_authenticated %}
<div class="h1">MyPage</div>
{% else %}
<div class="h1">Main</div>
{% endif %}
{% endblock %}

{% block content %}
{% if user.is_authenticated %}
<div class="h2">Welcome {{ user.first_name }} {{ user.last_name }}</div>
<!-- ↓追加 -->
<form method="post" action="{% url 'accounts:logout' %}">
    {% csrf_token %}
    <button type="submit" class="btn btn-primary">ログアウト</button>
</form>
{% else %}
<a href="{% url 'accounts:signup' %}" class="btn btn-primary">Signup</a>
<a href="{% url 'accounts:login' %}" class="btn btn-primary">Login</a>
{% endif %}
{% endblock %}
```


#### urlsにviewを登録


追加したloginページをルーティングに追加


```python:
from django.urls import path
from . import views

app_name = "accounts"

urlpatterns = [
    path("", views.IndexView.as_view(), name="index"),
    path('signup/', views.SignupView.as_view(), name="signup"),
    path('login/', views.LoginView.as_view(), name="login"),
    path('logout/', views.LogoutView.as_view(), name="logout"),
]
```


#### settings.pyにリダイレクト先を登録


ログアウト後にリダイレクトされるURLを指定します


```python:settings.py
LOGOUT_REDIRECT_URL = "accounts:login"
```


#### 動かしてみる


これでログイン、ログアウト機能が追加できた筈です


```python:terminal
python manage.py runserver
```


# まとめ


めちゃめちゃ簡単に実装できます
Djangoすごい


レッツトライ