0003-auth_oath-add-recovery-codes.patch
src/authentic2/auth2_multifactor/auth_oath/forms.py | ||
---|---|---|
25 | 25 |
self.fields['code'].help_text = \ |
26 | 26 |
_('In order to be granted access, you must enter the six-digit ' |
27 | 27 |
'code given by the authentificator app on your device.') |
28 | ||
29 | ||
30 |
class RecoveryForm(forms.Form): |
|
31 |
code = forms.CharField( |
|
32 |
max_length=10, min_length=10, label=_('Recovery code'), |
|
33 |
help_text=_("Use one of your recovery code. Remember these are for one-time " |
|
34 |
"use, hence the code you choose won't be valid next time.") |
|
35 |
) |
src/authentic2/auth2_multifactor/auth_oath/migrations/0002_recoverycode.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2019-06-25 12:40 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.conf import settings |
|
6 |
from django.db import migrations, models |
|
7 |
import django.db.models.deletion |
|
8 | ||
9 | ||
10 |
class Migration(migrations.Migration): |
|
11 | ||
12 |
dependencies = [ |
|
13 |
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
|
14 |
('auth_oath', '0001_initial'), |
|
15 |
] |
|
16 | ||
17 |
operations = [ |
|
18 |
migrations.CreateModel( |
|
19 |
name='RecoveryCode', |
|
20 |
fields=[ |
|
21 |
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
22 |
('code', models.CharField(max_length=10)), |
|
23 |
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='totp_recovery_codes', to=settings.AUTH_USER_MODEL, verbose_name='utilisateur')), |
|
24 |
], |
|
25 |
), |
|
26 |
] |
src/authentic2/auth2_multifactor/auth_oath/models.py | ||
---|---|---|
15 | 15 | |
16 | 16 |
def b32_encoded(self): |
17 | 17 |
return b32encode(unhexlify(self.key)).decode('ascii') |
18 | ||
19 | ||
20 |
class RecoveryCode(models.Model): |
|
21 | ||
22 |
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='totp_recovery_codes', |
|
23 |
verbose_name=_('user')) |
|
24 |
code = models.CharField(max_length=10) |
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html | ||
---|---|---|
12 | 12 |
{% endif %} |
13 | 13 |
</form> |
14 | 14 |
</div> |
15 | ||
16 |
{% if recovery_url %} |
|
17 |
<div> |
|
18 |
<p> |
|
19 |
{% trans "If missing your device, " %} |
|
20 |
<a href="{{ recovery_url }}">{% trans "click here to use a recovery code." %}</a> |
|
21 |
</p> |
|
22 |
</div> |
|
23 |
{% endif %} |
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html | ||
---|---|---|
1 | 1 |
{% load i18n %} |
2 | 2 | |
3 | 3 |
{% if enabled %} |
4 |
<a href={% url 'totp-recovery-display' %}>{% trans "View recovery codes" %}</a><br /> |
|
4 | 5 |
<p> |
5 | 6 |
{% trans "You can configure additional devices by scanning the QR code below." %} |
6 | 7 |
</p> |
... | ... | |
12 | 13 |
{% blocktrans %} |
13 | 14 |
Doing so will invalidate configuration on all devices, meaning that if |
14 | 15 |
it is enabled again the codes they generate won't be valid anymore. |
16 |
Recovery codes will also be discarded. |
|
15 | 17 |
{% endblocktrans %} |
16 | 18 |
</p> |
17 | 19 |
{% else %} |
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_display.html | ||
---|---|---|
1 |
{% extends "authentic2/base-page.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %} |
|
5 |
<h2>Recovery codes</h2> |
|
6 |
<p> |
|
7 |
{% blocktrans %} |
|
8 |
Please store the following one-time codes in a safe place. If you ever lose |
|
9 |
your device, they can be used to pass the verification step and regain |
|
10 |
access to your account. |
|
11 |
{% endblocktrans %} |
|
12 |
<ul> |
|
13 |
{% for code in object_list %} |
|
14 |
<li>{{ code.code }}</li> |
|
15 |
{% endfor %} |
|
16 |
</ul> |
|
17 |
{% if next_url %} |
|
18 |
<a href="{{ next_url }}">{% trans "Continue" %}</a> |
|
19 |
{% else %} |
|
20 |
<a href="{% url 'authenticators_profile' %}">{% trans "Back" %}</a> |
|
21 |
{% endif %} |
|
22 |
{% endblock %} |
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_recovery_form.html | ||
---|---|---|
1 |
{% extends "authentic2/base-page.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %} |
|
5 |
{% include "auth_oath/totp_form.html" %} |
|
6 |
{% endblock %} |
src/authentic2/auth2_multifactor/auth_oath/urls.py | ||
---|---|---|
11 | 11 |
urlpatterns = required( |
12 | 12 |
(login_required, auth_level_required(get_authenticator_level)), [ |
13 | 13 |
url(r'disable', views.disable, name='totp-disable'), |
14 |
url(r'recovery-codes', views.recovery_display, name='totp-recovery-display'), |
|
14 | 15 |
] |
15 | 16 |
) |
16 | 17 | |
17 | 18 |
urlpatterns += [ |
18 | 19 |
url(r'enable', login_required(views.enable), name='totp-enable'), |
20 |
url(r'recovery', login_required(views.recovery), name='totp-recovery'), |
|
19 | 21 |
] |
src/authentic2/auth2_multifactor/auth_oath/views.py | ||
---|---|---|
2 | 2 | |
3 | 3 |
from django.template.loader import render_to_string |
4 | 4 |
from django.utils.translation import ugettext as _ |
5 |
from django.views.generic.list import ListView |
|
5 | 6 |
from django.views.generic.edit import FormView |
6 | 7 | |
7 | 8 |
from oath import accept_totp |
8 | 9 | |
9 | 10 |
from authentic2.utils import redirect, get_next_url, csrf_token_check, make_url |
10 | 11 | |
11 |
from .forms import LoginForm, EnableForm |
|
12 |
from .models import OATHTOTPSecret |
|
12 |
from .forms import LoginForm, EnableForm, RecoveryForm
|
|
13 |
from .models import OATHTOTPSecret, RecoveryCode
|
|
13 | 14 |
from .utils import (get_qrcode, get_authenticator_level, get_authenticator_id) |
14 | 15 | |
15 | 16 | |
... | ... | |
47 | 48 | |
48 | 49 |
def get_context_data(self, **kwargs): |
49 | 50 |
kwargs['submit_name'] = 'oath-totp-submit' |
51 |
kwargs['recovery_url'] = make_url('totp-recovery', keep_params=True, |
|
52 |
request=self.request) |
|
50 | 53 |
return super(Login, self).get_context_data(**kwargs) |
51 | 54 | |
52 | 55 |
def get_success_url(self): |
... | ... | |
82 | 85 |
return super(Enable, self).dispatch(request, *args, **kwargs) |
83 | 86 | |
84 | 87 |
def get_success_url(self): |
85 |
return get_next_url(self.request.GET) |
|
88 |
return make_url('totp-recovery-display', keep_params=True, |
|
89 |
request=self.request) |
|
86 | 90 | |
87 | 91 |
def get_context_data(self, **kwargs): |
88 | 92 |
secret = self.get_secret_for_session() |
... | ... | |
114 | 118 |
self.request.user.enabled_auth_factors.create( |
115 | 119 |
authenticator_id=get_authenticator_id()) |
116 | 120 |
self.request.session['auth_level'] = get_authenticator_level() |
121 |
self.generate_recovery_codes() |
|
117 | 122 |
return super(Enable, self).form_valid(form) |
118 | 123 |
form.add_error('code', _('Invalid code.')) |
119 | 124 |
return self.form_invalid(form) |
120 | 125 | |
126 |
def generate_recovery_codes(self): |
|
127 |
for _ in range(10): |
|
128 |
code = format(SystemRandom().getrandbits(40), '010x') |
|
129 |
RecoveryCode.objects.create(user=self.request.user, code=code) |
|
130 | ||
121 | 131 | |
122 | 132 |
enable = Enable.as_view() |
123 | 133 | |
... | ... | |
131 | 141 |
pass |
132 | 142 |
else: |
133 | 143 |
request.user.oath_totp_secret.delete() |
144 |
request.user.totp_recovery_codes.all().delete() |
|
134 | 145 |
return redirect(request, 'authenticators_profile') |
146 | ||
147 | ||
148 |
class RecoveryCodesDisplay(ListView): |
|
149 |
template_name = 'auth_oath/totp_recovery_display.html' |
|
150 | ||
151 |
def get_context_data(self, **kwargs): |
|
152 |
kwargs['next_url'] = get_next_url(self.request.GET) |
|
153 |
return super(RecoveryCodesDisplay, self).get_context_data(**kwargs) |
|
154 | ||
155 |
def get_queryset(self): |
|
156 |
return self.request.user.totp_recovery_codes.all() |
|
157 | ||
158 | ||
159 |
recovery_display = RecoveryCodesDisplay.as_view() |
|
160 | ||
161 | ||
162 |
class RecoveryFormView(FormView): |
|
163 |
template_name = 'auth_oath/totp_recovery_form.html' |
|
164 |
form_class = RecoveryForm |
|
165 | ||
166 |
def get_success_url(self): |
|
167 |
return get_next_url(self.request.GET) |
|
168 | ||
169 |
def form_valid(self, form): |
|
170 |
code = form.cleaned_data['code'] |
|
171 |
try: |
|
172 |
recovery_code = self.request.user.totp_recovery_codes.get(code=code) |
|
173 |
except self.request.user.totp_recovery_codes.model.DoesNotExist: |
|
174 |
form.add_error('code', _('Invalid code.')) |
|
175 |
return self.form_invalid(form) |
|
176 |
recovery_code.delete() |
|
177 |
self.request.session['auth_level'] = get_authenticator_level() |
|
178 |
return super(RecoveryFormView, self).form_valid(form) |
|
179 | ||
180 | ||
181 |
recovery = RecoveryFormView.as_view() |
|
135 |
- |