flows: add API for user's stage settings
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
07142cab8b
commit
a6123cfbe4
|
@ -1,4 +1,6 @@
|
||||||
"""Flow Stage API Views"""
|
"""Flow Stage API Views"""
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from drf_yasg2.utils import swagger_auto_schema
|
from drf_yasg2.utils import swagger_auto_schema
|
||||||
from rest_framework.decorators import action
|
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.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
||||||
from authentik.flows.api.flows import FlowSerializer
|
from authentik.flows.api.flows import FlowSerializer
|
||||||
|
from authentik.flows.challenge import Challenge
|
||||||
from authentik.flows.models import Stage
|
from authentik.flows.models import Stage
|
||||||
from authentik.lib.templatetags.authentik_utils import verbose_name
|
from authentik.lib.templatetags.authentik_utils import verbose_name
|
||||||
from authentik.lib.utils.reflection import all_subclasses
|
from authentik.lib.utils.reflection import all_subclasses
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class StageSerializer(ModelSerializer, MetaNameSerializer):
|
class StageSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""Stage Serializer"""
|
"""Stage Serializer"""
|
||||||
|
@ -64,3 +70,19 @@ class StageViewSet(ReadOnlyModelViewSet):
|
||||||
)
|
)
|
||||||
data = sorted(data, key=lambda x: x["name"])
|
data = sorted(data, key=lambda x: x["name"])
|
||||||
return Response(TypeCreateSerializer(data, many=True).data)
|
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)
|
||||||
|
|
|
@ -10,6 +10,7 @@ from model_utils.managers import InheritanceManager
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.flows.challenge import Challenge
|
||||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||||
from authentik.policies.models import PolicyBindingModel
|
from authentik.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
|
@ -64,9 +65,9 @@ class Stage(SerializerModel):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@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
|
"""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
|
return None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
|
@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
|
from authentik.flows.challenge import Challenge, ChallengeTypes
|
||||||
from authentik.flows.models import ConfigurableStage, Stage
|
from authentik.flows.models import ConfigurableStage, Stage
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,10 +42,16 @@ class AuthenticatorStaticStage(ConfigurableStage, Stage):
|
||||||
return AuthenticatorStaticStageForm
|
return AuthenticatorStaticStageForm
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ui_user_settings(self) -> Optional[str]:
|
def ui_user_settings(self) -> Optional[Challenge]:
|
||||||
return reverse(
|
return Challenge(
|
||||||
"authentik_stages_authenticator_static:user-settings",
|
data={
|
||||||
kwargs={"stage_uuid": self.stage_uuid},
|
"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:
|
def __str__(self) -> str:
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% if not state %}
|
{% if not state %}
|
||||||
{% if stage.configure_flow %}
|
{% if stage.configure_flow %}
|
||||||
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23{% url 'authentik_core:user-settings' %}" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Static Tokens" %}</a>
|
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23user" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Static Tokens" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'authentik_stages_authenticator_static:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Static Tokens" %}</a>
|
<a href="{% url 'authentik_stages_authenticator_static:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Static Tokens" %}</a>
|
||||||
|
|
|
@ -44,4 +44,4 @@ class DisableView(LoginRequiredMixin, View):
|
||||||
Event.new(
|
Event.new(
|
||||||
"static_otp_disable", message="User disabled Static OTP Tokens."
|
"static_otp_disable", message="User disabled Static OTP Tokens."
|
||||||
).from_http(request)
|
).from_http(request)
|
||||||
return redirect("authentik_core:user-settings")
|
return redirect("/")
|
||||||
|
|
|
@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
|
from authentik.flows.challenge import Challenge, ChallengeTypes
|
||||||
from authentik.flows.models import ConfigurableStage, Stage
|
from authentik.flows.models import ConfigurableStage, Stage
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,10 +45,16 @@ class AuthenticatorTOTPStage(ConfigurableStage, Stage):
|
||||||
return AuthenticatorTOTPStageForm
|
return AuthenticatorTOTPStageForm
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ui_user_settings(self) -> Optional[str]:
|
def ui_user_settings(self) -> Optional[Challenge]:
|
||||||
return reverse(
|
return Challenge(
|
||||||
"authentik_stages_authenticator_totp:user-settings",
|
data={
|
||||||
kwargs={"stage_uuid": self.stage_uuid},
|
"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:
|
def __str__(self) -> str:
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
<p>
|
<p>
|
||||||
{% if not state %}
|
{% if not state %}
|
||||||
{% if stage.configure_flow %}
|
{% if stage.configure_flow %}
|
||||||
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23{% url 'authentik_core:user-settings' %}" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Time-based OTP" %}</a>
|
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23user" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Time-based OTP" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'authentik_stages_authenticator_totp:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Time-based OTP" %}</a>
|
<a href="{% url 'authentik_stages_authenticator_totp:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Time-based OTP" %}</a>
|
||||||
|
|
|
@ -39,4 +39,4 @@ class DisableView(LoginRequiredMixin, View):
|
||||||
Event.new("totp_disable", message="User disabled Time-based OTP.").from_http(
|
Event.new("totp_disable", message="User disabled Time-based OTP.").from_http(
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
return redirect("authentik_core:user-settings")
|
return redirect("/")
|
||||||
|
|
|
@ -11,6 +11,7 @@ from django.views import View
|
||||||
from django_otp.models import Device
|
from django_otp.models import Device
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
|
from authentik.flows.challenge import Challenge, ChallengeTypes
|
||||||
from authentik.flows.models import ConfigurableStage, Stage
|
from authentik.flows.models import ConfigurableStage, Stage
|
||||||
|
|
||||||
|
|
||||||
|
@ -42,10 +43,16 @@ class AuthenticateWebAuthnStage(ConfigurableStage, Stage):
|
||||||
return AuthenticateWebAuthnStageForm
|
return AuthenticateWebAuthnStageForm
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ui_user_settings(self) -> Optional[str]:
|
def ui_user_settings(self) -> Optional[Challenge]:
|
||||||
return reverse(
|
return Challenge(
|
||||||
"authentik_stages_authenticator_webauthn:user-settings",
|
data={
|
||||||
kwargs={"stage_uuid": self.stage_uuid},
|
"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:
|
def __str__(self) -> str:
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-c-card__footer">
|
<div class="pf-c-card__footer">
|
||||||
{% if stage.configure_flow %}
|
{% if stage.configure_flow %}
|
||||||
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23{% url 'authentik_core:user-settings' %}"
|
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23user"
|
||||||
class="ak-root-link pf-c-button pf-m-primary">{% trans "Configure WebAuthn" %}
|
class="ak-root-link pf-c-button pf-m-primary">{% trans "Configure WebAuthn" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
|
from authentik.flows.challenge import Challenge, ChallengeTypes
|
||||||
from authentik.flows.models import ConfigurableStage, Stage
|
from authentik.flows.models import ConfigurableStage, Stage
|
||||||
|
|
||||||
|
|
||||||
|
@ -48,11 +49,18 @@ class PasswordStage(ConfigurableStage, Stage):
|
||||||
return PasswordStageForm
|
return PasswordStageForm
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ui_user_settings(self) -> Optional[str]:
|
def ui_user_settings(self) -> Optional[Challenge]:
|
||||||
if not self.configure_flow:
|
if not self.configure_flow:
|
||||||
return None
|
return None
|
||||||
return reverse(
|
return Challenge(
|
||||||
"authentik_stages_password:user-settings", kwargs={"stage_uuid": self.pk}
|
data={
|
||||||
|
"type": ChallengeTypes.shell.value,
|
||||||
|
"title": self._meta.verbose_name,
|
||||||
|
"component": reverse(
|
||||||
|
"authentik_stages_password:user-settings",
|
||||||
|
kwargs={"stage_uuid": self.pk},
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
{% trans 'Reset your password' %}
|
{% trans 'Reset your password' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-c-card__body">
|
<div class="pf-c-card__body">
|
||||||
<a class="pf-c-button pf-m-primary ak-root-link" href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23{% url 'authentik_core:user-settings' %}">
|
<a class="pf-c-button pf-m-primary ak-root-link" href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23user">
|
||||||
{% trans 'Change password' %}
|
{% trans 'Change password' %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
42
swagger.yaml
42
swagger.yaml
|
@ -7326,6 +7326,48 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- stages
|
- stages
|
||||||
parameters: []
|
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}/:
|
/stages/all/{stage_uuid}/:
|
||||||
get:
|
get:
|
||||||
operationId: stages_all_read
|
operationId: stages_all_read
|
||||||
|
|
|
@ -97,7 +97,7 @@ class TestFlowsEnroll(SeleniumTestCase):
|
||||||
wait = WebDriverWait(interface_admin, self.wait_timeout)
|
wait = WebDriverWait(interface_admin, self.wait_timeout)
|
||||||
|
|
||||||
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-sidebar")))
|
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")
|
user = User.objects.get(username="foo")
|
||||||
self.assertEqual(user.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")))
|
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"))
|
self.assert_user(User.objects.get(username="foo"))
|
||||||
|
|
||||||
|
|
Reference in New Issue