-
{{ factor.name }} ({{ factor.slug }})
-
{{ factor|verbose_name }}
+
{{ stage.name }}
+
{{ stage|verbose_name }}
-
- {{ factor.order }}
-
+
- {{ factor.enabled }}
+ {{ stage.enabled }}
- {% trans 'Edit' %}
- {% trans 'Delete' %}
- {% get_links factor as links %}
+ {% trans 'Edit' %}
+ {% trans 'Delete' %}
+ {% get_links stage as links %}
{% for name, href in links.items %}
{% trans name %}
{% endfor %}
diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py
index 7806471d7..0f3526713 100644
--- a/passbook/admin/urls.py
+++ b/passbook/admin/urls.py
@@ -6,7 +6,7 @@ from passbook.admin.views import (
audit,
certificate_key_pair,
debug,
- factors,
+ flows,
groups,
invitations,
overview,
@@ -14,6 +14,7 @@ from passbook.admin.views import (
property_mapping,
providers,
sources,
+ stages,
users,
)
@@ -84,20 +85,29 @@ urlpatterns = [
providers.ProviderDeleteView.as_view(),
name="provider-delete",
),
- # Factors
- path("factors/", factors.FactorListView.as_view(), name="factors"),
- path("factors/create/", factors.FactorCreateView.as_view(), name="factor-create"),
+ # Stages
+ path("stages/", stages.StageListView.as_view(), name="stages"),
+ path("stages/create/", stages.StageCreateView.as_view(), name="stage-create"),
path(
- "factors//update/",
- factors.FactorUpdateView.as_view(),
- name="factor-update",
+ "stages//update/",
+ stages.StageUpdateView.as_view(),
+ name="stage-update",
),
path(
- "factors//delete/",
- factors.FactorDeleteView.as_view(),
- name="factor-delete",
+ "stages//delete/",
+ stages.StageDeleteView.as_view(),
+ name="stage-delete",
),
- # Factors
+ # Flows
+ path("flows/", flows.FlowListView.as_view(), name="flows"),
+ path("flows/create/", flows.FlowCreateView.as_view(), name="flow-create",),
+ path(
+ "flows//update/", flows.FlowUpdateView.as_view(), name="flow-update",
+ ),
+ path(
+ "flows//delete/", flows.FlowDeleteView.as_view(), name="flow-delete",
+ ),
+ # Property Mappings
path(
"property-mappings/",
property_mapping.PropertyMappingListView.as_view(),
diff --git a/passbook/admin/views/flows.py b/passbook/admin/views/flows.py
new file mode 100644
index 000000000..377cf93f4
--- /dev/null
+++ b/passbook/admin/views/flows.py
@@ -0,0 +1,77 @@
+"""passbook Flow administration"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import ugettext as _
+from django.views.generic import DeleteView, ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from passbook.flows.forms import FlowForm
+from passbook.flows.models import Flow
+from passbook.lib.views import CreateAssignPermView
+
+
+class FlowListView(LoginRequiredMixin, PermissionListMixin, ListView):
+ """Show list of all flows"""
+
+ model = Flow
+ permission_required = "passbook_flows.view_flow"
+ ordering = "name"
+ paginate_by = 40
+ template_name = "administration/flow/list.html"
+
+
+class FlowCreateView(
+ SuccessMessageMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Flow"""
+
+ model = Flow
+ form_class = FlowForm
+ permission_required = "passbook_flows.add_flow"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("passbook_admin:flows")
+ success_message = _("Successfully created Flow")
+
+ def get_context_data(self, **kwargs):
+ kwargs["type"] = "Flow"
+ return super().get_context_data(**kwargs)
+
+
+class FlowUpdateView(
+ SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
+):
+ """Update flow"""
+
+ model = Flow
+ form_class = FlowForm
+ permission_required = "passbook_flows.change_flow"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("passbook_admin:flows")
+ success_message = _("Successfully updated Flow")
+
+
+class FlowDeleteView(
+ SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, DeleteView
+):
+ """Delete flow"""
+
+ model = Flow
+ permission_required = "passbook_flows.delete_flow"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("passbook_admin:flows")
+ success_message = _("Successfully deleted Flow")
+
+ def delete(self, request, *args, **kwargs):
+ messages.success(self.request, self.success_message)
+ return super().delete(request, *args, **kwargs)
diff --git a/passbook/admin/views/invitations.py b/passbook/admin/views/invitations.py
index 59383d840..dd00bee83 100644
--- a/passbook/admin/views/invitations.py
+++ b/passbook/admin/views/invitations.py
@@ -11,17 +11,17 @@ from django.utils.translation import ugettext as _
from django.views.generic import DeleteView, ListView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
-from passbook.core.forms.invitations import InvitationForm
-from passbook.core.models import Invitation
from passbook.core.signals import invitation_created
from passbook.lib.views import CreateAssignPermView
+from passbook.stages.invitation.forms import InvitationForm
+from passbook.stages.invitation.models import Invitation
class InvitationListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all invitations"""
model = Invitation
- permission_required = "passbook_core.view_invitation"
+ permission_required = "passbook_stages_invitation.view_invitation"
template_name = "administration/invitation/list.html"
paginate_by = 10
ordering = "-expires"
@@ -37,7 +37,7 @@ class InvitationCreateView(
model = Invitation
form_class = InvitationForm
- permission_required = "passbook_core.add_invitation"
+ permission_required = "passbook_stages_invitation.add_invitation"
template_name = "generic/create.html"
success_url = reverse_lazy("passbook_admin:invitations")
@@ -61,7 +61,7 @@ class InvitationDeleteView(
"""Delete invitation"""
model = Invitation
- permission_required = "passbook_core.delete_invitation"
+ permission_required = "passbook_stages_invitation.delete_invitation"
template_name = "generic/delete.html"
success_url = reverse_lazy("passbook_admin:invitations")
diff --git a/passbook/admin/views/overview.py b/passbook/admin/views/overview.py
index 52025b50e..1b1b0a88b 100644
--- a/passbook/admin/views/overview.py
+++ b/passbook/admin/views/overview.py
@@ -5,16 +5,10 @@ from django.views.generic import TemplateView
from passbook import __version__
from passbook.admin.mixins import AdminRequiredMixin
-from passbook.core.models import (
- Application,
- Factor,
- Invitation,
- Policy,
- Provider,
- Source,
- User,
-)
+from passbook.core.models import Application, Policy, Provider, Source, User
+from passbook.flows.models import Flow, Stage
from passbook.root.celery import CELERY_APP
+from passbook.stages.invitation.models import Invitation
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
@@ -26,7 +20,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
"""Handle post (clear cache from modal)"""
if "clear" in self.request.POST:
cache.clear()
- return redirect(reverse("passbook_core:auth-login"))
+ return redirect(reverse("passbook_flows:default-authentication"))
return self.get(*args, **kwargs)
def get_context_data(self, **kwargs):
@@ -35,7 +29,8 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
kwargs["user_count"] = len(User.objects.all())
kwargs["provider_count"] = len(Provider.objects.all())
kwargs["source_count"] = len(Source.objects.all())
- kwargs["factor_count"] = len(Factor.objects.all())
+ kwargs["stage_count"] = len(Stage.objects.all())
+ kwargs["flow_count"] = len(Flow.objects.all())
kwargs["invitation_count"] = len(Invitation.objects.all())
kwargs["version"] = __version__
kwargs["worker_count"] = len(CELERY_APP.control.ping(timeout=0.5))
diff --git a/passbook/admin/views/policy.py b/passbook/admin/views/policy.py
index 588f3a5cf..7c65c1d5c 100644
--- a/passbook/admin/views/policy.py
+++ b/passbook/admin/views/policy.py
@@ -14,7 +14,7 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.forms.policies import PolicyTestForm
from passbook.core.models import Policy
-from passbook.lib.utils.reflection import path_to_class
+from passbook.lib.utils.reflection import all_subclasses, path_to_class
from passbook.lib.views import CreateAssignPermView
from passbook.policies.engine import PolicyEngine
@@ -30,7 +30,7 @@ class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
def get_context_data(self, **kwargs):
kwargs["types"] = {
- x.__name__: x._meta.verbose_name for x in Policy.__subclasses__()
+ x.__name__: x._meta.verbose_name for x in all_subclasses(Policy)
}
return super().get_context_data(**kwargs)
@@ -62,7 +62,7 @@ class PolicyCreateView(
def get_form_class(self):
policy_type = self.request.GET.get("type")
- model = next(x for x in Policy.__subclasses__() if x.__name__ == policy_type)
+ model = next(x for x in all_subclasses(Policy) if x.__name__ == policy_type)
if not model:
raise Http404
return path_to_class(model.form)
diff --git a/passbook/admin/views/property_mapping.py b/passbook/admin/views/property_mapping.py
index d81b933ed..426e06dd4 100644
--- a/passbook/admin/views/property_mapping.py
+++ b/passbook/admin/views/property_mapping.py
@@ -12,17 +12,10 @@ from django.views.generic import DeleteView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.core.models import PropertyMapping
-from passbook.lib.utils.reflection import path_to_class
+from passbook.lib.utils.reflection import all_subclasses, path_to_class
from passbook.lib.views import CreateAssignPermView
-def all_subclasses(cls):
- """Recursively return all subclassess of cls"""
- return set(cls.__subclasses__()).union(
- [s for c in cls.__subclasses__() for s in all_subclasses(c)]
- )
-
-
class PropertyMappingListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all property_mappings"""
diff --git a/passbook/admin/views/providers.py b/passbook/admin/views/providers.py
index 845dfaad0..f0a1d5892 100644
--- a/passbook/admin/views/providers.py
+++ b/passbook/admin/views/providers.py
@@ -12,7 +12,7 @@ from django.views.generic import DeleteView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.core.models import Provider
-from passbook.lib.utils.reflection import path_to_class
+from passbook.lib.utils.reflection import all_subclasses, path_to_class
from passbook.lib.views import CreateAssignPermView
@@ -27,7 +27,7 @@ class ProviderListView(LoginRequiredMixin, PermissionListMixin, ListView):
def get_context_data(self, **kwargs):
kwargs["types"] = {
- x.__name__: x._meta.verbose_name for x in Provider.__subclasses__()
+ x.__name__: x._meta.verbose_name for x in all_subclasses(Provider)
}
return super().get_context_data(**kwargs)
@@ -52,9 +52,7 @@ class ProviderCreateView(
def get_form_class(self):
provider_type = self.request.GET.get("type")
- model = next(
- x for x in Provider.__subclasses__() if x.__name__ == provider_type
- )
+ model = next(x for x in all_subclasses(Provider) if x.__name__ == provider_type)
if not model:
raise Http404
return path_to_class(model.form)
diff --git a/passbook/admin/views/sources.py b/passbook/admin/views/sources.py
index 68c1bd422..b5d46af2f 100644
--- a/passbook/admin/views/sources.py
+++ b/passbook/admin/views/sources.py
@@ -12,17 +12,10 @@ from django.views.generic import DeleteView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.core.models import Source
-from passbook.lib.utils.reflection import path_to_class
+from passbook.lib.utils.reflection import all_subclasses, path_to_class
from passbook.lib.views import CreateAssignPermView
-def all_subclasses(cls):
- """Recursively return all subclassess of cls"""
- return set(cls.__subclasses__()).union(
- [s for c in cls.__subclasses__() for s in all_subclasses(c)]
- )
-
-
class SourceListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all sources"""
diff --git a/passbook/admin/views/factors.py b/passbook/admin/views/stages.py
similarity index 53%
rename from passbook/admin/views/factors.py
rename to passbook/admin/views/stages.py
index 628c6a61f..c2c86d2be 100644
--- a/passbook/admin/views/factors.py
+++ b/passbook/admin/views/stages.py
@@ -1,4 +1,4 @@
-"""passbook Factor administration"""
+"""passbook Stage administration"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
@@ -11,30 +11,23 @@ from django.utils.translation import ugettext as _
from django.views.generic import DeleteView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
-from passbook.core.models import Factor
-from passbook.lib.utils.reflection import path_to_class
+from passbook.flows.models import Stage
+from passbook.lib.utils.reflection import all_subclasses, path_to_class
from passbook.lib.views import CreateAssignPermView
-def all_subclasses(cls):
- """Recursively return all subclassess of cls"""
- return set(cls.__subclasses__()).union(
- [s for c in cls.__subclasses__() for s in all_subclasses(c)]
- )
+class StageListView(LoginRequiredMixin, PermissionListMixin, ListView):
+ """Show list of all stages"""
-
-class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView):
- """Show list of all factors"""
-
- model = Factor
- template_name = "administration/factor/list.html"
- permission_required = "passbook_core.view_factor"
- ordering = "order"
+ model = Stage
+ template_name = "administration/stage/list.html"
+ permission_required = "passbook_flows.view_stage"
+ ordering = "name"
paginate_by = 40
def get_context_data(self, **kwargs):
kwargs["types"] = {
- x.__name__: x._meta.verbose_name for x in all_subclasses(Factor)
+ x.__name__: x._meta.verbose_name for x in all_subclasses(Stage)
}
return super().get_context_data(**kwargs)
@@ -42,46 +35,46 @@ class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView):
return super().get_queryset().select_subclasses()
-class FactorCreateView(
+class StageCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
- """Create new Factor"""
+ """Create new Stage"""
- model = Factor
+ model = Stage
template_name = "generic/create.html"
- permission_required = "passbook_core.add_factor"
+ permission_required = "passbook_flows.add_stage"
- success_url = reverse_lazy("passbook_admin:factors")
- success_message = _("Successfully created Factor")
+ success_url = reverse_lazy("passbook_admin:stages")
+ success_message = _("Successfully created Stage")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
- factor_type = self.request.GET.get("type")
- model = next(x for x in all_subclasses(Factor) if x.__name__ == factor_type)
+ stage_type = self.request.GET.get("type")
+ model = next(x for x in all_subclasses(Stage) if x.__name__ == stage_type)
kwargs["type"] = model._meta.verbose_name
return kwargs
def get_form_class(self):
- factor_type = self.request.GET.get("type")
- model = next(x for x in all_subclasses(Factor) if x.__name__ == factor_type)
+ stage_type = self.request.GET.get("type")
+ model = next(x for x in all_subclasses(Stage) if x.__name__ == stage_type)
if not model:
raise Http404
return path_to_class(model.form)
-class FactorUpdateView(
+class StageUpdateView(
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
):
- """Update factor"""
+ """Update stage"""
- model = Factor
- permission_required = "passbook_core.update_application"
+ model = Stage
+ permission_required = "passbook_flows.update_application"
template_name = "generic/update.html"
- success_url = reverse_lazy("passbook_admin:factors")
- success_message = _("Successfully updated Factor")
+ success_url = reverse_lazy("passbook_admin:stages")
+ success_message = _("Successfully updated Stage")
def get_form_class(self):
form_class_path = self.get_object().form
@@ -90,24 +83,24 @@ class FactorUpdateView(
def get_object(self, queryset=None):
return (
- Factor.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
+ Stage.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
-class FactorDeleteView(
+class StageDeleteView(
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, DeleteView
):
- """Delete factor"""
+ """Delete stage"""
- model = Factor
+ model = Stage
template_name = "generic/delete.html"
- permission_required = "passbook_core.delete_factor"
- success_url = reverse_lazy("passbook_admin:factors")
- success_message = _("Successfully deleted Factor")
+ permission_required = "passbook_flows.delete_stage"
+ success_url = reverse_lazy("passbook_admin:stages")
+ success_message = _("Successfully deleted Stage")
def get_object(self, queryset=None):
return (
- Factor.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
+ Stage.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
def delete(self, request, *args, **kwargs):
diff --git a/passbook/admin/views/users.py b/passbook/admin/views/users.py
index 257ad955a..618bbf907 100644
--- a/passbook/admin/views/users.py
+++ b/passbook/admin/views/users.py
@@ -94,9 +94,10 @@ class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailV
def get(self, request, *args, **kwargs):
"""Create nonce for user and return link"""
super().get(request, *args, **kwargs)
+ # TODO: create plan for user, get token
nonce = Nonce.objects.create(user=self.object)
link = request.build_absolute_uri(
- reverse("passbook_core:auth-password-reset", kwargs={"nonce": nonce.uuid})
+ reverse("passbook_flows:default-recovery", kwargs={"nonce": nonce.uuid})
)
messages.success(
request, _("Password reset link: %(link)s " % {"link": link})
diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py
index 061c16831..c9482cfff 100644
--- a/passbook/api/v2/urls.py
+++ b/passbook/api/v2/urls.py
@@ -1,4 +1,5 @@
"""api v2 urls"""
+from django.conf import settings
from django.conf.urls import url
from django.urls import path
from drf_yasg import openapi
@@ -9,20 +10,15 @@ from structlog import get_logger
from passbook.api.permissions import CustomObjectPermissions
from passbook.audit.api import EventViewSet
from passbook.core.api.applications import ApplicationViewSet
-from passbook.core.api.factors import FactorViewSet
from passbook.core.api.groups import GroupViewSet
-from passbook.core.api.invitations import InvitationViewSet
from passbook.core.api.policies import PolicyViewSet
from passbook.core.api.propertymappings import PropertyMappingViewSet
from passbook.core.api.providers import ProviderViewSet
from passbook.core.api.sources import SourceViewSet
from passbook.core.api.users import UserViewSet
-from passbook.factors.captcha.api import CaptchaFactorViewSet
-from passbook.factors.dummy.api import DummyFactorViewSet
-from passbook.factors.email.api import EmailFactorViewSet
-from passbook.factors.otp.api import OTPFactorViewSet
-from passbook.factors.password.api import PasswordFactorViewSet
+from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
from passbook.lib.utils.reflection import get_apps
+from passbook.policies.api import PolicyBindingViewSet
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
from passbook.policies.expression.api import ExpressionPolicyViewSet
from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet
@@ -35,6 +31,17 @@ from passbook.providers.oidc.api import OpenIDProviderViewSet
from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
from passbook.sources.oauth.api import OAuthSourceViewSet
+from passbook.stages.captcha.api import CaptchaStageViewSet
+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.password.api import PasswordStageViewSet
+from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet
+from passbook.stages.user_delete.api import UserDeleteStageViewSet
+from passbook.stages.user_login.api import UserLoginStageViewSet
+from passbook.stages.user_logout.api import UserLogoutStageViewSet
+from passbook.stages.user_write.api import UserWriteStageViewSet
LOGGER = get_logger()
router = routers.DefaultRouter()
@@ -46,40 +53,62 @@ for _passbook_app in get_apps():
LOGGER.debug("Mounted API URLs", app_name=_passbook_app.name)
router.register("core/applications", ApplicationViewSet)
-router.register("core/invitations", InvitationViewSet)
router.register("core/groups", GroupViewSet)
router.register("core/users", UserViewSet)
+
router.register("audit/events", EventViewSet)
+
router.register("sources/all", SourceViewSet)
router.register("sources/ldap", LDAPSourceViewSet)
router.register("sources/oauth", OAuthSourceViewSet)
+
router.register("policies/all", PolicyViewSet)
-router.register("policies/passwordexpiry", PasswordExpiryPolicyViewSet)
+router.register("policies/bindings", PolicyBindingViewSet)
+router.register("policies/expression", ExpressionPolicyViewSet)
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
router.register("policies/password", PasswordPolicyViewSet)
+router.register("policies/passwordexpiry", PasswordExpiryPolicyViewSet)
router.register("policies/reputation", ReputationPolicyViewSet)
router.register("policies/webhook", WebhookPolicyViewSet)
-router.register("policies/expression", ExpressionPolicyViewSet)
+
router.register("providers/all", ProviderViewSet)
router.register("providers/applicationgateway", ApplicationGatewayProviderViewSet)
router.register("providers/oauth", OAuth2ProviderViewSet)
router.register("providers/openid", OpenIDProviderViewSet)
router.register("providers/saml", SAMLProviderViewSet)
+
router.register("propertymappings/all", PropertyMappingViewSet)
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
-router.register("factors/all", FactorViewSet)
-router.register("factors/captcha", CaptchaFactorViewSet)
-router.register("factors/dummy", DummyFactorViewSet)
-router.register("factors/email", EmailFactorViewSet)
-router.register("factors/otp", OTPFactorViewSet)
-router.register("factors/password", PasswordFactorViewSet)
+
+router.register("stages/all", StageViewSet)
+router.register("stages/captcha", CaptchaStageViewSet)
+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/password", PasswordStageViewSet)
+router.register("stages/prompt/stages", PromptStageViewSet)
+router.register("stages/prompt/prompts", PromptViewSet)
+router.register("stages/user_delete", UserDeleteStageViewSet)
+router.register("stages/user_login", UserLoginStageViewSet)
+router.register("stages/user_logout", UserLogoutStageViewSet)
+router.register("stages/user_write", UserWriteStageViewSet)
+
+router.register("flows/instances", FlowViewSet)
+router.register("flows/bindings", FlowStageBindingViewSet)
+
+if settings.DEBUG:
+ from passbook.stages.dummy.api import DummyStageViewSet
+ from passbook.policies.dummy.api import DummyPolicyViewSet
+
+ router.register("stages/dummy", DummyStageViewSet)
+ router.register("policies/dummy", DummyPolicyViewSet)
info = openapi.Info(
title="passbook API",
default_version="v2",
- # description="Test description",
- # terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="hello@beryju.org"),
license=openapi.License(name="MIT License"),
)
diff --git a/passbook/core/api/factors.py b/passbook/core/api/factors.py
deleted file mode 100644
index ec812fa8b..000000000
--- a/passbook/core/api/factors.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Factor API Views"""
-from rest_framework.serializers import ModelSerializer, SerializerMethodField
-from rest_framework.viewsets import ReadOnlyModelViewSet
-
-from passbook.core.models import Factor
-
-
-class FactorSerializer(ModelSerializer):
- """Factor Serializer"""
-
- __type__ = SerializerMethodField(method_name="get_type")
-
- def get_type(self, obj):
- """Get object type so that we know which API Endpoint to use to get the full object"""
- return obj._meta.object_name.lower().replace("factor", "")
-
- class Meta:
-
- model = Factor
- fields = ["pk", "name", "slug", "order", "enabled", "__type__"]
-
-
-class FactorViewSet(ReadOnlyModelViewSet):
- """Factor Viewset"""
-
- queryset = Factor.objects.all()
- serializer_class = FactorSerializer
-
- def get_queryset(self):
- return Factor.objects.select_subclasses()
diff --git a/passbook/core/api/invitations.py b/passbook/core/api/invitations.py
deleted file mode 100644
index c6e451f62..000000000
--- a/passbook/core/api/invitations.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Invitation API Views"""
-from rest_framework.serializers import ModelSerializer
-from rest_framework.viewsets import ModelViewSet
-
-from passbook.core.models import Invitation
-
-
-class InvitationSerializer(ModelSerializer):
- """Invitation Serializer"""
-
- class Meta:
-
- model = Invitation
- fields = [
- "pk",
- "expires",
- "fixed_username",
- "fixed_email",
- "needs_confirmation",
- ]
-
-
-class InvitationViewSet(ModelViewSet):
- """Invitation Viewset"""
-
- queryset = Invitation.objects.all()
- serializer_class = InvitationSerializer
diff --git a/passbook/core/forms/authentication.py b/passbook/core/forms/authentication.py
deleted file mode 100644
index 557341284..000000000
--- a/passbook/core/forms/authentication.py
+++ /dev/null
@@ -1,89 +0,0 @@
-"""passbook core authentication forms"""
-from django import forms
-from django.core.exceptions import ValidationError
-from django.core.validators import validate_email
-from django.utils.translation import gettext_lazy as _
-from structlog import get_logger
-
-from passbook.core.models import User
-from passbook.lib.config import CONFIG
-from passbook.lib.utils.ui import human_list
-
-LOGGER = get_logger()
-
-
-class LoginForm(forms.Form):
- """Allow users to login"""
-
- title = _("Log in to your account")
- uid_field = forms.CharField(label=_(""))
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- if CONFIG.y("passbook.uid_fields") == ["e-mail"]:
- self.fields["uid_field"] = forms.EmailField()
- self.fields["uid_field"].label = human_list(
- [x.title() for x in CONFIG.y("passbook.uid_fields")]
- )
-
- def clean_uid_field(self):
- """Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
- if CONFIG.y("passbook.uid_fields") == ["email"]:
- validate_email(self.cleaned_data.get("uid_field"))
- return self.cleaned_data.get("uid_field")
-
-
-class SignUpForm(forms.Form):
- """SignUp Form"""
-
- title = _("Sign Up")
- name = forms.CharField(
- label=_("Name"), widget=forms.TextInput(attrs={"placeholder": _("Name")})
- )
- username = forms.CharField(
- label=_("Username"),
- widget=forms.TextInput(attrs={"placeholder": _("Username")}),
- )
- email = forms.EmailField(
- label=_("E-Mail"), widget=forms.TextInput(attrs={"placeholder": _("E-Mail")})
- )
- password = forms.CharField(
- label=_("Password"),
- widget=forms.PasswordInput(attrs={"placeholder": _("Password")}),
- )
- password_repeat = forms.CharField(
- label=_("Repeat Password"),
- widget=forms.PasswordInput(attrs={"placeholder": _("Repeat Password")}),
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # All fields which have initial data supplied are set to read only
- if "initial" in kwargs:
- for field in kwargs.get("initial").keys():
- self.fields[field].widget.attrs["readonly"] = "readonly"
-
- def clean_username(self):
- """Check if username is used already"""
- username = self.cleaned_data.get("username")
- if User.objects.filter(username=username).exists():
- LOGGER.warning("username already exists", username=username)
- raise ValidationError(_("Username already exists"))
- return username
-
- def clean_email(self):
- """Check if email is already used in django or other auth sources"""
- email = self.cleaned_data.get("email")
- # Check if user exists already, error early
- if User.objects.filter(email=email).exists():
- LOGGER.debug("email already exists", email=email)
- raise ValidationError(_("Email already exists"))
- return email
-
- def clean_password_repeat(self):
- """Check if Password adheres to filter and if passwords matche"""
- password = self.cleaned_data.get("password")
- password_repeat = self.cleaned_data.get("password_repeat")
- if password != password_repeat:
- raise ValidationError(_("Passwords don't match"))
- return self.cleaned_data.get("password_repeat")
diff --git a/passbook/core/forms/invitations.py b/passbook/core/forms/invitations.py
deleted file mode 100644
index be64ce302..000000000
--- a/passbook/core/forms/invitations.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""passbook core invitation form"""
-
-from django import forms
-from django.core.exceptions import ValidationError
-from django.utils.translation import gettext as _
-
-from passbook.core.models import Invitation, User
-
-
-class InvitationForm(forms.ModelForm):
- """InvitationForm"""
-
- def clean_fixed_username(self):
- """Check if username is already used"""
- username = self.cleaned_data.get("fixed_username")
- if User.objects.filter(username=username).exists():
- raise ValidationError(_("Username is already in use."))
- return username
-
- def clean_fixed_email(self):
- """Check if email is already used"""
- email = self.cleaned_data.get("fixed_email")
- if User.objects.filter(email=email).exists():
- raise ValidationError(_("E-Mail is already in use."))
- return email
-
- class Meta:
-
- model = Invitation
- fields = ["expires", "fixed_username", "fixed_email", "needs_confirmation"]
- labels = {
- "fixed_username": "Force user's username (optional)",
- "fixed_email": "Force user's email (optional)",
- }
- widgets = {
- "fixed_username": forms.TextInput(),
- "fixed_email": forms.TextInput(),
- }
diff --git a/passbook/core/forms/users.py b/passbook/core/forms/users.py
index b989eff9a..f6b725cda 100644
--- a/passbook/core/forms/users.py
+++ b/passbook/core/forms/users.py
@@ -1,8 +1,6 @@
"""passbook core user forms"""
from django import forms
-from django.forms import ValidationError
-from django.utils.translation import gettext_lazy as _
from passbook.core.models import User
@@ -15,28 +13,3 @@ class UserDetailForm(forms.ModelForm):
model = User
fields = ["username", "name", "email"]
widgets = {"name": forms.TextInput}
-
-
-class PasswordChangeForm(forms.Form):
- """Form to update password"""
-
- password = forms.CharField(
- label=_("Password"),
- widget=forms.PasswordInput(
- attrs={"placeholder": _("New Password"), "autocomplete": "new-password"}
- ),
- )
- password_repeat = forms.CharField(
- label=_("Repeat Password"),
- widget=forms.PasswordInput(
- attrs={"placeholder": _("Repeat Password"), "autocomplete": "new-password"}
- ),
- )
-
- def clean_password_repeat(self):
- """Check if Password adheres to filter and if passwords matche"""
- password = self.cleaned_data.get("password")
- password_repeat = self.cleaned_data.get("password_repeat")
- if password != password_repeat:
- raise ValidationError(_("Passwords don't match"))
- return self.cleaned_data.get("password_repeat")
diff --git a/passbook/core/migrations/0012_delete_factor.py b/passbook/core/migrations/0012_delete_factor.py
new file mode 100644
index 000000000..f8f9c3128
--- /dev/null
+++ b/passbook/core/migrations/0012_delete_factor.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.3 on 2020-05-08 17:58
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_core", "0011_auto_20200222_1822"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(name="Factor",),
+ ]
diff --git a/passbook/core/migrations/0013_delete_debugpolicy.py b/passbook/core/migrations/0013_delete_debugpolicy.py
new file mode 100644
index 000000000..4e658f01f
--- /dev/null
+++ b/passbook/core/migrations/0013_delete_debugpolicy.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.0.5 on 2020-05-10 10:01
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_policies", "0003_auto_20200508_1642"),
+ ("passbook_stages_password", "0001_initial"),
+ ("passbook_core", "0012_delete_factor"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(name="DebugPolicy",),
+ ]
diff --git a/passbook/core/migrations/0014_delete_invitation.py b/passbook/core/migrations/0014_delete_invitation.py
new file mode 100644
index 000000000..2f3e67b70
--- /dev/null
+++ b/passbook/core/migrations/0014_delete_invitation.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.5 on 2020-05-11 19:57
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_core", "0013_delete_debugpolicy"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(name="Invitation",),
+ ]
diff --git a/passbook/core/models.py b/passbook/core/models.py
index 25d33db16..6c0a6d5ea 100644
--- a/passbook/core/models.py
+++ b/passbook/core/models.py
@@ -1,7 +1,5 @@
"""passbook core models"""
from datetime import timedelta
-from random import SystemRandom
-from time import sleep
from typing import Any, Optional
from uuid import uuid4
@@ -10,7 +8,6 @@ from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.http import HttpRequest
-from django.urls import reverse_lazy
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import ExportModelOperationsMixin
@@ -34,7 +31,7 @@ NATIVE_ENVIRONMENT = NativeEnvironment()
def default_nonce_duration():
"""Default duration a Nonce is valid"""
- return now() + timedelta(hours=4)
+ return now() + timedelta(minutes=30)
class Group(ExportModelOperationsMixin("group"), UUIDModel):
@@ -103,30 +100,6 @@ class PolicyModel(UUIDModel, CreatedUpdatedModel):
policies = models.ManyToManyField("Policy", blank=True)
-class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
- """Authentication factor, multiple instances of the same Factor can be used"""
-
- name = models.TextField(help_text=_("Factor's display Name."))
- slug = models.SlugField(
- unique=True, help_text=_("Internal factor name, used in URLs.")
- )
- order = models.IntegerField()
- enabled = models.BooleanField(default=True)
-
- objects = InheritanceManager()
- type = ""
- form = ""
-
- @property
- def ui_user_settings(self) -> Optional[UIUserSettings]:
- """Entrypoint to integrate with User settings. Can either return None if no
- user settings are available, or an instanace of UIUserSettings."""
- return None
-
- def __str__(self):
- return f"Factor {self.slug}"
-
-
class Application(ExportModelOperationsMixin("application"), PolicyModel):
"""Every Application which uses passbook for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to
@@ -222,54 +195,6 @@ class Policy(ExportModelOperationsMixin("policy"), UUIDModel, CreatedUpdatedMode
raise PolicyException()
-class DebugPolicy(Policy):
- """Policy used for debugging the PolicyEngine. Returns a fixed result,
- but takes a random time to process."""
-
- result = models.BooleanField(default=False)
- wait_min = models.IntegerField(default=5)
- wait_max = models.IntegerField(default=30)
-
- form = "passbook.core.forms.policies.DebugPolicyForm"
-
- def passes(self, request: PolicyRequest) -> PolicyResult:
- """Wait random time then return result"""
- wait = SystemRandom().randrange(self.wait_min, self.wait_max)
- LOGGER.debug("Policy waiting", policy=self, delay=wait)
- sleep(wait)
- return PolicyResult(self.result, "Debugging")
-
- class Meta:
-
- verbose_name = _("Debug Policy")
- verbose_name_plural = _("Debug Policies")
-
-
-class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel):
- """Single-use invitation link"""
-
- created_by = models.ForeignKey("User", on_delete=models.CASCADE)
- expires = models.DateTimeField(default=None, blank=True, null=True)
- fixed_username = models.TextField(blank=True, default=None)
- fixed_email = models.TextField(blank=True, default=None)
- needs_confirmation = models.BooleanField(default=True)
-
- @property
- def link(self):
- """Get link to use invitation"""
- return (
- reverse_lazy("passbook_core:auth-sign-up") + f"?invitation={self.uuid.hex}"
- )
-
- def __str__(self):
- return f"Invitation {self.uuid.hex} created by {self.created_by}"
-
- class Meta:
-
- verbose_name = _("Invitation")
- verbose_name_plural = _("Invitations")
-
-
class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel):
"""One-time link for password resets/sign-up-confirmations"""
diff --git a/passbook/core/templates/base/page.html b/passbook/core/templates/base/page.html
index 3d6b6702d..27a793e28 100644
--- a/passbook/core/templates/base/page.html
+++ b/passbook/core/templates/base/page.html
@@ -40,7 +40,7 @@
diff --git a/passbook/core/templates/partials/form.html b/passbook/core/templates/partials/form.html
index c7aa7ebe9..77c867889 100644
--- a/passbook/core/templates/partials/form.html
+++ b/passbook/core/templates/partials/form.html
@@ -25,6 +25,11 @@
{{ field }}
+ {% if field.help_text %}
+
+ {{ field.help_text }}
+
+ {% endif %}
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
{{ field }} {{ field.label }}
diff --git a/passbook/core/templates/user/base.html b/passbook/core/templates/user/base.html
index 6e9d0e650..36770773b 100644
--- a/passbook/core/templates/user/base.html
+++ b/passbook/core/templates/user/base.html
@@ -18,16 +18,16 @@
- {% user_factors as user_factors_loc %}
- {% if user_factors_loc %}
+ {% user_stages as user_stages_loc %}
+ {% if user_stages_loc %}
- {% trans 'Factors' %}
+ {% trans 'Stages' %}
- {% for factor in user_factors_loc %}
+ {% for stage in user_stages_loc %}
-
-
- {{ factor.name }}
+
+
+ {{ stage.name }}
{% endfor %}
@@ -57,12 +57,8 @@
-
-
- {% block page %}
- {% endblock %}
-
-
+ {% block page %}
+ {% endblock %}
diff --git a/passbook/core/templates/user/settings.html b/passbook/core/templates/user/settings.html
index 09c8a2ec5..2c36a8e70 100644
--- a/passbook/core/templates/user/settings.html
+++ b/passbook/core/templates/user/settings.html
@@ -3,22 +3,55 @@
{% load i18n %}
{% block page %}
-
-
diff --git a/passbook/providers/saml/utils/time.py b/passbook/providers/saml/utils/time.py
index 3e88e0d93..2fe490ba9 100644
--- a/passbook/providers/saml/utils/time.py
+++ b/passbook/providers/saml/utils/time.py
@@ -1,5 +1,6 @@
"""Time utilities"""
import datetime
+from typing import Optional
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -38,7 +39,7 @@ def timedelta_from_string(expr: str) -> datetime.timedelta:
return datetime.timedelta(**kwargs)
-def get_time_string(delta: datetime.timedelta = None) -> str:
+def get_time_string(delta: Optional[datetime.timedelta] = None) -> str:
"""Get Data formatted in SAML format"""
if delta is None:
delta = datetime.timedelta()
diff --git a/passbook/recovery/tests.py b/passbook/recovery/tests.py
index bb2c19b68..3080b176e 100644
--- a/passbook/recovery/tests.py
+++ b/passbook/recovery/tests.py
@@ -6,6 +6,7 @@ from django.shortcuts import reverse
from django.test import TestCase
from passbook.core.models import Nonce, User
+from passbook.lib.config import CONFIG
class TestRecovery(TestCase):
@@ -16,10 +17,11 @@ class TestRecovery(TestCase):
def test_create_key(self):
"""Test creation of a new key"""
+ CONFIG.update_from_dict({"domain": "testserver"})
out = StringIO()
self.assertEqual(len(Nonce.objects.all()), 0)
call_command("create_recovery_key", "1", self.user.username, stdout=out)
- self.assertIn("https://localhost/recovery/use-nonce/", out.getvalue())
+ self.assertIn("https://testserver/recovery/use-nonce/", out.getvalue())
self.assertEqual(len(Nonce.objects.all()), 1)
def test_recovery_view(self):
diff --git a/passbook/root/settings.py b/passbook/root/settings.py
index 3f4ad769a..4378fdbd8 100644
--- a/passbook/root/settings.py
+++ b/passbook/root/settings.py
@@ -44,21 +44,16 @@ INTERNAL_IPS = ["127.0.0.1"]
ALLOWED_HOSTS = ["*"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
-LOGIN_URL = "passbook_core:auth-login"
-# CSRF_FAILURE_VIEW = 'passbook.core.views.errors.CSRFErrorView.as_view'
+LOGIN_URL = "passbook_flows:default-authentication"
# Custom user model
AUTH_USER_MODEL = "passbook_core.User"
-if DEBUG:
- CSRF_COOKIE_NAME = "passbook_csrf_debug"
- LANGUAGE_COOKIE_NAME = "passbook_language_debug"
- SESSION_COOKIE_NAME = "passbook_session_debug"
- SESSION_COOKIE_SAMESITE = None
-else:
- CSRF_COOKIE_NAME = "passbook_csrf"
- LANGUAGE_COOKIE_NAME = "passbook_language"
- SESSION_COOKIE_NAME = "passbook_session"
+_cookie_suffix = "_debug" if DEBUG else ""
+CSRF_COOKIE_NAME = f"passbook_csrf{_cookie_suffix}"
+LANGUAGE_COOKIE_NAME = f"passbook_language{_cookie_suffix}"
+SESSION_COOKIE_NAME = f"passbook_session{_cookie_suffix}"
+
SESSION_COOKIE_DOMAIN = CONFIG.y("domain", None)
AUTHENTICATION_BACKENDS = [
@@ -75,36 +70,48 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.postgres",
+ "django.contrib.humanize",
"rest_framework",
+ "django_filters",
"drf_yasg",
"guardian",
"django_prometheus",
- "passbook.static.apps.PassbookStaticConfig",
"passbook.admin.apps.PassbookAdminConfig",
"passbook.api.apps.PassbookAPIConfig",
- "passbook.lib.apps.PassbookLibConfig",
"passbook.audit.apps.PassbookAuditConfig",
"passbook.crypto.apps.PassbookCryptoConfig",
- "passbook.recovery.apps.PassbookRecoveryConfig",
- "passbook.sources.saml.apps.PassbookSourceSAMLConfig",
- "passbook.sources.ldap.apps.PassbookSourceLDAPConfig",
- "passbook.sources.oauth.apps.PassbookSourceOAuthConfig",
+ "passbook.flows.apps.PassbookFlowsConfig",
+ "passbook.lib.apps.PassbookLibConfig",
+ "passbook.policies.apps.PassbookPoliciesConfig",
+ "passbook.policies.dummy.apps.PassbookPolicyDummyConfig",
+ "passbook.policies.expiry.apps.PassbookPolicyExpiryConfig",
+ "passbook.policies.expression.apps.PassbookPolicyExpressionConfig",
+ "passbook.policies.hibp.apps.PassbookPolicyHIBPConfig",
+ "passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
+ "passbook.policies.reputation.apps.PassbookPolicyReputationConfig",
+ "passbook.policies.webhook.apps.PassbookPoliciesWebhookConfig",
"passbook.providers.app_gw.apps.PassbookApplicationApplicationGatewayConfig",
"passbook.providers.oauth.apps.PassbookProviderOAuthConfig",
"passbook.providers.oidc.apps.PassbookProviderOIDCConfig",
"passbook.providers.saml.apps.PassbookProviderSAMLConfig",
"passbook.providers.samlv2.apps.PassbookProviderSAMLv2Config",
- "passbook.factors.otp.apps.PassbookFactorOTPConfig",
- "passbook.factors.captcha.apps.PassbookFactorCaptchaConfig",
- "passbook.factors.password.apps.PassbookFactorPasswordConfig",
- "passbook.factors.dummy.apps.PassbookFactorDummyConfig",
- "passbook.factors.email.apps.PassbookFactorEmailConfig",
- "passbook.policies.expiry.apps.PassbookPolicyExpiryConfig",
- "passbook.policies.reputation.apps.PassbookPolicyReputationConfig",
- "passbook.policies.hibp.apps.PassbookPolicyHIBPConfig",
- "passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
- "passbook.policies.webhook.apps.PassbookPoliciesWebhookConfig",
- "passbook.policies.expression.apps.PassbookPolicyExpressionConfig",
+ "passbook.recovery.apps.PassbookRecoveryConfig",
+ "passbook.sources.ldap.apps.PassbookSourceLDAPConfig",
+ "passbook.sources.oauth.apps.PassbookSourceOAuthConfig",
+ "passbook.sources.saml.apps.PassbookSourceSAMLConfig",
+ "passbook.stages.captcha.apps.PassbookStageCaptchaConfig",
+ "passbook.stages.dummy.apps.PassbookStageDummyConfig",
+ "passbook.stages.email.apps.PassbookStageEmailConfig",
+ "passbook.stages.prompt.apps.PassbookStagPromptConfig",
+ "passbook.stages.identification.apps.PassbookStageIdentificationConfig",
+ "passbook.stages.invitation.apps.PassbookStageUserInvitationConfig",
+ "passbook.stages.user_delete.apps.PassbookStageUserDeleteConfig",
+ "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.password.apps.PassbookStagePasswordConfig",
+ "passbook.static.apps.PassbookStaticConfig",
]
GUARDIAN_MONKEY_PATCH = False
@@ -325,17 +332,19 @@ LOGGING = {
},
"loggers": {},
}
+LOG_LEVEL = "DEBUG" if DEBUG else "WARNING"
_LOGGING_HANDLER_MAP = {
- "": "DEBUG",
- "passbook": "DEBUG",
+ "": LOG_LEVEL,
+ "passbook": LOG_LEVEL,
"django": "WARNING",
"celery": "WARNING",
- "grpc": "DEBUG",
- "oauthlib": "DEBUG",
- "oauth2_provider": "DEBUG",
- "oidc_provider": "DEBUG",
+ "grpc": LOG_LEVEL,
+ "oauthlib": LOG_LEVEL,
+ "oauth2_provider": LOG_LEVEL,
+ "oidc_provider": LOG_LEVEL,
}
for handler_name, level in _LOGGING_HANDLER_MAP.items():
+ # pyright: reportGeneralTypeIssues=false
LOGGING["loggers"][handler_name] = {
"handlers": ["console"],
"level": level,
@@ -382,6 +391,7 @@ for _app in INSTALLED_APPS:
pass
if DEBUG:
+ SESSION_COOKIE_SAMESITE = None
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
diff --git a/passbook/root/tests.py b/passbook/root/tests.py
new file mode 100644
index 000000000..e8021c617
--- /dev/null
+++ b/passbook/root/tests.py
@@ -0,0 +1,28 @@
+"""root tests"""
+from base64 import b64encode
+
+from django.conf import settings
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+
+class TestRoot(TestCase):
+ """Test root application"""
+
+ def setUp(self):
+ super().setUp()
+ self.client = Client()
+
+ def test_monitoring_error(self):
+ """Test monitoring without any credentials"""
+ response = self.client.get(reverse("metrics"))
+ self.assertEqual(response.status_code, 401)
+
+ def test_monitoring_ok(self):
+ """Test monitoring with credentials"""
+ creds = "Basic " + b64encode(f"monitor:{settings.SECRET_KEY}".encode()).decode(
+ "utf-8"
+ )
+ auth_headers = {"HTTP_AUTHORIZATION": creds}
+ response = self.client.get(reverse("metrics"), **auth_headers)
+ self.assertEqual(response.status_code, 200)
diff --git a/passbook/root/urls.py b/passbook/root/urls.py
index b9741a118..17eee10c0 100644
--- a/passbook/root/urls.py
+++ b/passbook/root/urls.py
@@ -11,7 +11,12 @@ from passbook.root.monitoring import MetricsView
LOGGER = get_logger()
admin.autodiscover()
-admin.site.login = RedirectView.as_view(pattern_name="passbook_core:auth-login")
+admin.site.login = RedirectView.as_view(
+ pattern_name="passbook_flows:default-authentication"
+)
+admin.site.logout = RedirectView.as_view(
+ pattern_name="passbook_flows:default-invalidate"
+)
handler400 = error.BadRequestView.as_view()
handler403 = error.ForbiddenView.as_view()
@@ -30,7 +35,11 @@ for _passbook_app in get_apps():
),
)
urlpatterns.append(_path)
- LOGGER.debug("Mounted URLs", app_name=_passbook_app.name)
+ LOGGER.debug(
+ "Mounted URLs",
+ app_name=_passbook_app.name,
+ mountpoint=_passbook_app.mountpoint,
+ )
urlpatterns += [
# Administration
@@ -41,4 +50,4 @@ urlpatterns += [
if settings.DEBUG:
import debug_toolbar
- urlpatterns = [path("__debug__/", include(debug_toolbar.urls)),] + urlpatterns
+ urlpatterns = [path("-/debug/", include(debug_toolbar.urls)),] + urlpatterns
diff --git a/passbook/sources/oauth/views/core.py b/passbook/sources/oauth/views/core.py
index 5c0508c0a..36e84d869 100644
--- a/passbook/sources/oauth/views/core.py
+++ b/passbook/sources/oauth/views/core.py
@@ -13,9 +13,17 @@ from django.views.generic import RedirectView, View
from structlog import get_logger
from passbook.audit.models import Event, EventAction
-from passbook.factors.view import AuthenticationView, _redirect_with_qs
+from passbook.flows.models import Flow, FlowDesignation
+from passbook.flows.planner import (
+ PLAN_CONTEXT_PENDING_USER,
+ PLAN_CONTEXT_SSO,
+ FlowPlanner,
+)
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.lib.utils.urls import redirect_with_qs
from passbook.sources.oauth.clients import get_client
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
LOGGER = get_logger()
@@ -161,14 +169,21 @@ class OAuthCallback(OAuthClientMixin, View):
return None
def handle_login(self, user, source, access):
- """Prepare AuthenticationView, redirect users to remaining Factors"""
+ """Prepare Authentication Plan, redirect user FlowExecutor"""
user = authenticate(
source=access.source, identifier=access.identifier, request=self.request
)
- self.request.session[AuthenticationView.SESSION_PENDING_USER] = user.pk
- self.request.session[AuthenticationView.SESSION_USER_BACKEND] = user.backend
- self.request.session[AuthenticationView.SESSION_IS_SSO_LOGIN] = True
- return _redirect_with_qs("passbook_core:auth-process", self.request.GET)
+ # We run the Flow planner here so we can pass the Pending user in the context
+ flow = get_object_or_404(Flow, designation=FlowDesignation.AUTHENTICATION)
+ planner = FlowPlanner(flow)
+ plan = planner.plan(self.request)
+ plan.context[PLAN_CONTEXT_PENDING_USER] = user
+ plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = user.backend
+ plan.context[PLAN_CONTEXT_SSO] = True
+ self.request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "passbook_flows:flow-executor", self.request.GET, flow_slug=flow.slug,
+ )
# pylint: disable=unused-argument
def handle_existing_user(self, source, user, access, info):
diff --git a/passbook/factors/email/migrations/__init__.py b/passbook/stages/__init__.py
similarity index 100%
rename from passbook/factors/email/migrations/__init__.py
rename to passbook/stages/__init__.py
diff --git a/passbook/factors/otp/__init__.py b/passbook/stages/captcha/__init__.py
similarity index 100%
rename from passbook/factors/otp/__init__.py
rename to passbook/stages/captcha/__init__.py
diff --git a/passbook/stages/captcha/api.py b/passbook/stages/captcha/api.py
new file mode 100644
index 000000000..6357fc96a
--- /dev/null
+++ b/passbook/stages/captcha/api.py
@@ -0,0 +1,21 @@
+"""CaptchaStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.captcha.models import CaptchaStage
+
+
+class CaptchaStageSerializer(ModelSerializer):
+ """CaptchaStage Serializer"""
+
+ class Meta:
+
+ model = CaptchaStage
+ fields = ["pk", "name", "public_key", "private_key"]
+
+
+class CaptchaStageViewSet(ModelViewSet):
+ """CaptchaStage Viewset"""
+
+ queryset = CaptchaStage.objects.all()
+ serializer_class = CaptchaStageSerializer
diff --git a/passbook/stages/captcha/apps.py b/passbook/stages/captcha/apps.py
new file mode 100644
index 000000000..ba5c4ba7d
--- /dev/null
+++ b/passbook/stages/captcha/apps.py
@@ -0,0 +1,10 @@
+"""passbook captcha app"""
+from django.apps import AppConfig
+
+
+class PassbookStageCaptchaConfig(AppConfig):
+ """passbook captcha app"""
+
+ name = "passbook.stages.captcha"
+ label = "passbook_stages_captcha"
+ verbose_name = "passbook Stages.Captcha"
diff --git a/passbook/stages/captcha/forms.py b/passbook/stages/captcha/forms.py
new file mode 100644
index 000000000..892942a4a
--- /dev/null
+++ b/passbook/stages/captcha/forms.py
@@ -0,0 +1,25 @@
+"""passbook captcha stage forms"""
+from captcha.fields import ReCaptchaField
+from django import forms
+
+from passbook.stages.captcha.models import CaptchaStage
+
+
+class CaptchaForm(forms.Form):
+ """passbook captcha stage form"""
+
+ captcha = ReCaptchaField()
+
+
+class CaptchaStageForm(forms.ModelForm):
+ """Form to edit CaptchaStage Instance"""
+
+ class Meta:
+
+ model = CaptchaStage
+ fields = ["name", "public_key", "private_key"]
+ widgets = {
+ "name": forms.TextInput(),
+ "public_key": forms.TextInput(),
+ "private_key": forms.TextInput(),
+ }
diff --git a/passbook/stages/captcha/migrations/0001_initial.py b/passbook/stages/captcha/migrations/0001_initial.py
new file mode 100644
index 000000000..7a3fbb2ba
--- /dev/null
+++ b/passbook/stages/captcha/migrations/0001_initial.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.0.3 on 2020-05-08 17:58
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="CaptchaStage",
+ 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",
+ ),
+ ),
+ (
+ "public_key",
+ models.TextField(
+ help_text="Public key, acquired from https://www.google.com/recaptcha/intro/v3.html"
+ ),
+ ),
+ (
+ "private_key",
+ models.TextField(
+ help_text="Private key, acquired from https://www.google.com/recaptcha/intro/v3.html"
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Captcha Stage",
+ "verbose_name_plural": "Captcha Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ ]
diff --git a/passbook/factors/otp/migrations/__init__.py b/passbook/stages/captcha/migrations/__init__.py
similarity index 100%
rename from passbook/factors/otp/migrations/__init__.py
rename to passbook/stages/captcha/migrations/__init__.py
diff --git a/passbook/factors/captcha/models.py b/passbook/stages/captcha/models.py
similarity index 53%
rename from passbook/factors/captcha/models.py
rename to passbook/stages/captcha/models.py
index 3619bb6e5..492ac2f35 100644
--- a/passbook/factors/captcha/models.py
+++ b/passbook/stages/captcha/models.py
@@ -1,12 +1,12 @@
-"""passbook captcha factor"""
+"""passbook captcha stage"""
from django.db import models
from django.utils.translation import gettext_lazy as _
-from passbook.core.models import Factor
+from passbook.flows.models import Stage
-class CaptchaFactor(Factor):
- """Captcha Factor instance"""
+class CaptchaStage(Stage):
+ """Captcha Stage instance"""
public_key = models.TextField(
help_text=_(
@@ -19,13 +19,13 @@ class CaptchaFactor(Factor):
)
)
- type = "passbook.factors.captcha.factor.CaptchaFactor"
- form = "passbook.factors.captcha.forms.CaptchaFactorForm"
+ type = "passbook.stages.captcha.stage.CaptchaStage"
+ form = "passbook.stages.captcha.forms.CaptchaStageForm"
def __str__(self):
- return f"Captcha Factor {self.slug}"
+ return f"Captcha Stage {self.name}"
class Meta:
- verbose_name = _("Captcha Factor")
- verbose_name_plural = _("Captcha Factors")
+ verbose_name = _("Captcha Stage")
+ verbose_name_plural = _("Captcha Stages")
diff --git a/passbook/factors/captcha/settings.py b/passbook/stages/captcha/settings.py
similarity index 90%
rename from passbook/factors/captcha/settings.py
rename to passbook/stages/captcha/settings.py
index 7466ad4f8..17b1b3829 100644
--- a/passbook/factors/captcha/settings.py
+++ b/passbook/stages/captcha/settings.py
@@ -1,4 +1,4 @@
-"""passbook captcha_factor settings"""
+"""passbook captcha stage settings"""
# https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do
RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"
diff --git a/passbook/stages/captcha/stage.py b/passbook/stages/captcha/stage.py
new file mode 100644
index 000000000..2d95b8232
--- /dev/null
+++ b/passbook/stages/captcha/stage.py
@@ -0,0 +1,24 @@
+"""passbook captcha stage"""
+
+from django.views.generic import FormView
+
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.captcha.forms import CaptchaForm
+
+
+class CaptchaStage(FormView, AuthenticationStage):
+ """Simple captcha checker, logic is handeled in django-captcha module"""
+
+ form_class = CaptchaForm
+
+ def form_valid(self, form):
+ return self.executor.stage_ok()
+
+ def get_form(self, form_class=None):
+ form = CaptchaForm(**self.get_form_kwargs())
+ form.fields["captcha"].public_key = self.executor.current_stage.public_key
+ form.fields["captcha"].private_key = self.executor.current_stage.private_key
+ form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[
+ "captcha"
+ ].public_key
+ return form
diff --git a/passbook/stages/captcha/tests.py b/passbook/stages/captcha/tests.py
new file mode 100644
index 000000000..a97b57eb0
--- /dev/null
+++ b/passbook/stages/captcha/tests.py
@@ -0,0 +1,48 @@
+"""captcha tests"""
+from django.conf import settings
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.captcha.models import CaptchaStage
+
+
+class TestCaptchaStage(TestCase):
+ """Captcha tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create_user(
+ username="unittest", email="test@beryju.org"
+ )
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-captcha",
+ slug="test-captcha",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = CaptchaStage.objects.create(
+ name="captcha",
+ public_key=settings.RECAPTCHA_PUBLIC_KEY,
+ private_key=settings.RECAPTCHA_PRIVATE_KEY,
+ )
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_valid(self):
+ """Test valid captcha"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ {"g-recaptcha-response": "PASSED"},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
diff --git a/passbook/factors/password/__init__.py b/passbook/stages/dummy/__init__.py
similarity index 100%
rename from passbook/factors/password/__init__.py
rename to passbook/stages/dummy/__init__.py
diff --git a/passbook/stages/dummy/api.py b/passbook/stages/dummy/api.py
new file mode 100644
index 000000000..53235a3b9
--- /dev/null
+++ b/passbook/stages/dummy/api.py
@@ -0,0 +1,21 @@
+"""DummyStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.dummy.models import DummyStage
+
+
+class DummyStageSerializer(ModelSerializer):
+ """DummyStage Serializer"""
+
+ class Meta:
+
+ model = DummyStage
+ fields = ["pk", "name"]
+
+
+class DummyStageViewSet(ModelViewSet):
+ """DummyStage Viewset"""
+
+ queryset = DummyStage.objects.all()
+ serializer_class = DummyStageSerializer
diff --git a/passbook/stages/dummy/apps.py b/passbook/stages/dummy/apps.py
new file mode 100644
index 000000000..5b68b9459
--- /dev/null
+++ b/passbook/stages/dummy/apps.py
@@ -0,0 +1,11 @@
+"""passbook dummy stage config"""
+
+from django.apps import AppConfig
+
+
+class PassbookStageDummyConfig(AppConfig):
+ """passbook dummy stage config"""
+
+ name = "passbook.stages.dummy"
+ label = "passbook_stages_dummy"
+ verbose_name = "passbook Stages.Dummy"
diff --git a/passbook/stages/dummy/forms.py b/passbook/stages/dummy/forms.py
new file mode 100644
index 000000000..f611611f5
--- /dev/null
+++ b/passbook/stages/dummy/forms.py
@@ -0,0 +1,16 @@
+"""passbook administration forms"""
+from django import forms
+
+from passbook.stages.dummy.models import DummyStage
+
+
+class DummyStageForm(forms.ModelForm):
+ """Form to create/edit Dummy Stage"""
+
+ class Meta:
+
+ model = DummyStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/passbook/factors/dummy/migrations/0001_initial.py b/passbook/stages/dummy/migrations/0001_initial.py
similarity index 64%
rename from passbook/factors/dummy/migrations/0001_initial.py
rename to passbook/stages/dummy/migrations/0001_initial.py
index d0a905a87..83b2eea91 100644
--- a/passbook/factors/dummy/migrations/0001_initial.py
+++ b/passbook/stages/dummy/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 2.2.6 on 2019-10-07 14:07
+# Generated by Django 3.0.3 on 2020-05-08 17:58
import django.db.models.deletion
from django.db import migrations, models
@@ -9,29 +9,29 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ("passbook_core", "0001_initial"),
+ ("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
- name="DummyFactor",
+ name="DummyStage",
fields=[
(
- "factor_ptr",
+ "stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
- to="passbook_core.Factor",
+ to="passbook_flows.Stage",
),
),
],
options={
- "verbose_name": "Dummy Factor",
- "verbose_name_plural": "Dummy Factors",
+ "verbose_name": "Dummy Stage",
+ "verbose_name_plural": "Dummy Stages",
},
- bases=("passbook_core.factor",),
+ bases=("passbook_flows.stage",),
),
]
diff --git a/passbook/factors/password/migrations/__init__.py b/passbook/stages/dummy/migrations/__init__.py
similarity index 100%
rename from passbook/factors/password/migrations/__init__.py
rename to passbook/stages/dummy/migrations/__init__.py
diff --git a/passbook/stages/dummy/models.py b/passbook/stages/dummy/models.py
new file mode 100644
index 000000000..70457e217
--- /dev/null
+++ b/passbook/stages/dummy/models.py
@@ -0,0 +1,19 @@
+"""dummy stage models"""
+from django.utils.translation import gettext as _
+
+from passbook.flows.models import Stage
+
+
+class DummyStage(Stage):
+ """Dummy stage, mostly used to debug"""
+
+ type = "passbook.stages.dummy.stage.DummyStage"
+ form = "passbook.stages.dummy.forms.DummyStageForm"
+
+ def __str__(self):
+ return f"Dummy Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Dummy Stage")
+ verbose_name_plural = _("Dummy Stages")
diff --git a/passbook/stages/dummy/stage.py b/passbook/stages/dummy/stage.py
new file mode 100644
index 000000000..94a2a4cb6
--- /dev/null
+++ b/passbook/stages/dummy/stage.py
@@ -0,0 +1,12 @@
+"""passbook multi-stage authentication engine"""
+from django.http import HttpRequest
+
+from passbook.flows.stage import AuthenticationStage
+
+
+class DummyStage(AuthenticationStage):
+ """Dummy stage for testing with multiple stages"""
+
+ def post(self, request: HttpRequest):
+ """Just redirect to next stage"""
+ return self.executor.stage_ok()
diff --git a/passbook/stages/dummy/tests.py b/passbook/stages/dummy/tests.py
new file mode 100644
index 000000000..390f47aa8
--- /dev/null
+++ b/passbook/stages/dummy/tests.py
@@ -0,0 +1,50 @@
+"""dummy tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.stages.dummy.forms import DummyStageForm
+from passbook.stages.dummy.models import DummyStage
+
+
+class TestDummyStage(TestCase):
+ """Dummy tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-dummy",
+ slug="test-dummy",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = DummyStage.objects.create(name="dummy",)
+ FlowStageBinding.objects.create(
+ flow=self.flow, stage=self.stage, order=0,
+ )
+
+ def test_valid_render(self):
+ """Test that View renders correctly"""
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_post(self):
+ """Test with valid email, check that URL redirects back to itself"""
+ url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.post(url, {})
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(DummyStageForm(data).is_valid(), True)
diff --git a/passbook/stages/email/__init__.py b/passbook/stages/email/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/email/api.py b/passbook/stages/email/api.py
new file mode 100644
index 000000000..063a1020e
--- /dev/null
+++ b/passbook/stages/email/api.py
@@ -0,0 +1,33 @@
+"""EmailStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.email.models import EmailStage
+
+
+class EmailStageSerializer(ModelSerializer):
+ """EmailStage Serializer"""
+
+ class Meta:
+
+ model = EmailStage
+ fields = [
+ "pk",
+ "name",
+ "host",
+ "port",
+ "username",
+ "password",
+ "use_tls",
+ "use_ssl",
+ "timeout",
+ "from_address",
+ ]
+ extra_kwargs = {"password": {"write_only": True}}
+
+
+class EmailStageViewSet(ModelViewSet):
+ """EmailStage Viewset"""
+
+ queryset = EmailStage.objects.all()
+ serializer_class = EmailStageSerializer
diff --git a/passbook/stages/email/apps.py b/passbook/stages/email/apps.py
new file mode 100644
index 000000000..241904ee5
--- /dev/null
+++ b/passbook/stages/email/apps.py
@@ -0,0 +1,15 @@
+"""passbook email stage config"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class PassbookStageEmailConfig(AppConfig):
+ """passbook email stage config"""
+
+ name = "passbook.stages.email"
+ label = "passbook_stages_email"
+ verbose_name = "passbook Stages.Email"
+
+ def ready(self):
+ import_module("passbook.stages.email.tasks")
diff --git a/passbook/stages/email/forms.py b/passbook/stages/email/forms.py
new file mode 100644
index 000000000..bdd865fb1
--- /dev/null
+++ b/passbook/stages/email/forms.py
@@ -0,0 +1,40 @@
+"""passbook administration forms"""
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from passbook.stages.email.models import EmailStage
+
+
+class EmailStageSendForm(forms.Form):
+ """Form used when sending the e-mail to prevent multiple emails being sent"""
+
+ invalid = forms.CharField(widget=forms.HiddenInput, required=True)
+
+
+class EmailStageForm(forms.ModelForm):
+ """Form to create/edit E-Mail Stage"""
+
+ class Meta:
+
+ model = EmailStage
+ fields = [
+ "name",
+ "host",
+ "port",
+ "username",
+ "password",
+ "use_tls",
+ "use_ssl",
+ "timeout",
+ "from_address",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "host": forms.TextInput(),
+ "username": forms.TextInput(),
+ "password": forms.TextInput(),
+ }
+ labels = {
+ "use_tls": _("Use TLS"),
+ "use_ssl": _("Use SSL"),
+ }
diff --git a/passbook/factors/email/migrations/0001_initial.py b/passbook/stages/email/migrations/0001_initial.py
similarity index 76%
rename from passbook/factors/email/migrations/0001_initial.py
rename to passbook/stages/email/migrations/0001_initial.py
index e9b8519b2..940c614af 100644
--- a/passbook/factors/email/migrations/0001_initial.py
+++ b/passbook/stages/email/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 2.2.6 on 2019-10-08 12:23
+# Generated by Django 3.0.3 on 2020-05-08 17:59
import django.db.models.deletion
from django.db import migrations, models
@@ -9,22 +9,22 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ("passbook_core", "0001_initial"),
+ ("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
- name="EmailFactor",
+ name="EmailStage",
fields=[
(
- "factor_ptr",
+ "stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
- to="passbook_core.Factor",
+ to="passbook_flows.Stage",
),
),
("host", models.TextField(default="localhost")),
@@ -33,7 +33,7 @@ class Migration(migrations.Migration):
("password", models.TextField(blank=True, default="")),
("use_tls", models.BooleanField(default=False)),
("use_ssl", models.BooleanField(default=False)),
- ("timeout", models.IntegerField(default=0)),
+ ("timeout", models.IntegerField(default=10)),
("ssl_keyfile", models.TextField(blank=True, default=None, null=True)),
("ssl_certfile", models.TextField(blank=True, default=None, null=True)),
(
@@ -42,9 +42,9 @@ class Migration(migrations.Migration):
),
],
options={
- "verbose_name": "Email Factor",
- "verbose_name_plural": "Email Factors",
+ "verbose_name": "Email Stage",
+ "verbose_name_plural": "Email Stages",
},
- bases=("passbook_core.factor",),
+ bases=("passbook_flows.stage",),
),
]
diff --git a/passbook/stages/email/migrations/0002_auto_20200510_1844.py b/passbook/stages/email/migrations/0002_auto_20200510_1844.py
new file mode 100644
index 000000000..c7126df7d
--- /dev/null
+++ b/passbook/stages/email/migrations/0002_auto_20200510_1844.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.0.5 on 2020-05-10 18:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_email", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RemoveField(model_name="emailstage", name="ssl_certfile",),
+ migrations.RemoveField(model_name="emailstage", name="ssl_keyfile",),
+ migrations.AddField(
+ model_name="emailstage",
+ name="token_expiry",
+ field=models.IntegerField(
+ default=30, help_text="Time in minutes the token sent is valid."
+ ),
+ ),
+ ]
diff --git a/passbook/stages/email/migrations/__init__.py b/passbook/stages/email/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/factors/email/models.py b/passbook/stages/email/models.py
similarity index 54%
rename from passbook/factors/email/models.py
rename to passbook/stages/email/models.py
index f2027c1d9..c5ec2e9e5 100644
--- a/passbook/factors/email/models.py
+++ b/passbook/stages/email/models.py
@@ -1,13 +1,14 @@
-"""email factor models"""
-from django.core.mail.backends.smtp import EmailBackend
+"""email stage models"""
+from django.core.mail import get_connection
+from django.core.mail.backends.base import BaseEmailBackend
from django.db import models
from django.utils.translation import gettext as _
-from passbook.core.models import Factor
+from passbook.flows.models import Stage
-class EmailFactor(Factor):
- """email factor"""
+class EmailStage(Stage):
+ """email stage"""
host = models.TextField(default="localhost")
port = models.IntegerField(default=25)
@@ -17,18 +18,19 @@ class EmailFactor(Factor):
use_ssl = models.BooleanField(default=False)
timeout = models.IntegerField(default=10)
- ssl_keyfile = models.TextField(default=None, blank=True, null=True)
- ssl_certfile = models.TextField(default=None, blank=True, null=True)
+ token_expiry = models.IntegerField(
+ default=30, help_text=_("Time in minutes the token sent is valid.")
+ )
from_address = models.EmailField(default="system@passbook.local")
- type = "passbook.factors.email.factor.EmailFactorView"
- form = "passbook.factors.email.forms.EmailFactorForm"
+ type = "passbook.stages.email.stage.EmailStageView"
+ form = "passbook.stages.email.forms.EmailStageForm"
@property
- def backend(self) -> EmailBackend:
+ def backend(self) -> BaseEmailBackend:
"""Get fully configured EMail Backend instance"""
- return EmailBackend(
+ return get_connection(
host=self.host,
port=self.port,
username=self.username,
@@ -36,14 +38,12 @@ class EmailFactor(Factor):
use_tls=self.use_tls,
use_ssl=self.use_ssl,
timeout=self.timeout,
- ssl_certfile=self.ssl_certfile,
- ssl_keyfile=self.ssl_keyfile,
)
def __str__(self):
- return f"Email Factor {self.slug}"
+ return f"Email Stage {self.name}"
class Meta:
- verbose_name = _("Email Factor")
- verbose_name_plural = _("Email Factors")
+ verbose_name = _("Email Stage")
+ verbose_name_plural = _("Email Stages")
diff --git a/passbook/stages/email/stage.py b/passbook/stages/email/stage.py
new file mode 100644
index 000000000..0bb49e5b3
--- /dev/null
+++ b/passbook/stages/email/stage.py
@@ -0,0 +1,68 @@
+"""passbook multi-stage authentication engine"""
+from datetime import timedelta
+
+from django.contrib import messages
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, reverse
+from django.utils.http import urlencode
+from django.utils.timezone import now
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from passbook.core.models import Nonce
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.email.forms import EmailStageSendForm
+from passbook.stages.email.tasks import send_mails
+from passbook.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+QS_KEY_TOKEN = "token"
+
+
+class EmailStageView(FormView, AuthenticationStage):
+ """E-Mail stage which sends E-Mail for verification"""
+
+ form_class = EmailStageSendForm
+ template_name = "stages/email/waiting_message.html"
+
+ def get_full_url(self, **kwargs) -> str:
+ """Get full URL to be used in template"""
+ base_url = reverse(
+ "passbook_flows:flow-executor",
+ kwargs={"flow_slug": self.executor.flow.slug},
+ )
+ relative_url = f"{base_url}?{urlencode(kwargs)}"
+ return self.request.build_absolute_uri(relative_url)
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ if QS_KEY_TOKEN in request.GET:
+ nonce = get_object_or_404(Nonce, pk=request.GET[QS_KEY_TOKEN])
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = nonce.user
+ nonce.delete()
+ messages.success(request, _("Successfully verified E-Mail."))
+ return self.executor.stage_ok()
+ return super().get(request, *args, **kwargs)
+
+ def form_invalid(self, form: EmailStageSendForm) -> HttpResponse:
+ pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ valid_delta = timedelta(
+ minutes=self.executor.current_stage.token_expiry + 1
+ ) # + 1 because django timesince always rounds down
+ nonce = Nonce.objects.create(user=pending_user, expires=now() + valid_delta)
+ # Send mail to user
+ message = TemplateEmailMessage(
+ subject=_("passbook - Password Recovery"),
+ template_name="stages/email/for_email/password_reset.html",
+ to=[pending_user.email],
+ template_context={
+ "url": self.get_full_url(**{QS_KEY_TOKEN: nonce.pk.hex}),
+ "user": pending_user,
+ "expires": nonce.expires,
+ },
+ )
+ send_mails(self.executor.current_stage, message)
+ # We can't call stage_ok yet, as we're still waiting
+ # for the user to click the link in the email
+ return super().form_invalid(form)
diff --git a/passbook/stages/email/static/stages/email/css/base.css b/passbook/stages/email/static/stages/email/css/base.css
new file mode 100644
index 000000000..6f624e138
--- /dev/null
+++ b/passbook/stages/email/static/stages/email/css/base.css
@@ -0,0 +1,325 @@
+/* -------------------------------------
+GLOBAL RESETS
+------------------------------------- */
+
+/*All the styling goes here*/
+
+img {
+ border: none;
+ -ms-interpolation-mode: bicubic;
+ max-width: 100%;
+}
+
+body {
+ background-color: #fafafa;
+ font-family: sans-serif;
+ -webkit-font-smoothing: antialiased;
+ font-size: 14px;
+ line-height: 1.4;
+ margin: 0;
+ padding: 0;
+ -ms-text-size-adjust: 100%;
+ -webkit-text-size-adjust: 100%;
+}
+
+table {
+ border-collapse: separate;
+ mso-table-lspace: 0pt;
+ mso-table-rspace: 0pt;
+ width: 100%; }
+ table td {
+ font-family: sans-serif;
+ font-size: 14px;
+ vertical-align: top;
+ }
+
+ /* -------------------------------------
+ BODY & CONTAINER
+ ------------------------------------- */
+
+ .body {
+ background-color: #fafafa;
+ width: 100%;
+ }
+
+ /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
+ .container {
+ display: block;
+ margin: 0 auto !important;
+ /* makes it centered */
+ max-width: 580px;
+ padding: 10px;
+ width: 580px;
+ }
+
+ /* This should also be a block element, so that it will fill 100% of the .container */
+ .content {
+ box-sizing: border-box;
+ display: block;
+ margin: 0 auto;
+ max-width: 580px;
+ padding: 10px;
+ }
+
+ /* -------------------------------------
+ HEADER, FOOTER, MAIN
+ ------------------------------------- */
+ .main {
+ background: #ffffff;
+ border-radius: 3px;
+ width: 100%;
+ }
+
+ .wrapper {
+ box-sizing: border-box;
+ padding: 20px;
+ }
+
+ .content-block {
+ padding-bottom: 10px;
+ padding-top: 10px;
+ }
+
+ .footer {
+ clear: both;
+ margin-top: 10px;
+ text-align: center;
+ width: 100%;
+ }
+ .footer td,
+ .footer p,
+ .footer span,
+ .footer a {
+ color: #999999;
+ font-size: 12px;
+ text-align: center;
+ }
+
+ /* -------------------------------------
+ TYPOGRAPHY
+ ------------------------------------- */
+ h1,
+ h2,
+ h3,
+ h4 {
+ color: #000000;
+ font-family: sans-serif;
+ font-weight: 400;
+ line-height: 1.4;
+ margin: 0;
+ margin-bottom: 30px;
+ }
+
+ h1 {
+ font-size: 35px;
+ font-weight: 300;
+ text-align: center;
+ text-transform: capitalize;
+ }
+
+ p,
+ ul,
+ ol {
+ font-family: sans-serif;
+ font-size: 14px;
+ font-weight: normal;
+ margin: 0;
+ margin-bottom: 15px;
+ }
+ p li,
+ ul li,
+ ol li {
+ list-style-position: inside;
+ margin-left: 5px;
+ }
+
+ a {
+ color: #06c;
+ border-radius: 3px;
+ text-decoration: underline;
+ }
+
+ /* -------------------------------------
+ BUTTONS
+ ------------------------------------- */
+ .btn {
+ box-sizing: border-box;
+ width: 100%; }
+ .btn > tbody > tr > td {
+ padding-bottom: 15px; }
+ .btn table {
+ width: auto;
+ }
+ .btn table td {
+ background-color: #ffffff;
+ border-radius: 5px;
+ text-align: center;
+ }
+ .btn a {
+ background-color: #ffffff;
+ border: solid 1px #06c;
+ border-radius: 5px;
+ box-sizing: border-box;
+ color: #06c;
+ cursor: pointer;
+ display: inline-block;
+ font-size: 14px;
+ font-weight: bold;
+ margin: 0;
+ padding: 12px 25px;
+ text-decoration: none;
+ text-transform: capitalize;
+ }
+
+ .btn-primary table td {
+ background-color: #06c;
+ }
+
+ .btn-primary a {
+ background-color: #06c;
+ border-color: #06c;
+ color: #ffffff;
+ }
+
+ /* -------------------------------------
+ OTHER STYLES THAT MIGHT BE USEFUL
+ ------------------------------------- */
+ .last {
+ margin-bottom: 0;
+ }
+
+ .first {
+ margin-top: 0;
+ }
+
+ .align-center {
+ text-align: center;
+ }
+
+ .align-right {
+ text-align: right;
+ }
+
+ .align-left {
+ text-align: left;
+ }
+
+ .clear {
+ clear: both;
+ }
+
+ .mt0 {
+ margin-top: 0;
+ }
+
+ .mb0 {
+ margin-bottom: 0;
+ }
+
+ .preheader {
+ color: transparent;
+ display: none;
+ height: 0;
+ max-height: 0;
+ max-width: 0;
+ opacity: 0;
+ overflow: hidden;
+ mso-hide: all;
+ visibility: hidden;
+ width: 0;
+ }
+
+ .powered-by a {
+ text-decoration: none;
+ }
+
+ hr {
+ border: 0;
+ border-bottom: 1px solid #fafafa;
+ margin: 20px 0;
+ }
+
+ /* -------------------------------------
+ RESPONSIVE AND MOBILE FRIENDLY STYLES
+ ------------------------------------- */
+ @media only screen and (max-width: 620px) {
+ table[class=body] h1 {
+ font-size: 28px !important;
+ margin-bottom: 10px !important;
+ }
+ table[class=body] p,
+ table[class=body] ul,
+ table[class=body] ol,
+ table[class=body] td,
+ table[class=body] span,
+ table[class=body] a {
+ font-size: 16px !important;
+ }
+ table[class=body] .wrapper,
+ table[class=body] .article {
+ padding: 10px !important;
+ }
+ table[class=body] .content {
+ padding: 0 !important;
+ }
+ table[class=body] .container {
+ padding: 0 !important;
+ width: 100% !important;
+ }
+ table[class=body] .main {
+ border-left-width: 0 !important;
+ border-radius: 0 !important;
+ border-right-width: 0 !important;
+ }
+ table[class=body] .btn table {
+ width: 100% !important;
+ }
+ table[class=body] .btn a {
+ width: 100% !important;
+ }
+ table[class=body] .img-responsive {
+ height: auto !important;
+ max-width: 100% !important;
+ width: auto !important;
+ }
+ }
+
+ /* -------------------------------------
+ PRESERVE THESE STYLES IN THE HEAD
+ ------------------------------------- */
+ @media all {
+ .ExternalClass {
+ width: 100%;
+ }
+ .ExternalClass,
+ .ExternalClass p,
+ .ExternalClass span,
+ .ExternalClass font,
+ .ExternalClass td,
+ .ExternalClass div {
+ line-height: 100%;
+ }
+ .apple-link a {
+ color: inherit !important;
+ font-family: inherit !important;
+ font-size: inherit !important;
+ font-weight: inherit !important;
+ line-height: inherit !important;
+ text-decoration: none !important;
+ }
+ #MessageViewBody a {
+ color: inherit;
+ text-decoration: none;
+ font-size: inherit;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: inherit;
+ }
+ .btn-primary table td:hover {
+ background-color: #34495e !important;
+ }
+ .btn-primary a:hover {
+ background-color: #34495e !important;
+ border-color: #34495e !important;
+ }
+ }
diff --git a/passbook/stages/email/tasks.py b/passbook/stages/email/tasks.py
new file mode 100644
index 000000000..750a5326e
--- /dev/null
+++ b/passbook/stages/email/tasks.py
@@ -0,0 +1,42 @@
+"""email stage tasks"""
+from smtplib import SMTPException
+from typing import Any, Dict, List
+
+from celery import group
+from django.core.mail import EmailMultiAlternatives
+from structlog import get_logger
+
+from passbook.root.celery import CELERY_APP
+from passbook.stages.email.models import EmailStage
+
+LOGGER = get_logger()
+
+
+def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]):
+ """Wrapper to convert EmailMessage to dict and send it from worker"""
+ tasks = []
+ for message in messages:
+ tasks.append(_send_mail_task.s(stage.pk, message.__dict__))
+ lazy_group = group(*tasks)
+ promise = lazy_group()
+ return promise
+
+
+@CELERY_APP.task(
+ bind=True, autoretry_for=(SMTPException, ConnectionError,), retry_backoff=True
+)
+# pylint: disable=unused-argument
+def _send_mail_task(self, email_stage_pk: int, message: Dict[Any, Any]):
+ """Send E-Mail according to EmailStage parameters from background worker.
+ Automatically retries if message couldn't be sent."""
+ stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
+ backend = stage.backend
+ backend.open()
+ # Since django's EmailMessage objects are not JSON serialisable,
+ # we need to rebuild them from a dict
+ message_object = EmailMultiAlternatives()
+ for key, value in message.items():
+ setattr(message_object, key, value)
+ message_object.from_email = stage.from_address
+ LOGGER.debug("Sending mail", to=message_object.to)
+ stage.backend.send_messages([message_object])
diff --git a/passbook/core/templates/email/account_confirm.html b/passbook/stages/email/templates/stages/email/for_email/account_confirm.html
similarity index 100%
rename from passbook/core/templates/email/account_confirm.html
rename to passbook/stages/email/templates/stages/email/for_email/account_confirm.html
diff --git a/passbook/stages/email/templates/stages/email/for_email/base.html b/passbook/stages/email/templates/stages/email/for_email/base.html
new file mode 100644
index 000000000..21f4f9db1
--- /dev/null
+++ b/passbook/stages/email/templates/stages/email/for_email/base.html
@@ -0,0 +1,65 @@
+{% load passbook_stages_email %}
+{% load utils %}
+{% load static %}
+{% load i18n %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block content %}
+ {% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/passbook/core/templates/email/generic_email.html b/passbook/stages/email/templates/stages/email/for_email/generic_email.html
similarity index 100%
rename from passbook/core/templates/email/generic_email.html
rename to passbook/stages/email/templates/stages/email/for_email/generic_email.html
diff --git a/passbook/stages/email/templates/stages/email/for_email/password_reset.html b/passbook/stages/email/templates/stages/email/for_email/password_reset.html
new file mode 100644
index 000000000..deb24fb61
--- /dev/null
+++ b/passbook/stages/email/templates/stages/email/for_email/password_reset.html
@@ -0,0 +1,41 @@
+{% extends "stages/email/for_email/base.html" %}
+
+{% load utils %}
+{% load i18n %}
+{% load humanize %}
+
+{% block content %}
+
+
+ {% blocktrans with username=user.username %}
+ Hi {{ username }},
+ {% endblocktrans %}
+
+
+ {% blocktrans %}
+ You recently requested to change your password for you passbook account. Use the button below to set a new password.
+ {% endblocktrans %}
+
+
+
+ {% blocktrans with expires=expires|naturaltime %}
+ If you did not request a password change, please ignore this E-Mail. The link above is valid for {{ expires }}.
+ {% endblocktrans %}
+
+
+
+{% endblock %}
diff --git a/passbook/stages/email/templates/stages/email/waiting_message.html b/passbook/stages/email/templates/stages/email/waiting_message.html
new file mode 100644
index 000000000..bebfb7317
--- /dev/null
+++ b/passbook/stages/email/templates/stages/email/waiting_message.html
@@ -0,0 +1,21 @@
+{% extends 'login/base.html' %}
+
+{% load static %}
+{% load i18n %}
+
+{% block card %}
+
+
+ {% blocktrans %}
+ Check your E-Mails for a password reset link.
+ {% endblocktrans %}
+
+ {% csrf_token %}
+
+ {% block beneath_form %}
+ {% endblock %}
+
+ {% trans "Send Recovery E-Mail." %}
+
+
+{% endblock %}
diff --git a/passbook/stages/email/templatetags/__init__.py b/passbook/stages/email/templatetags/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/email/templatetags/passbook_stages_email.py b/passbook/stages/email/templatetags/passbook_stages_email.py
new file mode 100644
index 000000000..48cd26949
--- /dev/null
+++ b/passbook/stages/email/templatetags/passbook_stages_email.py
@@ -0,0 +1,24 @@
+"""passbook core inlining template tags"""
+from pathlib import Path
+
+from django import template
+from django.contrib.staticfiles import finders
+
+register = template.Library()
+
+
+@register.simple_tag()
+def inline_static_ascii(path: str) -> str:
+ """Inline static asset. Doesn't check file contents, plain text is assumed"""
+ result = finders.find(path)
+ with open(result) as _file:
+ return _file.read()
+
+
+@register.simple_tag()
+def inline_static_binary(path: str) -> str:
+ """Inline static asset. Uses file extension for base64 block"""
+ result = finders.find(path)
+ suffix = Path(path).suffix
+ with open(result) as _file:
+ return f"data:image/{suffix};base64," + _file.read()
diff --git a/passbook/stages/email/tests.py b/passbook/stages/email/tests.py
new file mode 100644
index 000000000..345316c18
--- /dev/null
+++ b/passbook/stages/email/tests.py
@@ -0,0 +1,88 @@
+"""email tests"""
+from unittest.mock import MagicMock, patch
+
+from django.core import mail
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import Nonce, User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.email.models import EmailStage
+from passbook.stages.email.stage import QS_KEY_TOKEN
+
+
+class TestEmailStage(TestCase):
+ """Email tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create_user(
+ username="unittest", email="test@beryju.org"
+ )
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-email",
+ slug="test-email",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = EmailStage.objects.create(name="email",)
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_rendering(self):
+ """Test with pending user"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_pending_user(self):
+ """Test with pending user"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ with self.settings(
+ EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"
+ ):
+ response = self.client.post(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(mail.outbox[0].subject, "passbook - Password Recovery")
+
+ def test_token(self):
+ """Test with token"""
+ # Make sure token exists
+ self.test_pending_user()
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ with patch("passbook.flows.views.FlowExecutorView.cancel", MagicMock()):
+ url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ token = Nonce.objects.get(user=self.user)
+ url += f"?{QS_KEY_TOKEN}={token.pk.hex}"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER], self.user)
diff --git a/passbook/stages/email/utils.py b/passbook/stages/email/utils.py
new file mode 100644
index 000000000..139b6af82
--- /dev/null
+++ b/passbook/stages/email/utils.py
@@ -0,0 +1,13 @@
+"""email utils"""
+from django.core.mail import EmailMultiAlternatives
+from django.template.loader import render_to_string
+
+
+class TemplateEmailMessage(EmailMultiAlternatives):
+ """Wrapper around EmailMultiAlternatives with integrated template rendering"""
+
+ def __init__(self, template_name=None, template_context=None, **kwargs):
+ html_content = render_to_string(template_name, template_context)
+ super().__init__(**kwargs)
+ self.content_subtype = "html"
+ self.attach_alternative(html_content, "text/html")
diff --git a/passbook/stages/identification/__init__.py b/passbook/stages/identification/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/identification/api.py b/passbook/stages/identification/api.py
new file mode 100644
index 000000000..bd56a0a61
--- /dev/null
+++ b/passbook/stages/identification/api.py
@@ -0,0 +1,26 @@
+"""Identification Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.identification.models import IdentificationStage
+
+
+class IdentificationStageSerializer(ModelSerializer):
+ """IdentificationStage Serializer"""
+
+ class Meta:
+
+ model = IdentificationStage
+ fields = [
+ "pk",
+ "name",
+ "user_fields",
+ "template",
+ ]
+
+
+class IdentificationStageViewSet(ModelViewSet):
+ """IdentificationStage Viewset"""
+
+ queryset = IdentificationStage.objects.all()
+ serializer_class = IdentificationStageSerializer
diff --git a/passbook/stages/identification/apps.py b/passbook/stages/identification/apps.py
new file mode 100644
index 000000000..714721728
--- /dev/null
+++ b/passbook/stages/identification/apps.py
@@ -0,0 +1,10 @@
+"""passbook identification stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStageIdentificationConfig(AppConfig):
+ """passbook identification stage config"""
+
+ name = "passbook.stages.identification"
+ label = "passbook_stages_identification"
+ verbose_name = "passbook Stages.Identification"
diff --git a/passbook/stages/identification/forms.py b/passbook/stages/identification/forms.py
new file mode 100644
index 000000000..04217f7f8
--- /dev/null
+++ b/passbook/stages/identification/forms.py
@@ -0,0 +1,46 @@
+"""passbook flows identification forms"""
+from django import forms
+from django.core.validators import validate_email
+from django.utils.translation import gettext_lazy as _
+from structlog import get_logger
+
+from passbook.lib.utils.ui import human_list
+from passbook.stages.identification.models import IdentificationStage, UserFields
+
+LOGGER = get_logger()
+
+
+class IdentificationStageForm(forms.ModelForm):
+ """Form to create/edit IdentificationStage instances"""
+
+ class Meta:
+
+ model = IdentificationStage
+ fields = ["name", "user_fields", "template"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class IdentificationForm(forms.Form):
+ """Allow users to login"""
+
+ stage: IdentificationStage
+
+ title = _("Log in to your account")
+ uid_field = forms.CharField(label=_(""))
+
+ def __init__(self, *args, **kwargs):
+ self.stage = kwargs.pop("stage")
+ super().__init__(*args, **kwargs)
+ if self.stage.user_fields == [UserFields.E_MAIL]:
+ self.fields["uid_field"] = forms.EmailField()
+ self.fields["uid_field"].label = human_list(
+ [x.title() for x in self.stage.user_fields]
+ )
+
+ def clean_uid_field(self):
+ """Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
+ if self.stage.user_fields == [UserFields.E_MAIL]:
+ validate_email(self.cleaned_data.get("uid_field"))
+ return self.cleaned_data.get("uid_field")
diff --git a/passbook/stages/identification/migrations/0001_initial.py b/passbook/stages/identification/migrations/0001_initial.py
new file mode 100644
index 000000000..1158f9598
--- /dev/null
+++ b/passbook/stages/identification/migrations/0001_initial.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.0.3 on 2020-05-09 18:34
+
+import django.contrib.postgres.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="IdentificationStage",
+ 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",
+ ),
+ ),
+ (
+ "user_fields",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("e-mail", "E Mail"), ("username", "Username")],
+ max_length=100,
+ ),
+ help_text="Fields of the user object to match against.",
+ size=None,
+ ),
+ ),
+ ("template", models.TextField()),
+ ],
+ options={
+ "verbose_name": "Identification Stage",
+ "verbose_name_plural": "Identification Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/identification/migrations/0002_auto_20200509_1916.py b/passbook/stages/identification/migrations/0002_auto_20200509_1916.py
new file mode 100644
index 000000000..6b5aeef2e
--- /dev/null
+++ b/passbook/stages/identification/migrations/0002_auto_20200509_1916.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.3 on 2020-05-09 19:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_identification", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="identificationstage",
+ name="template",
+ field=models.TextField(choices=[("login/form.html", "Default Login")]),
+ ),
+ ]
diff --git a/passbook/stages/identification/migrations/0003_auto_20200509_2025.py b/passbook/stages/identification/migrations/0003_auto_20200509_2025.py
new file mode 100644
index 000000000..5f9672350
--- /dev/null
+++ b/passbook/stages/identification/migrations/0003_auto_20200509_2025.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.0.3 on 2020-05-09 20:25
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_identification", "0002_auto_20200509_1916"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="identificationstage",
+ name="user_fields",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("email", "E Mail"), ("username", "Username")],
+ max_length=100,
+ ),
+ help_text="Fields of the user object to match against.",
+ size=None,
+ ),
+ ),
+ ]
diff --git a/passbook/stages/identification/migrations/0004_auto_20200510_1648.py b/passbook/stages/identification/migrations/0004_auto_20200510_1648.py
new file mode 100644
index 000000000..9c081b969
--- /dev/null
+++ b/passbook/stages/identification/migrations/0004_auto_20200510_1648.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.5 on 2020-05-10 16:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_identification", "0003_auto_20200509_2025"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="identificationstage",
+ name="template",
+ field=models.TextField(
+ choices=[
+ ("stages/identification/login.html", "Default Login"),
+ ("stages/identification/recovery.html", "Default Recovery"),
+ ]
+ ),
+ ),
+ ]
diff --git a/passbook/stages/identification/migrations/__init__.py b/passbook/stages/identification/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/identification/models.py b/passbook/stages/identification/models.py
new file mode 100644
index 000000000..d43b6b15c
--- /dev/null
+++ b/passbook/stages/identification/models.py
@@ -0,0 +1,41 @@
+"""identification stage models"""
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from passbook.flows.models import Stage
+
+
+class UserFields(models.TextChoices):
+ """Fields which the user can identify themselves with"""
+
+ E_MAIL = "email"
+ USERNAME = "username"
+
+
+class Templates(models.TextChoices):
+ """Templates to be used for the stage"""
+
+ DEFAULT_LOGIN = "stages/identification/login.html"
+ DEFAULT_RECOVERY = "stages/identification/recovery.html"
+
+
+class IdentificationStage(Stage):
+ """Identification stage, allows a user to identify themselves to authenticate."""
+
+ user_fields = ArrayField(
+ models.CharField(max_length=100, choices=UserFields.choices),
+ help_text=_("Fields of the user object to match against."),
+ )
+ template = models.TextField(choices=Templates.choices)
+
+ type = "passbook.stages.identification.stage.IdentificationStageView"
+ form = "passbook.stages.identification.forms.IdentificationStageForm"
+
+ def __str__(self):
+ return f"Identification Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Identification Stage")
+ verbose_name_plural = _("Identification Stages")
diff --git a/passbook/stages/identification/stage.py b/passbook/stages/identification/stage.py
new file mode 100644
index 000000000..5c1a05e0a
--- /dev/null
+++ b/passbook/stages/identification/stage.py
@@ -0,0 +1,82 @@
+"""Identification stage logic"""
+from typing import List, Optional
+
+from django.contrib import messages
+from django.db.models import Q
+from django.http import HttpResponse
+from django.shortcuts import reverse
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from passbook.core.models import Source, User
+from passbook.flows.models import FlowDesignation
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.identification.forms import IdentificationForm
+from passbook.stages.identification.models import IdentificationStage
+
+LOGGER = get_logger()
+
+
+class IdentificationStageView(FormView, AuthenticationStage):
+ """Form to identify the user"""
+
+ form_class = IdentificationForm
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["stage"] = self.executor.current_stage
+ return kwargs
+
+ def get_template_names(self) -> List[str]:
+ current_stage: IdentificationStage = self.executor.current_stage
+ return [current_stage.template]
+
+ def get_context_data(self, **kwargs):
+ # Check for related enrollment and recovery flow, add URL to view
+ enrollment_flow = self.executor.flow.related_flow(FlowDesignation.ENROLLMENT)
+ if enrollment_flow:
+ kwargs["enroll_url"] = reverse(
+ "passbook_flows:flow-executor",
+ kwargs={"flow_slug": enrollment_flow.slug},
+ )
+ recovery_flow = self.executor.flow.related_flow(FlowDesignation.RECOVERY)
+ if recovery_flow:
+ kwargs["recovery_url"] = reverse(
+ "passbook_flows:flow-executor",
+ kwargs={"flow_slug": recovery_flow.slug},
+ )
+
+ # Check all enabled source, add them if they have a UI Login button.
+ kwargs["sources"] = []
+ sources = (
+ Source.objects.filter(enabled=True).order_by("name").select_subclasses()
+ )
+ for source in sources:
+ ui_login_button = source.ui_login_button
+ if ui_login_button:
+ kwargs["sources"].append(ui_login_button)
+ return super().get_context_data(**kwargs)
+
+ def get_user(self, uid_value: str) -> Optional[User]:
+ """Find user instance. Returns None if no user was found."""
+ current_stage: IdentificationStage = self.executor.current_stage
+ query = Q()
+ for search_field in current_stage.user_fields:
+ query |= Q(**{search_field: uid_value})
+ users = User.objects.filter(query)
+ if users.exists():
+ LOGGER.debug("Found user", user=users.first(), query=query)
+ return users.first()
+ return None
+
+ def form_valid(self, form: IdentificationForm) -> HttpResponse:
+ """Form data is valid"""
+ pre_user = self.get_user(form.cleaned_data.get("uid_field"))
+ if not pre_user:
+ LOGGER.debug("invalid_login")
+ messages.error(self.request, _("Failed to authenticate."))
+ return self.form_invalid(form)
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = pre_user
+ return self.executor.stage_ok()
diff --git a/passbook/stages/identification/templates/stages/identification/login.html b/passbook/stages/identification/templates/stages/identification/login.html
new file mode 100644
index 000000000..640765f1e
--- /dev/null
+++ b/passbook/stages/identification/templates/stages/identification/login.html
@@ -0,0 +1 @@
+{% extends 'login/form.html' %}
diff --git a/passbook/stages/identification/templates/stages/identification/recovery.html b/passbook/stages/identification/templates/stages/identification/recovery.html
new file mode 100644
index 000000000..1cf4c4838
--- /dev/null
+++ b/passbook/stages/identification/templates/stages/identification/recovery.html
@@ -0,0 +1,68 @@
+{% extends 'base/skeleton.html' %}
+
+{% load static %}
+{% load i18n %}
+
+{% block body %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% include 'partials/messages.html' %}
+
+
+
+
+
+
+ {% trans 'Trouble Logging In?' %}
+
+
+
+ {% block card %}
+
+ {% block above_form %}
+ {% endblock %}
+
+ {% include 'partials/form.html' %}
+
+ {% block beneath_form %}
+ {% endblock %}
+
+ {% trans primary_action %}
+
+
+ {% endblock %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/passbook/stages/identification/tests.py b/passbook/stages/identification/tests.py
new file mode 100644
index 000000000..f3389c1ec
--- /dev/null
+++ b/passbook/stages/identification/tests.py
@@ -0,0 +1,111 @@
+"""identification tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.sources.oauth.models import OAuthSource
+from passbook.stages.identification.models import (
+ IdentificationStage,
+ Templates,
+ UserFields,
+)
+
+
+class TestIdentificationStage(TestCase):
+ """Identification tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-identification",
+ slug="test-identification",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = IdentificationStage.objects.create(
+ name="identification",
+ user_fields=[UserFields.E_MAIL],
+ template=Templates.DEFAULT_LOGIN,
+ )
+ FlowStageBinding.objects.create(
+ flow=self.flow, stage=self.stage, order=0,
+ )
+
+ # OAuthSource for the login view
+ OAuthSource.objects.create(name="test", slug="test")
+
+ def test_valid_render(self):
+ """Test that View renders correctly"""
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_valid_with_email(self):
+ """Test with valid email, check that URL redirects back to itself"""
+ form_data = {"uid_field": self.user.email}
+ url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.post(url, form_data)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ def test_invalid_with_username(self):
+ """Test invalid with username (user exists but stage only allows e-mail)"""
+ form_data = {"uid_field": self.user.username}
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ form_data,
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_invalid_with_invalid_email(self):
+ """Test with invalid e-mail (user doesn't exist) -> Will return to login form"""
+ form_data = {"uid_field": self.user.email + "test"}
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ form_data,
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_enrollment_flow(self):
+ """Test that enrollment flow is linked correctly"""
+ flow = Flow.objects.create(
+ name="enroll-test",
+ slug="unique-enrollment-string",
+ designation=FlowDesignation.ENROLLMENT,
+ )
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(flow.slug, response.rendered_content)
+
+ def test_recovery_flow(self):
+ """Test that recovery flow is linked correctly"""
+ flow = Flow.objects.create(
+ name="enroll-test",
+ slug="unique-recovery-string",
+ designation=FlowDesignation.RECOVERY,
+ )
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(flow.slug, response.rendered_content)
diff --git a/passbook/stages/invitation/__init__.py b/passbook/stages/invitation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/invitation/api.py b/passbook/stages/invitation/api.py
new file mode 100644
index 000000000..33da32b63
--- /dev/null
+++ b/passbook/stages/invitation/api.py
@@ -0,0 +1,45 @@
+"""Invitation Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.invitation.models import Invitation, InvitationStage
+
+
+class InvitationStageSerializer(ModelSerializer):
+ """InvitationStage Serializer"""
+
+ class Meta:
+
+ model = InvitationStage
+ fields = [
+ "pk",
+ "name",
+ "continue_flow_without_invitation",
+ ]
+
+
+class InvitationStageViewSet(ModelViewSet):
+ """InvitationStage Viewset"""
+
+ queryset = InvitationStage.objects.all()
+ serializer_class = InvitationStageSerializer
+
+
+class InvitationSerializer(ModelSerializer):
+ """Invitation Serializer"""
+
+ class Meta:
+
+ model = Invitation
+ fields = [
+ "pk",
+ "expires",
+ "fixed_data",
+ ]
+
+
+class InvitationViewSet(ModelViewSet):
+ """Invitation Viewset"""
+
+ queryset = Invitation.objects.all()
+ serializer_class = InvitationSerializer
diff --git a/passbook/stages/invitation/apps.py b/passbook/stages/invitation/apps.py
new file mode 100644
index 000000000..0b0eddd0b
--- /dev/null
+++ b/passbook/stages/invitation/apps.py
@@ -0,0 +1,10 @@
+"""passbook invitation stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStageUserInvitationConfig(AppConfig):
+ """passbook invitation stage config"""
+
+ name = "passbook.stages.invitation"
+ label = "passbook_stages_invitation"
+ verbose_name = "passbook Stages.User Invitation"
diff --git a/passbook/stages/invitation/forms.py b/passbook/stages/invitation/forms.py
new file mode 100644
index 000000000..a6a34d17e
--- /dev/null
+++ b/passbook/stages/invitation/forms.py
@@ -0,0 +1,33 @@
+"""passbook flows invitation forms"""
+from django import forms
+from django.utils.translation import gettext as _
+
+from passbook.stages.invitation.models import Invitation, InvitationStage
+
+
+class InvitationStageForm(forms.ModelForm):
+ """Form to create/edit InvitationStage instances"""
+
+ class Meta:
+
+ model = InvitationStage
+ fields = ["name", "continue_flow_without_invitation"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class InvitationForm(forms.ModelForm):
+ """InvitationForm"""
+
+ class Meta:
+
+ model = Invitation
+ fields = ["expires", "fixed_data"]
+ labels = {
+ "fixed_data": _("Optional fixed data to enforce on user enrollment."),
+ }
+ widgets = {
+ "fixed_username": forms.TextInput(),
+ "fixed_email": forms.TextInput(),
+ }
diff --git a/passbook/stages/invitation/migrations/0001_initial.py b/passbook/stages/invitation/migrations/0001_initial.py
new file mode 100644
index 000000000..31c10f124
--- /dev/null
+++ b/passbook/stages/invitation/migrations/0001_initial.py
@@ -0,0 +1,72 @@
+# Generated by Django 3.0.5 on 2020-05-11 19:09
+
+import uuid
+
+import django.contrib.postgres.fields.jsonb
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0004_auto_20200510_2310"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="InvitationStage",
+ 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",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Invitation Stage",
+ "verbose_name_plural": "Invitation Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ migrations.CreateModel(
+ name="Invitation",
+ fields=[
+ (
+ "uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("expires", models.DateTimeField(blank=True, default=None, null=True)),
+ (
+ "fixed_data",
+ django.contrib.postgres.fields.jsonb.JSONField(default=dict),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Invitation",
+ "verbose_name_plural": "Invitations",
+ },
+ ),
+ ]
diff --git a/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py b/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py
new file mode 100644
index 000000000..2772f6c7b
--- /dev/null
+++ b/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.0.5 on 2020-05-11 19:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_invitation", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="invitationstage",
+ name="continue_flow_without_invitation",
+ field=models.BooleanField(
+ default=False,
+ help_text="If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given.",
+ ),
+ ),
+ ]
diff --git a/passbook/stages/invitation/migrations/__init__.py b/passbook/stages/invitation/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/invitation/models.py b/passbook/stages/invitation/models.py
new file mode 100644
index 000000000..fed3ee8b0
--- /dev/null
+++ b/passbook/stages/invitation/models.py
@@ -0,0 +1,50 @@
+"""invitation stage models"""
+from django.contrib.postgres.fields import JSONField
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from passbook.core.models import User
+from passbook.flows.models import Stage
+from passbook.lib.models import UUIDModel
+
+
+class InvitationStage(Stage):
+ """Invitation stage, to enroll themselves with enforced parameters"""
+
+ continue_flow_without_invitation = models.BooleanField(
+ default=False,
+ help_text=_(
+ (
+ "If this flag is set, this Stage will jump to the next Stage when "
+ "no Invitation is given. By default this Stage will cancel the "
+ "Flow when no invitation is given."
+ )
+ ),
+ )
+
+ type = "passbook.stages.invitation.stage.InvitationStageView"
+ form = "passbook.stages.invitation.forms.InvitationStageForm"
+
+ def __str__(self):
+ return f"Invitation Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Invitation Stage")
+ verbose_name_plural = _("Invitation Stages")
+
+
+class Invitation(UUIDModel):
+ """Single-use invitation link"""
+
+ created_by = models.ForeignKey(User, on_delete=models.CASCADE)
+ expires = models.DateTimeField(default=None, blank=True, null=True)
+ fixed_data = JSONField(default=dict)
+
+ def __str__(self):
+ return f"Invitation {self.uuid.hex} created by {self.created_by}"
+
+ class Meta:
+
+ verbose_name = _("Invitation")
+ verbose_name_plural = _("Invitations")
diff --git a/passbook/stages/invitation/stage.py b/passbook/stages/invitation/stage.py
new file mode 100644
index 000000000..e4ca3a5ef
--- /dev/null
+++ b/passbook/stages/invitation/stage.py
@@ -0,0 +1,26 @@
+"""invitation stage logic"""
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404
+
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.invitation.models import Invitation, InvitationStage
+from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+INVITATION_TOKEN_KEY = "token"
+
+
+class InvitationStageView(AuthenticationStage):
+ """Finalise Authentication flow by logging the user in"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ stage: InvitationStage = self.executor.current_stage
+ if INVITATION_TOKEN_KEY not in request.GET:
+ # No Invitation was given, raise error or continue
+ if stage.continue_flow_without_invitation:
+ return self.executor.stage_ok()
+ return self.executor.stage_invalid()
+
+ token = request.GET[INVITATION_TOKEN_KEY]
+ invite: Invitation = get_object_or_404(Invitation, pk=token)
+ self.executor.plan.context[PLAN_CONTEXT_PROMPT] = invite.fixed_data
+ return self.executor.stage_ok()
diff --git a/passbook/stages/invitation/tests.py b/passbook/stages/invitation/tests.py
new file mode 100644
index 000000000..5998d26ac
--- /dev/null
+++ b/passbook/stages/invitation/tests.py
@@ -0,0 +1,110 @@
+"""invitation tests"""
+from unittest.mock import MagicMock, patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.invitation.forms import InvitationStageForm
+from passbook.stages.invitation.models import Invitation, InvitationStage
+from passbook.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT
+from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+
+
+class TestUserLoginStage(TestCase):
+ """Login tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-invitation",
+ slug="test-invitation",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = InvitationStage.objects.create(name="invitation")
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(InvitationStageForm(data).is_valid(), True)
+
+ def test_without_invitation_fail(self):
+ """Test without any invitation, continue_flow_without_invitation not set."""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_without_invitation_continue(self):
+ """Test without any invitation, continue_flow_without_invitation is set."""
+ self.stage.continue_flow_without_invitation = True
+ self.stage.save()
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+ self.stage.continue_flow_without_invitation = False
+ self.stage.save()
+
+ def test_with_invitation(self):
+ """Test with invitation, check data in session"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ data = {"foo": "bar"}
+ invite = Invitation.objects.create(
+ created_by=get_anonymous_user(), fixed_data=data
+ )
+
+ with patch("passbook.flows.views.FlowExecutorView.cancel", MagicMock()):
+ base_url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.get(
+ base_url + f"?{INVITATION_TOKEN_KEY}={invite.pk.hex}"
+ )
+
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
diff --git a/passbook/stages/otp/__init__.py b/passbook/stages/otp/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/otp/api.py b/passbook/stages/otp/api.py
new file mode 100644
index 000000000..9378a4cb0
--- /dev/null
+++ b/passbook/stages/otp/api.py
@@ -0,0 +1,21 @@
+"""OTPStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.otp.models import OTPStage
+
+
+class OTPStageSerializer(ModelSerializer):
+ """OTPStage Serializer"""
+
+ class Meta:
+
+ model = OTPStage
+ fields = ["pk", "name", "enforced"]
+
+
+class OTPStageViewSet(ModelViewSet):
+ """OTPStage Viewset"""
+
+ queryset = OTPStage.objects.all()
+ serializer_class = OTPStageSerializer
diff --git a/passbook/stages/otp/apps.py b/passbook/stages/otp/apps.py
new file mode 100644
index 000000000..88b5ce441
--- /dev/null
+++ b/passbook/stages/otp/apps.py
@@ -0,0 +1,12 @@
+"""passbook OTP AppConfig"""
+
+from django.apps.config import AppConfig
+
+
+class PassbookStageOTPConfig(AppConfig):
+ """passbook OTP AppConfig"""
+
+ name = "passbook.stages.otp"
+ label = "passbook_stages_otp"
+ verbose_name = "passbook Stages.OTP"
+ mountpoint = "user/otp/"
diff --git a/passbook/factors/otp/forms.py b/passbook/stages/otp/forms.py
similarity index 77%
rename from passbook/factors/otp/forms.py
rename to passbook/stages/otp/forms.py
index b71198a80..0033667cf 100644
--- a/passbook/factors/otp/forms.py
+++ b/passbook/stages/otp/forms.py
@@ -1,14 +1,12 @@
"""passbook OTP Forms"""
from django import forms
-from django.contrib.admin.widgets import FilteredSelectMultiple
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.factors.forms import GENERAL_FIELDS
-from passbook.factors.otp.models import OTPFactor
+from passbook.stages.otp.models import OTPStage
OTP_CODE_VALIDATOR = RegexValidator(
r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.")
@@ -68,20 +66,13 @@ class OTPSetupForm(forms.Form):
return self.cleaned_data.get("code")
-class OTPFactorForm(forms.ModelForm):
- """Form to edit OTPFactor instances"""
+class OTPStageForm(forms.ModelForm):
+ """Form to edit OTPStage instances"""
class Meta:
- model = OTPFactor
- fields = GENERAL_FIELDS + ["enforced"]
+ model = OTPStage
+ fields = ["name", "enforced"]
widgets = {
"name": forms.TextInput(),
- "order": forms.NumberInput(),
- "policies": FilteredSelectMultiple(_("policies"), False),
- }
- help_texts = {
- "policies": _(
- "Policies which determine if this factor applies to the current user."
- )
}
diff --git a/passbook/factors/otp/migrations/0001_initial.py b/passbook/stages/otp/migrations/0001_initial.py
similarity index 67%
rename from passbook/factors/otp/migrations/0001_initial.py
rename to passbook/stages/otp/migrations/0001_initial.py
index fe06e9300..205c8c98b 100644
--- a/passbook/factors/otp/migrations/0001_initial.py
+++ b/passbook/stages/otp/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 2.2.6 on 2019-10-07 14:07
+# Generated by Django 3.0.3 on 2020-05-08 17:59
import django.db.models.deletion
from django.db import migrations, models
@@ -9,36 +9,33 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ("passbook_core", "0001_initial"),
+ ("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
- name="OTPFactor",
+ name="OTPStage",
fields=[
(
- "factor_ptr",
+ "stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
- to="passbook_core.Factor",
+ to="passbook_flows.Stage",
),
),
(
"enforced",
models.BooleanField(
default=False,
- help_text="Enforce enabled OTP for Users this factor applies to.",
+ help_text="Enforce enabled OTP for Users this stage applies to.",
),
),
],
- options={
- "verbose_name": "OTP Factor",
- "verbose_name_plural": "OTP Factors",
- },
- bases=("passbook_core.factor",),
+ options={"verbose_name": "OTP Stage", "verbose_name_plural": "OTP Stages",},
+ bases=("passbook_flows.stage",),
),
]
diff --git a/passbook/stages/otp/migrations/__init__.py b/passbook/stages/otp/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/otp/models.py b/passbook/stages/otp/models.py
new file mode 100644
index 000000000..f0a6e1c88
--- /dev/null
+++ b/passbook/stages/otp/models.py
@@ -0,0 +1,34 @@
+"""OTP Stage"""
+from django.db import models
+from django.utils.translation import gettext as _
+
+from passbook.core.types import UIUserSettings
+from passbook.flows.models import Stage
+
+
+class OTPStage(Stage):
+ """OTP Stage"""
+
+ enforced = models.BooleanField(
+ default=False,
+ help_text=("Enforce enabled OTP for Users " "this stage applies to."),
+ )
+
+ type = "passbook.stages.otp.stages.OTPStage"
+ form = "passbook.stages.otp.forms.OTPStageForm"
+
+ @property
+ def ui_user_settings(self) -> UIUserSettings:
+ return UIUserSettings(
+ name="OTP",
+ icon="pficon-locked",
+ view_name="passbook_stages_otp:otp-user-settings",
+ )
+
+ def __str__(self):
+ return f"OTP Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("OTP Stage")
+ verbose_name_plural = _("OTP Stages")
diff --git a/passbook/factors/otp/settings.py b/passbook/stages/otp/settings.py
similarity index 100%
rename from passbook/factors/otp/settings.py
rename to passbook/stages/otp/settings.py
diff --git a/passbook/factors/otp/factors.py b/passbook/stages/otp/stage.py
similarity index 62%
rename from passbook/factors/otp/factors.py
rename to passbook/stages/otp/stage.py
index e16d8111b..3d62f7519 100644
--- a/passbook/factors/otp/factors.py
+++ b/passbook/stages/otp/stage.py
@@ -1,21 +1,22 @@
-"""OTP Factor logic"""
+"""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.factors.base import AuthenticationFactor
-from passbook.factors.otp.forms import OTPVerifyForm
-from passbook.factors.otp.views import OTP_SETTING_UP_KEY, EnableView
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.otp.forms import OTPVerifyForm
+from passbook.stages.otp.views import OTP_SETTING_UP_KEY, EnableView
LOGGER = get_logger()
-class OTPFactor(FormView, AuthenticationFactor):
- """OTP Factor View"""
+class OTPStage(FormView, AuthenticationStage):
+ """OTP Stage View"""
- template_name = "otp/factor.html"
+ template_name = "stages/otp/stage.html"
form_class = OTPVerifyForm
def get_context_data(self, **kwargs):
@@ -25,31 +26,34 @@ class OTPFactor(FormView, AuthenticationFactor):
def get(self, request, *args, **kwargs):
"""Check if User has OTP enabled and if OTP is enforced"""
- if not user_has_device(self.pending_user):
+ 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.authenticator.current_factor.enforced:
+ if self.executor.current_stage.enforced:
# Redirect to setup view
LOGGER.debug("OTP is enforced, redirecting to setup")
- request.user = self.pending_user
- LOGGER.debug("Passing GET to EnableView")
+ 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.authenticator.user_ok()
+ 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.pending_user
+ 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.pending_user, form.cleaned_data.get("code"))
+ device = match_token(
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
+ form.cleaned_data.get("code"),
+ )
if device:
- return self.authenticator.user_ok()
+ return self.executor.stage_ok()
messages.error(self.request, _("Invalid OTP."))
return self.form_invalid(form)
diff --git a/passbook/factors/otp/templates/otp/factor.html b/passbook/stages/otp/templates/stages/otp/factor.html
similarity index 100%
rename from passbook/factors/otp/templates/otp/factor.html
rename to passbook/stages/otp/templates/stages/otp/factor.html
diff --git a/passbook/stages/otp/templates/stages/otp/user_settings.html b/passbook/stages/otp/templates/stages/otp/user_settings.html
new file mode 100644
index 000000000..7cb86d941
--- /dev/null
+++ b/passbook/stages/otp/templates/stages/otp/user_settings.html
@@ -0,0 +1,42 @@
+{% extends "user/base.html" %}
+
+{% load utils %}
+{% load i18n %}
+
+{% block page %}
+
+
+
+
+ {% blocktrans with state=state|yesno:"Enabled,Disabled" %}
+ Status: {{ state }}
+ {% endblocktrans %}
+ {% if state %}
+
+ {% else %}
+
+ {% endif %}
+
+
+ {% if not state %}
+ {% trans "Enable OTP" %}
+ {% else %}
+ {% trans "Disable OTP" %}
+ {% endif %}
+
+
+
+
+
+
+
+
{% for token in static_tokens %}{{ token.token }}
+ {% empty %}{% trans 'N/A' %}{% endfor %}
+
+
+
+{% endblock %}
diff --git a/passbook/factors/otp/urls.py b/passbook/stages/otp/urls.py
similarity index 89%
rename from passbook/factors/otp/urls.py
rename to passbook/stages/otp/urls.py
index 4efc3ca88..012ff2923 100644
--- a/passbook/factors/otp/urls.py
+++ b/passbook/stages/otp/urls.py
@@ -2,7 +2,7 @@
from django.urls import path
-from passbook.factors.otp import views
+from passbook.stages.otp import views
urlpatterns = [
path("", views.UserSettingsView.as_view(), name="otp-user-settings"),
diff --git a/passbook/factors/otp/utils.py b/passbook/stages/otp/utils.py
similarity index 100%
rename from passbook/factors/otp/utils.py
rename to passbook/stages/otp/utils.py
diff --git a/passbook/factors/otp/views.py b/passbook/stages/otp/views.py
similarity index 90%
rename from passbook/factors/otp/views.py
rename to passbook/stages/otp/views.py
index 5561482ce..e933bedec 100644
--- a/passbook/factors/otp/views.py
+++ b/passbook/stages/otp/views.py
@@ -19,21 +19,21 @@ from qrcode.image.svg import SvgPathImage
from structlog import get_logger
from passbook.audit.models import Event, EventAction
-from passbook.factors.otp.forms import OTPSetupForm
-from passbook.factors.otp.utils import otpauth_url
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_factors_otp_key"
-OTP_SETTING_UP_KEY = "passbook_factors_otp_setup"
+OTP_SESSION_KEY = "passbook_stages_otp_key"
+OTP_SETTING_UP_KEY = "passbook_stages_otp_setup"
LOGGER = get_logger()
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control OTP"""
- template_name = "otp/user_settings.html"
+ template_name = "stages/otp/user_settings.html"
- # TODO: Check if OTP Factor exists and applies to user
+ # 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)
@@ -61,7 +61,7 @@ class DisableView(LoginRequiredMixin, View):
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_factors_otp:otp-user-settings"))
+ return redirect(reverse("passbook_stages_otp:otp-user-settings"))
class EnableView(LoginRequiredMixin, FormView):
@@ -74,7 +74,7 @@ class EnableView(LoginRequiredMixin, FormView):
totp_device = None
static_device = None
- # TODO: Check if OTP Factor exists and applies to user
+ # 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")
@@ -92,7 +92,7 @@ class EnableView(LoginRequiredMixin, FormView):
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_factors_otp:otp-user-settings")
+ 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)
@@ -127,7 +127,7 @@ class EnableView(LoginRequiredMixin, FormView):
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_factors_otp:otp-qr")
+ 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
@@ -143,7 +143,7 @@ class EnableView(LoginRequiredMixin, FormView):
Event.new(EventAction.CUSTOM, message="User enabled OTP.").from_http(
self.request
)
- return redirect("passbook_factors_otp:otp-user-settings")
+ return redirect("passbook_stages_otp:otp-user-settings")
@method_decorator(never_cache, name="dispatch")
diff --git a/passbook/stages/password/__init__.py b/passbook/stages/password/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/password/api.py b/passbook/stages/password/api.py
new file mode 100644
index 000000000..6b8f28703
--- /dev/null
+++ b/passbook/stages/password/api.py
@@ -0,0 +1,25 @@
+"""PasswordStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.password.models import PasswordStage
+
+
+class PasswordStageSerializer(ModelSerializer):
+ """PasswordStage Serializer"""
+
+ class Meta:
+
+ model = PasswordStage
+ fields = [
+ "pk",
+ "name",
+ "backends",
+ ]
+
+
+class PasswordStageViewSet(ModelViewSet):
+ """PasswordStage Viewset"""
+
+ queryset = PasswordStage.objects.all()
+ serializer_class = PasswordStageSerializer
diff --git a/passbook/stages/password/apps.py b/passbook/stages/password/apps.py
new file mode 100644
index 000000000..087e1f90c
--- /dev/null
+++ b/passbook/stages/password/apps.py
@@ -0,0 +1,10 @@
+"""passbook core app config"""
+from django.apps import AppConfig
+
+
+class PassbookStagePasswordConfig(AppConfig):
+ """passbook password stage config"""
+
+ name = "passbook.stages.password"
+ label = "passbook_stages_password"
+ verbose_name = "passbook Stages.Password"
diff --git a/passbook/factors/password/forms.py b/passbook/stages/password/forms.py
similarity index 64%
rename from passbook/factors/password/forms.py
rename to passbook/stages/password/forms.py
index 1594ab876..f9dc91421 100644
--- a/passbook/factors/password/forms.py
+++ b/passbook/stages/password/forms.py
@@ -4,9 +4,8 @@ from django.conf import settings
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
-from passbook.factors.forms import GENERAL_FIELDS
-from passbook.factors.password.models import PasswordFactor
from passbook.lib.utils.reflection import path_to_class
+from passbook.stages.password.models import PasswordStage
def get_authentication_backends():
@@ -32,25 +31,17 @@ class PasswordForm(forms.Form):
)
-class PasswordFactorForm(forms.ModelForm):
- """Form to create/edit Password Factors"""
+class PasswordStageForm(forms.ModelForm):
+ """Form to create/edit Password Stages"""
class Meta:
- model = PasswordFactor
- fields = GENERAL_FIELDS + ["backends", "password_policies", "reset_factors"]
+ model = PasswordStage
+ fields = ["name", "backends"]
widgets = {
"name": forms.TextInput(),
- "order": forms.NumberInput(),
- "policies": FilteredSelectMultiple(_("policies"), False),
"backends": FilteredSelectMultiple(
_("backends"), False, choices=get_authentication_backends()
),
"password_policies": FilteredSelectMultiple(_("password policies"), False),
- "reset_factors": FilteredSelectMultiple(_("reset factors"), False),
- }
- help_texts = {
- "policies": _(
- "Policies which determine if this factor applies to the current user."
- )
}
diff --git a/passbook/factors/password/migrations/0001_initial.py b/passbook/stages/password/migrations/0001_initial.py
similarity index 62%
rename from passbook/factors/password/migrations/0001_initial.py
rename to passbook/stages/password/migrations/0001_initial.py
index 58be06b49..b2c740c1d 100644
--- a/passbook/factors/password/migrations/0001_initial.py
+++ b/passbook/stages/password/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 2.2.6 on 2019-10-07 14:07
+# Generated by Django 3.0.3 on 2020-05-08 17:58
import django.contrib.postgres.fields
import django.db.models.deletion
@@ -10,28 +10,31 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ("passbook_core", "0001_initial"),
+ ("passbook_flows", "0001_initial"),
+ ("passbook_core", "0012_delete_factor"),
]
operations = [
migrations.CreateModel(
- name="PasswordFactor",
+ name="PasswordStage",
fields=[
(
- "factor_ptr",
+ "stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
- to="passbook_core.Factor",
+ to="passbook_flows.Stage",
),
),
(
"backends",
django.contrib.postgres.fields.ArrayField(
- base_field=models.TextField(), size=None
+ base_field=models.TextField(),
+ help_text="Selection of backends to test the password against.",
+ size=None,
),
),
(
@@ -40,9 +43,9 @@ class Migration(migrations.Migration):
),
],
options={
- "verbose_name": "Password Factor",
- "verbose_name_plural": "Password Factors",
+ "verbose_name": "Password Stage",
+ "verbose_name_plural": "Password Stages",
},
- bases=("passbook_core.factor",),
+ bases=("passbook_flows.stage",),
),
]
diff --git a/passbook/stages/password/migrations/0002_remove_passwordstage_password_policies.py b/passbook/stages/password/migrations/0002_remove_passwordstage_password_policies.py
new file mode 100644
index 000000000..2eff28c5d
--- /dev/null
+++ b/passbook/stages/password/migrations/0002_remove_passwordstage_password_policies.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.5 on 2020-05-10 16:48
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_password", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RemoveField(model_name="passwordstage", name="password_policies",),
+ ]
diff --git a/passbook/stages/password/migrations/__init__.py b/passbook/stages/password/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/password/models.py b/passbook/stages/password/models.py
new file mode 100644
index 000000000..8d585cfb6
--- /dev/null
+++ b/passbook/stages/password/models.py
@@ -0,0 +1,26 @@
+"""password stage models"""
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from passbook.flows.models import Stage
+
+
+class PasswordStage(Stage):
+ """Password-based Django-backend Authentication Stage"""
+
+ backends = ArrayField(
+ models.TextField(),
+ help_text=_("Selection of backends to test the password against."),
+ )
+
+ type = "passbook.stages.password.stage.PasswordStage"
+ form = "passbook.stages.password.forms.PasswordStageForm"
+
+ def __str__(self):
+ return f"Password Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Password Stage")
+ verbose_name_plural = _("Password Stages")
diff --git a/passbook/stages/password/stage.py b/passbook/stages/password/stage.py
new file mode 100644
index 000000000..67c30990b
--- /dev/null
+++ b/passbook/stages/password/stage.py
@@ -0,0 +1,88 @@
+"""passbook password stage"""
+from typing import Any, Dict, List, Optional
+
+from django.contrib.auth import _clean_credentials
+from django.contrib.auth.backends import BaseBackend
+from django.contrib.auth.signals import user_login_failed
+from django.core.exceptions import PermissionDenied
+from django.forms.utils import ErrorList
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from passbook.core.models import User
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+from passbook.lib.utils.reflection import path_to_class
+from passbook.stages.password.forms import PasswordForm
+
+LOGGER = get_logger()
+PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend"
+
+
+def authenticate(
+ request: HttpRequest, backends: List[str], **credentials: Dict[str, Any]
+) -> Optional[User]:
+ """If the given credentials are valid, return a User object.
+
+ Customized version of django's authenticate, which accepts a list of backends"""
+ for backend_path in backends:
+ backend: BaseBackend = path_to_class(backend_path)()
+ LOGGER.debug("Attempting authentication...", backend=backend)
+ user = backend.authenticate(request, **credentials)
+ if user is None:
+ LOGGER.debug("Backend returned nothing, continuing")
+ continue
+ # Annotate the user object with the path of the backend.
+ user.backend = backend_path
+ LOGGER.debug("Successful authentication", user=user, backend=backend)
+ return user
+
+ # The credentials supplied are invalid to all backends, fire signal
+ user_login_failed.send(
+ sender=__name__, credentials=_clean_credentials(credentials), request=request
+ )
+
+
+class PasswordStage(FormView, AuthenticationStage):
+ """Authentication stage which authenticates against django's AuthBackend"""
+
+ form_class = PasswordForm
+ template_name = "stages/password/backend.html"
+
+ def form_valid(self, form: PasswordForm) -> HttpResponse:
+ """Authenticate against django's authentication backend"""
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ return self.executor.stage_invalid()
+ # Get the pending user's username, which is used as
+ # an Identifier by most authentication backends
+ pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ auth_kwargs = {
+ "password": form.cleaned_data.get("password", None),
+ "username": pending_user.username,
+ }
+ try:
+ user = authenticate(
+ self.request, self.executor.current_stage.backends, **auth_kwargs
+ )
+ except PermissionDenied:
+ del auth_kwargs["password"]
+ # User was found, but permission was denied (i.e. user is not active)
+ LOGGER.debug("Denied access", **auth_kwargs)
+ return self.executor.stage_invalid()
+ else:
+ if not user:
+ # No user was found -> invalid credentials
+ LOGGER.debug("Invalid credentials")
+ # Manually inject error into form
+ # pylint: disable=protected-access
+ errors = form._errors.setdefault("password", ErrorList())
+ errors.append(_("Invalid password"))
+ return self.form_invalid(form)
+ # User instance returned from authenticate() has .backend property set
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
+ self.executor.plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = user.backend
+ return self.executor.stage_ok()
diff --git a/passbook/core/templates/login/factors/backend.html b/passbook/stages/password/templates/stages/password/backend.html
similarity index 50%
rename from passbook/core/templates/login/factors/backend.html
rename to passbook/stages/password/templates/stages/password/backend.html
index a88dd6b0a..e270a5bbc 100644
--- a/passbook/core/templates/login/factors/backend.html
+++ b/passbook/stages/password/templates/stages/password/backend.html
@@ -4,6 +4,7 @@
{% block beneath_form %}
{% if show_password_forget_notice %}
-{% trans 'Forgot password?' %}
+{# TODO: Link to dedicated recovery flow #}
+{% trans 'Forgot password?' %}
{% endif %}
{% endblock %}
diff --git a/passbook/stages/password/tests.py b/passbook/stages/password/tests.py
new file mode 100644
index 000000000..82cb4d947
--- /dev/null
+++ b/passbook/stages/password/tests.py
@@ -0,0 +1,116 @@
+"""password tests"""
+import string
+from random import SystemRandom
+from unittest.mock import MagicMock, patch
+
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.password.models import PasswordStage
+
+MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test"))
+
+
+class TestPasswordStage(TestCase):
+ """Password tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.password = "".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ )
+ self.user = User.objects.create_user(
+ username="unittest", email="test@beryju.org", password=self.password
+ )
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-password",
+ slug="test-password",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = PasswordStage.objects.create(
+ name="password", backends=["django.contrib.auth.backends.ModelBackend"]
+ )
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_without_user(self):
+ """Test without user"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Still have to send the password so the form is valid
+ {"password": self.password},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_valid_password(self):
+ """Test with a valid pending user and valid password"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Form data
+ {"password": self.password},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ def test_invalid_password(self):
+ """Test with a valid pending user and invalid password"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Form data
+ {"password": self.password + "test"},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ @patch(
+ "django.contrib.auth.backends.ModelBackend.authenticate",
+ MOCK_BACKEND_AUTHENTICATE,
+ )
+ def test_permission_denied(self):
+ """Test with a valid pending user and valid password.
+ Backend is patched to return PermissionError"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Form data
+ {"password": self.password + "test"},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
diff --git a/passbook/stages/prompt/__init__.py b/passbook/stages/prompt/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/prompt/api.py b/passbook/stages/prompt/api.py
new file mode 100644
index 000000000..381e9427c
--- /dev/null
+++ b/passbook/stages/prompt/api.py
@@ -0,0 +1,48 @@
+"""Prompt Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.prompt.models import Prompt, PromptStage
+
+
+class PromptStageSerializer(ModelSerializer):
+ """PromptStage Serializer"""
+
+ class Meta:
+
+ model = PromptStage
+ fields = [
+ "pk",
+ "name",
+ "fields",
+ ]
+
+
+class PromptStageViewSet(ModelViewSet):
+ """PromptStage Viewset"""
+
+ queryset = PromptStage.objects.all()
+ serializer_class = PromptStageSerializer
+
+
+class PromptSerializer(ModelSerializer):
+ """Prompt Serializer"""
+
+ class Meta:
+
+ model = Prompt
+ fields = [
+ "pk",
+ "field_key",
+ "label",
+ "type",
+ "required",
+ "placeholder",
+ ]
+
+
+class PromptViewSet(ModelViewSet):
+ """Prompt Viewset"""
+
+ queryset = Prompt.objects.all()
+ serializer_class = PromptSerializer
diff --git a/passbook/stages/prompt/apps.py b/passbook/stages/prompt/apps.py
new file mode 100644
index 000000000..783823381
--- /dev/null
+++ b/passbook/stages/prompt/apps.py
@@ -0,0 +1,10 @@
+"""passbook prompt stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStagPromptConfig(AppConfig):
+ """passbook prompt stage config"""
+
+ name = "passbook.stages.prompt"
+ label = "passbook_stages_prompt"
+ verbose_name = "passbook Stages.Prompt"
diff --git a/passbook/stages/prompt/forms.py b/passbook/stages/prompt/forms.py
new file mode 100644
index 000000000..ac520c2da
--- /dev/null
+++ b/passbook/stages/prompt/forms.py
@@ -0,0 +1,44 @@
+"""Prompt forms"""
+from django import forms
+from guardian.shortcuts import get_anonymous_user
+
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.policies.engine import PolicyEngine
+from passbook.stages.prompt.models import Prompt, PromptStage
+
+
+class PromptStageForm(forms.ModelForm):
+ """Form to create/edit Prompt Stage instances"""
+
+ class Meta:
+
+ model = PromptStage
+ fields = ["name", "fields"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class PromptForm(forms.Form):
+ """Dynamically created form based on PromptStage"""
+
+ stage: PromptStage
+ plan: FlowPlan
+
+ def __init__(self, stage: PromptStage, plan: FlowPlan, *args, **kwargs):
+ self.stage = stage
+ self.plan = plan
+ super().__init__(*args, **kwargs)
+ for field in self.stage.fields.all():
+ field: Prompt
+ self.fields[field.field_key] = field.field
+
+ def clean(self):
+ cleaned_data = super().clean()
+ user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
+ engine = PolicyEngine(self.stage.policies.all(), user)
+ engine.request.context = cleaned_data
+ engine.build()
+ passing, messages = engine.result
+ if not passing:
+ raise forms.ValidationError(messages)
diff --git a/passbook/stages/prompt/migrations/0001_initial.py b/passbook/stages/prompt/migrations/0001_initial.py
new file mode 100644
index 000000000..2809be16c
--- /dev/null
+++ b/passbook/stages/prompt/migrations/0001_initial.py
@@ -0,0 +1,87 @@
+# Generated by Django 3.0.5 on 2020-05-14 11:46
+
+import uuid
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0005_auto_20200512_1158"),
+ ("passbook_policies", "0003_auto_20200508_1642"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Prompt",
+ fields=[
+ (
+ "uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "field_key",
+ models.SlugField(
+ help_text="Name of the form field, also used to store the value"
+ ),
+ ),
+ ("label", models.TextField()),
+ (
+ "type",
+ models.CharField(
+ choices=[
+ ("text", "Text"),
+ ("e-mail", "Email"),
+ ("password", "Password"),
+ ("number", "Number"),
+ ("hidden", "Hidden"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ("required", models.BooleanField(default=True)),
+ ("placeholder", models.TextField()),
+ ],
+ options={"verbose_name": "Prompt", "verbose_name_plural": "Prompts",},
+ ),
+ migrations.CreateModel(
+ name="PromptStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ to="passbook_flows.Stage",
+ ),
+ ),
+ (
+ "policybindingmodel_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="passbook_policies.PolicyBindingModel",
+ ),
+ ),
+ ("fields", models.ManyToManyField(to="passbook_stages_prompt.Prompt")),
+ ],
+ options={
+ "verbose_name": "Prompt Stage",
+ "verbose_name_plural": "Prompt Stages",
+ },
+ bases=("passbook_policies.policybindingmodel", "passbook_flows.stage"),
+ ),
+ ]
diff --git a/passbook/stages/prompt/migrations/__init__.py b/passbook/stages/prompt/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/prompt/models.py b/passbook/stages/prompt/models.py
new file mode 100644
index 000000000..c239c2224
--- /dev/null
+++ b/passbook/stages/prompt/models.py
@@ -0,0 +1,96 @@
+"""prompt models"""
+from django import forms
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from passbook.flows.models import Stage
+from passbook.lib.models import UUIDModel
+from passbook.policies.models import PolicyBindingModel
+
+
+class FieldTypes(models.TextChoices):
+ """Field types an Prompt can be"""
+
+ TEXT = "text"
+ EMAIL = "e-mail"
+ PASSWORD = "password" # noqa # nosec
+ NUMBER = "number"
+ HIDDEN = "hidden"
+
+
+class Prompt(UUIDModel):
+ """Single Prompt, part of a prompt stage."""
+
+ field_key = models.SlugField(
+ help_text=_("Name of the form field, also used to store the value")
+ )
+ label = models.TextField()
+ type = models.CharField(max_length=100, choices=FieldTypes.choices)
+ required = models.BooleanField(default=True)
+ placeholder = models.TextField()
+
+ @property
+ def field(self):
+ """Return instantiated form input field"""
+ attrs = {"placeholder": _(self.placeholder)}
+ if self.type == FieldTypes.TEXT:
+ return forms.CharField(
+ label=_(self.label),
+ widget=forms.TextInput(attrs=attrs),
+ required=self.required,
+ )
+ if self.type == FieldTypes.EMAIL:
+ return forms.EmailField(
+ label=_(self.label),
+ widget=forms.TextInput(attrs=attrs),
+ required=self.required,
+ )
+ if self.type == FieldTypes.PASSWORD:
+ return forms.CharField(
+ label=_(self.label),
+ widget=forms.PasswordInput(attrs=attrs),
+ required=self.required,
+ )
+ if self.type == FieldTypes.NUMBER:
+ return forms.IntegerField(
+ label=_(self.label),
+ widget=forms.NumberInput(attrs=attrs),
+ required=self.required,
+ )
+ if self.type == FieldTypes.HIDDEN:
+ return forms.CharField(
+ widget=forms.HiddenInput(attrs=attrs),
+ required=False,
+ initial=self.placeholder,
+ )
+ raise ValueError("field_type is not valid, not one of FieldTypes.")
+
+ def save(self, *args, **kwargs):
+ if self.type not in FieldTypes:
+ raise ValueError
+ return super().save(*args, **kwargs)
+
+ def __str__(self):
+ return f"Prompt '{self.field_key}' type={self.type}'"
+
+ class Meta:
+
+ verbose_name = _("Prompt")
+ verbose_name_plural = _("Prompts")
+
+
+class PromptStage(PolicyBindingModel, Stage):
+ """Prompt Stage, pointing to multiple prompts"""
+
+ fields = models.ManyToManyField(Prompt)
+
+ type = "passbook.stages.prompt.stage.PromptStageView"
+ form = "passbook.stages.prompt.forms.PromptStageForm"
+
+ def __str__(self):
+ return f"Prompt Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Prompt Stage")
+ verbose_name_plural = _("Prompt Stages")
diff --git a/passbook/stages/prompt/stage.py b/passbook/stages/prompt/stage.py
new file mode 100644
index 000000000..c7b256d46
--- /dev/null
+++ b/passbook/stages/prompt/stage.py
@@ -0,0 +1,36 @@
+"""Prompt Stage Logic"""
+from django.http import HttpResponse
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.prompt.forms import PromptForm
+
+LOGGER = get_logger()
+PLAN_CONTEXT_PROMPT = "prompt_data"
+
+
+class PromptStageView(FormView, AuthenticationStage):
+ """Prompt Stage, save form data in plan context."""
+
+ template_name = "login/form.html"
+ form_class = PromptForm
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx["title"] = _(self.executor.current_stage.name)
+ return ctx
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["stage"] = self.executor.current_stage
+ kwargs["plan"] = self.executor.plan
+ return kwargs
+
+ def form_valid(self, form: PromptForm) -> HttpResponse:
+ """Form data is valid"""
+ if PLAN_CONTEXT_PROMPT not in self.executor.plan.context:
+ self.executor.plan.context[PLAN_CONTEXT_PROMPT] = {}
+ self.executor.plan.context[PLAN_CONTEXT_PROMPT].update(form.cleaned_data)
+ return self.executor.stage_ok()
diff --git a/passbook/stages/prompt/tests.py b/passbook/stages/prompt/tests.py
new file mode 100644
index 000000000..d4d2e108d
--- /dev/null
+++ b/passbook/stages/prompt/tests.py
@@ -0,0 +1,184 @@
+"""Prompt tests"""
+from unittest.mock import MagicMock, patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.policies.expression.models import ExpressionPolicy
+from passbook.policies.models import PolicyBinding
+from passbook.stages.prompt.forms import PromptForm
+from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
+from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+
+class TestPromptStage(TestCase):
+ """Prompt tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-prompt",
+ slug="test-prompt",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ text_prompt = Prompt.objects.create(
+ field_key="text_prompt",
+ label="TEXT_LABEL",
+ type=FieldTypes.TEXT,
+ required=True,
+ placeholder="TEXT_PLACEHOLDER",
+ )
+ email_prompt = Prompt.objects.create(
+ field_key="email_prompt",
+ label="EMAIL_LABEL",
+ type=FieldTypes.EMAIL,
+ required=True,
+ placeholder="EMAIL_PLACEHOLDER",
+ )
+ password_prompt = Prompt.objects.create(
+ field_key="password_prompt",
+ label="PASSWORD_LABEL",
+ type=FieldTypes.PASSWORD,
+ required=True,
+ placeholder="PASSWORD_PLACEHOLDER",
+ )
+ password2_prompt = Prompt.objects.create(
+ field_key="password2_prompt",
+ label="PASSWORD_LABEL",
+ type=FieldTypes.PASSWORD,
+ required=True,
+ placeholder="PASSWORD_PLACEHOLDER",
+ )
+ number_prompt = Prompt.objects.create(
+ field_key="number_prompt",
+ label="NUMBER_LABEL",
+ type=FieldTypes.NUMBER,
+ required=True,
+ placeholder="NUMBER_PLACEHOLDER",
+ )
+ hidden_prompt = Prompt.objects.create(
+ field_key="hidden_prompt",
+ type=FieldTypes.HIDDEN,
+ required=True,
+ placeholder="HIDDEN_PLACEHOLDER",
+ )
+ self.stage = PromptStage.objects.create(name="prompt-stage")
+ self.stage.fields.set(
+ [
+ text_prompt,
+ email_prompt,
+ password_prompt,
+ password2_prompt,
+ number_prompt,
+ hidden_prompt,
+ ]
+ )
+ self.stage.save()
+
+ self.prompt_data = {
+ text_prompt.field_key: "test-input",
+ email_prompt.field_key: "test@test.test",
+ password_prompt.field_key: "test",
+ password2_prompt.field_key: "test",
+ number_prompt.field_key: 3,
+ hidden_prompt.field_key: hidden_prompt.placeholder,
+ }
+
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_invalid_type(self):
+ """Test that invalid form type raises an error"""
+ with self.assertRaises(ValueError):
+ _ = Prompt.objects.create(
+ field_key="hidden_prompt",
+ type="invalid",
+ required=True,
+ placeholder="HIDDEN_PLACEHOLDER",
+ )
+ with self.assertRaises(ValueError):
+ prompt = Prompt.objects.create(
+ field_key="hidden_prompt",
+ type=FieldTypes.HIDDEN,
+ required=True,
+ placeholder="HIDDEN_PLACEHOLDER",
+ )
+ with patch.object(prompt, "type", MagicMock(return_value="invalid")):
+ _ = prompt.field
+
+ def test_render(self):
+ """Test render of form, check if all prompts are rendered correctly"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+ for prompt in self.stage.fields.all():
+ self.assertIn(prompt.field_key, response.rendered_content)
+ self.assertIn(prompt.label, response.rendered_content)
+ self.assertIn(prompt.placeholder, response.rendered_content)
+
+ def test_valid_form(self) -> PromptForm:
+ """Test form validation"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ expr = (
+ "{{ request.context.password_prompt == request.context.password2_prompt }}"
+ )
+ expr_policy = ExpressionPolicy.objects.create(
+ name="validate-form", expression=expr
+ )
+ PolicyBinding.objects.create(policy=expr_policy, target=self.stage)
+ form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
+ self.assertEqual(form.is_valid(), True)
+ return form
+
+ def test_invalid_form(self) -> PromptForm:
+ """Test form validation"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ expr = "False"
+ expr_policy = ExpressionPolicy.objects.create(
+ name="validate-form", expression=expr
+ )
+ PolicyBinding.objects.create(policy=expr_policy, target=self.stage)
+ form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
+ self.assertEqual(form.is_valid(), False)
+ return form
+
+ def test_valid_form_request(self):
+ """Test a request with valid form data"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ form = self.test_valid_form()
+
+ with patch("passbook.flows.views.FlowExecutorView.cancel", MagicMock()):
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ form.cleaned_data,
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ # Check that valid data has been saved
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ data = plan.context[PLAN_CONTEXT_PROMPT]
+ for prompt in self.stage.fields.all():
+ prompt: Prompt
+ self.assertEqual(data[prompt.field_key], self.prompt_data[prompt.field_key])
diff --git a/passbook/stages/user_delete/__init__.py b/passbook/stages/user_delete/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_delete/api.py b/passbook/stages/user_delete/api.py
new file mode 100644
index 000000000..5b2b84942
--- /dev/null
+++ b/passbook/stages/user_delete/api.py
@@ -0,0 +1,24 @@
+"""User Delete Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.user_delete.models import UserDeleteStage
+
+
+class UserDeleteStageSerializer(ModelSerializer):
+ """UserDeleteStage Serializer"""
+
+ class Meta:
+
+ model = UserDeleteStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class UserDeleteStageViewSet(ModelViewSet):
+ """UserDeleteStage Viewset"""
+
+ queryset = UserDeleteStage.objects.all()
+ serializer_class = UserDeleteStageSerializer
diff --git a/passbook/stages/user_delete/apps.py b/passbook/stages/user_delete/apps.py
new file mode 100644
index 000000000..c9119d295
--- /dev/null
+++ b/passbook/stages/user_delete/apps.py
@@ -0,0 +1,10 @@
+"""passbook delete stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStageUserDeleteConfig(AppConfig):
+ """passbook delete stage config"""
+
+ name = "passbook.stages.user_delete"
+ label = "passbook_stages_user_delete"
+ verbose_name = "passbook Stages.User Delete"
diff --git a/passbook/stages/user_delete/forms.py b/passbook/stages/user_delete/forms.py
new file mode 100644
index 000000000..c226f1f4f
--- /dev/null
+++ b/passbook/stages/user_delete/forms.py
@@ -0,0 +1,20 @@
+"""passbook flows delete forms"""
+from django import forms
+
+from passbook.stages.user_delete.models import UserDeleteStage
+
+
+class UserDeleteStageForm(forms.ModelForm):
+ """Form to delete/edit UserDeleteStage instances"""
+
+ class Meta:
+
+ model = UserDeleteStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class UserDeleteForm(forms.Form):
+ """Confirmation form to ensure user knows they are deleting their profile"""
diff --git a/passbook/factors/captcha/migrations/0001_initial.py b/passbook/stages/user_delete/migrations/0001_initial.py
similarity index 57%
rename from passbook/factors/captcha/migrations/0001_initial.py
rename to passbook/stages/user_delete/migrations/0001_initial.py
index 0f44952d8..f2e361a86 100644
--- a/passbook/factors/captcha/migrations/0001_initial.py
+++ b/passbook/stages/user_delete/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 2.2.6 on 2019-10-07 14:07
+# Generated by Django 3.0.5 on 2020-05-12 11:59
import django.db.models.deletion
from django.db import migrations, models
@@ -9,31 +9,29 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ("passbook_core", "0001_initial"),
+ ("passbook_flows", "0005_auto_20200512_1158"),
]
operations = [
migrations.CreateModel(
- name="CaptchaFactor",
+ name="UserDeleteStage",
fields=[
(
- "factor_ptr",
+ "stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
- to="passbook_core.Factor",
+ to="passbook_flows.Stage",
),
),
- ("public_key", models.TextField()),
- ("private_key", models.TextField()),
],
options={
- "verbose_name": "Captcha Factor",
- "verbose_name_plural": "Captcha Factors",
+ "verbose_name": "User Delete Stage",
+ "verbose_name_plural": "User Delete Stages",
},
- bases=("passbook_core.factor",),
+ bases=("passbook_flows.stage",),
),
]
diff --git a/passbook/stages/user_delete/migrations/__init__.py b/passbook/stages/user_delete/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_delete/models.py b/passbook/stages/user_delete/models.py
new file mode 100644
index 000000000..f7ad5a520
--- /dev/null
+++ b/passbook/stages/user_delete/models.py
@@ -0,0 +1,19 @@
+"""delete stage models"""
+from django.utils.translation import gettext_lazy as _
+
+from passbook.flows.models import Stage
+
+
+class UserDeleteStage(Stage):
+ """Delete stage, delete a user from saved data."""
+
+ type = "passbook.stages.user_delete.stage.UserDeleteStageView"
+ form = "passbook.stages.user_delete.forms.UserDeleteStageForm"
+
+ def __str__(self):
+ return f"User Delete Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Delete Stage")
+ verbose_name_plural = _("User Delete Stages")
diff --git a/passbook/stages/user_delete/stage.py b/passbook/stages/user_delete/stage.py
new file mode 100644
index 000000000..1547cf944
--- /dev/null
+++ b/passbook/stages/user_delete/stage.py
@@ -0,0 +1,34 @@
+"""Delete stage logic"""
+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 structlog import get_logger
+
+from passbook.core.models import User
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.user_delete.forms import UserDeleteForm
+
+LOGGER = get_logger()
+
+
+class UserDeleteStageView(FormView, AuthenticationStage):
+ """Finalise unenrollment flow by deleting the user object."""
+
+ form_class = UserDeleteForm
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ message = _("No Pending User.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ return super().get(request)
+
+ def form_valid(self, form: UserDeleteForm) -> HttpResponse:
+ user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ user.delete()
+ LOGGER.debug("Deleted user", user=user)
+ del self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ return self.executor.stage_ok()
diff --git a/passbook/stages/user_delete/tests.py b/passbook/stages/user_delete/tests.py
new file mode 100644
index 000000000..88ff49479
--- /dev/null
+++ b/passbook/stages/user_delete/tests.py
@@ -0,0 +1,74 @@
+"""delete tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.user_delete.models import UserDeleteStage
+
+
+class TestUserDeleteStage(TestCase):
+ """Delete tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.username = "qerqwerqrwqwerwq"
+ self.user = User.objects.create(username=self.username, email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-delete",
+ slug="test-delete",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserDeleteStage.objects.create(name="delete")
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_no_user(self):
+ """Test without user set"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_user_delete_get(self):
+ """Test Form render"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_user_delete_post(self):
+ """Test User delete (actual)"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ {},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertFalse(User.objects.filter(username=self.username).exists())
diff --git a/passbook/stages/user_login/__init__.py b/passbook/stages/user_login/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_login/api.py b/passbook/stages/user_login/api.py
new file mode 100644
index 000000000..19188172e
--- /dev/null
+++ b/passbook/stages/user_login/api.py
@@ -0,0 +1,24 @@
+"""Login Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.user_login.models import UserLoginStage
+
+
+class UserLoginStageSerializer(ModelSerializer):
+ """UserLoginStage Serializer"""
+
+ class Meta:
+
+ model = UserLoginStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class UserLoginStageViewSet(ModelViewSet):
+ """UserLoginStage Viewset"""
+
+ queryset = UserLoginStage.objects.all()
+ serializer_class = UserLoginStageSerializer
diff --git a/passbook/stages/user_login/apps.py b/passbook/stages/user_login/apps.py
new file mode 100644
index 000000000..a032dfd72
--- /dev/null
+++ b/passbook/stages/user_login/apps.py
@@ -0,0 +1,10 @@
+"""passbook login stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStageUserLoginConfig(AppConfig):
+ """passbook login stage config"""
+
+ name = "passbook.stages.user_login"
+ label = "passbook_stages_user_login"
+ verbose_name = "passbook Stages.User Login"
diff --git a/passbook/stages/user_login/forms.py b/passbook/stages/user_login/forms.py
new file mode 100644
index 000000000..e5f26fb71
--- /dev/null
+++ b/passbook/stages/user_login/forms.py
@@ -0,0 +1,16 @@
+"""passbook flows login forms"""
+from django import forms
+
+from passbook.stages.user_login.models import UserLoginStage
+
+
+class UserLoginStageForm(forms.ModelForm):
+ """Form to create/edit UserLoginStage instances"""
+
+ class Meta:
+
+ model = UserLoginStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/passbook/stages/user_login/migrations/0001_initial.py b/passbook/stages/user_login/migrations/0001_initial.py
new file mode 100644
index 000000000..5341ba28c
--- /dev/null
+++ b/passbook/stages/user_login/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.5 on 2020-05-10 14:03
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserLoginStage",
+ 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",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Login Stage",
+ "verbose_name_plural": "User Login Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/user_login/migrations/__init__.py b/passbook/stages/user_login/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_login/models.py b/passbook/stages/user_login/models.py
new file mode 100644
index 000000000..18a5bf4aa
--- /dev/null
+++ b/passbook/stages/user_login/models.py
@@ -0,0 +1,19 @@
+"""login stage models"""
+from django.utils.translation import gettext_lazy as _
+
+from passbook.flows.models import Stage
+
+
+class UserLoginStage(Stage):
+ """Login stage, allows a user to identify themselves to authenticate."""
+
+ type = "passbook.stages.user_login.stage.UserLoginStageView"
+ form = "passbook.stages.user_login.forms.UserLoginStageForm"
+
+ def __str__(self):
+ return f"User Login Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Login Stage")
+ verbose_name_plural = _("User Login Stages")
diff --git a/passbook/stages/user_login/stage.py b/passbook/stages/user_login/stage.py
new file mode 100644
index 000000000..6b3c85969
--- /dev/null
+++ b/passbook/stages/user_login/stage.py
@@ -0,0 +1,40 @@
+"""Login stage logic"""
+from django.contrib import messages
+from django.contrib.auth import login
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from structlog import get_logger
+
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+
+LOGGER = get_logger()
+
+
+class UserLoginStageView(AuthenticationStage):
+ """Finalise Authentication flow by logging the user in"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ message = _("No Pending user to login.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ if PLAN_CONTEXT_AUTHENTICATION_BACKEND not in self.executor.plan.context:
+ message = _("Pending user has no backend.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ backend = self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND]
+ login(
+ self.request,
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
+ backend=backend,
+ )
+ LOGGER.debug(
+ "Logged in",
+ user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
+ flow_slug=self.executor.flow.slug,
+ )
+ return self.executor.stage_ok()
diff --git a/passbook/stages/user_login/tests.py b/passbook/stages/user_login/tests.py
new file mode 100644
index 000000000..59b988d2b
--- /dev/null
+++ b/passbook/stages/user_login/tests.py
@@ -0,0 +1,83 @@
+"""login tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from passbook.stages.user_login.forms import UserLoginStageForm
+from passbook.stages.user_login.models import UserLoginStage
+
+
+class TestUserLoginStage(TestCase):
+ """Login tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-login",
+ slug="test-login",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserLoginStage.objects.create(name="login")
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_valid_password(self):
+ """Test with a valid pending user and backend"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ def test_without_user(self):
+ """Test a plan without any pending user, resulting in a denied"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_without_backend(self):
+ """Test a plan with pending user, without backend, resulting in a denied"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(UserLoginStageForm(data).is_valid(), True)
diff --git a/passbook/stages/user_logout/__init__.py b/passbook/stages/user_logout/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_logout/api.py b/passbook/stages/user_logout/api.py
new file mode 100644
index 000000000..4200a36d9
--- /dev/null
+++ b/passbook/stages/user_logout/api.py
@@ -0,0 +1,24 @@
+"""Logout Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.user_logout.models import UserLogoutStage
+
+
+class UserLogoutStageSerializer(ModelSerializer):
+ """UserLogoutStage Serializer"""
+
+ class Meta:
+
+ model = UserLogoutStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class UserLogoutStageViewSet(ModelViewSet):
+ """UserLogoutStage Viewset"""
+
+ queryset = UserLogoutStage.objects.all()
+ serializer_class = UserLogoutStageSerializer
diff --git a/passbook/stages/user_logout/apps.py b/passbook/stages/user_logout/apps.py
new file mode 100644
index 000000000..7d7c0ee80
--- /dev/null
+++ b/passbook/stages/user_logout/apps.py
@@ -0,0 +1,10 @@
+"""passbook logout stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStageUserLogoutConfig(AppConfig):
+ """passbook logout stage config"""
+
+ name = "passbook.stages.user_logout"
+ label = "passbook_stages_user_logout"
+ verbose_name = "passbook Stages.User Logout"
diff --git a/passbook/stages/user_logout/forms.py b/passbook/stages/user_logout/forms.py
new file mode 100644
index 000000000..a2e879835
--- /dev/null
+++ b/passbook/stages/user_logout/forms.py
@@ -0,0 +1,16 @@
+"""passbook flows logout forms"""
+from django import forms
+
+from passbook.stages.user_logout.models import UserLogoutStage
+
+
+class UserLogoutStageForm(forms.ModelForm):
+ """Form to create/edit UserLogoutStage instances"""
+
+ class Meta:
+
+ model = UserLogoutStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/passbook/stages/user_logout/migrations/0001_initial.py b/passbook/stages/user_logout/migrations/0001_initial.py
new file mode 100644
index 000000000..53456156e
--- /dev/null
+++ b/passbook/stages/user_logout/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.5 on 2020-05-10 22:56
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserLogoutStage",
+ 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",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Logout Stage",
+ "verbose_name_plural": "User Logout Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/user_logout/migrations/__init__.py b/passbook/stages/user_logout/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_logout/models.py b/passbook/stages/user_logout/models.py
new file mode 100644
index 000000000..55a03983b
--- /dev/null
+++ b/passbook/stages/user_logout/models.py
@@ -0,0 +1,19 @@
+"""logout stage models"""
+from django.utils.translation import gettext_lazy as _
+
+from passbook.flows.models import Stage
+
+
+class UserLogoutStage(Stage):
+ """Logout stage, allows a user to identify themselves to authenticate."""
+
+ type = "passbook.stages.user_logout.stage.UserLogoutStageView"
+ form = "passbook.stages.user_logout.forms.UserLogoutStageForm"
+
+ def __str__(self):
+ return f"User Logout Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Logout Stage")
+ verbose_name_plural = _("User Logout Stages")
diff --git a/passbook/stages/user_logout/stage.py b/passbook/stages/user_logout/stage.py
new file mode 100644
index 000000000..ef360b3ed
--- /dev/null
+++ b/passbook/stages/user_logout/stage.py
@@ -0,0 +1,22 @@
+"""Logout stage logic"""
+from django.contrib.auth import logout
+from django.http import HttpRequest, HttpResponse
+from structlog import get_logger
+
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+
+LOGGER = get_logger()
+
+
+class UserLogoutStageView(AuthenticationStage):
+ """Finalise Authentication flow by logging the user in"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ logout(self.request)
+ LOGGER.debug(
+ "Logged out",
+ user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
+ flow_slug=self.executor.flow.slug,
+ )
+ return self.executor.stage_ok()
diff --git a/passbook/stages/user_logout/tests.py b/passbook/stages/user_logout/tests.py
new file mode 100644
index 000000000..6f5a309e4
--- /dev/null
+++ b/passbook/stages/user_logout/tests.py
@@ -0,0 +1,52 @@
+"""logout tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from passbook.stages.user_logout.forms import UserLogoutStageForm
+from passbook.stages.user_logout.models import UserLogoutStage
+
+
+class TestUserLogoutStage(TestCase):
+ """Logout tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-logout",
+ slug="test-logout",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserLogoutStage.objects.create(name="logout")
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_valid_password(self):
+ """Test with a valid pending user and backend"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(UserLogoutStageForm(data).is_valid(), True)
diff --git a/passbook/stages/user_write/__init__.py b/passbook/stages/user_write/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_write/api.py b/passbook/stages/user_write/api.py
new file mode 100644
index 000000000..8e32a36ae
--- /dev/null
+++ b/passbook/stages/user_write/api.py
@@ -0,0 +1,24 @@
+"""User Write Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from passbook.stages.user_write.models import UserWriteStage
+
+
+class UserWriteStageSerializer(ModelSerializer):
+ """UserWriteStage Serializer"""
+
+ class Meta:
+
+ model = UserWriteStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class UserWriteStageViewSet(ModelViewSet):
+ """UserWriteStage Viewset"""
+
+ queryset = UserWriteStage.objects.all()
+ serializer_class = UserWriteStageSerializer
diff --git a/passbook/stages/user_write/apps.py b/passbook/stages/user_write/apps.py
new file mode 100644
index 000000000..f2dceb575
--- /dev/null
+++ b/passbook/stages/user_write/apps.py
@@ -0,0 +1,10 @@
+"""passbook write stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStageUserWriteConfig(AppConfig):
+ """passbook write stage config"""
+
+ name = "passbook.stages.user_write"
+ label = "passbook_stages_user_write"
+ verbose_name = "passbook Stages.User Write"
diff --git a/passbook/stages/user_write/forms.py b/passbook/stages/user_write/forms.py
new file mode 100644
index 000000000..f4e00d8ae
--- /dev/null
+++ b/passbook/stages/user_write/forms.py
@@ -0,0 +1,16 @@
+"""passbook flows write forms"""
+from django import forms
+
+from passbook.stages.user_write.models import UserWriteStage
+
+
+class UserWriteStageForm(forms.ModelForm):
+ """Form to write/edit UserWriteStage instances"""
+
+ class Meta:
+
+ model = UserWriteStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/passbook/stages/user_write/migrations/0001_initial.py b/passbook/stages/user_write/migrations/0001_initial.py
new file mode 100644
index 000000000..177866967
--- /dev/null
+++ b/passbook/stages/user_write/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.5 on 2020-05-10 21:21
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0003_auto_20200509_1258"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserWriteStage",
+ 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",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Write Stage",
+ "verbose_name_plural": "User Write Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/user_write/migrations/__init__.py b/passbook/stages/user_write/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/user_write/models.py b/passbook/stages/user_write/models.py
new file mode 100644
index 000000000..078b4e174
--- /dev/null
+++ b/passbook/stages/user_write/models.py
@@ -0,0 +1,19 @@
+"""write stage models"""
+from django.utils.translation import gettext_lazy as _
+
+from passbook.flows.models import Stage
+
+
+class UserWriteStage(Stage):
+ """Write stage, write a user from saved data."""
+
+ type = "passbook.stages.user_write.stage.UserWriteStageView"
+ form = "passbook.stages.user_write.forms.UserWriteStageForm"
+
+ def __str__(self):
+ return f"User Write Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Write Stage")
+ verbose_name_plural = _("User Write Stages")
diff --git a/passbook/stages/user_write/stage.py b/passbook/stages/user_write/stage.py
new file mode 100644
index 000000000..20c689064
--- /dev/null
+++ b/passbook/stages/user_write/stage.py
@@ -0,0 +1,52 @@
+"""Write stage logic"""
+from django.contrib import messages
+from django.contrib.auth.backends import ModelBackend
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from structlog import get_logger
+
+from passbook.core.models import User
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
+from passbook.flows.stage import AuthenticationStage
+from passbook.lib.utils.reflection import class_to_path
+from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+LOGGER = get_logger()
+
+
+class UserWriteStageView(AuthenticationStage):
+ """Finalise Enrollment flow by creating a user object."""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ if PLAN_CONTEXT_PROMPT not in self.executor.plan.context:
+ message = _("No Pending data.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
+ if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
+ user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ for key, value in data.items():
+ setter_name = f"set_{key}"
+ if hasattr(user, setter_name):
+ setter = getattr(user, setter_name)
+ if callable(setter):
+ setter(value)
+ else:
+ setattr(user, key, value)
+ user.save()
+ LOGGER.debug(
+ "Updated existing user", user=user, flow_slug=self.executor.flow.slug,
+ )
+ else:
+ user = User.objects.create_user(**data)
+ # Set created user as pending_user, so this can be chained with user_login
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
+ self.executor.plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = class_to_path(ModelBackend)
+ LOGGER.debug(
+ "Created new user", user=user, flow_slug=self.executor.flow.slug,
+ )
+ return self.executor.stage_ok()
diff --git a/passbook/stages/user_write/tests.py b/passbook/stages/user_write/tests.py
new file mode 100644
index 000000000..5bad06809
--- /dev/null
+++ b/passbook/stages/user_write/tests.py
@@ -0,0 +1,110 @@
+"""write tests"""
+import string
+from random import SystemRandom
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+from passbook.stages.user_write.forms import UserWriteStageForm
+from passbook.stages.user_write.models import UserWriteStage
+
+
+class TestUserWriteStage(TestCase):
+ """Write tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-write",
+ slug="test-write",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserWriteStage.objects.create(name="write")
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_user_create(self):
+ """Test creation of user"""
+ password = "".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ )
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PROMPT] = {
+ "username": "test-user",
+ "name": "name",
+ "email": "test@beryju.org",
+ "password": password,
+ }
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ user_qs = User.objects.filter(
+ username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
+ )
+ self.assertTrue(user_qs.exists())
+ self.assertTrue(user_qs.first().check_password(password))
+
+ def test_user_update(self):
+ """Test update of existing user"""
+ new_password = "".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ )
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create(
+ username="unittest", email="test@beryju.org"
+ )
+ plan.context[PLAN_CONTEXT_PROMPT] = {
+ "username": "test-user-new",
+ "password": new_password,
+ }
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ user_qs = User.objects.filter(
+ username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
+ )
+ self.assertTrue(user_qs.exists())
+ self.assertTrue(user_qs.first().check_password(new_password))
+
+ def test_without_data(self):
+ """Test without data results in error"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(UserWriteStageForm(data).is_valid(), True)
diff --git a/passbook/static/static/passbook/pf.js b/passbook/static/static/passbook/pf.js
index 99f8609cd..4a8780884 100644
--- a/passbook/static/static/passbook/pf.js
+++ b/passbook/static/static/passbook/pf.js
@@ -41,8 +41,8 @@ document.querySelectorAll(".codemirror").forEach((cm) => {
const convertToSlug = (text) => {
return text
.toLowerCase()
- .replace(/[^\w ]+/g, '')
- .replace(/ +/g, '-');
+ .replace(/ /g, '-')
+ .replace(/[^\w-]+/g, '');
};
document.querySelectorAll("input[name=name]").forEach((input) => {
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 95021473a..feba22bda 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1,3 +1,10 @@
{
- "reportMissingTypeStubs": false
+ "reportMissingTypeStubs": false,
+ "ignore": [
+ "**/migrations/**",
+ "**/node_modules/**"
+ ],
+ "strictParameterNoneValue": true,
+ "strictDictionaryInference": true,
+ "strictListInference": true
}
diff --git a/scripts/coverage.sh b/scripts/coverage.sh
index 61f10d820..c95d47574 100755
--- a/scripts/coverage.sh
+++ b/scripts/coverage.sh
@@ -1,5 +1,5 @@
#!/bin/bash -xe
-coverage run --concurrency=multiprocessing manage.py test
+coverage run --concurrency=multiprocessing manage.py test --failfast
coverage combine
coverage html
coverage report
diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh
index e97bac37c..2960f8976 100755
--- a/scripts/pre-commit.sh
+++ b/scripts/pre-commit.sh
@@ -6,3 +6,4 @@ scripts/coverage.sh
bandit -r passbook
pylint passbook
prospector
+./manage.py generate_swagger -o swagger.yaml -f yaml
diff --git a/swagger.yaml b/swagger.yaml
new file mode 100755
index 000000000..7681d5484
--- /dev/null
+++ b/swagger.yaml
@@ -0,0 +1,6319 @@
+swagger: '2.0'
+info:
+ title: passbook API
+ contact:
+ email: hello@beryju.org
+ license:
+ name: MIT License
+ version: v2
+basePath: /api/v2
+consumes:
+ - application/json
+produces:
+ - application/json
+securityDefinitions:
+ Basic:
+ type: basic
+security:
+ - Basic: []
+paths:
+ /audit/events/:
+ get:
+ operationId: audit_events_list
+ description: Event Read-Only 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/Event'
+ tags:
+ - audit
+ parameters: []
+ /audit/events/{uuid}/:
+ get:
+ operationId: audit_events_read
+ description: Event Read-Only Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Event'
+ tags:
+ - audit
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Audit Event.
+ required: true
+ type: string
+ format: uuid
+ /core/applications/:
+ get:
+ operationId: core_applications_list
+ description: Application 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/Application'
+ tags:
+ - core
+ post:
+ operationId: core_applications_create
+ description: Application Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Application'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/Application'
+ tags:
+ - core
+ parameters: []
+ /core/applications/{uuid}/:
+ get:
+ operationId: core_applications_read
+ description: Application Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Application'
+ tags:
+ - core
+ put:
+ operationId: core_applications_update
+ description: Application Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Application'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Application'
+ tags:
+ - core
+ patch:
+ operationId: core_applications_partial_update
+ description: Application Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Application'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Application'
+ tags:
+ - core
+ delete:
+ operationId: core_applications_delete
+ description: Application Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - core
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this application.
+ required: true
+ type: string
+ format: uuid
+ /core/groups/:
+ get:
+ operationId: core_groups_list
+ description: Group 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/Group'
+ tags:
+ - core
+ post:
+ operationId: core_groups_create
+ description: Group Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Group'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/Group'
+ tags:
+ - core
+ parameters: []
+ /core/groups/{uuid}/:
+ get:
+ operationId: core_groups_read
+ description: Group Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Group'
+ tags:
+ - core
+ put:
+ operationId: core_groups_update
+ description: Group Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Group'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Group'
+ tags:
+ - core
+ patch:
+ operationId: core_groups_partial_update
+ description: Group Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Group'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Group'
+ tags:
+ - core
+ delete:
+ operationId: core_groups_delete
+ description: Group Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - core
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this group.
+ required: true
+ type: string
+ format: uuid
+ /core/users/:
+ get:
+ operationId: core_users_list
+ description: User 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/User'
+ tags:
+ - core
+ post:
+ operationId: core_users_create
+ description: User Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/User'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/User'
+ tags:
+ - core
+ parameters: []
+ /core/users/{id}/:
+ get:
+ operationId: core_users_read
+ description: User Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/User'
+ tags:
+ - core
+ put:
+ operationId: core_users_update
+ description: User Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/User'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/User'
+ tags:
+ - core
+ patch:
+ operationId: core_users_partial_update
+ description: User Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/User'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/User'
+ tags:
+ - core
+ delete:
+ operationId: core_users_delete
+ description: User Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - core
+ parameters:
+ - name: id
+ in: path
+ description: A unique integer value identifying this user.
+ required: true
+ type: integer
+ /flows/bindings/:
+ get:
+ operationId: flows_bindings_list
+ description: FlowStageBinding Viewset
+ parameters:
+ - name: uuid
+ in: query
+ description: ''
+ required: false
+ type: string
+ - name: policies
+ in: query
+ description: ''
+ required: false
+ type: string
+ - name: flow
+ in: query
+ description: ''
+ required: false
+ type: string
+ - name: stage
+ in: query
+ description: ''
+ required: false
+ type: string
+ - name: re_evaluate_policies
+ in: query
+ description: ''
+ required: false
+ type: string
+ - name: order
+ in: query
+ description: ''
+ required: false
+ type: number
+ - 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/FlowStageBinding'
+ tags:
+ - flows
+ post:
+ operationId: flows_bindings_create
+ description: FlowStageBinding Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/FlowStageBinding'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/FlowStageBinding'
+ tags:
+ - flows
+ parameters: []
+ /flows/bindings/{uuid}/:
+ get:
+ operationId: flows_bindings_read
+ description: FlowStageBinding Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/FlowStageBinding'
+ tags:
+ - flows
+ put:
+ operationId: flows_bindings_update
+ description: FlowStageBinding Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/FlowStageBinding'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/FlowStageBinding'
+ tags:
+ - flows
+ patch:
+ operationId: flows_bindings_partial_update
+ description: FlowStageBinding Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/FlowStageBinding'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/FlowStageBinding'
+ tags:
+ - flows
+ delete:
+ operationId: flows_bindings_delete
+ description: FlowStageBinding Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - flows
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Flow Stage Binding.
+ required: true
+ type: string
+ format: uuid
+ /flows/instances/:
+ get:
+ operationId: flows_instances_list
+ description: Flow 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/Flow'
+ tags:
+ - flows
+ post:
+ operationId: flows_instances_create
+ description: Flow Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Flow'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/Flow'
+ tags:
+ - flows
+ parameters: []
+ /flows/instances/{uuid}/:
+ get:
+ operationId: flows_instances_read
+ description: Flow Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Flow'
+ tags:
+ - flows
+ put:
+ operationId: flows_instances_update
+ description: Flow Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Flow'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Flow'
+ tags:
+ - flows
+ patch:
+ operationId: flows_instances_partial_update
+ description: Flow Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Flow'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Flow'
+ tags:
+ - flows
+ delete:
+ operationId: flows_instances_delete
+ description: Flow Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - flows
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Flow.
+ required: true
+ type: string
+ format: uuid
+ /policies/all/:
+ get:
+ operationId: policies_all_list
+ description: Policy 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/Policy'
+ tags:
+ - policies
+ parameters: []
+ /policies/all/{uuid}/:
+ get:
+ operationId: policies_all_read
+ description: Policy Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Policy'
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this policy.
+ required: true
+ type: string
+ format: uuid
+ /policies/bindings/:
+ get:
+ operationId: policies_bindings_list
+ description: PolicyBinding 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/PolicyBinding'
+ tags:
+ - policies
+ post:
+ operationId: policies_bindings_create
+ description: PolicyBinding Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PolicyBinding'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/PolicyBinding'
+ tags:
+ - policies
+ parameters: []
+ /policies/bindings/{uuid}/:
+ get:
+ operationId: policies_bindings_read
+ description: PolicyBinding Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PolicyBinding'
+ tags:
+ - policies
+ put:
+ operationId: policies_bindings_update
+ description: PolicyBinding Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PolicyBinding'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PolicyBinding'
+ tags:
+ - policies
+ patch:
+ operationId: policies_bindings_partial_update
+ description: PolicyBinding Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PolicyBinding'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PolicyBinding'
+ tags:
+ - policies
+ delete:
+ operationId: policies_bindings_delete
+ description: PolicyBinding Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Policy Binding.
+ required: true
+ type: string
+ format: uuid
+ /policies/dummy/:
+ get:
+ operationId: policies_dummy_list
+ description: Dummy 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/DummyPolicy'
+ tags:
+ - policies
+ post:
+ operationId: policies_dummy_create
+ description: Dummy Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/DummyPolicy'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyPolicy'
+ tags:
+ - policies
+ parameters: []
+ /policies/dummy/{uuid}/:
+ get:
+ operationId: policies_dummy_read
+ description: Dummy Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyPolicy'
+ tags:
+ - policies
+ put:
+ operationId: policies_dummy_update
+ description: Dummy Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/DummyPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyPolicy'
+ tags:
+ - policies
+ patch:
+ operationId: policies_dummy_partial_update
+ description: Dummy Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/DummyPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyPolicy'
+ tags:
+ - policies
+ delete:
+ operationId: policies_dummy_delete
+ description: Dummy Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Dummy Policy.
+ required: true
+ type: string
+ format: uuid
+ /policies/expression/:
+ get:
+ operationId: policies_expression_list
+ description: Source 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/ExpressionPolicy'
+ tags:
+ - policies
+ post:
+ operationId: policies_expression_create
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ExpressionPolicy'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/ExpressionPolicy'
+ tags:
+ - policies
+ parameters: []
+ /policies/expression/{uuid}/:
+ get:
+ operationId: policies_expression_read
+ description: Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ExpressionPolicy'
+ tags:
+ - policies
+ put:
+ operationId: policies_expression_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ExpressionPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ExpressionPolicy'
+ tags:
+ - policies
+ patch:
+ operationId: policies_expression_partial_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ExpressionPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ExpressionPolicy'
+ tags:
+ - policies
+ delete:
+ operationId: policies_expression_delete
+ description: Source Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Expression Policy.
+ required: true
+ type: string
+ format: uuid
+ /policies/haveibeenpwned/:
+ get:
+ operationId: policies_haveibeenpwned_list
+ description: Source 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/HaveIBeenPwendPolicy'
+ tags:
+ - policies
+ post:
+ operationId: policies_haveibeenpwned_create
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/HaveIBeenPwendPolicy'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/HaveIBeenPwendPolicy'
+ tags:
+ - policies
+ parameters: []
+ /policies/haveibeenpwned/{uuid}/:
+ get:
+ operationId: policies_haveibeenpwned_read
+ description: Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/HaveIBeenPwendPolicy'
+ tags:
+ - policies
+ put:
+ operationId: policies_haveibeenpwned_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/HaveIBeenPwendPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/HaveIBeenPwendPolicy'
+ tags:
+ - policies
+ patch:
+ operationId: policies_haveibeenpwned_partial_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/HaveIBeenPwendPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/HaveIBeenPwendPolicy'
+ tags:
+ - policies
+ delete:
+ operationId: policies_haveibeenpwned_delete
+ description: Source Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Have I Been Pwned Policy.
+ required: true
+ type: string
+ format: uuid
+ /policies/password/:
+ get:
+ operationId: policies_password_list
+ description: Source 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/PasswordPolicy'
+ tags:
+ - policies
+ post:
+ operationId: policies_password_create
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordPolicy'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordPolicy'
+ tags:
+ - policies
+ parameters: []
+ /policies/password/{uuid}/:
+ get:
+ operationId: policies_password_read
+ description: Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordPolicy'
+ tags:
+ - policies
+ put:
+ operationId: policies_password_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordPolicy'
+ tags:
+ - policies
+ patch:
+ operationId: policies_password_partial_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordPolicy'
+ tags:
+ - policies
+ delete:
+ operationId: policies_password_delete
+ description: Source Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Password Policy.
+ required: true
+ type: string
+ format: uuid
+ /policies/passwordexpiry/:
+ get:
+ operationId: policies_passwordexpiry_list
+ description: Password Expiry 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/PasswordExpiryPolicy'
+ tags:
+ - policies
+ post:
+ operationId: policies_passwordexpiry_create
+ description: Password Expiry Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordExpiryPolicy'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordExpiryPolicy'
+ tags:
+ - policies
+ parameters: []
+ /policies/passwordexpiry/{uuid}/:
+ get:
+ operationId: policies_passwordexpiry_read
+ description: Password Expiry Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordExpiryPolicy'
+ tags:
+ - policies
+ put:
+ operationId: policies_passwordexpiry_update
+ description: Password Expiry Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordExpiryPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordExpiryPolicy'
+ tags:
+ - policies
+ patch:
+ operationId: policies_passwordexpiry_partial_update
+ description: Password Expiry Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordExpiryPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordExpiryPolicy'
+ tags:
+ - policies
+ delete:
+ operationId: policies_passwordexpiry_delete
+ description: Password Expiry Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Password Expiry Policy.
+ required: true
+ type: string
+ format: uuid
+ /policies/reputation/:
+ get:
+ operationId: policies_reputation_list
+ description: Source 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/ReputationPolicy'
+ tags:
+ - policies
+ post:
+ operationId: policies_reputation_create
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ReputationPolicy'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/ReputationPolicy'
+ tags:
+ - policies
+ parameters: []
+ /policies/reputation/{uuid}/:
+ get:
+ operationId: policies_reputation_read
+ description: Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ReputationPolicy'
+ tags:
+ - policies
+ put:
+ operationId: policies_reputation_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ReputationPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ReputationPolicy'
+ tags:
+ - policies
+ patch:
+ operationId: policies_reputation_partial_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ReputationPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ReputationPolicy'
+ tags:
+ - policies
+ delete:
+ operationId: policies_reputation_delete
+ description: Source Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Reputation Policy.
+ required: true
+ type: string
+ format: uuid
+ /policies/webhook/:
+ get:
+ operationId: policies_webhook_list
+ description: Source 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/WebhookPolicy'
+ tags:
+ - policies
+ post:
+ operationId: policies_webhook_create
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/WebhookPolicy'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/WebhookPolicy'
+ tags:
+ - policies
+ parameters: []
+ /policies/webhook/{uuid}/:
+ get:
+ operationId: policies_webhook_read
+ description: Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/WebhookPolicy'
+ tags:
+ - policies
+ put:
+ operationId: policies_webhook_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/WebhookPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/WebhookPolicy'
+ tags:
+ - policies
+ patch:
+ operationId: policies_webhook_partial_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/WebhookPolicy'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/WebhookPolicy'
+ tags:
+ - policies
+ delete:
+ operationId: policies_webhook_delete
+ description: Source Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - policies
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Webhook Policy.
+ required: true
+ type: string
+ format: uuid
+ /propertymappings/all/:
+ get:
+ operationId: propertymappings_all_list
+ description: PropertyMapping 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/PropertyMapping'
+ tags:
+ - propertymappings
+ parameters: []
+ /propertymappings/all/{uuid}/:
+ get:
+ operationId: propertymappings_all_read
+ description: PropertyMapping Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PropertyMapping'
+ tags:
+ - propertymappings
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Property Mapping.
+ required: true
+ type: string
+ format: uuid
+ /propertymappings/ldap/:
+ get:
+ operationId: propertymappings_ldap_list
+ description: LDAP PropertyMapping 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/LDAPPropertyMapping'
+ tags:
+ - propertymappings
+ post:
+ operationId: propertymappings_ldap_create
+ description: LDAP PropertyMapping Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/LDAPPropertyMapping'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPPropertyMapping'
+ tags:
+ - propertymappings
+ parameters: []
+ /propertymappings/ldap/{uuid}/:
+ get:
+ operationId: propertymappings_ldap_read
+ description: LDAP PropertyMapping Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPPropertyMapping'
+ tags:
+ - propertymappings
+ put:
+ operationId: propertymappings_ldap_update
+ description: LDAP PropertyMapping Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/LDAPPropertyMapping'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPPropertyMapping'
+ tags:
+ - propertymappings
+ patch:
+ operationId: propertymappings_ldap_partial_update
+ description: LDAP PropertyMapping Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/LDAPPropertyMapping'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPPropertyMapping'
+ tags:
+ - propertymappings
+ delete:
+ operationId: propertymappings_ldap_delete
+ description: LDAP PropertyMapping Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - propertymappings
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this LDAP Property Mapping.
+ required: true
+ type: string
+ format: uuid
+ /propertymappings/saml/:
+ get:
+ operationId: propertymappings_saml_list
+ description: SAMLPropertyMapping 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/SAMLPropertyMapping'
+ tags:
+ - propertymappings
+ post:
+ operationId: propertymappings_saml_create
+ description: SAMLPropertyMapping Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/SAMLPropertyMapping'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLPropertyMapping'
+ tags:
+ - propertymappings
+ parameters: []
+ /propertymappings/saml/{uuid}/:
+ get:
+ operationId: propertymappings_saml_read
+ description: SAMLPropertyMapping Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLPropertyMapping'
+ tags:
+ - propertymappings
+ put:
+ operationId: propertymappings_saml_update
+ description: SAMLPropertyMapping Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/SAMLPropertyMapping'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLPropertyMapping'
+ tags:
+ - propertymappings
+ patch:
+ operationId: propertymappings_saml_partial_update
+ description: SAMLPropertyMapping Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/SAMLPropertyMapping'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLPropertyMapping'
+ tags:
+ - propertymappings
+ delete:
+ operationId: propertymappings_saml_delete
+ description: SAMLPropertyMapping Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - propertymappings
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this SAML Property Mapping.
+ required: true
+ type: string
+ format: uuid
+ /providers/all/:
+ get:
+ operationId: providers_all_list
+ description: Provider 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/Provider'
+ tags:
+ - providers
+ parameters: []
+ /providers/all/{id}/:
+ get:
+ operationId: providers_all_read
+ description: Provider Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Provider'
+ tags:
+ - providers
+ parameters:
+ - name: id
+ in: path
+ description: A unique integer value identifying this provider.
+ required: true
+ type: integer
+ /providers/applicationgateway/:
+ get:
+ operationId: providers_applicationgateway_list
+ description: ApplicationGatewayProvider 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/ApplicationGatewayProvider'
+ tags:
+ - providers
+ post:
+ operationId: providers_applicationgateway_create
+ description: ApplicationGatewayProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ApplicationGatewayProvider'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/ApplicationGatewayProvider'
+ tags:
+ - providers
+ parameters: []
+ /providers/applicationgateway/{id}/:
+ get:
+ operationId: providers_applicationgateway_read
+ description: ApplicationGatewayProvider Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ApplicationGatewayProvider'
+ tags:
+ - providers
+ put:
+ operationId: providers_applicationgateway_update
+ description: ApplicationGatewayProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ApplicationGatewayProvider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ApplicationGatewayProvider'
+ tags:
+ - providers
+ patch:
+ operationId: providers_applicationgateway_partial_update
+ description: ApplicationGatewayProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/ApplicationGatewayProvider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/ApplicationGatewayProvider'
+ tags:
+ - providers
+ delete:
+ operationId: providers_applicationgateway_delete
+ description: ApplicationGatewayProvider Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - providers
+ parameters:
+ - name: id
+ in: path
+ description: A unique integer value identifying this Application Gateway Provider.
+ required: true
+ type: integer
+ /providers/oauth/:
+ get:
+ operationId: providers_oauth_list
+ description: OAuth2Provider 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/OAuth2Provider'
+ tags:
+ - providers
+ post:
+ operationId: providers_oauth_create
+ description: OAuth2Provider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OAuth2Provider'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuth2Provider'
+ tags:
+ - providers
+ parameters: []
+ /providers/oauth/{id}/:
+ get:
+ operationId: providers_oauth_read
+ description: OAuth2Provider Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuth2Provider'
+ tags:
+ - providers
+ put:
+ operationId: providers_oauth_update
+ description: OAuth2Provider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OAuth2Provider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuth2Provider'
+ tags:
+ - providers
+ patch:
+ operationId: providers_oauth_partial_update
+ description: OAuth2Provider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OAuth2Provider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuth2Provider'
+ tags:
+ - providers
+ delete:
+ operationId: providers_oauth_delete
+ description: OAuth2Provider Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - providers
+ parameters:
+ - name: id
+ in: path
+ description: A unique integer value identifying this OAuth2 Provider.
+ required: true
+ type: integer
+ /providers/openid/:
+ get:
+ operationId: providers_openid_list
+ description: OpenIDProvider 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/OpenIDProvider'
+ tags:
+ - providers
+ post:
+ operationId: providers_openid_create
+ description: OpenIDProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OpenIDProvider'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/OpenIDProvider'
+ tags:
+ - providers
+ parameters: []
+ /providers/openid/{id}/:
+ get:
+ operationId: providers_openid_read
+ description: OpenIDProvider Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OpenIDProvider'
+ tags:
+ - providers
+ put:
+ operationId: providers_openid_update
+ description: OpenIDProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OpenIDProvider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OpenIDProvider'
+ tags:
+ - providers
+ patch:
+ operationId: providers_openid_partial_update
+ description: OpenIDProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OpenIDProvider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OpenIDProvider'
+ tags:
+ - providers
+ delete:
+ operationId: providers_openid_delete
+ description: OpenIDProvider Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - providers
+ parameters:
+ - name: id
+ in: path
+ description: A unique integer value identifying this Client.
+ required: true
+ type: integer
+ /providers/saml/:
+ get:
+ operationId: providers_saml_list
+ description: SAMLProvider 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/SAMLProvider'
+ tags:
+ - providers
+ post:
+ operationId: providers_saml_create
+ description: SAMLProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/SAMLProvider'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLProvider'
+ tags:
+ - providers
+ parameters: []
+ /providers/saml/{id}/:
+ get:
+ operationId: providers_saml_read
+ description: SAMLProvider Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLProvider'
+ tags:
+ - providers
+ put:
+ operationId: providers_saml_update
+ description: SAMLProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/SAMLProvider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLProvider'
+ tags:
+ - providers
+ patch:
+ operationId: providers_saml_partial_update
+ description: SAMLProvider Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/SAMLProvider'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/SAMLProvider'
+ tags:
+ - providers
+ delete:
+ operationId: providers_saml_delete
+ description: SAMLProvider Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - providers
+ parameters:
+ - name: id
+ in: path
+ description: A unique integer value identifying this SAML Provider.
+ required: true
+ type: integer
+ /sources/all/:
+ get:
+ operationId: sources_all_list
+ description: Source 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/Source'
+ tags:
+ - sources
+ parameters: []
+ /sources/all/{uuid}/:
+ get:
+ operationId: sources_all_read
+ description: Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Source'
+ tags:
+ - sources
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this source.
+ required: true
+ type: string
+ format: uuid
+ /sources/ldap/:
+ get:
+ operationId: sources_ldap_list
+ description: LDAP Source 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/LDAPSource'
+ tags:
+ - sources
+ post:
+ operationId: sources_ldap_create
+ description: LDAP Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/LDAPSource'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPSource'
+ tags:
+ - sources
+ parameters: []
+ /sources/ldap/{uuid}/:
+ get:
+ operationId: sources_ldap_read
+ description: LDAP Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPSource'
+ tags:
+ - sources
+ put:
+ operationId: sources_ldap_update
+ description: LDAP Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/LDAPSource'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPSource'
+ tags:
+ - sources
+ patch:
+ operationId: sources_ldap_partial_update
+ description: LDAP Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/LDAPSource'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/LDAPSource'
+ tags:
+ - sources
+ delete:
+ operationId: sources_ldap_delete
+ description: LDAP Source Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - sources
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this LDAP Source.
+ required: true
+ type: string
+ format: uuid
+ /sources/oauth/:
+ get:
+ operationId: sources_oauth_list
+ description: Source 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/OAuthSource'
+ tags:
+ - sources
+ post:
+ operationId: sources_oauth_create
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OAuthSource'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuthSource'
+ tags:
+ - sources
+ parameters: []
+ /sources/oauth/{uuid}/:
+ get:
+ operationId: sources_oauth_read
+ description: Source Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuthSource'
+ tags:
+ - sources
+ put:
+ operationId: sources_oauth_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OAuthSource'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuthSource'
+ tags:
+ - sources
+ patch:
+ operationId: sources_oauth_partial_update
+ description: Source Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OAuthSource'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OAuthSource'
+ tags:
+ - sources
+ delete:
+ operationId: sources_oauth_delete
+ description: Source Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - sources
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Generic OAuth Source.
+ required: true
+ type: string
+ format: uuid
+ /stages/all/:
+ get:
+ operationId: stages_all_list
+ description: Stage 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/Stage'
+ tags:
+ - stages
+ parameters: []
+ /stages/all/{uuid}/:
+ get:
+ operationId: stages_all_read
+ description: Stage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Stage'
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/captcha/:
+ get:
+ operationId: stages_captcha_list
+ description: CaptchaStage 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/CaptchaStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_captcha_create
+ description: CaptchaStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/CaptchaStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/CaptchaStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/captcha/{uuid}/:
+ get:
+ operationId: stages_captcha_read
+ description: CaptchaStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/CaptchaStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_captcha_update
+ description: CaptchaStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/CaptchaStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/CaptchaStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_captcha_partial_update
+ description: CaptchaStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/CaptchaStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/CaptchaStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_captcha_delete
+ description: CaptchaStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Captcha Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/dummy/:
+ get:
+ operationId: stages_dummy_list
+ description: DummyStage 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/DummyStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_dummy_create
+ description: DummyStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/DummyStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/dummy/{uuid}/:
+ get:
+ operationId: stages_dummy_read
+ description: DummyStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_dummy_update
+ description: DummyStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/DummyStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_dummy_partial_update
+ description: DummyStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/DummyStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/DummyStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_dummy_delete
+ description: DummyStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Dummy Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/email/:
+ get:
+ operationId: stages_email_list
+ description: EmailStage 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/EmailStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_email_create
+ description: EmailStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/EmailStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/EmailStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/email/{uuid}/:
+ get:
+ operationId: stages_email_read
+ description: EmailStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/EmailStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_email_update
+ description: EmailStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/EmailStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/EmailStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_email_partial_update
+ description: EmailStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/EmailStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/EmailStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_email_delete
+ description: EmailStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Email Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/identification/:
+ get:
+ operationId: stages_identification_list
+ description: IdentificationStage 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/IdentificationStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_identification_create
+ description: IdentificationStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/IdentificationStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/IdentificationStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/identification/{uuid}/:
+ get:
+ operationId: stages_identification_read
+ description: IdentificationStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/IdentificationStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_identification_update
+ description: IdentificationStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/IdentificationStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/IdentificationStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_identification_partial_update
+ description: IdentificationStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/IdentificationStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/IdentificationStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_identification_delete
+ description: IdentificationStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Identification Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/invitation/:
+ get:
+ operationId: stages_invitation_list
+ description: InvitationStage 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/InvitationStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_invitation_create
+ description: InvitationStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/InvitationStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/InvitationStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/invitation/invitations/:
+ get:
+ operationId: stages_invitation_invitations_list
+ description: Invitation 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/Invitation'
+ tags:
+ - stages
+ post:
+ operationId: stages_invitation_invitations_create
+ description: Invitation Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Invitation'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/Invitation'
+ tags:
+ - stages
+ parameters: []
+ /stages/invitation/invitations/{uuid}/:
+ get:
+ operationId: stages_invitation_invitations_read
+ description: Invitation Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Invitation'
+ tags:
+ - stages
+ put:
+ operationId: stages_invitation_invitations_update
+ description: Invitation Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Invitation'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Invitation'
+ tags:
+ - stages
+ patch:
+ operationId: stages_invitation_invitations_partial_update
+ description: Invitation Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Invitation'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Invitation'
+ tags:
+ - stages
+ delete:
+ operationId: stages_invitation_invitations_delete
+ description: Invitation Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Invitation.
+ required: true
+ type: string
+ format: uuid
+ /stages/invitation/{uuid}/:
+ get:
+ operationId: stages_invitation_read
+ description: InvitationStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/InvitationStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_invitation_update
+ description: InvitationStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/InvitationStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/InvitationStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_invitation_partial_update
+ description: InvitationStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/InvitationStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/InvitationStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_invitation_delete
+ description: InvitationStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Invitation Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/otp/:
+ get:
+ operationId: stages_otp_list
+ description: OTPStage 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/OTPStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_otp_create
+ description: OTPStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OTPStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/OTPStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/otp/{uuid}/:
+ get:
+ operationId: stages_otp_read
+ description: OTPStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OTPStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_otp_update
+ description: OTPStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OTPStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OTPStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_otp_partial_update
+ description: OTPStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/OTPStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/OTPStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_otp_delete
+ description: OTPStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this OTP Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/password/:
+ get:
+ operationId: stages_password_list
+ description: PasswordStage 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/PasswordStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_password_create
+ description: PasswordStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/password/{uuid}/:
+ get:
+ operationId: stages_password_read
+ description: PasswordStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_password_update
+ description: PasswordStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_password_partial_update
+ description: PasswordStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PasswordStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PasswordStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_password_delete
+ description: PasswordStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Password Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/prompt/prompts/:
+ get:
+ operationId: stages_prompt_prompts_list
+ description: Prompt 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/Prompt'
+ tags:
+ - stages
+ post:
+ operationId: stages_prompt_prompts_create
+ description: Prompt Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Prompt'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/Prompt'
+ tags:
+ - stages
+ parameters: []
+ /stages/prompt/prompts/{uuid}/:
+ get:
+ operationId: stages_prompt_prompts_read
+ description: Prompt Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Prompt'
+ tags:
+ - stages
+ put:
+ operationId: stages_prompt_prompts_update
+ description: Prompt Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Prompt'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Prompt'
+ tags:
+ - stages
+ patch:
+ operationId: stages_prompt_prompts_partial_update
+ description: Prompt Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/Prompt'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Prompt'
+ tags:
+ - stages
+ delete:
+ operationId: stages_prompt_prompts_delete
+ description: Prompt Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this Prompt.
+ required: true
+ type: string
+ format: uuid
+ /stages/prompt/stages/:
+ get:
+ operationId: stages_prompt_stages_list
+ description: PromptStage 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/PromptStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_prompt_stages_create
+ description: PromptStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PromptStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/PromptStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/prompt/stages/{id}/:
+ get:
+ operationId: stages_prompt_stages_read
+ description: PromptStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PromptStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_prompt_stages_update
+ description: PromptStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PromptStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PromptStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_prompt_stages_partial_update
+ description: PromptStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PromptStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/PromptStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_prompt_stages_delete
+ description: PromptStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: id
+ in: path
+ description: A unique integer value identifying this Prompt Stage.
+ required: true
+ type: integer
+ /stages/user_delete/:
+ get:
+ operationId: stages_user_delete_list
+ description: UserDeleteStage 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/UserDeleteStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_user_delete_create
+ description: UserDeleteStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserDeleteStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserDeleteStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/user_delete/{uuid}/:
+ get:
+ operationId: stages_user_delete_read
+ description: UserDeleteStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserDeleteStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_user_delete_update
+ description: UserDeleteStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserDeleteStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserDeleteStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_user_delete_partial_update
+ description: UserDeleteStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserDeleteStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserDeleteStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_user_delete_delete
+ description: UserDeleteStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this User Delete Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/user_login/:
+ get:
+ operationId: stages_user_login_list
+ description: UserLoginStage 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/UserLoginStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_user_login_create
+ description: UserLoginStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserLoginStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLoginStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/user_login/{uuid}/:
+ get:
+ operationId: stages_user_login_read
+ description: UserLoginStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLoginStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_user_login_update
+ description: UserLoginStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserLoginStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLoginStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_user_login_partial_update
+ description: UserLoginStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserLoginStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLoginStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_user_login_delete
+ description: UserLoginStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this User Login Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/user_logout/:
+ get:
+ operationId: stages_user_logout_list
+ description: UserLogoutStage 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/UserLogoutStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_user_logout_create
+ description: UserLogoutStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserLogoutStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLogoutStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/user_logout/{uuid}/:
+ get:
+ operationId: stages_user_logout_read
+ description: UserLogoutStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLogoutStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_user_logout_update
+ description: UserLogoutStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserLogoutStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLogoutStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_user_logout_partial_update
+ description: UserLogoutStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserLogoutStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserLogoutStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_user_logout_delete
+ description: UserLogoutStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this User Logout Stage.
+ required: true
+ type: string
+ format: uuid
+ /stages/user_write/:
+ get:
+ operationId: stages_user_write_list
+ description: UserWriteStage 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/UserWriteStage'
+ tags:
+ - stages
+ post:
+ operationId: stages_user_write_create
+ description: UserWriteStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserWriteStage'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserWriteStage'
+ tags:
+ - stages
+ parameters: []
+ /stages/user_write/{uuid}/:
+ get:
+ operationId: stages_user_write_read
+ description: UserWriteStage Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserWriteStage'
+ tags:
+ - stages
+ put:
+ operationId: stages_user_write_update
+ description: UserWriteStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserWriteStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserWriteStage'
+ tags:
+ - stages
+ patch:
+ operationId: stages_user_write_partial_update
+ description: UserWriteStage Viewset
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/UserWriteStage'
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/UserWriteStage'
+ tags:
+ - stages
+ delete:
+ operationId: stages_user_write_delete
+ description: UserWriteStage Viewset
+ parameters: []
+ responses:
+ '204':
+ description: ''
+ tags:
+ - stages
+ parameters:
+ - name: uuid
+ in: path
+ description: A UUID string identifying this User Write Stage.
+ required: true
+ type: string
+ format: uuid
+definitions:
+ Event:
+ required:
+ - action
+ - app
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ user:
+ title: User
+ type: integer
+ x-nullable: true
+ action:
+ title: Action
+ type: string
+ enum:
+ - LOGIN
+ - LOGIN_FAILED
+ - LOGOUT
+ - AUTHORIZE_APPLICATION
+ - SUSPICIOUS_REQUEST
+ - SIGN_UP
+ - PASSWORD_RESET
+ - INVITE_CREATED
+ - INVITE_USED
+ - CUSTOM
+ date:
+ title: Date
+ type: string
+ format: date-time
+ readOnly: true
+ app:
+ title: App
+ type: string
+ minLength: 1
+ context:
+ title: Context
+ type: object
+ client_ip:
+ title: Client ip
+ type: string
+ minLength: 1
+ x-nullable: true
+ created:
+ title: Created
+ type: string
+ format: date-time
+ readOnly: true
+ Application:
+ required:
+ - name
+ - slug
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ description: Application's display Name.
+ type: string
+ minLength: 1
+ slug:
+ title: Slug
+ description: Internal application name, used in URLs.
+ type: string
+ format: slug
+ pattern: ^[-a-zA-Z0-9_]+$
+ maxLength: 50
+ minLength: 1
+ skip_authorization:
+ title: Skip authorization
+ type: boolean
+ provider:
+ title: Provider
+ type: integer
+ x-nullable: true
+ meta_launch_url:
+ title: Meta launch url
+ type: string
+ format: uri
+ maxLength: 200
+ meta_icon_url:
+ title: Meta icon url
+ type: string
+ meta_description:
+ title: Meta description
+ type: string
+ meta_publisher:
+ title: Meta publisher
+ type: string
+ policies:
+ type: array
+ items:
+ type: string
+ format: uuid
+ uniqueItems: true
+ Group:
+ required:
+ - name
+ - parent
+ - user_set
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ maxLength: 80
+ minLength: 1
+ parent:
+ title: Parent
+ type: string
+ format: uuid
+ user_set:
+ type: array
+ items:
+ type: integer
+ uniqueItems: true
+ attributes:
+ title: Attributes
+ type: object
+ User:
+ required:
+ - username
+ - name
+ type: object
+ properties:
+ pk:
+ title: ID
+ type: integer
+ readOnly: true
+ username:
+ title: Username
+ description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
+ only.
+ type: string
+ pattern: ^[\w.@+-]+$
+ maxLength: 150
+ minLength: 1
+ name:
+ title: Name
+ description: User's display name.
+ type: string
+ minLength: 1
+ email:
+ title: Email address
+ type: string
+ format: email
+ maxLength: 254
+ FlowStageBinding:
+ required:
+ - flow
+ - stage
+ - order
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ flow:
+ title: Flow
+ type: string
+ format: uuid
+ stage:
+ title: Stage
+ type: string
+ format: uuid
+ re_evaluate_policies:
+ title: Re evaluate policies
+ description: When this option is enabled, the planner will re-evaluate policies
+ bound to this.
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ policies:
+ type: array
+ items:
+ type: integer
+ readOnly: true
+ uniqueItems: true
+ Flow:
+ required:
+ - name
+ - slug
+ - designation
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ slug:
+ title: Slug
+ type: string
+ format: slug
+ pattern: ^[-a-zA-Z0-9_]+$
+ maxLength: 50
+ minLength: 1
+ designation:
+ title: Designation
+ type: string
+ enum:
+ - authentication
+ - invalidation
+ - enrollment
+ - unenrollment
+ - recovery
+ - password_change
+ stages:
+ type: array
+ items:
+ type: string
+ format: uuid
+ readOnly: true
+ uniqueItems: true
+ policies:
+ type: array
+ items:
+ type: integer
+ readOnly: true
+ uniqueItems: true
+ Policy:
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ __type__:
+ title: 'type '
+ type: string
+ readOnly: true
+ PolicyBinding:
+ required:
+ - policy
+ - target
+ type: object
+ properties:
+ policy:
+ title: Policy
+ type: string
+ format: uuid
+ target:
+ title: Target
+ type: integer
+ enabled:
+ title: Enabled
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ DummyPolicy:
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ result:
+ title: Result
+ type: boolean
+ wait_min:
+ title: Wait min
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ wait_max:
+ title: Wait max
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ ExpressionPolicy:
+ required:
+ - expression
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ expression:
+ title: Expression
+ type: string
+ minLength: 1
+ HaveIBeenPwendPolicy:
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ allowed_count:
+ title: Allowed count
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ PasswordPolicy:
+ required:
+ - error_message
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ amount_uppercase:
+ title: Amount uppercase
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ amount_lowercase:
+ title: Amount lowercase
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ amount_symbols:
+ title: Amount symbols
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ length_min:
+ title: Length min
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ symbol_charset:
+ title: Symbol charset
+ type: string
+ minLength: 1
+ error_message:
+ title: Error message
+ type: string
+ minLength: 1
+ PasswordExpiryPolicy:
+ required:
+ - days
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ days:
+ title: Days
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ deny_only:
+ title: Deny only
+ type: boolean
+ ReputationPolicy:
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ check_ip:
+ title: Check ip
+ type: boolean
+ check_username:
+ title: Check username
+ type: boolean
+ threshold:
+ title: Threshold
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ WebhookPolicy:
+ required:
+ - url
+ - method
+ - json_body
+ - json_headers
+ - result_jsonpath
+ - result_json_value
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ x-nullable: true
+ negate:
+ title: Negate
+ type: boolean
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ url:
+ title: Url
+ type: string
+ format: uri
+ maxLength: 200
+ minLength: 1
+ method:
+ title: Method
+ type: string
+ enum:
+ - GET
+ - POST
+ - PATCH
+ - DELETE
+ - PUT
+ json_body:
+ title: Json body
+ type: string
+ minLength: 1
+ json_headers:
+ title: Json headers
+ type: string
+ minLength: 1
+ result_jsonpath:
+ title: Result jsonpath
+ type: string
+ minLength: 1
+ result_json_value:
+ title: Result json value
+ type: string
+ minLength: 1
+ PropertyMapping:
+ required:
+ - name
+ - expression
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ expression:
+ title: Expression
+ type: string
+ minLength: 1
+ __type__:
+ title: 'type '
+ type: string
+ readOnly: true
+ LDAPPropertyMapping:
+ required:
+ - name
+ - expression
+ - object_field
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ expression:
+ title: Expression
+ type: string
+ minLength: 1
+ object_field:
+ title: Object field
+ type: string
+ minLength: 1
+ SAMLPropertyMapping:
+ required:
+ - name
+ - saml_name
+ - expression
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ saml_name:
+ title: SAML Name
+ type: string
+ minLength: 1
+ friendly_name:
+ title: Friendly name
+ type: string
+ x-nullable: true
+ expression:
+ title: Expression
+ type: string
+ minLength: 1
+ Provider:
+ type: object
+ properties:
+ pk:
+ title: ID
+ type: integer
+ readOnly: true
+ property_mappings:
+ type: array
+ items:
+ type: string
+ format: uuid
+ uniqueItems: true
+ __type__:
+ title: 'type '
+ type: string
+ readOnly: true
+ OpenIDProvider:
+ title: Client
+ required:
+ - client_id
+ - response_types
+ type: object
+ properties:
+ pk:
+ title: ID
+ type: integer
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ maxLength: 100
+ minLength: 1
+ client_type:
+ title: Client Type
+ description: Confidential clients are capable of maintaining the confidentiality
+ of their credentials. Public clients are incapable.
+ type: string
+ enum:
+ - confidential
+ - public
+ client_id:
+ title: Client ID
+ type: string
+ maxLength: 255
+ minLength: 1
+ client_secret:
+ title: Client SECRET
+ type: string
+ maxLength: 255
+ response_types:
+ type: array
+ items:
+ type: integer
+ uniqueItems: true
+ jwt_alg:
+ title: JWT Algorithm
+ description: Algorithm used to encode ID Tokens.
+ type: string
+ enum:
+ - HS256
+ - RS256
+ reuse_consent:
+ title: Reuse Consent?
+ description: If enabled, server will save the user consent given to a specific
+ client, so that user won't be prompted for the same authorization multiple
+ times.
+ type: boolean
+ require_consent:
+ title: Require Consent?
+ description: If disabled, the Server will NEVER ask the user for consent.
+ type: boolean
+ _redirect_uris:
+ title: Redirect URIs
+ description: Enter each URI on a new line.
+ type: string
+ minLength: 1
+ _scope:
+ title: Scopes
+ description: Specifies the authorized scope values for the client app.
+ type: string
+ ApplicationGatewayProvider:
+ required:
+ - name
+ - internal_host
+ - external_host
+ - client
+ type: object
+ properties:
+ pk:
+ title: ID
+ type: integer
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ internal_host:
+ title: Internal host
+ type: string
+ minLength: 1
+ external_host:
+ title: External host
+ type: string
+ minLength: 1
+ client:
+ $ref: '#/definitions/OpenIDProvider'
+ OAuth2Provider:
+ required:
+ - client_type
+ - authorization_grant_type
+ type: object
+ properties:
+ pk:
+ title: ID
+ type: integer
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ maxLength: 255
+ redirect_uris:
+ title: Redirect uris
+ description: Allowed URIs list, space separated
+ type: string
+ client_type:
+ title: Client type
+ type: string
+ enum:
+ - confidential
+ - public
+ authorization_grant_type:
+ title: Authorization grant type
+ type: string
+ enum:
+ - authorization-code
+ - implicit
+ - password
+ - client-credentials
+ client_id:
+ title: Client id
+ type: string
+ maxLength: 100
+ minLength: 1
+ client_secret:
+ title: Client secret
+ type: string
+ maxLength: 255
+ SAMLProvider:
+ required:
+ - name
+ - processor_path
+ - acs_url
+ - issuer
+ type: object
+ properties:
+ pk:
+ title: ID
+ type: integer
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ processor_path:
+ title: Processor path
+ type: string
+ maxLength: 255
+ minLength: 1
+ acs_url:
+ title: ACS URL
+ type: string
+ format: uri
+ maxLength: 200
+ minLength: 1
+ audience:
+ title: Audience
+ type: string
+ minLength: 1
+ issuer:
+ title: Issuer
+ description: Also known as EntityID
+ type: string
+ minLength: 1
+ assertion_valid_not_before:
+ title: Assertion valid not before
+ description: 'Assertion valid not before current time + this value (Format:
+ hours=-1;minutes=-2;seconds=-3).'
+ type: string
+ minLength: 1
+ assertion_valid_not_on_or_after:
+ title: Assertion valid not on or after
+ description: 'Assertion not valid on or after current time + this value (Format:
+ hours=1;minutes=2;seconds=3).'
+ type: string
+ minLength: 1
+ session_valid_not_on_or_after:
+ title: Session valid not on or after
+ description: 'Session not valid on or after current time + this value (Format:
+ hours=1;minutes=2;seconds=3).'
+ type: string
+ minLength: 1
+ property_mappings:
+ type: array
+ items:
+ type: string
+ format: uuid
+ uniqueItems: true
+ digest_algorithm:
+ title: Digest algorithm
+ type: string
+ enum:
+ - sha1
+ - sha256
+ signature_algorithm:
+ title: Signature algorithm
+ type: string
+ enum:
+ - rsa-sha1
+ - rsa-sha256
+ - ecdsa-sha256
+ - dsa-sha1
+ signing_kp:
+ title: Signing Keypair
+ description: Singing is enabled upon selection of a Key Pair.
+ type: string
+ format: uuid
+ x-nullable: true
+ require_signing:
+ title: Require signing
+ description: Require Requests to be signed by an X509 Certificate. Must match
+ the Certificate selected in `Singing Keypair`.
+ type: boolean
+ Source:
+ required:
+ - name
+ - slug
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ description: Source's display Name.
+ type: string
+ minLength: 1
+ slug:
+ title: Slug
+ description: Internal source name, used in URLs.
+ type: string
+ format: slug
+ pattern: ^[-a-zA-Z0-9_]+$
+ maxLength: 50
+ minLength: 1
+ enabled:
+ title: Enabled
+ type: boolean
+ __type__:
+ title: 'type '
+ type: string
+ readOnly: true
+ LDAPSource:
+ required:
+ - name
+ - slug
+ - server_uri
+ - bind_cn
+ - bind_password
+ - base_dn
+ - additional_user_dn
+ - additional_group_dn
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ description: Source's display Name.
+ type: string
+ minLength: 1
+ slug:
+ title: Slug
+ description: Internal source name, used in URLs.
+ type: string
+ format: slug
+ pattern: ^[-a-zA-Z0-9_]+$
+ maxLength: 50
+ minLength: 1
+ enabled:
+ title: Enabled
+ type: boolean
+ server_uri:
+ title: Server URI
+ type: string
+ minLength: 1
+ bind_cn:
+ title: Bind CN
+ type: string
+ minLength: 1
+ bind_password:
+ title: Bind password
+ type: string
+ minLength: 1
+ start_tls:
+ title: Enable Start TLS
+ type: boolean
+ base_dn:
+ title: Base DN
+ type: string
+ minLength: 1
+ additional_user_dn:
+ title: Addition User DN
+ description: Prepended to Base DN for User-queries.
+ type: string
+ minLength: 1
+ additional_group_dn:
+ title: Addition Group DN
+ description: Prepended to Base DN for Group-queries.
+ type: string
+ minLength: 1
+ user_object_filter:
+ title: User object filter
+ description: Consider Objects matching this filter to be Users.
+ type: string
+ minLength: 1
+ group_object_filter:
+ title: Group object filter
+ description: Consider Objects matching this filter to be Groups.
+ type: string
+ minLength: 1
+ user_group_membership_field:
+ title: User group membership field
+ description: Field which contains Groups of user.
+ type: string
+ minLength: 1
+ object_uniqueness_field:
+ title: Object uniqueness field
+ description: Field which contains a unique Identifier.
+ type: string
+ minLength: 1
+ sync_groups:
+ title: Sync groups
+ type: boolean
+ sync_parent_group:
+ title: Sync parent group
+ type: string
+ format: uuid
+ x-nullable: true
+ property_mappings:
+ type: array
+ items:
+ type: string
+ format: uuid
+ uniqueItems: true
+ OAuthSource:
+ required:
+ - name
+ - slug
+ - provider_type
+ - authorization_url
+ - access_token_url
+ - profile_url
+ - consumer_key
+ - consumer_secret
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ description: Source's display Name.
+ type: string
+ minLength: 1
+ slug:
+ title: Slug
+ description: Internal source name, used in URLs.
+ type: string
+ format: slug
+ pattern: ^[-a-zA-Z0-9_]+$
+ maxLength: 50
+ minLength: 1
+ enabled:
+ title: Enabled
+ type: boolean
+ provider_type:
+ title: Provider type
+ type: string
+ maxLength: 255
+ minLength: 1
+ request_token_url:
+ title: Request Token URL
+ type: string
+ maxLength: 255
+ authorization_url:
+ title: Authorization URL
+ type: string
+ maxLength: 255
+ minLength: 1
+ access_token_url:
+ title: Access Token URL
+ type: string
+ maxLength: 255
+ minLength: 1
+ profile_url:
+ title: Profile URL
+ type: string
+ maxLength: 255
+ minLength: 1
+ consumer_key:
+ title: Consumer key
+ type: string
+ minLength: 1
+ consumer_secret:
+ title: Consumer secret
+ type: string
+ minLength: 1
+ Stage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ __type__:
+ title: 'type '
+ type: string
+ readOnly: true
+ verbose_name:
+ title: Verbose name
+ type: string
+ readOnly: true
+ CaptchaStage:
+ required:
+ - name
+ - public_key
+ - private_key
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ public_key:
+ title: Public key
+ description: Public key, acquired from https://www.google.com/recaptcha/intro/v3.html
+ type: string
+ minLength: 1
+ private_key:
+ title: Private key
+ description: Private key, acquired from https://www.google.com/recaptcha/intro/v3.html
+ type: string
+ minLength: 1
+ DummyStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ EmailStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ host:
+ title: Host
+ type: string
+ minLength: 1
+ port:
+ title: Port
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ username:
+ title: Username
+ type: string
+ password:
+ title: Password
+ type: string
+ use_tls:
+ title: Use tls
+ type: boolean
+ use_ssl:
+ title: Use ssl
+ type: boolean
+ timeout:
+ title: Timeout
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ from_address:
+ title: From address
+ type: string
+ format: email
+ maxLength: 254
+ minLength: 1
+ IdentificationStage:
+ required:
+ - name
+ - user_fields
+ - template
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ user_fields:
+ description: Fields of the user object to match against.
+ type: array
+ items:
+ title: User fields
+ type: string
+ enum:
+ - email
+ - username
+ template:
+ title: Template
+ type: string
+ enum:
+ - stages/identification/login.html
+ - stages/identification/recovery.html
+ InvitationStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ continue_flow_without_invitation:
+ title: Continue flow without invitation
+ description: If this flag is set, this Stage will jump to the next Stage when
+ no Invitation is given. By default this Stage will cancel the Flow when
+ no invitation is given.
+ type: boolean
+ Invitation:
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ expires:
+ title: Expires
+ type: string
+ format: date-time
+ x-nullable: true
+ fixed_data:
+ title: Fixed data
+ type: object
+ OTPStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ enforced:
+ title: Enforced
+ description: Enforce enabled OTP for Users this stage applies to.
+ type: boolean
+ PasswordStage:
+ required:
+ - name
+ - backends
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ backends:
+ description: Selection of backends to test the password against.
+ type: array
+ items:
+ title: Backends
+ type: string
+ minLength: 1
+ Prompt:
+ required:
+ - field_key
+ - label
+ - type
+ - placeholder
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ field_key:
+ title: Field key
+ description: Name of the form field, also used to store the value
+ type: string
+ format: slug
+ pattern: ^[-a-zA-Z0-9_]+$
+ maxLength: 50
+ minLength: 1
+ label:
+ title: Label
+ type: string
+ minLength: 1
+ type:
+ title: Type
+ type: string
+ enum:
+ - text
+ - e-mail
+ - password
+ - number
+ - hidden
+ required:
+ title: Required
+ type: boolean
+ placeholder:
+ title: Placeholder
+ type: string
+ minLength: 1
+ PromptStage:
+ required:
+ - name
+ - fields
+ type: object
+ properties:
+ pk:
+ title: ID
+ type: integer
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ fields:
+ type: array
+ items:
+ type: string
+ format: uuid
+ uniqueItems: true
+ UserDeleteStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ UserLoginStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ UserLogoutStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ UserWriteStage:
+ required:
+ - name
+ type: object
+ properties:
+ pk:
+ title: Uuid
+ type: string
+ format: uuid
+ readOnly: true
+ name:
+ title: Name
+ type: string
+ minLength: 1