commit
7de2ad77b5
|
@ -128,6 +128,7 @@ class TestProviderSAML(SeleniumTestCase):
|
|||
By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/p[1]"
|
||||
).text,
|
||||
)
|
||||
sleep(1)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click()
|
||||
self.wait_for_url("http://localhost:9009/")
|
||||
self.assertEqual(
|
||||
|
|
|
@ -46,8 +46,8 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
|||
makedirs("out", exist_ok=True)
|
||||
self.driver = self._get_driver()
|
||||
self.driver.maximize_window()
|
||||
self.driver.implicitly_wait(60)
|
||||
self.wait = WebDriverWait(self.driver, 120)
|
||||
self.driver.implicitly_wait(300)
|
||||
self.wait = WebDriverWait(self.driver, 500)
|
||||
self.apply_default_data()
|
||||
self.logger = get_logger()
|
||||
|
||||
|
@ -68,7 +68,10 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
|||
|
||||
def wait_for_url(self, desired_url):
|
||||
"""Wait until URL is `desired_url`."""
|
||||
self.wait.until(lambda driver: driver.current_url == desired_url)
|
||||
self.wait.until(
|
||||
lambda driver: driver.current_url == desired_url,
|
||||
f"URL {self.driver.current_url} doesn't match expected URL {desired_url}",
|
||||
)
|
||||
|
||||
def url(self, view, **kwargs) -> str:
|
||||
"""reverse `view` with `**kwargs` into full URL using live_server_url"""
|
||||
|
|
|
@ -37,7 +37,9 @@ 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_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
|
||||
from passbook.stages.user_delete.api import UserDeleteStageViewSet
|
||||
|
@ -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", OTPStageViewSet)
|
||||
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)
|
||||
|
|
|
@ -5,9 +5,6 @@
|
|||
|
||||
{% block above_form %}
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">
|
||||
<span class="pf-c-form__label-text">{% trans "Username" %}</span>
|
||||
</label>
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="{% gravatar user.email %}" alt="">
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
<ul class="pf-c-nav__list">
|
||||
{% for stage in user_stages_loc %}
|
||||
<li class="pf-c-nav__item">
|
||||
<a href="{{ stage.url }}" class="pf-c-nav__link {% is_active stage.view_name %}">
|
||||
<a href="{{ stage.url }}" class="pf-c-nav__link {% if stage.url == request.get_full_path %} pf-m-current {% endif %}">
|
||||
{{ stage.name }}
|
||||
</a>
|
||||
</li>
|
||||
|
@ -41,7 +41,7 @@
|
|||
{% for source in user_sources_loc %}
|
||||
<li class="pf-c-nav__item">
|
||||
<a href="{{ source.view_name }}"
|
||||
class="pf-c-nav__link {% if user_settings.view_name == request.get_full_path %} pf-m-current {% endif %}">
|
||||
class="pf-c-nav__link {% if source.url == request.get_full_path %} pf-m-current {% endif %}">
|
||||
{{ source.name }}
|
||||
</a>
|
||||
</li>
|
||||
|
@ -54,9 +54,11 @@
|
|||
</div>
|
||||
<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
|
||||
<section class="pf-c-page__main-section">
|
||||
<div class="pf-l-split pf-m-gutter">
|
||||
{% block page %}
|
||||
{% endblock %}
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
{% block page %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
@ -3,28 +3,26 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block page %}
|
||||
<div class="pf-l-split__item">
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
<h1>{% trans 'Update details' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<form action="" method="post" class="pf-c-form pf-m-horizontal">
|
||||
{% include 'partials/form_horizontal.html' with form=form %}
|
||||
{% block beneath_form %}
|
||||
{% endblock %}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<div class="pf-c-form__actions">
|
||||
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
|
||||
{% if unenrollment_enabled %}
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
{% trans 'Update details' %}
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<form action="" method="post" class="pf-c-form pf-m-horizontal">
|
||||
{% include 'partials/form_horizontal.html' with form=form %}
|
||||
{% block beneath_form %}
|
||||
{% endblock %}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<div class="pf-c-form__actions">
|
||||
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
|
||||
{% if unenrollment_enabled %}
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -23,7 +23,7 @@ def user_stages(context: RequestContext) -> List[UIUserSettings]:
|
|||
if not user_settings:
|
||||
continue
|
||||
matching_stages.append(user_settings)
|
||||
return matching_stages
|
||||
return sorted(matching_stages, key=lambda x: x.name)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
|
@ -42,4 +42,4 @@ def user_sources(context: RequestContext) -> List[UIUserSettings]:
|
|||
policy_engine.build()
|
||||
if policy_engine.passing:
|
||||
matching_sources.append(user_settings)
|
||||
return matching_sources
|
||||
return sorted(matching_sources, key=lambda x: x.name)
|
||||
|
|
|
@ -13,5 +13,5 @@ class PassbookFlowsConfig(AppConfig):
|
|||
verbose_name = "passbook Flows"
|
||||
|
||||
def ready(self):
|
||||
"""Load policy cache clearing signals"""
|
||||
"""Flow signals that clear the cache"""
|
||||
import_module("passbook.flows.signals")
|
||||
|
|
|
@ -15,6 +15,13 @@ from passbook.policies.models import PolicyBindingModel
|
|||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class NotConfiguredAction(models.TextChoices):
|
||||
"""Decides how the FlowExecutor should proceed when a stage isn't configured"""
|
||||
|
||||
SKIP = "skip"
|
||||
# CONFIGURE = "configure"
|
||||
|
||||
|
||||
class FlowDesignation(models.TextChoices):
|
||||
"""Designation of what a Flow should be used for. At a later point, this
|
||||
should be replaced by a database entry."""
|
||||
|
|
|
@ -7,6 +7,13 @@ from structlog import get_logger
|
|||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def delete_cache_prefix(prefix: str) -> int:
|
||||
"""Delete keys prefixed with `prefix` and return count of deleted keys."""
|
||||
keys = cache.keys(prefix)
|
||||
cache.delete_many(keys)
|
||||
return len(keys)
|
||||
|
||||
|
||||
@receiver(post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def invalidate_flow_cache(sender, instance, **_):
|
||||
|
@ -15,17 +22,16 @@ def invalidate_flow_cache(sender, instance, **_):
|
|||
from passbook.flows.planner import cache_key
|
||||
|
||||
if isinstance(instance, Flow):
|
||||
LOGGER.debug("Invalidating Flow cache", flow=instance)
|
||||
cache.delete(f"{cache_key(instance)}*")
|
||||
total = delete_cache_prefix(f"{cache_key(instance)}*")
|
||||
LOGGER.debug("Invalidating Flow cache", flow=instance, len=total)
|
||||
if isinstance(instance, FlowStageBinding):
|
||||
LOGGER.debug("Invalidating Flow cache from FlowStageBinding", binding=instance)
|
||||
cache.delete(f"{cache_key(instance.flow)}*")
|
||||
total = delete_cache_prefix(f"{cache_key(instance.flow)}*")
|
||||
LOGGER.debug(
|
||||
"Invalidating Flow cache from FlowStageBinding", binding=instance, len=total
|
||||
)
|
||||
if isinstance(instance, Stage):
|
||||
LOGGER.debug("Invalidating Flow cache from Stage", stage=instance)
|
||||
total = 0
|
||||
for binding in FlowStageBinding.objects.filter(stage=instance):
|
||||
prefix = cache_key(binding.flow)
|
||||
keys = cache.keys(f"{prefix}*")
|
||||
total += len(keys)
|
||||
cache.delete_many(keys)
|
||||
LOGGER.debug("Deleted keys", len=total)
|
||||
total += delete_cache_prefix(f"{prefix}*")
|
||||
LOGGER.debug("Invalidating Flow cache from Stage", stage=instance, len=total)
|
||||
|
|
|
@ -111,6 +111,7 @@ class FlowExecutorView(View):
|
|||
stage_response = self.current_stage_view.get(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.exception(exc)
|
||||
return to_stage_response(
|
||||
request,
|
||||
render(
|
||||
|
@ -132,6 +133,7 @@ class FlowExecutorView(View):
|
|||
stage_response = self.current_stage_view.post(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.exception(exc)
|
||||
return to_stage_response(
|
||||
request,
|
||||
render(
|
||||
|
|
|
@ -107,7 +107,9 @@ INSTALLED_APPS = [
|
|||
"passbook.stages.user_login.apps.PassbookStageUserLoginConfig",
|
||||
"passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig",
|
||||
"passbook.stages.user_write.apps.PassbookStageUserWriteConfig",
|
||||
"passbook.stages.otp.apps.PassbookStageOTPConfig",
|
||||
"passbook.stages.otp_static.apps.PassbookStageOTPStaticConfig",
|
||||
"passbook.stages.otp_time.apps.PassbookStageOTPTimeConfig",
|
||||
"passbook.stages.otp_validate.apps.PassbookStageOTPValidateConfig",
|
||||
"passbook.stages.password.apps.PassbookStagePasswordConfig",
|
||||
"passbook.static.apps.PassbookStaticConfig",
|
||||
]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"""OAuth Client models"""
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse, reverse_lazy
|
||||
|
@ -61,7 +62,7 @@ class OAuthSource(Source):
|
|||
return f"Callback URL: <pre>{url}</pre>"
|
||||
|
||||
@property
|
||||
def ui_user_settings(self) -> UIUserSettings:
|
||||
def ui_user_settings(self) -> Optional[UIUserSettings]:
|
||||
view_name = "passbook_sources_oauth:oauth-client-user"
|
||||
return UIUserSettings(
|
||||
name=self.name, url=reverse(view_name, kwargs={"source_slug": self.slug}),
|
||||
|
|
|
@ -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,78 +0,0 @@
|
|||
"""passbook OTP Forms"""
|
||||
|
||||
from django import forms
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
|
||||
from passbook.stages.otp.models import OTPStage
|
||||
|
||||
OTP_CODE_VALIDATOR = RegexValidator(
|
||||
r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.")
|
||||
)
|
||||
|
||||
|
||||
class PictureWidget(forms.widgets.Widget):
|
||||
"""Widget to render value as img-tag"""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
return mark_safe(f'<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(),
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
"""OTP Stage"""
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.types import UIUserSettings
|
||||
from passbook.flows.models import Stage
|
||||
|
||||
|
||||
class OTPStage(Stage):
|
||||
"""OTP Stage"""
|
||||
|
||||
enforced = models.BooleanField(
|
||||
default=False,
|
||||
help_text=("Enforce enabled OTP for Users " "this stage applies to."),
|
||||
)
|
||||
|
||||
type = "passbook.stages.otp.stages.OTPStage"
|
||||
form = "passbook.stages.otp.forms.OTPStageForm"
|
||||
|
||||
@property
|
||||
def ui_user_settings(self) -> UIUserSettings:
|
||||
return UIUserSettings(
|
||||
name="OTP", url=reverse("passbook_stages_otp:otp-user-settings"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"OTP Stage {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OTP Stage")
|
||||
verbose_name_plural = _("OTP Stages")
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
|||
"""OTP Static stage"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookStageOTPStaticConfig(AppConfig):
|
||||
"""OTP Static stage"""
|
||||
|
||||
name = "passbook.stages.otp_static"
|
||||
label = "passbook_stages_otp_static"
|
||||
verbose_name = "passbook OTP.Static"
|
||||
mountpoint = "-/user/otp/static/"
|
|
@ -0,0 +1,39 @@
|
|||
"""OTP Static forms"""
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from passbook.stages.otp_static.models import OTPStaticStage
|
||||
|
||||
|
||||
class StaticTokenWidget(forms.widgets.Widget):
|
||||
"""Widget to render tokens as multiple labels"""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
final_string = '<ul class="pb-otp-tokens">'
|
||||
for token in value:
|
||||
final_string += f"<li>{token.token}</li>"
|
||||
final_string += "</ul>"
|
||||
return mark_safe(final_string) # nosec
|
||||
|
||||
|
||||
class SetupForm(forms.Form):
|
||||
"""Form to setup Static OTP"""
|
||||
|
||||
tokens = forms.CharField(widget=StaticTokenWidget, disabled=True, required=False)
|
||||
|
||||
def __init__(self, tokens, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["tokens"].initial = tokens
|
||||
|
||||
|
||||
class OTPStaticStageForm(forms.ModelForm):
|
||||
"""OTP Static Stage setup form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OTPStaticStage
|
||||
fields = ["name", "token_count"]
|
||||
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,32 @@
|
|||
"""OTP Static models"""
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.core.types import UIUserSettings
|
||||
from passbook.flows.models import Stage
|
||||
|
||||
|
||||
class OTPStaticStage(Stage):
|
||||
"""Generate static tokens for the user as a backup"""
|
||||
|
||||
token_count = models.IntegerField(default=6)
|
||||
|
||||
type = "passbook.stages.otp_static.stage.OTPStaticStageView"
|
||||
form = "passbook.stages.otp_static.forms.OTPStaticStageForm"
|
||||
|
||||
@property
|
||||
def ui_user_settings(self) -> Optional[UIUserSettings]:
|
||||
return UIUserSettings(
|
||||
name="Static OTP", url=reverse("passbook_stages_otp_static:user-settings"),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"OTP Static Stage {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OTP Static Setup Stage")
|
||||
verbose_name_plural = _("OTP Static Setup Stages")
|
|
@ -0,0 +1,5 @@
|
|||
"""OTP Static settings"""
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django_otp.plugins.otp_static",
|
||||
]
|
|
@ -0,0 +1,62 @@
|
|||
"""Static OTP Setup stage"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views.generic import FormView
|
||||
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.stages.otp_static.forms import SetupForm
|
||||
from passbook.stages.otp_static.models import OTPStaticStage
|
||||
|
||||
LOGGER = get_logger()
|
||||
SESSION_STATIC_DEVICE = "static_device"
|
||||
SESSION_STATIC_TOKENS = "static_device_tokens"
|
||||
|
||||
|
||||
class OTPStaticStageView(FormView, StageView):
|
||||
"""Static OTP Setup stage"""
|
||||
|
||||
form_class = SetupForm
|
||||
|
||||
def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
|
||||
kwargs = super().get_form_kwargs(**kwargs)
|
||||
tokens = self.request.session[SESSION_STATIC_TOKENS]
|
||||
kwargs["tokens"] = tokens
|
||||
return kwargs
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
if not user:
|
||||
LOGGER.debug("No pending user, continuing")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
# Currently, this stage only supports one device per user. If the user already
|
||||
# has a device, just skip to the next stage
|
||||
if StaticDevice.objects.filter(user=user).exists():
|
||||
return self.executor.stage_ok()
|
||||
|
||||
stage: OTPStaticStage = self.executor.current_stage
|
||||
|
||||
if SESSION_STATIC_DEVICE not in self.request.session:
|
||||
device = StaticDevice(user=user, confirmed=True)
|
||||
tokens = []
|
||||
for _ in range(0, stage.token_count):
|
||||
tokens.append(
|
||||
StaticToken(device=device, token=StaticToken.random_token())
|
||||
)
|
||||
self.request.session[SESSION_STATIC_DEVICE] = device
|
||||
self.request.session[SESSION_STATIC_TOKENS] = tokens
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form: SetupForm) -> HttpResponse:
|
||||
"""Verify OTP Token"""
|
||||
device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE]
|
||||
device.save()
|
||||
for token in self.request.session[SESSION_STATIC_TOKENS]:
|
||||
token.save()
|
||||
del self.request.session[SESSION_STATIC_DEVICE]
|
||||
del self.request.session[SESSION_STATIC_TOKENS]
|
||||
return self.executor.stage_ok()
|
|
@ -0,0 +1,20 @@
|
|||
{% 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 "Static One-Time Passwords" %}
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<ul class="pb-otp-tokens">
|
||||
{% for token in tokens %}
|
||||
<li>{{ token.token }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{% url 'passbook_stages_otp_static:disable' %}" class="pf-c-button pf-m-danger">{% trans "Disable Static Tokens" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -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"),
|
||||
]
|
|
@ -0,0 +1,41 @@
|
|||
"""otp Static view Tokens"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.views import View
|
||||
from django.views.generic import TemplateView
|
||||
from django_otp.plugins.otp_static.models import StaticToken, StaticDevice
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
|
||||
|
||||
class UserSettingsView(LoginRequiredMixin, TemplateView):
|
||||
"""View for user settings to control OTP"""
|
||||
|
||||
template_name = "stages/otp_static/user_settings.html"
|
||||
|
||||
# TODO: Check if OTP Stage exists and applies to user
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
static_devices = StaticDevice.objects.filter(
|
||||
user=self.request.user, confirmed=True
|
||||
)
|
||||
if static_devices.exists():
|
||||
kwargs["tokens"] = StaticToken.objects.filter(device=static_devices.first())
|
||||
return kwargs
|
||||
|
||||
|
||||
class DisableView(LoginRequiredMixin, View):
|
||||
"""Disable Static Tokens for user"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Delete all the devices for user"""
|
||||
devices = StaticDevice.objects.filter(user=request.user, confirmed=True)
|
||||
devices.delete()
|
||||
messages.success(request, "Successfully disabled Static OTP Tokens")
|
||||
# Create event with email notification
|
||||
Event.new(
|
||||
EventAction.CUSTOM, message="User disabled Static OTP Tokens."
|
||||
).from_http(request)
|
||||
return redirect("passbook_stages_otp:otp-user-settings")
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
|||
"""OTP Time"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookStageOTPTimeConfig(AppConfig):
|
||||
"""OTP time App config"""
|
||||
|
||||
name = "passbook.stages.otp_time"
|
||||
label = "passbook_stages_otp_time"
|
||||
verbose_name = "passbook OTP.Time"
|
||||
mountpoint = "-/user/otp/time/"
|
|
@ -0,0 +1,64 @@
|
|||
"""OTP Time forms"""
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
|
||||
from passbook.stages.otp_time.models import OTPTimeStage
|
||||
from passbook.stages.otp_validate.forms import OTP_CODE_VALIDATOR
|
||||
|
||||
|
||||
class PictureWidget(forms.widgets.Widget):
|
||||
"""Widget to render value as img-tag"""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
return mark_safe(f"<br>{value}") # nosec
|
||||
|
||||
|
||||
class SetupForm(forms.Form):
|
||||
"""Form to setup Time-based OTP"""
|
||||
|
||||
device: Device = None
|
||||
|
||||
qr_code = forms.CharField(
|
||||
widget=PictureWidget,
|
||||
disabled=True,
|
||||
required=False,
|
||||
label=_("Scan this Code with your OTP App."),
|
||||
)
|
||||
code = forms.CharField(
|
||||
label=_("Please enter the Token on your device."),
|
||||
validators=[OTP_CODE_VALIDATOR],
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"autocomplete": "off",
|
||||
"placeholder": "Code",
|
||||
"autofocus": "autofocus",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, device, qr_code, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.device = device
|
||||
self.fields["qr_code"].initial = qr_code
|
||||
|
||||
def clean_code(self):
|
||||
"""Check code with new otp device"""
|
||||
if self.device is not None:
|
||||
if not self.device.verify_token(self.cleaned_data.get("code")):
|
||||
raise forms.ValidationError(_("OTP Code does not match"))
|
||||
return self.cleaned_data.get("code")
|
||||
|
||||
|
||||
class OTPTimeStageForm(forms.ModelForm):
|
||||
"""OTP Time-based Stage setup form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OTPTimeStage
|
||||
fields = ["name", "digits"]
|
||||
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,40 @@
|
|||
"""OTP Time-based models"""
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.core.types import UIUserSettings
|
||||
from passbook.flows.models import Stage
|
||||
|
||||
|
||||
class TOTPDigits(models.IntegerChoices):
|
||||
"""OTP Time Digits"""
|
||||
|
||||
SIX = 6, _("6 digits, widely compatible")
|
||||
EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator")
|
||||
|
||||
|
||||
class OTPTimeStage(Stage):
|
||||
"""Enroll a user's device into Time-based OTP"""
|
||||
|
||||
digits = models.IntegerField(choices=TOTPDigits.choices)
|
||||
|
||||
type = "passbook.stages.otp_time.stage.OTPTimeStageView"
|
||||
form = "passbook.stages.otp_time.forms.OTPTimeStageForm"
|
||||
|
||||
@property
|
||||
def ui_user_settings(self) -> Optional[UIUserSettings]:
|
||||
return UIUserSettings(
|
||||
name="Time-based OTP",
|
||||
url=reverse("passbook_stages_otp_time:user-settings"),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"OTP Time (TOTP) Stage {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OTP Time (TOTP) Setup Stage")
|
||||
verbose_name_plural = _("OTP Time (TOTP) Setup Stages")
|
|
@ -0,0 +1,6 @@
|
|||
"""OTP Time"""
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django_otp.plugins.otp_totp",
|
||||
]
|
||||
OTP_TOTP_ISSUER = "passbook"
|
|
@ -0,0 +1,64 @@
|
|||
"""TOTP Setup stage"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.encoding import force_text
|
||||
from django.views.generic import FormView
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from lxml.etree import tostring # nosec
|
||||
from qrcode import QRCode
|
||||
from qrcode.image.svg import SvgFillImage
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.stages.otp_time.forms import SetupForm
|
||||
from passbook.stages.otp_time.models import OTPTimeStage
|
||||
|
||||
LOGGER = get_logger()
|
||||
SESSION_TOTP_DEVICE = "totp_device"
|
||||
|
||||
|
||||
class OTPTimeStageView(FormView, StageView):
|
||||
"""OTP totp Setup stage"""
|
||||
|
||||
form_class = SetupForm
|
||||
|
||||
def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
|
||||
kwargs = super().get_form_kwargs(**kwargs)
|
||||
device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE]
|
||||
kwargs["device"] = device
|
||||
kwargs["qr_code"] = self._get_qr_code(device)
|
||||
return kwargs
|
||||
|
||||
def _get_qr_code(self, device: TOTPDevice) -> str:
|
||||
"""Get QR Code SVG as string based on `device`"""
|
||||
qr_code = QRCode(image_factory=SvgFillImage)
|
||||
qr_code.add_data(device.config_url)
|
||||
return force_text(tostring(qr_code.make_image().get_image()))
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
if not user:
|
||||
LOGGER.debug("No pending user, continuing")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
# Currently, this stage only supports one device per user. If the user already
|
||||
# has a device, just skip to the next stage
|
||||
if TOTPDevice.objects.filter(user=user).exists():
|
||||
return self.executor.stage_ok()
|
||||
|
||||
stage: OTPTimeStage = self.executor.current_stage
|
||||
|
||||
if SESSION_TOTP_DEVICE not in self.request.session:
|
||||
device = TOTPDevice(user=user, confirmed=True, digits=stage.digits)
|
||||
|
||||
self.request.session[SESSION_TOTP_DEVICE] = device
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form: SetupForm) -> HttpResponse:
|
||||
"""Verify OTP Token"""
|
||||
device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE]
|
||||
device.save()
|
||||
del self.request.session[SESSION_TOTP_DEVICE]
|
||||
return self.executor.stage_ok()
|
|
@ -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="pf-c-button pf-m-primary">{% 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="pf-c-button pf-m-danger">{% 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 %}
|
|
@ -0,0 +1,9 @@
|
|||
"""OTP Time urls"""
|
||||
from django.urls import path
|
||||
|
||||
from passbook.stages.otp_time.views import DisableView, UserSettingsView
|
||||
|
||||
urlpatterns = [
|
||||
path("settings", UserSettingsView.as_view(), name="user-settings"),
|
||||
path("disable", DisableView.as_view(), name="disable"),
|
||||
]
|
|
@ -0,0 +1,38 @@
|
|||
"""otp time-based view"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.views import View
|
||||
from django.views.generic import TemplateView
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
|
||||
|
||||
class UserSettingsView(LoginRequiredMixin, TemplateView):
|
||||
"""View for user settings to control OTP"""
|
||||
|
||||
template_name = "stages/otp_time/user_settings.html"
|
||||
|
||||
# TODO: Check if OTP Stage exists and applies to user
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True)
|
||||
kwargs["state"] = totp_devices.exists()
|
||||
return kwargs
|
||||
|
||||
|
||||
class DisableView(LoginRequiredMixin, View):
|
||||
"""Disable TOTP for user"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Delete all the devices for user"""
|
||||
totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
|
||||
totp.delete()
|
||||
messages.success(request, "Successfully disabled Time-based OTP")
|
||||
# Create event with email notification
|
||||
Event.new(
|
||||
EventAction.CUSTOM, message="User disabled Time-based OTP."
|
||||
).from_http(request)
|
||||
return redirect("passbook_stages_otp:otp-user-settings")
|
|
@ -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
|
|
@ -0,0 +1,10 @@
|
|||
"""OTP Validation Stage"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookStageOTPValidateConfig(AppConfig):
|
||||
"""OTP Validation Stage"""
|
||||
|
||||
name = "passbook.stages.otp_validate"
|
||||
label = "passbook_stages_otp_validate"
|
||||
verbose_name = "passbook OTP.Validate"
|
|
@ -0,0 +1,55 @@
|
|||
"""OTP Validate stage forms"""
|
||||
from django import forms
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp import match_token
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.stages.otp_validate.models import OTPValidateStage
|
||||
|
||||
OTP_CODE_VALIDATOR = RegexValidator(
|
||||
r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.")
|
||||
)
|
||||
|
||||
|
||||
class ValidationForm(forms.Form):
|
||||
"""OTP Validate stage forms"""
|
||||
|
||||
user: User
|
||||
|
||||
code = forms.CharField(
|
||||
label=_("Please enter the token from your device."),
|
||||
validators=[OTP_CODE_VALIDATOR],
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"autocomplete": "off",
|
||||
"placeholder": "123456",
|
||||
"autofocus": "autofocus",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
|
||||
def clean_code(self):
|
||||
"""Validate code against all confirmed devices"""
|
||||
code = self.cleaned_data.get("code")
|
||||
device = match_token(self.user, code)
|
||||
if not device:
|
||||
raise forms.ValidationError(_("Invalid Token"))
|
||||
return code
|
||||
|
||||
|
||||
class OTPValidateStageForm(forms.ModelForm):
|
||||
"""OTP Validate stage forms"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OTPValidateStage
|
||||
fields = ["name"]
|
||||
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
}
|
|
@ -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,0 +1,24 @@
|
|||
"""OTP Validation Stage"""
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.flows.models import NotConfiguredAction, Stage
|
||||
|
||||
|
||||
class OTPValidateStage(Stage):
|
||||
"""Validate user's configured OTP Device"""
|
||||
|
||||
not_configured_action = models.TextField(
|
||||
choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP
|
||||
)
|
||||
|
||||
type = "passbook.stages.otp_validate.stage.OTPValidateStageView"
|
||||
form = "passbook.stages.otp_validate.forms.OTPValidateStageForm"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"OTP Validation Stage {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OTP Validation Stage")
|
||||
verbose_name_plural = _("OTP Validation Stages")
|
|
@ -0,0 +1,4 @@
|
|||
"""OTP Validate stage settings"""
|
||||
INSTALLED_APPS = [
|
||||
"django_otp",
|
||||
]
|
|
@ -0,0 +1,46 @@
|
|||
"""OTP Validation"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views.generic import FormView
|
||||
from django_otp import user_has_device
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.flows.models import NotConfiguredAction
|
||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.stages.otp_validate.forms import ValidationForm
|
||||
from passbook.stages.otp_validate.models import OTPValidateStage
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class OTPValidateStageView(FormView, StageView):
|
||||
"""OTP Validation"""
|
||||
|
||||
form_class = ValidationForm
|
||||
|
||||
def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
|
||||
kwargs = super().get_form_kwargs(**kwargs)
|
||||
kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
return kwargs
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
if not user:
|
||||
LOGGER.debug("No pending user, continuing")
|
||||
return self.executor.stage_ok()
|
||||
has_devices = user_has_device(user)
|
||||
stage: OTPValidateStage = self.executor.current_stage
|
||||
|
||||
if not has_devices:
|
||||
if stage.not_configured_action == NotConfiguredAction.SKIP:
|
||||
LOGGER.debug("OTP not configured, skipping stage")
|
||||
return self.executor.stage_ok()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form: ValidationForm) -> HttpResponse:
|
||||
"""Verify OTP Token"""
|
||||
# Since we do token checking in the form, we know the token is valid here
|
||||
# so we can just continue
|
||||
return self.executor.stage_ok()
|
|
@ -28,13 +28,14 @@ class PasswordForm(forms.Form):
|
|||
widget=forms.HiddenInput(attrs={"autocomplete": "username"}), required=False
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_("Please enter your password."),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={
|
||||
"placeholder": _("Password"),
|
||||
"autofocus": "autofocus",
|
||||
"autocomplete": "current-password",
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,39 +1,10 @@
|
|||
{% extends 'login/form_with_user.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% block card_title %}
|
||||
{% trans title %}
|
||||
{% endblock %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">
|
||||
<span class="pf-c-form__label-text">{% trans "Username" %}</span>
|
||||
</label>
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="{% gravatar user.email %}" alt="">
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="{% url 'passbook_flows:cancel' %}">{% trans 'Not you?' %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/form.html' %}
|
||||
|
||||
{% if recovery_flow %}
|
||||
<a href="{% url 'passbook_flows:flow-executor' flow_slug=recovery_flow.slug %}">{% trans 'Forgot password?' %}</a>
|
||||
{% endif %}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% block beneath_form %}
|
||||
{% if recovery_flow %}
|
||||
<a href="{% url 'passbook_flows:flow-executor' flow_slug=recovery_flow.slug %}">{% trans 'Forgot password?' %}</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -200,6 +200,7 @@ input[data-is-monospace] {
|
|||
|
||||
/* Form with user */
|
||||
.form-control-static {
|
||||
margin-top: var(--pf-global--spacer--sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
@ -209,10 +210,23 @@ input[data-is-monospace] {
|
|||
align-items: center;
|
||||
}
|
||||
.form-control-static img {
|
||||
margin-right: 5px;
|
||||
margin-right: var(--pf-global--spacer--xs);
|
||||
}
|
||||
.form-control-static a {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
line-height: 32px;
|
||||
padding-top: var(--pf-global--spacer--xs);
|
||||
padding-bottom: var(--pf-global--spacer--xs);
|
||||
line-height: var(--pf-global--spacer--xl);
|
||||
}
|
||||
|
||||
/* Static OTP Tokens, passbook.stages.otp_static */
|
||||
.pb-otp-tokens {
|
||||
list-style: circle;
|
||||
columns: 2;
|
||||
-webkit-columns: 2;
|
||||
-moz-columns: 2;
|
||||
margin-left: var(--pf-global--spacer--xs);
|
||||
}
|
||||
.pb-otp-tokens li {
|
||||
font-size: var(--pf-global--FontSize--2xl);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
|
346
swagger.yaml
346
swagger.yaml
|
@ -4037,10 +4037,10 @@ paths:
|
|||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/stages/otp/:
|
||||
/stages/otp_static/:
|
||||
get:
|
||||
operationId: stages_otp_list
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_static_list
|
||||
description: OTPStaticStage Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
|
@ -4084,73 +4084,73 @@ paths:
|
|||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
tags:
|
||||
- stages
|
||||
post:
|
||||
operationId: stages_otp_create
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_static_create
|
||||
description: OTPStaticStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
tags:
|
||||
- stages
|
||||
parameters: []
|
||||
/stages/otp/{stage_uuid}/:
|
||||
/stages/otp_static/{stage_uuid}/:
|
||||
get:
|
||||
operationId: stages_otp_read
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_static_read
|
||||
description: OTPStaticStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
tags:
|
||||
- stages
|
||||
put:
|
||||
operationId: stages_otp_update
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_static_update
|
||||
description: OTPStaticStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
tags:
|
||||
- stages
|
||||
patch:
|
||||
operationId: stages_otp_partial_update
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_static_partial_update
|
||||
description: OTPStaticStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPStage'
|
||||
$ref: '#/definitions/OTPStaticStage'
|
||||
tags:
|
||||
- stages
|
||||
delete:
|
||||
operationId: stages_otp_delete
|
||||
description: OTPStage Viewset
|
||||
operationId: stages_otp_static_delete
|
||||
description: OTPStaticStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
|
@ -4160,7 +4160,261 @@ paths:
|
|||
parameters:
|
||||
- name: stage_uuid
|
||||
in: path
|
||||
description: A UUID string identifying this OTP Stage.
|
||||
description: A UUID string identifying this OTP Static Setup Stage.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/stages/otp_time/:
|
||||
get:
|
||||
operationId: stages_otp_time_list
|
||||
description: OTPTimeStage Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: limit
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
- name: offset
|
||||
in: query
|
||||
description: The initial index from which to return the results.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
tags:
|
||||
- stages
|
||||
post:
|
||||
operationId: stages_otp_time_create
|
||||
description: OTPTimeStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
tags:
|
||||
- stages
|
||||
parameters: []
|
||||
/stages/otp_time/{stage_uuid}/:
|
||||
get:
|
||||
operationId: stages_otp_time_read
|
||||
description: OTPTimeStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
tags:
|
||||
- stages
|
||||
put:
|
||||
operationId: stages_otp_time_update
|
||||
description: OTPTimeStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
tags:
|
||||
- stages
|
||||
patch:
|
||||
operationId: stages_otp_time_partial_update
|
||||
description: OTPTimeStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPTimeStage'
|
||||
tags:
|
||||
- stages
|
||||
delete:
|
||||
operationId: stages_otp_time_delete
|
||||
description: OTPTimeStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
tags:
|
||||
- stages
|
||||
parameters:
|
||||
- name: stage_uuid
|
||||
in: path
|
||||
description: A UUID string identifying this OTP Time (TOTP) Setup Stage.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/stages/otp_validate/:
|
||||
get:
|
||||
operationId: stages_otp_validate_list
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: limit
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
- name: offset
|
||||
in: query
|
||||
description: The initial index from which to return the results.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
post:
|
||||
operationId: stages_otp_validate_create
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
parameters: []
|
||||
/stages/otp_validate/{stage_uuid}/:
|
||||
get:
|
||||
operationId: stages_otp_validate_read
|
||||
description: OTPValidateStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
put:
|
||||
operationId: stages_otp_validate_update
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
patch:
|
||||
operationId: stages_otp_validate_partial_update
|
||||
description: OTPValidateStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OTPValidateStage'
|
||||
tags:
|
||||
- stages
|
||||
delete:
|
||||
operationId: stages_otp_validate_delete
|
||||
description: OTPValidateStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
tags:
|
||||
- stages
|
||||
parameters:
|
||||
- name: stage_uuid
|
||||
in: path
|
||||
description: A UUID string identifying this OTP Validation Stage.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
|
@ -6350,7 +6604,47 @@ definitions:
|
|||
fixed_data:
|
||||
title: Fixed data
|
||||
type: object
|
||||
OTPStage:
|
||||
OTPStaticStage:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Stage uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
minLength: 1
|
||||
token_count:
|
||||
title: Token count
|
||||
type: integer
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
OTPTimeStage:
|
||||
required:
|
||||
- name
|
||||
- digits
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Stage uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
minLength: 1
|
||||
digits:
|
||||
title: Digits
|
||||
type: integer
|
||||
enum:
|
||||
- 6
|
||||
- 8
|
||||
OTPValidateStage:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
|
@ -6364,10 +6658,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 New Issue