flows: add ConfigurableStage base class and ConfigureFlowInitView
This commit is contained in:
parent
2aad523596
commit
7be50c2574
|
@ -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)
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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/"
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
|
@ -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}")
|
||||||
|
|
|
@ -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")
|
|
||||||
]
|
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
Reference in New Issue