stages/authenticator_validate: add configuration stage to configure Authenticator

This commit is contained in:
Jens Langhammer 2021-03-01 19:23:27 +01:00
parent 644a03e40e
commit ed8b78600e
9 changed files with 130 additions and 2 deletions

View File

@ -24,7 +24,7 @@ class NotConfiguredAction(models.TextChoices):
SKIP = "skip" SKIP = "skip"
DENY = "deny" DENY = "deny"
# CONFIGURE = "configure" CONFIGURE = "configure"
class FlowDesignation(models.TextChoices): class FlowDesignation(models.TextChoices):

View File

@ -47,6 +47,11 @@ class FlowPlan:
self.stages.append(stage) self.stages.append(stage)
self.markers.append(marker or StageMarker()) 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]: def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]:
"""Return next pending stage from the bottom of the list""" """Return next pending stage from the bottom of the list"""
if not self.has_stages: if not self.has_stages:

View File

@ -1,19 +1,34 @@
"""AuthenticatorValidateStage API Views""" """AuthenticatorValidateStage API Views"""
from rest_framework.serializers import ValidationError
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.flows.api.stages import StageSerializer from authentik.flows.api.stages import StageSerializer
from authentik.flows.models import NotConfiguredAction
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
class AuthenticatorValidateStageSerializer(StageSerializer): class AuthenticatorValidateStageSerializer(StageSerializer):
"""AuthenticatorValidateStage Serializer""" """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: class Meta:
model = AuthenticatorValidateStage model = AuthenticatorValidateStage
fields = StageSerializer.Meta.fields + [ fields = StageSerializer.Meta.fields + [
"not_configured_action", "not_configured_action",
"device_classes", "device_classes",
"configuration_stage",
] ]

View File

@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from django_otp import match_token from django_otp import match_token
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.models import NotConfiguredAction
from authentik.stages.authenticator_validate.models import ( from authentik.stages.authenticator_validate.models import (
AuthenticatorValidateStage, AuthenticatorValidateStage,
DeviceClasses, DeviceClasses,
@ -42,10 +43,31 @@ class ValidationForm(forms.Form):
class AuthenticatorValidateStageForm(forms.ModelForm): class AuthenticatorValidateStageForm(forms.ModelForm):
"""OTP Validate stage forms""" """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: class Meta:
model = AuthenticatorValidateStage model = AuthenticatorValidateStage
fields = ["name", "device_classes"] fields = [
"name",
"not_configured_action",
"device_classes",
"configuration_stage",
]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),

View File

@ -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",
),
),
]

View File

@ -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",
),
),
]

View File

@ -36,6 +36,21 @@ class AuthenticatorValidateStage(Stage):
choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP 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( device_classes = ArrayField(
models.TextField(), models.TextField(),
help_text=_("Device classes which can be used to authenticate"), help_text=_("Device classes which can be used to authenticate"),

View File

@ -133,6 +133,12 @@ class AuthenticatorValidateStageView(ChallengeStageView):
if stage.not_configured_action == NotConfiguredAction.DENY: if stage.not_configured_action == NotConfiguredAction.DENY:
LOGGER.debug("Authenticator not configured, denying") LOGGER.debug("Authenticator not configured, denying")
return self.executor.stage_invalid() 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) return super().get(request, *args, **kwargs)
def get_challenge(self) -> AuthenticatorChallenge: def get_challenge(self) -> AuthenticatorChallenge:

View File

@ -11070,6 +11070,7 @@ definitions:
enum: enum:
- skip - skip
- deny - deny
- configure
device_classes: device_classes:
description: '' description: ''
type: array type: array
@ -11077,6 +11078,14 @@ definitions:
title: Device classes title: Device classes
type: string type: string
minLength: 1 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: AuthenticateWebAuthnStage:
description: AuthenticateWebAuthnStage Serializer description: AuthenticateWebAuthnStage Serializer
required: required: