From f22f1ebcdef2a222f8f03e154e474cc9c80c1170 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 24 Nov 2022 23:47:25 +0100 Subject: [PATCH] stages/authenticator_validate: save used mfa devices in login event Signed-off-by: Jens Langhammer --- .../authenticator_validate/challenge.py | 1 + .../stages/authenticator_validate/stage.py | 6 ++ .../authenticator_validate/tests/test_duo.py | 96 ++++++++++++++++++- authentik/stages/password/tests.py | 3 +- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index fabbddcd4..7a49b647e 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -207,6 +207,7 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> request=stage_view.request, stage=stage_view.executor.current_stage, device_class=DeviceClasses.DUO.value, + duo_response=response, ) raise ValidationError("Duo denied access", code="denied") return device diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index eedd8ef15..ca07e0303 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -134,6 +134,12 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): # Here we only check if the any data was sent at all if "code" not in attrs and "webauthn" not in attrs and "duo" not in attrs: raise ValidationError("Empty response") + self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "mfa") + self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) + self.stage.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS].setdefault("mfa_devices", []) + self.stage.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS]["mfa_devices"].append( + self.device + ) return attrs diff --git a/authentik/stages/authenticator_validate/tests/test_duo.py b/authentik/stages/authenticator_validate/tests/test_duo.py index 6b779d6ce..3a18892a9 100644 --- a/authentik/stages/authenticator_validate/tests/test_duo.py +++ b/authentik/stages/authenticator_validate/tests/test_duo.py @@ -3,17 +3,22 @@ from unittest.mock import MagicMock, patch from django.contrib.sessions.middleware import SessionMiddleware from django.test.client import RequestFactory +from django.urls import reverse from rest_framework.exceptions import ValidationError -from authentik.core.tests.utils import create_test_admin_user -from authentik.flows.planner import FlowPlan +from authentik.core.tests.utils import create_test_admin_user, create_test_flow +from authentik.events.models import Event, EventAction +from authentik.flows.models import FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.stage import StageView from authentik.flows.tests import FlowTestCase -from authentik.flows.views.executor import FlowExecutorView +from authentik.flows.views.executor import SESSION_KEY_PLAN, FlowExecutorView from authentik.lib.generators import generate_id, generate_key from authentik.lib.tests.utils import dummy_get_response from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_validate.challenge import validate_challenge_duo +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses +from authentik.stages.user_login.models import UserLoginStage from authentik.tenants.utils import get_tenant_for_request @@ -97,3 +102,88 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): ), self.user, ) + + @patch( + "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.auth_client", + MagicMock( + return_value=MagicMock( + auth=MagicMock( + return_value={ + "result": "allow", + "status": "allow", + "status_msg": "Success. Logging you in...", + } + ) + ) + ), + ) + def test_full(self): + """Test full within a flow executor""" + duo_stage = AuthenticatorDuoStage.objects.create( + name=generate_id(), + client_id=generate_id(), + client_secret=generate_key(), + api_hostname="", + ) + duo_device = DuoDevice.objects.create( + user=self.user, + stage=duo_stage, + ) + + flow = create_test_flow(FlowDesignation.AUTHENTICATION) + stage = AuthenticatorValidateStage.objects.create( + name=generate_id(), + device_classes=[DeviceClasses.DUO], + ) + + plan = FlowPlan(flow_pk=flow.pk.hex) + plan.append(FlowStageBinding.objects.create(target=flow, stage=stage, order=2)) + plan.append( + FlowStageBinding.objects.create( + target=flow, stage=UserLoginStage.objects.create(name=generate_id()), order=3 + ) + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {"duo": duo_device.pk}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + event = Event.objects.filter( + action=EventAction.LOGIN, + user__pk=self.user.pk, + ).first() + self.assertIsNotNone(event) + self.assertEqual( + event.context, + { + "auth_method": "mfa", + "auth_method_args": { + "mfa_devices": [ + { + "app": "authentik_stages_authenticator_duo", + "model_name": "duodevice", + "name": "", + "pk": duo_device.pk, + } + ] + }, + "http_request": { + "args": {}, + "method": "GET", + "path": f"/api/v3/flows/executor/{flow.slug}/", + }, + }, + ) diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py index 96b34222b..33abe5961 100644 --- a/authentik/stages/password/tests.py +++ b/authentik/stages/password/tests.py @@ -11,6 +11,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests import FlowTestCase from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK from authentik.flows.views.executor import SESSION_KEY_PLAN +from authentik.lib.generators import generate_id from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.models import PasswordStage @@ -25,7 +26,7 @@ class TestPasswordStage(FlowTestCase): self.user = create_test_admin_user() self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) - self.stage = PasswordStage.objects.create(name="password", backends=[BACKEND_INBUILT]) + self.stage = PasswordStage.objects.create(name=generate_id(), backends=[BACKEND_INBUILT]) self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) @patch(