flows: implement planner, start new executor
This commit is contained in:
		| @ -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 New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer