From c450ffe33d5d7a7c25d963e1443441fa410952f5 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 21 Jan 2020 09:05:37 +0100 Subject: [PATCH 1/3] flows: initial API spec and implementation --- passbook/api/v2/urls.py | 4 +- passbook/audit/{api/events.py => api.py} | 2 +- passbook/core/models.py | 2 +- passbook/factors/view.py | 39 -------- passbook/{audit/api => flows}/__init__.py | 0 passbook/flows/api/__init__.py | 0 passbook/flows/api/execute.py | 117 ++++++++++++++++++++++ passbook/flows/api/serializers.py | 32 ++++++ passbook/flows/apps.py | 12 +++ passbook/flows/migrations/0001_initial.py | 70 +++++++++++++ passbook/flows/migrations/__init__.py | 0 passbook/flows/models.py | 21 ++++ passbook/providers/app_gw/api.py | 2 +- passbook/root/settings.py | 1 + 14 files changed, 259 insertions(+), 43 deletions(-) rename passbook/audit/{api/events.py => api.py} (95%) rename passbook/{audit/api => flows}/__init__.py (100%) create mode 100644 passbook/flows/api/__init__.py create mode 100644 passbook/flows/api/execute.py create mode 100644 passbook/flows/api/serializers.py create mode 100644 passbook/flows/apps.py create mode 100644 passbook/flows/migrations/0001_initial.py create mode 100644 passbook/flows/migrations/__init__.py create mode 100644 passbook/flows/models.py diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index a7b18b39cc1b..ffba1a0ed626 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -7,7 +7,7 @@ from structlog import get_logger from passbook.api.permissions import CustomObjectPermissions -from passbook.audit.api.events import EventViewSet +from passbook.audit.api import EventViewSet from passbook.core.api.applications import ApplicationViewSet from passbook.core.api.factors import FactorViewSet from passbook.core.api.groups import GroupViewSet @@ -37,6 +37,7 @@ from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet from passbook.sources.oauth.api import OAuthSourceViewSet +from passbook.flows.api.execute import FlowsExecuteViewSet LOGGER = get_logger() router = routers.DefaultRouter() @@ -78,6 +79,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/audit/api/events.py b/passbook/audit/api.py similarity index 95% rename from passbook/audit/api/events.py rename to passbook/audit/api.py index 8c0652bcc5c2..eafde6f4d4d2 100644 --- a/passbook/audit/api/events.py +++ b/passbook/audit/api.py @@ -18,7 +18,7 @@ class Meta: "date", "app", "context", - "request_ip", + "client_ip", "created", ] diff --git a/passbook/core/models.py b/passbook/core/models.py index 645fe0c292c4..17169cdb9159 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -11,10 +11,10 @@ from django.urls import reverse_lazy from django.utils.timezone import now from django.utils.translation import gettext as _ +from django_prometheus.models import ExportModelOperationsMixin from guardian.mixins import GuardianUserMixin from model_utils.managers import InheritanceManager from structlog import get_logger -from django_prometheus.models import ExportModelOperationsMixin from passbook.core.signals import password_changed from passbook.lib.models import CreatedUpdatedModel, UUIDModel diff --git a/passbook/factors/view.py b/passbook/factors/view.py index b3546c216eb4..d24958fee660 100644 --- a/passbook/factors/view.py +++ b/passbook/factors/view.py @@ -30,19 +30,6 @@ def _redirect_with_qs(view, get_query_set=None): 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): return AuthenticationView.SESSION_PENDING_USER in self.request.session @@ -55,32 +42,6 @@ def handle_no_permission(self): 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): - """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: - 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, *args, **kwargs): # Check if user passes test (i.e. SESSION_PENDING_USER is set) user_test_result = self.get_test_func()() diff --git a/passbook/audit/api/__init__.py b/passbook/flows/__init__.py similarity index 100% rename from passbook/audit/api/__init__.py rename to passbook/flows/__init__.py 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..f3f629f71b2f --- /dev/null +++ b/passbook/flows/api/execute.py @@ -0,0 +1,117 @@ +"""Flow Execution API""" +from typing import List, Tuple, Optional +from rest_framework.viewsets import ViewSet +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework.decorators import action +from passbook.flows.api.serializers import ( + ChallengeRequestSerializer, + ChallengeResponseSerializer, + InitiateFlowExecutionSerializer, +) +from passbook.flows.models import Flow +from drf_yasg.utils import swagger_auto_schema +from django.http import Http404 +from passbook.core.models import Factor +from structlog import get_logger +from django.shortcuts import get_object_or_404 +from passbook.core.models import User +from passbook.lib.config import CONFIG +from passbook.flows.api.serializers import ChallengeCapabilities + +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..90542cc00988 --- /dev/null +++ b/passbook/flows/api/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from enum import IntFlag + + +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/migrations/0001_initial.py b/passbook/flows/migrations/0001_initial.py new file mode 100644 index 000000000000..6ad39a47f49f --- /dev/null +++ b/passbook/flows/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 2.2.9 on 2020-01-20 20:30 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("passbook_core", "0005_merge_20191025_2022"), + ] + + operations = [ + migrations.CreateModel( + name="Flow", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("slug", models.SlugField(unique=True)), + ], + options={"abstract": False,}, + ), + migrations.CreateModel( + name="FlowToFactor", + 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", + ), + ), + ( + "flow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="passbook_flows.Flow", + ), + ), + ], + options={"unique_together": {("flow", "factor", "order")},}, + ), + migrations.AddField( + model_name="flow", + name="factors", + field=models.ManyToManyField( + through="passbook_flows.FlowToFactor", to="passbook_core.Factor" + ), + ), + ] 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..f4b06c656a79 --- /dev/null +++ b/passbook/flows/models.py @@ -0,0 +1,21 @@ +from django.db import models +from passbook.lib.models import UUIDModel +from passbook.core.models import Factor + + +class FlowToFactor(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=FlowToFactor) + # TODO: requireed_policies to apply flows to authenticated users only? diff --git a/passbook/providers/app_gw/api.py b/passbook/providers/app_gw/api.py index 39ccbc4b64cf..c4694f8b98da 100644 --- a/passbook/providers/app_gw/api.py +++ b/passbook/providers/app_gw/api.py @@ -34,7 +34,7 @@ def update(self, instance, validated_data): class Meta: model = ApplicationGatewayProvider - fields = ["pk", "name", "host", "client"] + fields = ["pk", "name", "internal_host", "external_host", "client"] read_only_fields = ["client"] diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 39b066c19ca4..2c214ce0bc73 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -75,6 +75,7 @@ "passbook.core.apps.PassbookCoreConfig", "passbook.admin.apps.PassbookAdminConfig", "passbook.api.apps.PassbookAPIConfig", + "passbook.flows.apps.PassbookFlowsConfig", "passbook.lib.apps.PassbookLibConfig", "passbook.audit.apps.PassbookAuditConfig", "passbook.recovery.apps.PassbookRecoveryConfig", From a54b4e543dc5eb4547cd6dcab1f3aade550d4d38 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 3 Mar 2020 16:21:04 +0100 Subject: [PATCH 2/3] initial flows implementation --- passbook/api/v2/urls.py | 2 +- passbook/core/models.py | 9 + .../core/templates/login/factors/backend.html | 2 +- .../core/tests/test_views_authentication.py | 2 +- passbook/core/urls.py | 11 +- passbook/core/views/authentication.py | 6 +- passbook/factors/base.py | 8 +- passbook/factors/password/factor.py | 15 +- passbook/factors/tests.py | 44 ++-- passbook/factors/view.py | 207 ------------------ passbook/flows/api/execute.py | 23 +- passbook/flows/api/serializers.py | 3 +- passbook/flows/executor/__init__.py | 0 passbook/flows/executor/base.py | 98 +++++++++ passbook/flows/executor/http.py | 192 ++++++++++++++++ passbook/flows/executor/radius.py | 6 + passbook/flows/executor/state.py | 12 + passbook/flows/migrations/0001_initial.py | 58 +++-- passbook/flows/models.py | 18 +- passbook/lib/utils/http.py | 13 +- passbook/policies/expression/evaluator.py | 3 +- passbook/sources/oauth/views/core.py | 7 +- 22 files changed, 438 insertions(+), 301 deletions(-) delete mode 100644 passbook/factors/view.py create mode 100644 passbook/flows/executor/__init__.py create mode 100644 passbook/flows/executor/base.py create mode 100644 passbook/flows/executor/http.py create mode 100644 passbook/flows/executor/radius.py create mode 100644 passbook/flows/executor/state.py diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index b1b90dd97f4c..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 @@ -35,7 +36,6 @@ from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet from passbook.sources.oauth.api import OAuthSourceViewSet -from passbook.flows.api.execute import FlowsExecuteViewSet LOGGER = get_logger() router = routers.DefaultRouter() diff --git a/passbook/core/models.py b/passbook/core/models.py index 25d33db16d32..103dfac19f70 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 @@ -117,6 +118,14 @@ class Factor(ExportModelOperationsMixin("factor"), PolicyModel): 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/password/factor.py b/passbook/factors/password/factor.py index 0aaf7c7d0e07..1fbfe090b924 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 @@ -65,17 +65,16 @@ def form_valid(self, form): "password": form.cleaned_data.get("password"), } for uid_field in uid_fields: - kwargs[uid_field] = getattr(self.authenticator.pending_user, uid_field) + kwargs[uid_field] = getattr( + self.authenticator.executor.pending_user, uid_field + ) try: user = authenticate( self.request, self.authenticator.current_factor.backends, **kwargs ) 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.executor.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/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 4cedcd4e3387..000000000000 --- a/passbook/factors/view.py +++ /dev/null @@ -1,207 +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""" - - # 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/api/execute.py b/passbook/flows/api/execute.py index f3f629f71b2f..63c1ecb48af2 100644 --- a/passbook/flows/api/execute.py +++ b/passbook/flows/api/execute.py @@ -1,23 +1,24 @@ """Flow Execution API""" -from typing import List, Tuple, Optional -from rest_framework.viewsets import ViewSet -from rest_framework.response import Response -from rest_framework.request import Request +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 drf_yasg.utils import swagger_auto_schema -from django.http import Http404 -from passbook.core.models import Factor -from structlog import get_logger -from django.shortcuts import get_object_or_404 -from passbook.core.models import User from passbook.lib.config import CONFIG -from passbook.flows.api.serializers import ChallengeCapabilities LOGGER = get_logger() diff --git a/passbook/flows/api/serializers.py b/passbook/flows/api/serializers.py index 90542cc00988..07a1cf22a127 100644 --- a/passbook/flows/api/serializers.py +++ b/passbook/flows/api/serializers.py @@ -1,6 +1,7 @@ -from rest_framework import serializers from enum import IntFlag +from rest_framework import serializers + class ChallengeCapabilities(IntFlag): """Capabilities a client can have, Bitwise combined.""" 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..6fa24df938a7 --- /dev/null +++ b/passbook/flows/executor/base.py @@ -0,0 +1,98 @@ +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("No more factors left") + 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("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): + pass diff --git a/passbook/flows/executor/http.py b/passbook/flows/executor/http.py new file mode 100644 index 000000000000..c3116321e80d --- /dev/null +++ b/passbook/flows/executor/http.py @@ -0,0 +1,192 @@ +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 __init__(self, request: HttpRequest): + super().__init__() + self._request = request + + def state_restore(self): + self._state = self._request.session[SESSION_STATE_KEY] + LOGGER.debug("state_restore", state=self._state) + + def state_persist(self): + self._request.session[SESSION_STATE_KEY] = self._state + LOGGER.debug("state_persist", state=self._state) + + def state_cleanup(self): + del self._request.session[SESSION_STATE_KEY] + + +class HttpExecutorView(UserPassesTestMixin, View): + """Wizard-like Multi-factor authenticator""" + + executor: HttpExecutor + + # Allow only not authenticated users to login + def test_func(self) -> bool: + return SESSION_STATE_KEY 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_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 = 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() + + self.executor = HttpExecutor(request) + self.executor.state_restore() + + # Lookup current factor object + self.current_factor = self.executor.get_next_factor() + if not self.current_factor: + return self._user_passed() + # Instantiate Next Factor and pass request + self._current_factor_class = self.current_factor.factor_class(self) + self._current_factor_class.pending_user = self.executor.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__), + ) + self.executor.factor_passed() + next_factor = self.executor.get_next_factor() + if next_factor: + LOGGER.debug("Rendering Factor", next_factor=next_factor) + return redirect_with_qs("passbook_core:flows-execute", self.request.GET) + # User passed all factors + LOGGER.debug( + "User passed all factors, logging in", user=self.executor.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""" + self.executor.passed() + # backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND] + login( + self.request, + self.executor.pending_user, + backend=self.executor._state.user_authentication_backend, + ) + LOGGER.debug("Logged in", user=self.executor.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""" + self.executor.state_cleanup() + LOGGER.debug("Cleaned up sessions") + + +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/migrations/0001_initial.py b/passbook/flows/migrations/0001_initial.py index 6ad39a47f49f..f4852250f42c 100644 --- a/passbook/flows/migrations/0001_initial.py +++ b/passbook/flows/migrations/0001_initial.py @@ -1,21 +1,22 @@ -# Generated by Django 2.2.9 on 2020-01-20 20:30 +# Generated by Django 3.0.3 on 2020-03-02 20:35 -from django.db import migrations, models -import django.db.models.deletion import uuid +import django.db.models.deletion +from django.db import migrations, models + class Migration(migrations.Migration): initial = True dependencies = [ - ("passbook_core", "0005_merge_20191025_2022"), + ("passbook_core", "0011_auto_20200222_1822"), ] operations = [ migrations.CreateModel( - name="Flow", + name="FactorBinding", fields=[ ( "uuid", @@ -26,12 +27,18 @@ class Migration(migrations.Migration): serialize=False, ), ), - ("slug", models.SlugField(unique=True)), + ("order", models.IntegerField()), + ( + "factor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="passbook_core.Factor", + ), + ), ], - options={"abstract": False,}, ), migrations.CreateModel( - name="FlowToFactor", + name="Flow", fields=[ ( "uuid", @@ -42,29 +49,36 @@ class Migration(migrations.Migration): serialize=False, ), ), - ("order", models.IntegerField()), + ("slug", models.SlugField(unique=True)), ( - "factor", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_core.Factor", + "designation", + models.CharField( + choices=[ + ("enroll", "Enroll"), + ("auth", "Authentication"), + ("recovery", "Recovery"), + ], + max_length=100, ), ), ( - "flow", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_flows.Flow", + "factors", + models.ManyToManyField( + through="passbook_flows.FactorBinding", + to="passbook_core.Factor", ), ), ], - options={"unique_together": {("flow", "factor", "order")},}, + options={"abstract": False,}, ), migrations.AddField( - model_name="flow", - name="factors", - field=models.ManyToManyField( - through="passbook_flows.FlowToFactor", to="passbook_core.Factor" + 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/models.py b/passbook/flows/models.py index f4b06c656a79..0a7e05bdbfac 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -1,9 +1,12 @@ from django.db import models -from passbook.lib.models import UUIDModel +from django.utils.translation import gettext_lazy as _ + from passbook.core.models import Factor +from passbook.lib.models import UUIDModel -class FlowToFactor(UUIDModel): +# TODO: Add PolicyModel +class FactorBinding(UUIDModel): flow = models.ForeignKey("Flow", on_delete=models.CASCADE) factor = models.ForeignKey(Factor, on_delete=models.CASCADE) @@ -17,5 +20,12 @@ class Meta: class Flow(UUIDModel): slug = models.SlugField(unique=True) - factors = models.ManyToManyField(Factor, through=FlowToFactor) - # TODO: requireed_policies to apply flows to authenticated users only? + factors = models.ManyToManyField(Factor, through=FactorBinding) + designation = models.CharField( + max_length=100, + choices=( + ("enroll", _("Enroll")), + ("auth", _("Authentication")), + ("recovery", _("Recovery")), + ), + ) 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/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): From a6f16276b882f5d2a9ff4c092d11f4b6426ad372 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 3 Mar 2020 16:53:02 +0100 Subject: [PATCH 3/3] more flows progress --- .../templates/administration/flow/list.html | 64 +++++++++++++ passbook/admin/urls.py | 10 ++ passbook/admin/views/factors.py | 2 +- passbook/admin/views/flows.py | 77 +++++++++++++++ .../migrations/0012_auto_20200303_1530.py | 15 +++ passbook/core/models.py | 2 - passbook/factors/dummy/factor.py | 4 +- passbook/factors/forms.py | 2 +- passbook/factors/otp/forms.py | 1 - passbook/factors/password/factor.py | 6 +- .../migrations/0002_auto_20191007_1411.py | 1 - passbook/flows/admin.py | 5 + passbook/flows/executor/base.py | 7 +- passbook/flows/executor/http.py | 94 ++++++------------- passbook/flows/forms.py | 20 ++++ passbook/flows/models.py | 8 ++ 16 files changed, 239 insertions(+), 79 deletions(-) create mode 100644 passbook/admin/templates/administration/flow/list.html create mode 100644 passbook/admin/views/flows.py create mode 100644 passbook/core/migrations/0012_auto_20200303_1530.py create mode 100644 passbook/flows/admin.py create mode 100644 passbook/flows/forms.py 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/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 103dfac19f70..106cc03eacc0 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -111,8 +111,6 @@ 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 = "" 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 1fbfe090b924..09044f7ae867 100644 --- a/passbook/factors/password/factor.py +++ b/passbook/factors/password/factor.py @@ -65,16 +65,14 @@ def form_valid(self, form): "password": form.cleaned_data.get("password"), } for uid_field in uid_fields: - kwargs[uid_field] = getattr( - self.authenticator.executor.pending_user, uid_field - ) + kwargs[uid_field] = getattr(self.authenticator.pending_user, uid_field) try: user = authenticate( self.request, self.authenticator.current_factor.backends, **kwargs ) if user: # User instance returned from authenticate() has .backend property set - self.authenticator.executor.pending_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/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/executor/base.py b/passbook/flows/executor/base.py index 6fa24df938a7..42f06b2d6748 100644 --- a/passbook/flows/executor/base.py +++ b/passbook/flows/executor/base.py @@ -76,7 +76,7 @@ def get_next_factor(self) -> Optional[Factor]: # 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("No more factors left") + LOGGER.debug("B_EX: factors exhausted") return None self._current_factor_binding = popped_factor # Make sure we have the correct subclass of the factor @@ -86,7 +86,7 @@ def get_next_factor(self) -> Optional[Factor]: return self._current_factor def factor_passed(self): - LOGGER.debug("factor_passed", factor=self._current_factor_binding) + 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() @@ -95,4 +95,5 @@ def factor_failed(self): self.state_cleanup() def passed(self): - pass + 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 index c3116321e80d..3e83799c36ec 100644 --- a/passbook/flows/executor/http.py +++ b/passbook/flows/executor/http.py @@ -52,33 +52,24 @@ def bootstrap_http_executor(request: HttpRequest, pending_user: User): class HttpExecutor(FlowExecutor): - _request: HttpRequest - - def __init__(self, request: HttpRequest): - super().__init__() - self._request = request + request: HttpRequest def state_restore(self): - self._state = self._request.session[SESSION_STATE_KEY] - LOGGER.debug("state_restore", state=self._state) + 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("state_persist", state=self._state) + 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] + del self.request.session[SESSION_STATE_KEY] + LOGGER.debug("HTTP_EX(state): state_clear") -class HttpExecutorView(UserPassesTestMixin, View): +class HttpExecutorView(HttpExecutor, View): """Wizard-like Multi-factor authenticator""" - executor: HttpExecutor - - # Allow only not authenticated users to login - def test_func(self) -> bool: - return SESSION_STATE_KEY 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.""" @@ -95,8 +86,7 @@ def _check_config_domain(self) -> Optional[HttpResponse]: return bad_request_message(self.request, message) return None - def handle_no_permission(self) -> HttpResponse: - # Function from UserPassesTestMixin + 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: @@ -105,87 +95,63 @@ def handle_no_permission(self) -> HttpResponse: 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()() + 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_permission() + return self.handle_no_state() - self.executor = HttpExecutor(request) - self.executor.state_restore() + self.state_restore() # Lookup current factor object - self.current_factor = self.executor.get_next_factor() + self.current_factor = self.get_next_factor() if not self.current_factor: - return self._user_passed() + 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.executor.pending_user + 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__), + "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( - "Passing POST", - view_class=class_to_path(self._current_factor_class.__class__), + "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""" - LOGGER.debug( - "Factor passed", - factor_class=class_to_path(self._current_factor_class.__class__), - ) - self.executor.factor_passed() - next_factor = self.executor.get_next_factor() - if next_factor: - LOGGER.debug("Rendering Factor", next_factor=next_factor) - return redirect_with_qs("passbook_core:flows-execute", self.request.GET) - # User passed all factors - LOGGER.debug( - "User passed all factors, logging in", user=self.executor.pending_user - ) - return self._user_passed() + 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("User invalid") - self.cleanup() + LOGGER.debug("HTTP_EX: User invalid") + self.factor_failed() return redirect_with_qs("passbook_core:auth-denied", self.request.GET) - def _user_passed(self) -> HttpResponse: - """User Successfully passed all factors""" - self.executor.passed() - # backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND] + def passed(self): + super().passed() login( self.request, - self.executor.pending_user, - backend=self.executor._state.user_authentication_backend, + self.pending_user, + backend=self._state.user_authentication_backend, ) - LOGGER.debug("Logged in", user=self.executor.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""" - self.executor.state_cleanup() - LOGGER.debug("Cleaned up sessions") class FactorPermissionDeniedView(PermissionDeniedView): 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/models.py b/passbook/flows/models.py index 0a7e05bdbfac..ef3e841d5336 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -29,3 +29,11 @@ class Flow(UUIDModel): ("recovery", _("Recovery")), ), ) + + def __str__(self): + return f"Flow {self.slug}" + + class Meta: + + verbose_name = _("Flow") + verbose_name_plural = _("Flows")