From b8b4534eb32fcbbeafb7f8534f49604823ee7804 Mon Sep 17 00:00:00 2001
From: Valentin Deniaud <vdeniaud@entrouvert.com>
Date: Tue, 2 Apr 2019 12:09:29 +0200
Subject: [PATCH] auth2_multifactor: add OATH authentication factor

Add a new module auth2_multifactor, which should contain all additionnal
authentication factors.
We make use of the authentication frontend/authenticator mechanisms, for
profile configuration and login blocks.  However, there is no associated
backends since we will work with users that are already authenticated.

This authentication factor requires the user to install a third party
app that implements TOTP, and lets them scan a QR Code, or manually
enter a key. Then they can enter a one-time password whenever required.
---
 MANIFEST.in                                   |   1 +
 setup.py                                      |   3 +
 src/authentic2/auth2_multifactor/__init__.py  |   5 +
 .../auth2_multifactor/auth_oath/__init__.py   |  15 ++
 .../auth_oath/app_settings.py                 |  23 ++
 .../auth_oath/authenticators.py               |  26 +++
 .../auth2_multifactor/auth_oath/forms.py      |  35 +++
 .../auth_oath/migrations/0001_initial.py      |  36 +++
 .../auth_oath/migrations/__init__.py          |   0
 .../auth2_multifactor/auth_oath/models.py     |  36 +++
 .../templates/auth_oath/disable_confirm.html  |  16 ++
 .../templates/auth_oath/generate_confirm.html |  10 +
 .../auth_oath/templates/auth_oath/qrcode.html |   3 +
 .../templates/auth_oath/totp_enable.html      |  30 +++
 .../templates/auth_oath/totp_form.html        |  23 ++
 .../templates/auth_oath/totp_profile.html     |  24 ++
 .../auth_oath/totp_recovery_display.html      |  22 ++
 .../auth_oath/totp_recovery_form.html         |   6 +
 .../auth2_multifactor/auth_oath/urls.py       |  22 ++
 .../auth2_multifactor/auth_oath/utils.py      |  21 ++
 .../auth2_multifactor/auth_oath/views.py      | 208 ++++++++++++++++++
 .../auth2_multifactor/decorators.py           |  11 +
 22 files changed, 576 insertions(+)
 create mode 100644 src/authentic2/auth2_multifactor/__init__.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/__init__.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/app_settings.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/authenticators.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/forms.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/migrations/__init__.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/models.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/disable_confirm.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/generate_confirm.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/qrcode.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_enable.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_display.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_form.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/urls.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/utils.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_oath/views.py
 create mode 100644 src/authentic2/auth2_multifactor/decorators.py

diff --git a/MANIFEST.in b/MANIFEST.in
index 5bfa6a28..c0fa1026 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -25,6 +25,7 @@ recursive-include src/authentic2_auth_fc/templates/authentic2_auth_fc *.html
 recursive-include src/authentic2_auth_saml/templates/authentic2_auth_saml *.html
 recursive-include src/authentic2_auth_oidc/templates/authentic2_auth_oidc *.html
 recursive-include src/authentic2_idp_oidc/templates/authentic2_idp_oidc *.html
+recursive-include src/authentic2/auth2_multifactor/auth_oath/templates *.html
 
 recursive-include src/authentic2/saml/fixtures *.json
 recursive-include src/authentic2/locale *.po *.mo
diff --git a/setup.py b/setup.py
index cfe9920c..e4c3b365 100755
--- a/setup.py
+++ b/setup.py
@@ -140,6 +140,8 @@ setup(name="authentic2",
           'xstatic-select2',
           'pillow',
           'tablib',
+          'qrcode',
+          'oath',
       ],
       zip_safe=False,
       classifiers=[
@@ -174,5 +176,6 @@ setup(name="authentic2",
               'authentic2-idp-oidc = authentic2_idp_oidc:Plugin',
               'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin',
               'authentic2-auth-fc = authentic2_auth_fc:Plugin',
+              'authentic2-auth-oath = authentic2.auth2_multifactor.auth_oath:Plugin',
           ],
       })
diff --git a/src/authentic2/auth2_multifactor/__init__.py b/src/authentic2/auth2_multifactor/__init__.py
new file mode 100644
index 00000000..9efc9308
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/__init__.py
@@ -0,0 +1,5 @@
+"""
+This package contains authentification modules that are not to be used as primary
+modes of authentification, but rather as supplementary authentication factors in
+order to guarantee higher levels of trust.
+"""
diff --git a/src/authentic2/auth2_multifactor/auth_oath/__init__.py b/src/authentic2/auth2_multifactor/auth_oath/__init__.py
new file mode 100644
index 00000000..023a7935
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/__init__.py
@@ -0,0 +1,15 @@
+class Plugin(object):
+    def get_before_urls(self):
+        from . import app_settings
+        from django.conf.urls import include, url
+        from authentic2.decorators import setting_enabled, required
+
+        return required(
+            setting_enabled('ENABLE', settings=app_settings),
+            [url(r'^accounts/authenticators/totp/', include(__name__ + '.urls'))])
+
+    def get_apps(self):
+        return [__name__]
+
+    def get_authenticators(self):
+        return ['authentic2.auth2_multifactor.auth_oath.authenticators.TOTPAuthenticator']
diff --git a/src/authentic2/auth2_multifactor/auth_oath/app_settings.py b/src/authentic2/auth2_multifactor/auth_oath/app_settings.py
new file mode 100644
index 00000000..2a02f589
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/app_settings.py
@@ -0,0 +1,23 @@
+class AppSettings(object):
+    __DEFAULTS = {
+        'ENABLE': True,
+        'LEVEL': 2,
+    }
+
+    def __init__(self, prefix):
+        self.prefix = prefix
+
+    def _setting(self, name, dflt):
+        from django.conf import settings
+        return getattr(settings, self.prefix+name, dflt)
+
+    def __getattr__(self, name):
+        if name not in self.__DEFAULTS:
+            raise AttributeError(name)
+        return self._setting(name, self.__DEFAULTS[name])
+
+
+import sys
+app_settings = AppSettings('A2_AUTH_OATH_')
+app_settings.__name__ = __name__
+sys.modules[__name__] = app_settings
diff --git a/src/authentic2/auth2_multifactor/auth_oath/authenticators.py b/src/authentic2/auth2_multifactor/auth_oath/authenticators.py
new file mode 100644
index 00000000..702a1b6d
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/authenticators.py
@@ -0,0 +1,26 @@
+from django.utils.translation import ugettext_lazy
+
+from . import app_settings
+
+
+class TOTPAuthenticator(object):
+    submit_name = 'oath-totp-submit'
+    auth_level = app_settings.LEVEL
+    _id = 'multifactor-totp'
+
+    def enabled(self):
+        return app_settings.ENABLE
+
+    def name(self):
+        return ugettext_lazy('One-time password')
+
+    def id(self):
+        return self._id
+
+    def login(self, request, *args, **kwargs):
+        from .views import totp_login
+        return totp_login(request, *args, **kwargs)
+
+    def profile(self, request, *args, **kwargs):
+        from .views import totp_profile
+        return totp_profile(request, *args, **kwargs)
diff --git a/src/authentic2/auth2_multifactor/auth_oath/forms.py b/src/authentic2/auth2_multifactor/auth_oath/forms.py
new file mode 100644
index 00000000..aed7f672
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/forms.py
@@ -0,0 +1,35 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext as _
+
+
+def validate_number(value):
+    try:
+        int(value)
+    except ValueError:
+        raise ValidationError(
+            _('Code must be a number.'),
+        )
+
+
+class EnableForm(forms.Form):
+    code = forms.CharField(
+        max_length=6, min_length=6, validators=[validate_number],
+        label=_('Code'),
+    )
+
+
+class LoginForm(EnableForm):
+    def __init__(self, *args, **kwargs):
+        super(LoginForm, self).__init__(*args, **kwargs)
+        self.fields['code'].help_text = \
+            _('In order to be granted access, you must enter the six-digit '
+              'code given by the authentificator app on your device.')
+
+
+class RecoveryForm(forms.Form):
+    code = forms.CharField(
+        max_length=10, min_length=10, label=_('Recovery code'),
+        help_text=_("Use one of your recovery code. Remember these are for one-time "
+                    "use, hence the code you choose won't be valid next time.")
+    )
diff --git a/src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py b/src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py
new file mode 100644
index 00000000..0f29d1a3
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-07-11 14:39
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('custom_user', '0016_auto_20180925_1107'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='OATHTOTPSecret',
+            fields=[
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='oath_totp_secret', serialize=False, to=settings.AUTH_USER_MODEL, verbose_name='utilisateur')),
+                ('key', models.CharField(max_length=40)),
+                ('drift', models.IntegerField(default=0)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='RecoveryCode',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('code', models.CharField(max_length=10)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='totp_recovery_codes', to=settings.AUTH_USER_MODEL, verbose_name='utilisateur')),
+            ],
+        ),
+    ]
diff --git a/src/authentic2/auth2_multifactor/auth_oath/migrations/__init__.py b/src/authentic2/auth2_multifactor/auth_oath/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/authentic2/auth2_multifactor/auth_oath/models.py b/src/authentic2/auth2_multifactor/auth_oath/models.py
new file mode 100644
index 00000000..a9c4cb35
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/models.py
@@ -0,0 +1,36 @@
+from base64 import b32encode
+from binascii import unhexlify
+from random import SystemRandom
+
+from django.conf import settings
+from django.db import models
+from django.utils.translation import ugettext as _
+
+
+class OATHTOTPSecret(models.Model):
+
+    user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True,
+                                related_name='oath_totp_secret', verbose_name=_('user'))
+    key = models.CharField(max_length=40)
+    drift = models.IntegerField(default=0)
+
+    def b32_encoded(self):
+        return b32encode(unhexlify(self.key)).decode('ascii')
+
+
+class RecoveryCodeManager(models.Manager):
+    # j'aurais bien aime avoir un truc qui permette de faire
+    # user.totp_recovery_codes.generate() mais j'ai pas reussi, relecteur si tu
+    # m'entends...
+    def generate_for_user(self, user):
+        for _ in range(10):
+            code = format(SystemRandom().getrandbits(40), '010x')
+            self.create(user=user, code=code)
+
+
+class RecoveryCode(models.Model):
+    objects = RecoveryCodeManager()
+
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='totp_recovery_codes',
+                             verbose_name=_('user'))
+    code = models.CharField(max_length=10)
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/disable_confirm.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/disable_confirm.html
new file mode 100644
index 00000000..28381f59
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/disable_confirm.html
@@ -0,0 +1,16 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block content %}
+  <form method="post">
+    {% csrf_token %}
+    <p>{% trans "Are you sure you want to disable the one-time password authenticator?" %}</p>
+    <input type="submit" value="{% trans "Confirm" %}">
+  </form>
+
+  {% blocktrans %}
+    Doing so will invalidate configuration on all devices, meaning that if
+    it is enabled again the codes they generate won't be valid anymore.
+    Recovery codes will also be discarded.
+  {% endblocktrans %}
+{% endblock %}
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/generate_confirm.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/generate_confirm.html
new file mode 100644
index 00000000..18200786
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/generate_confirm.html
@@ -0,0 +1,10 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block content %}
+  <form method="post">
+    {% csrf_token %}
+    <p>{% trans "Generating new recovery codes will invalidate old ones." %}</p>
+    <input type="submit" value="{% trans "Confirm" %}">
+  </form>
+{% endblock %}
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/qrcode.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/qrcode.html
new file mode 100644
index 00000000..7a52788c
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/qrcode.html
@@ -0,0 +1,3 @@
+<div style="text-align: center">
+  <img src="data:image/png;base64,{{ uri }}">
+</div>
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_enable.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_enable.html
new file mode 100644
index 00000000..aaaf5dfe
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_enable.html
@@ -0,0 +1,30 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block content %}
+<div>
+  <p>
+    {% blocktrans %}
+      Install any application capable of generating such codes (refer to the
+      documentation for recommendations). Then scan the QR code below, and
+      enter the six-digit code that the app will display in order to complete
+      the setup.
+    {% endblocktrans %}
+  </p>
+</div>
+<div>
+  {% include "auth_oath/qrcode.html" %}
+  <p>
+    {% trans "For manual setup when unable to scan the code, enter the following key : " %}
+    {{ secret }}
+  </p>
+</div>
+
+<div>
+  <form method="post" autocomplete="off" action="">
+    {% csrf_token %}
+    {{ form.as_p }}
+    <button class="submit-button" name="{{ submit_name }}">{% trans "Activate" %}</button>
+  </form>
+</div>
+{% endblock %}
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html
new file mode 100644
index 00000000..a287f23a
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html
@@ -0,0 +1,23 @@
+{% load i18n %}
+
+<h2>{% trans "Multi-factor authentication" %}</h1>
+
+<div>
+  <form method="post" autocomplete="off" action="">
+    {% csrf_token %}
+    {{ form.as_p }}
+    <button class="submit-button" name="{{ submit_name }}">{% trans "Submit" %}</button>
+    {% if cancel %}
+      <button class="cancel-button" name="cancel">{% trans 'Cancel' %}</button>
+    {% endif %}
+  </form>
+</div>
+
+{% if recovery_url %}
+  <div>
+    <p>
+      {% trans "If missing your device, " %}
+      <a href="{{ recovery_url }}">{% trans "click here to use a recovery code." %}</a>
+    </p>
+  </div>
+{% endif %}
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html
new file mode 100644
index 00000000..b1da2dc2
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html
@@ -0,0 +1,24 @@
+{% load i18n %}
+
+{% if enabled %}
+  <p>
+    {% trans "You can configure additional devices by scanning the QR code below." %}
+  </p>
+  {% include "auth_oath/qrcode.html" %}
+  <a href={% url 'totp-recovery-generate' %}>{% trans "Generate new recovery codes" %}</a><br />
+  <a href="{% url 'totp-disable' %}">{% trans "Disable this factor" %}</a>
+{% else %}
+  {% if login_url %}
+    <a href="{{ login_url }}">
+      {% trans "Insufficient authentication level to view, click here to increase." %}
+    </a>
+  {% else %}
+    <a href="{{ enable_url }}">
+      {% trans "Enable this factor " %}
+    </a>
+    {% blocktrans %}
+      in order to secure authentication with a one-time verification code. It
+      will be requested when needed on top of username and password.
+    {% endblocktrans %}
+  {% endif %}
+{% endif %}
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_display.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_display.html
new file mode 100644
index 00000000..cbccaf2f
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_display.html
@@ -0,0 +1,22 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block content %}
+  <h2>Recovery codes</h2>
+  <p>
+  {% blocktrans %}
+    Please store the following one-time codes in a safe place. If you ever lose
+    your device, they can be used to pass the verification step and regain
+    access to your account.
+  {% endblocktrans %}
+  <ul>
+    {% for code in object_list %}
+      <li>{{ code.code }}</li>
+    {% endfor %}
+  </ul>
+  {% if next_url %}
+    <a href="{{ next_url }}">{% trans "Continue" %}</a>
+  {% else %}
+    <a href="{% url 'authenticators_profile' %}">{% trans "Back" %}</a>
+  {% endif %}
+{% endblock %}
diff --git a/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_form.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_form.html
new file mode 100644
index 00000000..4fe32373
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_form.html
@@ -0,0 +1,6 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block content %}
+  {% include "auth_oath/totp_form.html" %}
+{% endblock %}
diff --git a/src/authentic2/auth2_multifactor/auth_oath/urls.py b/src/authentic2/auth2_multifactor/auth_oath/urls.py
new file mode 100644
index 00000000..811c8099
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/urls.py
@@ -0,0 +1,22 @@
+from django.conf.urls import url
+from django.contrib.auth.decorators import login_required
+
+from authentic2.decorators import required
+from authentic2.auth2_multifactor.decorators import auth_level_required
+
+from . import views
+from .authenticators import TOTPAuthenticator
+
+
+urlpatterns = required(
+    (login_required, auth_level_required(TOTPAuthenticator.auth_level)), [
+        url(r'^disable/$', views.disable, name='totp-disable'),
+        url(r'^recovery/codes/$', views.recovery_display, name='totp-recovery-display'),
+        url(r'^recovery/generate/$', views.recovery_generate, name='totp-recovery-generate'),
+    ]
+)
+
+urlpatterns += [
+    url(r'^enable/$', login_required(views.enable), name='totp-enable'),
+    url(r'^recovery/$', login_required(views.recovery), name='totp-recovery'),
+]
diff --git a/src/authentic2/auth2_multifactor/auth_oath/utils.py b/src/authentic2/auth2_multifactor/auth_oath/utils.py
new file mode 100644
index 00000000..47b8eb53
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/utils.py
@@ -0,0 +1,21 @@
+from base64 import b64encode
+from io import BytesIO
+
+from qrcode import QRCode
+from oath import google_authenticator
+
+
+def make_qrcode(user, secret=None):
+    if not secret:
+        secret = user.oath_totp_secret
+    ga = google_authenticator.GoogleAuthenticatorURI()
+    account = user.get_full_name().encode('utf-8')
+    uri = ga.generate(secret.key, account=account, issuer='Publik')
+    qr = QRCode(box_size=5)
+    qr.add_data(uri)
+    qr.make(fit=True)
+    img = qr.make_image()
+    img_bytes = BytesIO()
+    img.save(img_bytes, format='png')
+    uri = b64encode(img_bytes.getvalue())
+    return uri
diff --git a/src/authentic2/auth2_multifactor/auth_oath/views.py b/src/authentic2/auth2_multifactor/auth_oath/views.py
new file mode 100644
index 00000000..20dd0207
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/views.py
@@ -0,0 +1,208 @@
+from random import SystemRandom
+
+from django.template.loader import render_to_string
+from django.utils.translation import ugettext as _
+from django.views.generic import TemplateView
+from django.views.generic.list import ListView
+from django.views.generic.edit import FormView
+
+from oath import accept_totp
+
+from authentic2.utils import redirect, get_next_url, csrf_token_check, make_url
+
+from .authenticators import TOTPAuthenticator
+from .forms import LoginForm, EnableForm, RecoveryForm
+from .models import OATHTOTPSecret, RecoveryCode
+from .utils import make_qrcode
+
+RECOVERY_DISPLAY = 'allow_totp_recovery_code_display'
+
+
+class Profile(TemplateView):
+    template_name = 'auth_oath/totp_profile.html'
+
+    def get_context_data(self, **context):
+        try:
+            self.request.user.enabled_auth_factors.get(
+                authenticator_id=TOTPAuthenticator._id)
+        except self.request.user.enabled_auth_factors.model.DoesNotExist:
+            context['enabled'] = False
+            if 'auth_level' in self.request.GET:
+                # Forced enabling flow
+                context['enable_url'] = make_url('totp-enable', keep_params=True,
+                                                 request=self.request)
+            else:
+                context['enable_url'] = make_url('totp-enable',
+                                                 params={'next': self.request.get_full_path()})
+        else:
+            context['uri'] = make_qrcode(self.request.user)
+            auth_level = TOTPAuthenticator.auth_level
+            if self.request.session.get('auth_level', 1) < auth_level:
+                params = {
+                    'next': self.request.get_full_path(),
+                    'auth_level': auth_level,
+                }
+                context['login_url'] = make_url('auth_login', params=params)
+            else:
+                context['enabled'] = True
+        return super(Profile, self).get_context_data(**context)
+
+
+totp_profile = Profile.as_view()
+
+
+class Login(FormView):
+    template_name = 'auth_oath/totp_form.html'
+    form_class = LoginForm
+
+    def get_context_data(self, **kwargs):
+        kwargs['submit_name'] = 'oath-totp-submit'
+        kwargs['recovery_url'] = make_url('totp-recovery', keep_params=True,
+                                          request=self.request)
+        return super(Login, self).get_context_data(**kwargs)
+
+    def get_success_url(self):
+        return get_next_url(self.request.GET)
+
+    def form_valid(self, form):
+        code = form.cleaned_data['code']
+        secret = self.request.user.oath_totp_secret
+        success, drift = accept_totp(secret.key, str(code), drift=secret.drift)
+        csrf_token_check(self.request, form)
+        if success:
+            secret.drift = drift
+            secret.save()
+            self.request.session['auth_level'] = TOTPAuthenticator.auth_level
+            return super(Login, self).form_valid(form)
+        form.add_error('code', _('Invalid code.'))
+        return self.form_invalid(form)
+
+
+totp_login = Login.as_view()
+
+
+class Enable(FormView):
+    template_name = 'auth_oath/totp_enable.html'
+    form_class = EnableForm
+
+    def dispatch(self, request, *args, **kwargs):
+        try:
+            request.user.enabled_auth_factors.get(
+                authenticator_id=TOTPAuthenticator._id)
+            return redirect(request, 'authenticators_profile')
+        except request.user.enabled_auth_factors.model.DoesNotExist:
+            return super(Enable, self).dispatch(request, *args, **kwargs)
+
+    def get_success_url(self):
+        self.request.session[RECOVERY_DISPLAY] = True
+        return make_url('totp-recovery-display', keep_params=True,
+                        request=self.request)
+
+    def get_context_data(self, **kwargs):
+        secret = self.get_secret_for_session()
+        kwargs['uri'] = make_qrcode(self.request.user, secret)
+        encoded_secret = secret.b32_encoded()
+        kwargs['secret'] = ' '.join(encoded_secret[i:i + 4]
+                                    for i in range(0, len(encoded_secret), 4))
+        return super(Enable, self).get_context_data(**kwargs)
+
+    def get_secret_for_session(self):
+        """Make sure a new temporary secret is generated each session.
+
+        We don't want to generate a secret on every page refresh.
+        On the other hand, permanently storing the secret before the user has
+        completed setup would allow an attacker who already owns the account to
+        preventively get it, so that in case the user decides to enable TOTP
+        they would still have access.
+        """
+        key = self.request.session.setdefault(
+            'oath_totp_secret', format(SystemRandom().getrandbits(160), '040x'))
+        return OATHTOTPSecret(self.request.user.id, key)
+
+    def form_valid(self, form):
+        code = form.cleaned_data['code']
+        secret = self.get_secret_for_session()
+        success, _ = accept_totp(secret.key, str(code))
+        if success:
+            secret.save()
+            self.request.user.enabled_auth_factors.create(
+                authenticator_id=TOTPAuthenticator._id)
+            self.request.session['auth_level'] = TOTPAuthenticator.auth_level
+            del self.request.session['oath_totp_secret']
+            RecoveryCode.objects.generate_for_user(self.request.user)
+            return super(Enable, self).form_valid(form)
+        form.add_error('code', _('Invalid code.'))
+        return self.form_invalid(form)
+
+
+enable = Enable.as_view()
+
+
+class Disable(TemplateView):
+    template_name = 'auth_oath/disable_confirm.html'
+
+    def post(self, request):
+        try:
+            factor = request.user.enabled_auth_factors.get(
+                authenticator_id=TOTPAuthenticator._id)
+            factor.delete()
+        except request.user.enabled_auth_factors.model.DoesNotExist:
+            pass
+        else:
+            request.user.oath_totp_secret.delete()
+            request.user.totp_recovery_codes.all().delete()
+        return redirect(request, 'authenticators_profile')
+
+
+disable = Disable.as_view()
+
+
+class RecoveryCodesDisplay(ListView):
+    template_name = 'auth_oath/totp_recovery_display.html'
+
+    def get_context_data(self, **kwargs):
+        kwargs['next_url'] = get_next_url(self.request.GET)
+        return super(RecoveryCodesDisplay, self).get_context_data(**kwargs)
+
+    def get_queryset(self):
+        # Recovery codes should be displayed only once after generation
+        if self.request.session.get(RECOVERY_DISPLAY):
+            return self.request.user.totp_recovery_codes.all()
+
+
+recovery_display = RecoveryCodesDisplay.as_view()
+
+
+class RecoveryFormView(FormView):
+    template_name = 'auth_oath/totp_recovery_form.html'
+    form_class = RecoveryForm
+
+    def get_success_url(self):
+        return get_next_url(self.request.GET)
+
+    def form_valid(self, form):
+        code = form.cleaned_data['code']
+        try:
+            recovery_code = self.request.user.totp_recovery_codes.get(code=code)
+        except self.request.user.totp_recovery_codes.model.DoesNotExist:
+            form.add_error('code', _('Invalid code.'))
+            return self.form_invalid(form)
+        recovery_code.delete()
+        self.request.session['auth_level'] = TOTPAuthenticator.auth_level
+        return super(RecoveryFormView, self).form_valid(form)
+
+
+recovery = RecoveryFormView.as_view()
+
+
+class RecoveryGenerate(TemplateView):
+    template_name = 'auth_oath/generate_confirm.html'
+
+    def post(self, request):
+        request.user.totp_recovery_codes.all().delete()
+        RecoveryCode.objects.generate_for_user(request.user)
+        request.session[RECOVERY_DISPLAY] = True
+        return redirect(request, 'totp-recovery-display')
+
+
+recovery_generate = RecoveryGenerate.as_view()
diff --git a/src/authentic2/auth2_multifactor/decorators.py b/src/authentic2/auth2_multifactor/decorators.py
new file mode 100644
index 00000000..baa608a8
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/decorators.py
@@ -0,0 +1,11 @@
+from django.core.exceptions import PermissionDenied
+
+
+def auth_level_required(auth_level):
+    def actual_decorator(func):
+        def wrapped(request, *args, **kwargs):
+            if request.session.get('auth_level', 1) < auth_level:
+                raise PermissionDenied
+            return func(request, *args, **kwargs)
+        return wrapped
+    return actual_decorator
-- 
2.20.1

