stages/otp: start separation into 3 stages, otp_time, otp_static and otp_validate

This commit is contained in:
Jens Langhammer 2020-06-28 10:30:35 +02:00
parent e75e71a5ce
commit 8c36ab89e8
33 changed files with 424 additions and 430 deletions

View file

@ -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)

View file

@ -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",
]

View file

@ -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

View file

@ -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/"

View file

@ -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")

View file

@ -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",
]

View file

@ -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)

View file

@ -1,8 +0,0 @@
{% extends 'login/form_with_user.html' %}
{% load i18n %}
{% block above_form %}
{{ block.super }}
<p><b>{% trans 'Enter the Verification Code from your Authenticator App.' %}</b></p>
{% endblock %}

View file

@ -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"),
]

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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/"

View file

@ -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'<img src="{value}" />') # nosec
class OTPVerifyForm(forms.Form):
"""Simple Form to verify OTP Code"""
order = ["code"]
code = forms.CharField(
label=_("Code"),
validators=[OTP_CODE_VALIDATOR],
widget=forms.TextInput(attrs={"autocomplete": "off", "placeholder": "Code"}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# This is a little helper so the field is focused by default
self.fields["code"].widget.attrs.update(
{"autofocus": "autofocus", "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(),
}

View file

@ -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",),
),
]

View file

@ -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")

View file

@ -0,0 +1,3 @@
INSTALLED_APPS = [
"django_otp.plugins.otp_totp",
]

View file

@ -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()

View file

@ -6,7 +6,7 @@
{% block page %}
<div class="pf-c-card">
<div class="pf-c-card__header pf-c-title pf-m-md">
{% trans "One-Time Passwords" %}
{% trans "Time-based One-Time Passwords" %}
</div>
<div class="pf-c-card__body">
<p>
@ -21,22 +21,11 @@
</p>
<p>
{% if not state %}
<a href="{% url 'passbook_stages_otp:otp-enable' %}" class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a>
<a href="{% url 'passbook_stages_otp_time:otp-enable' %}" class="btn btn-success btn-sm">{% trans "Enable Time-based OTP" %}</a>
{% else %}
<a href="{% url 'passbook_stages_otp:otp-disable' %}" class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a>
<a href="{% url 'passbook_stages_otp_time:disable' %}" class="btn btn-danger btn-sm">{% trans "Disable Time-based OTP" %}</a>
{% endif %}
</p>
</div>
</div>
<div class="pf-c-card">
<div class="pf-c-card__header pf-c-title pf-m-md">
{% trans "Your Backup tokens:" %}
</div>
<div class="pf-c-card__body">
<pre>{% for token in static_tokens %}{{ token.token }}
{% empty %}{% trans 'N/A' %}{% endfor %}</pre>
</div>
</div>
</div>
{% endblock %}

View file

@ -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")
]

View file

@ -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")

View file

View file

@ -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

View file

@ -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"

View file

@ -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(),
}

View file

@ -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",),
),
]

View file

@ -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")

View file

@ -0,0 +1,3 @@
INSTALLED_APPS = [
"django_otp",
]

View file

@ -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()

View file

@ -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