diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index cb0e4862e6..727a41ddb8 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -1,6 +1,7 @@ """authentik stage Base view""" from typing import TYPE_CHECKING +from urllib.parse import urlencode from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -164,6 +165,16 @@ class ChallengeStageView(StageView): self.logger.warning("failed to template title", exc=exc) return self.executor.flow.title + @property + def cancel_url(self) -> str: + from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET + + next_param = self.request.session.get(SESSION_KEY_GET, {}).get(NEXT_ARG_NAME) + url = reverse("authentik_flows:cancel") + if next_param: + return f"{url}?{urlencode({NEXT_ARG_NAME: next_param})}" + return url + def _get_challenge(self, *args, **kwargs) -> Challenge: with ( start_span( @@ -186,7 +197,7 @@ class ChallengeStageView(StageView): data={ "title": self.format_title(), "background": self.executor.flow.background_url(self.request), - "cancel_url": reverse("authentik_flows:cancel"), + "cancel_url": self.cancel_url, "layout": self.executor.flow.layout, } ) diff --git a/authentik/flows/tests/__init__.py b/authentik/flows/tests/__init__.py index cb861a70b6..c0d4859dd3 100644 --- a/authentik/flows/tests/__init__.py +++ b/authentik/flows/tests/__init__.py @@ -32,8 +32,10 @@ class FlowTestCase(APITestCase): self.assertIsNotNone(raw_response["component"]) if flow: self.assertIn("flow_info", raw_response) - self.assertEqual( - raw_response["flow_info"]["cancel_url"], reverse("authentik_flows:cancel") + self.assertTrue( + raw_response["flow_info"]["cancel_url"].startswith( + reverse("authentik_flows:cancel") + ) ) # We don't check the flow title since it will most likely go # through ChallengeStageView.format_title() so might not match 1:1 diff --git a/authentik/flows/tests/test_executor.py b/authentik/flows/tests/test_executor.py index 6b981d66f2..ab6d95ebc0 100644 --- a/authentik/flows/tests/test_executor.py +++ b/authentik/flows/tests/test_executor.py @@ -36,6 +36,7 @@ from authentik.policies.types import PolicyResult from authentik.stages.deny.models import DenyStage from authentik.stages.dummy.models import DummyStage from authentik.stages.identification.models import IdentificationStage, UserFields +from authentik.stages.password.models import PasswordStage POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False, "foo")) POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) @@ -692,3 +693,49 @@ class TestFlowExecutor(FlowTestCase): self.client.logout() response = self.client.post(url, data="{", content_type="application/json") self.assertEqual(response.status_code, 200) + + def test_cancel_next(self): + """Test cancel URL with ?next param set""" + flow = create_test_flow() + + # Stage 0 is an identification stage + ident_stage = IdentificationStage.objects.create( + name=generate_id(), + user_fields=[UserFields.USERNAME], + ) + FlowStageBinding.objects.create( + target=flow, + stage=ident_stage, + order=0, + ) + + # Stage 1 is a password stage + password_stage = PasswordStage.objects.create(name=generate_id(), backends=[]) + FlowStageBinding.objects.create( + target=flow, + stage=password_stage, + order=1, + ) + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) + + f"?{urlencode({QS_QUERY: urlencode({NEXT_ARG_NAME: "/foo"})})}" + ) + self.assertStageResponse(res, flow, component="ak-stage-identification") + + res = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) + + f"?{urlencode({QS_QUERY: urlencode({NEXT_ARG_NAME: "/foo"})})}", + data={"component": "ak-stage-identification", "uid_field": generate_id()}, + follow=True, + ) + self.assertEqual(res.status_code, 200) + self.assertStageResponse( + res, + flow, + flow_info={ + "background": "/static/dist/assets/images/flow_background.jpg", + "cancel_url": "/flows/-/cancel/?next=%2Ffoo", + "layout": "stacked", + "title": flow.title, + }, + ) diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index c8d46a5739..8b049926fd 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -479,6 +479,9 @@ class CancelView(View): if SESSION_KEY_PLAN in request.session: del request.session[SESSION_KEY_PLAN] LOGGER.debug("Canceled current plan") + next_url = self.request.GET.get(NEXT_ARG_NAME) + if next_url and not is_url_absolute(next_url): + return redirect(next_url) return redirect("authentik_flows:default-invalidation")