diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index 9838dd114..0566bddf2 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -71,6 +71,7 @@ class FlowSerializer(ModelSerializer): "export_url", "layout", "denied_action", + "authentication", ] extra_kwargs = { "background": {"read_only": True}, diff --git a/authentik/flows/exceptions.py b/authentik/flows/exceptions.py index 6ea03429e..cf38d9a50 100644 --- a/authentik/flows/exceptions.py +++ b/authentik/flows/exceptions.py @@ -1,4 +1,6 @@ """flow exceptions""" +from typing import Optional + from django.utils.translation import gettext_lazy as _ from authentik.lib.sentry import SentryIgnoredException @@ -6,15 +8,15 @@ from authentik.policies.types import PolicyResult class FlowNonApplicableException(SentryIgnoredException): - """Flow does not apply to current user (denied by policy).""" + """Flow does not apply to current user (denied by policy, or otherwise).""" - policy_result: PolicyResult + policy_result: Optional[PolicyResult] = None @property def messages(self) -> str: """Get messages from policy result, fallback to generic reason""" - if len(self.policy_result.messages) < 1: - return _("Flow does not apply to current user (denied by policy).") + if not self.policy_result or len(self.policy_result.messages) < 1: + return _("Flow does not apply to current user.") return "\n".join(self.policy_result.messages) diff --git a/authentik/flows/migrations/0024_flow_authentication.py b/authentik/flows/migrations/0024_flow_authentication.py new file mode 100644 index 000000000..cbfc01971 --- /dev/null +++ b/authentik/flows/migrations/0024_flow_authentication.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.3 on 2022-11-30 09:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0023_flow_denied_action"), + ] + + operations = [ + migrations.AddField( + model_name="flow", + name="authentication", + field=models.TextField( + choices=[ + ("none", "None"), + ("require_authenticated", "Require Authenticated"), + ("require_unauthenticated", "Require Unauthenticated"), + ("require_superuser", "Require Superuser"), + ], + 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 6c7edc37b..a837b7dae 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -23,6 +23,15 @@ if TYPE_CHECKING: LOGGER = get_logger() +class FlowAuthenticationRequirement(models.TextChoices): + """Required level of authentication and authorization to access a flow""" + + NONE = "none" + REQUIRE_AUTHENTICATED = "require_authenticated" + REQUIRE_UNAUTHENTICATED = "require_unauthenticated" + REQUIRE_SUPERUSER = "require_superuser" + + class NotConfiguredAction(models.TextChoices): """Decides how the FlowExecutor should proceed when a stage isn't configured""" @@ -152,6 +161,12 @@ class Flow(SerializerModel, PolicyBindingModel): help_text=_("Configure what should happen when a flow denies access to a user."), ) + authentication = models.TextField( + choices=FlowAuthenticationRequirement.choices, + default=FlowAuthenticationRequirement.NONE, + help_text=_("Required level of authentication and authorization to access a flow."), + ) + @property def background_url(self) -> str: """Get the URL to the background image. If the name is /static or starts with http diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py index 547803dd2..367d7574b 100644 --- a/authentik/flows/planner.py +++ b/authentik/flows/planner.py @@ -13,7 +13,14 @@ from authentik.events.models import cleanse_dict from authentik.flows.apps import HIST_FLOWS_PLAN_TIME from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException from authentik.flows.markers import ReevaluateMarker, StageMarker -from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage, in_memory_stage +from authentik.flows.models import ( + Flow, + FlowAuthenticationRequirement, + FlowDesignation, + FlowStageBinding, + Stage, + in_memory_stage, +) from authentik.lib.config import CONFIG from authentik.policies.engine import PolicyEngine @@ -117,11 +124,30 @@ class FlowPlanner: self.flow = flow self._logger = get_logger().bind(flow_slug=flow.slug) + def _check_authentication(self, request: HttpRequest): + """Check the flow's authentication level is matched by `request`""" + if ( + self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED + and not request.user.is_authenticated + ): + raise FlowNonApplicableException() + if ( + self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED + and request.user.is_authenticated + ): + raise FlowNonApplicableException() + if ( + self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_SUPERUSER + and not request.user.is_superuser + ): + raise FlowNonApplicableException() + def plan( self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None ) -> FlowPlan: """Check each of the flows' policies, check policies for each stage with PolicyBinding and return ordered list""" + self._check_authentication(request) with Hub.current.start_span( op="authentik.flow.planner.plan", description=self.flow.slug ) as span: diff --git a/authentik/flows/tests/test_planner.py b/authentik/flows/tests/test_planner.py index e7da06e0a..790f5bba4 100644 --- a/authentik/flows/tests/test_planner.py +++ b/authentik/flows/tests/test_planner.py @@ -1,6 +1,7 @@ """flow planner tests""" from unittest.mock import MagicMock, Mock, PropertyMock, patch +from django.contrib.auth.models import AnonymousUser from django.contrib.sessions.middleware import SessionMiddleware from django.core.cache import cache from django.test import RequestFactory, TestCase @@ -8,10 +9,10 @@ from django.urls import reverse from guardian.shortcuts import get_anonymous_user from authentik.core.models import User -from authentik.core.tests.utils import create_test_flow +from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException from authentik.flows.markers import ReevaluateMarker, StageMarker -from authentik.flows.models import FlowDesignation, FlowStageBinding +from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key from authentik.lib.tests.utils import dummy_get_response from authentik.policies.dummy.models import DummyPolicy @@ -43,6 +44,30 @@ class TestFlowPlanner(TestCase): planner = FlowPlanner(flow) planner.plan(request) + def test_authentication(self): + """Test flow authentication""" + flow = create_test_flow() + flow.authentication = FlowAuthenticationRequirement.NONE + request = self.request_factory.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = AnonymousUser() + planner = FlowPlanner(flow) + planner.allow_empty_flows = True + planner.plan(request) + + with self.assertRaises(FlowNonApplicableException): + flow.authentication = FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED + FlowPlanner(flow).plan(request) + with self.assertRaises(FlowNonApplicableException): + flow.authentication = FlowAuthenticationRequirement.REQUIRE_SUPERUSER + FlowPlanner(flow).plan(request) + + request.user = create_test_admin_user() + planner = FlowPlanner(flow) + planner.allow_empty_flows = True + planner.plan(request) + @patch( "authentik.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE, diff --git a/blueprints/default/0-flow-password-change.yaml b/blueprints/default/0-flow-password-change.yaml index fd87049f8..60a4d72e7 100644 --- a/blueprints/default/0-flow-password-change.yaml +++ b/blueprints/default/0-flow-password-change.yaml @@ -6,6 +6,7 @@ entries: designation: stage_configuration name: Change Password title: Change password + authentication: require_authenticated identifiers: slug: default-password-change model: authentik_flows.flow diff --git a/blueprints/default/10-flow-default-authentication-flow.yaml b/blueprints/default/10-flow-default-authentication-flow.yaml index 9afbf6605..37192356b 100644 --- a/blueprints/default/10-flow-default-authentication-flow.yaml +++ b/blueprints/default/10-flow-default-authentication-flow.yaml @@ -11,6 +11,7 @@ entries: designation: authentication name: Welcome to authentik! title: Welcome to authentik! + authentication: require_unauthenticated identifiers: slug: default-authentication-flow model: authentik_flows.flow diff --git a/blueprints/default/10-flow-default-invalidation-flow.yaml b/blueprints/default/10-flow-default-invalidation-flow.yaml index 80f8e729c..a77b62852 100644 --- a/blueprints/default/10-flow-default-invalidation-flow.yaml +++ b/blueprints/default/10-flow-default-invalidation-flow.yaml @@ -6,6 +6,7 @@ entries: designation: invalidation name: Logout title: Default Invalidation Flow + authentication: require_authenticated identifiers: slug: default-invalidation-flow model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-authenticator-static-setup.yaml b/blueprints/default/20-flow-default-authenticator-static-setup.yaml index 7f3173e24..190ca9098 100644 --- a/blueprints/default/20-flow-default-authenticator-static-setup.yaml +++ b/blueprints/default/20-flow-default-authenticator-static-setup.yaml @@ -6,6 +6,7 @@ entries: designation: stage_configuration name: default-authenticator-static-setup title: Setup Static OTP Tokens + authentication: require_authenticated identifiers: slug: default-authenticator-static-setup model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-authenticator-totp-setup.yaml b/blueprints/default/20-flow-default-authenticator-totp-setup.yaml index d701dd0ea..b97f565e3 100644 --- a/blueprints/default/20-flow-default-authenticator-totp-setup.yaml +++ b/blueprints/default/20-flow-default-authenticator-totp-setup.yaml @@ -6,6 +6,7 @@ entries: designation: stage_configuration name: default-authenticator-totp-setup title: Setup Two-Factor authentication + authentication: require_authenticated identifiers: slug: default-authenticator-totp-setup model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml b/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml index 26b5a64c7..da0abf7be 100644 --- a/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml +++ b/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml @@ -6,6 +6,7 @@ entries: designation: stage_configuration name: default-authenticator-webauthn-setup title: Setup WebAuthn + authentication: require_authenticated identifiers: slug: default-authenticator-webauthn-setup model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml b/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml index 84dd76d25..452102ab8 100644 --- a/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml +++ b/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml @@ -6,6 +6,7 @@ entries: designation: authorization name: Authorize Application title: Redirecting to %(app)s + authentication: require_authenticated identifiers: slug: default-provider-authorization-explicit-consent model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml b/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml index b609afdd9..0cec0acfc 100644 --- a/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml +++ b/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml @@ -6,6 +6,7 @@ entries: designation: authorization name: Authorize Application title: Redirecting to %(app)s + authentication: require_authenticated identifiers: slug: default-provider-authorization-implicit-consent model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-source-authentication.yaml b/blueprints/default/20-flow-default-source-authentication.yaml index 7b630343b..9ece1a1ec 100644 --- a/blueprints/default/20-flow-default-source-authentication.yaml +++ b/blueprints/default/20-flow-default-source-authentication.yaml @@ -6,6 +6,7 @@ entries: designation: authentication name: Welcome to authentik! title: Welcome to authentik! + authentication: require_unauthenticated identifiers: slug: default-source-authentication model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-source-enrollment.yaml b/blueprints/default/20-flow-default-source-enrollment.yaml index 52e55b807..e354104db 100644 --- a/blueprints/default/20-flow-default-source-enrollment.yaml +++ b/blueprints/default/20-flow-default-source-enrollment.yaml @@ -6,6 +6,7 @@ entries: designation: enrollment name: Welcome to authentik! Please select a username. title: Welcome to authentik! Please select a username. + authentication: none identifiers: slug: default-source-enrollment model: authentik_flows.flow diff --git a/blueprints/default/20-flow-default-source-pre-authentication.yaml b/blueprints/default/20-flow-default-source-pre-authentication.yaml index c98843de0..0d6040d49 100644 --- a/blueprints/default/20-flow-default-source-pre-authentication.yaml +++ b/blueprints/default/20-flow-default-source-pre-authentication.yaml @@ -6,6 +6,7 @@ entries: designation: stage_configuration name: Pre-Authentication title: Pre-authentication + authentication: none identifiers: slug: default-source-pre-authentication model: authentik_flows.flow diff --git a/blueprints/default/30-flow-default-user-settings-flow.yaml b/blueprints/default/30-flow-default-user-settings-flow.yaml index 8254d4865..34a593be7 100644 --- a/blueprints/default/30-flow-default-user-settings-flow.yaml +++ b/blueprints/default/30-flow-default-user-settings-flow.yaml @@ -6,6 +6,7 @@ entries: designation: stage_configuration name: User settings title: Update your info + authentication: require_authenticated identifiers: slug: default-user-settings-flow model: authentik_flows.flow diff --git a/blueprints/example/flows-enrollment-2-stage.yaml b/blueprints/example/flows-enrollment-2-stage.yaml index 97fb39ef0..670befc99 100644 --- a/blueprints/example/flows-enrollment-2-stage.yaml +++ b/blueprints/example/flows-enrollment-2-stage.yaml @@ -12,6 +12,7 @@ entries: name: Default enrollment Flow title: Welcome to authentik! designation: enrollment + authentication: require_unauthenticated - identifiers: field_key: username label: Username diff --git a/blueprints/example/flows-enrollment-email-verification.yaml b/blueprints/example/flows-enrollment-email-verification.yaml index ac051202b..3528906a1 100644 --- a/blueprints/example/flows-enrollment-email-verification.yaml +++ b/blueprints/example/flows-enrollment-email-verification.yaml @@ -12,6 +12,7 @@ entries: name: Default enrollment Flow title: Welcome to authentik! designation: enrollment + authentication: require_unauthenticated - identifiers: field_key: username label: Username diff --git a/blueprints/example/flows-login-2fa.yaml b/blueprints/example/flows-login-2fa.yaml index 903daa5aa..fa316caa9 100644 --- a/blueprints/example/flows-login-2fa.yaml +++ b/blueprints/example/flows-login-2fa.yaml @@ -12,6 +12,7 @@ entries: name: Default Authentication Flow title: Welcome to authentik! designation: authentication + authentication: require_unauthenticated - identifiers: name: test-not-app-password id: test-not-app-password diff --git a/blueprints/example/flows-login-conditional-captcha.yaml b/blueprints/example/flows-login-conditional-captcha.yaml index 2d727acaf..f5eb6ca1b 100644 --- a/blueprints/example/flows-login-conditional-captcha.yaml +++ b/blueprints/example/flows-login-conditional-captcha.yaml @@ -12,6 +12,7 @@ entries: name: Default Authentication Flow title: Welcome to authentik! designation: authentication + authentication: require_unauthenticated - identifiers: name: default-authentication-login id: default-authentication-login diff --git a/blueprints/example/flows-recovery-email-verification.yaml b/blueprints/example/flows-recovery-email-verification.yaml index 9217fa762..8b1994cc8 100644 --- a/blueprints/example/flows-recovery-email-verification.yaml +++ b/blueprints/example/flows-recovery-email-verification.yaml @@ -12,6 +12,7 @@ entries: name: Default recovery flow title: Reset your password designation: recovery + authentication: require_unauthenticated - identifiers: field_key: password label: Password diff --git a/blueprints/example/flows-unenrollment.yaml b/blueprints/example/flows-unenrollment.yaml index 898405cd6..5d29dd2b9 100644 --- a/blueprints/example/flows-unenrollment.yaml +++ b/blueprints/example/flows-unenrollment.yaml @@ -12,6 +12,7 @@ entries: name: Default unenrollment flow title: Delete your account designation: unenrollment + authentication: require_authenticated - identifiers: name: default-unenrollment-user-delete id: default-unenrollment-user-delete diff --git a/schema.yml b/schema.yml index 5537db567..2a02e8407 100644 --- a/schema.yml +++ b/schema.yml @@ -25269,6 +25269,13 @@ components: - last_used - user - user_agent + AuthenticationEnum: + enum: + - none + - require_authenticated + - require_unauthenticated + - require_superuser + type: string AuthenticatorAttachmentEnum: enum: - platform @@ -27578,6 +27585,11 @@ components: - $ref: '#/components/schemas/DeniedActionEnum' description: Configure what should happen when a flow denies access to a user. + authentication: + allOf: + - $ref: '#/components/schemas/AuthenticationEnum' + description: Required level of authentication and authorization to access + a flow. required: - background - cache_count @@ -27774,6 +27786,11 @@ components: - $ref: '#/components/schemas/DeniedActionEnum' description: Configure what should happen when a flow denies access to a user. + authentication: + allOf: + - $ref: '#/components/schemas/AuthenticationEnum' + description: Required level of authentication and authorization to access + a flow. required: - designation - name @@ -33651,6 +33668,11 @@ components: - $ref: '#/components/schemas/DeniedActionEnum' description: Configure what should happen when a flow denies access to a user. + authentication: + allOf: + - $ref: '#/components/schemas/AuthenticationEnum' + description: Required level of authentication and authorization to access + a flow. PatchedFlowStageBindingRequest: type: object description: FlowStageBinding Serializer diff --git a/web/src/admin/flows/FlowForm.ts b/web/src/admin/flows/FlowForm.ts index e205eb541..3db763b8f 100644 --- a/web/src/admin/flows/FlowForm.ts +++ b/web/src/admin/flows/FlowForm.ts @@ -1,4 +1,5 @@ import { DesignationToLabel, LayoutToLabel } from "@goauthentik/admin/flows/utils"; +import { AuthenticationEnum } from "@goauthentik/api/dist/models/AuthenticationEnum"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -141,6 +142,37 @@ export class FlowForm extends ModelForm { `; } + renderAuthentication(): TemplateResult { + return html` + + + + + `; + } + renderLayout(): TemplateResult { return html` + + +

+ ${t`Required authentication level for this flow.`} +

+