diff --git a/Makefile b/Makefile index f4d06f02e..74b990445 100644 --- a/Makefile +++ b/Makefile @@ -130,3 +130,8 @@ ci-pyright: ci--meta-debug ci-pending-migrations: ci--meta-debug ./manage.py makemigrations --check + +install: + poetry install + cd web && npm i + cd website && npm i diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 69eab3d25..767007960 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -24,7 +24,6 @@ from drf_spectacular.utils import ( from guardian.shortcuts import get_anonymous_user, get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import CharField, DictField, JSONField, SerializerMethodField -from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ( @@ -46,9 +45,6 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER from authentik.core.models import ( - USER_ATTRIBUTE_CHANGE_EMAIL, - USER_ATTRIBUTE_CHANGE_NAME, - USER_ATTRIBUTE_CHANGE_USERNAME, USER_ATTRIBUTE_SA, USER_ATTRIBUTE_TOKEN_EXPIRING, Group, @@ -57,7 +53,6 @@ from authentik.core.models import ( User, ) from authentik.events.models import EventAction -from authentik.lib.config import CONFIG from authentik.stages.email.models import EmailStage from authentik.stages.email.tasks import send_mails from authentik.stages.email.utils import TemplateEmailMessage @@ -126,43 +121,6 @@ class UserSelfSerializer(ModelSerializer): "pk": group.pk, } - def validate_email(self, email: str): - """Check if the user is allowed to change their email""" - if self.instance.group_attributes().get( - USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True) - ): - return email - if email != self.instance.email: - raise ValidationError("Not allowed to change email.") - return email - - def validate_name(self, name: str): - """Check if the user is allowed to change their name""" - if self.instance.group_attributes().get( - USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True) - ): - return name - if name != self.instance.name: - raise ValidationError("Not allowed to change name.") - return name - - def validate_username(self, username: str): - """Check if the user is allowed to change their username""" - if self.instance.group_attributes().get( - USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True) - ): - return username - if username != self.instance.username: - raise ValidationError("Not allowed to change username.") - return username - - def save(self, **kwargs): - if self.instance: - attributes: dict = self.instance.attributes - attributes.update(self.validated_data.get("attributes", {})) - self.validated_data["attributes"] = attributes - return super().save(**kwargs) - class Meta: model = User @@ -407,26 +365,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): update_session_auth_hash(self.request, user) return Response(status=204) - @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)}) - @action( - methods=["PUT"], - detail=False, - pagination_class=None, - filter_backends=[], - permission_classes=[IsAuthenticated], - ) - def update_self(self, request: Request) -> Response: - """Allow users to change information on their own profile""" - data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data) - if not data.is_valid(): - return Response(data.errors, status=400) - new_user = data.save() - # If we're impersonating, we need to update that user object - # since it caches the full object - if SESSION_IMPERSONATE_USER in request.session: - request.session[SESSION_IMPERSONATE_USER] = new_user - return Response({"user": data.data}) - @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) @extend_schema(responses={200: UserMetricsSerializer(many=False)}) @action(detail=True, pagination_class=None, filter_backends=[]) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 4134c86fa..6cddd3043 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -2,12 +2,7 @@ from django.urls.base import reverse from rest_framework.test import APITestCase -from authentik.core.models import ( - USER_ATTRIBUTE_CHANGE_EMAIL, - USER_ATTRIBUTE_CHANGE_NAME, - USER_ATTRIBUTE_CHANGE_USERNAME, - User, -) +from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant from authentik.flows.models import FlowDesignation from authentik.lib.generators import generate_key @@ -22,51 +17,6 @@ class TestUsersAPI(APITestCase): self.admin = create_test_admin_user() self.user = User.objects.create(username="test-user") - def test_update_self(self): - """Test update_self""" - self.admin.attributes["foo"] = "bar" - self.admin.save() - self.admin.refresh_from_db() - self.client.force_login(self.admin) - response = self.client.put( - reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} - ) - self.admin.refresh_from_db() - self.assertEqual(response.status_code, 200) - self.assertEqual(self.admin.attributes["foo"], "bar") - self.assertEqual(self.admin.username, "foo") - self.assertEqual(self.admin.name, "foo") - - def test_update_self_name_denied(self): - """Test update_self""" - self.admin.attributes[USER_ATTRIBUTE_CHANGE_NAME] = False - self.admin.save() - self.client.force_login(self.admin) - response = self.client.put( - reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} - ) - self.assertEqual(response.status_code, 400) - - def test_update_self_username_denied(self): - """Test update_self""" - self.admin.attributes[USER_ATTRIBUTE_CHANGE_USERNAME] = False - self.admin.save() - self.client.force_login(self.admin) - response = self.client.put( - reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} - ) - self.assertEqual(response.status_code, 400) - - def test_update_self_email_denied(self): - """Test update_self""" - self.admin.attributes[USER_ATTRIBUTE_CHANGE_EMAIL] = False - self.admin.save() - self.client.force_login(self.admin) - response = self.client.put( - reverse("authentik_api:user-update-self"), data={"email": "foo", "name": "foo"} - ) - self.assertEqual(response.status_code, 400) - def test_metrics(self): """Test user's metrics""" self.client.force_login(self.admin) diff --git a/authentik/core/types.py b/authentik/core/types.py index 892f58e7e..2bcab71ac 100644 --- a/authentik/core/types.py +++ b/authentik/core/types.py @@ -29,4 +29,4 @@ class UserSettingSerializer(PassiveSerializer): component = CharField() title = CharField() configure_url = CharField(required=False) - icon_url = CharField() + icon_url = CharField(required=False) diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py index 2e6dcb8bc..d1417363f 100644 --- a/authentik/flows/planner.py +++ b/authentik/flows/planner.py @@ -13,7 +13,7 @@ from authentik.core.models import User from authentik.events.models import cleanse_dict from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException from authentik.flows.markers import ReevaluateMarker, StageMarker -from authentik.flows.models import Flow, FlowStageBinding, Stage +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage from authentik.lib.config import CONFIG from authentik.policies.engine import PolicyEngine @@ -124,6 +124,8 @@ class FlowPlanner: ) -> FlowPlan: """Check each of the flows' policies, check policies for each stage with PolicyBinding and return ordered list""" + if not default_context: + default_context = {} with Hub.current.start_span( op="authentik.flow.planner.plan", description=self.flow.slug ) as span: @@ -137,16 +139,14 @@ class FlowPlanner: # Bit of a workaround here, if there is a pending user set in the default context # we use that user for our cache key # to make sure they don't get the generic response - if default_context and PLAN_CONTEXT_PENDING_USER in default_context: - user = default_context[PLAN_CONTEXT_PENDING_USER] - else: - user = request.user + if PLAN_CONTEXT_PENDING_USER not in default_context: + default_context[PLAN_CONTEXT_PENDING_USER] = request.user + user = default_context[PLAN_CONTEXT_PENDING_USER] # First off, check the flow's direct policy bindings # to make sure the user even has access to the flow engine = PolicyEngine(self.flow, user, request) - if default_context: - span.set_data("default_context", cleanse_dict(default_context)) - engine.request.context = default_context + span.set_data("default_context", cleanse_dict(default_context)) + engine.request.context = default_context engine.build() result = engine.result if not result.passing: @@ -156,14 +156,15 @@ class FlowPlanner: # User is passing so far, check if we have a cached plan cached_plan_key = cache_key(self.flow, user) cached_plan = cache.get(cached_plan_key, None) - if cached_plan and self.use_cache: - self._logger.debug( - "f(plan): taking plan from cache", - key=cached_plan_key, - ) - # Reset the context as this isn't factored into caching - cached_plan.context = default_context or {} - return cached_plan + if self.flow.designation not in [FlowDesignation.STAGE_CONFIGURATION]: + if cached_plan and self.use_cache: + self._logger.debug( + "f(plan): taking plan from cache", + key=cached_plan_key, + ) + # Reset the context as this isn't factored into caching + cached_plan.context = default_context or {} + return cached_plan self._logger.debug( "f(plan): building plan", ) diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py index 4a7fedc37..51b8daf3e 100644 --- a/authentik/stages/prompt/api.py +++ b/authentik/stages/prompt/api.py @@ -49,6 +49,7 @@ class PromptSerializer(ModelSerializer): "order", "promptstage_set", "sub_text", + "placeholder_expression", ] diff --git a/authentik/stages/prompt/migrations/0007_prompt_placeholder_expression.py b/authentik/stages/prompt/migrations/0007_prompt_placeholder_expression.py new file mode 100644 index 000000000..581968456 --- /dev/null +++ b/authentik/stages/prompt/migrations/0007_prompt_placeholder_expression.py @@ -0,0 +1,49 @@ +# Generated by Django 4.0.2 on 2022-02-27 19:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_prompt", "0006_alter_prompt_type"), + ] + + operations = [ + migrations.AddField( + model_name="prompt", + name="placeholder_expression", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="prompt", + name="type", + field=models.CharField( + choices=[ + ("text", "Text: Simple Text input"), + ( + "text_read_only", + "Text (read-only): Simple 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"), + ("date", "Date"), + ("date-time", "Date Time"), + ("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 0c04aacc9..264f3b06d 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -3,6 +3,7 @@ from typing import Any, Optional from uuid import uuid4 from django.db import models +from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.fields import ( @@ -16,15 +17,23 @@ from rest_framework.fields import ( ReadOnlyField, ) from rest_framework.serializers import BaseSerializer +from structlog.stdlib import get_logger +from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression import PropertyMappingEvaluator +from authentik.core.models import User from authentik.flows.models import Stage from authentik.lib.models import SerializerModel from authentik.policies.models import Policy +LOGGER = get_logger() + class FieldTypes(models.TextChoices): """Field types an Prompt can be""" + # update website/docs/flow/stages/prompt.index.md + # Simple text field TEXT = "text", _("Text: Simple Text input") # Simple text field @@ -56,6 +65,8 @@ class FieldTypes(models.TextChoices): HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.") STATIC = "static", _("Static: Static value, displayed as-is.") + AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports") + class Prompt(SerializerModel): """Single Prompt, part of a prompt stage.""" @@ -73,12 +84,33 @@ class Prompt(SerializerModel): order = models.IntegerField(default=0) + placeholder_expression = models.BooleanField(default=False) + @property def serializer(self) -> BaseSerializer: from authentik.stages.prompt.api import PromptSerializer return PromptSerializer + def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str: + """Get fully interpolated placeholder""" + 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 + return prompt_context[self.field_key] + + if self.placeholder_expression: + evaluator = PropertyMappingEvaluator() + evaluator.set_context(user, request, self, prompt_context=prompt_context) + try: + return evaluator.evaluate(self.placeholder) + except Exception as exc: # pylint:disable=broad-except + LOGGER.warning( + "failed to evaluate prompt placeholder", + exc=PropertyMappingExpressionException(str(exc)), + ) + return self.placeholder + def field(self, default: Optional[Any]) -> CharField: """Get field type for Challenge and response""" field_class = CharField @@ -93,10 +125,6 @@ class Prompt(SerializerModel): field_class = EmailField if self.type == FieldTypes.NUMBER: field_class = IntegerField - if self.type == FieldTypes.HIDDEN: - field_class = HiddenField - kwargs["required"] = False - kwargs["default"] = self.placeholder if self.type == FieldTypes.CHECKBOX: field_class = BooleanField kwargs["required"] = False @@ -104,13 +132,22 @@ class Prompt(SerializerModel): field_class = DateField if self.type == FieldTypes.DATE_TIME: field_class = DateTimeField + + if self.type == FieldTypes.SEPARATOR: + kwargs["required"] = False + kwargs["label"] = "" + if self.type == FieldTypes.HIDDEN: + field_class = HiddenField + kwargs["required"] = False + kwargs["default"] = self.placeholder if self.type == FieldTypes.STATIC: kwargs["default"] = self.placeholder kwargs["required"] = False kwargs["label"] = "" - if self.type == FieldTypes.SEPARATOR: - kwargs["required"] = False - kwargs["label"] = "" + + if self.type == FieldTypes.AK_LOCALE: + kwargs["allow_blank"] = True + if default: kwargs["default"] = default # May not set both `required` and `default` diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py index 437d5cf01..349d6254d 100644 --- a/authentik/stages/prompt/stage.py +++ b/authentik/stages/prompt/stage.py @@ -165,13 +165,14 @@ class PromptStageView(ChallengeStageView): response_class = PromptChallengeResponse def get_challenge(self, *args, **kwargs) -> Challenge: - fields = list(self.executor.current_stage.fields.all().order_by("order")) + fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order")) serializers = [] context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}) for field in fields: data = StagePromptSerializer(field).data - if field.field_key in context_prompt: - data["placeholder"] = context_prompt.get(field.field_key) + data["placeholder"] = field.get_placeholder( + context_prompt, self.get_pending_user(), self.request + ) serializers.append(data) challenge = PromptChallenge( data={ diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py index 94084a69a..cabb6e6a2 100644 --- a/authentik/stages/prompt/tests.py +++ b/authentik/stages/prompt/tests.py @@ -1,16 +1,17 @@ """Prompt tests""" from unittest.mock import MagicMock, patch +from django.test import RequestFactory from django.urls import reverse from rest_framework.exceptions import ErrorDetail -from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import 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.policies.expression.models import ExpressionPolicy from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptChallengeResponse @@ -21,8 +22,8 @@ class TestPromptStage(FlowTestCase): def setUp(self): super().setUp() - self.user = User.objects.create(username="unittest", email="test@beryju.org") - + self.user = create_test_admin_user() + self.factory = RequestFactory() self.flow = Flow.objects.create( name="test-prompt", slug="test-prompt", @@ -219,3 +220,95 @@ class TestPromptStage(FlowTestCase): self.assertNotEqual(challenge_response.validated_data["hidden_prompt"], "foo") self.assertEqual(challenge_response.validated_data["hidden_prompt"], "hidden") self.assertNotEqual(challenge_response.validated_data["static_prompt"], "foo") + + def test_prompt_placeholder(self): + """Test placeholder and expression""" + context = { + "foo": generate_id(), + } + prompt: Prompt = Prompt( + field_key="text_prompt_expression", + label="TEXT_LABEL", + type=FieldTypes.TEXT, + placeholder="return prompt_context['foo']", + placeholder_expression=True, + ) + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] + ) + context["text_prompt_expression"] = generate_id() + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), + context["text_prompt_expression"], + ) + self.assertNotEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] + ) + + def test_prompt_placeholder_error(self): + """Test placeholder and expression""" + context = {} + prompt: Prompt = Prompt( + field_key="text_prompt_expression", + label="TEXT_LABEL", + type=FieldTypes.TEXT, + placeholder="something invalid dunno", + placeholder_expression=True, + ) + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), + "something invalid dunno", + ) + + def test_prompt_placeholder_disabled(self): + """Test placeholder and expression""" + context = {} + prompt: Prompt = Prompt( + field_key="text_prompt_expression", + label="TEXT_LABEL", + type=FieldTypes.TEXT, + placeholder="return prompt_context['foo']", + placeholder_expression=False, + ) + self.assertEqual( + prompt.get_placeholder(context, self.user, self.factory.get("/")), prompt.placeholder + ) + + def test_field_types(self): + """Ensure all field types can successfully be created""" + + def test_invalid_save(self): + """Ensure field can't be saved with invalid type""" + prompt: Prompt = Prompt( + field_key="text_prompt_expression", + label="TEXT_LABEL", + type="foo", + placeholder="foo", + placeholder_expression=False, + sub_text="test", + order=123, + ) + with self.assertRaises(ValueError): + prompt.save() + + +def field_type_tester_factory(field_type: FieldTypes): + """Test field for field_type""" + + def tester(self: TestPromptStage): + prompt: Prompt = Prompt( + field_key="text_prompt_expression", + label="TEXT_LABEL", + type=field_type, + placeholder="foo", + placeholder_expression=False, + sub_text="test", + order=123, + ) + self.assertIsNotNone(prompt.field("foo")) + + return tester + + +for _type in FieldTypes: + setattr(TestPromptStage, f"test_field_type_{_type}", field_type_tester_factory(_type)) diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py index e5fde9b67..51d774085 100644 --- a/authentik/stages/user_write/stage.py +++ b/authentik/stages/user_write/stage.py @@ -3,6 +3,7 @@ from typing import Any from django.contrib import messages from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.models import AnonymousUser from django.db import transaction from django.db.utils import IntegrityError from django.http import HttpRequest, HttpResponse @@ -56,7 +57,12 @@ class UserWriteStageView(StageView): return self.executor.stage_invalid() data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] user_created = False - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + # check if pending user is set (default to anonymous user), if + # it's an anonymous user then we need to create a new user. + if isinstance( + self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER, AnonymousUser()), + AnonymousUser, + ): self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User( is_active=not self.executor.current_stage.create_users_as_inactive ) diff --git a/authentik/tenants/api.py b/authentik/tenants/api.py index a18893674..631ceb7ee 100644 --- a/authentik/tenants/api.py +++ b/authentik/tenants/api.py @@ -50,6 +50,7 @@ class TenantSerializer(ModelSerializer): "flow_invalidation", "flow_recovery", "flow_unenrollment", + "flow_user_settings", "event_retention", "web_certificate", ] @@ -72,6 +73,7 @@ class CurrentTenantSerializer(PassiveSerializer): flow_invalidation = CharField(source="flow_invalidation.slug", required=False) flow_recovery = CharField(source="flow_recovery.slug", required=False) flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False) + flow_user_settings = CharField(source="flow_user_settings.slug", required=False) class TenantViewSet(UsedByMixin, ModelViewSet): diff --git a/authentik/tenants/migrations/0002_tenant_flow_user_settings.py b/authentik/tenants/migrations/0002_tenant_flow_user_settings.py new file mode 100644 index 000000000..8d628b925 --- /dev/null +++ b/authentik/tenants/migrations/0002_tenant_flow_user_settings.py @@ -0,0 +1,181 @@ +# Generated by Django 4.0.2 on 2022-02-26 21:14 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation +from authentik.stages.identification.models import UserFields +from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP + +AUTHORIZATION_POLICY = """from authentik.lib.config import CONFIG +from authentik.core.models import ( + USER_ATTRIBUTE_CHANGE_EMAIL, + USER_ATTRIBUTE_CHANGE_NAME, + USER_ATTRIBUTE_CHANGE_USERNAME +) +prompt_data = request.context.get("prompt_data") + +if not request.user.group_attributes().get( + USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True) +): + if prompt_data.get("email") != request.user.email: + ak_message("Not allowed to change email address.") + return False + +if not request.user.group_attributes().get( + USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True) +): + if prompt_data.get("name") != request.user.name: + ak_message("Not allowed to change name.") + return False + +if not request.user.group_attributes().get( + USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True) +): + if prompt_data.get("username") != request.user.username: + ak_message("Not allowed to change username.") + return False + +return True +""" + + +def create_default_user_settings_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + from authentik.stages.prompt.models import FieldTypes + + db_alias = schema_editor.connection.alias + + Tenant = apps.get_model("authentik_tenants", "Tenant") + + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + + ExpressionPolicy = apps.get_model("authentik_policies_expression", "ExpressionPolicy") + + UserWriteStage = apps.get_model("authentik_stages_user_write", "UserWriteStage") + PromptStage = apps.get_model("authentik_stages_prompt", "PromptStage") + Prompt = apps.get_model("authentik_stages_prompt", "Prompt") + + prompt_username, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="username", + order=200, + defaults={ + "label": "Username", + "type": FieldTypes.TEXT, + "placeholder": """try: + return user.username +except: + return """, + "placeholder_expression": True, + }, + ) + prompt_name, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="name", + order=201, + defaults={ + "label": "Name", + "type": FieldTypes.TEXT, + "placeholder": """try: + return user.name +except: + return """, + "placeholder_expression": True, + }, + ) + prompt_email, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="email", + order=202, + defaults={ + "label": "Email", + "type": FieldTypes.EMAIL, + "placeholder": """try: + return user.email +except: + return """, + "placeholder_expression": True, + }, + ) + prompt_locale, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="attributes.settings.locale", + order=203, + defaults={ + "label": "Locale", + "type": FieldTypes.AK_LOCALE, + "placeholder": """try: + return return user.attributes.get("settings", {}).get("locale", "") +except: + return """, + "placeholder_expression": True, + "required": True, + }, + ) + + validation_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-user-settings-authorization", + defaults={ + "expression": AUTHORIZATION_POLICY, + }, + ) + prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( + name="default-user-settings", + ) + prompt_stage.validation_policies.set([validation_policy]) + prompt_stage.fields.set([prompt_username, prompt_name, prompt_email, prompt_locale]) + prompt_stage.save() + user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( + name="default-user-settings-write" + ) + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-user-settings-flow", + designation=FlowDesignation.STAGE_CONFIGURATION, + defaults={ + "name": "Update your info", + }, + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=prompt_stage, + defaults={ + "order": 20, + }, + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=user_write, + defaults={ + "order": 100, + }, + ) + + tenant = Tenant.objects.using(db_alias).filter(default=True).first() + if not tenant: + return + tenant.flow_user_settings = flow + tenant.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_expression", "__latest__"), + ("authentik_stages_prompt", "0007_prompt_placeholder_expression"), + ("authentik_flows", "0021_auto_20211227_2103"), + ("authentik_tenants", "0001_squashed_0005_tenant_web_certificate"), + ] + + operations = [ + migrations.AddField( + model_name="tenant", + name="flow_user_settings", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tenant_user_settings", + to="authentik_flows.flow", + ), + ), + migrations.RunPython(create_default_user_settings_flow), + ] diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py index 57830003e..58b6de674 100644 --- a/authentik/tenants/models.py +++ b/authentik/tenants/models.py @@ -40,6 +40,9 @@ class Tenant(models.Model): flow_unenrollment = models.ForeignKey( Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_unenrollment" ) + flow_user_settings = models.ForeignKey( + Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_user_settings" + ) event_retention = models.TextField( default="days=365", diff --git a/schema.yml b/schema.yml index 2285fc88f..bf1a9f160 100644 --- a/schema.yml +++ b/schema.yml @@ -2342,6 +2342,11 @@ paths: schema: type: string format: uuid + - in: query + name: flow_user_settings + schema: + type: string + format: uuid - name: ordering required: false in: query @@ -3369,31 +3374,6 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' - /core/users/update_self/: - put: - operationId: core_users_update_self_update - description: Allow users to change information on their own profile - tags: - - core - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/UserSelfRequest' - required: true - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/SessionUser' - description: '' - '400': - $ref: '#/components/schemas/ValidationError' - '403': - $ref: '#/components/schemas/GenericError' /crypto/certificatekeypairs/: get: operationId: crypto_certificatekeypairs_list @@ -17636,6 +17616,7 @@ paths: schema: type: string enum: + - ak-locale - checkbox - date - date-time @@ -20471,11 +20452,7 @@ components: items: $ref: '#/components/schemas/FooterLink' readOnly: true - default: - - href: https://goauthentik.io/docs/?utm_source=authentik - name: Documentation - - href: https://goauthentik.io/?utm_source=authentik - name: authentik Website + default: [] flow_authentication: type: string flow_invalidation: @@ -20484,6 +20461,8 @@ components: type: string flow_unenrollment: type: string + flow_user_settings: + type: string required: - branding_favicon - branding_logo @@ -27772,6 +27751,8 @@ components: $ref: '#/components/schemas/StageRequest' sub_text: type: string + placeholder_expression: + type: boolean PatchedPromptStageRequest: type: object description: PromptStage Serializer @@ -28144,6 +28125,10 @@ components: type: string format: uuid nullable: true + flow_user_settings: + type: string + format: uuid + nullable: true event_retention: type: string minLength: 1 @@ -28723,6 +28708,8 @@ components: $ref: '#/components/schemas/Stage' sub_text: type: string + placeholder_expression: + type: boolean required: - field_key - label @@ -28790,6 +28777,8 @@ components: $ref: '#/components/schemas/StageRequest' sub_text: type: string + placeholder_expression: + type: boolean required: - field_key - label @@ -28877,6 +28866,7 @@ components: - separator - hidden - static + - ak-locale type: string PropertyMapping: type: object @@ -30567,6 +30557,10 @@ components: type: string format: uuid nullable: true + flow_user_settings: + type: string + format: uuid + nullable: true event_retention: type: string description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).' @@ -30614,6 +30608,10 @@ components: type: string format: uuid nullable: true + flow_user_settings: + type: string + format: uuid + nullable: true event_retention: type: string minLength: 1 @@ -31174,33 +31172,6 @@ components: required: - name - pk - UserSelfRequest: - type: object - description: |- - User Serializer for information a user can retrieve about themselves and - update about themselves - properties: - username: - type: string - minLength: 1 - description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ - only. - pattern: ^[\w.@+-]+$ - maxLength: 150 - name: - type: string - description: User's display name. - email: - type: string - format: email - title: Email address - maxLength: 254 - settings: - type: object - additionalProperties: {} - required: - - name - - username UserServiceAccountRequest: type: object properties: @@ -31238,7 +31209,6 @@ components: type: string required: - component - - icon_url - object_uid - title UserSourceConnection: diff --git a/web/src/api/Users.ts b/web/src/api/Users.ts index 201322a24..ee29746bb 100644 --- a/web/src/api/Users.ts +++ b/web/src/api/Users.ts @@ -2,7 +2,13 @@ import { CoreApi, SessionUser } from "@goauthentik/api"; import { activateLocale } from "../interfaces/locale"; import { DEFAULT_CONFIG } from "./Config"; -let globalMePromise: Promise; +let globalMePromise: Promise | undefined; + +export function refreshMe(): Promise { + globalMePromise = undefined; + return me(); +} + export function me(): Promise { if (!globalMePromise) { globalMePromise = new CoreApi(DEFAULT_CONFIG).coreUsersMeRetrieve().then((user) => { diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index ca2ddd6cb..1053c050e 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -1,7 +1,6 @@ import "@polymer/iron-form/iron-form"; import { IronFormElement } from "@polymer/iron-form/iron-form"; import "@polymer/paper-input/paper-input"; -import { PaperInputElement } from "@polymer/paper-input/paper-input"; import { CSSResult, LitElement, TemplateResult, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -22,6 +21,7 @@ import { showMessage } from "../../elements/messages/MessageContainer"; import { camelToSnake, convertToSlug } from "../../utils"; import { SearchSelect } from "../SearchSelect"; import { MessageLevel } from "../messages/Message"; +import { HorizontalFormElement } from "./HorizontalFormElement"; export class APIError extends Error { constructor(public response: ValidationError) { @@ -217,16 +217,15 @@ export class Form extends LitElement { throw errorMessage; } // assign all input-related errors to their elements - const elements: PaperInputElement[] = ironForm._getSubmittableElements(); + const elements: HorizontalFormElement[] = ironForm._getSubmittableElements(); elements.forEach((element) => { const elementName = element.name; if (!elementName) return; if (camelToSnake(elementName) in errorMessage) { - element.errorMessage = - errorMessage[camelToSnake(elementName)].join(", "); + element.errorMessages = errorMessage[camelToSnake(elementName)]; element.invalid = true; } else { - element.errorMessage = ""; + element.errorMessages = []; element.invalid = false; } }); diff --git a/web/src/elements/forms/HorizontalFormElement.ts b/web/src/elements/forms/HorizontalFormElement.ts index fb719309e..4bda70044 100644 --- a/web/src/elements/forms/HorizontalFormElement.ts +++ b/web/src/elements/forms/HorizontalFormElement.ts @@ -45,8 +45,8 @@ export class HorizontalFormElement extends LitElement { @property({ type: Boolean }) writeOnlyActivated = false; - @property() - errorMessage = ""; + @property({ attribute: false }) + errorMessages: string[] = []; _invalid = false; @@ -124,11 +124,11 @@ export class HorizontalFormElement extends LitElement { ${t`Click to change value`}

` : html``} - ${this.invalid - ? html`

- ${this.errorMessage} -

` - : html``} + ${this.errorMessages.map((message) => { + return html`

+ ${message} +

`; + })} `; diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts index 713c62006..377ad6e6c 100644 --- a/web/src/flows/FlowExecutor.ts +++ b/web/src/flows/FlowExecutor.ts @@ -53,7 +53,7 @@ import "./stages/prompt/PromptStage"; @customElement("ak-flow-executor") export class FlowExecutor extends LitElement implements StageHost { - flowSlug: string; + flowSlug?: string; private _challenge?: ChallengeTypes; @@ -90,7 +90,7 @@ export class FlowExecutor extends LitElement implements StageHost { loading = false; @property({ attribute: false }) - tenant?: CurrentTenant; + tenant!: CurrentTenant; @property({ attribute: false }) inspectorOpen: boolean; @@ -124,6 +124,7 @@ export class FlowExecutor extends LitElement implements StageHost { this.ws = new WebsocketClient(); this.flowSlug = window.location.pathname.split("/")[3]; this.inspectorOpen = window.location.search.includes("inspector"); + tenant().then((tenant) => (this.tenant = tenant)); } setBackground(url: string): void { @@ -142,7 +143,7 @@ export class FlowExecutor extends LitElement implements StageHost { this.loading = true; return new FlowsApi(DEFAULT_CONFIG) .flowsExecutorSolve({ - flowSlug: this.flowSlug, + flowSlug: this.flowSlug || "", query: window.location.search.substring(1), flowChallengeResponseRequest: payload, }) @@ -173,11 +174,10 @@ export class FlowExecutor extends LitElement implements StageHost { firstUpdated(): void { configureSentry(); - tenant().then((tenant) => (this.tenant = tenant)); this.loading = true; new FlowsApi(DEFAULT_CONFIG) .flowsExecutorGet({ - flowSlug: this.flowSlug, + flowSlug: this.flowSlug || "", query: window.location.search.substring(1), }) .then((challenge) => { diff --git a/web/src/flows/stages/base.ts b/web/src/flows/stages/base.ts index d6c97de67..20f1a3359 100644 --- a/web/src/flows/stages/base.ts +++ b/web/src/flows/stages/base.ts @@ -1,13 +1,15 @@ import { LitElement, TemplateResult, html } from "lit"; import { property } from "lit/decorators.js"; -import { ErrorDetail } from "@goauthentik/api"; +import { CurrentTenant, ErrorDetail } from "@goauthentik/api"; export interface StageHost { challenge?: unknown; - flowSlug: string; + flowSlug?: string; loading: boolean; submit(payload: unknown): Promise; + + readonly tenant: CurrentTenant; } export class BaseStage extends LitElement { diff --git a/web/src/flows/stages/prompt/PromptStage.ts b/web/src/flows/stages/prompt/PromptStage.ts index 3151c2aef..f1d285eb4 100644 --- a/web/src/flows/stages/prompt/PromptStage.ts +++ b/web/src/flows/stages/prompt/PromptStage.ts @@ -23,6 +23,7 @@ import { import "../../../elements/Divider"; import "../../../elements/EmptyState"; import "../../../elements/forms/FormElement"; +import { LOCALES } from "../../../interfaces/locale"; import { BaseStage } from "../base"; @customElement("ak-stage-prompt") @@ -31,7 +32,7 @@ export class PromptStage extends BaseStage`; + value="${placeholderAsValue ? prompt.placeholder : ""}">`; case PromptTypeEnum.TextReadOnly: return ``; + value="${placeholderAsValue ? prompt.placeholder : ""}">`; case PromptTypeEnum.Email: return ``; + value="${placeholderAsValue ? prompt.placeholder : ""}">`; case PromptTypeEnum.Password: return ``; case PromptTypeEnum.Static: return `

${prompt.placeholder}

`; + case PromptTypeEnum.AkLocale: + return ``; default: return `

invalid type '${prompt.type}'

`; } @@ -118,6 +133,54 @@ export class PromptStage extends BaseStage${unsafeHTML(prompt.subText)}

`; } + renderField(prompt: StagePrompt): TemplateResult { + // Checkbox is rendered differently + if (prompt.type === PromptTypeEnum.Checkbox) { + return html`
+ + + ${prompt.required + ? html`

${t`Required.`}

` + : html``} +

${unsafeHTML(prompt.subText)}

+
`; + } + // Special types that aren't rendered in a wrapper + if ( + prompt.type === PromptTypeEnum.Static || + prompt.type === PromptTypeEnum.Hidden || + prompt.type === PromptTypeEnum.Separator + ) { + return html` + ${unsafeHTML(this.renderPromptInner(prompt, false))} + ${this.renderPromptHelpText(prompt)} + `; + } + return html` + ${unsafeHTML(this.renderPromptInner(prompt, false))} + ${this.renderPromptHelpText(prompt)} + `; + } + + renderContinue(): TemplateResult { + return html`
+ +
`; + } + render(): TemplateResult { if (!this.challenge) { return html` `; @@ -133,54 +196,14 @@ export class PromptStage extends BaseStage ${this.challenge.fields.map((prompt) => { - // Checkbox is rendered differently - if (prompt.type === PromptTypeEnum.Checkbox) { - return html`
- - - ${prompt.required - ? html`

${t`Required.`}

` - : html``} -

${unsafeHTML(prompt.subText)}

-
`; - } - // Special types that aren't rendered in a wrapper - if ( - prompt.type === PromptTypeEnum.Static || - prompt.type === PromptTypeEnum.Hidden || - prompt.type === PromptTypeEnum.Separator - ) { - return html` - ${unsafeHTML(this.renderPromptInner(prompt))} - ${this.renderPromptHelpText(prompt)} - `; - } - return html` - ${unsafeHTML(this.renderPromptInner(prompt))} - ${this.renderPromptHelpText(prompt)} - `; + return this.renderField(prompt); })} ${"non_field_errors" in (this.challenge?.responseErrors || {}) ? this.renderNonFieldErrors( this.challenge?.responseErrors?.non_field_errors || [], ) : html``} -
- -
+ ${this.renderContinue()}