diff --git a/authentik/core/models.py b/authentik/core/models.py index d2e4b97d22..7153d72680 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -617,6 +617,9 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel): """Get serializer for this model""" raise NotImplementedError + def __str__(self) -> str: + return f"User-source connection (user={self.user.username}, source={self.source.slug})" + class Meta: unique_together = (("user", "source"),) diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index bbcc9b3779..18b126ad21 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -16,8 +16,9 @@ from authentik.core.models import Source, SourceUserMatchingModes, User, UserSou from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage from authentik.events.models import Event, EventAction from authentik.flows.exceptions import FlowNonApplicableException -from authentik.flows.models import Flow, Stage, in_memory_stage +from authentik.flows.models import Flow, FlowToken, Stage, in_memory_stage from authentik.flows.planner import ( + PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_REDIRECT, PLAN_CONTEXT_SOURCE, @@ -35,6 +36,8 @@ from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH +SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec + class Action(Enum): """Actions that can be decided based on the request @@ -222,22 +225,43 @@ class SourceFlowManager: **kwargs, ) -> HttpResponse: """Prepare Authentication Plan, redirect user FlowExecutor""" - # Ensure redirect is carried through when user was trying to - # authorize application - final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( - NEXT_ARG_NAME, "authentik_core:if-user" - ) kwargs.update( { # Since we authenticate the user by their token, they have no backend set PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SOURCE: self.source, - PLAN_CONTEXT_REDIRECT: final_redirect, PLAN_CONTEXT_SOURCES_CONNECTION: connection, } ) kwargs.update(self.policy_context) + if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: + token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) + self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) + plan = token.plan + plan.context[PLAN_CONTEXT_IS_RESTORED] = token + plan.context.update(kwargs) + for stage in self.get_stages_to_append(flow): + plan.append_stage(stage) + if stages: + for stage in stages: + plan.append_stage(stage) + self.request.session[SESSION_KEY_PLAN] = plan + flow_slug = token.flow.slug + token.delete() + return redirect_with_qs( + "authentik_core:if-flow", + self.request.GET, + flow_slug=flow_slug, + ) + # Ensure redirect is carried through when user was trying to + # authorize application + final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( + NEXT_ARG_NAME, "authentik_core:if-user" + ) + if PLAN_CONTEXT_REDIRECT not in kwargs: + kwargs[PLAN_CONTEXT_REDIRECT] = final_redirect + if not flow: return bad_request_message( self.request, diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 7eb238a831..1637d2f0ec 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -15,6 +15,7 @@ CELERY_BEAT_SCHEDULE = { TENANT_APPS = [ "authentik.enterprise.audit", "authentik.enterprise.providers.rac", + "authentik.enterprise.stages.source", ] MIDDLEWARE = ["authentik.enterprise.middleware.EnterpriseMiddleware"] diff --git a/authentik/enterprise/stages/__init__.py b/authentik/enterprise/stages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/source/__init__.py b/authentik/enterprise/stages/source/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/source/api.py b/authentik/enterprise/stages/source/api.py new file mode 100644 index 0000000000..5b2be29dc6 --- /dev/null +++ b/authentik/enterprise/stages/source/api.py @@ -0,0 +1,38 @@ +"""Source Stage API Views""" + +from rest_framework.exceptions import ValidationError +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.used_by import UsedByMixin +from authentik.core.models import Source +from authentik.enterprise.api import EnterpriseRequiredMixin +from authentik.enterprise.stages.source.models import SourceStage +from authentik.flows.api.stages import StageSerializer + + +class SourceStageSerializer(EnterpriseRequiredMixin, StageSerializer): + """SourceStage Serializer""" + + def validate_source(self, _source: Source) -> Source: + """Ensure configured source supports web-based login""" + source = Source.objects.filter(pk=_source.pk).select_subclasses().first() + if not source: + raise ValidationError("Invalid source") + login_button = source.ui_login_button(self.context["request"]) + if not login_button: + raise ValidationError("Invalid source selected, only web-based sources are supported.") + return source + + class Meta: + model = SourceStage + fields = StageSerializer.Meta.fields + ["source", "resume_timeout"] + + +class SourceStageViewSet(UsedByMixin, ModelViewSet): + """SourceStage Viewset""" + + queryset = SourceStage.objects.all() + serializer_class = SourceStageSerializer + filterset_fields = "__all__" + ordering = ["name"] + search_fields = ["name"] diff --git a/authentik/enterprise/stages/source/apps.py b/authentik/enterprise/stages/source/apps.py new file mode 100644 index 0000000000..2da1c768f6 --- /dev/null +++ b/authentik/enterprise/stages/source/apps.py @@ -0,0 +1,12 @@ +"""authentik stage app config""" + +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseStageSourceConfig(EnterpriseConfig): + """authentik source stage config""" + + name = "authentik.enterprise.stages.source" + label = "authentik_stages_source" + verbose_name = "authentik Enterprise.Stages.Source" + default = True diff --git a/authentik/enterprise/stages/source/migrations/0001_initial.py b/authentik/enterprise/stages/source/migrations/0001_initial.py new file mode 100644 index 0000000000..e2c9144a29 --- /dev/null +++ b/authentik/enterprise/stages/source/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.2 on 2024-02-25 20:44 + +import authentik.lib.utils.time +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0033_alter_user_options"), + ("authentik_flows", "0027_auto_20231028_1424"), + ] + + operations = [ + migrations.CreateModel( + name="SourceStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_flows.stage", + ), + ), + ( + "resume_timeout", + models.TextField( + default="minutes=10", + help_text="Amount of time a user can take to return from the source to continue the flow (Format: hours=-1;minutes=-2;seconds=-3)", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="authentik_core.source" + ), + ), + ], + options={ + "verbose_name": "Source Stage", + "verbose_name_plural": "Source Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/enterprise/stages/source/migrations/__init__.py b/authentik/enterprise/stages/source/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/source/models.py b/authentik/enterprise/stages/source/models.py new file mode 100644 index 0000000000..ac3fedeb7f --- /dev/null +++ b/authentik/enterprise/stages/source/models.py @@ -0,0 +1,45 @@ +"""Source stage models""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import Stage +from authentik.lib.utils.time import timedelta_string_validator + + +class SourceStage(Stage): + """Suspend the current flow execution and send the user to a source, + after which this flow execution is resumed.""" + + source = models.ForeignKey("authentik_core.Source", on_delete=models.CASCADE) + + resume_timeout = models.TextField( + default="minutes=10", + validators=[timedelta_string_validator], + help_text=_( + "Amount of time a user can take to return from the source to continue the flow " + "(Format: hours=-1;minutes=-2;seconds=-3)" + ), + ) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.enterprise.stages.source.api import SourceStageSerializer + + return SourceStageSerializer + + @property + def view(self) -> type[View]: + from authentik.enterprise.stages.source.stage import SourceStageView + + return SourceStageView + + @property + def component(self) -> str: + return "ak-stage-source-form" + + class Meta: + verbose_name = _("Source Stage") + verbose_name_plural = _("Source Stages") diff --git a/authentik/enterprise/stages/source/stage.py b/authentik/enterprise/stages/source/stage.py new file mode 100644 index 0000000000..a500d3138d --- /dev/null +++ b/authentik/enterprise/stages/source/stage.py @@ -0,0 +1,79 @@ +"""Source stage logic""" + +from typing import Any +from uuid import uuid4 + +from django.http import HttpRequest, HttpResponse +from django.utils.text import slugify +from django.utils.timezone import now +from guardian.shortcuts import get_anonymous_user + +from authentik.core.models import Source, User +from authentik.core.sources.flow_manager import SESSION_KEY_OVERRIDE_FLOW_TOKEN +from authentik.core.types import UILoginButton +from authentik.enterprise.stages.source.models import SourceStage +from authentik.flows.challenge import Challenge, ChallengeResponse +from authentik.flows.models import FlowToken +from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED +from authentik.flows.stage import ChallengeStageView +from authentik.lib.utils.time import timedelta_from_string + +PLAN_CONTEXT_RESUME_TOKEN = "resume_token" # nosec + + +class SourceStageView(ChallengeStageView): + """Suspend the current flow execution and send the user to a source, + after which this flow execution is resumed.""" + + login_button: UILoginButton + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + current_stage: SourceStage = self.executor.current_stage + source: Source = ( + Source.objects.filter(pk=current_stage.source_id).select_subclasses().first() + ) + if not source: + self.logger.warning("Source does not exist") + return self.executor.stage_invalid("Source does not exist") + self.login_button = source.ui_login_button(self.request) + if not self.login_button: + self.logger.warning("Source does not have a UI login button") + return self.executor.stage_invalid("Invalid source") + restore_token = self.executor.plan.context.get(PLAN_CONTEXT_IS_RESTORED) + override_token = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) + if restore_token and override_token and restore_token.pk == override_token.pk: + del self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] + return self.executor.stage_ok() + return super().dispatch(request, *args, **kwargs) + + def get_challenge(self, *args, **kwargs) -> Challenge: + resume_token = self.create_flow_token() + self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token + return self.login_button.challenge + + def create_flow_token(self) -> FlowToken: + """Save the current flow state in a token that can be used to resume this flow""" + pending_user: User = self.get_pending_user() + if pending_user.is_anonymous: + pending_user = get_anonymous_user() + current_stage: SourceStage = self.executor.current_stage + identifier = slugify(f"ak-source-stage-{current_stage.name}-{str(uuid4())}") + # Don't check for validity here, we only care if the token exists + tokens = FlowToken.objects.filter(identifier=identifier) + valid_delta = timedelta_from_string(current_stage.resume_timeout) + if not tokens.exists(): + return FlowToken.objects.create( + expires=now() + valid_delta, + user=pending_user, + identifier=identifier, + flow=self.executor.flow, + _plan=FlowToken.pickle(self.executor.plan), + ) + token = tokens.first() + # Check if token is expired and rotate key if so + if token.is_expired: + token.expire_action() + return token + + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + return self.executor.stage_ok() diff --git a/authentik/enterprise/stages/source/tests.py b/authentik/enterprise/stages/source/tests.py new file mode 100644 index 0000000000..954f2c3206 --- /dev/null +++ b/authentik/enterprise/stages/source/tests.py @@ -0,0 +1,99 @@ +"""Source stage tests""" + +from django.urls import reverse + +from authentik.core.tests.utils import create_test_flow, create_test_user +from authentik.enterprise.stages.source.models import SourceStage +from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken +from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan +from authentik.flows.tests import FlowTestCase +from authentik.flows.views.executor import SESSION_KEY_PLAN +from authentik.lib.generators import generate_id +from authentik.sources.saml.models import SAMLSource +from authentik.stages.identification.models import IdentificationStage, UserFields +from authentik.stages.password import BACKEND_INBUILT +from authentik.stages.password.models import PasswordStage +from authentik.stages.user_login.models import UserLoginStage + + +class TestSourceStage(FlowTestCase): + """Source stage tests""" + + def setUp(self): + self.source = SAMLSource.objects.create( + slug=generate_id(), + issuer="authentik", + allow_idp_initiated=True, + pre_authentication_flow=create_test_flow(), + ) + + def test_source_success(self): + """Test""" + user = create_test_user() + flow = create_test_flow(FlowDesignation.AUTHENTICATION) + stage = SourceStage.objects.create(name=generate_id(), source=self.source) + FlowStageBinding.objects.create( + target=flow, + stage=IdentificationStage.objects.create( + name=generate_id(), + user_fields=[UserFields.USERNAME], + ), + order=0, + ) + FlowStageBinding.objects.create( + target=flow, + stage=PasswordStage.objects.create(name=generate_id(), backends=[BACKEND_INBUILT]), + order=5, + ) + FlowStageBinding.objects.create(target=flow, stage=stage, order=10) + FlowStageBinding.objects.create( + target=flow, + stage=UserLoginStage.objects.create( + name=generate_id(), + ), + order=15, + ) + + # Get user identification stage + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + self.assertEqual(response.status_code, 200) + self.assertStageResponse(response, flow, component="ak-stage-identification") + # Send username + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + data={"uid_field": user.username}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertStageResponse(response, flow, component="ak-stage-password") + # Send password + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + data={"password": user.username}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertStageRedirects( + response, + reverse("authentik_sources_saml:login", kwargs={"source_slug": self.source.slug}), + ) + + # Hijack flow plan so we don't have to emulate the source + flow_token = FlowToken.objects.filter( + identifier__startswith=f"ak-source-stage-{stage.name.lower()}" + ).first() + self.assertIsNotNone(flow_token) + session = self.client.session + plan: FlowPlan = session[SESSION_KEY_PLAN] + plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token + session[SESSION_KEY_PLAN] = plan + session.save() + + # Pretend we've just returned from the source + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) diff --git a/authentik/enterprise/stages/source/urls.py b/authentik/enterprise/stages/source/urls.py new file mode 100644 index 0000000000..d0eb9786f9 --- /dev/null +++ b/authentik/enterprise/stages/source/urls.py @@ -0,0 +1,5 @@ +"""API URLs""" + +from authentik.enterprise.stages.source.api import SourceStageViewSet + +api_urlpatterns = [("stages/source", SourceStageViewSet)] diff --git a/authentik/flows/api/stages.py b/authentik/flows/api/stages.py index 89fb250232..81b3b1a8ab 100644 --- a/authentik/flows/api/stages.py +++ b/authentik/flows/api/stages.py @@ -13,6 +13,7 @@ from structlog.stdlib import get_logger from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.types import UserSettingSerializer +from authentik.enterprise.apps import EnterpriseConfig from authentik.flows.api.flows import FlowSetSerializer from authentik.flows.models import ConfigurableStage, Stage from authentik.lib.utils.reflection import all_subclasses @@ -75,6 +76,7 @@ class StageViewSet( "description": subclass.__doc__, "component": subclass().component, "model_name": subclass._meta.model_name, + "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), } ) data = sorted(data, key=lambda x: x["name"]) diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index 29aba77e46..c620107467 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -469,7 +469,7 @@ class FlowExecutorView(APIView): class CancelView(View): - """View which canels the currently active plan""" + """View which cancels the currently active plan""" def get(self, request: HttpRequest) -> HttpResponse: """View which canels the currently active plan""" diff --git a/authentik/sources/oauth/types/registry.py b/authentik/sources/oauth/types/registry.py index 199451c6cc..acb59c69bd 100644 --- a/authentik/sources/oauth/types/registry.py +++ b/authentik/sources/oauth/types/registry.py @@ -47,7 +47,7 @@ class SourceType: def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge: """Allow types to return custom challenges""" return RedirectChallenge( - instance={ + data={ "type": ChallengeTypes.REDIRECT.value, "to": reverse( "authentik_sources_oauth:oauth-client-login", diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index d04727c8d5..79dae2e2ca 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -54,7 +54,7 @@ class OAuthCallback(OAuthClientMixin, View): raw_profile=exc.doc, ).from_http(self.request) return self.handle_login_failure("Could not retrieve profile.") - identifier = self.get_user_id(raw_info) + identifier = self.get_user_id(info=raw_info) if identifier is None: return self.handle_login_failure("Could not determine id.") # Get or create access record @@ -67,6 +67,7 @@ class OAuthCallback(OAuthClientMixin, View): ) sfm.policy_context = {"oauth_userinfo": raw_info} return sfm.get_flow( + raw_info=raw_info, access_token=self.token.get("access_token"), ) @@ -116,6 +117,7 @@ class OAuthSourceFlowManager(SourceFlowManager): self, connection: UserOAuthSourceConnection, access_token: str | None = None, + **_, ) -> UserOAuthSourceConnection: """Set the access_token on the connection""" connection.access_token = access_token diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index 593f49446b..ca1a3c053b 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -190,7 +190,7 @@ class SAMLSource(Source): def ui_login_button(self, request: HttpRequest) -> UILoginButton: return UILoginButton( challenge=RedirectChallenge( - instance={ + data={ "type": ChallengeTypes.REDIRECT.value, "to": reverse( "authentik_sources_saml:login", diff --git a/authentik/sources/saml/processors/response.py b/authentik/sources/saml/processors/response.py index d10879fafe..508a77ba05 100644 --- a/authentik/sources/saml/processors/response.py +++ b/authentik/sources/saml/processors/response.py @@ -234,12 +234,14 @@ class ResponseProcessor: if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: return self._handle_name_id_transient() - return SAMLSourceFlowManager( + flow_manager = SAMLSourceFlowManager( self._source, self._http_request, name_id.text, delete_none_values(self.get_attributes()), ) + flow_manager.policy_context["saml_response"] = self._root + return flow_manager class SAMLSourceFlowManager(SourceFlowManager): diff --git a/authentik/stages/dummy/stage.py b/authentik/stages/dummy/stage.py index e6468420c2..e95c740817 100644 --- a/authentik/stages/dummy/stage.py +++ b/authentik/stages/dummy/stage.py @@ -12,6 +12,7 @@ class DummyChallenge(Challenge): """Dummy challenge""" component = CharField(default="ak-stage-dummy") + name = CharField() class DummyChallengeResponse(ChallengeResponse): @@ -35,5 +36,6 @@ class DummyStageView(ChallengeStageView): data={ "type": ChallengeTypes.NATIVE.value, "title": self.executor.current_stage.name, + "name": self.executor.current_stage.name, } ) diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 130442d2c9..3799991d9f 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -237,7 +237,9 @@ class IdentificationStageView(ChallengeStageView): ui_login_button = source.ui_login_button(self.request) if ui_login_button: button = asdict(ui_login_button) - button["challenge"] = ui_login_button.challenge.data + source_challenge = ui_login_button.challenge + source_challenge.is_valid() + button["challenge"] = source_challenge.data ui_sources.append(button) challenge.initial_data["sources"] = ui_sources return challenge diff --git a/blueprints/schema.json b/blueprints/schema.json index 80046626f9..da1082c7aa 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -2594,6 +2594,43 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_source.sourcestage" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_source.sourcestage" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_source.sourcestage" + } + } + }, { "type": "object", "required": [ @@ -3257,6 +3294,7 @@ "authentik.enterprise", "authentik.enterprise.audit", "authentik.enterprise.providers.rac", + "authentik.enterprise.stages.source", "authentik.events" ], "title": "App", @@ -3338,6 +3376,7 @@ "authentik_providers_rac.racprovider", "authentik_providers_rac.endpoint", "authentik_providers_rac.racpropertymapping", + "authentik_stages_source.sourcestage", "authentik_events.event", "authentik_events.notificationtransport", "authentik_events.notification", @@ -8018,6 +8057,109 @@ }, "required": [] }, + "model_authentik_stages_source.sourcestage": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "flow_set": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "slug": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "pattern": "^[-a-zA-Z0-9_]+$", + "title": "Slug", + "description": "Visible in the URL." + }, + "title": { + "type": "string", + "minLength": 1, + "title": "Title", + "description": "Shown as the Title in Flow pages." + }, + "designation": { + "type": "string", + "enum": [ + "authentication", + "authorization", + "invalidation", + "enrollment", + "unenrollment", + "recovery", + "stage_configuration" + ], + "title": "Designation", + "description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." + }, + "policy_engine_mode": { + "type": "string", + "enum": [ + "all", + "any" + ], + "title": "Policy engine mode" + }, + "compatibility_mode": { + "type": "boolean", + "title": "Compatibility mode", + "description": "Enable compatibility mode, increases compatibility with password managers on mobile devices." + }, + "layout": { + "type": "string", + "enum": [ + "stacked", + "content_left", + "content_right", + "sidebar_left", + "sidebar_right" + ], + "title": "Layout" + }, + "denied_action": { + "type": "string", + "enum": [ + "message_continue", + "message", + "continue" + ], + "title": "Denied action", + "description": "Configure what should happen when a flow denies access to a user." + } + }, + "required": [ + "name", + "slug", + "title", + "designation" + ] + }, + "title": "Flow set" + }, + "source": { + "type": "integer", + "title": "Source" + }, + "resume_timeout": { + "type": "string", + "minLength": 1, + "title": "Resume timeout", + "description": "Amount of time a user can take to return from the source to continue the flow (Format: hours=-1;minutes=-2;seconds=-3)" + } + }, + "required": [] + }, "model_authentik_events.event": { "type": "object", "properties": { diff --git a/schema.yml b/schema.yml index 8a830f9916..f2f1c2e23b 100644 --- a/schema.yml +++ b/schema.yml @@ -18512,6 +18512,7 @@ paths: - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage + - authentik_stages_source.sourcestage - authentik_stages_user_delete.userdeletestage - authentik_stages_user_login.userloginstage - authentik_stages_user_logout.userlogoutstage @@ -18587,6 +18588,7 @@ paths: * `authentik_providers_rac.racprovider` - RAC Provider * `authentik_providers_rac.endpoint` - RAC Endpoint * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping + * `authentik_stages_source.sourcestage` - Source Stage * `authentik_events.event` - Event * `authentik_events.notificationtransport` - Notification Transport * `authentik_events.notification` - Notification @@ -18800,6 +18802,7 @@ paths: - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage + - authentik_stages_source.sourcestage - authentik_stages_user_delete.userdeletestage - authentik_stages_user_login.userloginstage - authentik_stages_user_logout.userlogoutstage @@ -18875,6 +18878,7 @@ paths: * `authentik_providers_rac.racprovider` - RAC Provider * `authentik_providers_rac.endpoint` - RAC Endpoint * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping + * `authentik_stages_source.sourcestage` - Source Stage * `authentik_events.event` - Event * `authentik_events.notificationtransport` - Notification Transport * `authentik_events.notification` - Notification @@ -27872,6 +27876,289 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /stages/source/: + get: + operationId: stages_source_list + description: SourceStage Viewset + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: resume_timeout + schema: + type: string + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: source + schema: + type: string + format: uuid + - in: query + name: stage_uuid + schema: + type: string + format: uuid + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSourceStageList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: stages_source_create + description: SourceStage Viewset + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SourceStageRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/SourceStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/source/{stage_uuid}/: + get: + operationId: stages_source_retrieve + description: SourceStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Source Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SourceStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: stages_source_update + description: SourceStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Source Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SourceStageRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SourceStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: stages_source_partial_update + description: SourceStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Source Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedSourceStageRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SourceStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: stages_source_destroy + description: SourceStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Source Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/source/{stage_uuid}/used_by/: + get: + operationId: stages_source_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Source Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /stages/user_delete/: get: operationId: stages_user_delete_list @@ -29642,6 +29929,7 @@ components: - authentik.enterprise - authentik.enterprise.audit - authentik.enterprise.providers.rac + - authentik.enterprise.stages.source - authentik.events type: string description: |- @@ -29696,6 +29984,7 @@ components: * `authentik.enterprise` - authentik Enterprise * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC + * `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source * `authentik.events` - authentik Events AppleChallengeResponseRequest: type: object @@ -32057,7 +32346,10 @@ components: type: array items: $ref: '#/components/schemas/ErrorDetail' + name: + type: string required: + - name - type DummyChallengeResponseRequest: type: object @@ -32739,6 +33031,7 @@ components: * `authentik.enterprise` - authentik Enterprise * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC + * `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source * `authentik.events` - authentik Events model: allOf: @@ -32816,6 +33109,7 @@ components: * `authentik_providers_rac.racprovider` - RAC Provider * `authentik_providers_rac.endpoint` - RAC Endpoint * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping + * `authentik_stages_source.sourcestage` - Source Stage * `authentik_events.event` - Event * `authentik_events.notificationtransport` - Notification Transport * `authentik_events.notification` - Notification @@ -32938,6 +33232,7 @@ components: * `authentik.enterprise` - authentik Enterprise * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC + * `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source * `authentik.events` - authentik Events model: allOf: @@ -33015,6 +33310,7 @@ components: * `authentik_providers_rac.racprovider` - RAC Provider * `authentik_providers_rac.endpoint` - RAC Endpoint * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping + * `authentik_stages_source.sourcestage` - Source Stage * `authentik_events.event` - Event * `authentik_events.notificationtransport` - Notification Transport * `authentik_events.notification` - Notification @@ -35330,6 +35626,7 @@ components: - authentik_providers_rac.racprovider - authentik_providers_rac.endpoint - authentik_providers_rac.racpropertymapping + - authentik_stages_source.sourcestage - authentik_events.event - authentik_events.notificationtransport - authentik_events.notification @@ -35406,6 +35703,7 @@ components: * `authentik_providers_rac.racprovider` - RAC Provider * `authentik_providers_rac.endpoint` - RAC Endpoint * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping + * `authentik_stages_source.sourcestage` - Source Stage * `authentik_events.event` - Event * `authentik_events.notificationtransport` - Notification Transport * `authentik_events.notification` - Notification @@ -37340,6 +37638,18 @@ components: required: - pagination - results + PaginatedSourceStageList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/SourceStage' + required: + - pagination + - results PaginatedStageList: type: object properties: @@ -38583,6 +38893,7 @@ components: * `authentik.enterprise` - authentik Enterprise * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC + * `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source * `authentik.events` - authentik Events model: allOf: @@ -38660,6 +38971,7 @@ components: * `authentik_providers_rac.racprovider` - RAC Provider * `authentik_providers_rac.endpoint` - RAC Endpoint * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping + * `authentik_stages_source.sourcestage` - Source Stage * `authentik_events.event` - Event * `authentik_events.notificationtransport` - Notification Transport * `authentik_events.notification` - Notification @@ -40242,6 +40554,25 @@ components: impersonation: type: boolean description: Globally enable/disable impersonation. + PatchedSourceStageRequest: + type: object + description: SourceStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + source: + type: string + format: uuid + resume_timeout: + type: string + minLength: 1 + description: 'Amount of time a user can take to return from the source to + continue the flow (Format: hours=-1;minutes=-2;seconds=-3)' PatchedStaticDeviceRequest: type: object description: Serializer for static authenticator devices @@ -43586,6 +43917,74 @@ components: required: - name - slug + SourceStage: + type: object + description: SourceStage Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Stage uuid + name: + type: string + component: + type: string + description: Get object type so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSet' + source: + type: string + format: uuid + resume_timeout: + type: string + description: 'Amount of time a user can take to return from the source to + continue the flow (Format: hours=-1;minutes=-2;seconds=-3)' + required: + - component + - meta_model_name + - name + - pk + - source + - verbose_name + - verbose_name_plural + SourceStageRequest: + type: object + description: SourceStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + source: + type: string + format: uuid + resume_timeout: + type: string + minLength: 1 + description: 'Amount of time a user can take to return from the source to + continue the flow (Format: hours=-1;minutes=-2;seconds=-3)' + required: + - name + - source SourceType: type: object description: Serializer for SourceType diff --git a/web/src/admin/stages/StageListPage.ts b/web/src/admin/stages/StageListPage.ts index 0271c522d0..dac14e4e53 100644 --- a/web/src/admin/stages/StageListPage.ts +++ b/web/src/admin/stages/StageListPage.ts @@ -15,6 +15,7 @@ import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; +import "@goauthentik/admin/stages/source/SourceStageForm"; import "@goauthentik/admin/stages/user_delete/UserDeleteStageForm"; import "@goauthentik/admin/stages/user_login/UserLoginStageForm"; import "@goauthentik/admin/stages/user_logout/UserLogoutStageForm"; diff --git a/web/src/admin/stages/StageWizard.ts b/web/src/admin/stages/StageWizard.ts index 3ace712e4d..a3ac4b3059 100644 --- a/web/src/admin/stages/StageWizard.ts +++ b/web/src/admin/stages/StageWizard.ts @@ -1,3 +1,4 @@ +import "@goauthentik/admin/common/ak-license-notice"; import { StageBindingForm } from "@goauthentik/admin/flows/StageBindingForm"; import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm"; import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm"; @@ -14,12 +15,14 @@ import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; +import "@goauthentik/admin/stages/source/SourceStageForm"; import "@goauthentik/admin/stages/user_delete/UserDeleteStageForm"; import "@goauthentik/admin/stages/user_login/UserLoginStageForm"; import "@goauthentik/admin/stages/user_logout/UserLogoutStageForm"; import "@goauthentik/admin/stages/user_write/UserWriteStageForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKElement } from "@goauthentik/elements/Base"; +import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; import "@goauthentik/elements/forms/ProxyForm"; import "@goauthentik/elements/wizard/FormWizardPage"; import { FormWizardPage } from "@goauthentik/elements/wizard/FormWizardPage"; @@ -28,7 +31,7 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage"; import { msg, str } from "@lit/localize"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; -import { CSSResult, TemplateResult, html } from "lit"; +import { CSSResult, TemplateResult, html, nothing } from "lit"; import { property } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; @@ -39,7 +42,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { FlowStageBinding, Stage, StagesApi, TypeCreate } from "@goauthentik/api"; @customElement("ak-stage-wizard-initial") -export class InitialStageWizardPage extends WizardPage { +export class InitialStageWizardPage extends WithLicenseSummary(WizardPage) { @property({ attribute: false }) stageTypes: TypeCreate[] = []; sidebarLabel = () => msg("Select type"); @@ -62,6 +65,7 @@ export class InitialStageWizardPage extends WizardPage { render(): TemplateResult { return html`
`; diff --git a/web/src/admin/stages/source/SourceStageForm.ts b/web/src/admin/stages/source/SourceStageForm.ts new file mode 100644 index 0000000000..72acbf059f --- /dev/null +++ b/web/src/admin/stages/source/SourceStageForm.ts @@ -0,0 +1,99 @@ +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/SearchSelect/index"; +import "@goauthentik/elements/utils/TimeDeltaHelp"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + Source, + SourceStage, + SourcesAllListRequest, + SourcesApi, + StagesApi, +} from "@goauthentik/api"; + +@customElement("ak-stage-source-form") +export class SourceStageForm extends BaseStageForm+ ${msg( + "Amount of time a user can take to return from the source to continue the flow.", + )} +
+${msg(str`Stage name: ${this.challenge.name}`)}