LoginSignup
19
27

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-05-29

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

  • ログイン画面

スクリーンショット 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/ を組み合わせただけですけど、参考になれば。

19
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
27