flows: implement planner, start new executor

This commit is contained in:
Jens Langhammer 2020-05-08 14:33:14 +02:00
parent 97b5d120f8
commit 114bb1b0bd
15 changed files with 202 additions and 9 deletions

View file

@ -16,7 +16,7 @@ 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.view import AuthenticationView, _redirect_with_qs
from passbook.flows.views import AuthenticationView, _redirect_with_qs
from passbook.lib.config import CONFIG
LOGGER = get_logger()

View file

@ -13,7 +13,7 @@ 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.view import AuthenticationView
from passbook.flows.views import AuthenticationView
from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import path_to_class

View file

@ -0,0 +1,5 @@
"""flow exceptions"""
class FlowNonApplicableError(BaseException):
"""Exception raised when a Flow does not apply to a user."""

View file

@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
from django.views.generic import TemplateView
from passbook.core.models import User
from passbook.flows.view import AuthenticationView
from passbook.flows.views import AuthenticationView
from passbook.lib.config import CONFIG

View file

@ -0,0 +1,21 @@
# 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

@ -69,10 +69,12 @@ class FlowFactorBinding(PolicyBindingModel, UUIDModel):
order = models.IntegerField()
def __str__(self) -> str:
return f"Flow Factor Binding {self.flow} -> {self.factor}"
return f"Flow Factor Binding #{self.order} {self.flow} -> {self.factor}"
class Meta:
ordering = ["order", "flow"]
verbose_name = _("Flow Factor Binding")
verbose_name_plural = _("Flow Factor Bindings")
unique_together = (("flow", "factor", "order"),)

66
passbook/flows/planner.py Normal file
View file

@ -0,0 +1,66 @@
"""Flows Planner"""
from dataclasses import dataclass, field
from time import time
from typing import List, Tuple
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.policies.engine import PolicyEngine
LOGGER = get_logger()
@dataclass
class FlowPlan:
"""This data-class is the output of a FlowPlanner. It holds a flat list
of all Factors that should be run."""
factors: List[Factor] = field(default_factory=list)
def next(self) -> Factor:
"""Return next pending factor from the bottom of the list"""
factor_cls = self.factors.pop(0)
return factor_cls
class FlowPlanner:
"""Execute all policies to plan out a flat list of all Factors
that should be applied."""
flow: Flow
def __init__(self, flow: Flow):
self.flow = flow
def _check_flow_root_policies(self, request: HttpRequest) -> Tuple[bool, List[str]]:
engine = PolicyEngine(self.flow.policies.all(), request.user, request)
engine.build()
return engine.result
def plan(self, request: HttpRequest) -> FlowPlan:
"""Check each of the flows' policies, check policies for each factor with PolicyBinding
and return ordered list"""
LOGGER.debug("Starting planning process", flow=self.flow)
start_time = time()
plan = FlowPlan()
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
root_passing, root_passing_messages = self._check_flow_root_policies(request)
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)
engine.build()
passing, _ = engine.result
if passing:
LOGGER.debug("Factor passing", factor=factor)
plan.factors.append(factor)
end_time = time()
LOGGER.debug(
"Finished planning", flow=self.flow, duration_s=end_time - start_time
)
return plan

View file

@ -10,7 +10,7 @@ from django.urls import reverse
from passbook.core.models import User
from passbook.factors.dummy.models import DummyFactor
from passbook.factors.password.models import PasswordFactor
from passbook.flows.view import AuthenticationView
from passbook.flows.views import AuthenticationView
class TestFactorAuthentication(TestCase):

View file

@ -1,7 +1,11 @@
"""flow urls"""
from django.urls import path
from passbook.flows.view import AuthenticationView, FactorPermissionDeniedView
from passbook.flows.views import (
AuthenticationView,
FactorPermissionDeniedView,
FlowExecutorView,
)
urlpatterns = [
path("auth/process/", AuthenticationView.as_view(), name="auth-process"),
@ -15,4 +19,5 @@ urlpatterns = [
FactorPermissionDeniedView.as_view(),
name="auth-denied",
),
path("<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
]

View file

@ -11,6 +11,9 @@ from structlog import get_logger
from passbook.core.models import Factor, User
from passbook.core.views.utils import PermissionDeniedView
from passbook.flows.exceptions import FlowNonApplicableError
from passbook.flows.models import Flow
from passbook.flows.planner import FlowPlan, FlowPlanner
from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import class_to_path, path_to_class
from passbook.lib.utils.urls import is_url_absolute
@ -218,3 +221,66 @@ class AuthenticationView(UserPassesTestMixin, View):
class FactorPermissionDeniedView(PermissionDeniedView):
"""User could not be authenticated"""
SESSION_KEY_PLAN = "passbook_flows_plan"
class FlowExecutorView(View):
"""Stage 1 Flow executor, passing requests to Factor Views"""
flow: Flow
plan: FlowPlan
current_factor: Factor
current_factor_view: View
def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug)
# TODO: Do we always need this?
self.flow = get_object_or_404(Flow, slug=flow_slug)
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
# Early check if theres an active Plan for the current session
if SESSION_KEY_PLAN not in self.request.session:
LOGGER.debug(
"No active Plan found, initiating planner", flow_slug=flow_slug
)
try:
self.plan = self._initiate_plan()
except FlowNonApplicableError as exc:
LOGGER.warning("Flow not applicable to current user", exc=exc)
return redirect("passbook_core:index")
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
# as it hasn't been successfully passed yet
self.current_factor = self.plan.next()
LOGGER.debug("Current factor", current_factor=self.current_factor)
factor_cls = path_to_class(self.current_factor.type)
self.current_factor_view = factor_cls(self)
# self.current_factor_view.pending_user = self.pending_user
self.current_factor_view.request = request
return super().dispatch(request)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass get request to current factor"""
LOGGER.debug(
"Passing GET", view_class=class_to_path(self.current_factor_view.__class__),
)
return self.current_factor_view.get(request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass post request to current factor"""
LOGGER.debug(
"Passing POST",
view_class=class_to_path(self.current_factor_view.__class__),
)
return self.current_factor_view.post(request, *args, **kwargs)
def _initiate_plan(self) -> FlowPlan:
planner = FlowPlanner(self.flow)
plan = planner.plan(self.request)
self.request.session[SESSION_KEY_PLAN] = plan
return plan

View file

@ -8,7 +8,7 @@ from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.nativetypes import NativeEnvironment
from structlog import get_logger
from passbook.flows.view import AuthenticationView
from passbook.flows.views import AuthenticationView
from passbook.lib.utils.http import get_client_ip
from passbook.policies.types import PolicyRequest, PolicyResult

View file

@ -0,0 +1,20 @@
# Generated by Django 3.0.3 on 2020-05-08 12:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_policies", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="policybindingmodel",
options={
"verbose_name": "Policy Binding Model",
"verbose_name_plural": "Policy Binding Models",
},
),
]

View file

@ -11,6 +11,11 @@ class PolicyBindingModel(models.Model):
policies = models.ManyToManyField(Policy, through="PolicyBinding", related_name="+")
class Meta:
verbose_name = _("Policy Binding Model")
verbose_name_plural = _("Policy Binding Models")
class PolicyBinding(UUIDModel):
"""Relationship between a Policy and a PolicyBindingModel."""
@ -25,6 +30,9 @@ class PolicyBinding(UUIDModel):
# default value and non-unique for compatibility
order = models.IntegerField(default=0)
def __str__(self) -> str:
return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}"
class Meta:
verbose_name = _("Policy Binding")

View file

@ -164,5 +164,5 @@ class SAMLPropertyMapping(PropertyMapping):
def get_provider_choices():
"""Return tuple of class_path, class name of all providers."""
return [
(class_to_path(x), x.__name__) for x in Processor.__dict__["__subclasses__"]()
(class_to_path(x), x.__name__) for x in getattr(Processor, "__subclasses__")()
]

View file

@ -13,7 +13,7 @@ from django.views.generic import RedirectView, View
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.flows.view import AuthenticationView, _redirect_with_qs
from passbook.flows.views import AuthenticationView, _redirect_with_qs
from passbook.sources.oauth.clients import get_client
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection