diff --git a/authentik/flows/api/stages.py b/authentik/flows/api/stages.py index 2ea926899..3fb2241fc 100644 --- a/authentik/flows/api/stages.py +++ b/authentik/flows/api/stages.py @@ -1,4 +1,6 @@ """Flow Stage API Views""" +from typing import Iterable + from django.urls import reverse from drf_yasg2.utils import swagger_auto_schema from rest_framework.decorators import action @@ -6,13 +8,17 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.viewsets import ReadOnlyModelViewSet +from structlog.stdlib import get_logger from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.flows.api.flows import FlowSerializer +from authentik.flows.challenge import Challenge from authentik.flows.models import Stage from authentik.lib.templatetags.authentik_utils import verbose_name from authentik.lib.utils.reflection import all_subclasses +LOGGER = get_logger() + class StageSerializer(ModelSerializer, MetaNameSerializer): """Stage Serializer""" @@ -64,3 +70,19 @@ class StageViewSet(ReadOnlyModelViewSet): ) data = sorted(data, key=lambda x: x["name"]) return Response(TypeCreateSerializer(data, many=True).data) + + @swagger_auto_schema(responses={200: Challenge(many=True)}) + @action(detail=False) + def user_settings(self, request: Request) -> Response: + """Get all stages the user can configure""" + _all_stages: Iterable[Stage] = Stage.objects.all().select_subclasses() + matching_stages: list[dict] = [] + for stage in _all_stages: + user_settings = stage.ui_user_settings + if not user_settings: + continue + stage_challenge = user_settings + if not stage_challenge.is_valid(): + LOGGER.warning(stage_challenge.errors) + matching_stages.append(stage_challenge.initial_data) + return Response(matching_stages) diff --git a/authentik/flows/models.py b/authentik/flows/models.py index b345a1c22..5de7369d1 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -10,6 +10,7 @@ from model_utils.managers import InheritanceManager from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger +from authentik.flows.challenge import Challenge from authentik.lib.models import InheritanceForeignKey, SerializerModel from authentik.policies.models import PolicyBindingModel @@ -64,9 +65,9 @@ class Stage(SerializerModel): raise NotImplementedError @property - def ui_user_settings(self) -> Optional[str]: + def ui_user_settings(self) -> Optional[Challenge]: """Entrypoint to integrate with User settings. Can either return None if no - user settings are available, or a string with the URL to fetch.""" + user settings are available, or a challenge.""" return None def __str__(self): diff --git a/authentik/stages/authenticator_static/models.py b/authentik/stages/authenticator_static/models.py index 8184f3c27..b394c0c38 100644 --- a/authentik/stages/authenticator_static/models.py +++ b/authentik/stages/authenticator_static/models.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer +from authentik.flows.challenge import Challenge, ChallengeTypes from authentik.flows.models import ConfigurableStage, Stage @@ -41,10 +42,16 @@ class AuthenticatorStaticStage(ConfigurableStage, Stage): return AuthenticatorStaticStageForm @property - def ui_user_settings(self) -> Optional[str]: - return reverse( - "authentik_stages_authenticator_static:user-settings", - kwargs={"stage_uuid": self.stage_uuid}, + def ui_user_settings(self) -> Optional[Challenge]: + return Challenge( + data={ + "type": ChallengeTypes.shell.value, + "title": self._meta.verbose_name, + "component": reverse( + "authentik_stages_authenticator_static:user-settings", + kwargs={"stage_uuid": self.stage_uuid}, + ), + } ) def __str__(self) -> str: diff --git a/authentik/stages/authenticator_static/templates/stages/authenticator_static/user_settings.html b/authentik/stages/authenticator_static/templates/stages/authenticator_static/user_settings.html index b69f37225..bc01cd3cb 100644 --- a/authentik/stages/authenticator_static/templates/stages/authenticator_static/user_settings.html +++ b/authentik/stages/authenticator_static/templates/stages/authenticator_static/user_settings.html @@ -22,7 +22,7 @@ {% if not state %} {% if stage.configure_flow %} - {% trans "Enable Static Tokens" %} + {% trans "Enable Static Tokens" %} {% endif %} {% else %} {% trans "Disable Static Tokens" %} diff --git a/authentik/stages/authenticator_static/views.py b/authentik/stages/authenticator_static/views.py index 13e5517bb..ab9775765 100644 --- a/authentik/stages/authenticator_static/views.py +++ b/authentik/stages/authenticator_static/views.py @@ -44,4 +44,4 @@ class DisableView(LoginRequiredMixin, View): Event.new( "static_otp_disable", message="User disabled Static OTP Tokens." ).from_http(request) - return redirect("authentik_core:user-settings") + return redirect("/") diff --git a/authentik/stages/authenticator_totp/models.py b/authentik/stages/authenticator_totp/models.py index 4f19d47a7..a683da3d4 100644 --- a/authentik/stages/authenticator_totp/models.py +++ b/authentik/stages/authenticator_totp/models.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer +from authentik.flows.challenge import Challenge, ChallengeTypes from authentik.flows.models import ConfigurableStage, Stage @@ -44,10 +45,16 @@ class AuthenticatorTOTPStage(ConfigurableStage, Stage): return AuthenticatorTOTPStageForm @property - def ui_user_settings(self) -> Optional[str]: - return reverse( - "authentik_stages_authenticator_totp:user-settings", - kwargs={"stage_uuid": self.stage_uuid}, + def ui_user_settings(self) -> Optional[Challenge]: + return Challenge( + data={ + "type": ChallengeTypes.shell.value, + "title": self._meta.verbose_name, + "component": reverse( + "authentik_stages_authenticator_totp:user-settings", + kwargs={"stage_uuid": self.stage_uuid}, + ), + } ) def __str__(self) -> str: diff --git a/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html b/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html index 54bc16e14..722da5f36 100644 --- a/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html +++ b/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html @@ -18,7 +18,7 @@

{% if not state %} {% if stage.configure_flow %} - {% trans "Enable Time-based OTP" %} + {% trans "Enable Time-based OTP" %} {% endif %} {% else %} {% trans "Disable Time-based OTP" %} diff --git a/authentik/stages/authenticator_totp/views.py b/authentik/stages/authenticator_totp/views.py index e94624aec..a518f3cfd 100644 --- a/authentik/stages/authenticator_totp/views.py +++ b/authentik/stages/authenticator_totp/views.py @@ -39,4 +39,4 @@ class DisableView(LoginRequiredMixin, View): Event.new("totp_disable", message="User disabled Time-based OTP.").from_http( request ) - return redirect("authentik_core:user-settings") + return redirect("/") diff --git a/authentik/stages/authenticator_webauthn/models.py b/authentik/stages/authenticator_webauthn/models.py index cc63da205..0357dc812 100644 --- a/authentik/stages/authenticator_webauthn/models.py +++ b/authentik/stages/authenticator_webauthn/models.py @@ -11,6 +11,7 @@ from django.views import View from django_otp.models import Device from rest_framework.serializers import BaseSerializer +from authentik.flows.challenge import Challenge, ChallengeTypes from authentik.flows.models import ConfigurableStage, Stage @@ -42,10 +43,16 @@ class AuthenticateWebAuthnStage(ConfigurableStage, Stage): return AuthenticateWebAuthnStageForm @property - def ui_user_settings(self) -> Optional[str]: - return reverse( - "authentik_stages_authenticator_webauthn:user-settings", - kwargs={"stage_uuid": self.stage_uuid}, + def ui_user_settings(self) -> Optional[Challenge]: + return Challenge( + data={ + "type": ChallengeTypes.shell.value, + "title": self._meta.verbose_name, + "component": reverse( + "authentik_stages_authenticator_webauthn:user-settings", + kwargs={"stage_uuid": self.stage_uuid}, + ), + } ) def __str__(self) -> str: diff --git a/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html index 38daec51a..ea97b915b 100644 --- a/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html +++ b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html @@ -39,7 +39,7 @@

- + {% trans 'Change password' %}
diff --git a/swagger.yaml b/swagger.yaml index 1544348f3..f0c18a547 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -7326,6 +7326,48 @@ paths: tags: - stages parameters: [] + /stages/all/user_settings/: + get: + operationId: stages_all_user_settings + description: Get all stages the user can configure + parameters: + - name: name + in: query + description: '' + required: false + type: string + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: Page Index + required: false + type: integer + - name: page_size + in: query + description: Page Size + required: false + type: integer + responses: + '200': + description: Challenge that gets sent to the client based on which stage + is currently active + schema: + description: '' + type: array + items: + $ref: '#/definitions/Challenge' + tags: + - stages + parameters: [] /stages/all/{stage_uuid}/: get: operationId: stages_all_read diff --git a/tests/e2e/test_flows_enroll.py b/tests/e2e/test_flows_enroll.py index f3dad005c..f8ddbde61 100644 --- a/tests/e2e/test_flows_enroll.py +++ b/tests/e2e/test_flows_enroll.py @@ -97,7 +97,7 @@ class TestFlowsEnroll(SeleniumTestCase): wait = WebDriverWait(interface_admin, self.wait_timeout) wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-sidebar"))) - self.driver.get(self.shell_url("authentik_core:user-settings")) + self.driver.get(self.shell_url("authentik_core:user-details")) user = User.objects.get(username="foo") self.assertEqual(user.username, "foo") @@ -196,7 +196,7 @@ class TestFlowsEnroll(SeleniumTestCase): ) wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-sidebar"))) - self.driver.get(self.shell_url("authentik_core:user-settings")) + self.driver.get(self.shell_url("authentik_core:user-details")) self.assert_user(User.objects.get(username="foo"))