From 212e966dd403767a2fd0a7f024e9b88a24e24d97 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 8 May 2020 19:46:39 +0200 Subject: [PATCH] factors: -> stage --- passbook/admin/urls.py | 22 +++---- passbook/admin/views/overview.py | 14 ++--- .../admin/views/{factors.py => stages.py} | 62 +++++++++---------- passbook/api/v2/urls.py | 27 ++++---- passbook/core/api/factors.py | 30 --------- .../core/migrations/0012_delete_factor.py | 14 +++++ passbook/core/models.py | 24 ------- passbook/core/templates/user/base.html | 12 ++-- .../templatetags/passbook_user_settings.py | 44 ++++++------- passbook/core/types.py | 2 +- passbook/core/views/authentication.py | 2 +- passbook/core/views/user.py | 2 +- passbook/factors/captcha/api.py | 21 ------- passbook/factors/captcha/apps.py | 10 --- passbook/factors/captcha/forms.py | 35 ----------- .../captcha/migrations/0001_initial.py | 39 ------------ .../migrations/0002_auto_20200221_1410.py | 27 -------- passbook/factors/dummy/api.py | 21 ------- passbook/factors/dummy/apps.py | 11 ---- passbook/factors/dummy/factor.py | 12 ---- passbook/factors/dummy/forms.py | 21 ------- passbook/factors/dummy/models.py | 19 ------ passbook/factors/email/apps.py | 15 ----- .../migrations/0002_auto_20191011_1224.py | 18 ------ passbook/factors/otp/api.py | 21 ------- passbook/factors/otp/apps.py | 12 ---- passbook/factors/otp/models.py | 34 ---------- passbook/factors/password/api.py | 30 --------- passbook/factors/password/apps.py | 15 ----- .../migrations/0002_auto_20191007_1411.py | 24 ------- .../0003_passwordfactor_reset_factors.py | 21 ------- .../migrations/0004_auto_20200221_1410.py | 23 ------- passbook/factors/password/signals.py | 23 ------- passbook/flows/api.py | 49 +++++++++++---- passbook/flows/forms.py | 21 +++---- passbook/flows/migrations/0001_initial.py | 54 +++++++++++----- ...default_flows.py => 0002_default_flows.py} | 18 ++++-- ..._flowfactorbinding_re_evaluate_policies.py | 21 ------- .../migrations/0003_auto_20200508_1230.py | 21 ------- .../migrations/0005_auto_20200508_1642.py | 23 ------- passbook/flows/models.py | 46 ++++++++++---- passbook/flows/planner.py | 31 ++++++---- passbook/flows/{factor_base.py => stage.py} | 6 +- passbook/flows/views.py | 61 +++++++++--------- passbook/root/settings.py | 10 +-- passbook/sources/oauth/views/core.py | 4 +- passbook/{factors => stages}/__init__.py | 0 .../{factors => stages}/captcha/__init__.py | 0 passbook/stages/captcha/api.py | 21 +++++++ passbook/stages/captcha/apps.py | 10 +++ passbook/stages/captcha/forms.py | 25 ++++++++ .../stages/captcha/migrations/0001_initial.py | 49 +++++++++++++++ .../captcha/migrations/__init__.py | 0 .../{factors => stages}/captcha/models.py | 18 +++--- .../{factors => stages}/captcha/settings.py | 2 +- .../factor.py => stages/captcha/stage.py} | 14 ++--- .../{factors => stages}/dummy/__init__.py | 0 passbook/stages/dummy/api.py | 21 +++++++ passbook/stages/dummy/apps.py | 11 ++++ passbook/stages/dummy/forms.py | 16 +++++ .../dummy/migrations/0001_initial.py | 16 ++--- .../dummy/migrations/__init__.py | 0 passbook/stages/dummy/models.py | 19 ++++++ passbook/stages/dummy/stage.py | 12 ++++ .../{factors => stages}/email/__init__.py | 0 passbook/{factors => stages}/email/api.py | 21 +++---- passbook/stages/email/apps.py | 15 +++++ passbook/{factors => stages}/email/forms.py | 20 ++---- .../email/migrations/0001_initial.py | 18 +++--- .../email/migrations/__init__.py | 0 passbook/{factors => stages}/email/models.py | 18 +++--- .../email/factor.py => stages/email/stage.py} | 18 +++--- passbook/{factors => stages}/email/tasks.py | 20 +++--- passbook/{factors => stages}/email/utils.py | 0 passbook/{factors => stages}/otp/__init__.py | 0 passbook/stages/otp/api.py | 21 +++++++ passbook/stages/otp/apps.py | 12 ++++ passbook/{factors => stages}/otp/forms.py | 19 ++---- .../otp/migrations/0001_initial.py | 19 +++--- .../otp/migrations/__init__.py | 0 passbook/stages/otp/models.py | 34 ++++++++++ passbook/{factors => stages}/otp/settings.py | 0 .../otp/factors.py => stages/otp/stage.py} | 18 +++--- .../otp/templates/stages}/otp/factor.html | 0 .../templates/stages}/otp/user_settings.html | 4 +- passbook/{factors => stages}/otp/urls.py | 2 +- passbook/{factors => stages}/otp/utils.py | 0 passbook/{factors => stages}/otp/views.py | 20 +++--- .../{factors => stages}/password/__init__.py | 0 passbook/stages/password/api.py | 26 ++++++++ passbook/stages/password/apps.py | 10 +++ .../password/exceptions.py | 0 .../{factors => stages}/password/forms.py | 19 ++---- .../password/migrations/0001_initial.py | 21 ++++--- .../password/migrations/__init__.py | 0 .../{factors => stages}/password/models.py | 22 +++---- .../factor.py => stages/password/stage.py} | 18 +++--- .../templates/stages}/password/backend.html | 0 scripts/coverage.sh | 2 +- 99 files changed, 745 insertions(+), 958 deletions(-) rename passbook/admin/views/{factors.py => stages.py} (62%) delete mode 100644 passbook/core/api/factors.py create mode 100644 passbook/core/migrations/0012_delete_factor.py delete mode 100644 passbook/factors/captcha/api.py delete mode 100644 passbook/factors/captcha/apps.py delete mode 100644 passbook/factors/captcha/forms.py delete mode 100644 passbook/factors/captcha/migrations/0001_initial.py delete mode 100644 passbook/factors/captcha/migrations/0002_auto_20200221_1410.py delete mode 100644 passbook/factors/dummy/api.py delete mode 100644 passbook/factors/dummy/apps.py delete mode 100644 passbook/factors/dummy/factor.py delete mode 100644 passbook/factors/dummy/forms.py delete mode 100644 passbook/factors/dummy/models.py delete mode 100644 passbook/factors/email/apps.py delete mode 100644 passbook/factors/email/migrations/0002_auto_20191011_1224.py delete mode 100644 passbook/factors/otp/api.py delete mode 100644 passbook/factors/otp/apps.py delete mode 100644 passbook/factors/otp/models.py delete mode 100644 passbook/factors/password/api.py delete mode 100644 passbook/factors/password/apps.py delete mode 100644 passbook/factors/password/migrations/0002_auto_20191007_1411.py delete mode 100644 passbook/factors/password/migrations/0003_passwordfactor_reset_factors.py delete mode 100644 passbook/factors/password/migrations/0004_auto_20200221_1410.py delete mode 100644 passbook/factors/password/signals.py rename passbook/flows/migrations/{0004_default_flows.py => 0002_default_flows.py} (58%) delete mode 100644 passbook/flows/migrations/0002_flowfactorbinding_re_evaluate_policies.py delete mode 100644 passbook/flows/migrations/0003_auto_20200508_1230.py delete mode 100644 passbook/flows/migrations/0005_auto_20200508_1642.py rename passbook/flows/{factor_base.py => stage.py} (84%) rename passbook/{factors => stages}/__init__.py (100%) rename passbook/{factors => stages}/captcha/__init__.py (100%) create mode 100644 passbook/stages/captcha/api.py create mode 100644 passbook/stages/captcha/apps.py create mode 100644 passbook/stages/captcha/forms.py create mode 100644 passbook/stages/captcha/migrations/0001_initial.py rename passbook/{factors => stages}/captcha/migrations/__init__.py (100%) rename passbook/{factors => stages}/captcha/models.py (53%) rename passbook/{factors => stages}/captcha/settings.py (90%) rename passbook/{factors/captcha/factor.py => stages/captcha/stage.py} (66%) rename passbook/{factors => stages}/dummy/__init__.py (100%) create mode 100644 passbook/stages/dummy/api.py create mode 100644 passbook/stages/dummy/apps.py create mode 100644 passbook/stages/dummy/forms.py rename passbook/{factors => stages}/dummy/migrations/0001_initial.py (64%) rename passbook/{factors => stages}/dummy/migrations/__init__.py (100%) create mode 100644 passbook/stages/dummy/models.py create mode 100644 passbook/stages/dummy/stage.py rename passbook/{factors => stages}/email/__init__.py (100%) rename passbook/{factors => stages}/email/api.py (54%) create mode 100644 passbook/stages/email/apps.py rename passbook/{factors => stages}/email/forms.py (60%) rename passbook/{factors => stages}/email/migrations/0001_initial.py (76%) rename passbook/{factors => stages}/email/migrations/__init__.py (100%) rename passbook/{factors => stages}/email/models.py (76%) rename passbook/{factors/email/factor.py => stages/email/stage.py} (75%) rename passbook/{factors => stages}/email/tasks.py (62%) rename passbook/{factors => stages}/email/utils.py (100%) rename passbook/{factors => stages}/otp/__init__.py (100%) create mode 100644 passbook/stages/otp/api.py create mode 100644 passbook/stages/otp/apps.py rename passbook/{factors => stages}/otp/forms.py (77%) rename passbook/{factors => stages}/otp/migrations/0001_initial.py (67%) rename passbook/{factors => stages}/otp/migrations/__init__.py (100%) create mode 100644 passbook/stages/otp/models.py rename passbook/{factors => stages}/otp/settings.py (100%) rename passbook/{factors/otp/factors.py => stages/otp/stage.py} (82%) rename passbook/{factors/otp/templates => stages/otp/templates/stages}/otp/factor.html (100%) rename passbook/{factors/otp/templates => stages/otp/templates/stages}/otp/user_settings.html (91%) rename passbook/{factors => stages}/otp/urls.py (89%) rename passbook/{factors => stages}/otp/utils.py (100%) rename passbook/{factors => stages}/otp/views.py (91%) rename passbook/{factors => stages}/password/__init__.py (100%) create mode 100644 passbook/stages/password/api.py create mode 100644 passbook/stages/password/apps.py rename passbook/{factors => stages}/password/exceptions.py (100%) rename passbook/{factors => stages}/password/forms.py (64%) rename passbook/{factors => stages}/password/migrations/0001_initial.py (62%) rename passbook/{factors => stages}/password/migrations/__init__.py (100%) rename passbook/{factors => stages}/password/models.py (63%) rename passbook/{factors/password/factor.py => stages/password/stage.py} (86%) rename passbook/{factors/password/templates/factors => stages/password/templates/stages}/password/backend.html (100%) diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index 6435a446d..0f3526713 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -6,7 +6,6 @@ from passbook.admin.views import ( audit, certificate_key_pair, debug, - factors, flows, groups, invitations, @@ -15,6 +14,7 @@ from passbook.admin.views import ( property_mapping, providers, sources, + stages, users, ) @@ -85,18 +85,18 @@ 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", ), # Flows path("flows/", flows.FlowListView.as_view(), name="flows"), @@ -107,7 +107,7 @@ urlpatterns = [ path( "flows//delete/", flows.FlowDeleteView.as_view(), name="flow-delete", ), - # Factors + # Property Mappings path( "property-mappings/", property_mapping.PropertyMappingListView.as_view(), diff --git a/passbook/admin/views/overview.py b/passbook/admin/views/overview.py index 52025b50e..9248eac5b 100644 --- a/passbook/admin/views/overview.py +++ b/passbook/admin/views/overview.py @@ -5,15 +5,8 @@ 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, Invitation, Policy, Provider, Source, User +from passbook.flows.models import Flow, Stage from passbook.root.celery import CELERY_APP @@ -35,7 +28,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/factors.py b/passbook/admin/views/stages.py similarity index 62% rename from passbook/admin/views/factors.py rename to passbook/admin/views/stages.py index 628c6a61f..c5623a22e 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,7 +11,7 @@ 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.flows.models import Stage from passbook.lib.utils.reflection import path_to_class from passbook.lib.views import CreateAssignPermView @@ -23,18 +23,18 @@ def all_subclasses(cls): ) -class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView): - """Show list of all factors""" +class StageListView(LoginRequiredMixin, PermissionListMixin, ListView): + """Show list of all flows""" - model = Factor - template_name = "administration/factor/list.html" - permission_required = "passbook_core.view_factor" + model = Stage + template_name = "administration/flow/list.html" + permission_required = "passbook_core.view_flow" ordering = "order" 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 +42,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_core.add_flow" - success_url = reverse_lazy("passbook_admin:factors") - success_message = _("Successfully created Factor") + success_url = reverse_lazy("passbook_admin:flows") + 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) + flow_type = self.request.GET.get("type") + model = next(x for x in all_subclasses(Stage) if x.__name__ == flow_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) + flow_type = self.request.GET.get("type") + model = next(x for x in all_subclasses(Stage) if x.__name__ == flow_type) if not model: raise Http404 return path_to_class(model.form) -class FactorUpdateView( +class StageUpdateView( SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView ): - """Update factor""" + """Update flow""" - model = Factor + model = Stage permission_required = "passbook_core.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:flows") + success_message = _("Successfully updated Stage") def get_form_class(self): form_class_path = self.get_object().form @@ -90,24 +90,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 flow""" - 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_core.delete_flow" + success_url = reverse_lazy("passbook_admin:flows") + 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/api/v2/urls.py b/passbook/api/v2/urls.py index 524de960e..d651e991f 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -9,7 +9,6 @@ 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 @@ -17,12 +16,7 @@ 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 FlowFactorBindingViewSet, FlowViewSet +from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet from passbook.lib.utils.reflection import get_apps from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet from passbook.policies.expression.api import ExpressionPolicyViewSet @@ -36,6 +30,11 @@ 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.dummy.api import DummyStageViewSet +from passbook.stages.email.api import EmailStageViewSet +from passbook.stages.otp.api import OTPStageViewSet +from passbook.stages.password.api import PasswordStageViewSet LOGGER = get_logger() router = routers.DefaultRouter() @@ -69,14 +68,14 @@ 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/dummy", DummyStageViewSet) +router.register("stages/email", EmailStageViewSet) +router.register("stages/otp", OTPStageViewSet) +router.register("stages/password", PasswordStageViewSet) router.register("flows", FlowViewSet) -router.register("flows/bindings", FlowFactorBindingViewSet) +router.register("flows/bindings", FlowStageBindingViewSet) info = openapi.Info( title="passbook API", 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/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/models.py b/passbook/core/models.py index 25d33db16..29f489f2a 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -103,30 +103,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 diff --git a/passbook/core/templates/user/base.html b/passbook/core/templates/user/base.html index 6e9d0e650..274d08346 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' %}

    - {% for factor in user_factors_loc %} + {% for stage in user_stages_loc %}
  • - - - {{ factor.name }} + + + {{ stage.name }}
  • {% endfor %} diff --git a/passbook/core/templatetags/passbook_user_settings.py b/passbook/core/templatetags/passbook_user_settings.py index 478903b1c..57cb25711 100644 --- a/passbook/core/templatetags/passbook_user_settings.py +++ b/passbook/core/templatetags/passbook_user_settings.py @@ -4,7 +4,7 @@ from typing import Iterable, List from django import template from django.template.context import RequestContext -from passbook.core.models import Factor, Source +from passbook.core.models import Source from passbook.core.types import UIUserSettings from passbook.policies.engine import PolicyEngine @@ -12,24 +12,24 @@ register = template.Library() @register.simple_tag(takes_context=True) -def user_factors(context: RequestContext) -> List[UIUserSettings]: - """Return list of all factors which apply to user""" - user = context.get("request").user - _all_factors: Iterable[Factor] = ( - Factor.objects.filter(enabled=True).order_by("order").select_subclasses() - ) - matching_factors: List[UIUserSettings] = [] - for factor in _all_factors: - user_settings = factor.ui_user_settings - if not user_settings: - continue - policy_engine = PolicyEngine( - factor.policies.all(), user, context.get("request") - ) - policy_engine.build() - if policy_engine.passing: - matching_factors.append(user_settings) - return matching_factors +# pylint: disable=unused-argument +def user_stages(context: RequestContext) -> List[UIUserSettings]: + """Return list of all stages which apply to user""" + # TODO: Rewrite this based on flows + # user = context.get("request").user + # _all_stages: Iterable[Stage] = (Stage.objects.all().select_subclasses()) + matching_stages: List[UIUserSettings] = [] + # for stage in _all_stages: + # user_settings = stage.ui_user_settings + # if not user_settings: + # continue + # policy_engine = PolicyEngine( + # stage.policies.all(), user, context.get("request") + # ) + # policy_engine.build() + # if policy_engine.passing: + # matching_stages.append(user_settings) + return matching_stages @register.simple_tag(takes_context=True) @@ -40,12 +40,12 @@ def user_sources(context: RequestContext) -> List[UIUserSettings]: Source.objects.filter(enabled=True).select_subclasses() ) matching_sources: List[UIUserSettings] = [] - for factor in _all_sources: - user_settings = factor.ui_user_settings + for source in _all_sources: + user_settings = source.ui_user_settings if not user_settings: continue policy_engine = PolicyEngine( - factor.policies.all(), user, context.get("request") + source.policies.all(), user, context.get("request") ) policy_engine.build() if policy_engine.passing: diff --git a/passbook/core/types.py b/passbook/core/types.py index 0e03a91af..86d6eeea6 100644 --- a/passbook/core/types.py +++ b/passbook/core/types.py @@ -5,7 +5,7 @@ from typing import Optional @dataclass class UIUserSettings: - """Dataclass for Factor and Source's user_settings""" + """Dataclass for Stage and Source's user_settings""" name: str icon: str diff --git a/passbook/core/views/authentication.py b/passbook/core/views/authentication.py index 2951b2b7f..86739871d 100644 --- a/passbook/core/views/authentication.py +++ b/passbook/core/views/authentication.py @@ -15,12 +15,12 @@ from structlog import get_logger from passbook.core.forms.authentication import LoginForm, SignUpForm from passbook.core.models import Invitation, Nonce, Source, User from passbook.core.signals import invitation_used, user_signed_up -from passbook.factors.password.exceptions import PasswordPolicyInvalid from passbook.flows.models import Flow, FlowDesignation from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from passbook.flows.views import SESSION_KEY_PLAN from passbook.lib.config import CONFIG from passbook.lib.utils.urls import redirect_with_qs +from passbook.stages.password.exceptions import PasswordPolicyInvalid LOGGER = get_logger() diff --git a/passbook/core/views/user.py b/passbook/core/views/user.py index 6c4bdcfc6..0127266a9 100644 --- a/passbook/core/views/user.py +++ b/passbook/core/views/user.py @@ -10,8 +10,8 @@ from django.utils.translation import gettext as _ from django.views.generic import DeleteView, FormView, UpdateView from passbook.core.forms.users import PasswordChangeForm, UserDetailForm -from passbook.factors.password.exceptions import PasswordPolicyInvalid from passbook.lib.config import CONFIG +from passbook.stages.password.exceptions import PasswordPolicyInvalid class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView): diff --git a/passbook/factors/captcha/api.py b/passbook/factors/captcha/api.py deleted file mode 100644 index 0786321b4..000000000 --- a/passbook/factors/captcha/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""CaptchaFactor API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.factors.captcha.models import CaptchaFactor - - -class CaptchaFactorSerializer(ModelSerializer): - """CaptchaFactor Serializer""" - - class Meta: - - model = CaptchaFactor - fields = ["pk", "name", "slug", "order", "enabled", "public_key", "private_key"] - - -class CaptchaFactorViewSet(ModelViewSet): - """CaptchaFactor Viewset""" - - queryset = CaptchaFactor.objects.all() - serializer_class = CaptchaFactorSerializer diff --git a/passbook/factors/captcha/apps.py b/passbook/factors/captcha/apps.py deleted file mode 100644 index d054894ae..000000000 --- a/passbook/factors/captcha/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook captcha app""" -from django.apps import AppConfig - - -class PassbookFactorCaptchaConfig(AppConfig): - """passbook captcha app""" - - name = "passbook.factors.captcha" - label = "passbook_factors_captcha" - verbose_name = "passbook Factors.Captcha" diff --git a/passbook/factors/captcha/forms.py b/passbook/factors/captcha/forms.py deleted file mode 100644 index c19a3eeb4..000000000 --- a/passbook/factors/captcha/forms.py +++ /dev/null @@ -1,35 +0,0 @@ -"""passbook captcha factor forms""" -from captcha.fields import ReCaptchaField -from django import forms -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.utils.translation import gettext_lazy as _ - -from passbook.factors.captcha.models import CaptchaFactor -from passbook.flows.forms import GENERAL_FIELDS - - -class CaptchaForm(forms.Form): - """passbook captcha factor form""" - - captcha = ReCaptchaField() - - -class CaptchaFactorForm(forms.ModelForm): - """Form to edit CaptchaFactor Instance""" - - class Meta: - - model = CaptchaFactor - fields = GENERAL_FIELDS + ["public_key", "private_key"] - widgets = { - "name": forms.TextInput(), - "order": forms.NumberInput(), - "policies": FilteredSelectMultiple(_("policies"), False), - "public_key": forms.TextInput(), - "private_key": forms.TextInput(), - } - help_texts = { - "policies": _( - "Policies which determine if this factor applies to the current user." - ) - } diff --git a/passbook/factors/captcha/migrations/0001_initial.py b/passbook/factors/captcha/migrations/0001_initial.py deleted file mode 100644 index 0f44952d8..000000000 --- a/passbook/factors/captcha/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 2.2.6 on 2019-10-07 14:07 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="CaptchaFactor", - fields=[ - ( - "factor_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", - ), - ), - ("public_key", models.TextField()), - ("private_key", models.TextField()), - ], - options={ - "verbose_name": "Captcha Factor", - "verbose_name_plural": "Captcha Factors", - }, - bases=("passbook_core.factor",), - ), - ] diff --git a/passbook/factors/captcha/migrations/0002_auto_20200221_1410.py b/passbook/factors/captcha/migrations/0002_auto_20200221_1410.py deleted file mode 100644 index 0960ee54d..000000000 --- a/passbook/factors/captcha/migrations/0002_auto_20200221_1410.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-21 14:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_factors_captcha", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="captchafactor", - name="private_key", - field=models.TextField( - help_text="Private key, acquired from https://www.google.com/recaptcha/intro/v3.html" - ), - ), - migrations.AlterField( - model_name="captchafactor", - name="public_key", - field=models.TextField( - help_text="Public key, acquired from https://www.google.com/recaptcha/intro/v3.html" - ), - ), - ] diff --git a/passbook/factors/dummy/api.py b/passbook/factors/dummy/api.py deleted file mode 100644 index 108698aaa..000000000 --- a/passbook/factors/dummy/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""DummyFactor API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.factors.dummy.models import DummyFactor - - -class DummyFactorSerializer(ModelSerializer): - """DummyFactor Serializer""" - - class Meta: - - model = DummyFactor - fields = ["pk", "name", "slug", "order", "enabled"] - - -class DummyFactorViewSet(ModelViewSet): - """DummyFactor Viewset""" - - queryset = DummyFactor.objects.all() - serializer_class = DummyFactorSerializer diff --git a/passbook/factors/dummy/apps.py b/passbook/factors/dummy/apps.py deleted file mode 100644 index 4cb858b88..000000000 --- a/passbook/factors/dummy/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook dummy factor config""" - -from django.apps import AppConfig - - -class PassbookFactorDummyConfig(AppConfig): - """passbook dummy factor config""" - - name = "passbook.factors.dummy" - label = "passbook_factors_dummy" - verbose_name = "passbook Factors.Dummy" diff --git a/passbook/factors/dummy/factor.py b/passbook/factors/dummy/factor.py deleted file mode 100644 index 5152c2753..000000000 --- a/passbook/factors/dummy/factor.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook multi-factor authentication engine""" -from django.http import HttpRequest - -from passbook.flows.factor_base import AuthenticationFactor - - -class DummyFactor(AuthenticationFactor): - """Dummy factor for testing with multiple factors""" - - def post(self, request: HttpRequest): - """Just redirect to next factor""" - return self.executor.factor_ok() diff --git a/passbook/factors/dummy/forms.py b/passbook/factors/dummy/forms.py deleted file mode 100644 index 72de002c9..000000000 --- a/passbook/factors/dummy/forms.py +++ /dev/null @@ -1,21 +0,0 @@ -"""passbook administration forms""" -from django import forms -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.utils.translation import gettext as _ - -from passbook.factors.dummy.models import DummyFactor -from passbook.flows.forms import GENERAL_FIELDS - - -class DummyFactorForm(forms.ModelForm): - """Form to create/edit Dummy Factor""" - - class Meta: - - model = DummyFactor - fields = GENERAL_FIELDS - widgets = { - "name": forms.TextInput(), - "order": forms.NumberInput(), - "policies": FilteredSelectMultiple(_("policies"), False), - } diff --git a/passbook/factors/dummy/models.py b/passbook/factors/dummy/models.py deleted file mode 100644 index a1e24d6ab..000000000 --- a/passbook/factors/dummy/models.py +++ /dev/null @@ -1,19 +0,0 @@ -"""dummy factor models""" -from django.utils.translation import gettext as _ - -from passbook.core.models import Factor - - -class DummyFactor(Factor): - """Dummy factor, mostly used to debug""" - - type = "passbook.factors.dummy.factor.DummyFactor" - form = "passbook.factors.dummy.forms.DummyFactorForm" - - def __str__(self): - return f"Dummy Factor {self.slug}" - - class Meta: - - verbose_name = _("Dummy Factor") - verbose_name_plural = _("Dummy Factors") diff --git a/passbook/factors/email/apps.py b/passbook/factors/email/apps.py deleted file mode 100644 index 1382cf173..000000000 --- a/passbook/factors/email/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -"""passbook email factor config""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookFactorEmailConfig(AppConfig): - """passbook email factor config""" - - name = "passbook.factors.email" - label = "passbook_factors_email" - verbose_name = "passbook Factors.Email" - - def ready(self): - import_module("passbook.factors.email.tasks") diff --git a/passbook/factors/email/migrations/0002_auto_20191011_1224.py b/passbook/factors/email/migrations/0002_auto_20191011_1224.py deleted file mode 100644 index b020e7c5a..000000000 --- a/passbook/factors/email/migrations/0002_auto_20191011_1224.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.6 on 2019-10-11 12:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_factors_email", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="emailfactor", - name="timeout", - field=models.IntegerField(default=10), - ), - ] diff --git a/passbook/factors/otp/api.py b/passbook/factors/otp/api.py deleted file mode 100644 index 4962ddc46..000000000 --- a/passbook/factors/otp/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""OTPFactor API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.factors.otp.models import OTPFactor - - -class OTPFactorSerializer(ModelSerializer): - """OTPFactor Serializer""" - - class Meta: - - model = OTPFactor - fields = ["pk", "name", "slug", "order", "enabled", "enforced"] - - -class OTPFactorViewSet(ModelViewSet): - """OTPFactor Viewset""" - - queryset = OTPFactor.objects.all() - serializer_class = OTPFactorSerializer diff --git a/passbook/factors/otp/apps.py b/passbook/factors/otp/apps.py deleted file mode 100644 index d04263248..000000000 --- a/passbook/factors/otp/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook OTP AppConfig""" - -from django.apps.config import AppConfig - - -class PassbookFactorOTPConfig(AppConfig): - """passbook OTP AppConfig""" - - name = "passbook.factors.otp" - label = "passbook_factors_otp" - verbose_name = "passbook Factors.OTP" - mountpoint = "user/otp/" diff --git a/passbook/factors/otp/models.py b/passbook/factors/otp/models.py deleted file mode 100644 index 1c87d6d69..000000000 --- a/passbook/factors/otp/models.py +++ /dev/null @@ -1,34 +0,0 @@ -"""OTP Factor""" -from django.db import models -from django.utils.translation import gettext as _ - -from passbook.core.models import Factor -from passbook.core.types import UIUserSettings - - -class OTPFactor(Factor): - """OTP Factor""" - - enforced = models.BooleanField( - default=False, - help_text=("Enforce enabled OTP for Users " "this factor applies to."), - ) - - type = "passbook.factors.otp.factors.OTPFactor" - form = "passbook.factors.otp.forms.OTPFactorForm" - - @property - def ui_user_settings(self) -> UIUserSettings: - return UIUserSettings( - name="OTP", - icon="pficon-locked", - view_name="passbook_factors_otp:otp-user-settings", - ) - - def __str__(self): - return f"OTP Factor {self.slug}" - - class Meta: - - verbose_name = _("OTP Factor") - verbose_name_plural = _("OTP Factors") diff --git a/passbook/factors/password/api.py b/passbook/factors/password/api.py deleted file mode 100644 index 1c8b3e407..000000000 --- a/passbook/factors/password/api.py +++ /dev/null @@ -1,30 +0,0 @@ -"""PasswordFactor API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.factors.password.models import PasswordFactor - - -class PasswordFactorSerializer(ModelSerializer): - """PasswordFactor Serializer""" - - class Meta: - - model = PasswordFactor - fields = [ - "pk", - "name", - "slug", - "order", - "enabled", - "backends", - "password_policies", - "reset_factors", - ] - - -class PasswordFactorViewSet(ModelViewSet): - """PasswordFactor Viewset""" - - queryset = PasswordFactor.objects.all() - serializer_class = PasswordFactorSerializer diff --git a/passbook/factors/password/apps.py b/passbook/factors/password/apps.py deleted file mode 100644 index 207d91f9a..000000000 --- a/passbook/factors/password/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -"""passbook core app config""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookFactorPasswordConfig(AppConfig): - """passbook password factor config""" - - name = "passbook.factors.password" - label = "passbook_factors_password" - verbose_name = "passbook Factors.Password" - - def ready(self): - import_module("passbook.factors.password.signals") diff --git a/passbook/factors/password/migrations/0002_auto_20191007_1411.py b/passbook/factors/password/migrations/0002_auto_20191007_1411.py deleted file mode 100644 index 3cafedf26..000000000 --- a/passbook/factors/password/migrations/0002_auto_20191007_1411.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.2.6 on 2019-10-07 14:11 - -from django.db import migrations - - -def create_initial_factor(apps, schema_editor): - """Create initial PasswordFactor if none exists""" - PasswordFactor = apps.get_model("passbook_factors_password", "PasswordFactor") - if not PasswordFactor.objects.exists(): - PasswordFactor.objects.create( - name="password", - slug="password", - order=0, - backends=["django.contrib.auth.backends.ModelBackend"], - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_factors_password", "0001_initial"), - ] - - operations = [migrations.RunPython(create_initial_factor)] diff --git a/passbook/factors/password/migrations/0003_passwordfactor_reset_factors.py b/passbook/factors/password/migrations/0003_passwordfactor_reset_factors.py deleted file mode 100644 index b64e40df8..000000000 --- a/passbook/factors/password/migrations/0003_passwordfactor_reset_factors.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2.6 on 2019-10-08 09:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0001_initial"), - ("passbook_factors_password", "0002_auto_20191007_1411"), - ] - - operations = [ - migrations.AddField( - model_name="passwordfactor", - name="reset_factors", - field=models.ManyToManyField( - blank=True, related_name="reset_factors", to="passbook_core.Factor" - ), - ), - ] diff --git a/passbook/factors/password/migrations/0004_auto_20200221_1410.py b/passbook/factors/password/migrations/0004_auto_20200221_1410.py deleted file mode 100644 index a34fdb1b9..000000000 --- a/passbook/factors/password/migrations/0004_auto_20200221_1410.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-21 14:10 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_factors_password", "0003_passwordfactor_reset_factors"), - ] - - operations = [ - migrations.AlterField( - model_name="passwordfactor", - name="backends", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.TextField(), - help_text="Selection of backends to test the password against.", - size=None, - ), - ), - ] diff --git a/passbook/factors/password/signals.py b/passbook/factors/password/signals.py deleted file mode 100644 index ffdacda86..000000000 --- a/passbook/factors/password/signals.py +++ /dev/null @@ -1,23 +0,0 @@ -"""passbook password factor signals""" -from django.dispatch import receiver - -from passbook.core.signals import password_changed -from passbook.factors.password.exceptions import PasswordPolicyInvalid - - -@receiver(password_changed) -def password_policy_checker(sender, password, **_): - """Run password through all password policies which are applied to the user""" - from passbook.factors.password.models import PasswordFactor - from passbook.policies.engine import PolicyEngine - - setattr(sender, "__password__", password) - _all_factors = PasswordFactor.objects.filter(enabled=True).order_by("order") - for factor in _all_factors: - policy_engine = PolicyEngine( - factor.password_policies.all().select_subclasses(), sender - ) - policy_engine.build() - passing, messages = policy_engine.result - if not passing: - raise PasswordPolicyInvalid(*messages) diff --git a/passbook/flows/api.py b/passbook/flows/api.py index 9edc3d9e5..bfb4bbde2 100644 --- a/passbook/flows/api.py +++ b/passbook/flows/api.py @@ -1,8 +1,8 @@ """Flow API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet +from rest_framework.serializers import ModelSerializer, SerializerMethodField +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet -from passbook.flows.models import Flow, FlowFactorBinding +from passbook.flows.models import Flow, FlowStageBinding, Stage class FlowSerializer(ModelSerializer): @@ -11,7 +11,7 @@ class FlowSerializer(ModelSerializer): class Meta: model = Flow - fields = ["pk", "name", "slug", "designation", "factors", "policies"] + fields = ["pk", "name", "slug", "designation", "stages", "policies"] class FlowViewSet(ModelViewSet): @@ -21,17 +21,42 @@ class FlowViewSet(ModelViewSet): serializer_class = FlowSerializer -class FlowFactorBindingSerializer(ModelSerializer): - """FlowFactorBinding Serializer""" +class FlowStageBindingSerializer(ModelSerializer): + """FlowStageBinding Serializer""" class Meta: - model = FlowFactorBinding - fields = ["pk", "flow", "factor", "re_evaluate_policies", "order", "policies"] + model = FlowStageBinding + fields = ["pk", "flow", "stage", "re_evaluate_policies", "order", "policies"] -class FlowFactorBindingViewSet(ModelViewSet): - """FlowFactorBinding Viewset""" +class FlowStageBindingViewSet(ModelViewSet): + """FlowStageBinding Viewset""" - queryset = FlowFactorBinding.objects.all() - serializer_class = FlowFactorBindingSerializer + queryset = FlowStageBinding.objects.all() + serializer_class = FlowStageBindingSerializer + + +class StageSerializer(ModelSerializer): + """Stage 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("stage", "") + + class Meta: + + model = Stage + fields = ["pk", "name", "__type__"] + + +class StageViewSet(ReadOnlyModelViewSet): + """Stage Viewset""" + + queryset = Stage.objects.all() + serializer_class = StageSerializer + + def get_queryset(self): + return Stage.objects.select_subclasses() diff --git a/passbook/flows/forms.py b/passbook/flows/forms.py index b7936891e..227679a10 100644 --- a/passbook/flows/forms.py +++ b/passbook/flows/forms.py @@ -1,12 +1,10 @@ -"""factor forms""" +"""Flow and Stage forms""" from django import forms from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.translation import gettext_lazy as _ -from passbook.flows.models import Flow, FlowFactorBinding - -GENERAL_FIELDS = ["name", "slug", "order", "policies", "enabled"] +from passbook.flows.models import Flow, FlowStageBinding class FlowForm(forms.ModelForm): @@ -19,29 +17,30 @@ class FlowForm(forms.ModelForm): "name", "slug", "designation", - "factors", + "stages", "policies", ] widgets = { "name": forms.TextInput(), - "factors": FilteredSelectMultiple(_("policies"), False), + "stages": FilteredSelectMultiple(_("stages"), False), + "policies": FilteredSelectMultiple(_("policies"), False), } -class FlowFactorBindingForm(forms.ModelForm): - """FlowFactorBinding Form""" +class FlowStageBindingForm(forms.ModelForm): + """FlowStageBinding Form""" class Meta: - model = FlowFactorBinding + model = FlowStageBinding fields = [ "flow", - "factor", + "stage", "re_evaluate_policies", "order", "policies", ] widgets = { "name": forms.TextInput(), - "factors": FilteredSelectMultiple(_("policies"), False), + "policies": FilteredSelectMultiple(_("policies"), False), } diff --git a/passbook/flows/migrations/0001_initial.py b/passbook/flows/migrations/0001_initial.py index e07d2ce27..df7121778 100644 --- a/passbook/flows/migrations/0001_initial.py +++ b/passbook/flows/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-05-07 18:35 +# Generated by Django 3.0.3 on 2020-05-08 18:27 import uuid @@ -11,8 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("passbook_policies", "0001_initial"), - ("passbook_core", "0011_auto_20200222_1822"), + ("passbook_policies", "0003_auto_20200508_1642"), ] operations = [ @@ -37,6 +36,7 @@ class Migration(migrations.Migration): ("AUTHENTICATION", "authentication"), ("ENROLLMENT", "enrollment"), ("RECOVERY", "recovery"), + ("PASSWORD_CHANGE", "password_change"), ], max_length=100, ), @@ -55,7 +55,23 @@ class Migration(migrations.Migration): bases=("passbook_policies.policybindingmodel", models.Model), ), migrations.CreateModel( - name="FlowFactorBinding", + name="Stage", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField()), + ], + options={"abstract": False,}, + ), + migrations.CreateModel( + name="FlowStageBinding", fields=[ ( "policybindingmodel_ptr", @@ -75,14 +91,14 @@ class Migration(migrations.Migration): serialize=False, ), ), - ("order", models.IntegerField()), ( - "factor", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_core.Factor", + "re_evaluate_policies", + models.BooleanField( + default=False, + help_text="When this option is enabled, the planner will re-evaluate policies bound to this.", ), ), + ("order", models.IntegerField()), ( "flow", models.ForeignKey( @@ -90,19 +106,29 @@ class Migration(migrations.Migration): to="passbook_flows.Flow", ), ), + ( + "stage", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="passbook_flows.Stage", + ), + ), ], options={ - "verbose_name": "Flow Factor Binding", - "verbose_name_plural": "Flow Factor Bindings", - "unique_together": {("flow", "factor", "order")}, + "verbose_name": "Flow Stage Binding", + "verbose_name_plural": "Flow Stage Bindings", + "ordering": ["order", "flow"], + "unique_together": {("flow", "stage", "order")}, }, bases=("passbook_policies.policybindingmodel", models.Model), ), migrations.AddField( model_name="flow", - name="factors", + name="stages", field=models.ManyToManyField( - through="passbook_flows.FlowFactorBinding", to="passbook_core.Factor" + blank=True, + through="passbook_flows.FlowStageBinding", + to="passbook_flows.Stage", ), ), ] diff --git a/passbook/flows/migrations/0004_default_flows.py b/passbook/flows/migrations/0002_default_flows.py similarity index 58% rename from passbook/flows/migrations/0004_default_flows.py rename to passbook/flows/migrations/0002_default_flows.py index c04d55df1..d2c326b7b 100644 --- a/passbook/flows/migrations/0004_default_flows.py +++ b/passbook/flows/migrations/0002_default_flows.py @@ -9,29 +9,35 @@ from passbook.flows.models import FlowDesignation def create_default_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): Flow = apps.get_model("passbook_flows", "Flow") - FlowFactorBinding = apps.get_model("passbook_flows", "FlowFactorBinding") - PasswordFactor = apps.get_model("passbook_factors_password", "PasswordFactor") + FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") + PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage") db_alias = schema_editor.connection.alias if Flow.objects.using(db_alias).all().exists(): # Only create default flow when none exist return - pw_factor = PasswordFactor.objects.using(db_alias).first() + if not PasswordStage.objects.using(db_alias).exists(): + PasswordStage.objects.using(db_alias).create( + name="password", backends=["django.contrib.auth.backends.ModelBackend"], + ) + + pw_stage = PasswordStage.objects.using(db_alias).first() flow = Flow.objects.using(db_alias).create( name="default-authentication-flow", slug="default-authentication-flow", designation=FlowDesignation.AUTHENTICATION, ) - FlowFactorBinding.objects.using(db_alias).create( - flow=flow, factor=pw_factor, order=0, + FlowStageBinding.objects.using(db_alias).create( + flow=flow, stage=pw_stage, order=0, ) class Migration(migrations.Migration): dependencies = [ - ("passbook_flows", "0003_auto_20200508_1230"), + ("passbook_flows", "0001_initial"), + ("passbook_stages_password", "0001_initial"), ] operations = [migrations.RunPython(create_default_flow)] diff --git a/passbook/flows/migrations/0002_flowfactorbinding_re_evaluate_policies.py b/passbook/flows/migrations/0002_flowfactorbinding_re_evaluate_policies.py deleted file mode 100644 index bbd5bf15c..000000000 --- a/passbook/flows/migrations/0002_flowfactorbinding_re_evaluate_policies.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-07 19:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="flowfactorbinding", - name="re_evaluate_policies", - field=models.BooleanField( - default=False, - help_text="When this option is enabled, the planner will re-evaluate policies bound to this.", - ), - ), - ] diff --git a/passbook/flows/migrations/0003_auto_20200508_1230.py b/passbook/flows/migrations/0003_auto_20200508_1230.py deleted file mode 100644 index 645c7a53d..000000000 --- a/passbook/flows/migrations/0003_auto_20200508_1230.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-08 12:30 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0002_flowfactorbinding_re_evaluate_policies"), - ] - - operations = [ - migrations.AlterModelOptions( - name="flowfactorbinding", - options={ - "ordering": ["order", "flow"], - "verbose_name": "Flow Factor Binding", - "verbose_name_plural": "Flow Factor Bindings", - }, - ), - ] diff --git a/passbook/flows/migrations/0005_auto_20200508_1642.py b/passbook/flows/migrations/0005_auto_20200508_1642.py deleted file mode 100644 index 3bf11a86e..000000000 --- a/passbook/flows/migrations/0005_auto_20200508_1642.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-08 16:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0011_auto_20200222_1822"), - ("passbook_flows", "0004_default_flows"), - ] - - operations = [ - migrations.AlterField( - model_name="flow", - name="factors", - field=models.ManyToManyField( - blank=True, - through="passbook_flows.FlowFactorBinding", - to="passbook_core.Factor", - ), - ), - ] diff --git a/passbook/flows/models.py b/passbook/flows/models.py index 5169fc1d8..164c14878 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -1,11 +1,12 @@ """Flow models""" from enum import Enum -from typing import Tuple +from typing import Optional, Tuple from django.db import models from django.utils.translation import gettext_lazy as _ +from model_utils.managers import InheritanceManager -from passbook.core.models import Factor +from passbook.core.types import UIUserSettings from passbook.lib.models import UUIDModel from passbook.policies.models import PolicyBindingModel @@ -17,6 +18,7 @@ class FlowDesignation(Enum): AUTHENTICATION = "authentication" ENROLLMENT = "enrollment" RECOVERY = "recovery" + PASSWORD_CHANGE = "password_change" # nosec # noqa @staticmethod def as_choices() -> Tuple[Tuple[str, str]]: @@ -26,8 +28,28 @@ class FlowDesignation(Enum): ) +class Stage(UUIDModel): + """Stage is an instance of a component used in a flow. This can verify the user, + enroll the user or offer a way of recovery""" + + name = models.TextField() + + 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"Stage {self.name}" + + class Flow(PolicyBindingModel, UUIDModel): - """Flow describes how a series of Factors should be executed to authenticate/enroll/recover + """Flow describes how a series of Stages should be executed to authenticate/enroll/recover a user. Additionally, policies can be applied, to specify which users have access to this flow.""" @@ -36,7 +58,7 @@ class Flow(PolicyBindingModel, UUIDModel): designation = models.CharField(max_length=100, choices=FlowDesignation.as_choices()) - factors = models.ManyToManyField(Factor, through="FlowFactorBinding", blank=True) + stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) pbm = models.OneToOneField( PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+" @@ -51,13 +73,13 @@ class Flow(PolicyBindingModel, UUIDModel): verbose_name_plural = _("Flows") -class FlowFactorBinding(PolicyBindingModel, UUIDModel): - """Relationship between Flow and Factor. Order is required and unique for - each flow-factor Binding. Additionally, policies can be specified, which determine if +class FlowStageBinding(PolicyBindingModel, UUIDModel): + """Relationship between Flow and Stage. Order is required and unique for + each flow-stage Binding. Additionally, policies can be specified, which determine if this Binding applies to the current user""" flow = models.ForeignKey("Flow", on_delete=models.CASCADE) - factor = models.ForeignKey(Factor, on_delete=models.CASCADE) + stage = models.ForeignKey(Stage, on_delete=models.CASCADE) re_evaluate_policies = models.BooleanField( default=False, @@ -69,12 +91,12 @@ class FlowFactorBinding(PolicyBindingModel, UUIDModel): order = models.IntegerField() def __str__(self) -> str: - return f"Flow Factor Binding #{self.order} {self.flow} -> {self.factor}" + return f"Flow Stage Binding #{self.order} {self.flow} -> {self.stage}" class Meta: ordering = ["order", "flow"] - verbose_name = _("Flow Factor Binding") - verbose_name_plural = _("Flow Factor Bindings") - unique_together = (("flow", "factor", "order"),) + verbose_name = _("Flow Stage Binding") + verbose_name_plural = _("Flow Stage Bindings") + unique_together = (("flow", "stage", "order"),) diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py index 7f0efa01a..b00f4f944 100644 --- a/passbook/flows/planner.py +++ b/passbook/flows/planner.py @@ -7,7 +7,7 @@ from django.http import HttpRequest from structlog import get_logger from passbook.flows.exceptions import FlowNonApplicableError -from passbook.flows.models import Factor, Flow +from passbook.flows.models import Flow, Stage from passbook.policies.engine import PolicyEngine LOGGER = get_logger() @@ -19,19 +19,19 @@ PLAN_CONTEXT_SSO = "is_sso" @dataclass class FlowPlan: """This data-class is the output of a FlowPlanner. It holds a flat list - of all Factors that should be run.""" + of all Stages that should be run.""" - factors: List[Factor] = field(default_factory=list) + stages: List[Stage] = field(default_factory=list) context: Dict[str, Any] = field(default_factory=dict) - def next(self) -> Factor: - """Return next pending factor from the bottom of the list""" - factor_cls = self.factors.pop(0) - return factor_cls + def next(self) -> Stage: + """Return next pending stage from the bottom of the list""" + stage_cls = self.stages.pop(0) + return stage_cls class FlowPlanner: - """Execute all policies to plan out a flat list of all Factors + """Execute all policies to plan out a flat list of all Stages that should be applied.""" flow: Flow @@ -45,7 +45,7 @@ class FlowPlanner: return engine.result def plan(self, request: HttpRequest) -> FlowPlan: - """Check each of the flows' policies, check policies for each factor with PolicyBinding + """Check each of the flows' policies, check policies for each stage with PolicyBinding and return ordered list""" LOGGER.debug("Starting planning process", flow=self.flow) start_time = time() @@ -56,13 +56,18 @@ class FlowPlanner: if not root_passing: raise FlowNonApplicableError(root_passing_messages) # Check Flow policies - for factor in self.flow.factors.order_by("order").select_subclasses(): - engine = PolicyEngine(factor.policies.all(), request.user, request) + for stage in ( + self.flow.stages.order_by("flowstagebinding__order") + .select_subclasses() + .select_related() + ): + binding = stage.flowstagebinding_set.get(flow__pk=self.flow.pk) + engine = PolicyEngine(binding.policies.all(), request.user, request) engine.build() passing, _ = engine.result if passing: - LOGGER.debug("Factor passing", factor=factor) - plan.factors.append(factor) + LOGGER.debug("Stage passing", stage=stage) + plan.stages.append(stage) end_time = time() LOGGER.debug( "Finished planning", flow=self.flow, duration_s=end_time - start_time diff --git a/passbook/flows/factor_base.py b/passbook/flows/stage.py similarity index 84% rename from passbook/flows/factor_base.py rename to passbook/flows/stage.py index 001b444a2..804f9b325 100644 --- a/passbook/flows/factor_base.py +++ b/passbook/flows/stage.py @@ -1,4 +1,4 @@ -"""passbook multi-factor authentication engine""" +"""passbook stage Base view""" from typing import Any, Dict from django.forms import ModelForm @@ -11,8 +11,8 @@ from passbook.flows.views import FlowExecutorView from passbook.lib.config import CONFIG -class AuthenticationFactor(TemplateView): - """Abstract Authentication factor, inherits TemplateView but can be combined with FormView""" +class AuthenticationStage(TemplateView): + """Abstract Authentication stage, inherits TemplateView but can be combined with FormView""" form: ModelForm = None diff --git a/passbook/flows/views.py b/passbook/flows/views.py index 1cf812d05..69de2c645 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -1,4 +1,4 @@ -"""passbook multi-factor authentication engine""" +"""passbook multi-stage authentication engine""" from typing import Optional from django.contrib.auth import login @@ -7,10 +7,9 @@ from django.shortcuts import get_object_or_404, redirect from django.views.generic import View from structlog import get_logger -from passbook.core.models import Factor from passbook.core.views.utils import PermissionDeniedView from passbook.flows.exceptions import FlowNonApplicableError -from passbook.flows.models import Flow +from passbook.flows.models import Flow, Stage from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner from passbook.lib.config import CONFIG from passbook.lib.utils.reflection import class_to_path, path_to_class @@ -24,13 +23,13 @@ SESSION_KEY_PLAN = "passbook_flows_plan" class FlowExecutorView(View): - """Stage 1 Flow executor, passing requests to Factor Views""" + """Stage 1 Flow executor, passing requests to Stage Views""" flow: Flow plan: FlowPlan - current_factor: Factor - current_factor_view: View + current_stage: Stage + current_stage_view: View def setup(self, request: HttpRequest, flow_slug: str): super().setup(request, flow_slug=flow_slug) @@ -77,36 +76,34 @@ class FlowExecutorView(View): else: LOGGER.debug("Continuing existing plan", flow_slug=flow_slug) self.plan = self.request.session[SESSION_KEY_PLAN] - # We don't save the Plan after getting the next factor + # We don't save the Plan after getting the next stage # as it hasn't been successfully passed yet - self.current_factor = self.plan.next() + self.current_stage = self.plan.next() LOGGER.debug( - "Current factor", - current_factor=self.current_factor, - flow_slug=self.flow.slug, + "Current stage", current_stage=self.current_stage, flow_slug=self.flow.slug, ) - factor_cls = path_to_class(self.current_factor.type) - self.current_factor_view = factor_cls(self) - self.current_factor_view.request = request + stage_cls = path_to_class(self.current_stage.type) + self.current_stage_view = stage_cls(self) + self.current_stage_view.request = request return super().dispatch(request) def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """pass get request to current factor""" + """pass get request to current stage""" LOGGER.debug( "Passing GET", - view_class=class_to_path(self.current_factor_view.__class__), + view_class=class_to_path(self.current_stage_view.__class__), flow_slug=self.flow.slug, ) - return self.current_factor_view.get(request, *args, **kwargs) + return self.current_stage_view.get(request, *args, **kwargs) def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """pass post request to current factor""" + """pass post request to current stage""" LOGGER.debug( "Passing POST", - view_class=class_to_path(self.current_factor_view.__class__), + view_class=class_to_path(self.current_stage_view.__class__), flow_slug=self.flow.slug, ) - return self.current_factor_view.post(request, *args, **kwargs) + return self.current_stage_view.post(request, *args, **kwargs) def _initiate_plan(self) -> FlowPlan: planner = FlowPlanner(self.flow) @@ -115,7 +112,7 @@ class FlowExecutorView(View): return plan def _flow_done(self) -> HttpResponse: - """User Successfully passed all factors""" + """User Successfully passed all stages""" backend = self.plan.context[PLAN_CONTEXT_PENDING_USER].backend login( self.request, self.plan.context[PLAN_CONTEXT_PENDING_USER], backend=backend @@ -131,34 +128,34 @@ class FlowExecutorView(View): return redirect(next_param) return redirect_with_qs("passbook_core:overview") - def factor_ok(self) -> HttpResponse: - """Callback called by factors upon successful completion. + def stage_ok(self) -> HttpResponse: + """Callback called by stages upon successful completion. Persists updated plan and context to session.""" LOGGER.debug( - "Factor ok", - factor_class=class_to_path(self.current_factor_view.__class__), + "Stage ok", + stage_class=class_to_path(self.current_stage_view.__class__), flow_slug=self.flow.slug, ) self.request.session[SESSION_KEY_PLAN] = self.plan - if self.plan.factors: + if self.plan.stages: LOGGER.debug( - "Continuing with next factor", - reamining=len(self.plan.factors), + "Continuing with next stage", + reamining=len(self.plan.stages), flow_slug=self.flow.slug, ) return redirect_with_qs( "passbook_flows:flow-executor", self.request.GET, **self.kwargs ) - # User passed all factors + # User passed all stages LOGGER.debug( - "User passed all factors", + "User passed all stages", user=self.plan.context[PLAN_CONTEXT_PENDING_USER], flow_slug=self.flow.slug, ) return self._flow_done() - def factor_invalid(self) -> HttpResponse: - """Callback used factor when data is correct but a policy denies access + def stage_invalid(self) -> HttpResponse: + """Callback used stage when data is correct but a policy denies access or the user account is disabled.""" LOGGER.debug("User invalid", flow_slug=self.flow.slug) self.cancel() diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 9b22a0174..9e8c910e9 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -96,11 +96,11 @@ INSTALLED_APPS = [ "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.stages.otp.apps.PassbookStageOTPConfig", + "passbook.stages.captcha.apps.PassbookStageCaptchaConfig", + "passbook.stages.password.apps.PassbookStagePasswordConfig", + "passbook.stages.dummy.apps.PassbookStageDummyConfig", + "passbook.stages.email.apps.PassbookStageEmailConfig", "passbook.policies.expiry.apps.PassbookPolicyExpiryConfig", "passbook.policies.reputation.apps.PassbookPolicyReputationConfig", "passbook.policies.hibp.apps.PassbookPolicyHIBPConfig", diff --git a/passbook/sources/oauth/views/core.py b/passbook/sources/oauth/views/core.py index e794fff0e..36e84d869 100644 --- a/passbook/sources/oauth/views/core.py +++ b/passbook/sources/oauth/views/core.py @@ -13,7 +13,6 @@ from django.views.generic import RedirectView, View from structlog import get_logger from passbook.audit.models import Event, EventAction -from passbook.factors.password.factor import PLAN_CONTEXT_AUTHENTICATION_BACKEND from passbook.flows.models import Flow, FlowDesignation from passbook.flows.planner import ( PLAN_CONTEXT_PENDING_USER, @@ -24,6 +23,7 @@ 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() @@ -169,7 +169,7 @@ 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 ) diff --git a/passbook/factors/__init__.py b/passbook/stages/__init__.py similarity index 100% rename from passbook/factors/__init__.py rename to passbook/stages/__init__.py diff --git a/passbook/factors/captcha/__init__.py b/passbook/stages/captcha/__init__.py similarity index 100% rename from passbook/factors/captcha/__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/captcha/migrations/__init__.py b/passbook/stages/captcha/migrations/__init__.py similarity index 100% rename from passbook/factors/captcha/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/factors/captcha/factor.py b/passbook/stages/captcha/stage.py similarity index 66% rename from passbook/factors/captcha/factor.py rename to passbook/stages/captcha/stage.py index 130d6916b..2d95b8232 100644 --- a/passbook/factors/captcha/factor.py +++ b/passbook/stages/captcha/stage.py @@ -1,23 +1,23 @@ -"""passbook captcha factor""" +"""passbook captcha stage""" from django.views.generic import FormView -from passbook.factors.captcha.forms import CaptchaForm -from passbook.flows.factor_base import AuthenticationFactor +from passbook.flows.stage import AuthenticationStage +from passbook.stages.captcha.forms import CaptchaForm -class CaptchaFactor(FormView, AuthenticationFactor): +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.factor_ok() + 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_factor.public_key - form.fields["captcha"].private_key = self.executor.current_factor.private_key + 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 diff --git a/passbook/factors/dummy/__init__.py b/passbook/stages/dummy/__init__.py similarity index 100% rename from passbook/factors/dummy/__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/dummy/migrations/__init__.py b/passbook/stages/dummy/migrations/__init__.py similarity index 100% rename from passbook/factors/dummy/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/factors/email/__init__.py b/passbook/stages/email/__init__.py similarity index 100% rename from passbook/factors/email/__init__.py rename to passbook/stages/email/__init__.py diff --git a/passbook/factors/email/api.py b/passbook/stages/email/api.py similarity index 54% rename from passbook/factors/email/api.py rename to passbook/stages/email/api.py index 165ce9064..14e6c9a3c 100644 --- a/passbook/factors/email/api.py +++ b/passbook/stages/email/api.py @@ -1,22 +1,19 @@ -"""EmailFactor API Views""" +"""EmailStage API Views""" from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet -from passbook.factors.email.models import EmailFactor +from passbook.stages.email.models import EmailStage -class EmailFactorSerializer(ModelSerializer): - """EmailFactor Serializer""" +class EmailStageSerializer(ModelSerializer): + """EmailStage Serializer""" class Meta: - model = EmailFactor + model = EmailStage fields = [ "pk", "name", - "slug", - "order", - "enabled", "host", "port", "username", @@ -31,8 +28,8 @@ class EmailFactorSerializer(ModelSerializer): extra_kwargs = {"password": {"write_only": True}} -class EmailFactorViewSet(ModelViewSet): - """EmailFactor Viewset""" +class EmailStageViewSet(ModelViewSet): + """EmailStage Viewset""" - queryset = EmailFactor.objects.all() - serializer_class = EmailFactorSerializer + 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/factors/email/forms.py b/passbook/stages/email/forms.py similarity index 60% rename from passbook/factors/email/forms.py rename to passbook/stages/email/forms.py index 8429a0870..ae93d428e 100644 --- a/passbook/factors/email/forms.py +++ b/passbook/stages/email/forms.py @@ -1,19 +1,18 @@ """passbook administration forms""" from django import forms -from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.translation import gettext_lazy as _ -from passbook.factors.email.models import EmailFactor -from passbook.flows.forms import GENERAL_FIELDS +from passbook.stages.email.models import EmailStage -class EmailFactorForm(forms.ModelForm): - """Form to create/edit Dummy Factor""" +class EmailStageForm(forms.ModelForm): + """Form to create/edit Dummy Stage""" class Meta: - model = EmailFactor - fields = GENERAL_FIELDS + [ + model = EmailStage + fields = [ + "name", "host", "port", "username", @@ -27,8 +26,6 @@ class EmailFactorForm(forms.ModelForm): ] widgets = { "name": forms.TextInput(), - "order": forms.NumberInput(), - "policies": FilteredSelectMultiple(_("policies"), False), "host": forms.TextInput(), "username": forms.TextInput(), "password": forms.TextInput(), @@ -41,8 +38,3 @@ class EmailFactorForm(forms.ModelForm): "ssl_keyfile": _("SSL Keyfile (optional)"), "ssl_certfile": _("SSL Certfile (optional)"), } - help_texts = { - "policies": _( - "Policies which determine if this factor applies to the current user." - ) - } 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/factors/email/migrations/__init__.py b/passbook/stages/email/migrations/__init__.py similarity index 100% rename from passbook/factors/email/migrations/__init__.py rename to passbook/stages/email/migrations/__init__.py diff --git a/passbook/factors/email/models.py b/passbook/stages/email/models.py similarity index 76% rename from passbook/factors/email/models.py rename to passbook/stages/email/models.py index f2027c1d9..57a104ed8 100644 --- a/passbook/factors/email/models.py +++ b/passbook/stages/email/models.py @@ -1,13 +1,13 @@ -"""email factor models""" +"""email stage models""" from django.core.mail.backends.smtp import EmailBackend 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) @@ -22,8 +22,8 @@ class EmailFactor(Factor): 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: @@ -41,9 +41,9 @@ class EmailFactor(Factor): ) 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/factors/email/factor.py b/passbook/stages/email/stage.py similarity index 75% rename from passbook/factors/email/factor.py rename to passbook/stages/email/stage.py index 8c7d0527a..129e57d4f 100644 --- a/passbook/factors/email/factor.py +++ b/passbook/stages/email/stage.py @@ -1,4 +1,4 @@ -"""passbook multi-factor authentication engine""" +"""passbook multi-stage authentication engine""" from django.contrib import messages from django.http import HttpRequest from django.shortcuts import reverse @@ -6,17 +6,17 @@ from django.utils.translation import gettext as _ from structlog import get_logger from passbook.core.models import Nonce -from passbook.factors.email.tasks import send_mails -from passbook.factors.email.utils import TemplateEmailMessage -from passbook.flows.factor_base import AuthenticationFactor from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.stage import AuthenticationStage from passbook.lib.config import CONFIG +from passbook.stages.email.tasks import send_mails +from passbook.stages.email.utils import TemplateEmailMessage LOGGER = get_logger() -class EmailFactorView(AuthenticationFactor): - """Dummy factor for testing with multiple factors""" +class EmailStageView(AuthenticationStage): + """E-Mail stage which sends E-Mail for verification""" def get_context_data(self, **kwargs): kwargs["show_password_forget_notice"] = CONFIG.y( @@ -41,10 +41,10 @@ class EmailFactorView(AuthenticationFactor): ) }, ) - send_mails(self.executor.current_factor, message) + send_mails(self.executor.current_stage, message) messages.success(request, _("Check your E-Mails for a password reset link.")) return self.executor.cancel() def post(self, request: HttpRequest): - """Just redirect to next factor""" - return self.executor.factor_ok() + """Just redirect to next stage""" + return self.executor.stage_ok() diff --git a/passbook/factors/email/tasks.py b/passbook/stages/email/tasks.py similarity index 62% rename from passbook/factors/email/tasks.py rename to passbook/stages/email/tasks.py index b3e752b04..265f69cb2 100644 --- a/passbook/factors/email/tasks.py +++ b/passbook/stages/email/tasks.py @@ -1,4 +1,4 @@ -"""email factor tasks""" +"""email stage tasks""" from smtplib import SMTPException from typing import Any, Dict, List @@ -6,38 +6,38 @@ from celery import group from django.core.mail import EmailMessage from structlog import get_logger -from passbook.factors.email.models import EmailFactor from passbook.root.celery import CELERY_APP +from passbook.stages.email.models import EmailStage LOGGER = get_logger() -def send_mails(factor: EmailFactor, *messages: List[EmailMessage]): +def send_mails(stage: EmailStage, *messages: List[EmailMessage]): """Wrapper to convert EmailMessage to dict and send it from worker""" tasks = [] for message in messages: - tasks.append(_send_mail_task.s(factor.pk, message.__dict__)) + tasks.append(_send_mail_task.s(stage.pk, message.__dict__)) lazy_group = group(*tasks) promise = lazy_group() return promise @CELERY_APP.task(bind=True) -def _send_mail_task(self, email_factor_pk: int, message: Dict[Any, Any]): - """Send E-Mail according to EmailFactor parameters from background worker. +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.""" - factor: EmailFactor = EmailFactor.objects.get(pk=email_factor_pk) - backend = factor.backend + 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 = EmailMessage() for key, value in message.items(): setattr(message_object, key, value) - message_object.from_email = factor.from_address + message_object.from_email = stage.from_address LOGGER.debug("Sending mail", to=message_object.to) try: - num_sent = factor.backend.send_messages([message_object]) + num_sent = stage.backend.send_messages([message_object]) except SMTPException as exc: raise self.retry(exc=exc) if num_sent != 1: diff --git a/passbook/factors/email/utils.py b/passbook/stages/email/utils.py similarity index 100% rename from passbook/factors/email/utils.py rename to passbook/stages/email/utils.py diff --git a/passbook/factors/otp/__init__.py b/passbook/stages/otp/__init__.py similarity index 100% rename from passbook/factors/otp/__init__.py rename to passbook/stages/otp/__init__.py 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 c6c6816a5..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.otp.models import OTPFactor -from passbook.flows.forms import GENERAL_FIELDS +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/factors/otp/migrations/__init__.py b/passbook/stages/otp/migrations/__init__.py similarity index 100% rename from passbook/factors/otp/migrations/__init__.py rename to passbook/stages/otp/migrations/__init__.py 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 82% rename from passbook/factors/otp/factors.py rename to passbook/stages/otp/stage.py index 50ba3d21f..3d62f7519 100644 --- a/passbook/factors/otp/factors.py +++ b/passbook/stages/otp/stage.py @@ -1,22 +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.otp.forms import OTPVerifyForm -from passbook.factors.otp.views import OTP_SETTING_UP_KEY, EnableView -from passbook.flows.factor_base import AuthenticationFactor 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): @@ -29,7 +29,7 @@ class OTPFactor(FormView, AuthenticationFactor): pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] if not user_has_device(pending_user): LOGGER.debug("User doesn't have OTP Setup.") - if self.executor.current_factor.enforced: + if self.executor.current_stage.enforced: # Redirect to setup view LOGGER.debug("OTP is enforced, redirecting to setup") request.user = pending_user @@ -54,6 +54,6 @@ class OTPFactor(FormView, AuthenticationFactor): form.cleaned_data.get("code"), ) if device: - return self.executor.factor_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/factors/otp/templates/otp/user_settings.html b/passbook/stages/otp/templates/stages/otp/user_settings.html similarity index 91% rename from passbook/factors/otp/templates/otp/user_settings.html rename to passbook/stages/otp/templates/stages/otp/user_settings.html index 4764de835..bb5ff5359 100644 --- a/passbook/factors/otp/templates/otp/user_settings.html +++ b/passbook/stages/otp/templates/stages/otp/user_settings.html @@ -23,10 +23,10 @@

    {% if not state %} - {% trans "Enable OTP" %} {% else %} - {% trans "Disable OTP" %} {% endif %}

    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 91% rename from passbook/factors/otp/views.py rename to passbook/stages/otp/views.py index 5561482ce..833441318 100644 --- a/passbook/factors/otp/views.py +++ b/passbook/stages/otp/views.py @@ -19,12 +19,12 @@ 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() @@ -33,7 +33,7 @@ class UserSettingsView(LoginRequiredMixin, TemplateView): template_name = "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/factors/password/__init__.py b/passbook/stages/password/__init__.py similarity index 100% rename from passbook/factors/password/__init__.py rename to passbook/stages/password/__init__.py diff --git a/passbook/stages/password/api.py b/passbook/stages/password/api.py new file mode 100644 index 000000000..48e1c5191 --- /dev/null +++ b/passbook/stages/password/api.py @@ -0,0 +1,26 @@ +"""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", + "password_policies", + ] + + +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/exceptions.py b/passbook/stages/password/exceptions.py similarity index 100% rename from passbook/factors/password/exceptions.py rename to passbook/stages/password/exceptions.py 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 50b48e5c0..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.password.models import PasswordFactor -from passbook.flows.forms import GENERAL_FIELDS 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/factors/password/migrations/__init__.py b/passbook/stages/password/migrations/__init__.py similarity index 100% rename from passbook/factors/password/migrations/__init__.py rename to passbook/stages/password/migrations/__init__.py diff --git a/passbook/factors/password/models.py b/passbook/stages/password/models.py similarity index 63% rename from passbook/factors/password/models.py rename to passbook/stages/password/models.py index 9247f80be..74359a876 100644 --- a/passbook/factors/password/models.py +++ b/passbook/stages/password/models.py @@ -1,26 +1,24 @@ -"""password factor models""" +"""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.core.models import Factor, Policy, User +from passbook.core.models import Policy, User from passbook.core.types import UIUserSettings +from passbook.flows.models import Stage -class PasswordFactor(Factor): - """Password-based Django-backend Authentication Factor""" +class PasswordStage(Stage): + """Password-based Django-backend Authentication Stage""" backends = ArrayField( models.TextField(), help_text=_("Selection of backends to test the password against."), ) password_policies = models.ManyToManyField(Policy, blank=True) - reset_factors = models.ManyToManyField( - Factor, blank=True, related_name="reset_factors" - ) - type = "passbook.factors.password.factor.PasswordFactor" - form = "passbook.factors.password.forms.PasswordFactorForm" + type = "passbook.stages.password.stage.PasswordStage" + form = "passbook.stages.password.forms.PasswordStageForm" @property def ui_user_settings(self) -> UIUserSettings: @@ -38,9 +36,9 @@ class PasswordFactor(Factor): return True def __str__(self): - return "Password Factor %s" % self.slug + return f"Password Stage {self.name}" class Meta: - verbose_name = _("Password Factor") - verbose_name_plural = _("Password Factors") + verbose_name = _("Password Stage") + verbose_name_plural = _("Password Stages") diff --git a/passbook/factors/password/factor.py b/passbook/stages/password/stage.py similarity index 86% rename from passbook/factors/password/factor.py rename to passbook/stages/password/stage.py index ce048b820..d3e691dd7 100644 --- a/passbook/factors/password/factor.py +++ b/passbook/stages/password/stage.py @@ -1,4 +1,4 @@ -"""passbook multi-factor authentication engine""" +"""passbook password stage""" from inspect import Signature from typing import Optional @@ -11,11 +11,11 @@ from django.views.generic import FormView from structlog import get_logger from passbook.core.models import User -from passbook.factors.password.forms import PasswordForm -from passbook.flows.factor_base import AuthenticationFactor from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.stage import AuthenticationStage from passbook.lib.config import CONFIG 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" @@ -53,11 +53,11 @@ def authenticate(request, backends, **credentials) -> Optional[User]: ) -class PasswordFactor(FormView, AuthenticationFactor): - """Authentication factor which authenticates against django's AuthBackend""" +class PasswordStage(FormView, AuthenticationStage): + """Authentication stage which authenticates against django's AuthBackend""" form_class = PasswordForm - template_name = "factors/password/backend.html" + template_name = "stages/password/backend.html" def form_valid(self, form): """Authenticate against django's authentication backend""" @@ -71,7 +71,7 @@ class PasswordFactor(FormView, AuthenticationFactor): ) try: user = authenticate( - self.request, self.executor.current_factor.backends, **kwargs + self.request, self.executor.current_stage.backends, **kwargs ) if user: # User instance returned from authenticate() has .backend property set @@ -79,7 +79,7 @@ class PasswordFactor(FormView, AuthenticationFactor): self.executor.plan.context[ PLAN_CONTEXT_AUTHENTICATION_BACKEND ] = user.backend - return self.executor.factor_ok() + return self.executor.stage_ok() # No user was found -> invalid credentials LOGGER.debug("Invalid credentials") # Manually inject error into form @@ -90,4 +90,4 @@ class PasswordFactor(FormView, AuthenticationFactor): except PermissionDenied: # User was found, but permission was denied (i.e. user is not active) LOGGER.debug("Denied access", **kwargs) - return self.executor.factor_invalid() + return self.executor.stage_invalid() diff --git a/passbook/factors/password/templates/factors/password/backend.html b/passbook/stages/password/templates/stages/password/backend.html similarity index 100% rename from passbook/factors/password/templates/factors/password/backend.html rename to passbook/stages/password/templates/stages/password/backend.html 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