環境
Windows 11 Home
Python 3.10.2
Django 4.0.2
venv利用あり
※関連記事 第1回が完了していること
関連記事
Django 第1回:Django Custom User Model の作成
Django 第2回:Django 初回ログイン時にパスワード変更を強制する
Django 第3回:Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する
Django 第4回:Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する 今回
Django 第5回:Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除
背景
アカウント作成をする際、管理者が1件ずつ処理するのは現実的ではない。
そこでユーザからのリクエストを本Appで受け付け、上位者がそれを承認することでアカウントを自動作成することを想定する。
上長はアカウント不要とし、その代わりに上長向けにランダムなURLを発行し、そこで承認か非承認かを選択いただく。
またURLには有効期間を設け、長時間放置されるような申請については自動的にURLを無効化とする。
今回は1次承認者は申請者が自由に記載できるようにするが、システム開発責任者等に固定してもよい。
(申請者→申請者上長→システム開発責任者)
手順1:App作成
以下コマンドでcreate_user
Appを作成する。
python manage.py startapp create_user
settings.pyに作成したAppを追加する
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'users',
'create_user', # 追加
]
手順2:models.py
from django.db import models
import uuid as uuid_lib
class ApproveUser(models.Model):
class Meta:
verbose_name = 'アカウント申請ユーザ'
verbose_name_plural = 'アカウント申請ユーザ'
# ユーザ申請時に作成・更新
uuid = models.UUIDField(default=uuid_lib.uuid4, primary_key=True, editable=False) # 管理ID
username = models.CharField(unique=False, max_length=30) # ユーザ氏名、同姓同名があるためunique=False
email = models.EmailField(unique=False) # ユーザメールアドレス、何度か申請することを考えunique=False
application_date = models.DateTimeField() # 申請日時
first_approver_name = models.CharField(max_length=30, unique=False) # 1次承認者氏名
first_approver_email = models.EmailField(unique=False) # 1次承認者メールアドレス
first_approver_token = models.CharField(max_length=50) # 1次承認者用URLトークン
first_approver_expired_seconds = models.IntegerField() # 1次承認者用トークン有効時間
first_approver_url = models.URLField() # 1次承認用URL
# 1次承認時に更新
first_approver_time = models.DateTimeField(blank=True, null=True) # 1次承認日時
first_approver_result = models.BooleanField(blank=True, null=True) # 1次承認結果
以下2つのコマンドでDBに反映をする。
python manage.py makemigrations
(Aprooveuser
になっていますが、正しくは記載のコードのとおりApproveuser
です)
手順2:forms.py
次に作成したモデルをベースにフォームを作成する。
create_user
配下に forms.py
を作成する。
以下のように記載する。
from django import forms
from .models import ApproveUser # models.pyで作成したmodel
class AppricationForm(forms.ModelForm):
"""アカウント申請フォーム"""
class Meta:
model = ApproveUser
fields = ['username', 'email', 'first_approver_name', 'first_approver_email',] # Formに表示される項目
# Formに実際に表示される文字
labels = {
'username': '申請者 氏名',
'email': '申請者 メールアドレス',
'first_approver_name': '1次承認者 氏名',
'first_approver_email': '1次承認者 メールアドレス',
}
Widgets = {
'username': forms.TextInput(attrs={'placeholder': '山田 太郎'}),
'email': forms.TextInput(attrs={'placeholder': 'xxxx.yyyy@gmail.com'}),
'first_approver_name': forms.TextInput(attrs={'placeholder': '日本 花子'}),
'first_approver_email': forms.TextInput(attrs={'placeholder': 'zzzz.zzzz@gmail.com'}),
}
def clean(self):
"""申請者と1次承認が同じメールアドレスは不可"""
cleaned_data = super().clean()
email = cleaned_data.get('email')
first_approver_email = cleaned_data.get('first_approver_email')
if email == first_approver_email:
raise forms.ValidationError('申請者と1次承認者のメールアドレスを同じにはできません')
return cleaned_data
class FirstApproveForm(forms.ModelForm):
"""1次承認フォーム"""
# 1次承認時に変更できなくさせる(表示のみ)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].widget.attrs['readonly'] = 'readonly'
self.fields['email'].widget.attrs['readonly'] = 'readonly'
self.fields['first_approver_name'].widget.attrs['readonly'] = 'readonly'
self.fields['first_approver_email'].widget.attrs['readonly'] = 'readonly'
class Meta:
model = ApproveUser
fields = ['username', 'email', 'first_approver_name', 'first_approver_email', 'first_approver_result',] # Formに表示される項目
labels = {
'username': '申請者 氏名',
'email': '申請者 メールアドレス',
'first_approver_name': '1次承認者 氏名',
'first_approver_email': '1次承認者 メールアドレス',
'first_approver_result': '1次承認結果',
}
first_approver_result = forms.BooleanField() # 1次承認の結果(True/False)
手順3:views.py
次に作成したViewで動作を決めていく。
from django.shortcuts import render
from datetime import timedelta
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.utils import timezone
from .forms import AppricationForm
from .models import ApproveUser
from users.models import User
import string
import random
import math
import secrets
Token_LENGTH = 30 # ランダムURLを作成するためのTOKEN
PASSWORD_LENGTH = 12 # パスワードの長さ
EXPIRE_SECONDS = 60 #ランダムURLの有効期限(秒)
def pass_gen(size=PASSWORD_LENGTH):
"""パスワードジェネレータ"""
chars = string.ascii_uppercase + string.ascii_lowercase + string.digits
return "".join(secrets.choice(chars) for x in range(size))
def create_account(username, email):
"""アカウント作成"""
# passoword生成
def_pass = pass_gen(PASSWORD_LENGTH)
try:
# すでにパスワードがある場合
us = User.objects.get(email=email)
# password設定
us.set_password(def_pass)
us.password_changed = False # Falseであれば次回ログイン時にパスワード変更画面に強制リダイレクト
# 権限付与
us.is_active = True
us.is_staff = True
# save
us.save()
return def_pass
except:
# Userの作成
us = User.objects.create_user(username=username, email=email, password=def_pass)
# 権限付与
us.is_active = True
us.is_staff = True
# save
us.save()
return def_pass
def get_random_chars(char_num=Token_LENGTH):
"""ランダムな文字列を作る"""
return "".join([random.choice(string.ascii_letters + string.digits) for i in range(char_num)])
def create(request):
"""アカウント申請画面"""
if request.method == 'POST':
# POSTであればフォームからデータを受け取る
form = AppricationForm(request.POST)
if form.is_valid():
obj = form.save(commit=False)
try:
# アカウントがすでにあるかどうか
# ある場合も受け付けたように処理する(悪意のあるユーザにアカウント有無を判別されないようにする)
queryset = User.objects.get(email=form.cleaned_data['email'])
email = queryset.email
is_active = queryset.is_active
is_staff = queryset.is_staff
if is_active == True and is_staff == True:
# すでに有効なアカウントがある場合
message = """
titte:アカウント申請
message:有効なアカウントが存在します。
"""
print(message)
except:
# 有効なアカウントがないので処理を続ける
pass
# 期限付きURLの作成
Timestamp_signer = TimestampSigner()
context = {}
context["expired_seconds"] = EXPIRE_SECONDS # URLの有効期限
token = get_random_chars()
token_signed = Timestamp_signer.sign(token) # ランダムURLの生成
context["token_signed"] = token_signed
obj.application_date = timezone.now() # 申請日時
obj.first_approver_token = token
obj.first_approver_url = f"http://127.0.0.1:8000/create_user/{token_signed}"
obj.first_approver_expired_seconds = EXPIRE_SECONDS
first_approver_name = form.cleaned_data['first_approver_name']
first_approver_email = form.cleaned_data['first_approver_email']
username = form.cleaned_data['username']
email = form.cleaned_data['email']
message =f"""
title:アカウント1次承認依頼
message:{first_approver_name} ({first_approver_email})さん
{username} ({email})さんよりアカウント承認依頼が届いています。
以下のURLより承認を行ってください。
{obj.first_approver_url}
以上
"""
print(message)
context = {
'title': 'アカウント申請完了',
'message': 'アカウントを申請しました',
}
return render(request, 'create_user/create_request_done.html', context)
else:
# データが不正であればフォームを再描画する
context = {'form': form}
return render(request, 'create_user/create.html', context)
else:
# RequestがGET
context = {
'title': 'アカウント申請',
'form': AppricationForm(),
}
return render(request, 'create_user/create.html', context)
def create_request_done(request):
"""アカウント申請完了画面"""
context = {
'title': 'アカウント申請完了',
'message': 'アカウントを申請しました',
}
return render(request, 'create_user/create_request_done.html', context)
手順4:htmlの作成
描画するHTMLがないので作成する。
/mysite/create_user
配下にtemplates
フォルダを作り、さらにその配下にcreate_user
フォルダを作成する。
/mysite/create_user/templates/create_user
配下に以下の二つの新規ファイルを作成する
create_request_done.html
create.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
</head>
<body>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</body>
</html>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
</head>
<body>
<form action="{% url 'create_user:create' %}"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>
手順5:urls.py
ViewとURLを紐づける。
/mysite/create_user
配下にurls.py
を作成する。
from django.urls import path
from . import views
app_name = "create_user" # URLパターンを逆引きできるように名前を付ける
urlpatterns = [
path('create/', views.create, name = 'create'), # アカウント申請画面
path('create/create_request_done', views.create_request_done, name="create_request_done"),
]
手順5:mysiteのurls.py
mysite
配下のurls.py
を更新し、create_user
配下のurls.py
を読み込むようにする。
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('create_user/', include('create_user.urls'))
]
手順6:動作の確認(途中まで)
この時点で、申請のページまでは作成できたので動作を確認する。
python manage.py runserver
でサーバを立ち上げ、
http://127.0.0.1:8000/create_user/create/
にアクセスする。
CSSを設定していないので簡素だがフォームが表示される。
一方、申請した内容は1次承認者に送られるが、ここではPrint文で出力しているのでそれを確認する。
実際にはここでメールを1次承認者に送信する。
申請まではできたので、次はこのランダムURLから1次承認者が承認し、アカウントを払い出す部分を追加していく。
手順7:views.pyの更新
ランダムURLにアクセスしたさいのViewを作成していく
from django.shortcuts import render
from datetime import timedelta
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.utils import timezone
from django.views.generic.base import TemplateView
from .forms import AppricationForm, FirstApproveForm
from .models import ApproveUser
from users.models import User
import string
import random
import math
import secrets
Token_LENGTH = 30 # ランダムURLを作成するためのTOKEN
PASSWORD_LENGTH = 12 # パスワードの長さ
EXPIRE_SECONDS = 60 #ランダムURLの有効期限(秒)
def pass_gen(size=PASSWORD_LENGTH):
"""パスワードジェネレータ"""
chars = string.ascii_uppercase + string.ascii_lowercase + string.digits
return "".join(secrets.choice(chars) for x in range(size))
def create_account(username, email):
"""アカウント作成"""
# passoword生成
def_pass = pass_gen(PASSWORD_LENGTH)
try:
# すでにパスワードがある場合
us = User.objects.get(email=email)
# password設定
us.set_password(def_pass)
us.password_changed = False # Falseであれば次回ログイン時にパスワード変更画面に強制リダイレクト
# 権限付与
us.is_active = True
us.is_staff = True
# save
us.save()
return def_pass
except:
# Userの作成
us = User.objects.create_user(username=username, email=email, password=def_pass)
# 権限付与
us.is_active = True
us.is_staff = True
# save
us.save()
return def_pass
def get_random_chars(char_num=Token_LENGTH):
"""ランダムな文字列を作る"""
return "".join([random.choice(string.ascii_letters + string.digits) for i in range(char_num)])
def create(request):
"""アカウント申請画面"""
if request.method == 'POST':
# POSTであればフォームからデータを受け取る
form = AppricationForm(request.POST)
if form.is_valid():
obj = form.save(commit=False)
try:
# アカウントがすでにあるかどうか
# ある場合も受け付けたように処理する(悪意のあるユーザにアカウント有無を判別されないようにする)
queryset = User.objects.get(email=form.cleaned_data['email'])
email = queryset.email
is_active = queryset.is_active
is_staff = queryset.is_staff
if is_active == True and is_staff == True:
# すでに有効なアカウントがある場合
message = """
titte:アカウント申請
message:有効なアカウントが存在します。
"""
print(message)
except:
# 有効なアカウントがないので処理を続ける
pass
# 期限付きURLの作成
Timestamp_signer = TimestampSigner()
context = {}
context["expired_seconds"] = EXPIRE_SECONDS # URLの有効期限
token = get_random_chars()
token_signed = Timestamp_signer.sign(token) # ランダムURLの生成
context["token_signed"] = token_signed
obj.application_date = timezone.now() # 申請日時
obj.first_approver_token = token
obj.first_approver_url = f"http://127.0.0.1:8000/create_user/{token_signed}"
obj.first_approver_expired_seconds = EXPIRE_SECONDS
first_approver_name = form.cleaned_data['first_approver_name']
first_approver_email = form.cleaned_data['first_approver_email']
username = form.cleaned_data['username']
email = form.cleaned_data['email']
message =f"""
title:アカウント1次承認依頼
message:{first_approver_name} ({first_approver_email})さん
{username} ({email})さんよりアカウント承認依頼が届いています。
以下のURLより承認を行ってください。
{obj.first_approver_url}
以上
"""
print(message)
context = {
'title': 'アカウント申請完了',
'message': 'アカウントを申請しました',
}
return render(request, 'create_user/create_request_done.html', context)
else:
# データが不正であればフォームを再描画する
context = {'form': form}
return render(request, 'create_user/create.html', context)
else:
# RequestがGET
context = {
'title': 'アカウント申請',
'form': AppricationForm(),
}
return render(request, 'create_user/create.html', context)
def create_request_done(request):
"""アカウント申請完了画面"""
context = {
'title': 'アカウント申請完了',
'message': 'アカウントを申請しました',
}
return render(request, 'create_user/create_request_done.html', context)
class ApproveView(TemplateView):
template_name = 'create_users/approve.html'
def get(self, request, token):
if request.method == "GET":
# GET = ランダムURLにアクセスしてきた際
timestamp_signer = TimestampSigner()
if token:
try:
# TOKENが有効なら
unsigned_token = timestamp_signer.unsign(token, max_age=timedelta(seconds=EXPIRE_SECONDS))
user = ApproveUser.objects.get(first_approver_token=unsigned_token)
if user.first_approver_time == None:
# TOKEN未使用ならば
initial_dict = {
'username': user.username,
'email': user.email,
}
params = {
'title': '承認ページ',
'form': FirstApproveForm(initial=initial_dict)
}
return render(request, 'create_user/approve.html', params)
else:
params = {
'title': '承認ページ',
'message': 'すでに承認あるいは否認済みです'
}
return render(request, 'create_user/approve_done.html', params)
except SignatureExpired:
# TOKENが期限切れならば
params = {
'title': '承認ページ',
'message': '有効期限切れにより自動的に否認されました'
}
return render(request, 'create_user/approve_done.html', params)
except BadSignature:
# TOKENが不正ならば(URLが間違っていたら)
params = {
'title': '承認ページ',
'message': 'URLが正しくありません'
}
return render(request, 'create_user/approve_done.html', params)
else:
# TOKENがない
params = {
'title': '承認ページ',
'message': 'トークンが見つかりません'
}
return render(request, 'create_user/approve_done.html', params)
def post(self, request, token):
if request.method == "POST":
# 承認・否認をする際
timestamp_signer = TimestampSigner()
unsigned_token = timestamp_signer.unsign(token, max_age=timedelta(seconds=EXPIRE_SECONDS))
try:
obj = ApproveUser.objects.get(first_approver_token=unsigned_token)
form = FirstApproveForm(request.POST)
if form.is_valid():
# パラメータ更新
obj.first_approver_time = timezone.now()
obj.first_approver_result = form.cleaned_data['first_approver_result']
obj.save()
# アカウント作成
username = obj.username
email = obj.email
password = create_account(username, email)
message = """
title:アカウント払い出し完了
message:{username} ({email})さん
アカウントの払い出しが完了しました。
id : {email}
password : {password}
以上
"""
print(message)
params = {
'title': '承認ページ',
'message': '処理が完了しました'
}
return render(request, 'create_user/approve_done.html', params)
else:
params = {
'title': '承認ページ',
'message': 'エラーが発生しました'
}
return render(request, 'create_user/approve_done.html', params)
except:
params = {
'title': '承認ページ',
'message': 'エラーが発生しました'
}
return render(request, 'create_user/approve_done.html', params)
手順8:承認用htmlの作成
/mysite/create_user/templates/create_user
配下に以下の新規ファイルを作成する
approve_done.html
approve.html
actionの部分が先ほどと異なる
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
</head>
<body>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</body>
</html>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
</head>
<body>
<form action="" 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>
手順9:urls.pyの更新
ViewとランダムURLを紐づける。
from django.urls import path
from . import views
app_name = "create_user" # URLパターンを逆引きできるように名前を付ける
urlpatterns = [
path('create/', views.create, name = 'create'), # アカウント申請画面
path('create/create_request_done', views.create_request_done, name="create_request_done"),
path('<str:token>/', views.ApproveView.as_view(), name = 'ApproveView'), # 追加
]
手順10:動作確認
アクセスして必要事項を入力
http://127.0.0.1:8000/create_user/create/
記載のURLにアクセスする。
1次承認結果以外の欄はReadonly属性を指定しているので文字の変更はできない。
コンソールにはPrint文でアカウントの払い出し完了が出力されている
手順11:admin.py
管理画面で申請履歴を確認できるようにするには以下の手順を行う。
管理画面で見えなくていい場合は実施する必要はない。
なおアカウント申請履歴=重要な履歴として、アカウント申請履歴データは削除させないものとする。
from django.contrib import admin
from django.utils.translation import gettext, gettext_lazy as _
from .models import ApproveUser
@admin.register(ApproveUser)
class ApproveUserAdmin(admin.ModelAdmin):
fieldsets = (
(None, {'fields': ('username',)}),
(_('Personal info'), {'fields': ('email', 'application_date',)}),
(_('First Approver info'), {'fields': ('first_approver_name', 'first_approver_email', 'first_approver_url', 'first_approver_token', 'first_approver_expired_seconds',)}),
(_('First Approver Result'), {'fields': ('first_approver_time', 'first_approver_result',)}),
)
list_display = ('username', 'email', 'application_date', 'first_approver_name', 'first_approver_email', 'first_approver_time', 'first_approver_result',)
search_field = ('username', 'email',)
ordering = ('-application_date',)
def get_actions(self, request):
# 一覧画面から削除できないようにする
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
def has_delete_permission(self, request, obj=None):
# 詳細画面から削除できないようにする
return False
http://127.0.0.1:8000/admin/ にアクセスする。
CREATE_USER アカウント申請ユーザ が追加されているのが確認できる。
クリックすると申請一覧が確認できる。(削除できないことも確認)
USERNAMEをクリックして詳細を確認できる。(詳細でも削除できないことも確認)
今後の改善点
class FirstApproveForm(forms.ModelForm):
...
"""1次承認フォーム"""
# 1次承認時に変更できなくさせる(表示のみ)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].widget.attrs['readonly'] = 'readonly'
self.fields['email'].widget.attrs['readonly'] = 'readonly'
self.fields['first_approver_name'].widget.attrs['readonly'] = 'readonly'
self.fields['first_approver_email'].widget.attrs['readonly'] = 'readonly'
...
の部分を
class FirstApproveForm(forms.ModelForm):
...
"""1次承認フォーム"""
# 1次承認時に変更できなくさせる(表示のみ)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].widget.attrs['disabled'] = 'disabled'
self.fields['email'].widget.attrs['disabled'] = 'disabled'
self.fields['first_approver_name'].widget.attrs['disabled'] = 'disabled'
self.fields['first_approver_email'].widget.attrs['disabled'] = 'disabled'
...
とすることで、承認者に選択できない個所を明確にすることができる。(グレーアウトできる)
しかしながらその状態ではい
いいえ
を選択するとエラーになってしまったことからdisabled
は不採用とした。
選択肢・ボタンも冴えない。CSS、Bootstrapなどを使用することで、雰囲気を変えることができる。
参考
Django ランダムなURLをUUIDで作成する方法
Djangoの標準機能だけで期限つきリンクを作ってみる。
Django 第1回:Django Custom User Model の作成
Django 第2回:Django 初回ログイン時にパスワード変更を強制する
Django 第3回:Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する
Django 第4回:Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する
Django 第5回:Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除