From 8c36ab89e8bb021ec8ed65c731d41d3bbd2fe547 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 28 Jun 2020 10:30:35 +0200 Subject: [PATCH] stages/otp: start separation into 3 stages, otp_time, otp_static and otp_validate --- passbook/api/v2/urls.py | 4 +- passbook/root/settings.py | 3 +- passbook/stages/otp/api.py | 21 --- passbook/stages/otp/apps.py | 12 -- passbook/stages/otp/models.py | 34 ---- passbook/stages/otp/settings.py | 10 -- passbook/stages/otp/stage.py | 59 ------- .../otp/templates/stages/otp/factor.html | 8 - passbook/stages/otp/urls.py | 12 -- passbook/stages/otp/utils.py | 17 -- passbook/stages/otp/views.py | 166 ------------------ passbook/stages/{otp => otp_time}/__init__.py | 0 passbook/stages/otp_time/api.py | 21 +++ passbook/stages/otp_time/apps.py | 9 + passbook/stages/{otp => otp_time}/forms.py | 44 +---- .../otp_time/migrations/0001_initial.py | 38 ++++ .../{otp => otp_time}/migrations/__init__.py | 0 passbook/stages/otp_time/models.py | 38 ++++ passbook/stages/otp_time/settings.py | 3 + passbook/stages/otp_time/stage.py | 79 +++++++++ .../stages/otp_time}/user_settings.html | 17 +- passbook/stages/otp_time/urls.py | 8 + passbook/stages/otp_time/views.py | 38 ++++ passbook/stages/otp_validate/__init__.py | 0 passbook/stages/otp_validate/api.py | 24 +++ passbook/stages/otp_validate/apps.py | 8 + passbook/stages/otp_validate/forms.py | 38 ++++ .../migrations/0001_initial.py | 18 +- .../otp_validate/migrations/__init__.py | 0 passbook/stages/otp_validate/models.py | 23 +++ passbook/stages/otp_validate/settings.py | 3 + passbook/stages/otp_validate/stage.py | 45 +++++ swagger.yaml | 54 +++--- 33 files changed, 424 insertions(+), 430 deletions(-) delete mode 100644 passbook/stages/otp/api.py delete mode 100644 passbook/stages/otp/apps.py delete mode 100644 passbook/stages/otp/models.py delete mode 100644 passbook/stages/otp/settings.py delete mode 100644 passbook/stages/otp/stage.py delete mode 100644 passbook/stages/otp/templates/stages/otp/factor.html delete mode 100644 passbook/stages/otp/urls.py delete mode 100644 passbook/stages/otp/utils.py delete mode 100644 passbook/stages/otp/views.py rename passbook/stages/{otp => otp_time}/__init__.py (100%) create mode 100644 passbook/stages/otp_time/api.py create mode 100644 passbook/stages/otp_time/apps.py rename passbook/stages/{otp => otp_time}/forms.py (52%) create mode 100644 passbook/stages/otp_time/migrations/0001_initial.py rename passbook/stages/{otp => otp_time}/migrations/__init__.py (100%) create mode 100644 passbook/stages/otp_time/models.py create mode 100644 passbook/stages/otp_time/settings.py create mode 100644 passbook/stages/otp_time/stage.py rename passbook/stages/{otp/templates/stages/otp => otp_time/templates/stages/otp_time}/user_settings.html (53%) create mode 100644 passbook/stages/otp_time/urls.py create mode 100644 passbook/stages/otp_time/views.py create mode 100644 passbook/stages/otp_validate/__init__.py create mode 100644 passbook/stages/otp_validate/api.py create mode 100644 passbook/stages/otp_validate/apps.py create mode 100644 passbook/stages/otp_validate/forms.py rename passbook/stages/{otp => otp_validate}/migrations/0001_initial.py (64%) create mode 100644 passbook/stages/otp_validate/migrations/__init__.py create mode 100644 passbook/stages/otp_validate/models.py create mode 100644 passbook/stages/otp_validate/settings.py create mode 100644 passbook/stages/otp_validate/stage.py diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 8b2ee36f3..44916d093 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -36,7 +36,7 @@ from passbook.stages.dummy.api import DummyStageViewSet from passbook.stages.email.api import EmailStageViewSet from passbook.stages.identification.api import IdentificationStageViewSet from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet -from passbook.stages.otp.api import OTPStageViewSet +from passbook.stages.otp_validate.api import OTPValidateStageViewSet from passbook.stages.password.api import PasswordStageViewSet from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet from passbook.stages.user_delete.api import UserDeleteStageViewSet @@ -89,7 +89,7 @@ router.register("stages/email", EmailStageViewSet) router.register("stages/identification", IdentificationStageViewSet) router.register("stages/invitation", InvitationStageViewSet) router.register("stages/invitation/invitations", InvitationViewSet) -router.register("stages/otp", OTPStageViewSet) +router.register("stages/otp_validate", OTPValidateStageViewSet) router.register("stages/password", PasswordStageViewSet) router.register("stages/prompt/stages", PromptStageViewSet) router.register("stages/prompt/prompts", PromptViewSet) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 43983c159..04b282221 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -107,7 +107,8 @@ INSTALLED_APPS = [ "passbook.stages.user_login.apps.PassbookStageUserLoginConfig", "passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig", "passbook.stages.user_write.apps.PassbookStageUserWriteConfig", - "passbook.stages.otp.apps.PassbookStageOTPConfig", + "passbook.stages.otp_time.apps.PassbookStageOTPTimeConfig", + "passbook.stages.otp_validate.apps.PassbookStageOTPValidateConfig", "passbook.stages.password.apps.PassbookStagePasswordConfig", "passbook.static.apps.PassbookStaticConfig", ] diff --git a/passbook/stages/otp/api.py b/passbook/stages/otp/api.py deleted file mode 100644 index 9378a4cb0..000000000 --- a/passbook/stages/otp/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""OTPStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.otp.models import OTPStage - - -class OTPStageSerializer(ModelSerializer): - """OTPStage Serializer""" - - class Meta: - - model = OTPStage - fields = ["pk", "name", "enforced"] - - -class OTPStageViewSet(ModelViewSet): - """OTPStage Viewset""" - - queryset = OTPStage.objects.all() - serializer_class = OTPStageSerializer diff --git a/passbook/stages/otp/apps.py b/passbook/stages/otp/apps.py deleted file mode 100644 index 88b5ce441..000000000 --- a/passbook/stages/otp/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook OTP AppConfig""" - -from django.apps.config import AppConfig - - -class PassbookStageOTPConfig(AppConfig): - """passbook OTP AppConfig""" - - name = "passbook.stages.otp" - label = "passbook_stages_otp" - verbose_name = "passbook Stages.OTP" - mountpoint = "user/otp/" diff --git a/passbook/stages/otp/models.py b/passbook/stages/otp/models.py deleted file mode 100644 index f0a6e1c88..000000000 --- a/passbook/stages/otp/models.py +++ /dev/null @@ -1,34 +0,0 @@ -"""OTP Stage""" -from django.db import models -from django.utils.translation import gettext as _ - -from passbook.core.types import UIUserSettings -from passbook.flows.models import Stage - - -class OTPStage(Stage): - """OTP Stage""" - - enforced = models.BooleanField( - default=False, - help_text=("Enforce enabled OTP for Users " "this stage applies to."), - ) - - type = "passbook.stages.otp.stages.OTPStage" - form = "passbook.stages.otp.forms.OTPStageForm" - - @property - def ui_user_settings(self) -> UIUserSettings: - return UIUserSettings( - name="OTP", - icon="pficon-locked", - view_name="passbook_stages_otp:otp-user-settings", - ) - - def __str__(self): - return f"OTP Stage {self.name}" - - class Meta: - - verbose_name = _("OTP Stage") - verbose_name_plural = _("OTP Stages") diff --git a/passbook/stages/otp/settings.py b/passbook/stages/otp/settings.py deleted file mode 100644 index 6bd9d8f83..000000000 --- a/passbook/stages/otp/settings.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook OTP Settings""" - -MIDDLEWARE = [ - "django_otp.middleware.OTPMiddleware", -] -INSTALLED_APPS = [ - "django_otp", - "django_otp.plugins.otp_static", - "django_otp.plugins.otp_totp", -] diff --git a/passbook/stages/otp/stage.py b/passbook/stages/otp/stage.py deleted file mode 100644 index 7d303a9c1..000000000 --- a/passbook/stages/otp/stage.py +++ /dev/null @@ -1,59 +0,0 @@ -"""OTP Stage logic""" -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 structlog import get_logger - -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.stages.otp.forms import OTPVerifyForm -from passbook.stages.otp.views import OTP_SETTING_UP_KEY, EnableView - -LOGGER = get_logger() - - -class OTPStage(FormView, StageView): - """OTP Stage View""" - - template_name = "stages/otp/stage.html" - form_class = OTPVerifyForm - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["title"] = _("Enter Verification Code") - return kwargs - - def get(self, request, *args, **kwargs): - """Check if User has OTP enabled and if OTP is enforced""" - pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - if not user_has_device(pending_user): - LOGGER.debug("User doesn't have OTP Setup.") - if self.executor.current_stage.enforced: - # Redirect to setup view - LOGGER.debug("OTP is enforced, redirecting to setup") - request.user = pending_user - messages.info(request, _("OTP is enforced. Please setup OTP.")) - return EnableView.as_view()(request) - LOGGER.debug("OTP is not enforced, skipping form") - return self.executor.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.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - return EnableView.as_view()(request) - return super().post(self, request, *args, **kwargs) - - def form_valid(self, form: OTPVerifyForm): - """Verify OTP Token""" - device = match_token( - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], - form.cleaned_data.get("code"), - ) - if device: - return self.executor.stage_ok() - messages.error(self.request, _("Invalid OTP.")) - return self.form_invalid(form) diff --git a/passbook/stages/otp/templates/stages/otp/factor.html b/passbook/stages/otp/templates/stages/otp/factor.html deleted file mode 100644 index c95f13cbe..000000000 --- a/passbook/stages/otp/templates/stages/otp/factor.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'login/form_with_user.html' %} - -{% load i18n %} - -{% block above_form %} -{{ block.super }} -

{% trans 'Enter the Verification Code from your Authenticator App.' %}

-{% endblock %} diff --git a/passbook/stages/otp/urls.py b/passbook/stages/otp/urls.py deleted file mode 100644 index 012ff2923..000000000 --- a/passbook/stages/otp/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook OTP Urls""" - -from django.urls import path - -from passbook.stages.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/stages/otp/utils.py b/passbook/stages/otp/utils.py deleted file mode 100644 index 978803747..000000000 --- a/passbook/stages/otp/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -"""passbook OTP Utils""" - -from django.utils.http import urlencode - - -def otpauth_url(accountname, secret, issuer=None, digits=6): - """Create otpauth according to - https://github.com/google/google-authenticator/wiki/Key-Uri-Format""" - # Ensure that the secret parameter is the FIRST parameter of the URI, this - # allows Microsoft Authenticator to work. - query = [ - ("secret", secret), - ("digits", digits), - ("issuer", "passbook"), - ] - - return "otpauth://totp/%s:%s?%s" % (issuer, accountname, urlencode(query)) diff --git a/passbook/stages/otp/views.py b/passbook/stages/otp/views.py deleted file mode 100644 index 82ff8b333..000000000 --- a/passbook/stages/otp/views.py +++ /dev/null @@ -1,166 +0,0 @@ -"""passbook OTP Views""" -from base64 import b32encode -from binascii import unhexlify - -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import Http404, HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.urls import reverse -from django.utils.decorators import method_decorator -from django.utils.translation import ugettext as _ -from django.views import View -from django.views.decorators.cache import never_cache -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 structlog import get_logger - -from passbook.audit.models import Event, EventAction -from passbook.lib.config import CONFIG -from passbook.stages.otp.forms import OTPSetupForm -from passbook.stages.otp.utils import otpauth_url - -OTP_SESSION_KEY = "passbook_stages_otp_key" -OTP_SETTING_UP_KEY = "passbook_stages_otp_setup" -LOGGER = get_logger() - - -class UserSettingsView(LoginRequiredMixin, TemplateView): - """View for user settings to control OTP""" - - template_name = "stages/otp/user_settings.html" - - # TODO: Check if OTP Stage 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, View): - """Disable TOTP for user""" - - def get(self, request: HttpRequest) -> HttpResponse: - """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 OTP") - # Create event with email notification - Event.new(EventAction.CUSTOM, message="User disabled OTP.").from_http(request) - return redirect(reverse("passbook_stages_otp:otp-user-settings")) - - -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 Stage exists and applies to user - def get_context_data(self, **kwargs): - kwargs["config"] = CONFIG.y("passbook") - kwargs["title"] = _("Configure 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_stages_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 - # TODO: Send static tokens via Email - 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_stages_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] - Event.new(EventAction.CUSTOM, message="User enabled OTP.").from_http( - self.request - ) - return redirect("passbook_stages_otp:otp-user-settings") - - -@method_decorator(never_cache, name="dispatch") -class QRView(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/stages/otp/__init__.py b/passbook/stages/otp_time/__init__.py similarity index 100% rename from passbook/stages/otp/__init__.py rename to passbook/stages/otp_time/__init__.py diff --git a/passbook/stages/otp_time/api.py b/passbook/stages/otp_time/api.py new file mode 100644 index 000000000..3ce955c01 --- /dev/null +++ b/passbook/stages/otp_time/api.py @@ -0,0 +1,21 @@ +"""OTPTimeStage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from passbook.stages.otp_time.models import OTPTimeStage + + +class OTPTimeStageSerializer(ModelSerializer): + """OTPTimeStage Serializer""" + + class Meta: + + model = OTPTimeStage + fields = ["pk", "name", "digits"] + + +class OTPTimeStageViewSet(ModelViewSet): + """OTPTimeStage Viewset""" + + queryset = OTPTimeStage.objects.all() + serializer_class = OTPTimeStageSerializer diff --git a/passbook/stages/otp_time/apps.py b/passbook/stages/otp_time/apps.py new file mode 100644 index 000000000..854be8ab8 --- /dev/null +++ b/passbook/stages/otp_time/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class PassbookStageOTPTimeConfig(AppConfig): + + name = "passbook.stages.otp_time" + label = "passbook_stages_otp_time" + verbose_name = "passbook OTP.Time" + mountpoint = "-/user/otp/time/" diff --git a/passbook/stages/otp/forms.py b/passbook/stages/otp_time/forms.py similarity index 52% rename from passbook/stages/otp/forms.py rename to passbook/stages/otp_time/forms.py index 0033667cf..490ac569f 100644 --- a/passbook/stages/otp/forms.py +++ b/passbook/stages/otp_time/forms.py @@ -1,16 +1,10 @@ -"""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 gettext_lazy as _ from django_otp.models import Device -from passbook.stages.otp.models import OTPStage - -OTP_CODE_VALIDATOR = RegexValidator( - r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.") -) +from passbook.stages.otp_time.models import OTPTimeStage +from passbook.stages.otp_validate.forms import OTP_CODE_VALIDATOR class PictureWidget(forms.widgets.Widget): @@ -20,30 +14,11 @@ class PictureWidget(forms.widgets.Widget): return mark_safe(f'') # 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", "autocomplete": "off"} - ) - - -class OTPSetupForm(forms.Form): - """OTP Setup form""" +class SetupForm(forms.Form): title = _("Set up OTP") device: Device = None + qr_code = forms.CharField( widget=PictureWidget, disabled=True, @@ -56,8 +31,6 @@ class OTPSetupForm(forms.Form): 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: @@ -66,13 +39,12 @@ class OTPSetupForm(forms.Form): return self.cleaned_data.get("code") -class OTPStageForm(forms.ModelForm): - """Form to edit OTPStage instances""" - +class OTPTimeStageForm(forms.ModelForm): class Meta: - model = OTPStage - fields = ["name", "enforced"] + model = OTPTimeStage + fields = ["name", "digits"] + widgets = { "name": forms.TextInput(), } diff --git a/passbook/stages/otp_time/migrations/0001_initial.py b/passbook/stages/otp_time/migrations/0001_initial.py new file mode 100644 index 000000000..d3bf815d8 --- /dev/null +++ b/passbook/stages/otp_time/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.7 on 2020-06-13 15:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("passbook_flows", "0005_provider_flows"), + ] + + operations = [ + migrations.CreateModel( + name="OTPTimeStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="passbook_flows.Stage", + ), + ), + ("digits", models.IntegerField(choices=[(6, "Six"), (8, "Eight")])), + ], + options={ + "verbose_name": "OTP Time (TOTP) Setup Stage", + "verbose_name_plural": "OTP Time (TOTP) Setup Stages", + }, + bases=("passbook_flows.stage",), + ), + ] diff --git a/passbook/stages/otp/migrations/__init__.py b/passbook/stages/otp_time/migrations/__init__.py similarity index 100% rename from passbook/stages/otp/migrations/__init__.py rename to passbook/stages/otp_time/migrations/__init__.py diff --git a/passbook/stages/otp_time/models.py b/passbook/stages/otp_time/models.py new file mode 100644 index 000000000..21d85d9ea --- /dev/null +++ b/passbook/stages/otp_time/models.py @@ -0,0 +1,38 @@ +from typing import Optional +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from passbook.core.types import UIUserSettings +from passbook.flows.models import NotConfiguredAction, Stage +from django.template.context import RequestContext + + +class TOTPDigits(models.IntegerChoices): + + SIX = 6, _("6 digits, widely compatible") + EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator") + + +class OTPTimeStage(Stage): + """Enroll a user's device into Time-based OTP""" + + digits = models.IntegerField(choices=TOTPDigits.choices) + + type = "passbook.stages.otp_time.stage.OTPTimeStageView" + form = "passbook.stages.otp_time.forms.OTPTimeStageForm" + + @staticmethod + def ui_user_settings(context: RequestContext) -> Optional[UIUserSettings]: + return UIUserSettings( + name="Time-based OTP", + icon="pficon-locked", + view_name="passbook_stages_otp_time:user-settings", + ) + + def __str__(self) -> str: + return f"OTP Time (TOTP) Stage {self.name}" + + class Meta: + + verbose_name = _("OTP Time (TOTP) Setup Stage") + verbose_name_plural = _("OTP Time (TOTP) Setup Stages") diff --git a/passbook/stages/otp_time/settings.py b/passbook/stages/otp_time/settings.py new file mode 100644 index 000000000..cf6be3f9b --- /dev/null +++ b/passbook/stages/otp_time/settings.py @@ -0,0 +1,3 @@ +INSTALLED_APPS = [ + "django_otp.plugins.otp_totp", +] diff --git a/passbook/stages/otp_time/stage.py b/passbook/stages/otp_time/stage.py new file mode 100644 index 000000000..648760eb8 --- /dev/null +++ b/passbook/stages/otp_time/stage.py @@ -0,0 +1,79 @@ +from typing import Any, Dict +from base64 import b32encode +from binascii import unhexlify + +from django.contrib import messages +from django.http import HttpRequest, HttpResponse +from django.utils.http import urlencode +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from django_otp import match_token, user_has_device +from django_otp.plugins.otp_totp.models import TOTPDevice +from qrcode import make +from qrcode.image.svg import SvgPathImage +from structlog import get_logger + +from passbook.flows.models import NotConfiguredAction, Stage +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.stage import StageView +from passbook.stages.otp_time.forms import SetupForm +from passbook.stages.otp_time.models import OTPTimeStage + +LOGGER = get_logger() +PLAN_CONTEXT_TOTP_DEVICE = "totp_device" + + +def otp_auth_url(device: TOTPDevice) -> str: + """Create otpauth according to + https://github.com/google/google-authenticator/wiki/Key-Uri-Format""" + # Ensure that the secret parameter is the FIRST parameter of the URI, this + # allows Microsoft Authenticator to work. + issuer = "passbook" + + rawkey = unhexlify(device.key.encode("ascii")) + secret = b32encode(rawkey).decode("utf-8") + + query = [ + ("secret", secret), + ("digits", device.digits), + ("issuer", issuer), + ] + + return "otpauth://totp/%s:%s?%s" % (issuer, device.user.username, urlencode(query)) + + +class OTPTimeStageView(FormView, StageView): + + form_class = SetupForm + + def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: + kwargs = super().get_form_kwargs(**kwargs) + device: TOTPDevice = self.executor.plan.context[PLAN_CONTEXT_TOTP_DEVICE] + kwargs["device"] = device + kwargs["qr_code"] = self._get_qr_code(device) + return kwargs + + def _get_qr_code(self, device: TOTPDevice) -> str: + """Get QR Code SVG as string based on `device`""" + url = otp_auth_url(device) + # Make and return QR code + img = make(url, image_factory=SvgPathImage) + return img._img + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) + if not user: + LOGGER.debug("No pending user, continuing") + return self.executor.stage_ok() + + stage: OTPTimeStage = self.executor.current_stage + device = TOTPDevice(user=user, confirmed=True, digits=stage.digits) + + self.executor.plan.context[PLAN_CONTEXT_TOTP_DEVICE] = device + return super().get(request, *args, **kwargs) + + def form_valid(self, form: SetupForm) -> HttpResponse: + """Verify OTP Token""" + device: TOTPDevice = self.executor.plan.context[PLAN_CONTEXT_TOTP_DEVICE] + device.save() + return self.executor.stage_ok() diff --git a/passbook/stages/otp/templates/stages/otp/user_settings.html b/passbook/stages/otp_time/templates/stages/otp_time/user_settings.html similarity index 53% rename from passbook/stages/otp/templates/stages/otp/user_settings.html rename to passbook/stages/otp_time/templates/stages/otp_time/user_settings.html index 474a27716..aa7b30cc0 100644 --- a/passbook/stages/otp/templates/stages/otp/user_settings.html +++ b/passbook/stages/otp_time/templates/stages/otp_time/user_settings.html @@ -6,7 +6,7 @@ {% block page %}
- {% trans "One-Time Passwords" %} + {% trans "Time-based One-Time Passwords" %}
- -
-
- {% trans "Your Backup tokens:" %} -
-
-
{% for token in static_tokens %}{{ token.token }}
-        {% empty %}{% trans 'N/A' %}{% endfor %}
-
-
- {% endblock %} diff --git a/passbook/stages/otp_time/urls.py b/passbook/stages/otp_time/urls.py new file mode 100644 index 000000000..2b52a82c4 --- /dev/null +++ b/passbook/stages/otp_time/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from passbook.stages.otp_time.views import UserSettingsView, DisableView + +urlpatterns = [ + path("settings", UserSettingsView.as_view(), name="user-settings"), + path("disable", DisableView.as_view(), name="disable") +] diff --git a/passbook/stages/otp_time/views.py b/passbook/stages/otp_time/views.py new file mode 100644 index 000000000..893d20d6e --- /dev/null +++ b/passbook/stages/otp_time/views.py @@ -0,0 +1,38 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.views import View +from django.views.generic import FormView, TemplateView +from django_otp.plugins.otp_totp.models import TOTPDevice +from django.contrib import messages +from passbook.audit.models import Event, EventAction +from django.shortcuts import redirect + +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from passbook.flows.views import SESSION_KEY_PLAN +from passbook.stages.otp_time.models import OTPTimeStage + + +class UserSettingsView(LoginRequiredMixin, TemplateView): + """View for user settings to control OTP""" + + template_name = "stages/otp_time/user_settings.html" + + # TODO: Check if OTP Stage exists and applies to user + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True) + kwargs["state"] = totp_devices.exists() + return kwargs + + +class DisableView(LoginRequiredMixin, View): + """Disable TOTP for user""" + + def get(self, request: HttpRequest) -> HttpResponse: + """Delete all the devices for user""" + totp = TOTPDevice.objects.filter(user=request.user, confirmed=True) + totp.delete() + messages.success(request, "Successfully disabled Time-based OTP") + # Create event with email notification + Event.new(EventAction.CUSTOM, message="User disabled Time-based OTP.").from_http(request) + return redirect("passbook_stages_otp:otp-user-settings") diff --git a/passbook/stages/otp_validate/__init__.py b/passbook/stages/otp_validate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/otp_validate/api.py b/passbook/stages/otp_validate/api.py new file mode 100644 index 000000000..5f6ccbb5f --- /dev/null +++ b/passbook/stages/otp_validate/api.py @@ -0,0 +1,24 @@ +"""OTPValidateStage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from passbook.stages.otp_validate.models import OTPValidateStage + + +class OTPValidateStageSerializer(ModelSerializer): + """OTPValidateStage Serializer""" + + class Meta: + + model = OTPValidateStage + fields = [ + "pk", + "name", + ] + + +class OTPValidateStageViewSet(ModelViewSet): + """OTPValidateStage Viewset""" + + queryset = OTPValidateStage.objects.all() + serializer_class = OTPValidateStageSerializer diff --git a/passbook/stages/otp_validate/apps.py b/passbook/stages/otp_validate/apps.py new file mode 100644 index 000000000..0973a5a1c --- /dev/null +++ b/passbook/stages/otp_validate/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class PassbookStageOTPValidateConfig(AppConfig): + + name = "passbook.stages.otp_validate" + label = "passbook_stages_otp_validate" + verbose_name = "passbook OTP.Validate" diff --git a/passbook/stages/otp_validate/forms.py b/passbook/stages/otp_validate/forms.py new file mode 100644 index 000000000..ee4fe096b --- /dev/null +++ b/passbook/stages/otp_validate/forms.py @@ -0,0 +1,38 @@ +from django import forms +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ + +from passbook.stages.otp_validate.models import OTPValidateStage + +OTP_CODE_VALIDATOR = RegexValidator( + r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.") +) + + +class ValidationForm(forms.Form): + + code = forms.CharField( + label=_("Code"), + validators=[OTP_CODE_VALIDATOR], + widget=forms.TextInput( + attrs={ + "autocomplete": "off", + "placeholder": "Code", + "autofocus": "autofocus", + } + ), + ) + + def clean_code(self): + pass + + +class OTPValidateStageForm(forms.ModelForm): + class Meta: + + model = OTPValidateStage + fields = ["name"] + + widgets = { + "name": forms.TextInput(), + } diff --git a/passbook/stages/otp/migrations/0001_initial.py b/passbook/stages/otp_validate/migrations/0001_initial.py similarity index 64% rename from passbook/stages/otp/migrations/0001_initial.py rename to passbook/stages/otp_validate/migrations/0001_initial.py index d27bf3cec..f26447ddd 100644 --- a/passbook/stages/otp/migrations/0001_initial.py +++ b/passbook/stages/otp_validate/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 +# Generated by Django 3.0.7 on 2020-06-13 15:28 import django.db.models.deletion from django.db import migrations, models @@ -9,12 +9,12 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("passbook_flows", "0001_initial"), + ("passbook_flows", "0005_provider_flows"), ] operations = [ migrations.CreateModel( - name="OTPStage", + name="OTPValidateStage", fields=[ ( "stage_ptr", @@ -28,14 +28,14 @@ class Migration(migrations.Migration): ), ), ( - "enforced", - models.BooleanField( - default=False, - help_text="Enforce enabled OTP for Users this stage applies to.", - ), + "not_configured_action", + models.TextField(choices=[("skip", "Skip")], default="skip"), ), ], - options={"verbose_name": "OTP Stage", "verbose_name_plural": "OTP Stages",}, + options={ + "verbose_name": "OTP Validation Stage", + "verbose_name_plural": "OTP Validation Stages", + }, bases=("passbook_flows.stage",), ), ] diff --git a/passbook/stages/otp_validate/migrations/__init__.py b/passbook/stages/otp_validate/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/otp_validate/models.py b/passbook/stages/otp_validate/models.py new file mode 100644 index 000000000..990e0fb45 --- /dev/null +++ b/passbook/stages/otp_validate/models.py @@ -0,0 +1,23 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from passbook.flows.models import NotConfiguredAction, Stage + + +class OTPValidateStage(Stage): + """Validate user's configured OTP Device""" + + not_configured_action = models.TextField( + choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP + ) + + type = "passbook.stages.otp_validate.stage.OTPValidateStageView" + form = "passbook.stages.otp_validate.forms.OTPValidateStageForm" + + def __str__(self) -> str: + return f"OTP Validation Stage {self.name}" + + class Meta: + + verbose_name = _("OTP Validation Stage") + verbose_name_plural = _("OTP Validation Stages") diff --git a/passbook/stages/otp_validate/settings.py b/passbook/stages/otp_validate/settings.py new file mode 100644 index 000000000..44212ad7d --- /dev/null +++ b/passbook/stages/otp_validate/settings.py @@ -0,0 +1,3 @@ +INSTALLED_APPS = [ + "django_otp", +] diff --git a/passbook/stages/otp_validate/stage.py b/passbook/stages/otp_validate/stage.py new file mode 100644 index 000000000..f9088fcee --- /dev/null +++ b/passbook/stages/otp_validate/stage.py @@ -0,0 +1,45 @@ +from django.contrib import messages +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from django_otp import match_token, user_has_device +from django_otp.models import Device +from structlog import get_logger + +from passbook.flows.models import NotConfiguredAction, Stage +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.stage import StageView +from passbook.stages.otp_validate.forms import ValidationForm +from passbook.stages.otp_validate.models import OTPValidateStage + +LOGGER = get_logger() + + +class OTPValidateStageView(FormView, StageView): + + form_class = ValidationForm + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) + if not user: + LOGGER.debug("No pending user, continuing") + return self.executor.stage_ok() + has_devices = user_has_device(user) + stage: OTPValidateStage = self.executor.current_stage + + if not has_devices: + if stage.not_configured_action == NotConfiguredAction.SKIP: + LOGGER.debug("OTP not configured, skipping stage") + return self.executor.stage_ok() + return super().get(request, *args, **kwargs) + + def form_valid(self, form: ValidationForm) -> HttpResponse: + """Verify OTP Token""" + device = match_token( + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], + form.cleaned_data.get("code"), + ) + if not device: + messages.error(self.request, _("Invalid OTP.")) + return self.form_invalid(form) + return self.executor.stage_ok() diff --git a/swagger.yaml b/swagger.yaml index c891e99e8..23563d842 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -3910,10 +3910,10 @@ paths: required: true type: string format: uuid - /stages/otp/: + /stages/otp_validate/: get: - operationId: stages_otp_list - description: OTPStage Viewset + operationId: stages_otp_validate_list + description: OTPValidateStage Viewset parameters: - name: ordering in: query @@ -3957,73 +3957,73 @@ paths: results: type: array items: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' tags: - stages post: - operationId: stages_otp_create - description: OTPStage Viewset + operationId: stages_otp_validate_create + description: OTPValidateStage Viewset parameters: - name: data in: body required: true schema: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' responses: '201': description: '' schema: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' tags: - stages parameters: [] - /stages/otp/{stage_uuid}/: + /stages/otp_validate/{stage_uuid}/: get: - operationId: stages_otp_read - description: OTPStage Viewset + operationId: stages_otp_validate_read + description: OTPValidateStage Viewset parameters: [] responses: '200': description: '' schema: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' tags: - stages put: - operationId: stages_otp_update - description: OTPStage Viewset + operationId: stages_otp_validate_update + description: OTPValidateStage Viewset parameters: - name: data in: body required: true schema: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' responses: '200': description: '' schema: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' tags: - stages patch: - operationId: stages_otp_partial_update - description: OTPStage Viewset + operationId: stages_otp_validate_partial_update + description: OTPValidateStage Viewset parameters: - name: data in: body required: true schema: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' responses: '200': description: '' schema: - $ref: '#/definitions/OTPStage' + $ref: '#/definitions/OTPValidateStage' tags: - stages delete: - operationId: stages_otp_delete - description: OTPStage Viewset + operationId: stages_otp_validate_delete + description: OTPValidateStage Viewset parameters: [] responses: '204': @@ -4033,7 +4033,7 @@ paths: parameters: - name: stage_uuid in: path - description: A UUID string identifying this OTP Stage. + description: A UUID string identifying this OTP Validation Stage. required: true type: string format: uuid @@ -5177,7 +5177,7 @@ definitions: - enrollment - unenrollment - recovery - - password_change + - user_settings stages: type: array items: @@ -6209,7 +6209,7 @@ definitions: fixed_data: title: Fixed data type: object - OTPStage: + OTPValidateStage: required: - name type: object @@ -6223,10 +6223,6 @@ definitions: title: Name type: string minLength: 1 - enforced: - title: Enforced - description: Enforce enabled OTP for Users this stage applies to. - type: boolean PasswordStage: required: - name