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

Djangoはログイン機能が付いてきますが、
二要素認証の機能は付いてないので、付けてみました。

##Djangoとは
公式ドキュメントを見て下さい。
[Django ドキュメント](https://docs.djangoproject.com/ja/2.0/)

Wikipediaもどうぞ。
[Django - Wikipedia](https://ja.wikipedia.org/wiki/Django)

Wikipediaに「Djangoは、Pythonで実装されたWebアプリケーションフレームワーク。」と書いている通りです。
RubyでいうRails、JavaでいうSpring、PHPでいうCakePHPみたいなものです。


##二要素認証とは
二要素認証については、Wikipediaでも見て下さい。
[多要素認証 - Wikipedia](https://ja.wikipedia.org/wiki/%E5%A4%9A%E8%A6%81%E7%B4%A0%E8%AA%8D%E8%A8%BC)

ただの「ID・パスワード」の認証だけだと「知る要素」となりますが、
Google認証システムを使うと「持つ要素」もカバーできる、という事です。


##Google認証システムとは
この辺りが参考になるかなと。
[Google 2段階認証プロセス](https://www.google.co.jp/intl/ja/landing/2step/)

ちなみに、以下からインストールできます。
[App Store](https://itunes.apple.com/jp/app/google-authenticator/id388497605) | [Google Play](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2)

実装の仕組みを理解するのは、ここが良かったです。
[2要素認証・2段階認証を実装する際のポイント](https://remotestance.com/blog/2940/)

実際のコードは、ここが参考になりました。
[shinsaka/googleauthenticator_demo - Github](https://github.com/shinsaka/googleauthenticator_demo/)


##実装していく前に、処理フローの確認

まず、ユーザ登録しないと意味ないので、**ユーザ登録機能**を付けます。
(Djangoにデフォルトでは付いてませんが、簡単に組み込めます。)
(`python manage.py createsuperuser`でユーザは作れますが、ユーザ登録後にQRコードを表示させたいので、今回は使いません。)

ユーザ登録については、こちらを参考にしました。
(Djangoの事がたくさん書かれててとても助かりました！)
[https://torina.top/detail/273/](https://torina.top/detail/273/)

上記の手順に従うと、以下のようなフローになります。
__1. ユーザ登録画面から、ユーザ情報を入力
2. 入力完了し送信すると仮登録状態となり、入力されたメールアドレス宛にURLを送信する
3. URLにアクセスすると本登録が完了する__

この（3）に、**Google認証システムのQRコードを表示**させます。
そして、ログイン画面でトークンを入力させ、一致したら、ログイン可能という事にします。

という事で、以下になります。(1~2は省略)
__3. URLにアクセスすると本登録が完了し、QRコードを表示
4. QRコードをGoogle認証システムに登録
5. ログイン画面で「ユーザID」「パスワード」「トークン」を入力しログイン試行__


##バージョン情報

```sh:terminal
$ python -V
Python 3.6.0

$ pip list | grep Django
Django (2.0.5)
```


##実装していく

###0. 適当にアプリ作っておく
事前準備として、適当にアプリを作っておく。
あと、必要なパッケージをpipで入れておく。(多分この２つで良いはず....）

- パッケージ
    - `pip install qrcode`
    - `pip install onetimepass`
- アプリ
    - 適当な名前でいいので、作成
- プロジェクト
    - `settings.py`: アプリを登録
    - `settings.py`: templatesの配置をプロジェクトの下にしておく
    - `settings.py`: staticの追加([ref](https://docs.djangoproject.com/en/2.0/howto/static-files/))
    - `settings.py`: メール送信できるように設定追加([ref](https://torina.top/detail/278/))
    - `urls.py`: customLoginアプリのURLを追加
    - `templates/base.html`: ベースとなるHTMLを作成


```sh:terminal
$ pip install qrcode
$ pip install onetimepass
```

```sh:terminal
$ python manage.py startapp customLogin
```

```py: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
...
```

```py:Project/urls.py
from django.conf.urls import url, include
urlpatterns = [
	...
    url(r'^login/', include('customLogin.urls')),
    url(r'', admin.site.urls),
	...
]
```

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

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

```py: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
```

```py: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')
```

```html: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 %}
```

```txt:Project/templates/customLogin/mail/subject.txt
仮登録完了メール。（改行などの制御文字入れたらNG。一行で完結させる。）
```

```txt: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`: 仮登録完了画面の作成

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

```py: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)
```

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

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

```py: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()
```

```py: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()

```

```html: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認証システム』の使い方](http://ico-support.com/archives/2034)

アプリ開いて、右下の赤い丸ボタンを押下して、「バーコードをスキャン」を押下して、QRコードを読み込んだらOKです。

###5. ログイン画面で「ユーザID」「パスワード」「トークン」を入力しログイン試行
ログイン画面をカスタムして、「トークン」の入力エリアを追加します。
そして、ログイン試行されたら、トークンを照合します。

- アプリ
	- `urls.py`: ログイン画面のURLを追加
	- `forms.py`: ログイン画面のフォームを追加
	- `utils.py`: Google認証システム用にモジュール作成
	- `views.py`: ログイン画面の処理を追加
	- `templates/login.html`: ログイン画面の作成

```py:customLogin/urls.py
from django.urls import path
from . import views

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

```py: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)
```

```py:customLogin/utils.py
import onetimepass


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

```py: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)
```

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

```sh:terminal
$ python manage.py migrate
$ python manage.py runserver
```

http://localhost:8000/login/

- ログイン画面

<img width="490" alt="スクリーンショット 2018-05-29 15.16.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/142821/603833a4-6925-7988-e0d4-ac692ba652f5.png">

- [Sign up]を押下して、ユーザ登録画面に遷移
- ユーザ情報を適当に入力

<img width="491" alt="スクリーンショット 2018-05-29 15.17.39.png" src="https://qiita-image-store.s3.amazonaws.com/0/142821/71023cec-70ba-b1d1-d1ca-569c3a691be8.png">

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

<img width="491" alt="スクリーンショット 2018-05-29 15.17.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/142821/fd43de71-eb7b-b959-0eb0-70941c622095.png">

- メールが届く

<img width="623" alt="スクリーンショット 2018-05-29 15.18.21.png" src="https://qiita-image-store.s3.amazonaws.com/0/142821/80322779-472f-6c5c-3199-c1964ba8e97c.png">

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

<img width="490" alt="スクリーンショット 2018-05-29 15.18.36.png" src="https://qiita-image-store.s3.amazonaws.com/0/142821/24609c23-d3c7-28d1-4c37-db76aaeff054.png">

- ログイン画面に遷移して、ログイン試行
- 不正なトークンの場合は、エラー表示

<img width="489" alt="スクリーンショット 2018-05-29 15.19.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/142821/be5dd35f-a96a-a0a3-42f4-ae9b0cffce06.png">

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

<img width="490" alt="スクリーンショット 2018-05-29 15.22.46.png" src="https://qiita-image-store.s3.amazonaws.com/0/142821/e691d0d7-a713-f1aa-ae86-bfc40a4f966e.png">



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