環境
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 の作成
Django 第2回:Django 初回ログイン時にパスワード変更を強制する
Django 第3回:Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する
Django 第4回:Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する
Django 第5回:Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除 今回
背景
django-axes
によってユーザ認証機能にログ試行制限を設ける。
この場合、一定時間(1時間、5時間など設定で変更可)ユーザはアクセスができなくなるわけだが
実業務としてアクセス解除の依頼がユーザから管理者に寄せられることが想定される。
管理サイトあるいはコマンドでの解除も可能ではあるが管理者の負担が大きい。
そこで今回は解除申請をフォームで受け付け、ユーザのメールアドレスにランダムかつ有効期限ついたメールを送り
そのURLにアクセスが確認された場合、ロック解除を行う機能を実装する。
手順1:django-axes のインストール
Susscessfully Instaled
が出ていればOK
手順2: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
手順4:ロックされるかテスト
http://127.0.0.1:8000/admin/
にアクセスし、わざとログインを連続5回間違える。
すると以下の画面が表示され、ロックされたことが確認できる。
なおこの画面は変更することができる。
変更したい場合はコチラのサイトを参考に。
このロック状態をユーザからのフォーム投稿で解除できるようにしていく。
手順5: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
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
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
フォルダを作る。
その中に以下3ファイルを作る
<!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>
<!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>
<!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
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
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/
にアクセスする。
コンソールにメールで送信する内容(メール機能は本記事では扱わない)が出力されているので確認する。
未だランダムURLの機能を実装してないないので、コンソールのURLにアクセスしてもエラーになる。
手順12:views.pyの更新
UnlockDoneView
classを追加したのとimport文を追加した。全文載せておく。
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回 の続きになるが全文記載しておく。
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にアクセスするとロックが解除されるとともに画面にその旨表示される。
(ロックされていない状態でロック解除申請のURLをクリックしても、Views.pyのresultの戻り値が1にならないため解除画面は確認できない)
手順15:管理画面の追加
管理画面( http://127.0.0.1:8000/admin )に入ると、AXESとロック解除申請ユーザが増えている。
前者はアクセス履歴、後者はアクセスロック解除申請履歴が確認できる。
アクセスロック解除申請履歴ではその名のとおり、アクセスロック解除申請とその結果が閲覧できる。
参考
Djangoでログイン試行回数制限を付けられる「django-axes」の使い方
Djangoのユーザ認証機能にログイン試行回数制限を追加する
Django 第1回:Django Custom User Model の作成
Django 第2回:Django 初回ログイン時にパスワード変更を強制する
Django 第3回:Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する
Django 第4回:Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する
Django 第5回:Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除