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.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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
5
passbook/flows/exceptions.py
Normal file
5
passbook/flows/exceptions.py
Normal file
|
@ -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 passbook.core.models import User
|
||||
from passbook.flows.view import AuthenticationView
|
||||
from passbook.flows.views import AuthenticationView
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
|
||||
|
|
21
passbook/flows/migrations/0003_auto_20200508_1230.py
Normal file
21
passbook/flows/migrations/0003_auto_20200508_1230.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
66
passbook/flows/planner.py
Normal 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
|
|
@ -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):
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
20
passbook/policies/migrations/0002_auto_20200508_1230.py
Normal file
20
passbook/policies/migrations/0002_auto_20200508_1230.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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")
|
||||
|
|
|
@ -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__")()
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Reference in a new issue