Projet

Général

Profil

0002-auth2_multifactor-add-OATH-authentication-factor.patch

Valentin Deniaud, 25 juin 2019 15:56

Télécharger (21,8 ko)

Voir les différences:

Subject: [PATCH 2/3] 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   |  10 ++
 .../auth_oath/app_settings.py                 |  23 +++
 .../auth_oath/authenticators.py               |  25 ++++
 .../auth2_multifactor/auth_oath/forms.py      |  27 ++++
 .../auth_oath/migrations/0001_initial.py      |  27 ++++
 .../auth_oath/migrations/__init__.py          |   0
 .../auth2_multifactor/auth_oath/models.py     |  17 +++
 .../auth_oath/templates/auth_oath/qrcode.html |   3 +
 .../templates/auth_oath/totp_enable.html      |  30 ++++
 .../templates/auth_oath/totp_form.html        |  14 ++
 .../templates/auth_oath/totp_profile.html     |  31 ++++
 .../auth2_multifactor/auth_oath/urls.py       |  19 +++
 .../auth2_multifactor/auth_oath/utils.py      |  31 ++++
 .../auth2_multifactor/auth_oath/views.py      | 134 ++++++++++++++++++
 .../auth2_multifactor/decorators.py           |  12 ++
 18 files changed, 412 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/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/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
MANIFEST.in
25 25
recursive-include src/authentic2_auth_saml/templates/authentic2_auth_saml *.html
26 26
recursive-include src/authentic2_auth_oidc/templates/authentic2_auth_oidc *.html
27 27
recursive-include src/authentic2_idp_oidc/templates/authentic2_idp_oidc *.html
28
recursive-include src/authentic2/auth2_multifactor/auth_oath/templates *.html
28 29

  
29 30
recursive-include src/authentic2/saml/fixtures *.json
30 31
recursive-include src/authentic2/locale *.po *.mo
setup.py
140 140
          'xstatic-select2',
141 141
          'pillow',
142 142
          'tablib',
143
          'qrcode',
144
          'oath',
143 145
      ],
144 146
      zip_safe=False,
145 147
      classifiers=[
......
174 176
              'authentic2-idp-oidc = authentic2_idp_oidc:Plugin',
175 177
              'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin',
176 178
              'authentic2-auth-fc = authentic2_auth_fc:Plugin',
179
              'authentic2-auth-oath = authentic2.auth2_multifactor.auth_oath:Plugin',
177 180
          ],
178 181
      })
src/authentic2/auth2_multifactor/__init__.py
1
"""
2
This package contains authentification modules that are not to be used as primary
3
modes of authentification, but rather as supplementary authentication factors in
4
order to guarantee higher levels of trust.
5
"""
src/authentic2/auth2_multifactor/auth_oath/__init__.py
1
class Plugin(object):
2
    def get_before_urls(self):
3
        from . import urls
4
        return urls.urlpatterns
5

  
6
    def get_apps(self):
7
        return [__name__]
8

  
9
    def get_authenticators(self):
10
        return ['authentic2.auth2_multifactor.auth_oath.authenticators.TOTPAuthenticator']
src/authentic2/auth2_multifactor/auth_oath/app_settings.py
1
class AppSettings(object):
2
    __DEFAULTS = {
3
        'ENABLE': True,
4
        'LEVEL': 2,
5
    }
6

  
7
    def __init__(self, prefix):
8
        self.prefix = prefix
9

  
10
    def _setting(self, name, dflt):
11
        from django.conf import settings
12
        return getattr(settings, self.prefix+name, dflt)
13

  
14
    def __getattr__(self, name):
15
        if name not in self.__DEFAULTS:
16
            raise AttributeError(name)
17
        return self._setting(name, self.__DEFAULTS[name])
18

  
19

  
20
import sys
21
app_settings = AppSettings('A2_AUTH_OATH_')
22
app_settings.__name__ = __name__
23
sys.modules[__name__] = app_settings
src/authentic2/auth2_multifactor/auth_oath/authenticators.py
1
from django.utils.translation import ugettext_lazy
2

  
3
from . import app_settings
4
from .views import totp_profile, totp_login
5

  
6

  
7
class TOTPAuthenticator(object):
8
    submit_name = 'oath-totp-submit'
9
    auth_level = app_settings.LEVEL
10
    _id = 'multifactor-totp'
11

  
12
    def enabled(self):
13
        return app_settings.ENABLE
14

  
15
    def name(self):
16
        return ugettext_lazy('One-time password')
17

  
18
    def id(self):
19
        return self._id
20

  
21
    def login(self, request, *args, **kwargs):
22
        return totp_login(request, *args, **kwargs)
23

  
24
    def profile(self, request, *args, **kwargs):
25
        return totp_profile(request, *args, **kwargs)
src/authentic2/auth2_multifactor/auth_oath/forms.py
1
from django import forms
2
from django.core.exceptions import ValidationError
3
from django.utils.translation import ugettext as _
4

  
5

  
6
def validate_number(value):
7
    try:
8
        int(value)
9
    except ValueError:
10
        raise ValidationError(
11
            _('Code must be a number.'),
12
        )
13

  
14

  
15
class EnableForm(forms.Form):
16
    code = forms.CharField(
17
        max_length=6, min_length=6, validators=[validate_number],
18
        label=_('Code'),
19
    )
20

  
21

  
22
class LoginForm(EnableForm):
23
    def __init__(self, *args, **kwargs):
24
        super(LoginForm, self).__init__(*args, **kwargs)
25
        self.fields['code'].help_text = \
26
            _('In order to be granted access, you must enter the six-digit '
27
              'code given by the authentificator app on your device.')
src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2019-04-01 08:21
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
    initial = True
13

  
14
    dependencies = [
15
        ('custom_user', '0016_auto_20180925_1107'),
16
    ]
17

  
18
    operations = [
19
        migrations.CreateModel(
20
            name='OATHTOTPSecret',
21
            fields=[
22
                ('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')),
23
                ('key', models.CharField(max_length=40)),
24
                ('drift', models.IntegerField(default=0)),
25
            ],
26
        ),
27
    ]
src/authentic2/auth2_multifactor/auth_oath/models.py
1
from base64 import b32encode
2
from binascii import unhexlify
3

  
4
from django.conf import settings
5
from django.db import models
6
from django.utils.translation import ugettext as _
7

  
8

  
9
class OATHTOTPSecret(models.Model):
10

  
11
    user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True,
12
                                related_name='oath_totp_secret', verbose_name=_('user'))
13
    key = models.CharField(max_length=40)
14
    drift = models.IntegerField(default=0)
15

  
16
    def b32_encoded(self):
17
        return b32encode(unhexlify(self.key)).decode('ascii')
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/qrcode.html
1
<div style="text-align: center">
2
  <img src="data:image/png;base64,{{ uri }}">
3
</div>
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_enable.html
1
{% extends "authentic2/base-page.html" %}
2
{% load i18n %}
3

  
4
{% block content %}
5
<div>
6
  <p>
7
    {% blocktrans %}
8
      Install any application capable of generating such codes (refer to the
9
      documentation for recommendations). Then scan the QR code below, and
10
      enter the six-digit code that the app will display in order to complete
11
      the setup.
12
    {% endblocktrans %}
13
  </p>
14
</div>
15
<div>
16
  {% include "auth_oath/qrcode.html" %}
17
  <p>
18
    {% trans "For manual setup when unable to scan the code, enter the following key : " %}
19
    {{ secret }}
20
  </p>
21
</div>
22

  
23
<div>
24
  <form method="post" autocomplete="off" action="">
25
    {% csrf_token %}
26
    {{ form.as_p }}
27
    <button class="submit-button" name="{{ submit_name }}">{% trans "Activate" %}</button>
28
  </form>
29
</div>
30
{% endblock %}
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html
1
{% load i18n %}
2

  
3
<h2>{% trans "Multi-factor authentication" %}</h1>
4

  
5
<div>
6
  <form method="post" autocomplete="off" action="">
7
    {% csrf_token %}
8
    {{ form.as_p }}
9
    <button class="submit-button" name="{{ submit_name }}">{% trans "Submit" %}</button>
10
    {% if cancel %}
11
      <button class="cancel-button" name="cancel">{% trans 'Cancel' %}</button>
12
    {% endif %}
13
  </form>
14
</div>
src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html
1
{% load i18n %}
2

  
3
{% if enabled %}
4
  <p>
5
    {% trans "You can configure additional devices by scanning the QR code below." %}
6
  </p>
7
  {% include "auth_oath/qrcode.html" %}
8
  <p>
9
    <a onclick="return confirm('{% trans "Are you sure ?" %}')" href="{% url 'totp-disable' %}">
10
      {% trans "Click here to disable this factor." %}
11
    </a>
12
    {% blocktrans %}
13
      Doing so will invalidate configuration on all devices, meaning that if
14
      it is enabled again the codes they generate won't be valid anymore.
15
    {% endblocktrans %}
16
  </p>
17
{% else %}
18
  {% if login_url %}
19
    <a href="{{ login_url }}">
20
      {% trans "Insufficient authentication level to view, click here to increase." %}
21
    </a>
22
  {% else %}
23
    <a href="{{ enable_url }}">
24
      {% trans "Enable this factor " %}
25
    </a>
26
    {% blocktrans %}
27
      in order to secure authentication with a one-time verification code. It
28
      will be requested when needed on top of username and password.
29
    {% endblocktrans %}
30
  {% endif %}
31
{% endif %}
src/authentic2/auth2_multifactor/auth_oath/urls.py
1
from django.conf.urls import url
2
from django.contrib.auth.decorators import login_required
3

  
4
from authentic2.decorators import required
5
from authentic2.auth2_multifactor.decorators import auth_level_required
6

  
7
from . import views
8
from .utils import get_authenticator_level
9

  
10

  
11
urlpatterns = required(
12
    (login_required, auth_level_required(get_authenticator_level)), [
13
        url(r'disable', views.disable, name='totp-disable'),
14
    ]
15
)
16

  
17
urlpatterns += [
18
    url(r'enable', login_required(views.enable), name='totp-enable'),
19
]
src/authentic2/auth2_multifactor/auth_oath/utils.py
1
from base64 import b64encode
2
from io import BytesIO
3

  
4
from qrcode import QRCode
5
from oath import google_authenticator
6

  
7

  
8
def get_qrcode(user, secret=None):
9
    if not secret:
10
        secret = user.oath_totp_secret
11
    ga = google_authenticator.GoogleAuthenticatorURI()
12
    account = user.get_full_name().encode('utf-8')
13
    uri = ga.generate(secret.key, account=account, issuer='Publik')
14
    qr = QRCode(box_size=5)
15
    qr.add_data(uri)
16
    qr.make(fit=True)
17
    img = qr.make_image()
18
    img_bytes = BytesIO()
19
    img.save(img_bytes, format='png')
20
    uri = b64encode(img_bytes.getvalue())
21
    return uri
22

  
23

  
24
def get_authenticator_id():
25
    from .authenticators import TOTPAuthenticator
26
    return TOTPAuthenticator._id
27

  
28

  
29
def get_authenticator_level():
30
    from .authenticators import TOTPAuthenticator
31
    return TOTPAuthenticator.auth_level
src/authentic2/auth2_multifactor/auth_oath/views.py
1
from random import SystemRandom
2

  
3
from django.template.loader import render_to_string
4
from django.utils.translation import ugettext as _
5
from django.views.generic.edit import FormView
6

  
7
from oath import accept_totp
8

  
9
from authentic2.utils import redirect, get_next_url, csrf_token_check, make_url
10

  
11
from .forms import LoginForm, EnableForm
12
from .models import OATHTOTPSecret
13
from .utils import (get_qrcode, get_authenticator_level, get_authenticator_id)
14

  
15

  
16
def totp_profile(request, *args, **kwargs):
17
    context = {}
18
    try:
19
        request.user.enabled_auth_factors.get(
20
            authenticator_id=get_authenticator_id())
21
    except request.user.enabled_auth_factors.model.DoesNotExist:
22
        context['enabled'] = False
23
        if 'auth_level' in request.GET:
24
            # Forced enabling flow
25
            context['enable_url'] = make_url('totp-enable', keep_params=True,
26
                                             request=request)
27
        else:
28
            context['enable_url'] = make_url('totp-enable',
29
                                             params={'next': request.get_full_path()})
30
    else:
31
        context['uri'] = get_qrcode(request.user)
32
        auth_level = get_authenticator_level()
33
        if request.session.get('auth_level', 1) < auth_level:
34
            params = {
35
                'next': request.get_full_path(),
36
                'auth_level': auth_level,
37
            }
38
            context['login_url'] = make_url('auth_login', params=params)
39
        else:
40
            context['enabled'] = True
41
    return render_to_string('auth_oath/totp_profile.html', context, request=request)
42

  
43

  
44
class Login(FormView):
45
    template_name = 'auth_oath/totp_form.html'
46
    form_class = LoginForm
47

  
48
    def get_context_data(self, **kwargs):
49
        kwargs['submit_name'] = 'oath-totp-submit'
50
        return super(Login, self).get_context_data(**kwargs)
51

  
52
    def get_success_url(self):
53
        return get_next_url(self.request.GET)
54

  
55
    def form_valid(self, form):
56
        code = form.cleaned_data['code']
57
        secret = self.request.user.oath_totp_secret
58
        success, drift = accept_totp(secret.key, str(code), drift=secret.drift)
59
        csrf_token_check(self.request, form)
60
        if success:
61
            secret.drift = drift
62
            secret.save()
63
            self.request.session['auth_level'] = get_authenticator_level()
64
            return super(Login, self).form_valid(form)
65
        form.add_error('code', _('Invalid code.'))
66
        return self.form_invalid(form)
67

  
68

  
69
totp_login = Login.as_view()
70

  
71

  
72
class Enable(FormView):
73
    template_name = 'auth_oath/totp_enable.html'
74
    form_class = EnableForm
75

  
76
    def dispatch(self, request, *args, **kwargs):
77
        try:
78
            request.user.enabled_auth_factors.get(
79
                authenticator_id=get_authenticator_id())
80
            return redirect(request, 'authenticators_profile')
81
        except request.user.enabled_auth_factors.model.DoesNotExist:
82
            return super(Enable, self).dispatch(request, *args, **kwargs)
83

  
84
    def get_success_url(self):
85
        return get_next_url(self.request.GET)
86

  
87
    def get_context_data(self, **kwargs):
88
        secret = self.get_secret_for_session()
89
        kwargs['uri'] = get_qrcode(self.request.user, secret)
90
        encoded_secret = secret.b32_encoded()
91
        kwargs['secret'] = ' '.join(encoded_secret[i:i + 4]
92
                                    for i in range(0, len(encoded_secret), 4))
93
        return super(Enable, self).get_context_data(**kwargs)
94

  
95
    def get_secret_for_session(self):
96
        """Make sure a new temporary secret is generated each session.
97

  
98
        We don't want to generate a secret on every page refresh.
99
        On the other hand, permanently storing the secret before the user has
100
        completed setup would allow an attacker who already owns the account to
101
        preventively get it, so that in case the user decides to enable TOTP
102
        they would still have access.
103
        """
104
        key = self.request.session.setdefault(
105
            'oath_totp_secret', format(SystemRandom().getrandbits(160), '040x'))
106
        return OATHTOTPSecret(self.request.user.id, key)
107

  
108
    def form_valid(self, form):
109
        code = form.cleaned_data['code']
110
        secret = self.get_secret_for_session()
111
        success, _ = accept_totp(secret.key, str(code))
112
        if success:
113
            secret.save()
114
            self.request.user.enabled_auth_factors.create(
115
                authenticator_id=get_authenticator_id())
116
            self.request.session['auth_level'] = get_authenticator_level()
117
            return super(Enable, self).form_valid(form)
118
        form.add_error('code', _('Invalid code.'))
119
        return self.form_invalid(form)
120

  
121

  
122
enable = Enable.as_view()
123

  
124

  
125
def disable(request):
126
    try:
127
        factor = request.user.enabled_auth_factors.get(
128
            authenticator_id=get_authenticator_id())
129
        factor.delete()
130
    except request.user.enabled_auth_factors.model.DoesNotExist:
131
        pass
132
    else:
133
        request.user.oath_totp_secret.delete()
134
    return redirect(request, 'authenticators_profile')
src/authentic2/auth2_multifactor/decorators.py
1
from django.core.exceptions import PermissionDenied
2

  
3

  
4
def auth_level_required(auth_level):
5
    def actual_decorator(func):
6
        actual_auth_level = auth_level() if callable(auth_level) else auth_level
7
        def wrapped(request, *args, **kwargs):
8
            if request.session.get('auth_level', 1) < actual_auth_level:
9
                raise PermissionDenied
10
            return func(request, *args, **kwargs)
11
        return wrapped
12
    return actual_decorator
0
-