Edited at

Djangoで、二要素認証を、Google認証システムを使って実装。

More than 1 year has passed since last update.

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」「パスワード」「トークン」を入力しログイン試行


バージョン情報


terminal

$ python -V

Python 3.6.0

$ pip list | grep Django
Django (2.0.5)



実装していく


0. 適当にアプリ作っておく

事前準備として、適当にアプリを作っておく。

あと、必要なパッケージをpipで入れておく。(多分この2つで良いはず....)


  • パッケージ


    • pip install qrcode

    • pip install onetimepass



  • アプリ


    • 適当な名前でいいので、作成



  • プロジェクト



    • settings.py: アプリを登録


    • settings.py: templatesの配置をプロジェクトの下にしておく


    • settings.py: staticの追加(ref)


    • settings.py: メール送信できるように設定追加(ref)


    • urls.py: customLoginアプリのURLを追加


    • templates/base.html: ベースとなるHTMLを作成




terminal

$ pip install qrcode

$ pip install onetimepass


terminal

$ python manage.py startapp customLogin



Project/settings.py

...

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
...


Project/urls.py

from django.conf.urls import url, include

urlpatterns = [
...
url(r'^login/', include('customLogin.urls')),
url(r'', admin.site.urls),
...
]


Project/templates/base.html

{% 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: 送信メールの本文作成




customLogin/urls.py

from django.urls import path

from . import views

app_name = 'customLogin'
urlpatterns = [
...
path(r'user_create/', views.UserCreate.as_view(), name='user_create'),
...
]



customLogin/forms.py

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



customLogin/views.py

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')



Project/templates/customLogin/user_create.html

{% 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 %}



Project/templates/customLogin/mail/subject.txt

仮登録完了メール。(改行などの制御文字入れたらNG。一行で完結させる。)



Project/templates/customLogin/mail/message.txt

{{ user.username }} 様,

仮登録が完了したから、以下のURLにアクセスして本登録を完了させてね。
{{ protocol }}://{{ domain }}{% url 'customLogin:user_create_complete' token %}



2. 入力完了し送信すると仮登録状態となり、入力されたメールアドレス宛にURLを送信する

と言いつつ、(1)で仮登録とメール送信まで終わっているので、ここでは「仮登録完了したよ〜」って表示するだけ。


  • アプリ



    • urls.py: 仮登録完了画面のURLを追加


    • views.py: 仮登録完了画面の処理を追加


    • templates/user_create_done.html: 仮登録完了画面の作成




customLogin/urls.py

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'),
...
]



customLogin/views.py

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)



Project/templates/customLogin/user_create_done.html

{% 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: ユーザ登録画面の作成




customLogin/urls.py

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'),
...
]



customLogin/utils.py

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()



customLogin/views.py

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()



Project/templates/customLogin/user_create_complete.html

{% 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: ログイン画面の作成




customLogin/urls.py

from django.urls import path

from . import views

app_name = 'customLogin'
urlpatterns = [
...
path(r'', views.CustomLoginView.as_view(), name='login'),
...
]



customLogin/forms.py

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)



customLogin/utils.py

import onetimepass

def get_token(user):
return str(onetimepass.get_totp(get_secret(user)))



customLogin/views.py

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)



Project/templates/customLogin/login.html

{% 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が野暮ったいので、見た目が酷いですが、そこは好きにいじって下さい。


terminal

$ python manage.py migrate

$ python manage.py runserver

http://localhost:8000/login/


  • ログイン画面

スクリーンショット 2018-05-29 15.16.41.png


  • [Sign up]を押下して、ユーザ登録画面に遷移

  • ユーザ情報を適当に入力

スクリーンショット 2018-05-29 15.17.39.png


  • 仮登録完了画面が表示されて、

スクリーンショット 2018-05-29 15.17.56.png


  • メールが届く

スクリーンショット 2018-05-29 15.18.21.png


  • URLに遷移すると、本登録が完了し、QRコードが表示される

スクリーンショット 2018-05-29 15.18.36.png


  • ログイン画面に遷移して、ログイン試行

  • 不正なトークンの場合は、エラー表示

スクリーンショット 2018-05-29 15.19.16.png


  • 正常なトークンの場合は、ログインに成功して、中に入れる

スクリーンショット 2018-05-29 15.22.46.png


最後に

https://torina.top/detail/273/https://github.com/shinsaka/googleauthenticator_demo/ を組み合わせただけですけど、参考になれば。