flows: add ConfigurableStage base class and ConfigureFlowInitView

This commit is contained in:
Jens Langhammer 2020-09-24 20:05:49 +02:00
parent 2aad523596
commit 7be50c2574
12 changed files with 102 additions and 55 deletions

View File

@ -20,5 +20,5 @@ def admin_autoregister(app: AppConfig):
for _app in apps.get_app_configs(): for _app in apps.get_app_configs():
if _app.label.startswith("passbook_"): 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) admin_autoregister(_app)

View File

@ -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),
),
]

View File

@ -37,7 +37,7 @@ class FlowDesignation(models.TextChoices):
ENROLLMENT = "enrollment" ENROLLMENT = "enrollment"
UNRENOLLMENT = "unenrollment" UNRENOLLMENT = "unenrollment"
RECOVERY = "recovery" RECOVERY = "recovery"
STAGE_SETUP = "stage_setup" STAGE_CONFIGURATION = "stage_configuration"
class Stage(SerializerModel): class Stage(SerializerModel):
@ -73,6 +73,29 @@ class Stage(SerializerModel):
return f"Stage {self.name}" 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: def in_memory_stage(view: Type["StageView"]) -> Stage:
"""Creates an in-memory stage instance, based on a `_type` as view.""" """Creates an in-memory stage instance, based on a `_type` as view."""
stage = Stage() stage = Stage()

View File

@ -3,7 +3,7 @@ from django.urls import path
from passbook.flows.models import FlowDesignation from passbook.flows.models import FlowDesignation
from passbook.flows.views import ( from passbook.flows.views import (
CancelView, CancelView, ConfigureFlowInitView,
FlowExecutorShellView, FlowExecutorShellView,
FlowExecutorView, FlowExecutorView,
ToDefaultFlow, ToDefaultFlow,
@ -36,6 +36,7 @@ urlpatterns = [
name="default-unenrollment", name="default-unenrollment",
), ),
path("-/cancel/", CancelView.as_view(), name="cancel"), path("-/cancel/", CancelView.as_view(), name="cancel"),
path("-/configure/<uuid:stage_uuid>/", ConfigureFlowInitView.as_view(), name="configure"),
path("b/<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"), path("b/<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
path( path(
"<slug:flow_slug>/", FlowExecutorShellView.as_view(), name="flow-executor-shell" "<slug:flow_slug>/", FlowExecutorShellView.as_view(), name="flow-executor-shell"

View File

@ -1,6 +1,7 @@
"""passbook multi-stage authentication engine""" """passbook multi-stage authentication engine"""
from traceback import format_tb from traceback import format_tb
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import ( from django.http import (
Http404, Http404,
@ -19,8 +20,8 @@ from structlog import get_logger
from passbook.audit.models import cleanse_dict from passbook.audit.models import cleanse_dict
from passbook.core.models import PASSBOOK_USER_DEBUG from passbook.core.models import PASSBOOK_USER_DEBUG
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.models import Flow, FlowDesignation, Stage from passbook.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
from passbook.flows.planner import FlowPlan, FlowPlanner from passbook.flows.planner import FlowPlan, FlowPlanner, PLAN_CONTEXT_PENDING_USER
from passbook.lib.utils.reflection import class_to_path from passbook.lib.utils.reflection import class_to_path
from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs
from passbook.policies.http import AccessDeniedResponse 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")} {"type": "template", "body": source.content.decode("utf-8")}
) )
return source 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,
)

View File

@ -15,7 +15,7 @@ class PasswordStageSerializer(ModelSerializer):
"pk", "pk",
"name", "name",
"backends", "backends",
"change_flow", "configure_flow",
"failed_attempts_before_cancel", "failed_attempts_before_cancel",
] ]

View File

@ -8,4 +8,3 @@ class PassbookStagePasswordConfig(AppConfig):
name = "passbook.stages.password" name = "passbook.stages.password"
label = "passbook_stages_password" label = "passbook_stages_password"
verbose_name = "passbook Stages.Password" verbose_name = "passbook Stages.Password"
mountpoint = "-/user/stage/password/"

View File

@ -41,14 +41,14 @@ class PasswordStageForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["change_flow"].queryset = Flow.objects.filter( self.fields["configure_flow"].queryset = Flow.objects.filter(
designation=FlowDesignation.STAGE_SETUP designation=FlowDesignation.STAGE_CONFIGURATION
) )
class Meta: class Meta:
model = PasswordStage model = PasswordStage
fields = ["name", "backends", "change_flow", "failed_attempts_before_cancel"] fields = ["name", "backends", "configure_flow", "failed_attempts_before_cancel"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"backends": FilteredSelectMultiple( "backends": FilteredSelectMultiple(

View File

@ -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',
),
]

View File

@ -11,11 +11,11 @@ from django.views import View
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
from passbook.core.types import UIUserSettings 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 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.""" """Prompts the user for their password, and validates it against the configured backends."""
backends = ArrayField( 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 @property
def serializer(self) -> BaseSerializer: def serializer(self) -> BaseSerializer:
from passbook.stages.password.api import PasswordStageSerializer from passbook.stages.password.api import PasswordStageSerializer
@ -66,7 +53,7 @@ class PasswordStage(Stage):
if not self.change_flow: if not self.change_flow:
return None return None
base_url = reverse( 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")}) args = urlencode({NEXT_ARG_NAME: reverse("passbook_core:user-settings")})
return UIUserSettings(name=_("Change password"), url=f"{base_url}?{args}") return UIUserSettings(name=_("Change password"), url=f"{base_url}?{args}")

View File

@ -1,8 +0,0 @@
"""password stage URLs"""
from django.urls import path
from passbook.stages.password.views import ChangeFlowInitView
urlpatterns = [
path("<uuid:stage_uuid>/change/", ChangeFlowInitView.as_view(), name="change")
]

View File

@ -9,24 +9,3 @@ from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs from passbook.lib.utils.urls import redirect_with_qs
from passbook.stages.password.models import PasswordStage 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,
)