From ed8b78600e3f9a22ff8d284e2228c59f58ec70e0 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 1 Mar 2021 19:23:27 +0100 Subject: [PATCH] stages/authenticator_validate: add configuration stage to configure Authenticator --- authentik/flows/models.py | 2 +- authentik/flows/planner.py | 5 ++++ .../stages/authenticator_validate/api.py | 15 ++++++++++ .../stages/authenticator_validate/forms.py | 24 +++++++++++++++- ...icatorvalidatestage_configuration_stage.py | 28 +++++++++++++++++++ .../migrations/0006_auto_20210301_1757.py | 28 +++++++++++++++++++ .../stages/authenticator_validate/models.py | 15 ++++++++++ .../stages/authenticator_validate/stage.py | 6 ++++ swagger.yaml | 9 ++++++ 9 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 authentik/stages/authenticator_validate/migrations/0005_authenticatorvalidatestage_configuration_stage.py create mode 100644 authentik/stages/authenticator_validate/migrations/0006_auto_20210301_1757.py diff --git a/authentik/flows/models.py b/authentik/flows/models.py index b7f1cd758..b345a1c22 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -24,7 +24,7 @@ class NotConfiguredAction(models.TextChoices): SKIP = "skip" DENY = "deny" - # CONFIGURE = "configure" + CONFIGURE = "configure" class FlowDesignation(models.TextChoices): diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py index 1f4b07e93..f66f02dc6 100644 --- a/authentik/flows/planner.py +++ b/authentik/flows/planner.py @@ -47,6 +47,11 @@ class FlowPlan: self.stages.append(stage) self.markers.append(marker or StageMarker()) + def insert(self, stage: Stage, marker: Optional[StageMarker] = None): + """Insert stage into plan, as immediate next stage""" + self.stages.insert(1, stage) + self.markers.insert(1, marker or StageMarker()) + def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]: """Return next pending stage from the bottom of the list""" if not self.has_stages: diff --git a/authentik/stages/authenticator_validate/api.py b/authentik/stages/authenticator_validate/api.py index 23a0738dd..4a51aabb3 100644 --- a/authentik/stages/authenticator_validate/api.py +++ b/authentik/stages/authenticator_validate/api.py @@ -1,19 +1,34 @@ """AuthenticatorValidateStage API Views""" +from rest_framework.serializers import ValidationError from rest_framework.viewsets import ModelViewSet from authentik.flows.api.stages import StageSerializer +from authentik.flows.models import NotConfiguredAction from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage class AuthenticatorValidateStageSerializer(StageSerializer): """AuthenticatorValidateStage Serializer""" + 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: + raise ValidationError( + ( + 'When "Not configured action" is set to "Configure", ' + "you must set a configuration stage." + ) + ) + return value + class Meta: model = AuthenticatorValidateStage fields = StageSerializer.Meta.fields + [ "not_configured_action", "device_classes", + "configuration_stage", ] diff --git a/authentik/stages/authenticator_validate/forms.py b/authentik/stages/authenticator_validate/forms.py index bdc4c9316..12e5ea97d 100644 --- a/authentik/stages/authenticator_validate/forms.py +++ b/authentik/stages/authenticator_validate/forms.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from django_otp import match_token from authentik.core.models import User +from authentik.flows.models import NotConfiguredAction from authentik.stages.authenticator_validate.models import ( AuthenticatorValidateStage, DeviceClasses, @@ -42,10 +43,31 @@ class ValidationForm(forms.Form): class AuthenticatorValidateStageForm(forms.ModelForm): """OTP Validate stage forms""" + def clean_not_configured_action(self): + """Ensure that a configuration stage is set when not_configured_action is configure""" + not_configured_action = self.cleaned_data.get("not_configured_action") + configuration_stage = self.cleaned_data.get("configuration_stage") + if ( + not_configured_action == NotConfiguredAction.CONFIGURE + and configuration_stage is None + ): + raise forms.ValidationError( + ( + 'When "Not configured action" is set to "Configure", ' + "you must set a configuration stage." + ) + ) + return not_configured_action + class Meta: model = AuthenticatorValidateStage - fields = ["name", "device_classes"] + fields = [ + "name", + "not_configured_action", + "device_classes", + "configuration_stage", + ] widgets = { "name": forms.TextInput(), diff --git a/authentik/stages/authenticator_validate/migrations/0005_authenticatorvalidatestage_configuration_stage.py b/authentik/stages/authenticator_validate/migrations/0005_authenticatorvalidatestage_configuration_stage.py new file mode 100644 index 000000000..e9a8ab6b3 --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0005_authenticatorvalidatestage_configuration_stage.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.7 on 2021-03-01 17:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ("authentik_stages_authenticator_validate", "0004_auto_20210301_0949"), + ] + + operations = [ + migrations.AddField( + model_name="authenticatorvalidatestage", + name="configuration_stage", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Stage used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_flows.stage", + ), + ), + ] diff --git a/authentik/stages/authenticator_validate/migrations/0006_auto_20210301_1757.py b/authentik/stages/authenticator_validate/migrations/0006_auto_20210301_1757.py new file mode 100644 index 000000000..146e960d6 --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0006_auto_20210301_1757.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.7 on 2021-03-01 17:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_stages_authenticator_validate", + "0005_authenticatorvalidatestage_configuration_stage", + ), + ] + + operations = [ + migrations.AlterField( + model_name="authenticatorvalidatestage", + name="not_configured_action", + field=models.TextField( + choices=[ + ("skip", "Skip"), + ("deny", "Deny"), + ("configure", "Configure"), + ], + default="skip", + ), + ), + ] diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index 4938be2db..dce7e0b59 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -36,6 +36,21 @@ class AuthenticatorValidateStage(Stage): choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP ) + configuration_stage = models.ForeignKey( + 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 " + "devices. After this configuration Stage passes, the user is not prompted again." + ) + ), + ) + device_classes = ArrayField( models.TextField(), help_text=_("Device classes which can be used to authenticate"), diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index f8ed7e842..a1facfa3f 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -133,6 +133,12 @@ class AuthenticatorValidateStageView(ChallengeStageView): if stage.not_configured_action == NotConfiguredAction.DENY: LOGGER.debug("Authenticator not configured, denying") return self.executor.stage_invalid() + if stage.not_configured_action == NotConfiguredAction.CONFIGURE: + LOGGER.debug("Authenticator not configured, sending user to configure") + # plan.insert inserts at 1 index, so when stage_ok pops 0, + # the configuration stage is next + self.executor.plan.insert(stage.configuration_stage) + return self.executor.stage_ok() return super().get(request, *args, **kwargs) def get_challenge(self) -> AuthenticatorChallenge: diff --git a/swagger.yaml b/swagger.yaml index bbfdfac1b..96cca515a 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -11070,6 +11070,7 @@ definitions: enum: - skip - deny + - configure device_classes: description: '' type: array @@ -11077,6 +11078,14 @@ definitions: title: Device classes type: string minLength: 1 + configuration_stage: + title: Configuration stage + description: Stage used to configure Authenticator when user doesn't have + any compatible devices. After this configuration Stage passes, the user + is not prompted again. + type: string + format: uuid + x-nullable: true AuthenticateWebAuthnStage: description: AuthenticateWebAuthnStage Serializer required: