diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py
index 8b2ee36f3a..44916d093b 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 43983c1599..04b2822211 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 9378a4cb08..0000000000
--- 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 88b5ce4418..0000000000
--- 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 f0a6e1c88f..0000000000
--- 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 6bd9d8f83e..0000000000
--- 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 7d303a9c13..0000000000
--- 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 c95f13cbec..0000000000
--- 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 012ff2923b..0000000000
--- 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 978803747c..0000000000
--- 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 82ff8b3334..0000000000
--- 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 0000000000..3ce955c01a
--- /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 0000000000..854be8ab88
--- /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 0033667cf0..490ac569f8 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 0000000000..d3bf815d85
--- /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 0000000000..21d85d9ea2
--- /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 0000000000..cf6be3f9b6
--- /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 0000000000..648760eb8b
--- /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 474a277166..aa7b30cc08 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 %}
-
-
-
-
-
{% 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 0000000000..2b52a82c48
--- /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 0000000000..893d20d6e0
--- /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 0000000000..e69de29bb2
diff --git a/passbook/stages/otp_validate/api.py b/passbook/stages/otp_validate/api.py
new file mode 100644
index 0000000000..5f6ccbb5fd
--- /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 0000000000..0973a5a1ce
--- /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 0000000000..ee4fe096b4
--- /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 d27bf3ceca..f26447dddc 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 0000000000..e69de29bb2
diff --git a/passbook/stages/otp_validate/models.py b/passbook/stages/otp_validate/models.py
new file mode 100644
index 0000000000..990e0fb45a
--- /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 0000000000..44212ad7d8
--- /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 0000000000..f9088fceef
--- /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 c891e99e8f..23563d842d 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