stages/otp: start separation into 3 stages, otp_time, otp_static and otp_validate
This commit is contained in:
parent
e75e71a5ce
commit
8c36ab89e8
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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
|
|
@ -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/"
|
|
@ -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")
|
|
@ -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",
|
||||
]
|
|
@ -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)
|
|
@ -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 %}
|
|
@ -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"),
|
||||
]
|
|
@ -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))
|
|
@ -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
|
21
passbook/stages/otp_time/api.py
Normal file
21
passbook/stages/otp_time/api.py
Normal 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
|
9
passbook/stages/otp_time/apps.py
Normal file
9
passbook/stages/otp_time/apps.py
Normal 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/"
|
|
@ -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(),
|
||||
}
|
38
passbook/stages/otp_time/migrations/0001_initial.py
Normal file
38
passbook/stages/otp_time/migrations/0001_initial.py
Normal 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",),
|
||||
),
|
||||
]
|
38
passbook/stages/otp_time/models.py
Normal file
38
passbook/stages/otp_time/models.py
Normal 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")
|
3
passbook/stages/otp_time/settings.py
Normal file
3
passbook/stages/otp_time/settings.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
INSTALLED_APPS = [
|
||||
"django_otp.plugins.otp_totp",
|
||||
]
|
79
passbook/stages/otp_time/stage.py
Normal file
79
passbook/stages/otp_time/stage.py
Normal 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()
|
|
@ -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 %}
|
8
passbook/stages/otp_time/urls.py
Normal file
8
passbook/stages/otp_time/urls.py
Normal 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")
|
||||
]
|
38
passbook/stages/otp_time/views.py
Normal file
38
passbook/stages/otp_time/views.py
Normal 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")
|
0
passbook/stages/otp_validate/__init__.py
Normal file
0
passbook/stages/otp_validate/__init__.py
Normal file
24
passbook/stages/otp_validate/api.py
Normal file
24
passbook/stages/otp_validate/api.py
Normal 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
|
8
passbook/stages/otp_validate/apps.py
Normal file
8
passbook/stages/otp_validate/apps.py
Normal 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"
|
38
passbook/stages/otp_validate/forms.py
Normal file
38
passbook/stages/otp_validate/forms.py
Normal 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(),
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
0
passbook/stages/otp_validate/migrations/__init__.py
Normal file
0
passbook/stages/otp_validate/migrations/__init__.py
Normal file
23
passbook/stages/otp_validate/models.py
Normal file
23
passbook/stages/otp_validate/models.py
Normal 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")
|
3
passbook/stages/otp_validate/settings.py
Normal file
3
passbook/stages/otp_validate/settings.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
INSTALLED_APPS = [
|
||||
"django_otp",
|
||||
]
|
45
passbook/stages/otp_validate/stage.py
Normal file
45
passbook/stages/otp_validate/stage.py
Normal 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()
|
54
swagger.yaml
54
swagger.yaml
|
@ -3910,10 +3910,10 @@ paths:
|
|||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/stages/otp/:
|
||||
/stages/otp_validate/:
|
||||
get:
|
||||
operationId: stages_otp_list
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_validate_list
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
|
@ -3957,73 +3957,73 @@ paths:
|
|||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
post:
|
||||
operationId: stages_otp_create
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_validate_create
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
parameters: []
|
||||
/stages/otp/{stage_uuid}/:
|
||||
/stages/otp_validate/{stage_uuid}/:
|
||||
get:
|
||||
operationId: stages_otp_read
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_validate_read
|
||||
description: OTPValidateStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
put:
|
||||
operationId: stages_otp_update
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_validate_update
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
patch:
|
||||
operationId: stages_otp_partial_update
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_validate_partial_update
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
delete:
|
||||
operationId: stages_otp_delete
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_validate_delete
|
||||
description: OTPValidateStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
|
@ -4033,7 +4033,7 @@ paths:
|
|||
parameters:
|
||||
- name: stage_uuid
|
||||
in: path
|
||||
description: A UUID string identifying this OTP Stage.
|
||||
description: A UUID string identifying this OTP Validation Stage.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
|
@ -5177,7 +5177,7 @@ definitions:
|
|||
- enrollment
|
||||
- unenrollment
|
||||
- recovery
|
||||
- password_change
|
||||
- user_settings
|
||||
stages:
|
||||
type: array
|
||||
items:
|
||||
|
@ -6209,7 +6209,7 @@ definitions:
|
|||
fixed_data:
|
||||
title: Fixed data
|
||||
type: object
|
||||
OTPStage:
|
||||
OTPValidateStage:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
|
@ -6223,10 +6223,6 @@ definitions:
|
|||
title: Name
|
||||
type: string
|
||||
minLength: 1
|
||||
enforced:
|
||||
title: Enforced
|
||||
description: Enforce enabled OTP for Users this stage applies to.
|
||||
type: boolean
|
||||
PasswordStage:
|
||||
required:
|
||||
- name
|
||||
|
|
Reference in a new issue