From 143167714c3706a00226d86563c3c64f993c7e42 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 2 Apr 2019 12:09:29 +0200 Subject: [PATCH 09/13] auth2_multifactor: add OATH authentication factor Add a new module auth2_multifactor, which should contain all additionnal authentication factors. We will 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 works by letting the user scan a QR Code with their phone, thus requiring a third party app that implements TOTP. Then they can enter a one-time password when required. --- setup.py | 2 + src/authentic2/auth2_multifactor/__init__.py | 5 ++ .../auth2_multifactor/auth_oath/__init__.py | 0 .../auth_oath/authenticator.py | 48 +++++++++++++++++++ .../auth2_multifactor/auth_oath/forms.py | 17 +++++++ .../auth_oath/migrations/0001_initial.py | 27 +++++++++++ .../auth_oath/migrations/__init__.py | 0 .../auth2_multifactor/auth_oath/models.py | 11 +++++ .../templates/auth_oath/totp_form.html | 11 +++++ .../templates/auth_oath/totp_profile.html | 18 +++++++ .../auth2_multifactor/auth_oath/urls.py | 8 ++++ .../auth2_multifactor/auth_oath/views.py | 45 +++++++++++++++++ src/authentic2/auth2_multifactor/urls.py | 6 +++ src/authentic2/urls.py | 3 +- 14 files changed, 200 insertions(+), 1 deletion(-) 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/authenticator.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/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/views.py create mode 100644 src/authentic2/auth2_multifactor/urls.py diff --git a/setup.py b/setup.py index fd53c63c..88b5c403 100755 --- a/setup.py +++ b/setup.py @@ -139,6 +139,8 @@ setup(name="authentic2", 'xstatic-select2', 'pillow', 'tablib', + 'qrcode', + 'oath', ], zip_safe=False, classifiers=[ diff --git a/src/authentic2/auth2_multifactor/__init__.py b/src/authentic2/auth2_multifactor/__init__.py new file mode 100644 index 00000000..9efc9308 --- /dev/null +++ b/src/authentic2/auth2_multifactor/__init__.py @@ -0,0 +1,5 @@ +""" +This package contains authentification modules that are not to be used as primary +modes of authentification, but rather as supplementary authentication factors in +order to guarantee higher levels of trust. +""" diff --git a/src/authentic2/auth2_multifactor/auth_oath/__init__.py b/src/authentic2/auth2_multifactor/auth_oath/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/authentic2/auth2_multifactor/auth_oath/authenticator.py b/src/authentic2/auth2_multifactor/auth_oath/authenticator.py new file mode 100644 index 00000000..7f1f02bb --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/authenticator.py @@ -0,0 +1,48 @@ +from django.shortcuts import render +from django.utils.translation import ugettext as _, ugettext_lazy + +from oath import accept_totp + +from authentic2 import utils + +from .forms import TOTPForm +from .views import totp_profile + + +class TOTPAuthenticator(object): + submit_name = 'oath-totp-submit' + auth_level = 2 + + def enabled(self): + # TODO add a setting + return True + + def name(self): + return ugettext_lazy('One-time password') + + def id(self): + return 'totp' + + def login(self, request, *args, **kwargs): + context = kwargs.get('context', {}) + context['submit_name'] = self.submit_name + is_post = request.method == 'POST' and self.submit_name in request.POST + data = request.POST if is_post else None + form = TOTPForm(data) + if is_post: + utils.csrf_token_check(request, form) + if form.is_valid(): + code = form.cleaned_data['code'] + secret = request.user.oath_totp_secret + success, drift = accept_totp(secret.key, str(code), drift=secret.drift) + if success: + secret.drift = drift + secret.save() + request.session['auth_level'] = self.auth_level + return utils.continue_to_next_url(request) + form.add_error('code', _('Invalid code.')) + context['form'] = form + return render(request, 'auth_oath/totp_form.html', context) + + def profile(self, request, *args, **kwargs): + return totp_profile(request, *args, **kwargs) diff --git a/src/authentic2/auth2_multifactor/auth_oath/forms.py b/src/authentic2/auth2_multifactor/auth_oath/forms.py new file mode 100644 index 00000000..5cf70cb4 --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/forms.py @@ -0,0 +1,17 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext as _ + + +def validate_number(value): + try: + int(value) + except ValueError: + raise ValidationError( + _('Code must be a number.'), + ) + + +class TOTPForm(forms.Form): + code = forms.CharField(max_length=6, min_length=6, + validators=[validate_number]) diff --git a/src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py b/src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py new file mode 100644 index 00000000..3133919c --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2019-04-01 08:21 +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): + + initial = True + + dependencies = [ + ('custom_user', '0016_auto_20180925_1107'), + ] + + operations = [ + migrations.CreateModel( + name='OATHTOTPSecret', + fields=[ + ('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')), + ('key', models.CharField(max_length=40)), + ('drift', models.IntegerField(default=0)), + ], + ), + ] diff --git a/src/authentic2/auth2_multifactor/auth_oath/migrations/__init__.py b/src/authentic2/auth2_multifactor/auth_oath/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/authentic2/auth2_multifactor/auth_oath/models.py b/src/authentic2/auth2_multifactor/auth_oath/models.py new file mode 100644 index 00000000..2ad951cd --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/models.py @@ -0,0 +1,11 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import ugettext as _ + + +class OATHTOTPSecret(models.Model): + + user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True, + related_name='oath_totp_secret', verbose_name=_('user')) + key = models.CharField(max_length=40) + drift = models.IntegerField(default=0) 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 new file mode 100644 index 00000000..16da8e6d --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_form.html @@ -0,0 +1,11 @@ +{% load i18n staticfiles %} +
+
+{% csrf_token %} +{{ form.as_p }} + +{% if cancel %} + +{% 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 new file mode 100644 index 00000000..c48ec1e7 --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html @@ -0,0 +1,18 @@ +{% load i18n %} + +{% comment %} +TODO toggle hide/show qrcode +add confirmation when changing it +dynamically update +add explanations +{% endcomment %} + +

QR Code

+ +
+

+ +

+
+ +{% trans "Generate new code" %} diff --git a/src/authentic2/auth2_multifactor/auth_oath/urls.py b/src/authentic2/auth2_multifactor/auth_oath/urls.py new file mode 100644 index 00000000..856bf58c --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'change_secret', views.change_secret), +] diff --git a/src/authentic2/auth2_multifactor/auth_oath/views.py b/src/authentic2/auth2_multifactor/auth_oath/views.py new file mode 100644 index 00000000..7c81085a --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/views.py @@ -0,0 +1,45 @@ +from base64 import b64encode +from io import BytesIO +from random import SystemRandom + +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect +from django.shortcuts import render + +import qrcode +from oath import google_authenticator + +from .models import OATHTOTPSecret + + +def set_secret(request): + key = format(SystemRandom().getrandbits(160), '040x') + secret = OATHTOTPSecret(request.user.id, key) + secret.save() + return secret + + +def get_qrcode(request): + try: + secret = request.user.oath_totp_secret + except OATHTOTPSecret.DoesNotExist: + secret = set_secret(request) + ga = google_authenticator.GoogleAuthenticatorURI() + uri = ga.generate(secret.key, account=request.user.get_full_name(), issuer='Publik') + qr = qrcode.make(uri) + img_bytes = BytesIO() + qr.save(img_bytes, format='png') # ou save dans un httpresponse ? + uri = b64encode(img_bytes.getvalue()) + return uri + + +def totp_profile(request, *args, **kwargs): + context = {} + context['uri'] = get_qrcode(request) + return render(request, 'auth_oath/totp_profile.html', context) + + +@login_required +def change_secret(request): + set_secret(request) + return HttpResponseRedirect('/accounts/') diff --git a/src/authentic2/auth2_multifactor/urls.py b/src/authentic2/auth2_multifactor/urls.py new file mode 100644 index 00000000..22fbbb01 --- /dev/null +++ b/src/authentic2/auth2_multifactor/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import url + + +urlpatterns = [ + url(r'oath/', include('authentic2.auth2_multifactor.auth_oath.urls'), +] diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py index b35b1559..7990922f 100644 --- a/src/authentic2/urls.py +++ b/src/authentic2/urls.py @@ -27,7 +27,8 @@ not_homepage_patterns += [ url(r'^admin/', include(admin.site.urls)), url(r'^idp/', include('authentic2.idp.urls')), url(r'^manage/', include('authentic2.manager.urls')), - url(r'^api/', include('authentic2.api_urls')) + url(r'^api/', include('authentic2.api_urls')), + url(r'', include('authentic2.auth2_multifactor.auth_oath.urls')), ] -- 2.20.1