stages/otp_static: start implementing static stage

This commit is contained in:
Jens Langhammer
2020-06-30 13:49:23 +02:00
parent 3716bda76e
commit d2bf579ff6
16 changed files with 568 additions and 6 deletions

View File

@ -37,6 +37,8 @@ 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_static.api import OTPStaticStageViewSet
from passbook.stages.otp_time.api import OTPTimeStageViewSet
from passbook.stages.otp_validate.api import OTPValidateStageViewSet
from passbook.stages.password.api import PasswordStageViewSet
from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet
@ -91,10 +93,12 @@ 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_static", OTPStaticStageViewSet)
router.register("stages/otp_time", OTPTimeStageViewSet)
router.register("stages/otp_validate", OTPValidateStageViewSet)
router.register("stages/password", PasswordStageViewSet)
router.register("stages/prompt/stages", PromptStageViewSet)
router.register("stages/prompt/prompts", PromptViewSet)
router.register("stages/prompt/stages", PromptStageViewSet)
router.register("stages/user_delete", UserDeleteStageViewSet)
router.register("stages/user_login", UserLoginStageViewSet)
router.register("stages/user_logout", UserLogoutStageViewSet)

View File

@ -107,6 +107,7 @@ INSTALLED_APPS = [
"passbook.stages.user_login.apps.PassbookStageUserLoginConfig",
"passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig",
"passbook.stages.user_write.apps.PassbookStageUserWriteConfig",
"passbook.stages.otp_static.apps.PassbookStageOTPStaticConfig",
"passbook.stages.otp_time.apps.PassbookStageOTPTimeConfig",
"passbook.stages.otp_validate.apps.PassbookStageOTPValidateConfig",
"passbook.stages.password.apps.PassbookStagePasswordConfig",

View File

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

View File

@ -0,0 +1,28 @@
"""OTP Static forms"""
from django import forms
from django.utils.translation import gettext_lazy as _
from passbook.stages.otp_static.models import OTPStaticStage
class SetupForm(forms.Form):
"""Form to setup Static OTP"""
tokens = forms.MultipleChoiceField(disabled=True, required=False)
def __init__(self, tokens, *args, **kwargs):
super().__init__(*args, **kwargs)
print(tokens)
self.fields['tokens'].choices = [(x.token, x.token) for x in tokens]
class OTPStaticStageForm(forms.ModelForm):
"""OTP Static Stage setup form"""
class Meta:
model = OTPStaticStage
fields = ["name", "token_count"]
widgets = {
"name": forms.TextInput(),
}

View File

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

View File

@ -0,0 +1,33 @@
"""OTP Static-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 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-based 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")

View File

@ -0,0 +1,5 @@
"""OTP Static settings"""
INSTALLED_APPS = [
"django_otp.plugins.otp_static",
]

View File

@ -0,0 +1,57 @@
"""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 = [StaticToken(device=device, token=StaticToken.random_token()) for _ in range(0, stage.token_count)]
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()
[x.save() for x in self.request.session[SESSION_STATIC_TOKENS]]
del self.request.session[SESSION_STATIC_DEVICE]
del self.request.session[SESSION_STATIC_TOKENS]
return self.executor.stage_ok()

View File

@ -0,0 +1,31 @@
{% extends "user/base.html" %}
{% load passbook_utils %}
{% load i18n %}
{% block page %}
<div class="pf-c-card">
<div class="pf-c-card__header pf-c-title pf-m-md">
{% trans "Time-based One-Time Passwords" %}
</div>
<div class="pf-c-card__body">
<p>
{% blocktrans with state=state|yesno:"Enabled,Disabled" %}
Status: {{ state }}
{% endblocktrans %}
{% if state %}
<i class="pf-icon pf-icon-ok"></i>
{% else %}
<i class="pf-icon pf-icon-error-circle-o"></i>
{% endif %}
</p>
<p>
{% if not state %}
<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_time:disable' %}" class="btn btn-danger btn-sm">{% trans "Disable Time-based OTP" %}</a>
{% endif %}
</p>
</div>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,40 @@
"""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 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
)
kwargs["state"] = static_devices.exists()
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")

View File

@ -18,7 +18,6 @@ class PictureWidget(forms.widgets.Widget):
class SetupForm(forms.Form):
"""Form to setup Time-based OTP"""
title = _("Set up OTP")
device: Device = None
qr_code = forms.CharField(

View File

@ -9,6 +9,7 @@ from lxml.etree import tostring # nosec
from qrcode import QRCode
from qrcode.image.svg import SvgFillImage
from structlog import get_logger
from django_otp.plugins.otp_totp.models import TOTPDevice
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import StageView
@ -43,6 +44,11 @@ class OTPTimeStageView(FormView, StageView):
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:

View File

@ -9,10 +9,6 @@ from django_otp.plugins.otp_totp.models import TOTPDevice
from passbook.audit.models import Event, EventAction
# 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"""