Projet

Général

Profil

0003-auth_oath-add-recovery-codes.patch

Valentin Deniaud, 25 juin 2019 15:56

Télécharger (11,3 ko)

Voir les différences:

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
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
-