0002-auth2_multifactor-add-OATH-authentication-factor.patch
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 |
- |