Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除

## 環境
Windows 11 Home
Python 3.10.2
Django 4.0.2
venv利用あり
(PyPI)
django-axes==5.31.0
条件：関連記事 第1回が完了していること

## 関連記事
Django 第1回：[Django Custom User Model の作成](https://qiita.com/startours777/items/706d38e712b0c737a16a)
Django 第2回：[Django 初回ログイン時にパスワード変更を強制する](https://qiita.com/startours777/items/28db625a9bb81a36d4ad)
Django 第3回：[Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する](https://qiita.com/startours777/items/d550ca9b67cea4408a44)
Django 第4回：[Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する](https://qiita.com/startours777/items/529e74062e7054e79517)
Django 第5回：[Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除](https://qiita.com/startours777/items/5b1fc415e047a2044129)　今回

## 背景
`django-axes`によってユーザ認証機能にログ試行制限を設ける。
この場合、一定時間（1時間、5時間など設定で変更可）ユーザはアクセスができなくなるわけだが
実業務としてアクセス解除の依頼がユーザから管理者に寄せられることが想定される。
管理サイトあるいはコマンドでの解除も可能ではあるが管理者の負担が大きい。
そこで今回は解除申請をフォームで受け付け、ユーザのメールアドレスにランダムかつ有効期限ついたメールを送り
そのURLにアクセスが確認された場合、ロック解除を行う機能を実装する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/ff4e0984-bb20-2436-f0cf-87c21c93c2eb.png)

## 手順1：django-axes のインストール
`pip install django-axes`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/db4a07c6-deca-6a55-6e89-20cbe0ebf640.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/61dad8af-474f-3fb8-52a8-08546dd1a9bd.png)

`Susscessfully Instaled`が出ていればOK

## 手順2：mysite\settings.py

```python:mysite\settings.py
INSTALLED_APPS = [
    ...
    'axes',
]

MIDDLEWARE = [
    ...
    'axes.middleware.AxesMiddleware',
]

AUTHENTICATION_BACKENDS = [
    'axes.backends.AxesBackend',
    'django.contrib.auth.backends.ModelBackend',
]

AXES_FAILURE_LIMIT = 5 # ログイン試行回数
AXES_COOLOFF_TIME = 2 # 自動でロックを解除するまでの時間(単位は時間)
AXES_ONLY_USER_FAILURES = True # Trueにすることでロック対象をIPアドレスではなくユーザ名で判断
AXES_RESET_ON_SUCCESS = True # ログイン成功したらログイン失敗回数をリセットする
AXES_META_PRECEDENCE_ORDER = [
    'HTTP_X_FORWARDED_FOR', # リバースプロキシを使った場合でも利用できるようにする
]
```

## 手順3：migrate
axesで使用するDBを作成するためにmigrateが必要です
`python manage.py migrate`

このようなログが出ればOK。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/99cc69f5-afef-a660-fcce-42bbd964114d.png)

## 手順4：ロックされるかテスト
`http://127.0.0.1:8000/admin/`にアクセスし、わざとログインを連続5回間違える。
すると以下の画面が表示され、ロックされたことが確認できる。
なおこの画面は変更することができる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/ab039d24-ca60-f5a8-61b6-7572c2115644.png)
変更したい場合は[コチラ](https://create-it-myself.com/know-how/how-to-use-django-axes/)のサイトを参考に。

このロック状態をユーザからのフォーム投稿で解除できるようにしていく。

## 手順5：users/models.py
```python:users\models.py
...
class UnlockUser(models.Model):
    """ロック解除申請用のユーザ、何度でも申請できる"""
    class Meta:
        verbose_name = 'ロック解除申請ユーザ'
        verbose_name_plural = 'ロック解除申請ユーザ'

    unlock_uuid = models.UUIDField(default=uuid_lib.uuid4, primary_key=True, editable=False) # 管理ID
    unlock_email = models.EmailField(unique=False) # メールアドレス = これで認証する
    unlock_application_date = models.DateTimeField() # ユーザ申請日時
    unlock_token = models.CharField(max_length=50) # ランダムURL用のトークン
    unlock_expired_seconds = models.IntegerField() # ランダムURLの有効期限（秒）
    unlock_expired_limit = models.DateTimeField()
    unlock_url = models.URLField() # ランダムURL
    unlock_time = models.DateTimeField(blank=True, null=True) # ロック解除日時
```

## 手順6：users/forms.py
`users`配下に`forms.py`を作成する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/10b6de4c-fa07-fb22-a130-dc8578d1a4f5.png)

```python:users\forms.py
from django import forms
from .models import UnlockUser

class UnlockForm(forms.ModelForm):
    class Meta:
        model = UnlockUser
        fields = ['unlock_email']
        label = {
            'unlock_email': 'ロック解除するメールアドレス',
        }
        Widgets = {
            'unlock_email': forms.TextInput(attrs={'placeholder': 'xxx.yyy@gmail.com'})
        }
```

## 手順7：users/views.py

```python:users\views.py
from django.shortcuts import render
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.utils import timezone
from datetime import timedelta
import random
import string
import math
from .forms import UnlockForm

TOKEN_LENGTH = 30 # トークンの文字数
UNLOCK_EXPIRED_SECONDS = 60 * 60 * 2 # トークンの有効期限 = ロック解除メールの有効期限 （秒）

def get_random_chars(char_num=TOKEN_LENGTH):
    """ランダムな文字列を作る"""
    return "".join([random.choice(string.ascii_letters + string.digits) for i in range(char_num)])

def Unlock(request):
    """アカウントロック解除画面"""
    if request.method == 'POST':
        form = UnlockForm(request.POST)
        if form.is_valid():
            obj = form.save(commit=False)

            # 期限付きランダムURLの作成
            timestamp_signer = TimestampSigner()
            context = {}
            context['expired_seconds'] = UNLOCK_EXPIRED_SECONDS
            token = get_random_chars()
            token_signed = timestamp_signer.sign(token)
            context['token_signed'] = token_signed
            expired_limit = timezone.now() + timezone.timedelta(seconds=UNLOCK_EXPIRED_SECONDS)

            obj.unlock_application_date = timezone.now()
            obj.unlock_expired_seconds = UNLOCK_EXPIRED_SECONDS
            obj.unlock_expired_limit = expired_limit
            obj.unlock_url = f'http://127.0.0.1:8000/users/{token_signed}'
            obj.unlock_token = token
            obj.save()

            # メール送信部分（print文で代用）
            user_id = obj.unlock_uuid
            email = form.cleaned_data['unlock_email']

            print(f"""
            {email}さん
            ロック解除申請を受け付けました。
            以下のURLより本人確認をしてください。
            {obj.unlock_url}

            (管理ID:{user_id})
            """)

            params = {
                'title': 'アカウントロック解除申請',
                'message': f'ロック解除申請を受け付けました。解除用のメールをお送りしていますのでご確認ください。(管理ID:{user_id})'
            }
            return render(request, 'users/unlock_requested.html', params)

        else:
            # データが不正ならフォームを再描画する
            context = {'form': form}
            return render(request, 'user/unlock.html', context)

    else:
        """GETの際=フォームを描画"""
        params={
            'title': 'アカウントロック解除',
            'form': UnlockForm(),
        }
        return render(request, 'users/unlock.html', params)
```

## 手順8：htmlの作成

`users`配下に`templates`フォルダを作り、さらにその中に`users`フォルダを作る。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/d767ae7f-05ad-4220-e494-485cf716d719.png)

その中に以下3ファイルを作る
- unlock.html
- unlock_requested.html
- unlock_done.html
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/736933bf-c7ec-09e6-d778-a4465fa147a7.png)

```html:users\templates\users\unlock.html
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
</head>
<body>
    <form action="{% url 'users:unlock' %}"method="post">
        {% csrf_token %}
            <table class="table">
                {{ form.as_table }}
                    <tr><th><td>
                        <input type="submit" value="送信" class="btn">
                    </td></th></tr>

            </table>
    </form>
</body>
</html>
```

```html:users\templates\users\unlock_requested.html
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
</head>
<body>
    <form action="{% url 'users:unlock' %}"method="post">
        {% csrf_token %}
            <table class="table">
                {{ form.as_table }}
                    <p>{{ message }}</p>
            </table>
    </form>
</body>
</html>
```

```html:users\templates\users\unlock_do.html
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
</head>
<body>
    {% csrf_token %}
        <table class="table">
            {{ form.as_table }}
                <p>{{ message }}</p>
        </table>
</body>
</html>
```

## 手順9 mysite\urls.py
```python:mysite\urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('users/', include('users.urls')),
]

```

## 手順10：users\urls.py
`users`配下に`urls.py`を新規作成する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/6668efc9-086e-677d-6dca-047d602c50db.png)

```python:users\urls.py
from django.urls import URLPattern, path
from . import views

# URLパターンを逆引きできるように名前を付ける
app_name = 'users'

urlpatterns = [
    path('unlock/', views.Unlock, name='unlock'), # アカウントロック解除
]
```

## 手順11：動作テスト
`http://127.0.0.1:8000/users/unlock/`にアクセスする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/39cbcf4a-1a36-6ef7-2e64-5407c530fddc.png)

適当なアドレスを入れ、送信を押す。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/a0eb884f-71be-b449-1c95-841604268cfb.png)

リクエスト受領案内が出ればOK
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/5c12f2f6-4119-ce76-30b9-11048e07feba.png)

コンソールにメールで送信する内容(メール機能は本記事では扱わない)が出力されているので確認する。
未だランダムURLの機能を実装してないないので、コンソールのURLにアクセスしてもエラーになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/08caee00-3cfd-8a91-817b-6371fbafa16f.png)

## 手順12：views.pyの更新
`UnlockDoneView`classを追加したのとimport文を追加した。全文載せておく。

```python:users\views.py
from django.shortcuts import render
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.views.generic.base import TemplateView
from django.utils import timezone
from datetime import timedelta
from axes.handlers.proxy import AxesProxyHandler
import random
import string
from .forms import UnlockForm
from .models import UnlockUser

TOKEN_LENGTH = 30 # トークンの文字数
UNLOCK_EXPIRED_SECONDS = 60 * 60 * 2 # トークンの有効期限 = ロック解除メールの有効期限 （秒）

def get_random_chars(char_num=TOKEN_LENGTH):
    """ランダムな文字列を作る"""
    return "".join([random.choice(string.ascii_letters + string.digits) for i in range(char_num)])

def Unlock(request):
    """アカウントロック解除画面"""
    if request.method == 'POST':
        form = UnlockForm(request.POST)
        if form.is_valid():
            obj = form.save(commit=False)

            # 期限付きランダムURLの作成
            timestamp_signer = TimestampSigner()
            context = {}
            context['expired_seconds'] = UNLOCK_EXPIRED_SECONDS
            token = get_random_chars()
            token_signed = timestamp_signer.sign(token)
            context['token_signed'] = token_signed
            expired_limit = timezone.now() + timezone.timedelta(seconds=UNLOCK_EXPIRED_SECONDS)

            obj.unlock_application_date = timezone.now()
            obj.unlock_expired_seconds = UNLOCK_EXPIRED_SECONDS
            obj.unlock_expired_limit = expired_limit
            obj.unlock_url = f'http://127.0.0.1:8000/users/{token_signed}'
            obj.unlock_token = token
            obj.save()

            # メール送信部分（print文で代用）
            user_id = obj.unlock_uuid
            email = form.cleaned_data['unlock_email']

            print(f"""
            {email}さん
            ロック解除申請を受け付けました。
            以下のURLより本人確認をしてください。
            {obj.unlock_url}

            (管理ID:{user_id})
            """)

            params = {
                'title': 'アカウントロック解除申請',
                'message': f'ロック解除申請を受け付けました。解除用のメールをお送りしていますのでご確認ください。'
            }
            return render(request, 'users/unlock_requested.html', params)

        else:
            # データが不正ならフォームを再描画する
            context = {'form': form}
            return render(request, 'user/unlock.html', context)

    else:
        """GETの際=フォームを描画"""
        params={
            'title': 'アカウントロック解除',
            'form': UnlockForm(),
        }
        return render(request, 'users/unlock.html', params)

class UnlockDoneView(TemplateView):
    template_name = 'users/unlock_done.html'
    timestamp_signer = TimestampSigner()

    def get(self, request, token=None):
        context = {}
        context['expired_seconds'] = UNLOCK_EXPIRED_SECONDS
        
        if token:
            try:
                unsigned_token = self.timestamp_signer.unsign(
                    token,
                    max_age=timedelta(seconds=UNLOCK_EXPIRED_SECONDS)
                )
                queryset = UnlockUser.objects.get(unlock_token=unsigned_token)
                email = queryset.unlock_email

                if queryset.unlock_result == False:
                    context["message"] = '有効なトークンです'

                    # ロック解除(解除成功で戻り値1)
                    result = AxesProxyHandler.reset_attempts(username=email)
                    print('result-----',result)
                    
                    if result == 1:
                        queryset.unlock_time = timezone.now()
                        queryset.unlock_result = True
                        queryset.save()

                        params = {
                            'title': 'アカウントロック解除',
                            'message': 'ロックを解除しました。', 
                        }
                    else:
                        params = {
                            'title': 'アカウントロック解除',
                            'message': '解除できませんでした。ロックされていない可能性があります', 
                        }
                else:
                    # 使用済み
                    context["message"] = '使用済みのトークンです'
                    params = {
                        'title': 'アカウントロック解除',
                        'message': '解除済みです。', 
                    }
            except SignatureExpired:
                # 有効期限切れ
                context["message"] = 'このトークンは有効期限が切れています'
                params = {
                    'title': 'アカウントロック解除',
                    'message': '期限切れです。申請をやり直してください。', 
                }
            except BadSignature:
                # TOKENが不正（URLが間違っている）
                context["message"] = 'トークンが正しくありません'
                params = {
                    'title': 'アカウントロック解除',
                    'message': 'URLが正しくありません。', 
                }

        return render(request, self.template_name, params)
```

## 手順13：users\urls.py
[Django 第1回](https://qiita.com/startours777/items/706d38e712b0c737a16a) の続きになるが全文記載しておく。

```python:users\admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import gettext, gettext_lazy as _
from .models import User, UnlockUser

@admin.register(User)
class UserAdmin(UserAdmin):
    fieldsets = (
        (None, {'fields': ('username', 'password')}),
        (_('Personal Info',), {'fields': ('email',)}),
        (_('Permissions',), {'fields': ('is_active', 'is_staff', 'is_superuser',)}),
        (_('Password',), {'fields': ('password_changed', 'password_changed_date',)}),
        (_('Important Dates',), {'fields': ('last_login', 'date_joined',)}),
    )

    list_display = ('username', 'email', 'is_active',)

@admin.register(UnlockUser)
class UnlockUserAdmin(admin.ModelAdmin):
    fieldsets = (
        (_('User',), {'fields': ('unlock_uuid', 'unlock_email',)}),
        (_('Application',), {'fields': ('unlock_application_date', 'unlock_token', 'unlock_expired_seconds', 'unlock_expired_limit', 'unlock_url',)}),
        (_('Result',), {'fields': ('unlock_time', 'unlock_result',)}),
    )

    list_display = ('unlock_application_date', 'unlock_email', 'unlock_result',)
    search_fields = ('unlock_email',)
    ordering = ('-unlock_application_date',)
```



## 手順14：動作テスト2
ログイン画面で5回間違えてロック状態にする。
http://127.0.0.1:8000/users/unlock/ でメールアドレスを入れる
コンソールにURLが出力されるので、URLにアクセスするとロックが解除されるとともに画面にその旨表示される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/2c73a5a6-1923-4a39-2c27-32ea7111e787.png)


（ロックされていない状態でロック解除申請のURLをクリックしても、Views.pyのresultの戻り値が1にならないため解除画面は確認できない）

ロック解除された場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/6854352d-fcf1-34b7-d991-1b88053a0074.png)

すでに解除済みURLをクリックした場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/96939d5e-39c8-647c-8e62-0c78cdf3420f.png)

ロックしていないのに解除申請をした場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/f6dd902d-f000-a445-491e-5433229c3153.png)

期限切れの場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/a3429cf7-087b-b3c2-3df4-4e9a41c08bf2.png)


## 手順15：管理画面の追加
管理画面( http://127.0.0.1:8000/admin )に入ると、AXESとロック解除申請ユーザが増えている。
前者はアクセス履歴、後者はアクセスロック解除申請履歴が確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/b818d0d6-cb76-e370-3754-499e1bb3899f.png)

アクセスロック解除申請履歴ではその名のとおり、アクセスロック解除申請とその結果が閲覧できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/23e149ab-109a-7f13-50a4-4984abbcf510.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/b6f44692-124d-f07d-6d5d-21e6a1866771.png)

## 参考
[Djangoでログイン試行回数制限を付けられる「django-axes」の使い方](https://create-it-myself.com/know-how/how-to-use-django-axes/)
[Djangoのユーザ認証機能にログイン試行回数制限を追加する](https://create-it-myself.com/know-how/add-login-attempts-limit-to-django-user-authentication/)
Django 第1回：[Django Custom User Model の作成](https://qiita.com/startours777/items/706d38e712b0c737a16a)
Django 第2回：[Django 初回ログイン時にパスワード変更を強制する](https://qiita.com/startours777/items/28db625a9bb81a36d4ad)
Django 第3回：[Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する](https://qiita.com/startours777/items/d550ca9b67cea4408a44)
Django 第4回：[Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する](https://qiita.com/startours777/items/529e74062e7054e79517)　
Django 第5回：[Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除](https://qiita.com/startours777/items/5b1fc415e047a2044129)