factors: -> stage

This commit is contained in:
Jens Langhammer
2020-05-08 19:46:39 +02:00
parent 08c0eb2ec6
commit 212e966dd4
99 changed files with 745 additions and 958 deletions

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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()