diff --git a/passbook/core/admin.py b/passbook/core/admin.py index 023981121..a1f5f5450 100644 --- a/passbook/core/admin.py +++ b/passbook/core/admin.py @@ -20,5 +20,5 @@ def admin_autoregister(app: AppConfig): for _app in apps.get_app_configs(): if _app.label.startswith("passbook_"): - LOGGER.debug("Registering application for dj-admin", app=_app.label) + LOGGER.debug("Registering application for dj-admin", application=_app.label) admin_autoregister(_app) diff --git a/passbook/flows/migrations/0013_auto_20200924_1605.py b/passbook/flows/migrations/0013_auto_20200924_1605.py new file mode 100644 index 000000000..6e2ac1c55 --- /dev/null +++ b/passbook/flows/migrations/0013_auto_20200924_1605.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.1 on 2020-09-24 16:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_flows', '0012_auto_20200908_1542'), + ] + + operations = [ + migrations.AlterField( + model_name='flow', + name='designation', + field=models.CharField(choices=[('authentication', 'Authentication'), ('authorization', 'Authorization'), ('invalidation', 'Invalidation'), ('enrollment', 'Enrollment'), ('unenrollment', 'Unrenollment'), ('recovery', 'Recovery'), ('stage_configuration', 'Stage Configuration')], max_length=100), + ), + ] diff --git a/passbook/flows/models.py b/passbook/flows/models.py index 2f7580773..c508201d2 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -37,7 +37,7 @@ class FlowDesignation(models.TextChoices): ENROLLMENT = "enrollment" UNRENOLLMENT = "unenrollment" RECOVERY = "recovery" - STAGE_SETUP = "stage_setup" + STAGE_CONFIGURATION = "stage_configuration" class Stage(SerializerModel): @@ -73,6 +73,29 @@ class Stage(SerializerModel): return f"Stage {self.name}" +class ConfigurableStage(models.Model): + """Abstract base class for a Stage that can be configured by the enduser. + The stage should create a default flow with the configure_stage designation during + migration.""" + + configure_flow = models.ForeignKey( + 'passbook_flows.Flow', + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text=_( + ( + "Flow used by an authenticated user to change their password. " + "If empty, user will be unable to change their password." + ) + ), + ) + + class Meta: + + abstract = True + + def in_memory_stage(view: Type["StageView"]) -> Stage: """Creates an in-memory stage instance, based on a `_type` as view.""" stage = Stage() diff --git a/passbook/flows/urls.py b/passbook/flows/urls.py index 861d93cc1..4473e201c 100644 --- a/passbook/flows/urls.py +++ b/passbook/flows/urls.py @@ -3,7 +3,7 @@ from django.urls import path from passbook.flows.models import FlowDesignation from passbook.flows.views import ( - CancelView, + CancelView, ConfigureFlowInitView, FlowExecutorShellView, FlowExecutorView, ToDefaultFlow, @@ -36,6 +36,7 @@ urlpatterns = [ name="default-unenrollment", ), path("-/cancel/", CancelView.as_view(), name="cancel"), + path("-/configure//", ConfigureFlowInitView.as_view(), name="configure"), path("b//", FlowExecutorView.as_view(), name="flow-executor"), path( "/", FlowExecutorShellView.as_view(), name="flow-executor-shell" diff --git a/passbook/flows/views.py b/passbook/flows/views.py index 1a6ab4d75..2573c4f4f 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -1,6 +1,7 @@ """passbook multi-stage authentication engine""" from traceback import format_tb from typing import Any, Dict, Optional +from django.contrib.auth.mixins import LoginRequiredMixin from django.http import ( Http404, @@ -19,8 +20,8 @@ from structlog import get_logger from passbook.audit.models import cleanse_dict from passbook.core.models import PASSBOOK_USER_DEBUG from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException -from passbook.flows.models import Flow, FlowDesignation, Stage -from passbook.flows.planner import FlowPlan, FlowPlanner +from passbook.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage +from passbook.flows.planner import FlowPlan, FlowPlanner, PLAN_CONTEXT_PENDING_USER from passbook.lib.utils.reflection import class_to_path from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs from passbook.policies.http import AccessDeniedResponse @@ -295,3 +296,32 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons {"type": "template", "body": source.content.decode("utf-8")} ) return source + + +class ConfigureFlowInitView(LoginRequiredMixin, View): + """Initiate planner for selected change flow and redirect to flow executor, + or raise Http404 if no configure_flow has been set.""" + + def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse: + """Initiate planner for selected change flow and redirect to flow executor, + or raise Http404 if no configure_flow has been set.""" + try: + stage: Stage = Stage.objects.get_subclass(pk=stage_uuid) + except Stage.DoesNotExist as exc: + raise Http404 from exc + if not issubclass(stage, ConfigurableStage): + LOGGER.debug("Stage does not inherit ConfigurableStage", stage=stage) + raise Http404 + if not stage.configure_flow: + LOGGER.debug("Stage has no configure_flow set", stage=stage) + raise Http404 + + plan = FlowPlanner(stage.configure_flow).plan( + request, {PLAN_CONTEXT_PENDING_USER: request.user} + ) + request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "passbook_flows:flow-executor-shell", + self.request.GET, + flow_slug=stage.configure_flow.slug, + ) diff --git a/passbook/stages/password/api.py b/passbook/stages/password/api.py index 846ac07c6..9da54f9b6 100644 --- a/passbook/stages/password/api.py +++ b/passbook/stages/password/api.py @@ -15,7 +15,7 @@ class PasswordStageSerializer(ModelSerializer): "pk", "name", "backends", - "change_flow", + "configure_flow", "failed_attempts_before_cancel", ] diff --git a/passbook/stages/password/apps.py b/passbook/stages/password/apps.py index 7b7fdb8b8..087e1f90c 100644 --- a/passbook/stages/password/apps.py +++ b/passbook/stages/password/apps.py @@ -8,4 +8,3 @@ class PassbookStagePasswordConfig(AppConfig): name = "passbook.stages.password" label = "passbook_stages_password" verbose_name = "passbook Stages.Password" - mountpoint = "-/user/stage/password/" diff --git a/passbook/stages/password/forms.py b/passbook/stages/password/forms.py index ccb2169df..a2cda931c 100644 --- a/passbook/stages/password/forms.py +++ b/passbook/stages/password/forms.py @@ -41,14 +41,14 @@ class PasswordStageForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["change_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.STAGE_SETUP + self.fields["configure_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.STAGE_CONFIGURATION ) class Meta: model = PasswordStage - fields = ["name", "backends", "change_flow", "failed_attempts_before_cancel"] + fields = ["name", "backends", "configure_flow", "failed_attempts_before_cancel"] widgets = { "name": forms.TextInput(), "backends": FilteredSelectMultiple( diff --git a/passbook/stages/password/migrations/0004_auto_20200924_1605.py b/passbook/stages/password/migrations/0004_auto_20200924_1605.py new file mode 100644 index 000000000..885d062a8 --- /dev/null +++ b/passbook/stages/password/migrations/0004_auto_20200924_1605.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.1 on 2020-09-24 16:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_stages_password', '0003_passwordstage_failed_attempts_before_cancel'), + ] + + operations = [ + migrations.RenameField( + model_name='passwordstage', + old_name='change_flow', + new_name='configure_flow', + ), + ] diff --git a/passbook/stages/password/models.py b/passbook/stages/password/models.py index f3c94a1df..d7621608c 100644 --- a/passbook/stages/password/models.py +++ b/passbook/stages/password/models.py @@ -11,11 +11,11 @@ from django.views import View from rest_framework.serializers import BaseSerializer from passbook.core.types import UIUserSettings -from passbook.flows.models import Flow, Stage +from passbook.flows.models import ConfigurableStage, Stage from passbook.flows.views import NEXT_ARG_NAME -class PasswordStage(Stage): +class PasswordStage(ConfigurableStage, Stage): """Prompts the user for their password, and validates it against the configured backends.""" backends = ArrayField( @@ -32,19 +32,6 @@ class PasswordStage(Stage): ), ) - change_flow = models.ForeignKey( - Flow, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text=_( - ( - "Flow used by an authenticated user to change their password. " - "If empty, user will be unable to change their password." - ) - ), - ) - @property def serializer(self) -> BaseSerializer: from passbook.stages.password.api import PasswordStageSerializer @@ -66,7 +53,7 @@ class PasswordStage(Stage): if not self.change_flow: return None base_url = reverse( - "passbook_stages_password:change", kwargs={"stage_uuid": self.pk} + "passbook_flows:configure", kwargs={"stage_uuid": self.pk} ) args = urlencode({NEXT_ARG_NAME: reverse("passbook_core:user-settings")}) return UIUserSettings(name=_("Change password"), url=f"{base_url}?{args}") diff --git a/passbook/stages/password/urls.py b/passbook/stages/password/urls.py deleted file mode 100644 index 3e8f2ae4c..000000000 --- a/passbook/stages/password/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -"""password stage URLs""" -from django.urls import path - -from passbook.stages.password.views import ChangeFlowInitView - -urlpatterns = [ - path("/change/", ChangeFlowInitView.as_view(), name="change") -] diff --git a/passbook/stages/password/views.py b/passbook/stages/password/views.py index 98d0e77a7..1a54de1ae 100644 --- a/passbook/stages/password/views.py +++ b/passbook/stages/password/views.py @@ -9,24 +9,3 @@ from passbook.flows.views import SESSION_KEY_PLAN from passbook.lib.utils.urls import redirect_with_qs from passbook.stages.password.models import PasswordStage - -class ChangeFlowInitView(LoginRequiredMixin, View): - """Initiate planner for selected change flow and redirect to flow executor, - or raise Http404 if no change_flow has been set.""" - - def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse: - """Initiate planner for selected change flow and redirect to flow executor, - or raise Http404 if no change_flow has been set.""" - stage: PasswordStage = get_object_or_404(PasswordStage, pk=stage_uuid) - if not stage.change_flow: - raise Http404 - - plan = FlowPlanner(stage.change_flow).plan( - request, {PLAN_CONTEXT_PENDING_USER: request.user} - ) - request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - self.request.GET, - flow_slug=stage.change_flow.slug, - )