-
{% endblock %}
diff --git a/passbook/core/templatetags/passbook_user_settings.py b/passbook/core/templatetags/passbook_user_settings.py
index 1af1b9c97..c04860627 100644
--- a/passbook/core/templatetags/passbook_user_settings.py
+++ b/passbook/core/templatetags/passbook_user_settings.py
@@ -23,7 +23,7 @@ def user_stages(context: RequestContext) -> List[UIUserSettings]:
if not user_settings:
continue
matching_stages.append(user_settings)
- return matching_stages
+ return sorted(matching_stages, key=lambda x: x.name)
@register.simple_tag(takes_context=True)
@@ -42,4 +42,4 @@ def user_sources(context: RequestContext) -> List[UIUserSettings]:
policy_engine.build()
if policy_engine.passing:
matching_sources.append(user_settings)
- return matching_sources
+ return sorted(matching_sources, key=lambda x: x.name)
diff --git a/passbook/flows/apps.py b/passbook/flows/apps.py
index f31f699da..b5eeb0666 100644
--- a/passbook/flows/apps.py
+++ b/passbook/flows/apps.py
@@ -13,5 +13,5 @@ class PassbookFlowsConfig(AppConfig):
verbose_name = "passbook Flows"
def ready(self):
- """Load policy cache clearing signals"""
+ """Flow signals that clear the cache"""
import_module("passbook.flows.signals")
diff --git a/passbook/flows/models.py b/passbook/flows/models.py
index 44ea86c91..1f71130a9 100644
--- a/passbook/flows/models.py
+++ b/passbook/flows/models.py
@@ -15,6 +15,13 @@ from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
+class NotConfiguredAction(models.TextChoices):
+ """Decides how the FlowExecutor should proceed when a stage isn't configured"""
+
+ SKIP = "skip"
+ # CONFIGURE = "configure"
+
+
class FlowDesignation(models.TextChoices):
"""Designation of what a Flow should be used for. At a later point, this
should be replaced by a database entry."""
diff --git a/passbook/flows/signals.py b/passbook/flows/signals.py
index d4353ef94..59c1bbc0d 100644
--- a/passbook/flows/signals.py
+++ b/passbook/flows/signals.py
@@ -7,6 +7,13 @@ from structlog import get_logger
LOGGER = get_logger()
+def delete_cache_prefix(prefix: str) -> int:
+ """Delete keys prefixed with `prefix` and return count of deleted keys."""
+ keys = cache.keys(prefix)
+ cache.delete_many(keys)
+ return len(keys)
+
+
@receiver(post_save)
# pylint: disable=unused-argument
def invalidate_flow_cache(sender, instance, **_):
@@ -15,17 +22,16 @@ def invalidate_flow_cache(sender, instance, **_):
from passbook.flows.planner import cache_key
if isinstance(instance, Flow):
- LOGGER.debug("Invalidating Flow cache", flow=instance)
- cache.delete(f"{cache_key(instance)}*")
+ total = delete_cache_prefix(f"{cache_key(instance)}*")
+ LOGGER.debug("Invalidating Flow cache", flow=instance, len=total)
if isinstance(instance, FlowStageBinding):
- LOGGER.debug("Invalidating Flow cache from FlowStageBinding", binding=instance)
- cache.delete(f"{cache_key(instance.flow)}*")
+ total = delete_cache_prefix(f"{cache_key(instance.flow)}*")
+ LOGGER.debug(
+ "Invalidating Flow cache from FlowStageBinding", binding=instance, len=total
+ )
if isinstance(instance, Stage):
- LOGGER.debug("Invalidating Flow cache from Stage", stage=instance)
total = 0
for binding in FlowStageBinding.objects.filter(stage=instance):
prefix = cache_key(binding.flow)
- keys = cache.keys(f"{prefix}*")
- total += len(keys)
- cache.delete_many(keys)
- LOGGER.debug("Deleted keys", len=total)
+ total += delete_cache_prefix(f"{prefix}*")
+ LOGGER.debug("Invalidating Flow cache from Stage", stage=instance, len=total)
diff --git a/passbook/flows/views.py b/passbook/flows/views.py
index 89395c4eb..499e7413c 100644
--- a/passbook/flows/views.py
+++ b/passbook/flows/views.py
@@ -111,6 +111,7 @@ class FlowExecutorView(View):
stage_response = self.current_stage_view.get(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
+ LOGGER.exception(exc)
return to_stage_response(
request,
render(
@@ -132,6 +133,7 @@ class FlowExecutorView(View):
stage_response = self.current_stage_view.post(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
+ LOGGER.exception(exc)
return to_stage_response(
request,
render(
diff --git a/passbook/root/settings.py b/passbook/root/settings.py
index 43983c159..0fccf7ce1 100644
--- a/passbook/root/settings.py
+++ b/passbook/root/settings.py
@@ -107,7 +107,9 @@ 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_static.apps.PassbookStageOTPStaticConfig",
+ "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/sources/oauth/models.py b/passbook/sources/oauth/models.py
index 09058c041..dcc60e742 100644
--- a/passbook/sources/oauth/models.py
+++ b/passbook/sources/oauth/models.py
@@ -1,4 +1,5 @@
"""OAuth Client models"""
+from typing import Optional
from django.db import models
from django.urls import reverse, reverse_lazy
@@ -61,7 +62,7 @@ class OAuthSource(Source):
return f"Callback URL:
{url}
"
@property
- def ui_user_settings(self) -> UIUserSettings:
+ def ui_user_settings(self) -> Optional[UIUserSettings]:
view_name = "passbook_sources_oauth:oauth-client-user"
return UIUserSettings(
name=self.name, url=reverse(view_name, kwargs={"source_slug": self.slug}),
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/forms.py b/passbook/stages/otp/forms.py
deleted file mode 100644
index 0033667cf..000000000
--- a/passbook/stages/otp/forms.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""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.")
-)
-
-
-class PictureWidget(forms.widgets.Widget):
- """Widget to render value as img-tag"""
-
- def render(self, name, value, attrs=None, renderer=None):
- 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"""
-
- title = _("Set up OTP")
- device: 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 OTPStageForm(forms.ModelForm):
- """Form to edit OTPStage instances"""
-
- class Meta:
-
- model = OTPStage
- fields = ["name", "enforced"]
- widgets = {
- "name": forms.TextInput(),
- }
diff --git a/passbook/stages/otp/models.py b/passbook/stages/otp/models.py
deleted file mode 100644
index 10015be61..000000000
--- a/passbook/stages/otp/models.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""OTP Stage"""
-from django.db import models
-from django.urls import reverse
-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", url=reverse("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_static/__init__.py
similarity index 100%
rename from passbook/stages/otp/__init__.py
rename to passbook/stages/otp_static/__init__.py
diff --git a/passbook/stages/otp_static/api.py b/passbook/stages/otp_static/api.py
new file mode 100644
index 000000000..fa706cb75
--- /dev/null
+++ b/passbook/stages/otp_static/api.py
@@ -0,0 +1,21 @@
+"""OTPStaticStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.otp_static.models import OTPStaticStage
+
+
+class OTPStaticStageSerializer(ModelSerializer):
+ """OTPStaticStage Serializer"""
+
+ class Meta:
+
+ model = OTPStaticStage
+ fields = ["pk", "name", "token_count"]
+
+
+class OTPStaticStageViewSet(ModelViewSet):
+ """OTPStaticStage Viewset"""
+
+ queryset = OTPStaticStage.objects.all()
+ serializer_class = OTPStaticStageSerializer
diff --git a/passbook/stages/otp_static/apps.py b/passbook/stages/otp_static/apps.py
new file mode 100644
index 000000000..2bd71c7d6
--- /dev/null
+++ b/passbook/stages/otp_static/apps.py
@@ -0,0 +1,11 @@
+"""OTP Static stage"""
+from django.apps import AppConfig
+
+
+class PassbookStageOTPStaticConfig(AppConfig):
+ """OTP Static stage"""
+
+ name = "passbook.stages.otp_static"
+ label = "passbook_stages_otp_static"
+ verbose_name = "passbook OTP.Static"
+ mountpoint = "-/user/otp/static/"
diff --git a/passbook/stages/otp_static/forms.py b/passbook/stages/otp_static/forms.py
new file mode 100644
index 000000000..637f87770
--- /dev/null
+++ b/passbook/stages/otp_static/forms.py
@@ -0,0 +1,39 @@
+"""OTP Static forms"""
+from django import forms
+from django.utils.safestring import mark_safe
+
+from passbook.stages.otp_static.models import OTPStaticStage
+
+
+class StaticTokenWidget(forms.widgets.Widget):
+ """Widget to render tokens as multiple labels"""
+
+ def render(self, name, value, attrs=None, renderer=None):
+ final_string = '
'
+ for token in value:
+ final_string += f"- {token.token}
"
+ final_string += "
"
+ return mark_safe(final_string) # nosec
+
+
+class SetupForm(forms.Form):
+ """Form to setup Static OTP"""
+
+ tokens = forms.CharField(widget=StaticTokenWidget, disabled=True, required=False)
+
+ def __init__(self, tokens, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["tokens"].initial = tokens
+
+
+class OTPStaticStageForm(forms.ModelForm):
+ """OTP Static Stage setup form"""
+
+ class Meta:
+
+ model = OTPStaticStage
+ fields = ["name", "token_count"]
+
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/passbook/stages/otp_static/migrations/0001_initial.py b/passbook/stages/otp_static/migrations/0001_initial.py
new file mode 100644
index 000000000..93c26b3e5
--- /dev/null
+++ b/passbook/stages/otp_static/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.0.7 on 2020-06-30 11:43
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0006_auto_20200629_0857"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="OTPStaticStage",
+ 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",
+ ),
+ ),
+ ("token_count", models.IntegerField(default=6)),
+ ],
+ options={
+ "verbose_name": "OTP Static Setup Stage",
+ "verbose_name_plural": "OTP Static Setup Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/otp/migrations/__init__.py b/passbook/stages/otp_static/migrations/__init__.py
similarity index 100%
rename from passbook/stages/otp/migrations/__init__.py
rename to passbook/stages/otp_static/migrations/__init__.py
diff --git a/passbook/stages/otp_static/models.py b/passbook/stages/otp_static/models.py
new file mode 100644
index 000000000..63da93211
--- /dev/null
+++ b/passbook/stages/otp_static/models.py
@@ -0,0 +1,32 @@
+"""OTP Static models"""
+from typing import Optional
+
+from django.db import models
+from django.shortcuts import reverse
+from django.utils.translation import gettext_lazy as _
+
+from passbook.core.types import UIUserSettings
+from passbook.flows.models import Stage
+
+
+class OTPStaticStage(Stage):
+ """Generate static tokens for the user as a backup"""
+
+ token_count = models.IntegerField(default=6)
+
+ type = "passbook.stages.otp_static.stage.OTPStaticStageView"
+ form = "passbook.stages.otp_static.forms.OTPStaticStageForm"
+
+ @property
+ def ui_user_settings(self) -> Optional[UIUserSettings]:
+ return UIUserSettings(
+ name="Static OTP", url=reverse("passbook_stages_otp_static:user-settings"),
+ )
+
+ def __str__(self) -> str:
+ return f"OTP Static Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("OTP Static Setup Stage")
+ verbose_name_plural = _("OTP Static Setup Stages")
diff --git a/passbook/stages/otp_static/settings.py b/passbook/stages/otp_static/settings.py
new file mode 100644
index 000000000..9c3cc4952
--- /dev/null
+++ b/passbook/stages/otp_static/settings.py
@@ -0,0 +1,5 @@
+"""OTP Static settings"""
+
+INSTALLED_APPS = [
+ "django_otp.plugins.otp_static",
+]
diff --git a/passbook/stages/otp_static/stage.py b/passbook/stages/otp_static/stage.py
new file mode 100644
index 000000000..a37de0dfb
--- /dev/null
+++ b/passbook/stages/otp_static/stage.py
@@ -0,0 +1,62 @@
+"""Static OTP Setup stage"""
+from typing import Any, Dict
+
+from django.http import HttpRequest, HttpResponse
+from django.views.generic import FormView
+from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
+from structlog import get_logger
+
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import StageView
+from passbook.stages.otp_static.forms import SetupForm
+from passbook.stages.otp_static.models import OTPStaticStage
+
+LOGGER = get_logger()
+SESSION_STATIC_DEVICE = "static_device"
+SESSION_STATIC_TOKENS = "static_device_tokens"
+
+
+class OTPStaticStageView(FormView, StageView):
+ """Static OTP Setup stage"""
+
+ form_class = SetupForm
+
+ def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
+ kwargs = super().get_form_kwargs(**kwargs)
+ tokens = self.request.session[SESSION_STATIC_TOKENS]
+ kwargs["tokens"] = tokens
+ return kwargs
+
+ 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()
+
+ # Currently, this stage only supports one device per user. If the user already
+ # has a device, just skip to the next stage
+ if StaticDevice.objects.filter(user=user).exists():
+ return self.executor.stage_ok()
+
+ stage: OTPStaticStage = self.executor.current_stage
+
+ if SESSION_STATIC_DEVICE not in self.request.session:
+ device = StaticDevice(user=user, confirmed=True)
+ tokens = []
+ for _ in range(0, stage.token_count):
+ tokens.append(
+ StaticToken(device=device, token=StaticToken.random_token())
+ )
+ self.request.session[SESSION_STATIC_DEVICE] = device
+ self.request.session[SESSION_STATIC_TOKENS] = tokens
+ return super().get(request, *args, **kwargs)
+
+ def form_valid(self, form: SetupForm) -> HttpResponse:
+ """Verify OTP Token"""
+ device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE]
+ device.save()
+ for token in self.request.session[SESSION_STATIC_TOKENS]:
+ token.save()
+ del self.request.session[SESSION_STATIC_DEVICE]
+ del self.request.session[SESSION_STATIC_TOKENS]
+ return self.executor.stage_ok()
diff --git a/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html b/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html
new file mode 100644
index 000000000..ab443368a
--- /dev/null
+++ b/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html
@@ -0,0 +1,20 @@
+{% extends "user/base.html" %}
+
+{% load passbook_utils %}
+{% load i18n %}
+
+{% block page %}
+
+{% endblock %}
diff --git a/passbook/stages/otp_static/urls.py b/passbook/stages/otp_static/urls.py
new file mode 100644
index 000000000..7eb2ce2c8
--- /dev/null
+++ b/passbook/stages/otp_static/urls.py
@@ -0,0 +1,9 @@
+"""OTP static urls"""
+from django.urls import path
+
+from passbook.stages.otp_static.views import DisableView, UserSettingsView
+
+urlpatterns = [
+ path("settings", UserSettingsView.as_view(), name="user-settings"),
+ path("disable", DisableView.as_view(), name="disable"),
+]
diff --git a/passbook/stages/otp_static/views.py b/passbook/stages/otp_static/views.py
new file mode 100644
index 000000000..a73eeb0cf
--- /dev/null
+++ b/passbook/stages/otp_static/views.py
@@ -0,0 +1,41 @@
+"""otp Static view Tokens"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.views import View
+from django.views.generic import TemplateView
+from django_otp.plugins.otp_static.models import StaticToken, StaticDevice
+
+from passbook.audit.models import Event, EventAction
+
+
+class UserSettingsView(LoginRequiredMixin, TemplateView):
+ """View for user settings to control OTP"""
+
+ template_name = "stages/otp_static/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_devices = StaticDevice.objects.filter(
+ user=self.request.user, confirmed=True
+ )
+ if static_devices.exists():
+ kwargs["tokens"] = StaticToken.objects.filter(device=static_devices.first())
+ return kwargs
+
+
+class DisableView(LoginRequiredMixin, View):
+ """Disable Static Tokens for user"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """Delete all the devices for user"""
+ devices = StaticDevice.objects.filter(user=request.user, confirmed=True)
+ devices.delete()
+ messages.success(request, "Successfully disabled Static OTP Tokens")
+ # Create event with email notification
+ Event.new(
+ EventAction.CUSTOM, message="User disabled Static OTP Tokens."
+ ).from_http(request)
+ return redirect("passbook_stages_otp:otp-user-settings")
diff --git a/passbook/stages/otp_time/__init__.py b/passbook/stages/otp_time/__init__.py
new file mode 100644
index 000000000..e69de29bb
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..c4421055d
--- /dev/null
+++ b/passbook/stages/otp_time/apps.py
@@ -0,0 +1,11 @@
+"""OTP Time"""
+from django.apps import AppConfig
+
+
+class PassbookStageOTPTimeConfig(AppConfig):
+ """OTP time App config"""
+
+ 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_time/forms.py b/passbook/stages/otp_time/forms.py
new file mode 100644
index 000000000..36052404d
--- /dev/null
+++ b/passbook/stages/otp_time/forms.py
@@ -0,0 +1,64 @@
+"""OTP Time forms"""
+from django import forms
+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_time.models import OTPTimeStage
+from passbook.stages.otp_validate.forms import OTP_CODE_VALIDATOR
+
+
+class PictureWidget(forms.widgets.Widget):
+ """Widget to render value as img-tag"""
+
+ def render(self, name, value, attrs=None, renderer=None):
+ return mark_safe(f"
{value}") # nosec
+
+
+class SetupForm(forms.Form):
+ """Form to setup Time-based OTP"""
+
+ device: Device = None
+
+ qr_code = forms.CharField(
+ widget=PictureWidget,
+ disabled=True,
+ required=False,
+ label=_("Scan this Code with your OTP App."),
+ )
+ code = forms.CharField(
+ label=_("Please enter the Token on your device."),
+ validators=[OTP_CODE_VALIDATOR],
+ widget=forms.TextInput(
+ attrs={
+ "autocomplete": "off",
+ "placeholder": "Code",
+ "autofocus": "autofocus",
+ }
+ ),
+ )
+
+ def __init__(self, device, qr_code, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.device = device
+ self.fields["qr_code"].initial = qr_code
+
+ def clean_code(self):
+ """Check code with new otp device"""
+ if self.device is not None:
+ if not self.device.verify_token(self.cleaned_data.get("code")):
+ raise forms.ValidationError(_("OTP Code does not match"))
+ return self.cleaned_data.get("code")
+
+
+class OTPTimeStageForm(forms.ModelForm):
+ """OTP Time-based Stage setup form"""
+
+ class Meta:
+
+ 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_time/migrations/__init__.py b/passbook/stages/otp_time/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/otp_time/models.py b/passbook/stages/otp_time/models.py
new file mode 100644
index 000000000..5d4c14b16
--- /dev/null
+++ b/passbook/stages/otp_time/models.py
@@ -0,0 +1,40 @@
+"""OTP Time-based models"""
+from typing import Optional
+
+from django.db import models
+from django.shortcuts import reverse
+from django.utils.translation import gettext_lazy as _
+
+from passbook.core.types import UIUserSettings
+from passbook.flows.models import Stage
+
+
+class TOTPDigits(models.IntegerChoices):
+ """OTP Time Digits"""
+
+ 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"
+
+ @property
+ def ui_user_settings(self) -> Optional[UIUserSettings]:
+ return UIUserSettings(
+ name="Time-based OTP",
+ url=reverse("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..5392069f7
--- /dev/null
+++ b/passbook/stages/otp_time/settings.py
@@ -0,0 +1,6 @@
+"""OTP Time"""
+
+INSTALLED_APPS = [
+ "django_otp.plugins.otp_totp",
+]
+OTP_TOTP_ISSUER = "passbook"
diff --git a/passbook/stages/otp_time/stage.py b/passbook/stages/otp_time/stage.py
new file mode 100644
index 000000000..74e517a2a
--- /dev/null
+++ b/passbook/stages/otp_time/stage.py
@@ -0,0 +1,64 @@
+"""TOTP Setup stage"""
+from typing import Any, Dict
+
+from django.http import HttpRequest, HttpResponse
+from django.utils.encoding import force_text
+from django.views.generic import FormView
+from django_otp.plugins.otp_totp.models import TOTPDevice
+from lxml.etree import tostring # nosec
+from qrcode import QRCode
+from qrcode.image.svg import SvgFillImage
+from structlog import get_logger
+
+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()
+SESSION_TOTP_DEVICE = "totp_device"
+
+
+class OTPTimeStageView(FormView, StageView):
+ """OTP totp Setup stage"""
+
+ form_class = SetupForm
+
+ def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
+ kwargs = super().get_form_kwargs(**kwargs)
+ device: TOTPDevice = self.request.session[SESSION_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`"""
+ qr_code = QRCode(image_factory=SvgFillImage)
+ qr_code.add_data(device.config_url)
+ return force_text(tostring(qr_code.make_image().get_image()))
+
+ 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()
+
+ # Currently, this stage only supports one device per user. If the user already
+ # has a device, just skip to the next stage
+ if TOTPDevice.objects.filter(user=user).exists():
+ return self.executor.stage_ok()
+
+ stage: OTPTimeStage = self.executor.current_stage
+
+ if SESSION_TOTP_DEVICE not in self.request.session:
+ device = TOTPDevice(user=user, confirmed=True, digits=stage.digits)
+
+ self.request.session[SESSION_TOTP_DEVICE] = device
+ return super().get(request, *args, **kwargs)
+
+ def form_valid(self, form: SetupForm) -> HttpResponse:
+ """Verify OTP Token"""
+ device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE]
+ device.save()
+ del self.request.session[SESSION_TOTP_DEVICE]
+ 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..98f13e177 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 000000000..6aa07bc5a
--- /dev/null
+++ b/passbook/stages/otp_time/urls.py
@@ -0,0 +1,9 @@
+"""OTP Time urls"""
+from django.urls import path
+
+from passbook.stages.otp_time.views import DisableView, UserSettingsView
+
+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..63f5afdfc
--- /dev/null
+++ b/passbook/stages/otp_time/views.py
@@ -0,0 +1,38 @@
+"""otp time-based view"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.views import View
+from django.views.generic import TemplateView
+from django_otp.plugins.otp_totp.models import TOTPDevice
+
+from passbook.audit.models import Event, EventAction
+
+
+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..761d24ec2
--- /dev/null
+++ b/passbook/stages/otp_validate/apps.py
@@ -0,0 +1,10 @@
+"""OTP Validation Stage"""
+from django.apps import AppConfig
+
+
+class PassbookStageOTPValidateConfig(AppConfig):
+ """OTP Validation Stage"""
+
+ 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..d1d58952c
--- /dev/null
+++ b/passbook/stages/otp_validate/forms.py
@@ -0,0 +1,55 @@
+"""OTP Validate stage forms"""
+from django import forms
+from django.core.validators import RegexValidator
+from django.utils.translation import gettext_lazy as _
+from django_otp import match_token
+
+from passbook.core.models import User
+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):
+ """OTP Validate stage forms"""
+
+ user: User
+
+ code = forms.CharField(
+ label=_("Please enter the token from your device."),
+ validators=[OTP_CODE_VALIDATOR],
+ widget=forms.TextInput(
+ attrs={
+ "autocomplete": "off",
+ "placeholder": "123456",
+ "autofocus": "autofocus",
+ }
+ ),
+ )
+
+ def __init__(self, user, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user = user
+
+ def clean_code(self):
+ """Validate code against all confirmed devices"""
+ code = self.cleaned_data.get("code")
+ device = match_token(self.user, code)
+ if not device:
+ raise forms.ValidationError(_("Invalid Token"))
+ return code
+
+
+class OTPValidateStageForm(forms.ModelForm):
+ """OTP Validate stage forms"""
+
+ 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..7e8ab7d38
--- /dev/null
+++ b/passbook/stages/otp_validate/models.py
@@ -0,0 +1,24 @@
+"""OTP Validation Stage"""
+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..34902a427
--- /dev/null
+++ b/passbook/stages/otp_validate/settings.py
@@ -0,0 +1,4 @@
+"""OTP Validate stage settings"""
+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..5d378e4cc
--- /dev/null
+++ b/passbook/stages/otp_validate/stage.py
@@ -0,0 +1,46 @@
+"""OTP Validation"""
+from typing import Any, Dict
+
+from django.http import HttpRequest, HttpResponse
+from django.views.generic import FormView
+from django_otp import user_has_device
+from structlog import get_logger
+
+from passbook.flows.models import NotConfiguredAction
+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):
+ """OTP Validation"""
+
+ form_class = ValidationForm
+
+ def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
+ kwargs = super().get_form_kwargs(**kwargs)
+ kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
+ return kwargs
+
+ 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"""
+ # Since we do token checking in the form, we know the token is valid here
+ # so we can just continue
+ return self.executor.stage_ok()
diff --git a/passbook/stages/password/forms.py b/passbook/stages/password/forms.py
index 1ac9fadbb..c27a5f842 100644
--- a/passbook/stages/password/forms.py
+++ b/passbook/stages/password/forms.py
@@ -28,13 +28,14 @@ class PasswordForm(forms.Form):
widget=forms.HiddenInput(attrs={"autocomplete": "username"}), required=False
)
password = forms.CharField(
+ label=_("Please enter your password."),
widget=forms.PasswordInput(
attrs={
"placeholder": _("Password"),
"autofocus": "autofocus",
"autocomplete": "current-password",
}
- )
+ ),
)
diff --git a/passbook/stages/password/templates/stages/password/backend.html b/passbook/stages/password/templates/stages/password/backend.html
index 74547608d..fc313d520 100644
--- a/passbook/stages/password/templates/stages/password/backend.html
+++ b/passbook/stages/password/templates/stages/password/backend.html
@@ -1,39 +1,10 @@
+{% extends 'login/form_with_user.html' %}
+
{% load i18n %}
{% load passbook_utils %}
-