diff --git a/authentik/stages/authenticator_validate/api.py b/authentik/stages/authenticator_validate/api.py index 08b110e3e..20c016726 100644 --- a/authentik/stages/authenticator_validate/api.py +++ b/authentik/stages/authenticator_validate/api.py @@ -13,8 +13,8 @@ class AuthenticatorValidateStageSerializer(StageSerializer): def validate_not_configured_action(self, value): """Ensure that a configuration stage is set when not_configured_action is configure""" - configuration_stage = self.initial_data.get("configuration_stage") - if value == NotConfiguredAction.CONFIGURE and configuration_stage is None: + configuration_stages = self.initial_data.get("configuration_stages") + if value == NotConfiguredAction.CONFIGURE and configuration_stages is None: raise ValidationError( ( 'When "Not configured action" is set to "Configure", ' @@ -29,7 +29,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer): fields = StageSerializer.Meta.fields + [ "not_configured_action", "device_classes", - "configuration_stage", + "configuration_stages", ] @@ -38,5 +38,5 @@ class AuthenticatorValidateStageViewSet(UsedByMixin, ModelViewSet): queryset = AuthenticatorValidateStage.objects.all() serializer_class = AuthenticatorValidateStageSerializer - filterset_fields = ["name", "not_configured_action", "configuration_stage"] + filterset_fields = ["name", "not_configured_action", "configuration_stages"] ordering = ["name"] diff --git a/authentik/stages/authenticator_validate/migrations/0010_remove_authenticatorvalidatestage_configuration_stage_and_more.py b/authentik/stages/authenticator_validate/migrations/0010_remove_authenticatorvalidatestage_configuration_stage_and_more.py new file mode 100644 index 000000000..be6377e6a --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0010_remove_authenticatorvalidatestage_configuration_stage_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.0.1 on 2022-01-05 22:09 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_configuration_stage(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + AuthenticatorValidateStage = apps.get_model( + "authentik_stages_authenticator_validate", "AuthenticatorValidateStage" + ) + + for stage in AuthenticatorValidateStage.objects.using(db_alias).all(): + if stage.configuration_stage: + stage.configuration_stages.set([stage.configuration_stage]) + stage.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0021_auto_20211227_2103"), + ("authentik_stages_authenticator_validate", "0009_default_stage"), + ] + + operations = [ + migrations.AddField( + model_name="authenticatorvalidatestage", + name="configuration_stages", + field=models.ManyToManyField( + blank=True, + default=None, + help_text="Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again.", + related_name="+", + to="authentik_flows.Stage", + ), + ), + migrations.RunPython(migrate_configuration_stage), + migrations.RemoveField( + model_name="authenticatorvalidatestage", + name="configuration_stage", + ), + ] diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index c9f9cd382..4f21b20d1 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -38,16 +38,14 @@ class AuthenticatorValidateStage(Stage): choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP ) - configuration_stage = models.ForeignKey( + configuration_stages = models.ManyToManyField( Stage, - null=True, blank=True, default=None, - on_delete=models.SET_DEFAULT, related_name="+", help_text=_( ( - "Stage used to configure Authenticator when user doesn't have any compatible " + "Stages used to configure Authenticator when user doesn't have any compatible " "devices. After this configuration Stage passes, the user is not prompted again." ) ), diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index d30ff1ad2..622e0873c 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -1,10 +1,12 @@ """Authenticator Validation""" from django.http import HttpRequest, HttpResponse from django_otp import devices_for_user -from rest_framework.fields import CharField, IntegerField, JSONField, ListField +from rest_framework.fields import CharField, IntegerField, JSONField, ListField, UUIDField from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger +from authentik.core.api.utils import PassiveSerializer +from authentik.core.models import User from authentik.events.models import Event, EventAction from authentik.events.utils import cleanse_dict, sanitize_dict from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge @@ -26,6 +28,18 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS LOGGER = get_logger() +SESSION_STAGES = "goauthentik.io/stages/authenticator_validate/stages" +SESSION_SELECTED_STAGE = "goauthentik.io/stages/authenticator_validate/selected_stage" +SESSION_DEVICE_CHALLENGES = "goauthentik.io/stages/authenticator_validate/device_challenges" + + +class SelectableStageSerializer(PassiveSerializer): + """Serializer for stages which can be selected by users""" + + pk = UUIDField() + name = CharField() + verbose_name = CharField() + meta_model_name = CharField() class AuthenticatorValidationChallenge(WithUserInfoChallenge): @@ -33,12 +47,14 @@ class AuthenticatorValidationChallenge(WithUserInfoChallenge): device_challenges = ListField(child=DeviceChallenge()) component = CharField(default="ak-stage-authenticator-validate") + configuration_stages = ListField(child=SelectableStageSerializer()) class AuthenticatorValidationChallengeResponse(ChallengeResponse): """Challenge used for Code-based and WebAuthn authenticators""" selected_challenge = DeviceChallenge(required=False) + selected_stage = CharField(required=False) code = CharField(required=False) webauthn = JSONField(required=False) @@ -84,6 +100,15 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): select_challenge(self.stage.request, devices.first()) return challenge + def validate_selected_stage(self, stage_pk: str) -> str: + """Check that the selected stage is valid""" + stages = self.stage.request.session.get(SESSION_STAGES, []) + if not any(str(stage.pk) == stage_pk for stage in stages): + raise ValidationError("Selected stage is invalid") + LOGGER.debug("Setting selected stage to ", stage=stage_pk) + self.stage.request.session[SESSION_SELECTED_STAGE] = stage_pk + return stage_pk + def validate(self, attrs: dict): # Checking if the given data is from a valid device class is done above # Here we only check if the any data was sent at all @@ -164,7 +189,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): else: LOGGER.debug("No pending user, continuing") return self.executor.stage_ok() - self.request.session["device_challenges"] = challenges + self.request.session[SESSION_DEVICE_CHALLENGES] = challenges # No allowed devices if len(challenges) < 1: @@ -175,35 +200,71 @@ class AuthenticatorValidateStageView(ChallengeStageView): LOGGER.debug("Authenticator not configured, denying") return self.executor.stage_invalid() if stage.not_configured_action == NotConfiguredAction.CONFIGURE: - if not stage.configuration_stage: - Event.new( - EventAction.CONFIGURATION_ERROR, - message=( - "Authenticator validation stage is set to configure user " - "but no configuration flow is set." - ), - stage=self, - ).from_http(self.request).set_user(user).save() - return self.executor.stage_invalid() - LOGGER.debug("Authenticator not configured, sending user to configure") - # Because the foreign key to stage.configuration_stage points to - # a base stage class, we need to do another lookup - stage = Stage.objects.get_subclass(pk=stage.configuration_stage.pk) - # plan.insert inserts at 1 index, so when stage_ok pops 0, - # the configuration stage is next - self.executor.plan.insert_stage(stage) - return self.executor.stage_ok() + LOGGER.debug("Authenticator not configured, forcing configure") + return self.prepare_stages(user) return super().get(request, *args, **kwargs) - def get_challenge(self) -> AuthenticatorValidationChallenge: - challenges = self.request.session.get("device_challenges") - if not challenges: - LOGGER.debug("Authenticator Validation stage ran without challenges") + def prepare_stages(self, user: User, *args, **kwargs) -> HttpResponse: + """Check how the user can configure themselves. If no stages are set, return an error. + If a single stage is set, insert that stage directly. If multiple are selected, include + them in the challenge.""" + stage: AuthenticatorValidateStage = self.executor.current_stage + if not stage.configuration_stages.exists(): + Event.new( + EventAction.CONFIGURATION_ERROR, + message=( + "Authenticator validation stage is set to configure user " + "but no configuration flow is set." + ), + stage=self, + ).from_http(self.request).set_user(user).save() return self.executor.stage_invalid() + if stage.configuration_stages.count() == 1: + self.request.session[SESSION_SELECTED_STAGE] = stage.configuration_stages.first() + LOGGER.debug( + "Single stage configured, auto-selecting", + stage=self.request.session[SESSION_SELECTED_STAGE], + ) + stages = Stage.objects.filter(pk__in=stage.configuration_stages.all()).select_subclasses() + self.request.session[SESSION_STAGES] = stages + return super().get(self.request, *args, **kwargs) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + if ( + SESSION_SELECTED_STAGE in self.request.session + and self.executor.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE + ): + LOGGER.debug("Got selected stage in session, running that") + stage_pk = self.request.session.get(SESSION_SELECTED_STAGE) + # Because the foreign key to stage.configuration_stage points to + # a base stage class, we need to do another lookup + stage = Stage.objects.get_subclass(pk=stage_pk) + # plan.insert inserts at 1 index, so when stage_ok pops 0, + # the configuration stage is next + self.executor.plan.insert_stage(stage) + return self.executor.stage_ok() + return super().post(request, *args, **kwargs) + + def get_challenge(self) -> AuthenticatorValidationChallenge: + challenges = self.request.session.get(SESSION_DEVICE_CHALLENGES, []) + stages = self.request.session.get(SESSION_STAGES, []) + stage_challenges = [] + for stage in stages: + serializer = SelectableStageSerializer( + data={ + "pk": stage.pk, + "name": stage.name, + "verbose_name": str(stage._meta.verbose_name), + "meta_model_name": f"{stage._meta.app_label}.{stage._meta.model_name}", + } + ) + serializer.is_valid() + stage_challenges.append(serializer.data) return AuthenticatorValidationChallenge( data={ "type": ChallengeTypes.NATIVE.value, "device_challenges": challenges, + "configuration_stages": stage_challenges, } ) diff --git a/authentik/stages/authenticator_validate/tests.py b/authentik/stages/authenticator_validate/tests.py index 3b9ee8879..80b4fd9e0 100644 --- a/authentik/stages/authenticator_validate/tests.py +++ b/authentik/stages/authenticator_validate/tests.py @@ -43,8 +43,8 @@ class AuthenticatorValidateStageTests(FlowTestCase): stage = AuthenticatorValidateStage.objects.create( name="foo", not_configured_action=NotConfiguredAction.CONFIGURE, - configuration_stage=conf_stage, ) + stage.configuration_stages.set([conf_stage]) flow = Flow.objects.create(name="test", slug="test", title="test") FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) FlowStageBinding.objects.create(target=flow, stage=stage, order=1) diff --git a/schema.yml b/schema.yml index 7c4464e76..ab4112680 100644 --- a/schema.yml +++ b/schema.yml @@ -15045,10 +15045,14 @@ paths: description: AuthenticatorValidateStage Viewset parameters: - in: query - name: configuration_stage + name: configuration_stages schema: - type: string - format: uuid + type: array + items: + type: string + format: uuid + explode: true + style: form - in: query name: name schema: @@ -19826,11 +19830,12 @@ components: items: $ref: '#/components/schemas/DeviceClassesEnum' description: Device classes which can be used to authenticate - configuration_stage: - type: string - format: uuid - nullable: true - description: Stage used to configure Authenticator when user doesn't have + configuration_stages: + type: array + items: + type: string + format: uuid + description: Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again. required: @@ -19858,11 +19863,12 @@ components: items: $ref: '#/components/schemas/DeviceClassesEnum' description: Device classes which can be used to authenticate - configuration_stage: - type: string - format: uuid - nullable: true - description: Stage used to configure Authenticator when user doesn't have + configuration_stages: + type: array + items: + type: string + format: uuid + description: Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again. required: @@ -19892,7 +19898,12 @@ components: type: array items: $ref: '#/components/schemas/DeviceChallenge' + configuration_stages: + type: array + items: + $ref: '#/components/schemas/SelectableStage' required: + - configuration_stages - device_challenges - pending_user - pending_user_avatar @@ -19907,6 +19918,9 @@ components: default: ak-stage-authenticator-validate selected_challenge: $ref: '#/components/schemas/DeviceChallengeRequest' + selected_stage: + type: string + minLength: 1 code: type: string minLength: 1 @@ -26677,11 +26691,12 @@ components: items: $ref: '#/components/schemas/DeviceClassesEnum' description: Device classes which can be used to authenticate - configuration_stage: - type: string - format: uuid - nullable: true - description: Stage used to configure Authenticator when user doesn't have + configuration_stages: + type: array + items: + type: string + format: uuid + description: Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again. PatchedCaptchaStageRequest: @@ -30017,6 +30032,24 @@ components: - direct - cached type: string + SelectableStage: + type: object + description: Serializer for stages which can be selected by users + properties: + pk: + type: string + format: uuid + name: + type: string + verbose_name: + type: string + meta_model_name: + type: string + required: + - meta_model_name + - name + - pk + - verbose_name ServiceConnection: type: object description: ServiceConnection Serializer diff --git a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts index 97aee14ef..368ed38c4 100644 --- a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts +++ b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts @@ -67,7 +67,7 @@ export class AuthenticatorValidateStage return this._selectedDeviceChallenge; } - submit(payload: AuthenticatorValidationChallengeResponseRequest): Promise { + submit(payload: AuthenticatorValidationChallengeResponseRequest): Promise { return this.host?.submit(payload) || Promise.resolve(); } @@ -140,7 +140,7 @@ export class AuthenticatorValidateStage } renderDevicePicker(): TemplateResult { - return html`