flows: implement planner, start new executor
This commit is contained in:
parent
97b5d120f8
commit
114bb1b0bd
|
@ -16,7 +16,7 @@ from passbook.core.forms.authentication import LoginForm, SignUpForm
|
||||||
from passbook.core.models import Invitation, Nonce, Source, User
|
from passbook.core.models import Invitation, Nonce, Source, User
|
||||||
from passbook.core.signals import invitation_used, user_signed_up
|
from passbook.core.signals import invitation_used, user_signed_up
|
||||||
from passbook.factors.password.exceptions import PasswordPolicyInvalid
|
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
|
from passbook.lib.config import CONFIG
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
|
@ -13,7 +13,7 @@ from structlog import get_logger
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.factors.password.forms import PasswordForm
|
from passbook.factors.password.forms import PasswordForm
|
||||||
from passbook.flows.factor_base import AuthenticationFactor
|
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.config import CONFIG
|
||||||
from passbook.lib.utils.reflection import path_to_class
|
from passbook.lib.utils.reflection import path_to_class
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""flow exceptions"""
|
||||||
|
|
||||||
|
|
||||||
|
class FlowNonApplicableError(BaseException):
|
||||||
|
"""Exception raised when a Flow does not apply to a user."""
|
|
@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.view import AuthenticationView
|
from passbook.flows.views import AuthenticationView
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -69,10 +69,12 @@ class FlowFactorBinding(PolicyBindingModel, UUIDModel):
|
||||||
order = models.IntegerField()
|
order = models.IntegerField()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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:
|
class Meta:
|
||||||
|
|
||||||
|
ordering = ["order", "flow"]
|
||||||
|
|
||||||
verbose_name = _("Flow Factor Binding")
|
verbose_name = _("Flow Factor Binding")
|
||||||
verbose_name_plural = _("Flow Factor Bindings")
|
verbose_name_plural = _("Flow Factor Bindings")
|
||||||
unique_together = (("flow", "factor", "order"),)
|
unique_together = (("flow", "factor", "order"),)
|
||||||
|
|
|
@ -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
|
|
@ -10,7 +10,7 @@ from django.urls import reverse
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.factors.dummy.models import DummyFactor
|
from passbook.factors.dummy.models import DummyFactor
|
||||||
from passbook.factors.password.models import PasswordFactor
|
from passbook.factors.password.models import PasswordFactor
|
||||||
from passbook.flows.view import AuthenticationView
|
from passbook.flows.views import AuthenticationView
|
||||||
|
|
||||||
|
|
||||||
class TestFactorAuthentication(TestCase):
|
class TestFactorAuthentication(TestCase):
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
"""flow urls"""
|
"""flow urls"""
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from passbook.flows.view import AuthenticationView, FactorPermissionDeniedView
|
from passbook.flows.views import (
|
||||||
|
AuthenticationView,
|
||||||
|
FactorPermissionDeniedView,
|
||||||
|
FlowExecutorView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("auth/process/", AuthenticationView.as_view(), name="auth-process"),
|
path("auth/process/", AuthenticationView.as_view(), name="auth-process"),
|
||||||
|
@ -15,4 +19,5 @@ urlpatterns = [
|
||||||
FactorPermissionDeniedView.as_view(),
|
FactorPermissionDeniedView.as_view(),
|
||||||
name="auth-denied",
|
name="auth-denied",
|
||||||
),
|
),
|
||||||
|
path("<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -11,6 +11,9 @@ from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import Factor, User
|
from passbook.core.models import Factor, User
|
||||||
from passbook.core.views.utils import PermissionDeniedView
|
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.config import CONFIG
|
||||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
|
from passbook.lib.utils.reflection import class_to_path, path_to_class
|
||||||
from passbook.lib.utils.urls import is_url_absolute
|
from passbook.lib.utils.urls import is_url_absolute
|
||||||
|
@ -218,3 +221,66 @@ class AuthenticationView(UserPassesTestMixin, View):
|
||||||
|
|
||||||
class FactorPermissionDeniedView(PermissionDeniedView):
|
class FactorPermissionDeniedView(PermissionDeniedView):
|
||||||
"""User could not be authenticated"""
|
"""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
|
|
@ -8,7 +8,7 @@ from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
||||||
from jinja2.nativetypes import NativeEnvironment
|
from jinja2.nativetypes import NativeEnvironment
|
||||||
from structlog import get_logger
|
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.lib.utils.http import get_client_ip
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,6 +11,11 @@ class PolicyBindingModel(models.Model):
|
||||||
|
|
||||||
policies = models.ManyToManyField(Policy, through="PolicyBinding", related_name="+")
|
policies = models.ManyToManyField(Policy, through="PolicyBinding", related_name="+")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
verbose_name = _("Policy Binding Model")
|
||||||
|
verbose_name_plural = _("Policy Binding Models")
|
||||||
|
|
||||||
|
|
||||||
class PolicyBinding(UUIDModel):
|
class PolicyBinding(UUIDModel):
|
||||||
"""Relationship between a Policy and a PolicyBindingModel."""
|
"""Relationship between a Policy and a PolicyBindingModel."""
|
||||||
|
@ -25,6 +30,9 @@ class PolicyBinding(UUIDModel):
|
||||||
# default value and non-unique for compatibility
|
# default value and non-unique for compatibility
|
||||||
order = models.IntegerField(default=0)
|
order = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Policy Binding")
|
verbose_name = _("Policy Binding")
|
||||||
|
|
|
@ -164,5 +164,5 @@ class SAMLPropertyMapping(PropertyMapping):
|
||||||
def get_provider_choices():
|
def get_provider_choices():
|
||||||
"""Return tuple of class_path, class name of all providers."""
|
"""Return tuple of class_path, class name of all providers."""
|
||||||
return [
|
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__")()
|
||||||
]
|
]
|
||||||
|
|
|
@ -13,7 +13,7 @@ from django.views.generic import RedirectView, View
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
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.clients import get_client
|
||||||
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||||
|
|
||||||
|
|
Reference in New Issue