From bb81bb5a8d061e20bfd4beccddbc6e1c668a2b2a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 25 Feb 2019 12:29:40 +0100 Subject: [PATCH] totp => otp, integrate with factors, new setup form --- .bumpversion.cfg | 4 +- passbook/core/auth/factor.py | 4 - passbook/core/auth/view.py | 44 ++-- passbook/core/settings.py | 2 +- passbook/core/urls.py | 2 +- passbook/lib/boilerplate.py | 12 + passbook/oauth_client/forms.py | 2 + passbook/{totp => otp}/__init__.py | 2 +- passbook/otp/apps.py | 12 + passbook/otp/factors.py | 49 +++++ passbook/otp/forms.py | 66 ++++++ passbook/otp/migrations/0001_initial.py | 28 +++ .../tests => otp/migrations}/__init__.py | 0 passbook/otp/models.py | 24 ++ passbook/otp/requirements.txt | 2 + passbook/{totp => otp}/settings.py | 4 +- .../templates/otp/setup.html} | 0 passbook/otp/templates/otp/user_settings.html | 50 +++++ passbook/otp/urls.py | 12 + passbook/{totp => otp}/utils.py | 2 +- passbook/otp/views.py | 164 ++++++++++++++ passbook/totp/apps.py | 12 - passbook/totp/forms.py | 52 ----- passbook/totp/middleware.py | 32 --- passbook/totp/requirements.txt | 1 - .../totp/templates/totp/user_settings.html | 54 ----- passbook/totp/tests/test_middleware.py | 25 --- passbook/totp/urls.py | 14 -- passbook/totp/views.py | 207 ------------------ requirements.txt | 2 +- 30 files changed, 455 insertions(+), 429 deletions(-) create mode 100644 passbook/lib/boilerplate.py rename passbook/{totp => otp}/__init__.py (50%) create mode 100644 passbook/otp/apps.py create mode 100644 passbook/otp/factors.py create mode 100644 passbook/otp/forms.py create mode 100644 passbook/otp/migrations/0001_initial.py rename passbook/{totp/tests => otp/migrations}/__init__.py (100%) create mode 100644 passbook/otp/models.py create mode 100644 passbook/otp/requirements.txt rename passbook/{totp => otp}/settings.py (62%) rename passbook/{totp/templates/totp/wizard_setup_static.html => otp/templates/otp/setup.html} (100%) create mode 100644 passbook/otp/templates/otp/user_settings.html create mode 100644 passbook/otp/urls.py rename passbook/{totp => otp}/utils.py (95%) create mode 100644 passbook/otp/views.py delete mode 100644 passbook/totp/apps.py delete mode 100644 passbook/totp/forms.py delete mode 100644 passbook/totp/middleware.py delete mode 100644 passbook/totp/requirements.txt delete mode 100644 passbook/totp/templates/totp/user_settings.html delete mode 100644 passbook/totp/tests/test_middleware.py delete mode 100644 passbook/totp/urls.py delete mode 100644 passbook/totp/views.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 12eafcdc6..df6d6bd53 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -9,7 +9,7 @@ tag_name = version/{new_version} [bumpversion:part:release] optional_value = stable -values = +values = alpha beta stable @@ -40,5 +40,5 @@ values = [bumpversion:file:passbook/oauth_provider/__init__.py] -[bumpversion:file:passbook/totp/__init__.py] +[bumpversion:file:passbook/otp/__init__.py] diff --git a/passbook/core/auth/factor.py b/passbook/core/auth/factor.py index 91427651f..4af91625e 100644 --- a/passbook/core/auth/factor.py +++ b/passbook/core/auth/factor.py @@ -1,13 +1,9 @@ """passbook multi-factor authentication engine""" -from logging import getLogger - from django.utils.translation import gettext as _ from django.views.generic import TemplateView from passbook.lib.config import CONFIG -LOGGER = getLogger(__name__) - class AuthenticationFactor(TemplateView): """Abstract Authentication factor, inherits TemplateView but can be combined with FormView""" diff --git a/passbook/core/auth/view.py b/passbook/core/auth/view.py index 69083b867..a63561aba 100644 --- a/passbook/core/auth/view.py +++ b/passbook/core/auth/view.py @@ -24,7 +24,9 @@ class AuthenticationView(UserPassesTestMixin, View): pending_user = None pending_factors = [] - _current_factor = None + _current_factor_class = None + + current_factor = None # Allow only not authenticated users to login def test_func(self): @@ -37,6 +39,7 @@ class AuthenticationView(UserPassesTestMixin, View): return redirect(reverse('passbook_core:overview')) def dispatch(self, request, *args, **kwargs): + print(request.session.keys()) # Extract pending user from session (only remember uid) if AuthenticationView.SESSION_PENDING_USER in request.session: self.pending_user = get_object_or_404( @@ -50,43 +53,47 @@ class AuthenticationView(UserPassesTestMixin, View): else: # Get an initial list of factors which are currently enabled # and apply to the current user. We check policies here and block the request - _all_factors = Factor.objects.filter(enabled=True) + _all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses() self.pending_factors = [] for factor in _all_factors: if factor.passes(self.pending_user): - self.pending_factors.append(factor.type) + self.pending_factors.append((factor.uuid.hex, factor.type)) # Read and instantiate factor from session - factor_class = None + factor_uuid, factor_class = None, None if AuthenticationView.SESSION_FACTOR not in request.session: # Case when no factors apply to user, return error denied if not self.pending_factors: return self.user_invalid() - factor_class = self.pending_factors[0] + factor_uuid, factor_class = self.pending_factors[0] else: - factor_class = request.session[AuthenticationView.SESSION_FACTOR] + factor_uuid, factor_class = request.session[AuthenticationView.SESSION_FACTOR] + # Lookup current factor object + self.current_factor = Factor.objects.filter(uuid=factor_uuid).select_subclasses().first() # Instantiate Next Factor and pass request factor = path_to_class(factor_class) - self._current_factor = factor(self) - self._current_factor.pending_user = self.pending_user - self._current_factor.request = request + self._current_factor_class = factor(self) + self._current_factor_class.pending_user = self.pending_user + self._current_factor_class.request = request return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): """pass get request to current factor""" - LOGGER.debug("Passing GET to %s", class_to_path(self._current_factor.__class__)) - return self._current_factor.get(request, *args, **kwargs) + LOGGER.debug("Passing GET to %s", class_to_path(self._current_factor_class.__class__)) + return self._current_factor_class.get(request, *args, **kwargs) def post(self, request, *args, **kwargs): """pass post request to current factor""" - LOGGER.debug("Passing POST to %s", class_to_path(self._current_factor.__class__)) - return self._current_factor.post(request, *args, **kwargs) + LOGGER.debug("Passing POST to %s", class_to_path(self._current_factor_class.__class__)) + return self._current_factor_class.post(request, *args, **kwargs) def user_ok(self): """Redirect to next Factor""" - LOGGER.debug("Factor %s passed", class_to_path(self._current_factor.__class__)) + LOGGER.debug("Factor %s passed", class_to_path(self._current_factor_class.__class__)) # Remove passed factor from pending factors - if class_to_path(self._current_factor.__class__) in self.pending_factors: - self.pending_factors.remove(class_to_path(self._current_factor.__class__)) + current_factor_tuple = (self.current_factor.uuid.hex, + class_to_path(self._current_factor_class.__class__)) + if current_factor_tuple in self.pending_factors: + self.pending_factors.remove(current_factor_tuple) next_factor = None if self.pending_factors: next_factor = self.pending_factors.pop() @@ -120,11 +127,12 @@ class AuthenticationView(UserPassesTestMixin, View): def _cleanup(self): """Remove temporary data from session""" - session_keys = ['SESSION_FACTOR', 'SESSION_PENDING_FACTORS', - 'SESSION_PENDING_USER', 'SESSION_USER_BACKEND', ] + session_keys = [self.SESSION_FACTOR, self.SESSION_PENDING_FACTORS, + self.SESSION_PENDING_USER, self.SESSION_USER_BACKEND, ] for key in session_keys: if key in self.request.session: del self.request.session[key] + print(self.request.session.keys()) LOGGER.debug("Cleaned up sessions") class FactorPermissionDeniedView(PermissionDeniedView): diff --git a/passbook/core/settings.py b/passbook/core/settings.py index b7bb10876..0cba254c4 100644 --- a/passbook/core/settings.py +++ b/passbook/core/settings.py @@ -71,7 +71,7 @@ INSTALLED_APPS = [ 'passbook.oauth_client.apps.PassbookOAuthClientConfig', 'passbook.oauth_provider.apps.PassbookOAuthProviderConfig', 'passbook.saml_idp.apps.PassbookSAMLIDPConfig', - 'passbook.totp.apps.PassbookTOTPConfig', + 'passbook.otp.apps.PassbookOTPConfig', 'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig', ] diff --git a/passbook/core/urls.py b/passbook/core/urls.py index ff73bde90..3070a408c 100644 --- a/passbook/core/urls.py +++ b/passbook/core/urls.py @@ -19,9 +19,9 @@ core_urls = [ path('auth/login/', authentication.LoginView.as_view(), name='auth-login'), path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'), path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'), + path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'), path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'), path('auth/process//', view.AuthenticationView.as_view(), name='auth-process'), - path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'), # User views path('user/', user.UserSettingsView.as_view(), name='user-settings'), path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'), diff --git a/passbook/lib/boilerplate.py b/passbook/lib/boilerplate.py new file mode 100644 index 000000000..f21cad45e --- /dev/null +++ b/passbook/lib/boilerplate.py @@ -0,0 +1,12 @@ +"""passbook django boilerplate code""" +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache + + +class NeverCacheMixin(): + """Use never_cache as mixin for CBV""" + + @method_decorator(never_cache) + def dispatch(self, *args, **kwargs): + """Use never_cache as mixin for CBV""" + return super().dispatch(*args, **kwargs) diff --git a/passbook/oauth_client/forms.py b/passbook/oauth_client/forms.py index 26121d2d6..6187f4869 100644 --- a/passbook/oauth_client/forms.py +++ b/passbook/oauth_client/forms.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext as _ from passbook.admin.forms.source import SOURCE_FORM_FIELDS from passbook.oauth_client.models import OAuthSource +from passbook.oauth_client.source_types.manager import MANAGER class OAuthSourceForm(forms.ModelForm): @@ -27,6 +28,7 @@ class OAuthSourceForm(forms.ModelForm): 'name': forms.TextInput(), 'consumer_key': forms.TextInput(), 'consumer_secret': forms.TextInput(), + 'provider_type': forms.Select(choices=MANAGER.get_name_tuple()), } labels = { 'request_token_url': _('Request Token URL'), diff --git a/passbook/totp/__init__.py b/passbook/otp/__init__.py similarity index 50% rename from passbook/totp/__init__.py rename to passbook/otp/__init__.py index a286258ec..552145a66 100644 --- a/passbook/totp/__init__.py +++ b/passbook/otp/__init__.py @@ -1,2 +1,2 @@ -"""passbook totp Header""" +"""passbook otp Header""" __version__ = '0.0.6-alpha' diff --git a/passbook/otp/apps.py b/passbook/otp/apps.py new file mode 100644 index 000000000..b7e7e1fa8 --- /dev/null +++ b/passbook/otp/apps.py @@ -0,0 +1,12 @@ +"""passbook OTP AppConfig""" + +from django.apps.config import AppConfig + + +class PassbookOTPConfig(AppConfig): + """passbook OTP AppConfig""" + + name = 'passbook.otp' + label = 'passbook_otp' + verbose_name = 'passbook OTP' + mountpoint = 'user/otp/' diff --git a/passbook/otp/factors.py b/passbook/otp/factors.py new file mode 100644 index 000000000..69bcd224c --- /dev/null +++ b/passbook/otp/factors.py @@ -0,0 +1,49 @@ +"""OTP Factor logic""" +from logging import getLogger + +from django.contrib import messages +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from django_otp import match_token, user_has_device + +from passbook.core.auth.factor import AuthenticationFactor +from passbook.otp.forms import OTPVerifyForm +from passbook.otp.views import OTP_SETTING_UP_KEY, EnableView + +LOGGER = getLogger(__name__) + +class OTPFactor(FormView, AuthenticationFactor): + """OTP Factor View""" + + template_name = 'login/form_with_user.html' + form_class = OTPVerifyForm + + def get(self, request, *args, **kwargs): + """Check if User has OTP enabled and if OTP is enforced""" + if not user_has_device(self.pending_user): + LOGGER.debug("User doesn't have OTP Setup.") + if self.authenticator.current_factor.enforced: + # Redirect to setup view + LOGGER.debug("OTP is enforced, redirecting to setup") + request.user = self.pending_user + LOGGER.debug("Passing GET to EnableView") + return EnableView().dispatch(request) + LOGGER.debug("OTP is not enforced, skipping form") + return self.authenticator.user_ok() + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + """Check if setup is in progress and redirect to EnableView""" + if OTP_SETTING_UP_KEY in request.session: + LOGGER.debug("Passing POST to EnableView") + request.user = self.pending_user + return EnableView().dispatch(request) + return super().post(self, request, *args, **kwargs) + + def form_valid(self, form: OTPVerifyForm): + """Verify OTP Token""" + device = match_token(self.pending_user, form.cleaned_data.get('code')) + if device: + return self.authenticator.user_ok() + messages.error(self.request, _('Invalid OTP.')) + return self.form_invalid(form) diff --git a/passbook/otp/forms.py b/passbook/otp/forms.py new file mode 100644 index 000000000..52c00b3d2 --- /dev/null +++ b/passbook/otp/forms.py @@ -0,0 +1,66 @@ +"""passbook OTP Forms""" + +from django import forms +from django.core.validators import RegexValidator +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _ + +from passbook.core.forms.factors import GENERAL_FIELDS +from passbook.otp.models import OTPFactor + +OTP_CODE_VALIDATOR = RegexValidator(r'^[0-9a-z]{6,8}$', + _('Only alpha-numeric characters are allowed.')) + + +class PictureWidget(forms.widgets.Widget): + """Widget to render value as img-tag""" + + def render(self, name, value, attrs=None, renderer=None): + return mark_safe("" % value) # nosec + + +class OTPVerifyForm(forms.Form): + """Simple Form to verify OTP Code""" + order = ['code'] + + code = forms.CharField(label=_('Code'), validators=[OTP_CODE_VALIDATOR], + widget=forms.TextInput(attrs={ + 'autocomplete': 'off', + 'placeholder': 'Code' + })) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # This is a little helper so the field is focused by default + self.fields['code'].widget.attrs.update({'autofocus': 'autofocus'}) + + +class OTPSetupForm(forms.Form): + """OTP Setup form""" + title = _('Set up OTP') + device = None + qr_code = forms.CharField(widget=PictureWidget, disabled=True, required=False, + label=_('Scan this Code with your OTP App.')) + code = forms.CharField(label=_('Code'), validators=[OTP_CODE_VALIDATOR], + widget=forms.TextInput(attrs={'placeholder': _('One-Time Password')})) + + tokens = forms.MultipleChoiceField(disabled=True, required=False) + + def clean_code(self): + """Check code with new otp device""" + if self.device is not None: + if not self.device.verify_token(int(self.cleaned_data.get('code'))): + raise forms.ValidationError(_("OTP Code does not match")) + return self.cleaned_data.get('code') + +class OTPFactorForm(forms.ModelForm): + """Form to edit OTPFactor instances""" + + class Meta: + + model = OTPFactor + fields = GENERAL_FIELDS + ['enforced'] + widgets = { + 'name': forms.TextInput(), + 'order': forms.NumberInput(), + } diff --git a/passbook/otp/migrations/0001_initial.py b/passbook/otp/migrations/0001_initial.py new file mode 100644 index 000000000..9e7483c43 --- /dev/null +++ b/passbook/otp/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 2.1.7 on 2019-02-25 09:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('passbook_core', '0010_auto_20190224_1016'), + ] + + operations = [ + migrations.CreateModel( + name='OTPFactor', + fields=[ + ('factor_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Factor')), + ('enforced', models.BooleanField(default=False, help_text='Enforce enabled OTP for Users this factor applies to.')), + ], + options={ + 'verbose_name': 'OTP Factor', + 'verbose_name_plural': 'OTP Factors', + }, + bases=('passbook_core.factor',), + ), + ] diff --git a/passbook/totp/tests/__init__.py b/passbook/otp/migrations/__init__.py similarity index 100% rename from passbook/totp/tests/__init__.py rename to passbook/otp/migrations/__init__.py diff --git a/passbook/otp/models.py b/passbook/otp/models.py new file mode 100644 index 000000000..e5d03e205 --- /dev/null +++ b/passbook/otp/models.py @@ -0,0 +1,24 @@ +"""OTP Factor""" + +from django.db import models +from django.utils.translation import gettext as _ + +from passbook.core.models import Factor + + +class OTPFactor(Factor): + """OTP Factor""" + + enforced = models.BooleanField(default=False, help_text=('Enforce enabled OTP for Users ' + 'this factor applies to.')) + + type = 'passbook.otp.factors.OTPFactor' + form = 'passbook.otp.forms.OTPFactorForm' + + def __str__(self): + return "OTP Factor %s" % self.slug + + class Meta: + + verbose_name = _('OTP Factor') + verbose_name_plural = _('OTP Factors') diff --git a/passbook/otp/requirements.txt b/passbook/otp/requirements.txt new file mode 100644 index 000000000..5a3913776 --- /dev/null +++ b/passbook/otp/requirements.txt @@ -0,0 +1,2 @@ +django_otp +qrcode diff --git a/passbook/totp/settings.py b/passbook/otp/settings.py similarity index 62% rename from passbook/totp/settings.py rename to passbook/otp/settings.py index 94377cc24..d622ebab1 100644 --- a/passbook/totp/settings.py +++ b/passbook/otp/settings.py @@ -1,10 +1,8 @@ -"""passbook TOTP Settings""" +"""passbook OTP Settings""" -OTP_LOGIN_URL = 'passbook_totp:totp-verify' OTP_TOTP_ISSUER = 'passbook' MIDDLEWARE = [ 'django_otp.middleware.OTPMiddleware', - 'passbook.totp.middleware.totp_force_verify', ] INSTALLED_APPS = [ 'django_otp', diff --git a/passbook/totp/templates/totp/wizard_setup_static.html b/passbook/otp/templates/otp/setup.html similarity index 100% rename from passbook/totp/templates/totp/wizard_setup_static.html rename to passbook/otp/templates/otp/setup.html diff --git a/passbook/otp/templates/otp/user_settings.html b/passbook/otp/templates/otp/user_settings.html new file mode 100644 index 000000000..ffab10d50 --- /dev/null +++ b/passbook/otp/templates/otp/user_settings.html @@ -0,0 +1,50 @@ +{% extends "user/base.html" %} + +{% load utils %} +{% load i18n %} + +{% block title %} +{% title "OTP" %} +{% endblock %} + +{% block page %} +

+ {% trans "One-Time Passwords" %} +

+
+
+ +
+
+
+
+ {% trans "Your Backup tokens:" %} +
+
+
{% for token in static_tokens %}{{ token.token }}
+{% empty %}{% trans 'N/A' %}{% endfor %}
+
+
+
+
+{% endblock %} diff --git a/passbook/otp/urls.py b/passbook/otp/urls.py new file mode 100644 index 000000000..96574c117 --- /dev/null +++ b/passbook/otp/urls.py @@ -0,0 +1,12 @@ +"""passbook OTP Urls""" + +from django.urls import path + +from passbook.otp import views + +urlpatterns = [ + path('', views.UserSettingsView.as_view(), name='otp-user-settings'), + path('qr/', views.QRView.as_view(), name='otp-qr'), + path('enable/', views.EnableView.as_view(), name='otp-enable'), + path('disable/', views.DisableView.as_view(), name='otp-disable'), +] diff --git a/passbook/totp/utils.py b/passbook/otp/utils.py similarity index 95% rename from passbook/totp/utils.py rename to passbook/otp/utils.py index f3ca643c4..3366853a3 100644 --- a/passbook/totp/utils.py +++ b/passbook/otp/utils.py @@ -1,4 +1,4 @@ -"""passbook Mod TOTP Utils""" +"""passbook OTP Utils""" from django.conf import settings from django.utils.http import urlencode diff --git a/passbook/otp/views.py b/passbook/otp/views.py new file mode 100644 index 000000000..4b0fa9dc7 --- /dev/null +++ b/passbook/otp/views.py @@ -0,0 +1,164 @@ +"""passbook OTP Views""" +from base64 import b32encode +from binascii import unhexlify +from logging import getLogger + +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.views import View +from django.views.generic import FormView, TemplateView +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken +from django_otp.plugins.otp_totp.models import TOTPDevice +from qrcode import make +from qrcode.image.svg import SvgPathImage + +from passbook.lib.boilerplate import NeverCacheMixin +from passbook.lib.config import CONFIG +from passbook.otp.forms import OTPSetupForm +from passbook.otp.utils import otpauth_url + +OTP_SESSION_KEY = 'passbook_otp_key' +OTP_SETTING_UP_KEY = 'passbook_otp_setup' +LOGGER = getLogger(__name__) + +class UserSettingsView(LoginRequiredMixin, TemplateView): + """View for user settings to control OTP""" + + template_name = 'otp/user_settings.html' + + # TODO: Check if OTP Factor exists and applies to user + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + static = StaticDevice.objects.filter(user=self.request.user, confirmed=True) + if static.exists(): + kwargs['static_tokens'] = StaticToken.objects.filter(device=static.first()) \ + .order_by('token') + totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True) + kwargs['state'] = totp_devices.exists() and static.exists() + return kwargs + +class DisableView(LoginRequiredMixin, TemplateView): + """Disable TOTP for user""" + # TODO: Use Django DeleteView with custom delete? + # def + # # Delete all the devices for user + # static = get_object_or_404(StaticDevice, user=request.user, confirmed=True) + # static_tokens = StaticToken.objects.filter(device=static).order_by('token') + # totp = TOTPDevice.objects.filter(user=request.user, confirmed=True) + # static.delete() + # totp.delete() + # for token in static_tokens: + # token.delete() + # messages.success(request, 'Successfully disabled TOTP') + # # Create event with email notification + # # Event.create( + # # user=request.user, + # # message=_('You disabled TOTP.'), + # # current=True, + # # request=request, + # # send_notification=True) + # return redirect(reverse('passbook_core:overview')) + + +class EnableView(LoginRequiredMixin, FormView): + """View to set up OTP""" + + title = _('Set up OTP') + form_class = OTPSetupForm + template_name = 'login/form.html' + + totp_device = None + static_device = None + + # TODO: Check if OTP Factor exists and applies to user + def get_context_data(self, **kwargs): + kwargs['config'] = CONFIG.get('passbook') + kwargs['is_login'] = True + kwargs['title'] = _('Configue OTP') + kwargs['primary_action'] = _('Setup') + return super().get_context_data(**kwargs) + + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + # Check if user has TOTP setup already + finished_totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=True) + finished_static_devices = StaticDevice.objects.filter(user=request.user, confirmed=True) + if finished_totp_devices.exists() and finished_static_devices.exists(): + messages.error(request, _('You already have TOTP enabled!')) + del request.session[OTP_SETTING_UP_KEY] + return redirect('passbook_otp:otp-user-settings') + request.session[OTP_SETTING_UP_KEY] = True + # Check if there's an unconfirmed device left to set up + totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False) + if not totp_devices.exists(): + # Create new TOTPDevice and save it, but not confirm it + self.totp_device = TOTPDevice(user=request.user, confirmed=False) + self.totp_device.save() + else: + self.totp_device = totp_devices.first() + + # Check if we have a static device already + static_devices = StaticDevice.objects.filter(user=request.user, confirmed=False) + if not static_devices.exists(): + # Create new static device and some codes + self.static_device = StaticDevice(user=request.user, confirmed=False) + self.static_device.save() + # Create 9 tokens and save them + # pylint: disable=unused-variable + for counter in range(0, 9): + token = StaticToken(device=self.static_device, token=StaticToken.random_token()) + token.save() + else: + self.static_device = static_devices.first() + + # Somehow convert the generated key to base32 for the QR code + rawkey = unhexlify(self.totp_device.key.encode('ascii')) + request.session[OTP_SESSION_KEY] = b32encode(rawkey).decode("utf-8") + return super().dispatch(request, *args, **kwargs) + + def get_form(self, form_class=None): + form = super().get_form(form_class=form_class) + form.device = self.totp_device + form.fields['qr_code'].initial = reverse('passbook_otp:otp-qr') + tokens = [(x.token, x.token) for x in self.static_device.token_set.all()] + form.fields['tokens'].choices = tokens + return form + + def form_valid(self, form): + # Save device as confirmed + LOGGER.debug("Saved OTP Devices") + self.totp_device.confirmed = True + self.totp_device.save() + self.static_device.confirmed = True + self.static_device.save() + del self.request.session[OTP_SETTING_UP_KEY] + # Create event with email notification + # TODO: Create Audit Log entry + # Event.create( + # user=self.request.user, + # message=_('You activated TOTP.'), + # current=True, + # request=self.request, + # send_notification=True) + return redirect('passbook_otp:otp-user-settings') + +class QRView(NeverCacheMixin, View): + """View returns an SVG image with the OTP token information""" + + def get(self, request: HttpRequest) -> HttpResponse: + """View returns an SVG image with the OTP token information""" + # Get the data from the session + try: + key = request.session[OTP_SESSION_KEY] + except KeyError: + raise Http404 + + url = otpauth_url(accountname=request.user.username, secret=key) + # Make and return QR code + img = make(url, image_factory=SvgPathImage) + resp = HttpResponse(content_type='image/svg+xml; charset=utf-8') + img.save(resp) + return resp diff --git a/passbook/totp/apps.py b/passbook/totp/apps.py deleted file mode 100644 index 90bfc1a86..000000000 --- a/passbook/totp/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook TOTP AppConfig""" - -from django.apps.config import AppConfig - - -class PassbookTOTPConfig(AppConfig): - """passbook TOTP AppConfig""" - - name = 'passbook.totp' - label = 'passbook_totp' - verbose_name = 'passbook TOTP' - mountpoint = 'user/totp/' diff --git a/passbook/totp/forms.py b/passbook/totp/forms.py deleted file mode 100644 index ff35a1226..000000000 --- a/passbook/totp/forms.py +++ /dev/null @@ -1,52 +0,0 @@ -"""passbook TOTP Forms""" - -from django import forms -from django.core.validators import RegexValidator -from django.utils.safestring import mark_safe -from django.utils.translation import ugettext_lazy as _ - -TOTP_CODE_VALIDATOR = RegexValidator(r'^[0-9a-z]{6,8}$', - _('Only alpha-numeric characters are allowed.')) - - -class PictureWidget(forms.widgets.Widget): - """Widget to render value as img-tag""" - - def render(self, name, value, attrs=None, renderer=None): - return mark_safe("" % value) # nosec - - -class TOTPVerifyForm(forms.Form): - """Simple Form to verify TOTP Code""" - order = ['code'] - - code = forms.CharField(label=_('Code'), validators=[TOTP_CODE_VALIDATOR], - widget=forms.TextInput(attrs={'autocomplete': 'off'})) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # This is a little helper so the field is focused by default - self.fields['code'].widget.attrs.update({'autofocus': 'autofocus'}) - - -class TOTPSetupInitForm(forms.Form): - """Initial TOTP Setup form""" - title = _('Set up TOTP') - device = None - confirmed = False - qr_code = forms.CharField(widget=PictureWidget, disabled=True, required=False, - label=_('Scan this Code with your TOTP App.')) - code = forms.CharField(label=_('Code'), validators=[TOTP_CODE_VALIDATOR]) - - def clean_code(self): - """Check code with new totp device""" - if self.device is not None: - if not self.device.verify_token(int(self.cleaned_data.get('code'))) \ - and not self.confirmed: - raise forms.ValidationError(_("TOTP Code does not match")) - return self.cleaned_data.get('code') - - -class TOTPSetupStaticForm(forms.Form): - """Static form to show generated static tokens""" - tokens = forms.MultipleChoiceField(disabled=True, required=False) diff --git a/passbook/totp/middleware.py b/passbook/totp/middleware.py deleted file mode 100644 index f435e51ea..000000000 --- a/passbook/totp/middleware.py +++ /dev/null @@ -1,32 +0,0 @@ -"""passbook TOTP Middleware to force users with TOTP set up to verify""" - -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.http import urlencode -from django_otp import user_has_device - - -def totp_force_verify(get_response): - """Middleware to force TOTP Verification""" - - def middleware(request): - """Middleware to force TOTP Verification""" - - # pylint: disable=too-many-boolean-expressions - if request.user.is_authenticated and \ - user_has_device(request.user) and \ - not request.user.is_verified() and \ - request.path != reverse('passbook_totp:totp-verify') and \ - request.path != reverse('passbook_core:auth-logout') and \ - not request.META.get('HTTP_AUTHORIZATION', '').startswith('Bearer'): - # User has TOTP set up but is not verified - - # At this point the request is already forwarded to the target destination - # So we just add the current request's path as next parameter - args = '?%s' % urlencode({'next': request.get_full_path()}) - return redirect(reverse('passbook_totp:totp-verify') + args) - - response = get_response(request) - return response - - return middleware diff --git a/passbook/totp/requirements.txt b/passbook/totp/requirements.txt deleted file mode 100644 index f474aaf53..000000000 --- a/passbook/totp/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -django-two-factor-auth diff --git a/passbook/totp/templates/totp/user_settings.html b/passbook/totp/templates/totp/user_settings.html deleted file mode 100644 index f9f92b5bb..000000000 --- a/passbook/totp/templates/totp/user_settings.html +++ /dev/null @@ -1,54 +0,0 @@ -{% extends "user/base.html" %} - -{% load utils %} -{% load i18n %} -{% load hostname %} -{% load setting %} -{% load fieldtype %} - -{% block title %} -{% title "Overview" %} -{% endblock %} - -{% block content %} -

{% trans "2-Factor Authentication" %}

-
-
-
-
- {% trans "Status" %} -
- -
-
-
-
-
- {% trans "Your Backup tokens:" %} -
-
-
{% for token in static_tokens %}{{ token.token }}
-{% endfor %}
-
-
-
-
-{% endblock %} diff --git a/passbook/totp/tests/test_middleware.py b/passbook/totp/tests/test_middleware.py deleted file mode 100644 index 600bc2aeb..000000000 --- a/passbook/totp/tests/test_middleware.py +++ /dev/null @@ -1,25 +0,0 @@ -"""passbook TOTP Middleware Test""" - -import os - -from django.contrib.auth.models import AnonymousUser -from django.test import RequestFactory, TestCase -from django.urls import reverse - -from passbook.core.views import overview -from passbook.totp.middleware import totp_force_verify - - -class TestMiddleware(TestCase): - """passbook TOTP Middleware Test""" - - def setUp(self): - os.environ['RECAPTCHA_TESTING'] = 'True' - self.factory = RequestFactory() - - def test_totp_force_verify_anon(self): - """Test Anonymous TFA Force""" - request = self.factory.get(reverse('passbook_core:overview')) - request.user = AnonymousUser() - response = totp_force_verify(overview.OverviewView.as_view())(request) - self.assertEqual(response.status_code, 302) diff --git a/passbook/totp/urls.py b/passbook/totp/urls.py deleted file mode 100644 index d2c036751..000000000 --- a/passbook/totp/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -"""passbook TOTP Urls""" - -from django.urls import path - -from passbook.totp import views - -urlpatterns = [ - path('', views.index, name='totp-index'), - path('qr/', views.qr_code, name='totp-qr'), - path('verify/', views.verify, name='totp-verify'), - # path('enable/', views.TFASetupView.as_view(), name='totp-enable'), - path('disable/', views.disable, name='totp-disable'), - path('user_settings/', views.user_settings, name='totp-user_settings'), -] diff --git a/passbook/totp/views.py b/passbook/totp/views.py deleted file mode 100644 index 23ed83177..000000000 --- a/passbook/totp/views.py +++ /dev/null @@ -1,207 +0,0 @@ -"""passbook TOTP Views""" -# from base64 import b32encode -# from binascii import unhexlify - -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.http import Http404, HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.utils.translation import ugettext as _ -from django.views.decorators.cache import never_cache -from django_otp import login, match_token, user_has_device -from django_otp.decorators import otp_required -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken -from django_otp.plugins.otp_totp.models import TOTPDevice -from qrcode import make as qr_make -from qrcode.image.svg import SvgPathImage - -from passbook.lib.decorators import reauth_required -# from passbook.core.models import Event -# from passbook.core.views.wizards import BaseWizardView -from passbook.totp.forms import TOTPVerifyForm -from passbook.totp.utils import otpauth_url - -TFA_SESSION_KEY = 'passbook_2fa_key' - - -@login_required -@reauth_required -def index(request: HttpRequest) -> HttpResponse: - """Show empty index page""" - return render(request, 'core/generic.html', { - 'text': 'Test TOTP passed' - }) - - -@login_required -def verify(request: HttpRequest) -> HttpResponse: - """Verify TOTP Token""" - if not user_has_device(request.user): - messages.error(request, _("You don't have 2-Factor Authentication set up.")) - if request.method == 'POST': - form = TOTPVerifyForm(request.POST) - if form.is_valid(): - device = match_token(request.user, form.cleaned_data.get('code')) - if device: - login(request, device) - messages.success(request, _('Successfully validated TOTP Token.')) - # Check if there is a next GET parameter and redirect to that - if 'next' in request.GET: - return redirect(request.GET.get('next')) - # Otherwise just index - return redirect(reverse('passbook_core:overview')) - messages.error(request, _('Invalid 2-Factor Token.')) - else: - form = TOTPVerifyForm() - - return render(request, 'generic/form_login.html', { - 'form': form, - 'title': _("SSO - Two-factor verification"), - 'primary_action': _("Verify"), - 'extra_links': { - 'passbook_core:auth-logout': 'Logout', - } - }) - - -@login_required -def user_settings(request: HttpRequest) -> HttpResponse: - """View for user settings to control TOTP""" - static = get_object_or_404(StaticDevice, user=request.user, confirmed=True) - static_tokens = StaticToken.objects.filter(device=static).order_by('token') - finished_totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=True) - finished_static_devices = StaticDevice.objects.filter(user=request.user, confirmed=True) - state = finished_totp_devices.exists() and finished_static_devices.exists() - return render(request, 'totp/user_settings.html', { - 'static_tokens': static_tokens, - 'state': state, - }) - - -@login_required -@reauth_required -@otp_required -def disable(request: HttpRequest) -> HttpResponse: - """Disable TOTP for user""" - # Delete all the devices for user - static = get_object_or_404(StaticDevice, user=request.user, confirmed=True) - static_tokens = StaticToken.objects.filter(device=static).order_by('token') - totp = TOTPDevice.objects.filter(user=request.user, confirmed=True) - static.delete() - totp.delete() - for token in static_tokens: - token.delete() - messages.success(request, 'Successfully disabled TOTP') - # Create event with email notification - # Event.create( - # user=request.user, - # message=_('You disabled TOTP.'), - # current=True, - # request=request, - # send_notification=True) - return redirect(reverse('passbook_core:overview')) - - -# # pylint: disable=too-many-ancestors -# @method_decorator([login_required, reauth_required], name="dispatch") -# class TFASetupView(BaseWizardView): -# """Wizard to create a Mail Account""" - -# title = _('Set up TOTP') -# form_list = [TFASetupInitForm, TFASetupStaticForm] - -# totp_device = None -# static_device = None -# confirmed = False - -# def get_template_names(self): -# if self.steps.current == '1': -# return 'totp/wizard_setup_static.html' -# return self.template_name - -# def handle_request(self, request: HttpRequest): -# # Check if user has TOTP setup already -# finished_totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=True) -# finished_static_devices = StaticDevice.objects.filter(user=request.user, confirmed=True) -# if finished_totp_devices.exists() or finished_static_devices.exists(): -# messages.error(request, _('You already have TOTP enabled!')) -# return redirect(reverse('passbook_core:overview')) -# # Check if there's an unconfirmed device left to set up -# totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False) -# if not totp_devices.exists(): -# # Create new TOTPDevice and save it, but not confirm it -# self.totp_device = TOTPDevice(user=request.user, confirmed=False) -# self.totp_device.save() -# else: -# self.totp_device = totp_devices.first() - -# # Check if we have a static device already -# static_devices = StaticDevice.objects.filter(user=request.user, confirmed=False) -# if not static_devices.exists(): -# # Create new static device and some codes -# self.static_device = StaticDevice(user=request.user, confirmed=False) -# self.static_device.save() -# # Create 9 tokens and save them -# # pylint: disable=unused-variable -# for counter in range(0, 9): -# token = StaticToken(device=self.static_device, token=StaticToken.random_token()) -# token.save() -# else: -# self.static_device = static_devices.first() - -# # Somehow convert the generated key to base32 for the QR code -# rawkey = unhexlify(self.totp_device.key.encode('ascii')) -# request.session[TFA_SESSION_KEY] = b32encode(rawkey).decode("utf-8") -# return True - -# def get_form(self, step=None, data=None, files=None): -# form = super(TFASetupView, self).get_form(step, data, files) -# if step is None: -# step = self.steps.current -# if step == '0': -# form.confirmed = self.confirmed -# form.device = self.totp_device -# form.fields['qr_code'].initial = reverse('passbook_tfa:tfa-qr') -# elif step == '1': -# # This is a bit of a hack, but the 2fa token from step 1 has been checked here -# # And we need to save it, otherwise it's going to fail in render_done -# # and we're going to be redirected to step0 -# self.confirmed = True - -# tokens = [(x.token, x.token) for x in self.static_device.token_set.all()] -# form.fields['tokens'].choices = tokens -# return form - -# def finish(self, *forms): -# # Save device as confirmed -# self.totp_device.confirmed = True -# self.totp_device.save() -# self.static_device.confirmed = True -# self.static_device.save() -# # Create event with email notification -# Event.create( -# user=self.request.user, -# message=_('You activated TOTP.'), -# current=True, -# request=self.request, -# send_notification=True) -# return redirect(reverse('passbook_tfa:tfa-index')) - - -@never_cache -@login_required -def qr_code(request: HttpRequest) -> HttpResponse: - """View returns an SVG image with the OTP token information""" - # Get the data from the session - try: - key = request.session[TFA_SESSION_KEY] - except KeyError: - raise Http404 - - url = otpauth_url(accountname=request.user.username, secret=key) - # Make and return QR code - img = qr_make(url, image_factory=SvgPathImage) - resp = HttpResponse(content_type='image/svg+xml; charset=utf-8') - img.save(resp) - return resp diff --git a/requirements.txt b/requirements.txt index 24b554dea..65aceaac4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ -r passbook/oauth_client/requirements.txt -r passbook/ldap/requirements.txt -r passbook/saml_idp/requirements.txt --r passbook/totp/requirements.txt +-r passbook/otp/requirements.txt -r passbook/oauth_provider/requirements.txt -r passbook/audit/requirements.txt -r passbook/captcha_factor/requirements.txt