From a4d8691bb02b485a31cc336fb5c99f87d52fabcf Mon Sep 17 00:00:00 2001
From: Valentin Deniaud
Date: Mon, 24 Jun 2019 18:22:51 +0200
Subject: [PATCH 3/3] auth_oath: add recovery codes
They can be used as an alternate way to pass the verification process,
and should be used when a user has lost access to their device.
---
.../auth2_multifactor/auth_oath/forms.py | 8 +++
.../auth_oath/migrations/0002_recoverycode.py | 26 +++++++++
.../auth2_multifactor/auth_oath/models.py | 7 +++
.../templates/auth_oath/totp_form.html | 9 ++++
.../templates/auth_oath/totp_profile.html | 2 +
.../auth_oath/totp_recovery_display.html | 22 ++++++++
.../auth_oath/totp_recovery_form.html | 6 +++
.../auth2_multifactor/auth_oath/urls.py | 2 +
.../auth2_multifactor/auth_oath/views.py | 53 +++++++++++++++++--
9 files changed, 132 insertions(+), 3 deletions(-)
create mode 100644 src/authentic2/auth2_multifactor/auth_oath/migrations/0002_recoverycode.py
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
diff --git a/src/authentic2/auth2_multifactor/auth_oath/forms.py b/src/authentic2/auth2_multifactor/auth_oath/forms.py
index 0cd7a623..aed7f672 100644
--- a/src/authentic2/auth2_multifactor/auth_oath/forms.py
+++ b/src/authentic2/auth2_multifactor/auth_oath/forms.py
@@ -25,3 +25,11 @@ class LoginForm(EnableForm):
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/0002_recoverycode.py b/src/authentic2/auth2_multifactor/auth_oath/migrations/0002_recoverycode.py
new file mode 100644
index 00000000..26784c78
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_oath/migrations/0002_recoverycode.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-06-25 12:40
+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):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('auth_oath', '0001_initial'),
+ ]
+
+ operations = [
+ 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/models.py b/src/authentic2/auth2_multifactor/auth_oath/models.py
index c605804c..b5e2bc59 100644
--- a/src/authentic2/auth2_multifactor/auth_oath/models.py
+++ b/src/authentic2/auth2_multifactor/auth_oath/models.py
@@ -15,3 +15,10 @@ class OATHTOTPSecret(models.Model):
def b32_encoded(self):
return b32encode(unhexlify(self.key)).decode('ascii')
+
+
+class RecoveryCode(models.Model):
+
+ 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/totp_form.html b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html
index d991c04e..a287f23a 100644
--- 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
@@ -12,3 +12,12 @@
{% endif %}
+
+{% if recovery_url %}
+
+{% 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
index 8546a4af..34486037 100644
--- 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
@@ -1,6 +1,7 @@
{% load i18n %}
{% if enabled %}
+ {% trans "View recovery codes" %}
{% trans "You can configure additional devices by scanning the QR code below." %}
@@ -12,6 +13,7 @@
{% 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 %}
{% else %}
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 %}
+ Recovery codes
+
+ {% 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 %}
+
+ {% for code in object_list %}
+ - {{ code.code }}
+ {% endfor %}
+
+ {% if next_url %}
+ {% trans "Continue" %}
+ {% else %}
+ {% trans "Back" %}
+ {% 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
index 31e7db55..7f12a6ec 100644
--- a/src/authentic2/auth2_multifactor/auth_oath/urls.py
+++ b/src/authentic2/auth2_multifactor/auth_oath/urls.py
@@ -11,9 +11,11 @@ from .utils import get_authenticator_level
urlpatterns = required(
(login_required, auth_level_required(get_authenticator_level)), [
url(r'disable', views.disable, name='totp-disable'),
+ url(r'recovery-codes', views.recovery_display, name='totp-recovery-display'),
]
)
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/views.py b/src/authentic2/auth2_multifactor/auth_oath/views.py
index be21226c..c0d48007 100644
--- a/src/authentic2/auth2_multifactor/auth_oath/views.py
+++ b/src/authentic2/auth2_multifactor/auth_oath/views.py
@@ -2,14 +2,15 @@ from random import SystemRandom
from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
+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 .forms import LoginForm, EnableForm
-from .models import OATHTOTPSecret
+from .forms import LoginForm, EnableForm, RecoveryForm
+from .models import OATHTOTPSecret, RecoveryCode
from .utils import (get_qrcode, get_authenticator_level, get_authenticator_id)
@@ -47,6 +48,8 @@ class Login(FormView):
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):
@@ -82,7 +85,8 @@ class Enable(FormView):
return super(Enable, self).dispatch(request, *args, **kwargs)
def get_success_url(self):
- return get_next_url(self.request.GET)
+ return make_url('totp-recovery-display', keep_params=True,
+ request=self.request)
def get_context_data(self, **kwargs):
secret = self.get_secret_for_session()
@@ -114,10 +118,16 @@ class Enable(FormView):
self.request.user.enabled_auth_factors.create(
authenticator_id=get_authenticator_id())
self.request.session['auth_level'] = get_authenticator_level()
+ self.generate_recovery_codes()
return super(Enable, self).form_valid(form)
form.add_error('code', _('Invalid code.'))
return self.form_invalid(form)
+ def generate_recovery_codes(self):
+ for _ in range(10):
+ code = format(SystemRandom().getrandbits(40), '010x')
+ RecoveryCode.objects.create(user=self.request.user, code=code)
+
enable = Enable.as_view()
@@ -131,4 +141,41 @@ def disable(request):
pass
else:
request.user.oath_totp_secret.delete()
+ request.user.totp_recovery_codes.all().delete()
return redirect(request, 'authenticators_profile')
+
+
+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):
+ 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'] = get_authenticator_level()
+ return super(RecoveryFormView, self).form_valid(form)
+
+
+recovery = RecoveryFormView.as_view()
--
2.20.1