diff --git a/authentik/flows/exceptions.py b/authentik/flows/exceptions.py index 81b9793507..0c65f93052 100644 --- a/authentik/flows/exceptions.py +++ b/authentik/flows/exceptions.py @@ -11,6 +11,10 @@ class FlowNonApplicableException(SentryIgnoredException): policy_result: PolicyResult | None = None + def __init__(self, policy_result: PolicyResult | None = None, *args): + super().__init__(*args) + self.policy_result = policy_result + @property def messages(self) -> str: """Get messages from policy result, fallback to generic reason""" diff --git a/authentik/flows/migrations/0027_auto_20231028_1424.py b/authentik/flows/migrations/0027_auto_20231028_1424.py index a46aec0a35..466cf24593 100644 --- a/authentik/flows/migrations/0027_auto_20231028_1424.py +++ b/authentik/flows/migrations/0027_auto_20231028_1424.py @@ -42,6 +42,7 @@ class Migration(migrations.Migration): ("require_superuser", "Require Superuser"), ("require_redirect", "Require Redirect"), ("require_outpost", "Require Outpost"), + ("require_token", "Require Token"), ], default="none", help_text="Required level of authentication and authorization to access a flow.", diff --git a/authentik/flows/models.py b/authentik/flows/models.py index 1a5563792a..dfedaa6ed7 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -40,6 +40,7 @@ class FlowAuthenticationRequirement(models.TextChoices): REQUIRE_SUPERUSER = "require_superuser" REQUIRE_REDIRECT = "require_redirect" REQUIRE_OUTPOST = "require_outpost" + REQUIRE_TOKEN = "require_token" class NotConfiguredAction(models.TextChoices): diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py index d81eff4d7d..61c2c13f40 100644 --- a/authentik/flows/planner.py +++ b/authentik/flows/planner.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any from django.core.cache import cache from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ from sentry_sdk import start_span from sentry_sdk.tracing import Span from structlog.stdlib import BoundLogger, get_logger @@ -26,6 +27,7 @@ from authentik.lib.config import CONFIG from authentik.lib.utils.urls import redirect_with_qs from authentik.outposts.models import Outpost from authentik.policies.engine import PolicyEngine +from authentik.policies.types import PolicyResult from authentik.root.middleware import ClientIPMiddleware if TYPE_CHECKING: @@ -226,6 +228,15 @@ class FlowPlanner: and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None ): raise FlowNonApplicableException() + if ( + self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_TOKEN + and context.get(PLAN_CONTEXT_IS_RESTORED) is None + ): + raise FlowNonApplicableException( + PolicyResult( + False, _("This link is invalid or has expired. Please request a new one.") + ) + ) outpost_user = ClientIPMiddleware.get_outpost_user(request) if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: if not outpost_user: @@ -273,9 +284,7 @@ class FlowPlanner: engine.build() result = engine.result if not result.passing: - exc = FlowNonApplicableException() - exc.policy_result = result - raise exc + raise FlowNonApplicableException(result) # User is passing so far, check if we have a cached plan cached_plan_key = cache_key(self.flow, user) cached_plan = cache.get(cached_plan_key, None) diff --git a/authentik/flows/tests/test_executor.py b/authentik/flows/tests/test_executor.py index 117e866758..28b1a03414 100644 --- a/authentik/flows/tests/test_executor.py +++ b/authentik/flows/tests/test_executor.py @@ -1,5 +1,6 @@ """flow views tests""" +from datetime import timedelta from unittest.mock import MagicMock, PropertyMock, patch from urllib.parse import urlencode @@ -7,6 +8,7 @@ from django.http import HttpRequest, HttpResponse from django.test import override_settings from django.test.client import RequestFactory from django.urls import reverse +from django.utils.timezone import now from rest_framework.exceptions import ParseError from authentik.core.models import Group, User @@ -17,6 +19,7 @@ from authentik.flows.models import ( FlowDeniedAction, FlowDesignation, FlowStageBinding, + FlowToken, InvalidResponseAction, ) from authentik.flows.planner import FlowPlan, FlowPlanner @@ -24,6 +27,7 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageVie from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import ( NEXT_ARG_NAME, + QS_KEY_TOKEN, QS_QUERY, SESSION_KEY_PLAN, FlowExecutorView, @@ -740,3 +744,77 @@ class TestFlowExecutor(FlowTestCase): "title": flow.title, }, ) + + @patch( + "authentik.flows.views.executor.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_expired_flow_token(self): + """Test that an expired flow token shows an appropriate error message""" + flow = create_test_flow( + FlowDesignation.RECOVERY, + authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN, + ) + user = create_test_user() + plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[], markers=[]) + + token = FlowToken.objects.create( + user=user, + identifier=generate_id(), + flow=flow, + _plan=FlowToken.pickle(plan), + expires=now() - timedelta(hours=1), + ) + + url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) + response = self.client.get( + url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: token.key})})}" + ) + self.assertStageResponse( + response, + flow, + component="ak-stage-access-denied", + error_message="This link is invalid or has expired. Please request a new one.", + ) + + @patch( + "authentik.flows.views.executor.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_invalid_flow_token_require_token(self): + """Test that an invalid/nonexistent token on a REQUIRE_TOKEN flow shows error""" + flow = create_test_flow( + FlowDesignation.RECOVERY, + authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN, + ) + + url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) + response = self.client.get( + url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: 'invalid-token'})})}" + ) + self.assertStageResponse( + response, + flow, + component="ak-stage-access-denied", + error_message="This link is invalid or has expired. Please request a new one.", + ) + + @patch( + "authentik.flows.views.executor.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_no_token_require_token(self): + """Test that accessing a REQUIRE_TOKEN flow without any token shows error""" + flow = create_test_flow( + FlowDesignation.RECOVERY, + authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN, + ) + + url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) + response = self.client.get(url) + self.assertStageResponse( + response, + flow, + component="ak-stage-access-denied", + error_message="This link is invalid or has expired. Please request a new one.", + ) diff --git a/authentik/flows/tests/test_planner.py b/authentik/flows/tests/test_planner.py index d0e16990b6..dc15dbae9a 100644 --- a/authentik/flows/tests/test_planner.py +++ b/authentik/flows/tests/test_planner.py @@ -26,6 +26,7 @@ from authentik.flows.models import ( ) from authentik.flows.planner import ( PLAN_CONTEXT_IS_REDIRECTED, + PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key, @@ -129,6 +130,22 @@ class TestFlowPlanner(TestCase): planner.allow_empty_flows = True planner.plan(request) + def test_authentication_require_token(self): + """Test flow authentication (require_token)""" + flow = create_test_flow() + flow.authentication = FlowAuthenticationRequirement.REQUIRE_TOKEN + request = self.request_factory.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + planner = FlowPlanner(flow) + planner.allow_empty_flows = True + + with self.assertRaises(FlowNonApplicableException): + planner.plan(request) + + context = {PLAN_CONTEXT_IS_RESTORED: True} + planner.plan(request, context) + @patch( "authentik.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE, diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index 9bd547392a..0b2ebdefdb 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -62,6 +62,7 @@ from authentik.policies.engine import PolicyEngine LOGGER = get_logger() # Argument used to redirect user after login NEXT_ARG_NAME = "next" + SESSION_KEY_PLAN = "authentik/flows/plan" SESSION_KEY_GET = "authentik/flows/get" SESSION_KEY_POST = "authentik/flows/post" diff --git a/blueprints/schema.json b/blueprints/schema.json index f4427ad534..f290b1eef1 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -8430,7 +8430,8 @@ "require_unauthenticated", "require_superuser", "require_redirect", - "require_outpost" + "require_outpost", + "require_token" ], "title": "Authentication", "description": "Required level of authentication and authorization to access a flow." diff --git a/packages/client-ts/src/models/AuthenticationEnum.ts b/packages/client-ts/src/models/AuthenticationEnum.ts index babf1fb0c9..fba12d4fcd 100644 --- a/packages/client-ts/src/models/AuthenticationEnum.ts +++ b/packages/client-ts/src/models/AuthenticationEnum.ts @@ -23,6 +23,7 @@ export const AuthenticationEnum = { RequireSuperuser: "require_superuser", RequireRedirect: "require_redirect", RequireOutpost: "require_outpost", + RequireToken: "require_token", UnknownDefaultOpenApi: "11184809", } as const; export type AuthenticationEnum = (typeof AuthenticationEnum)[keyof typeof AuthenticationEnum]; diff --git a/schema.yml b/schema.yml index a50fec42e1..a746ee831c 100644 --- a/schema.yml +++ b/schema.yml @@ -34355,6 +34355,7 @@ components: - require_superuser - require_redirect - require_outpost + - require_token type: string AuthenticatorAttachmentEnum: enum: diff --git a/web/src/admin/flows/FlowForm.ts b/web/src/admin/flows/FlowForm.ts index ee45e937c0..f9418abb8a 100644 --- a/web/src/admin/flows/FlowForm.ts +++ b/web/src/admin/flows/FlowForm.ts @@ -218,6 +218,15 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm) { > ${msg("Require Outpost (flow can only be executed from an outpost)")} +

${msg("Required authentication level for this flow.")}