From 8b52d711e85fa31b7b8f75577a7f4dcba7db6bb1 Mon Sep 17 00:00:00 2001 From: sdimovv <36302090+sdimovv@users.noreply.github.com> Date: Sun, 19 Mar 2023 19:56:17 +0200 Subject: [PATCH] stages/prompt: Add Radio Button Group, Dropdown and Text Area prompt fields (#4822) * Added radio-button prompt type in model * Add radio-button prompt * Refactored radio-button prompt; Added dropdown prompt * Added tests * Fixed unrelated to choice fields bug causing validation errors; Added more tests * Added description for new prompts * Added docs * Fix lint * Add forgotten file changes * Fix lint * Small fix * Add text-area prompts * Update authentik/stages/prompt/models.py Co-authored-by: Jens L. Signed-off-by: sdimovv <36302090+sdimovv@users.noreply.github.com> * Update authentik/stages/prompt/models.py Co-authored-by: Jens L. Signed-off-by: sdimovv <36302090+sdimovv@users.noreply.github.com> * Fix inline css * remove AKGlobal, update schema Signed-off-by: Jens Langhammer --------- Signed-off-by: sdimovv <36302090+sdimovv@users.noreply.github.com> Signed-off-by: Jens Langhammer Co-authored-by: Jens L. Co-authored-by: Jens Langhammer --- .../policies/password/tests/test_flows.py | 1 + ...er_prompt_placeholder_alter_prompt_type.py | 65 +++++++ authentik/stages/prompt/models.py | 73 +++++++- authentik/stages/prompt/stage.py | 33 +++- authentik/stages/prompt/tests.py | 161 ++++++++++++++++++ schema.yml | 32 ++++ web/src/admin/stages/prompt/PromptForm.ts | 30 +++- web/src/flow/stages/prompt/PromptStage.ts | 67 +++++++- website/docs/flow/stages/prompt/index.md | 43 +++-- 9 files changed, 475 insertions(+), 30 deletions(-) create mode 100644 authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py diff --git a/authentik/policies/password/tests/test_flows.py b/authentik/policies/password/tests/test_flows.py index 42e1ae558..bdf661b46 100644 --- a/authentik/policies/password/tests/test_flows.py +++ b/authentik/policies/password/tests/test_flows.py @@ -54,6 +54,7 @@ class TestPasswordPolicyFlow(FlowTestCase): component="ak-stage-prompt", fields=[ { + "choices": None, "field_key": "password", "label": "PASSWORD_LABEL", "order": 0, diff --git a/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py new file mode 100644 index 000000000..906369440 --- /dev/null +++ b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py @@ -0,0 +1,65 @@ +# Generated by Django 4.1.7 on 2023-03-03 17:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_prompt", "0009_prompt_name"), + ] + + operations = [ + migrations.AlterField( + model_name="prompt", + name="placeholder", + field=models.TextField( + blank=True, + help_text="When creating a Radio Button Group or Dropdown, enable interpreting as expression and return a list to return multiple choices.", + ), + ), + migrations.AlterField( + model_name="prompt", + name="type", + field=models.CharField( + choices=[ + ("text", "Text: Simple Text input"), + ("text_area", "Text area: Multiline Text Input."), + ( + "text_read_only", + "Text (read-only): Simple Text input, but cannot be edited.", + ), + ( + "text_area_read_only", + "Text area (read-only): Multiline Text input, but cannot be edited.", + ), + ( + "username", + "Username: Same as Text input, but checks for and prevents duplicate usernames.", + ), + ("email", "Email: Text field with Email type."), + ( + "password", + "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.", + ), + ("number", "Number"), + ("checkbox", "Checkbox"), + ( + "radio-button-group", + "Fixed choice field rendered as a group of radio buttons.", + ), + ("dropdown", "Fixed choice field rendered as a dropdown."), + ("date", "Date"), + ("date-time", "Date Time"), + ( + "file", + "File: File upload for arbitrary files. File content will be available in flow context as data-URI", + ), + ("separator", "Separator: Static Separator Line"), + ("hidden", "Hidden: Hidden field, can be used to insert data into form."), + ("static", "Static: Static value, displayed as-is."), + ("ak-locale", "authentik: Selection of locales authentik supports"), + ], + max_length=100, + ), + ), + ] diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index 62be9df16..425c4d5fc 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -11,6 +11,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.fields import ( BooleanField, CharField, + ChoiceField, DateField, DateTimeField, EmailField, @@ -38,10 +39,17 @@ class FieldTypes(models.TextChoices): # Simple text field TEXT = "text", _("Text: Simple Text input") + # Long text field + TEXT_AREA = "text_area", _("Text area: Multiline Text Input.") # Simple text field TEXT_READ_ONLY = "text_read_only", _( "Text (read-only): Simple Text input, but cannot be edited." ) + # Long text field + TEXT_AREA_READ_ONLY = "text_area_read_only", _( + "Text area (read-only): Multiline Text input, but cannot be edited." + ) + # Same as text, but has autocomplete for password managers USERNAME = ( "username", @@ -58,6 +66,10 @@ class FieldTypes(models.TextChoices): ) NUMBER = "number" CHECKBOX = "checkbox" + RADIO_BUTTON_GROUP = "radio-button-group", _( + "Fixed choice field rendered as a group of radio buttons." + ) + DROPDOWN = "dropdown", _("Fixed choice field rendered as a dropdown.") DATE = "date" DATE_TIME = "date-time" @@ -76,6 +88,9 @@ class FieldTypes(models.TextChoices): AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports") +CHOICE_FIELDS = (FieldTypes.RADIO_BUTTON_GROUP, FieldTypes.DROPDOWN) + + class InlineFileField(CharField): """Field for inline data-URI base64 encoded files""" @@ -102,7 +117,13 @@ class Prompt(SerializerModel): label = models.TextField() type = models.CharField(max_length=100, choices=FieldTypes.choices) required = models.BooleanField(default=True) - placeholder = models.TextField(blank=True) + placeholder = models.TextField( + blank=True, + help_text=_( + "When creating a Radio Button Group or Dropdown, enable interpreting as " + "expression and return a list to return multiple choices." + ), + ) sub_text = models.TextField(blank=True, default="") order = models.IntegerField(default=0) @@ -115,8 +136,46 @@ class Prompt(SerializerModel): return PromptSerializer + def get_choices( + self, prompt_context: dict, user: User, request: HttpRequest + ) -> Optional[tuple[dict[str, Any]]]: + """Get fully interpolated list of choices""" + if self.type not in CHOICE_FIELDS: + return None + + raw_choices = self.placeholder + + if self.field_key in prompt_context: + raw_choices = prompt_context[self.field_key] + elif self.placeholder_expression: + evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context) + try: + raw_choices = evaluator.evaluate(self.placeholder) + except Exception as exc: # pylint:disable=broad-except + LOGGER.warning( + "failed to evaluate prompt choices", + exc=PropertyMappingExpressionException(str(exc)), + ) + + if isinstance(raw_choices, (list, tuple, set)): + choices = raw_choices + else: + choices = [raw_choices] + + if len(choices) == 0: + LOGGER.warning("failed to get prompt choices", choices=choices, input=raw_choices) + + return tuple(choices) + def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str: """Get fully interpolated placeholder""" + if self.type in CHOICE_FIELDS: + # Make sure to return a valid choice as placeholder + choices = self.get_choices(prompt_context, user, request) + if not choices: + return "" + return choices[0] + if self.field_key in prompt_context: # We don't want to parse this as an expression since a user will # be able to control the input @@ -133,16 +192,16 @@ class Prompt(SerializerModel): ) return self.placeholder - def field(self, default: Optional[Any]) -> CharField: - """Get field type for Challenge and response""" + def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField: + """Get field type for Challenge and response. Choices are only valid for CHOICE_FIELDS.""" field_class = CharField kwargs = { "required": self.required, } - if self.type == FieldTypes.TEXT: + if self.type in (FieldTypes.TEXT, FieldTypes.TEXT_AREA): kwargs["trim_whitespace"] = False kwargs["allow_blank"] = not self.required - if self.type == FieldTypes.TEXT_READ_ONLY: + if self.type in (FieldTypes.TEXT_READ_ONLY, FieldTypes.TEXT_AREA_READ_ONLY): field_class = ReadOnlyField # required can't be set for ReadOnlyField kwargs["required"] = False @@ -154,13 +213,15 @@ class Prompt(SerializerModel): if self.type == FieldTypes.CHECKBOX: field_class = BooleanField kwargs["required"] = False + if self.type in CHOICE_FIELDS: + field_class = ChoiceField + kwargs["choices"] = choices or [] if self.type == FieldTypes.DATE: field_class = DateField if self.type == FieldTypes.DATE_TIME: field_class = DateTimeField if self.type == FieldTypes.FILE: field_class = InlineFileField - if self.type == FieldTypes.SEPARATOR: kwargs["required"] = False kwargs["label"] = "" diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py index 915cc695d..d0d723342 100644 --- a/authentik/stages/prompt/stage.py +++ b/authentik/stages/prompt/stage.py @@ -7,7 +7,14 @@ from django.db.models.query import QuerySet from django.http import HttpRequest, HttpResponse from django.http.request import QueryDict from django.utils.translation import gettext_lazy as _ -from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField, empty +from rest_framework.fields import ( + BooleanField, + CharField, + ChoiceField, + IntegerField, + ListField, + empty, +) from rest_framework.serializers import ValidationError from authentik.core.api.utils import PassiveSerializer @@ -33,6 +40,7 @@ class StagePromptSerializer(PassiveSerializer): placeholder = CharField(allow_blank=True) order = IntegerField() sub_text = CharField(allow_blank=True) + choices = ListField(child=CharField(allow_blank=True), allow_empty=True, allow_null=True) class PromptChallenge(Challenge): @@ -65,10 +73,13 @@ class PromptChallengeResponse(ChallengeResponse): fields = list(self.stage_instance.fields.all()) for field in fields: field: Prompt + choices = field.get_choices( + plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request + ) current = field.get_placeholder( plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request ) - self.fields[field.field_key] = field.field(current) + self.fields[field.field_key] = field.field(current, choices) # Special handling for fields with username type # these check for existing users with the same username if field.type == FieldTypes.USERNAME: @@ -99,7 +110,12 @@ class PromptChallengeResponse(ChallengeResponse): # Check if we have any static or hidden fields, and ensure they # still have the same value static_hidden_fields: QuerySet[Prompt] = self.stage_instance.fields.filter( - type__in=[FieldTypes.HIDDEN, FieldTypes.STATIC, FieldTypes.TEXT_READ_ONLY] + type__in=[ + FieldTypes.HIDDEN, + FieldTypes.STATIC, + FieldTypes.TEXT_READ_ONLY, + FieldTypes.TEXT_AREA_READ_ONLY, + ] ) for static_hidden in static_hidden_fields: field = self.fields[static_hidden.field_key] @@ -180,8 +196,15 @@ class PromptStageView(ChallengeStageView): context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}) for field in fields: data = StagePromptSerializer(field).data - data["placeholder"] = field.get_placeholder( - context_prompt, self.get_pending_user(), self.request + # Ensure all choices and placeholders are str, as otherwise further in + # we can fail serializer validation if we return some types such as bool + choices = field.get_choices(context_prompt, self.get_pending_user(), self.request) + if choices: + data["choices"] = [str(choice) for choice in choices] + else: + data["choices"] = None + data["placeholder"] = str( + field.get_placeholder(context_prompt, self.get_pending_user(), self.request) ) serializers.append(data) challenge = PromptChallenge( diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py index 4f593d1b5..4a7453ea0 100644 --- a/authentik/stages/prompt/tests.py +++ b/authentik/stages/prompt/tests.py @@ -45,6 +45,14 @@ class TestPromptStage(FlowTestCase): required=True, placeholder="TEXT_PLACEHOLDER", ) + text_area_prompt = Prompt.objects.create( + name=generate_id(), + field_key="text_area_prompt", + label="TEXT_AREA_LABEL", + type=FieldTypes.TEXT_AREA, + required=True, + placeholder="TEXT_AREA_PLACEHOLDER", + ) email_prompt = Prompt.objects.create( name=generate_id(), field_key="email_prompt", @@ -91,6 +99,19 @@ class TestPromptStage(FlowTestCase): required=True, placeholder="static", ) + radio_button_group = Prompt.objects.create( + name=generate_id(), + field_key="radio_button_group", + type=FieldTypes.RADIO_BUTTON_GROUP, + required=True, + placeholder="test", + ) + dropdown = Prompt.objects.create( + name=generate_id(), + field_key="dropdown", + type=FieldTypes.DROPDOWN, + required=True, + ) self.stage = PromptStage.objects.create(name="prompt-stage") self.stage.fields.set( [ @@ -102,18 +123,23 @@ class TestPromptStage(FlowTestCase): number_prompt, hidden_prompt, static_prompt, + radio_button_group, + dropdown, ] ) self.prompt_data = { username_prompt.field_key: "test-username", text_prompt.field_key: "test-input", + text_area_prompt.field_key: "test-area-input", email_prompt.field_key: "test@test.test", password_prompt.field_key: "test", password2_prompt.field_key: "test", number_prompt.field_key: 3, hidden_prompt.field_key: hidden_prompt.placeholder, static_prompt.field_key: static_prompt.placeholder, + radio_button_group.field_key: radio_button_group.placeholder, + dropdown.field_key: "", } self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) @@ -251,6 +277,34 @@ class TestPromptStage(FlowTestCase): {"username_prompt": [ErrorDetail(string="Username is already taken.", code="invalid")]}, ) + def test_invalid_choice_field(self): + """Test invalid choice field value""" + plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) + self.prompt_data["radio_button_group"] = "some invalid choice" + self.prompt_data["dropdown"] = "another invalid choice" + challenge_response = PromptChallengeResponse( + None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view + ) + self.assertEqual(challenge_response.is_valid(), False) + self.assertEqual( + challenge_response.errors, + { + "radio_button_group": [ + ErrorDetail( + string=f"\"{self.prompt_data['radio_button_group']}\" " + "is not a valid choice.", + code="invalid_choice", + ) + ], + "dropdown": [ + ErrorDetail( + string=f"\"{self.prompt_data['dropdown']}\" is not a valid choice.", + code="invalid_choice", + ) + ], + }, + ) + def test_static_hidden_overwrite(self): """Test that static and hidden fields ignore any value sent to them""" plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) @@ -289,6 +343,113 @@ class TestPromptStage(FlowTestCase): prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] ) + def test_choice_prompts_placeholders(self): + """Test placeholders and expression of choice fields""" + context = {"foo": generate_id()} + + # No choices - unusable (in the sense it creates an unsubmittable form) + # but valid behaviour + prompt: Prompt = Prompt( + field_key="fixed_choice_prompt_expression", + label="LABEL", + type=FieldTypes.RADIO_BUTTON_GROUP, + placeholder="return []", + placeholder_expression=True, + ) + self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "") + self.assertEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple()) + context["fixed_choice_prompt_expression"] = generate_id() + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), + context["fixed_choice_prompt_expression"], + ) + self.assertEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), + (context["fixed_choice_prompt_expression"],), + ) + self.assertNotEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "") + self.assertNotEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple()) + + del context["fixed_choice_prompt_expression"] + + # Single choice + prompt: Prompt = Prompt( + field_key="fixed_choice_prompt_expression", + label="LABEL", + type=FieldTypes.RADIO_BUTTON_GROUP, + placeholder="return prompt_context['foo']", + placeholder_expression=True, + ) + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] + ) + self.assertEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],) + ) + context["fixed_choice_prompt_expression"] = generate_id() + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), + context["fixed_choice_prompt_expression"], + ) + self.assertEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), + (context["fixed_choice_prompt_expression"],), + ) + self.assertNotEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] + ) + self.assertNotEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],) + ) + + del context["fixed_choice_prompt_expression"] + + # Multi choice + prompt: Prompt = Prompt( + field_key="fixed_choice_prompt_expression", + label="LABEL", + type=FieldTypes.DROPDOWN, + placeholder="return [prompt_context['foo'], True, 'text']", + placeholder_expression=True, + ) + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] + ) + self.assertEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), + (context["foo"], True, "text"), + ) + context["fixed_choice_prompt_expression"] = tuple(["text", generate_id(), 2]) + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), + "text", + ) + self.assertEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), + context["fixed_choice_prompt_expression"], + ) + self.assertNotEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] + ) + self.assertNotEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), + (context["foo"], True, "text"), + ) + + def test_choices_are_none_for_non_choice_fields(self): + """Test choices are None for non choice fields""" + context = {} + prompt: Prompt = Prompt( + field_key="text_prompt_expression", + label="TEXT_LABEL", + type=FieldTypes.TEXT, + placeholder="choice", + ) + self.assertEqual( + prompt.get_choices(context, self.user, self.factory.get("/")), + None, + ) + def test_prompt_placeholder_error(self): """Test placeholder and expression""" context = {} diff --git a/schema.yml b/schema.yml index 6a750b6b7..3e56bfbec 100644 --- a/schema.yml +++ b/schema.yml @@ -24125,24 +24125,32 @@ paths: - checkbox - date - date-time + - dropdown - email - file - hidden - number - password + - radio-button-group - separator - static - text + - text_area + - text_area_read_only - text_read_only - username description: |- * `text` - Text: Simple Text input + * `text_area` - Text area: Multiline Text Input. * `text_read_only` - Text (read-only): Simple Text input, but cannot be edited. + * `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames. * `email` - Email: Text field with Email type. * `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical. * `number` - Number * `checkbox` - Checkbox + * `radio-button-group` - Fixed choice field rendered as a group of radio buttons. + * `dropdown` - Fixed choice field rendered as a dropdown. * `date` - Date * `date-time` - Date Time * `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI @@ -24152,12 +24160,16 @@ paths: * `ak-locale` - authentik: Selection of locales authentik supports * `text` - Text: Simple Text input + * `text_area` - Text area: Multiline Text Input. * `text_read_only` - Text (read-only): Simple Text input, but cannot be edited. + * `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames. * `email` - Email: Text field with Email type. * `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical. * `number` - Number * `checkbox` - Checkbox + * `radio-button-group` - Fixed choice field rendered as a group of radio buttons. + * `dropdown` - Fixed choice field rendered as a dropdown. * `date` - Date * `date-time` - Date Time * `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI @@ -36262,6 +36274,8 @@ components: type: boolean placeholder: type: string + description: When creating a Radio Button Group or Dropdown, enable interpreting + as expression and return a list to return multiple choices. order: type: integer maximum: 2147483647 @@ -37388,6 +37402,8 @@ components: type: boolean placeholder: type: string + description: When creating a Radio Button Group or Dropdown, enable interpreting + as expression and return a list to return multiple choices. order: type: integer maximum: 2147483647 @@ -37461,6 +37477,8 @@ components: type: boolean placeholder: type: string + description: When creating a Radio Button Group or Dropdown, enable interpreting + as expression and return a list to return multiple choices. order: type: integer maximum: 2147483647 @@ -37554,12 +37572,16 @@ components: PromptTypeEnum: enum: - text + - text_area - text_read_only + - text_area_read_only - username - email - password - number - checkbox + - radio-button-group + - dropdown - date - date-time - file @@ -37570,12 +37592,16 @@ components: type: string description: |- * `text` - Text: Simple Text input + * `text_area` - Text area: Multiline Text Input. * `text_read_only` - Text (read-only): Simple Text input, but cannot be edited. + * `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames. * `email` - Email: Text field with Email type. * `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical. * `number` - Number * `checkbox` - Checkbox + * `radio-button-group` - Fixed choice field rendered as a group of radio buttons. + * `dropdown` - Fixed choice field rendered as a dropdown. * `date` - Date * `date-time` - Date Time * `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI @@ -39448,7 +39474,13 @@ components: type: integer sub_text: type: string + choices: + type: array + items: + type: string + nullable: true required: + - choices - field_key - label - order diff --git a/web/src/admin/stages/prompt/PromptForm.ts b/web/src/admin/stages/prompt/PromptForm.ts index efbbf9edb..9c4c9de53 100644 --- a/web/src/admin/stages/prompt/PromptForm.ts +++ b/web/src/admin/stages/prompt/PromptForm.ts @@ -49,12 +49,24 @@ export class PromptForm extends ModelForm { > ${t`Text: Simple Text input`} + + + +