Djangoでカスタムユーザ認証

独自定義のユーザ認証を行いたい場合にみるドキュメント。
自分の中の腹落ち整理もかねて書き残す。

参考
[Django の認証方法のカスタマイズ \| Django ドキュメント | Django](https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#specifying-a-custom-user-model)

## 目次

1. [環境情報](#環境情報)
2. [前提 AbstractUser vs AbstractBaseUser](#前提)
3. [必要なもの](#必要なもの)
4. [実装例](#実装例)
5. [終わりに](#終わりに)

## 環境情報

os, python, django のバージョン

```
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 10 (buster)"

$ python --version
Python 3.8.5

$ python -m django --version
3.1.7
```

## 前提 

AbstractUser vs AbstractBaseUser

| class            | use case                                                                              |
| ---------------- | ------------------------------------------------------------------------------------- |
| AbstractUser     | Djangoがデフォルトで用意しているユーザ情報 + いくつかの追加属性で十分な場合に利用する |
| AbstractBaseUser | Djangoがデフォルトで用意しているユーザ情報では不十分な場合に利用する                  |

**不十分な場合って？**

`username` や `email` 以外の認証情報を使用したいとか、認証の識別子が重複しているとか、そういった理由がある場合はデフォルトでは不十分になるんだと思われる。
AbstractBaseUser を使うためには合わせて BaseUserManager も実装する必要がある。

[Django の認証方法のカスタマイズ \| Django ドキュメント | Django](https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#writing-a-manager-for-a-custom-user-model)

ここでいう `username` はユーザ名というよりかはユーザの識別子としての意味合いだと思われる。


## 必要なもの

今回はシステムの都合で独自に定義したIDを使って認証を行う方法を選択する。
必要なものは以下の二つ。

1. AbstractBaseUser を継承した会員情報 - Member クラスとして実装してみる
2. BaseUserManager を継承した Member クラスで認証/認可を行うためのマネージャクラス - MemberManager として実装してみる
3. その他 url や view の設定など

## 実装例

`django-admin startproject sampleapp` で作成したプロジェクトを例に書くことにする。
まずは一旦画面を出すため url や view の設定から行うことにする。
自動生成されないファイルやディレクトリは適宜作成する。

ログイン確認用のアプリケーションを作成して

```
$ python manage.py startapp accounts
```

urlなどの設定を追加して

```diff:sampleapp/settings.py
 INSTALLED_APPS = [
+    'accounts.apps.AccountsConfig',
     'django.contrib.admin',
     'django.contrib.auth',
     'django.contrib.contenttypes',
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
 ]
```
```diff:sampleapp/urls.py
-from django.urls import path
+from django.urls import path, include

urlpatterns = [
+    path('accounts/', include('accounts.urls')),
     path('admin/', admin.site.urls),
 ]
```
```python:accounts/urls.py
from django.urls import path
from . import views

app_name = 'accounts'
urlpatterns = [
    path('', views.signin, name='login'),
]
```

ログイン画面の 処理 && テンプレート と

```python:accounts/views.py
from django.shortcuts import render

def signin(request):
    list = {}
    return render(
        request,
        'accounts/login.html',
        {'list': list},
    )
```
```html:accounts/templates/accounts/login.html
<h1>Login</h1>
{% if error_message %}
<p><strong style="color: red;">{{ error_message }}</strong></p>
{% endif %}
<form action="{% url 'accounts:login' %}" method="post">
    {% csrf_token %}
    <label for="member_id">member id</label>
    <input type="text" name="member_id" id="member_id" value="">    
    <br>
    <label for="password">password</label>
    <input type="password" name="password" id="password" value="">
    <br>
    <input type="submit" value="Login">
</form>
```
ログインに成功したときのテンプレートを追加する

```html:accounts/templates/accounts/login_success.html
<h1>Login Success</h1>
<p>Congratulations! You have successfully logged in</p>
```

これで仮のログイン画面が表示できるはず。
`python manage.py runserver` して django を起動して、ブラウザから `https://localhost:8000/accounts/` などにアクセスしてログイン画面を確認する。
ここまでで意味不明な場合は [公式のチュートリアル](https://docs.djangoproject.com/ja/3.2/intro/tutorial01/) あたりをなぞって貰えばわかるはず。
次にモデルを作ってログイン処理を追加する。

今回はメンバーIDとパスワードを認証の要素に指定して使うことにする。
まずは会員を表す Member クラスを AbstractBaseUser を継承させて定義する。

```python:accounts/models.py
from django.contrib.auth.base_user import AbstractBaseUser

class Member(AbstractBaseUser):
    member_id = models.CharField(primary_key=True, max_length=8, null=False, unique=True)
    password = models.CharField(max_length=1024, null=True)
    last_login = models.DateTimeField(null=True)

    USERNAME_FIELD = 'member_id'
    objects = None
```

objects には BaseUserManager を継承した MemberManager を設定するので一旦 None を代入しておく。
次に MemberManager を追記して objects に MemberManager のインスタンスを代入する。

```diff:accounts/models.py
-from django.contrib.auth.base_user import AbstractBaseUser
+from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager

+class MemberManager(BaseUserManager):
+    pass

 class Member(AbstractBaseUser):
     member_id = models.CharField(primary_key=True, max_length=8, null=False, unique=True)
     password = models.CharField(max_length=1024, null=True)
     last_login = models.DateTimeField(null=True)

     USERNAME_FIELD = 'member_id'
-    objects = None
+    objects = MemberManager()
```

本来は MemberManager に create_user, create_superuser を定義してユーザを作成するんだろうけど、一旦手動でユーザを作成するので pass にしておく。

ログイン認証用のカスタムユーザモデルを作成したので、認証対象になるよう設定を追加する。

```diff:sampleapp/settings.py
+# CustomUser
+AUTH_USER_MODEL = 'accounts.Member'
```

views のロジックに認証処理を追加する。
GETの場合はそのまま返却して、POSTの場合は認証処理を実行する。
認証に成功した場合はログイン成功ページに遷移させて、認証に失敗した場合はエラーメッセージとともにログイン画面を再表示する。

```diff:accounts/views.py
 from django.shortcuts import render
+from django.contrib.auth import authenticate, login, logout
 def signin(request):
-    list = {}
-    return render(
-        request,
-        'accounts/login.html',
-        {'list': list},
-    )
+    if request.method == "GET":
+        return render(request, 'accounts/login.html')
+    user = None
+    if request.method == "POST":
+        member_id = request.POST['member_id']
+        password = request.POST['password']
+        user = authenticate(request, username=member_id, password=password)
+
+    if user is not None:
+        login(request, user)
+        return render(request, 'accounts/login_success.html')
+    else:
+        return render(request, 'accounts/login.html', {'error_message': 'login failed'})
```

次にユーザ認証をするためにユーザデータを作成する。

```
$ python manage.py makemigrations

$ python manage.py migrate

$ python manage.py shell
>>> from accounts.models import Member
>>> member = Member(member_id=1)
>>> member.set_password("1q2w3e")
>>> member.save()
```

先ほどのログイン画面から member_id:`1`, password:`1q2w3e` でログインしてみる
`Congratulations! You have successfully logged in` が表示されれば成功。

## 終わりに

当然もっとDjangoに寄り添って作れば綺麗で簡単にできると思うんだけど、公式ドキュメントを見ながらわかる範囲で寄り道する旅路も悪くはないはず。
非効率の先に高効率が成り立つ場合もきっとある。

## おまけに

遭遇したエラーについて

**Message**

```
Manager' object has no attribute 'get_by_natural_key'
```

**原因**
モデルクラス内のマネージャを代入しておく `objects` 変数が空なのでエラーになっている。

**対応**
モデルの `objects` 変数に独自で定義したマネージャを代入しておく。

```diff:
 class Employee(AbstractBaseUser):
     shain_id = models.CharField('社員ID', primary_key=True, max_length=8, null=False, unique=True)
     # ...
     last_login = models.DateTimeField('最終ログイン日時', null=True) 
 
     USERNAME_FIELD = 'shain_id'
+    objects = EmployeeManager()
```

https://cloud6.net/so/django/1407130