From ee958517daaa6d3d4243058bd0ae7fb36ddbc0e6 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 2 Apr 2019 12:09:29 +0200 Subject: [PATCH 2/4] 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 | 22 +++++++++ .../auth_oath/authenticators.py | 27 ++++++++++ .../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 | 11 +++++ .../auth2_multifactor/auth_oath/urls.py | 8 +++ .../auth2_multifactor/auth_oath/utils.py | 38 ++++++++++++++ .../auth2_multifactor/auth_oath/views.py | 49 +++++++++++++++++++ 15 files changed, 240 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/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 diff --git a/MANIFEST.in b/MANIFEST.in index 78aaa189..b63cd427 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -22,6 +22,7 @@ recursive-include src/authentic2/auth2_auth/auth2_oath/templates *.html *.txt *. recursive-include src/authentic2_auth_saml/templates/authentic2_auth_saml *.html recursive-include src/authentic2_auth_oidc/templates/authentic2_auth_oidc *.html recursive-include src/authentic2_idp_oidc/templates/authentic2_idp_oidc *.html +recursive-include src/authentic2/auth2_multifactor/auth_oath/templates *.html recursive-include src/authentic2/saml/fixtures *.json recursive-include src/authentic2/locale *.po *.mo diff --git a/setup.py b/setup.py index fd53c63c..106a43d2 100755 --- a/setup.py +++ b/setup.py @@ -139,6 +139,8 @@ setup(name="authentic2", 'xstatic-select2', 'pillow', 'tablib', + 'qrcode', + 'oath', ], zip_safe=False, classifiers=[ @@ -172,5 +174,6 @@ setup(name="authentic2", 'authentic2-idp-cas = authentic2_idp_cas:Plugin', 'authentic2-idp-oidc = authentic2_idp_oidc:Plugin', 'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin', + 'authentic2-auth-oath = authentic2.auth2_multifactor.auth_oath:Plugin', ], }) 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..2e45f579 --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/__init__.py @@ -0,0 +1,10 @@ +class Plugin(object): + def get_before_urls(self): + from . import urls + return urls.urlpatterns + + def get_apps(self): + return [__name__] + + def get_authenticators(self): + return ['authentic2.auth2_multifactor.auth_oath.authenticators.TOTPAuthenticator'] diff --git a/src/authentic2/auth2_multifactor/auth_oath/app_settings.py b/src/authentic2/auth2_multifactor/auth_oath/app_settings.py new file mode 100644 index 00000000..97b299bc --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/app_settings.py @@ -0,0 +1,22 @@ +class AppSettings(object): + __DEFAULTS = { + 'ENABLE': True, + 'LEVEL': 2, + } + + def __init__(self, prefix): + self.prefix = prefix + + def _setting(self, name, dflt): + from django.conf import settings + return getattr(settings, self.prefix+name, dflt) + + def __getattr__(self, name): + if name not in self.__DEFAULTS: + raise AttributeError(name) + return self._setting(name, self.__DEFAULTS[name]) + +import sys +app_settings = AppSettings('A2_AUTH_OATH_') +app_settings.__name__ = __name__ +sys.modules[__name__] = app_settings diff --git a/src/authentic2/auth2_multifactor/auth_oath/authenticators.py b/src/authentic2/auth2_multifactor/auth_oath/authenticators.py new file mode 100644 index 00000000..e39c4c74 --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/authenticators.py @@ -0,0 +1,27 @@ +from django.shortcuts import render +from django.utils.translation import ugettext as _, ugettext_lazy + +from oath import accept_totp + +from . import app_settings +from .views import totp_profile, totp_login + + +class TOTPAuthenticator(object): + submit_name = 'oath-totp-submit' + auth_level = app_settings.LEVEL + + def enabled(self): + return app_settings.ENABLE + + def name(self): + return ugettext_lazy('One-time password') + + def id(self): + return 'multifactor-totp' + + def login(self, request, *args, **kwargs): + return totp_login(request, *args, **kwargs) + + 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..90f49510 --- /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..3ba954e7 --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/templates/auth_oath/totp_profile.html @@ -0,0 +1,11 @@ +{% load i18n %} + +

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..57e85137 --- /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, name='totp-change-secret'), +] diff --git a/src/authentic2/auth2_multifactor/auth_oath/utils.py b/src/authentic2/auth2_multifactor/auth_oath/utils.py new file mode 100644 index 00000000..2fc54086 --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/utils.py @@ -0,0 +1,38 @@ +from base64 import b64encode +from io import BytesIO +from random import SystemRandom + +from qrcode import QRCode +from oath import google_authenticator + +from .models import OATHTOTPSecret + + +def get_qrcode(user): + try: + secret = user.oath_totp_secret + except OATHTOTPSecret.DoesNotExist: + secret = set_secret(user) + ga = google_authenticator.GoogleAuthenticatorURI() + account = user.get_full_name().encode('utf-8') + uri = ga.generate(secret.key, account=account, issuer='Publik') + qr = QRCode(box_size=5) + qr.add_data(uri) + qr.make(fit=True) + img = qr.make_image() + img_bytes = BytesIO() + img.save(img_bytes, format='png') + uri = b64encode(img_bytes.getvalue()) + return uri + + +def set_secret(user): + key = format(SystemRandom().getrandbits(160), '040x') + secret = OATHTOTPSecret(user.id, key) + secret.save() + return secret + + +def get_authenticator_level(): + from .authenticators import TOTPAuthenticator + return TOTPAuthenticator.auth_level 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..07e6d3d1 --- /dev/null +++ b/src/authentic2/auth2_multifactor/auth_oath/views.py @@ -0,0 +1,49 @@ +from django.contrib.auth.decorators import login_required +from django.template.loader import render_to_string +from django.utils.translation import ugettext as _, ugettext_lazy +from django.views.generic.edit import FormView + +from oath import accept_totp + +from authentic2.utils import redirect, get_next_url, csrf_token_check + +from .forms import TOTPForm +from .utils import get_qrcode, set_secret, get_authenticator_level + + +def totp_profile(request, *args, **kwargs): + context = {} + context['uri'] = get_qrcode(request.user) + return render_to_string('auth_oath/totp_profile.html', context, request=request) + +class Login(FormView): + template_name = 'auth_oath/totp_form.html' + form_class = TOTPForm + + def get_context_data(self, **kwargs): + kwargs['submit_name'] = 'oath-totp-submit' + return super(Login, self).get_context_data(**kwargs) + + def get_success_url(self): + return get_next_url(self.request.GET) + + def form_valid(self, form): + code = form.cleaned_data['code'] + secret = self.request.user.oath_totp_secret + success, drift = accept_totp(secret.key, str(code), drift=secret.drift) + csrf_token_check(self.request, form) + if success: + secret.drift = drift + secret.save() + self.request.session['auth_level'] = get_authenticator_level() + return super(Login, self).form_valid(form) + form.add_error('code', _('Invalid code.')) + return self.form_invalid(form) + +totp_login = Login.as_view() + + +@login_required +def change_secret(request): + set_secret(request.user) + return redirect(request, 'account_management') -- 2.20.1