factors: -> stage

This commit is contained in:
Jens Langhammer
2020-05-08 19:46:39 +02:00
parent 08c0eb2ec6
commit 212e966dd4
99 changed files with 745 additions and 958 deletions

View File

View File

@ -0,0 +1,21 @@
"""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

@ -0,0 +1,12 @@
"""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

@ -0,0 +1,78 @@
"""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'<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"""
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(),
}

View File

@ -0,0 +1,41 @@
# Generated by Django 3.0.3 on 2020-05-08 17:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="OTPStage",
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",
),
),
(
"enforced",
models.BooleanField(
default=False,
help_text="Enforce enabled OTP for Users this stage applies to.",
),
),
],
options={"verbose_name": "OTP Stage", "verbose_name_plural": "OTP Stages",},
bases=("passbook_flows.stage",),
),
]

View File

@ -0,0 +1,34 @@
"""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

@ -0,0 +1,10 @@
"""passbook OTP Settings"""
MIDDLEWARE = [
"django_otp.middleware.OTPMiddleware",
]
INSTALLED_APPS = [
"django_otp",
"django_otp.plugins.otp_static",
"django_otp.plugins.otp_totp",
]

View File

@ -0,0 +1,59 @@
"""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 AuthenticationStage
from passbook.stages.otp.forms import OTPVerifyForm
from passbook.stages.otp.views import OTP_SETTING_UP_KEY, EnableView
LOGGER = get_logger()
class OTPStage(FormView, AuthenticationStage):
"""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

@ -0,0 +1,8 @@
{% 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

@ -0,0 +1,48 @@
{% extends "user/base.html" %}
{% load utils %}
{% load i18n %}
{% block page %}
<div class="pf-c-card__header pf-c-title pf-m-md">
<h1>{% trans "One-Time Passwords" %}</h1>
</div>
<div class="pf-c-card__body">
<div class="row">
<div class="col-md-6">
<div class="card-footer">
<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:otp-enable' %}"
class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a>
{% else %}
<a href="{% url 'passbook_stages_otp:otp-disable' %}"
class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a>
{% endif %}
</p>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans "Your Backup tokens:" %}
</div>
<div class="card-block">
<pre>{% for token in static_tokens %}{{ token.token }}
{% empty %}{% trans 'N/A' %}{% endfor %}</pre>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
"""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

@ -0,0 +1,17 @@
"""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

@ -0,0 +1,166 @@
"""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 = "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 E-Mail
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