Djangoはログイン機能が付いてきますが、
二要素認証の機能は付いてないので、付けてみました。
##Djangoとは
公式ドキュメントを見て下さい。
Django ドキュメント
Wikipediaもどうぞ。
Django - Wikipedia
Wikipediaに「Djangoは、Pythonで実装されたWebアプリケーションフレームワーク。」と書いている通りです。
RubyでいうRails、JavaでいうSpring、PHPでいうCakePHPみたいなものです。
##二要素認証とは
二要素認証については、Wikipediaでも見て下さい。
多要素認証 - Wikipedia
ただの「ID・パスワード」の認証だけだと「知る要素」となりますが、
Google認証システムを使うと「持つ要素」もカバーできる、という事です。
##Google認証システムとは
この辺りが参考になるかなと。
Google 2段階認証プロセス
ちなみに、以下からインストールできます。
App Store | Google Play
実装の仕組みを理解するのは、ここが良かったです。
2要素認証・2段階認証を実装する際のポイント
実際のコードは、ここが参考になりました。
shinsaka/googleauthenticator_demo - Github
##実装していく前に、処理フローの確認
まず、ユーザ登録しないと意味ないので、ユーザ登録機能を付けます。
(Djangoにデフォルトでは付いてませんが、簡単に組み込めます。)
(python manage.py createsuperuser
でユーザは作れますが、ユーザ登録後にQRコードを表示させたいので、今回は使いません。)
ユーザ登録については、こちらを参考にしました。
(Djangoの事がたくさん書かれててとても助かりました!)
https://torina.top/detail/273/
上記の手順に従うと、以下のようなフローになります。
1. ユーザ登録画面から、ユーザ情報を入力
2. 入力完了し送信すると仮登録状態となり、入力されたメールアドレス宛にURLを送信する
3. URLにアクセスすると本登録が完了する
この(3)に、Google認証システムのQRコードを表示させます。
そして、ログイン画面でトークンを入力させ、一致したら、ログイン可能という事にします。
という事で、以下になります。(1~2は省略)
3. URLにアクセスすると本登録が完了し、QRコードを表示
4. QRコードをGoogle認証システムに登録
5. ログイン画面で「ユーザID」「パスワード」「トークン」を入力しログイン試行
##バージョン情報
$ python -V
Python 3.6.0
$ pip list | grep Django
Django (2.0.5)
##実装していく
###0. 適当にアプリ作っておく
事前準備として、適当にアプリを作っておく。
あと、必要なパッケージをpipで入れておく。(多分この2つで良いはず....)
- パッケージ
pip install qrcode
pip install onetimepass
- アプリ
- 適当な名前でいいので、作成
- プロジェクト
$ pip install qrcode
$ pip install onetimepass
$ python manage.py startapp customLogin
...
INSTALLED_APPS = [
...
'customLogin',
...
]
...
TEMPLATES = [
{
...
'DIRS': [os.path.join(BASE_DIR, 'Project/templates')],
...
},
]
...
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, "static")
...
DEFAULT_FROM_EMAIL = 'hogehoge@example.com'
EMAIL_HOST = 'localhost'
EMAIL_PORT = 25
...
from django.conf.urls import url, include
urlpatterns = [
...
url(r'^login/', include('customLogin.urls')),
url(r'', admin.site.urls),
...
]
{% load i18n static %}
<!DOCTYPE html>{% get_current_language as LANGUAGE_CODE %}
<html lang="{{ LANGUAGE_CODE|default:"en-us" }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
{% block extra_css %}{% endblock %}
<title>{% block title %}Dummy{% endblock %}</title>
</head>
<body>
<div class="container">
{% block content %}
{{ content }}
{% endblock %}
</div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T" crossorigin="anonymous"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
###1. ユーザ登録画面から、ユーザ情報を入力
ユーザ登録画面を作って、GUIでユーザ登録できるようにする。
(CUIだとpython manage.py createsuperuser
で出来るが、GUIで完結する方が利便性高いので)
また、入力情報でユーザを仮登録し、入力されたメールアドレス宛にメールを送信する。
- アプリ
-
urls.py
: ユーザ登録画面のURLを追加 -
forms.py
: ユーザ登録画面のフォームを追加 -
views.py
: ユーザ登録画面の処理を追加 -
templates/user_create.html
: ユーザ登録画面の作成 -
templates/mail/subject.txt
: 送信メールの件名作成 -
templates/mail/message.txt
: 送信メールの本文作成
-
from django.urls import path
from . import views
app_name = 'customLogin'
urlpatterns = [
...
path(r'user_create/', views.UserCreate.as_view(), name='user_create'),
...
]
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm
User = get_user_model()
class CustomUserCreateForm(UserCreationForm):
class Meta:
model = User
fields = ('username', 'email')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = 'form-control'
field.widget.attrs['placeholder'] = field.label
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.core.signing import dumps
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.template.loader import get_template
from django.views import generic
from .forms import CustomUserCreateForm
User = get_user_model()
class UserCreate(generic.CreateView):
"""ユーザ登録"""
template_name = 'customLogin/user_create.html'
form_class = CustomUserCreateForm
def get(self, request, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect('/')
return super().get(request, **kwargs)
def form_valid(self, form):
# 仮登録
user = form.save(commit=False)
user.is_active = False
user.save()
# メール送信
current_site = get_current_site(self.request)
domain = current_site.domain
context = {
'protocol': 'https' if self.request.is_secure() else 'http',
'domain': domain,
'token': dumps(user.pk),
'user': user
}
subject_template = get_template('customLogin/mail/subject.txt')
message_template = get_template('customLogin/mail/message.txt')
subject = subject_template.render(context)
message = message_template.render(context)
user.email_user(subject, message)
return redirect('customLogin:user_create_done')
{% extends "base.html" %}
{% load i18n admin_static %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}{% endblock %}
{% block breadcrumbs %}{% endblock %}¥
{% block extrastyle %}
{{ block.super }}
{% endblock %}
{% block content %}
<form action="" method="post">
{{ form.non_field_errors }}
{% for field in form %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }} {{ field }}
</div>
{% endfor %}
{% csrf_token %}
<div class="submit-row">
<input type="submit" value="{% trans 'Sign up' %}" />
</div>
</form>
{% endblock %}
仮登録完了メール。(改行などの制御文字入れたらNG。一行で完結させる。)
{{ user.username }} 様,
仮登録が完了したから、以下のURLにアクセスして本登録を完了させてね。
{{ protocol }}://{{ domain }}{% url 'customLogin:user_create_complete' token %}
###2. 入力完了し送信すると仮登録状態となり、入力されたメールアドレス宛にURLを送信する
と言いつつ、(1)で仮登録とメール送信まで終わっているので、ここでは「仮登録完了したよ〜」って表示するだけ。
- アプリ
-
urls.py
: 仮登録完了画面のURLを追加 -
views.py
: 仮登録完了画面の処理を追加 -
templates/user_create_done.html
: 仮登録完了画面の作成
-
from django.urls import path
from . import views
app_name = 'customLogin'
urlpatterns = [
...
path(r'user_create/done', views.UserCreateDone.as_view(), name='user_create_done'),
...
]
from django.contrib.auth import get_user_model
from django.http import HttpResponseRedirect
from django.views import generic
User = get_user_model()
class UserCreateDone(generic.TemplateView):
"""仮登録完了"""
template_name = 'customLogin/user_create_done.html'
def get(self, request, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect('/')
return super().get(request, **kwargs)
{% extends "base.html" %}
{% block content %}
<div id="content-main">
<h1>仮登録完了</h1>
<p>メール送信したよ。URLにアクセスして本登録を完了させてね。</p>
</div>
{% endblock %}
###3. URLにアクセスすると本登録が完了し、QRコードを表示
メールに記載されているURLの遷移先を作成。
ここにアクセスする事で、本登録を完了させる。
また、Google認証システムのQRコードを表示して、登録させる。
- アプリ
-
urls.py
: 本登録完了画面のURLを追加 -
utils.py
: Google認証システム用にモジュール作成 -
views.py
: 本登録完了画面の処理を追加 -
templates/user_create_complete.html
: ユーザ登録画面の作成
-
from django.urls import path
from . import views
app_name = 'customLogin'
urlpatterns = [
...
path(r'user_create/complete/<token>/', views.UserCreateComplete.as_view(), name='user_create_complete'),
...
]
import base64
import qrcode
from io import BytesIO
def get_secret(user):
"""
本当は秘密鍵を設定するんだろうけど、面倒なので、「メールアドレス」と「登録日時」をくっつけたモノを使う。一意になればとりあえずいいかなと。。。
"""
return base64.b32encode(
(user.email + str(user.date_joined)).encode()
).decode()
def get_auth_url(email, secret, issuer='ProjectName'):
"""
下に書いてあるURLフォーマットで設定する必要がある。
最初の「isr」「uid」は、Google認証システムのアプリ上にも表示されるから、プロジェクト名とメールアドレスを突っ込むのが無難だと思う。
"""
url_template = 'otpauth://totp/{isr}:{uid}?secret={secret}&issuer={isr}'
return url_template.format(
uid=email,
secret=secret,
isr=issuer)
def get_image_b64(url):
qr = qrcode.make(url)
img = BytesIO()
qr.save(img)
return base64.b64encode(img.getvalue()).decode()
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.signing import BadSignature, SignatureExpired, loads
from django.http import HttpResponseBadRequest
from django.http import HttpResponseRedirect
from django.views import generic
from . import utils
User = get_user_model()
class UserCreateComplete(generic.TemplateView):
"""本登録完了"""
template_name = 'customLogin/user_create_complete.html'
timeout_seconds = getattr(settings, 'ACTIVATION_TIMEOUT_SECONDS', 60 * 60 * 24) # デフォルトでは1日以内
def get(self, request, **kwargs):
"""tokenが正しければ本登録."""
if request.user.is_authenticated:
return HttpResponseRedirect('/')
token = kwargs.get('token')
try:
user_pk = loads(token, max_age=self.timeout_seconds)
# 期限切れ
except SignatureExpired:
return HttpResponseBadRequest()
# tokenが間違っている
except BadSignature:
return HttpResponseBadRequest()
# tokenは問題なし
try:
user = User.objects.get(pk=user_pk)
except User.DoenNotExist:
return HttpResponseBadRequest()
if not user.is_active:
# 問題なければ本登録とする
user.is_active = True
user.is_staff = True
user.is_superuser = True
user.save()
# QRコード生成
request.session["img"] = utils.get_image_b64(utils.get_auth_url(user.email, utils.get_secret(user)))
return super().get(request, **kwargs)
return HttpResponseBadRequest()
{% extends "base.html" %}
{% block content %}
<div id="content-main">
<h1>本登録完了</h1>
<p>「Google認証システム」で、以下のQRコードを読み込んで下さい。</p>
<p>(※このページを閉じたら再度QRコードを表示する事が出来ないので注意してね。)</p>
<p><img src="data:image/png;base64,{{ request.session.img }}" alt="qrcode" width="200"/></p>
</div>
{% endblock %}
###4. QRコードをGoogle認証システムに登録
この辺参考にして下さい。
二段階認証アプリ『Google認証システム』の使い方
アプリ開いて、右下の赤い丸ボタンを押下して、「バーコードをスキャン」を押下して、QRコードを読み込んだらOKです。
###5. ログイン画面で「ユーザID」「パスワード」「トークン」を入力しログイン試行
ログイン画面をカスタムして、「トークン」の入力エリアを追加します。
そして、ログイン試行されたら、トークンを照合します。
- アプリ
-
urls.py
: ログイン画面のURLを追加 -
forms.py
: ログイン画面のフォームを追加 -
utils.py
: Google認証システム用にモジュール作成 -
views.py
: ログイン画面の処理を追加 -
templates/login.html
: ログイン画面の作成
-
from django.urls import path
from . import views
app_name = 'customLogin'
urlpatterns = [
...
path(r'', views.CustomLoginView.as_view(), name='login'),
...
]
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import AuthenticationForm
from . import utils
class CustomLoginForm(AuthenticationForm):
"""カスタムログインフォーム"""
token = forms.CharField(max_length=254, label="Google Authenticator OTP")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = 'form-control'
field.widget.attrs['placeholder'] = field.label
def confirm_login_allowed(self, user):
"""二要素認証チェック"""
if utils.get_token(user) != self.cleaned_data.get('token'):
raise forms.ValidationError(
"'Google Authenticator OTP' is invalid."
)
super().confirm_login_allowed(user)
import onetimepass
def get_token(user):
return str(onetimepass.get_totp(get_secret(user)))
from django.contrib.auth.views import LoginView
from django.http import HttpResponseRedirect
from .forms import CustomLoginForm
class CustomLoginView(LoginView):
"""ログイン"""
form_class = CustomLoginForm
template_name = 'customLogin/login.html'
def get(self, request, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect('/')
return super().get(request, **kwargs)
{% extends "base.html" %}
{% load i18n admin_static %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}{% endblock %}
{% block breadcrumbs %}{% endblock %}¥
{% block extrastyle %}
{{ block.super }}
{% endblock %}
{% block content %}
{% if form.errors and not form.non_field_errors %}
<p class="errornote">
{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}
</p>
{% endif %}
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p class="errornote">
{{ error }}
</p>
{% endfor %}
{% endif %}
<div id="content-main">
<h1>Sign in.</h1>
<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }} {{ field }}
</div>
{% endfor %}
{% csrf_token %}
<div class="submit-row">
<input type="submit" value="{% trans 'Sign in' %}" />
</div>
</form>
<hr>
<div class="card col-md-6">
<div class="card-body">
<a href="{% url 'customLogin:user_create' %}" class="btn btn-success btn-lg btn-block" >Sign up.</a>
</div>
</div>
<script type="text/javascript">
document.getElementById('id_username').focus()
</script>
</div>
{% endblock %}
##確認
さて、ここまで出来たらブラウザで確認します。
設定してるCSSとJSが野暮ったいので、見た目が酷いですが、そこは好きにいじって下さい。
$ python manage.py migrate
$ python manage.py runserver
- ログイン画面
- [Sign up]を押下して、ユーザ登録画面に遷移
- ユーザ情報を適当に入力
- 仮登録完了画面が表示されて、
- メールが届く
- URLに遷移すると、本登録が完了し、QRコードが表示される
- ログイン画面に遷移して、ログイン試行
- 不正なトークンの場合は、エラー表示
- 正常なトークンの場合は、ログインに成功して、中に入れる
##最後に
https://torina.top/detail/273/ と https://github.com/shinsaka/googleauthenticator_demo/ を組み合わせただけですけど、参考になれば。