diff --git a/passbook/admin/templates/administration/flow/list.html b/passbook/admin/templates/administration/flow/list.html new file mode 100644 index 000000000000..ef08516ae8cb --- /dev/null +++ b/passbook/admin/templates/administration/flow/list.html @@ -0,0 +1,64 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load utils %} + +{% block content %} +
+
+

+ + {% trans 'Flows' %} +

+

{% trans "External Flows which use passbook as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}

+
+
+
+
+
+ + {% include 'partials/pagination.html' %} +
+ + + + + + + + + + + {% for flow in object_list %} + + + + + + + {% endfor %} + +
{% trans 'Identifier' %}{% trans 'Designation' %}{% trans 'Bindings' %}
+
+
{{ flow.slug }}
+
+
+ + {{ flow.designation }} + + + + {{ flow.factors.all|length }} + + + {% trans 'Edit' %} + {% trans 'Delete' %} +
+
+ {% include 'partials/pagination.html' %} +
+
+
+{% endblock %} diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index 51279ecad586..1bb1ff3d8c1e 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -6,6 +6,7 @@ audit, debug, factors, + flows, groups, invitations, overview, @@ -37,6 +38,15 @@ applications.ApplicationDeleteView.as_view(), name="application-delete", ), + # Flows + path("flows/", flows.FlowListView.as_view(), name="flows"), + path("flows/create/", flows.FlowCreateView.as_view(), name="flow-create",), + path( + "flows//update/", flows.FlowUpdateView.as_view(), name="flow-update", + ), + path( + "flows//delete/", flows.FlowDeleteView.as_view(), name="flow-delete", + ), # Sources path("sources/", sources.SourceListView.as_view(), name="sources"), path("sources/create/", sources.SourceCreateView.as_view(), name="source-create"), diff --git a/passbook/admin/views/factors.py b/passbook/admin/views/factors.py index 628c6a61fb37..f177695ab785 100644 --- a/passbook/admin/views/factors.py +++ b/passbook/admin/views/factors.py @@ -29,7 +29,7 @@ class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView): model = Factor template_name = "administration/factor/list.html" permission_required = "passbook_core.view_factor" - ordering = "order" + ordering = "slug" paginate_by = 40 def get_context_data(self, **kwargs): diff --git a/passbook/admin/views/flows.py b/passbook/admin/views/flows.py new file mode 100644 index 000000000000..b3294057f1c5 --- /dev/null +++ b/passbook/admin/views/flows.py @@ -0,0 +1,77 @@ +"""passbook Flow administration""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic import DeleteView, ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from passbook.flows.forms import FlowForm +from passbook.flows.models import Flow +from passbook.lib.views import CreateAssignPermView + + +class FlowListView(LoginRequiredMixin, PermissionListMixin, ListView): + """Show list of all flows""" + + model = Flow + permission_required = "passbook_flows.view_flow" + ordering = "slug" + paginate_by = 40 + template_name = "administration/flow/list.html" + + +class FlowCreateView( + SuccessMessageMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Flow""" + + model = Flow + form_class = FlowForm + permission_required = "passbook_flows.add_flow" + + template_name = "generic/create.html" + success_url = reverse_lazy("passbook_admin:flows") + success_message = _("Successfully created Flow") + + def get_context_data(self, **kwargs): + kwargs["type"] = "Flow" + return super().get_context_data(**kwargs) + + +class FlowUpdateView( + SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView +): + """Update flow""" + + model = Flow + form_class = FlowForm + permission_required = "passbook_flows.change_flow" + + template_name = "generic/update.html" + success_url = reverse_lazy("passbook_admin:flows") + success_message = _("Successfully updated Flow") + + +class FlowDeleteView( + SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, DeleteView +): + """Delete flow""" + + model = Flow + permission_required = "passbook_flows.delete_flow" + + template_name = "generic/delete.html" + success_url = reverse_lazy("passbook_admin:flows") + success_message = _("Successfully deleted Flow") + + def delete(self, request, *args, **kwargs): + messages.success(self.request, self.success_message) + return super().delete(request, *args, **kwargs) diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 061c16831009..d5d1daa9e322 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -22,6 +22,7 @@ from passbook.factors.email.api import EmailFactorViewSet from passbook.factors.otp.api import OTPFactorViewSet from passbook.factors.password.api import PasswordFactorViewSet +from passbook.flows.api.execute import FlowsExecuteViewSet from passbook.lib.utils.reflection import get_apps from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet from passbook.policies.expression.api import ExpressionPolicyViewSet @@ -74,6 +75,7 @@ router.register("factors/email", EmailFactorViewSet) router.register("factors/otp", OTPFactorViewSet) router.register("factors/password", PasswordFactorViewSet) +router.register("flows/execute", FlowsExecuteViewSet, basename="flow") info = openapi.Info( title="passbook API", diff --git a/passbook/core/migrations/0012_auto_20200303_1530.py b/passbook/core/migrations/0012_auto_20200303_1530.py new file mode 100644 index 000000000000..b7eb32583b7f --- /dev/null +++ b/passbook/core/migrations/0012_auto_20200303_1530.py @@ -0,0 +1,15 @@ +# Generated by Django 3.0.3 on 2020-03-03 15:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_core", "0011_auto_20200222_1822"), + ] + + operations = [ + migrations.RemoveField(model_name="factor", name="enabled",), + migrations.RemoveField(model_name="factor", name="order",), + ] diff --git a/passbook/core/models.py b/passbook/core/models.py index 25d33db16d32..106cc03eacc0 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -25,6 +25,7 @@ from passbook.core.signals import password_changed from passbook.core.types import UILoginButton, UIUserSettings from passbook.lib.models import CreatedUpdatedModel, UUIDModel +from passbook.lib.utils.reflection import class_to_path, path_to_class from passbook.policies.exceptions import PolicyException from passbook.policies.types import PolicyRequest, PolicyResult @@ -110,13 +111,19 @@ class Factor(ExportModelOperationsMixin("factor"), PolicyModel): slug = models.SlugField( unique=True, help_text=_("Internal factor name, used in URLs.") ) - order = models.IntegerField() - enabled = models.BooleanField(default=True) objects = InheritanceManager() type = "" form = "" + _factor_class = None + + @property + def factor_class(self): + if not self._factor_class: + self._factor_class = path_to_class(self.type) + return self._factor_class + @property def ui_user_settings(self) -> Optional[UIUserSettings]: """Entrypoint to integrate with User settings. Can either return None if no diff --git a/passbook/core/templates/login/factors/backend.html b/passbook/core/templates/login/factors/backend.html index a88dd6b0a122..d0b15bbc50aa 100644 --- a/passbook/core/templates/login/factors/backend.html +++ b/passbook/core/templates/login/factors/backend.html @@ -4,6 +4,6 @@ {% block beneath_form %} {% if show_password_forget_notice %} -{% trans 'Forgot password?' %} +{% trans 'Forgot password?' %} {% endif %} {% endblock %} diff --git a/passbook/core/tests/test_views_authentication.py b/passbook/core/tests/test_views_authentication.py index 4dd0b0954bf0..3a1eea9cc0e1 100644 --- a/passbook/core/tests/test_views_authentication.py +++ b/passbook/core/tests/test_views_authentication.py @@ -77,7 +77,7 @@ def test_login_view_post(self): reverse("passbook_core:auth-login"), data=self.login_data ) self.assertEqual(login_response.status_code, 302) - self.assertEqual(login_response.url, reverse("passbook_core:auth-process")) + self.assertEqual(login_response.url, reverse("passbook_core:flows-execute")) def test_sign_up_view_post(self): """Test account.sign_up view POST (Anonymous)""" diff --git a/passbook/core/urls.py b/passbook/core/urls.py index 154cc12f1e60..4d10328052d1 100644 --- a/passbook/core/urls.py +++ b/passbook/core/urls.py @@ -3,7 +3,7 @@ from structlog import get_logger from passbook.core.views import authentication, overview, user -from passbook.factors import view +from passbook.flows.executor.http import FactorPermissionDeniedView, HttpExecutorView LOGGER = get_logger() @@ -19,7 +19,7 @@ ), path( "auth/process/denied/", - view.FactorPermissionDeniedView.as_view(), + FactorPermissionDeniedView.as_view(), name="auth-denied", ), path( @@ -27,12 +27,7 @@ authentication.PasswordResetView.as_view(), name="auth-password-reset", ), - path("auth/process/", view.AuthenticationView.as_view(), name="auth-process"), - path( - "auth/process//", - view.AuthenticationView.as_view(), - name="auth-process", - ), + path("flows/execute/", HttpExecutorView.as_view(), name="flows-execute"), # User views path("_/user/", user.UserSettingsView.as_view(), name="user-settings"), path("_/user/delete/", user.UserDeleteView.as_view(), name="user-delete"), diff --git a/passbook/core/views/authentication.py b/passbook/core/views/authentication.py index 4749beeda54e..4122dee7989a 100644 --- a/passbook/core/views/authentication.py +++ b/passbook/core/views/authentication.py @@ -16,7 +16,7 @@ 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.factors.view import AuthenticationView, _redirect_with_qs +from passbook.flows.executor.http import bootstrap_http_executor, redirect_with_qs from passbook.lib.config import CONFIG LOGGER = get_logger() @@ -71,8 +71,8 @@ def form_valid(self, form: LoginForm) -> HttpResponse: if not pre_user: # No user found return self.invalid_login(self.request) - self.request.session[AuthenticationView.SESSION_PENDING_USER] = pre_user.pk - return _redirect_with_qs("passbook_core:auth-process", self.request.GET) + bootstrap_http_executor(self.request, pre_user) + return redirect_with_qs("passbook_core:flows-execute", self.request.GET) def invalid_login( self, request: HttpRequest, disabled_user: User = None diff --git a/passbook/factors/base.py b/passbook/factors/base.py index ec44b213dafb..3516e544c2fa 100644 --- a/passbook/factors/base.py +++ b/passbook/factors/base.py @@ -5,21 +5,21 @@ from django.views.generic import TemplateView from passbook.core.models import User -from passbook.factors.view import AuthenticationView +from passbook.flows.executor.http import HttpExecutorView from passbook.lib.config import CONFIG -class AuthenticationFactor(TemplateView): +class Factor(TemplateView): """Abstract Authentication factor, inherits TemplateView but can be combined with FormView""" form: ModelForm = None required: bool = True - authenticator: AuthenticationView + authenticator: HttpExecutorView pending_user: User request: HttpRequest = None template_name = "login/form_with_user.html" - def __init__(self, authenticator: AuthenticationView): + def __init__(self, authenticator: HttpExecutorView): self.authenticator = authenticator self.pending_user = None diff --git a/passbook/factors/dummy/factor.py b/passbook/factors/dummy/factor.py index 63fce66c1f0b..b704e627b2b9 100644 --- a/passbook/factors/dummy/factor.py +++ b/passbook/factors/dummy/factor.py @@ -1,10 +1,10 @@ """passbook multi-factor authentication engine""" from django.http import HttpRequest -from passbook.factors.base import AuthenticationFactor +from passbook.factors.base import Factor -class DummyFactor(AuthenticationFactor): +class DummyFactor(Factor): """Dummy factor for testing with multiple factors""" def post(self, request: HttpRequest): diff --git a/passbook/factors/forms.py b/passbook/factors/forms.py index 6bc2443bb61d..4fa62707ee7d 100644 --- a/passbook/factors/forms.py +++ b/passbook/factors/forms.py @@ -1,3 +1,3 @@ """factor forms""" -GENERAL_FIELDS = ["name", "slug", "order", "policies", "enabled"] +GENERAL_FIELDS = ["name", "slug", "policies"] diff --git a/passbook/factors/otp/forms.py b/passbook/factors/otp/forms.py index b71198a80fed..0c28b97747e3 100644 --- a/passbook/factors/otp/forms.py +++ b/passbook/factors/otp/forms.py @@ -77,7 +77,6 @@ class Meta: fields = GENERAL_FIELDS + ["enforced"] widgets = { "name": forms.TextInput(), - "order": forms.NumberInput(), "policies": FilteredSelectMultiple(_("policies"), False), } help_texts = { diff --git a/passbook/factors/password/factor.py b/passbook/factors/password/factor.py index 0aaf7c7d0e07..09044f7ae867 100644 --- a/passbook/factors/password/factor.py +++ b/passbook/factors/password/factor.py @@ -11,9 +11,9 @@ from structlog import get_logger from passbook.core.models import User -from passbook.factors.base import AuthenticationFactor +from passbook.factors.base import Factor from passbook.factors.password.forms import PasswordForm -from passbook.factors.view import AuthenticationView +from passbook.flows.executor.http import HttpExecutorView from passbook.lib.config import CONFIG from passbook.lib.utils.reflection import path_to_class @@ -52,7 +52,7 @@ def authenticate(request, backends, **credentials) -> Optional[User]: ) -class PasswordFactor(FormView, AuthenticationFactor): +class PasswordFactor(FormView, Factor): """Authentication factor which authenticates against django's AuthBackend""" form_class = PasswordForm @@ -72,10 +72,7 @@ def form_valid(self, form): ) if user: # User instance returned from authenticate() has .backend property set - self.authenticator.pending_user = user - self.request.session[ - AuthenticationView.SESSION_USER_BACKEND - ] = user.backend + self.authenticator.pending_user.backend = user.backend return self.authenticator.user_ok() # No user was found -> invalid credentials LOGGER.debug("Invalid credentials") diff --git a/passbook/factors/password/migrations/0002_auto_20191007_1411.py b/passbook/factors/password/migrations/0002_auto_20191007_1411.py index 3cafedf262a4..8fcc15cf483d 100644 --- a/passbook/factors/password/migrations/0002_auto_20191007_1411.py +++ b/passbook/factors/password/migrations/0002_auto_20191007_1411.py @@ -10,7 +10,6 @@ def create_initial_factor(apps, schema_editor): PasswordFactor.objects.create( name="password", slug="password", - order=0, backends=["django.contrib.auth.backends.ModelBackend"], ) diff --git a/passbook/factors/tests.py b/passbook/factors/tests.py index 3311081a369c..cfdae66e00ae 100644 --- a/passbook/factors/tests.py +++ b/passbook/factors/tests.py @@ -10,7 +10,7 @@ from passbook.core.models import User from passbook.factors.dummy.models import DummyFactor from passbook.factors.password.models import PasswordFactor -from passbook.factors.view import AuthenticationView +from passbook.flows.executor.http import HttpExecutorView class TestFactorAuthentication(TestCase): @@ -36,31 +36,31 @@ def setUp(self): ) def test_unauthenticated_raw(self): - """test direct call to AuthenticationView""" - response = self.client.get(reverse("passbook_core:auth-process")) + """test direct call to HttpExecutorView""" + response = self.client.get(reverse("passbook_core:flows-execute")) # Response should be 400 since no pending user is set self.assertEqual(response.status_code, 400) def test_unauthenticated_prepared(self): """test direct call but with pending_uesr in session""" - request = RequestFactory().get(reverse("passbook_core:auth-process")) + request = RequestFactory().get(reverse("passbook_core:flows-execute")) request.user = AnonymousUser() request.session = {} - request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + request.session[HttpExecutorView.SESSION_PENDING_USER] = self.user.pk - response = AuthenticationView.as_view()(request) + response = HttpExecutorView.as_view()(request) self.assertEqual(response.status_code, 200) def test_no_factors(self): """Test with all factors disabled""" self.factor.enabled = False self.factor.save() - request = RequestFactory().get(reverse("passbook_core:auth-process")) + request = RequestFactory().get(reverse("passbook_core:flows-execute")) request.user = AnonymousUser() request.session = {} - request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + request.session[HttpExecutorView.SESSION_PENDING_USER] = self.user.pk - response = AuthenticationView.as_view()(request) + response = HttpExecutorView.as_view()(request) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse("passbook_core:auth-denied")) self.factor.enabled = True @@ -69,7 +69,7 @@ def test_no_factors(self): def test_authenticated(self): """Test with already logged in user""" self.client.force_login(self.user) - response = self.client.get(reverse("passbook_core:auth-process")) + response = self.client.get(reverse("passbook_core:flows-execute")) # Response should be 400 since no pending user is set self.assertEqual(response.status_code, 400) self.client.logout() @@ -77,15 +77,15 @@ def test_authenticated(self): def test_unauthenticated_post(self): """Test post request as unauthenticated user""" request = RequestFactory().post( - reverse("passbook_core:auth-process"), data={"password": self.password} + reverse("passbook_core:flows-execute"), data={"password": self.password} ) request.user = AnonymousUser() middleware = SessionMiddleware() middleware.process_request(request) request.session.save() # pylint: disable=no-member - request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + request.session[HttpExecutorView.SESSION_PENDING_USER] = self.user.pk - response = AuthenticationView.as_view()(request) + response = HttpExecutorView.as_view()(request) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse("passbook_core:overview")) self.client.logout() @@ -93,16 +93,16 @@ def test_unauthenticated_post(self): def test_unauthenticated_post_invalid(self): """Test post request as unauthenticated user""" request = RequestFactory().post( - reverse("passbook_core:auth-process"), + reverse("passbook_core:flows-execute"), data={"password": self.password + "a"}, ) request.user = AnonymousUser() middleware = SessionMiddleware() middleware.process_request(request) request.session.save() # pylint: disable=no-member - request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + request.session[HttpExecutorView.SESSION_PENDING_USER] = self.user.pk - response = AuthenticationView.as_view()(request) + response = HttpExecutorView.as_view()(request) self.assertEqual(response.status_code, 200) self.client.logout() @@ -110,28 +110,28 @@ def test_multifactor(self): """Test view with multiple active factors""" DummyFactor.objects.get_or_create(name="dummy", slug="dummy", order=1) request = RequestFactory().post( - reverse("passbook_core:auth-process"), data={"password": self.password} + reverse("passbook_core:flows-execute"), data={"password": self.password} ) request.user = AnonymousUser() middleware = SessionMiddleware() middleware.process_request(request) request.session.save() # pylint: disable=no-member - request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + request.session[HttpExecutorView.SESSION_PENDING_USER] = self.user.pk - response = AuthenticationView.as_view()(request) + response = HttpExecutorView.as_view()(request) session_copy = request.session.items() self.assertEqual(response.status_code, 302) # Verify view redirects to itself after auth - self.assertEqual(response.url, reverse("passbook_core:auth-process")) + self.assertEqual(response.url, reverse("passbook_core:flows-execute")) # Run another request with same session which should result in a logged in user - request = RequestFactory().post(reverse("passbook_core:auth-process")) + request = RequestFactory().post(reverse("passbook_core:flows-execute")) request.user = AnonymousUser() middleware = SessionMiddleware() middleware.process_request(request) for key, value in session_copy: request.session[key] = value request.session.save() # pylint: disable=no-member - response = AuthenticationView.as_view()(request) + response = HttpExecutorView.as_view()(request) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse("passbook_core:overview")) diff --git a/passbook/factors/view.py b/passbook/factors/view.py deleted file mode 100644 index a26877edcfe4..000000000000 --- a/passbook/factors/view.py +++ /dev/null @@ -1,220 +0,0 @@ -"""passbook multi-factor authentication engine""" -from typing import List, Optional, Tuple - -from django.contrib.auth import login -from django.contrib.auth.mixins import UserPassesTestMixin -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, reverse -from django.utils.http import urlencode -from django.views.generic import View -from structlog import get_logger - -from passbook.core.models import Factor, User -from passbook.core.views.utils import PermissionDeniedView -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 -from passbook.lib.views import bad_request_message -from passbook.policies.engine import PolicyEngine - -LOGGER = get_logger() -# Argument used to redirect user after login -NEXT_ARG_NAME = "next" - - -def _redirect_with_qs(view, get_query_set=None): - """Wrapper to redirect whilst keeping GET Parameters""" - target = reverse(view) - if get_query_set: - target += "?" + urlencode(get_query_set.items()) - return redirect(target) - - -class AuthenticationView(UserPassesTestMixin, View): - """Wizard-like Multi-factor authenticator""" - - SESSION_FACTOR = "passbook_factor" - SESSION_PENDING_FACTORS = "passbook_pending_factors" - SESSION_PENDING_USER = "passbook_pending_user" - SESSION_USER_BACKEND = "passbook_user_backend" - SESSION_IS_SSO_LOGIN = "passbook_sso_login" - - pending_user: User - pending_factors: List[Tuple[str, str]] = [] - - _current_factor_class: Factor - - current_factor: Factor - - # Allow only not authenticated users to login - def test_func(self) -> bool: - return AuthenticationView.SESSION_PENDING_USER in self.request.session - - def _check_config_domain(self) -> Optional[HttpResponse]: - """Checks if current request's domain matches configured Domain, and - adds a warning if not.""" - current_domain = self.request.get_host() - if ":" in current_domain: - current_domain, _ = current_domain.split(":") - config_domain = CONFIG.y("domain") - if current_domain != config_domain: - message = ( - f"Current domain of '{current_domain}' doesn't " - f"match configured domain of '{config_domain}'." - ) - LOGGER.warning(message) - return bad_request_message(self.request, message) - return None - - def handle_no_permission(self) -> HttpResponse: - # Function from UserPassesTestMixin - if NEXT_ARG_NAME in self.request.GET: - return redirect(self.request.GET.get(NEXT_ARG_NAME)) - if self.request.user.is_authenticated: - return _redirect_with_qs("passbook_core:overview", self.request.GET) - return _redirect_with_qs("passbook_core:auth-login", self.request.GET) - - def get_pending_factors(self) -> List[Tuple[str, str]]: - """Loading pending factors from Database or load from session variable""" - # Write pending factors to session - if AuthenticationView.SESSION_PENDING_FACTORS in self.request.session: - return self.request.session[AuthenticationView.SESSION_PENDING_FACTORS] - # Get an initial list of factors which are currently enabled - # and apply to the current user. We check policies here and block the request - _all_factors = ( - Factor.objects.filter(enabled=True).order_by("order").select_subclasses() - ) - pending_factors = [] - for factor in _all_factors: - factor: Factor - LOGGER.debug( - "Checking if factor applies to user", - factor=factor, - user=self.pending_user, - ) - policy_engine = PolicyEngine( - factor.policies.all(), self.pending_user, self.request - ) - policy_engine.build() - if policy_engine.passing: - pending_factors.append((factor.uuid.hex, factor.type)) - LOGGER.debug("Factor applies", factor=factor, user=self.pending_user) - return pending_factors - - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - # Check if user passes test (i.e. SESSION_PENDING_USER is set) - user_test_result = self.get_test_func()() - if not user_test_result: - incorrect_domain_message = self._check_config_domain() - if incorrect_domain_message: - return incorrect_domain_message - return self.handle_no_permission() - # Extract pending user from session (only remember uid) - self.pending_user = get_object_or_404( - User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER] - ) - self.pending_factors = self.get_pending_factors() - # Read and instantiate factor from session - factor_uuid, factor_class = None, None - if AuthenticationView.SESSION_FACTOR not in request.session: - # Case when no factors apply to user, return error denied - if not self.pending_factors: - # Case when user logged in from SSO provider and no more factors apply - if AuthenticationView.SESSION_IS_SSO_LOGIN in request.session: - LOGGER.debug("User authenticated with SSO, logging in...") - return self._user_passed() - return self.user_invalid() - factor_uuid, factor_class = self.pending_factors[0] - else: - factor_uuid, factor_class = request.session[ - AuthenticationView.SESSION_FACTOR - ] - # Lookup current factor object - self.current_factor = ( - Factor.objects.filter(uuid=factor_uuid).select_subclasses().first() - ) - # Instantiate Next Factor and pass request - factor = path_to_class(factor_class) - self._current_factor_class = factor(self) - self._current_factor_class.pending_user = self.pending_user - self._current_factor_class.request = request - return super().dispatch(request, *args, **kwargs) - - 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_class.__class__), - ) - return self._current_factor_class.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_class.__class__), - ) - return self._current_factor_class.post(request, *args, **kwargs) - - def user_ok(self) -> HttpResponse: - """Redirect to next Factor""" - LOGGER.debug( - "Factor passed", - factor_class=class_to_path(self._current_factor_class.__class__), - ) - # Remove passed factor from pending factors - current_factor_tuple = ( - self.current_factor.uuid.hex, - class_to_path(self._current_factor_class.__class__), - ) - if current_factor_tuple in self.pending_factors: - self.pending_factors.remove(current_factor_tuple) - next_factor = None - if self.pending_factors: - next_factor = self.pending_factors.pop() - # Save updated pening_factor list to session - self.request.session[ - AuthenticationView.SESSION_PENDING_FACTORS - ] = self.pending_factors - self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor - LOGGER.debug("Rendering Factor", next_factor=next_factor) - return _redirect_with_qs("passbook_core:auth-process", self.request.GET) - # User passed all factors - LOGGER.debug("User passed all factors, logging in", user=self.pending_user) - return self._user_passed() - - def user_invalid(self) -> HttpResponse: - """Show error message, user cannot login. - This should only be shown if user authenticated successfully, but is disabled/locked/etc""" - LOGGER.debug("User invalid") - self.cleanup() - return _redirect_with_qs("passbook_core:auth-denied", self.request.GET) - - def _user_passed(self) -> HttpResponse: - """User Successfully passed all factors""" - backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND] - login(self.request, self.pending_user, backend=backend) - LOGGER.debug("Logged in", user=self.pending_user) - # Cleanup - self.cleanup() - next_param = self.request.GET.get(NEXT_ARG_NAME, None) - if next_param and not is_url_absolute(next_param): - return redirect(next_param) - return _redirect_with_qs("passbook_core:overview") - - def cleanup(self): - """Remove temporary data from session""" - session_keys = [ - self.SESSION_FACTOR, - self.SESSION_PENDING_FACTORS, - self.SESSION_PENDING_USER, - self.SESSION_USER_BACKEND, - ] - for key in session_keys: - if key in self.request.session: - del self.request.session[key] - LOGGER.debug("Cleaned up sessions") - - -class FactorPermissionDeniedView(PermissionDeniedView): - """User could not be authenticated""" diff --git a/passbook/flows/__init__.py b/passbook/flows/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/passbook/flows/admin.py b/passbook/flows/admin.py new file mode 100644 index 000000000000..e1c2e5c587c8 --- /dev/null +++ b/passbook/flows/admin.py @@ -0,0 +1,5 @@ +"""passbook flows model admin""" + +from passbook.lib.admin import admin_autoregister + +admin_autoregister("passbook_flows") diff --git a/passbook/flows/api/__init__.py b/passbook/flows/api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/passbook/flows/api/execute.py b/passbook/flows/api/execute.py new file mode 100644 index 000000000000..63c1ecb48af2 --- /dev/null +++ b/passbook/flows/api/execute.py @@ -0,0 +1,118 @@ +"""Flow Execution API""" +from typing import List, Optional, Tuple + +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet +from structlog import get_logger + +from passbook.core.models import Factor, User +from passbook.flows.api.serializers import ( + ChallengeCapabilities, + ChallengeRequestSerializer, + ChallengeResponseSerializer, + InitiateFlowExecutionSerializer, +) +from passbook.flows.models import Flow +from passbook.lib.config import CONFIG + +LOGGER = get_logger() + +SESSION_PENDING_USER = "passbook_flows_pending_user" +SESSION_PENDING_FACTORS = "passbook_flows_pending_factors" + + +def get_user_by_uid(uid_value: str) -> Optional[User]: + """Find user instance. Returns None if no user was found.""" + for search_field in CONFIG.y("passbook.uid_fields"): + # Workaround for E-Mail -> email + if search_field == "e-mail": + search_field = "email" + users = User.objects.filter(**{search_field: uid_value}) + if users.exists(): + LOGGER.debug("Found user", user=users.first(), uid_field=search_field) + return users.first() + return None + + +class FlowsExecuteViewSet(ViewSet): + """Views to execute flows""" + + authentication_classes = [] + queryset = Flow.objects.none() + http_method_names = ["get", "put", "post"] + + pending_flow: Flow + pending_user: User + + @swagger_auto_schema( + methods=["PUT"], + request_body=InitiateFlowExecutionSerializer, + responses={201: "Successfully initiated Flow Execution", 404: "UID not found."}, + ) + @action(methods=["PUT"], detail=True) + def initiate(self, request: Request, pk) -> Response: + # lookup flow, save pk, 404 if not found + self.pending_flow = get_object_or_404(Flow, id=pk) + # Get user by UID, save pk, 404 if not found + req = InitiateFlowExecutionSerializer(data=request.data) + if not req.is_valid(): + return Response(req.errors) + self.pending_user = get_user_by_uid(req.validated_data.get("user_identifier")) + if not self.pending_user: + raise Http404 + # save list of factors of flow, in order + + @swagger_auto_schema( + methods=["GET"], responses={200: ChallengeRequestSerializer(many=False)} + ) + @swagger_auto_schema(methods=["POST"], request_body=ChallengeResponseSerializer) + @action(methods=["GET", "POST"], detail=True) + def challenge(self, request: Request, pk) -> Response: + # 404 if not initiated + if request.method.lower() == "post": + return self.resolve_challenge(request, pk) + return self.get_challenge(request, pk) + + def get_challenge(self, request: Request, pk) -> Response: + # pop next factor off of queued factors + # load pending user and factor + # check if policy applies + # return challenge + pass + + def resolve_challenge(self, request: Request, pk) -> Response: + # load next factor from queue + # initialise with pending_user and request + # user_ok or user_fail + pass + + def get_pending_factors(self) -> List[Factor]: + """Loading pending factors from Database or load from session variable""" + # Write pending factors to session + if SESSION_PENDING_FACTORS in self.request.session: + return self.request.session[SESSION_PENDING_FACTORS] + # Get an initial list of factors which are currently enabled + # and apply to the current user. We check policies here and block the request + _all_factors = ( + Factor.objects.filter(enabled=True).order_by("order").select_subclasses() + ) + pending_factors = [] + for factor in _all_factors: + LOGGER.debug( + "Checking if factor applies to user", + factor=factor, + user=self.pending_user, + ) + policy_engine = PolicyEngine( + factor.policies.all(), self.pending_user, self.request + ) + policy_engine.build() + if policy_engine.passing: + pending_factors.append((factor.uuid.hex, factor.type)) + LOGGER.debug("Factor applies", factor=factor, user=self.pending_user) + return pending_factors diff --git a/passbook/flows/api/serializers.py b/passbook/flows/api/serializers.py new file mode 100644 index 000000000000..07a1cf22a127 --- /dev/null +++ b/passbook/flows/api/serializers.py @@ -0,0 +1,33 @@ +from enum import IntFlag + +from rest_framework import serializers + + +class ChallengeCapabilities(IntFlag): + """Capabilities a client can have, Bitwise combined.""" + + # Standard Password Input. Note that this is only a single Input, and does not apply + # to TOTP for example + Password = 1 + # Client that can wait, i.e. for Notification-based 2FA like Authy. + Wait = 2 + # Client supports additional (Text for totp, for example) + AuxiliaryInput = 4 + + # This is a special capability that is only possible in the HTML Frontend. + JSLoading = 100 + + +class InitiateFlowExecutionSerializer(serializers.Serializer): + # TODO: write current instance in session + + capabilities = serializers.IntegerField(required=True) + user_identifier = serializers.CharField() + + +class ChallengeRequestSerializer(serializers.Serializer): + pass + + +class ChallengeResponseSerializer(serializers.Serializer): + pass diff --git a/passbook/flows/apps.py b/passbook/flows/apps.py new file mode 100644 index 000000000000..7d0ce19fb231 --- /dev/null +++ b/passbook/flows/apps.py @@ -0,0 +1,12 @@ +"""passbook Flows AppConfig""" + +from django.apps import AppConfig + + +class PassbookFlowsConfig(AppConfig): + """passbook Flows Config""" + + name = "passbook.flows" + label = "passbook_flows" + # mountpoint = "flows/" + verbose_name = "passbook Flows" diff --git a/passbook/flows/executor/__init__.py b/passbook/flows/executor/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/passbook/flows/executor/base.py b/passbook/flows/executor/base.py new file mode 100644 index 000000000000..42f06b2d6748 --- /dev/null +++ b/passbook/flows/executor/base.py @@ -0,0 +1,99 @@ +from typing import List, Optional + +from structlog import get_logger + +from passbook.core.models import Factor, User +from passbook.flows.executor.state import FlowState +from passbook.flows.models import FactorBinding, Flow + +LOGGER = get_logger() + + +class FlowExecutor: + + _state: FlowState + + _flow: Flow + _factor_bindings: List[FactorBinding] + _pending_user: User + + _current_factor_binding: FactorBinding + _current_factor: Factor + + def __init__(self): + self._state = None + self._flow = None + self._factor_bindings = [] + self._pending_user = None + self._current_factor = None + self._current_factor_binding = None + + def state_restore(self): + raise NotImplementedError() + + def state_persist(self): + raise NotImplementedError() + + def state_cleanup(self): + raise NotImplementedError() + + @property + def flow(self) -> Flow: + if not self._flow: + self._flow = Flow.objects.get(pk=self._state.flow_pk) + return self._flow + + @property + def pending_factors(self) -> List[FactorBinding]: + if self._factor_bindings: + return self._factor_bindings + + factors = FactorBinding.objects.filter(flow=self.flow.pk).order_by("order") + if self._state.factor_binding_last_order > 0: + factors = factors.filter(order__gt=self._state.factor_binding_last_order) + self._factor_bindings = list(factors) + # TODO: When Factors have policies, check them here + return self._factor_bindings + + @property + def pending_user(self) -> User: + if not self._pending_user: + self._pending_user = User.objects.get(pk=self._state.pending_user_pk) + return self._pending_user + + def _pop_next_factor(self) -> Optional[FactorBinding]: + # If we don't have any more factors pending, return here + if len(self.pending_factors) < 1: + return None + return self._factor_bindings.pop(0) + + def get_next_factor(self) -> Optional[Factor]: + # Check if we've already got a factor loaded that needs solving + if not self._current_factor: + # Check if we have an existing FactorBinding, and pop the next one + # we *dont* persist the state here, as this factor is still in progress + if not self._current_factor_binding: + # There might not be any more factors left, return none in that case + popped_factor = self._pop_next_factor() + if not popped_factor: + LOGGER.debug("B_EX: factors exhausted") + return None + self._current_factor_binding = popped_factor + # Make sure we have the correct subclass of the factor + self._current_factor = Factor.objects.get_subclass( + pk=self._current_factor_binding.factor + ) + return self._current_factor + + def factor_passed(self): + LOGGER.debug("B_EX: factor_passed", factor=self._current_factor_binding) + self._state.factor_binding_last_order = self._current_factor_binding.order + self._pop_next_factor() + self.state_persist() + + def factor_failed(self): + self.state_cleanup() + + def passed(self): + LOGGER.debug("B_EX: Logged in", user=self.pending_user) + self.state_cleanup() diff --git a/passbook/flows/executor/http.py b/passbook/flows/executor/http.py new file mode 100644 index 000000000000..3e83799c36ec --- /dev/null +++ b/passbook/flows/executor/http.py @@ -0,0 +1,158 @@ +from typing import Any, List, Optional, Tuple + +from django.contrib.auth import login +from django.contrib.auth.mixins import UserPassesTestMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect, reverse +from django.views.generic import View +from structlog import get_logger + +from passbook.core.models import Factor, User +from passbook.core.views.utils import PermissionDeniedView +from passbook.flows.executor.base import FlowExecutor +from passbook.flows.executor.state import FlowState +from passbook.flows.models import Flow +from passbook.lib.config import CONFIG +from passbook.lib.utils.http import redirect_with_qs +from passbook.lib.utils.reflection import class_to_path, path_to_class +from passbook.lib.utils.urls import is_url_absolute +from passbook.lib.views import bad_request_message +from passbook.policies.engine import PolicyEngine + +LOGGER = get_logger() +SESSION_STATE_KEY = "passbook_flows_state" +NEXT_ARG_NAME = "next" + + +def check_config_domain(request: HttpRequest) -> Optional[HttpResponse]: + """Checks if current request's domain matches configured Domain, and + adds a warning if not.""" + current_domain = request.get_host() + if ":" in current_domain: + current_domain, _ = current_domain.split(":") + config_domain = CONFIG.y("domain") + if current_domain != config_domain: + message = ( + f"Current domain of '{current_domain}' doesn't " + f"match configured domain of '{config_domain}'." + ) + LOGGER.warning(message) + return bad_request_message(request, message) + return None + + +def bootstrap_http_executor(request: HttpRequest, pending_user: User): + """Bootstrap HttpExecutor by creating the initial state in the user's session""" + state = FlowState( + pending_user_pk=pending_user.pk, + flow_pk=Flow.objects.filter(designation="auth").first().pk, + ) + request.session[SESSION_STATE_KEY] = state + + +class HttpExecutor(FlowExecutor): + + request: HttpRequest + + def state_restore(self): + self._state = self.request.session[SESSION_STATE_KEY] + LOGGER.debug("HTTP_EX(state): state_restore", state=self._state) + + def state_persist(self): + self.request.session[SESSION_STATE_KEY] = self._state + LOGGER.debug("HTTP_EX(state): state_persist", state=self._state) + + def state_cleanup(self): + del self.request.session[SESSION_STATE_KEY] + LOGGER.debug("HTTP_EX(state): state_clear") + + +class HttpExecutorView(HttpExecutor, View): + """Wizard-like Multi-factor authenticator""" + + def _check_config_domain(self) -> Optional[HttpResponse]: + """Checks if current request's domain matches configured Domain, and + adds a warning if not.""" + current_domain = self.request.get_host() + if ":" in current_domain: + current_domain, _ = current_domain.split(":") + config_domain = CONFIG.y("domain") + if current_domain != config_domain: + message = ( + f"Current domain of '{current_domain}' doesn't " + f"match configured domain of '{config_domain}'." + ) + LOGGER.warning(message) + return bad_request_message(self.request, message) + return None + + def handle_no_state(self) -> HttpResponse: + if NEXT_ARG_NAME in self.request.GET: + return redirect_with_qs(self.request.GET.get(NEXT_ARG_NAME)) + if self.request.user.is_authenticated: + return redirect_with_qs("passbook_core:overview", self.request.GET) + return redirect_with_qs("passbook_core:auth-login", self.request.GET) + + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + # Check if user passes test (i.e. SESSION_PENDING_USER is set) + user_test_result = SESSION_STATE_KEY in self.request.session + if not user_test_result: + incorrect_domain_message = self._check_config_domain() + if incorrect_domain_message: + return incorrect_domain_message + return self.handle_no_state() + + self.state_restore() + + # Lookup current factor object + self.current_factor = self.get_next_factor() + if not self.current_factor: + self.passed() + next_param = self.request.GET.get(NEXT_ARG_NAME, None) + if next_param and not is_url_absolute(next_param): + return redirect(next_param) + return redirect_with_qs("passbook_core:overview") + # Instantiate Next Factor and pass request + self._current_factor_class = self.current_factor.factor_class(self) + self._current_factor_class.pending_user = self.pending_user + self._current_factor_class.request = request + return super().dispatch(request, *args, **kwargs) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """pass get request to current factor""" + LOGGER.debug( + "HTTP_EX: forwarding GET", factor=self._current_factor_class.__class__, + ) + return self._current_factor_class.get(request, *args, **kwargs) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """pass post request to current factor""" + LOGGER.debug( + "HTTP_EX: forwarding POST", factor=self._current_factor_class.__class__, + ) + return self._current_factor_class.post(request, *args, **kwargs) + + def user_ok(self) -> HttpResponse: + """Redirect to next Factor""" + self.factor_passed() + LOGGER.debug("HTTP_EX: Redirecting to next factor") + return redirect_with_qs("passbook_core:flows-execute", self.request.GET) + + def user_invalid(self) -> HttpResponse: + """Show error message, user cannot login. + This should only be shown if user authenticated successfully, but is disabled/locked/etc""" + LOGGER.debug("HTTP_EX: User invalid") + self.factor_failed() + return redirect_with_qs("passbook_core:auth-denied", self.request.GET) + + def passed(self): + super().passed() + login( + self.request, + self.pending_user, + backend=self._state.user_authentication_backend, + ) + + +class FactorPermissionDeniedView(PermissionDeniedView): + """User could not be authenticated""" diff --git a/passbook/flows/executor/radius.py b/passbook/flows/executor/radius.py new file mode 100644 index 000000000000..83a4aba22f4e --- /dev/null +++ b/passbook/flows/executor/radius.py @@ -0,0 +1,6 @@ +from passbook.flows.executor.base import FlowExecutor + + +class RadiusExecuter(FlowExecutor): + def on_user(self): + pass diff --git a/passbook/flows/executor/state.py b/passbook/flows/executor/state.py new file mode 100644 index 000000000000..a05fb2fa4e53 --- /dev/null +++ b/passbook/flows/executor/state.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import Optional +from uuid import UUID + + +@dataclass +class FlowState: + + flow_pk: Optional[UUID] = None + pending_user_pk: int = -1 + factor_binding_last_order: int = -1 + user_authentication_backend: str = "django.contrib.auth.backends.ModelBackend" diff --git a/passbook/flows/forms.py b/passbook/flows/forms.py new file mode 100644 index 000000000000..e59fe1f53d17 --- /dev/null +++ b/passbook/flows/forms.py @@ -0,0 +1,20 @@ +"""passbook Flow forms""" + +from django import forms + +from passbook.flows.models import Flow + + +class FlowForm(forms.ModelForm): + """Flow Form""" + + class Meta: + + model = Flow + fields = [ + "slug", + "designation", + ] + widgets = { + "slug": forms.TextInput(), + } diff --git a/passbook/flows/migrations/0001_initial.py b/passbook/flows/migrations/0001_initial.py new file mode 100644 index 000000000000..f4852250f42c --- /dev/null +++ b/passbook/flows/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# Generated by Django 3.0.3 on 2020-03-02 20:35 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("passbook_core", "0011_auto_20200222_1822"), + ] + + operations = [ + migrations.CreateModel( + name="FactorBinding", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("order", models.IntegerField()), + ( + "factor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="passbook_core.Factor", + ), + ), + ], + ), + migrations.CreateModel( + name="Flow", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("slug", models.SlugField(unique=True)), + ( + "designation", + models.CharField( + choices=[ + ("enroll", "Enroll"), + ("auth", "Authentication"), + ("recovery", "Recovery"), + ], + max_length=100, + ), + ), + ( + "factors", + models.ManyToManyField( + through="passbook_flows.FactorBinding", + to="passbook_core.Factor", + ), + ), + ], + options={"abstract": False,}, + ), + migrations.AddField( + model_name="factorbinding", + name="flow", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="passbook_flows.Flow" + ), + ), + migrations.AlterUniqueTogether( + name="factorbinding", unique_together={("flow", "factor", "order")}, + ), + ] diff --git a/passbook/flows/migrations/__init__.py b/passbook/flows/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/passbook/flows/models.py b/passbook/flows/models.py new file mode 100644 index 000000000000..ef3e841d5336 --- /dev/null +++ b/passbook/flows/models.py @@ -0,0 +1,39 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from passbook.core.models import Factor +from passbook.lib.models import UUIDModel + + +# TODO: Add PolicyModel +class FactorBinding(UUIDModel): + + flow = models.ForeignKey("Flow", on_delete=models.CASCADE) + factor = models.ForeignKey(Factor, on_delete=models.CASCADE) + order = models.IntegerField() + + class Meta: + + unique_together = (("flow", "factor", "order"),) + + +class Flow(UUIDModel): + + slug = models.SlugField(unique=True) + factors = models.ManyToManyField(Factor, through=FactorBinding) + designation = models.CharField( + max_length=100, + choices=( + ("enroll", _("Enroll")), + ("auth", _("Authentication")), + ("recovery", _("Recovery")), + ), + ) + + def __str__(self): + return f"Flow {self.slug}" + + class Meta: + + verbose_name = _("Flow") + verbose_name_plural = _("Flows") diff --git a/passbook/lib/utils/http.py b/passbook/lib/utils/http.py index 22dc038b65f4..7a917dd8c249 100644 --- a/passbook/lib/utils/http.py +++ b/passbook/lib/utils/http.py @@ -1,7 +1,9 @@ """http helpers""" from typing import Any, Dict, Optional -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, reverse +from django.utils.http import urlencode def _get_client_ip_from_meta(meta: Dict[str, Any]) -> Optional[str]: @@ -22,3 +24,12 @@ def get_client_ip(request: HttpRequest) -> Optional[str]: """Attempt to get the client's IP by checking common HTTP Headers. Returns none if no IP Could be found""" return _get_client_ip_from_meta(request.META) + + +def redirect_with_qs(view: str, get_query_set=None) -> HttpResponse: + """Wrapper to redirect whilst keeping GET Parameters""" + # TODO: Check if URL is relative/absolute + target = reverse(view) + if get_query_set: + target += "?" + urlencode(get_query_set.items()) + return redirect(target) diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py index 2a0e0193afb7..f9ec76bbbb9c 100644 --- a/passbook/policies/expression/evaluator.py +++ b/passbook/policies/expression/evaluator.py @@ -8,7 +8,6 @@ from jinja2.nativetypes import NativeEnvironment from structlog import get_logger -from passbook.factors.view import AuthenticationView from passbook.lib.utils.http import get_client_ip from passbook.policies.types import PolicyRequest, PolicyResult @@ -55,7 +54,7 @@ def _get_expression_context( kwargs["pb_logger"] = get_logger() if request.http_request: kwargs["pb_is_sso_flow"] = request.http_request.session.get( - AuthenticationView.SESSION_IS_SSO_LOGIN, False + "pb_is_sso_flow", False # TODO: "pb_is_sso_flow" as Constant somewhere ) kwargs["pb_client_ip"] = ( get_client_ip(request.http_request) or "255.255.255.255" diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 503d8da139d7..2fd2ba414f6e 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -83,6 +83,7 @@ "passbook.static.apps.PassbookStaticConfig", "passbook.admin.apps.PassbookAdminConfig", "passbook.api.apps.PassbookAPIConfig", + "passbook.flows.apps.PassbookFlowsConfig", "passbook.lib.apps.PassbookLibConfig", "passbook.audit.apps.PassbookAuditConfig", "passbook.recovery.apps.PassbookRecoveryConfig", diff --git a/passbook/sources/oauth/views/core.py b/passbook/sources/oauth/views/core.py index 5c0508c0abbf..e23297dcce20 100644 --- a/passbook/sources/oauth/views/core.py +++ b/passbook/sources/oauth/views/core.py @@ -13,7 +13,6 @@ from structlog import get_logger from passbook.audit.models import Event, EventAction -from passbook.factors.view import AuthenticationView, _redirect_with_qs from passbook.sources.oauth.clients import get_client from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection @@ -165,10 +164,8 @@ def handle_login(self, user, source, access): user = authenticate( source=access.source, identifier=access.identifier, request=self.request ) - self.request.session[AuthenticationView.SESSION_PENDING_USER] = user.pk - self.request.session[AuthenticationView.SESSION_USER_BACKEND] = user.backend - self.request.session[AuthenticationView.SESSION_IS_SSO_LOGIN] = True - return _redirect_with_qs("passbook_core:auth-process", self.request.GET) + # TODO: Use flow http bootstrap + return redirect_with_qs("passbook_core:flows-execute", self.request.GET) # pylint: disable=unused-argument def handle_existing_user(self, source, user, access, info):