flows: add API for user's stage settings

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-03-18 00:33:07 +01:00
parent 07142cab8b
commit a6123cfbe4
14 changed files with 119 additions and 25 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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:

View File

@ -22,7 +22,7 @@
</ul>
{% if not state %}
{% 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 %}
{% 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>

View File

@ -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("/")

View File

@ -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:

View File

@ -18,7 +18,7 @@
<p>
{% if not state %}
{% 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 %}
{% 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>

View File

@ -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("/")

View File

@ -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:

View File

@ -39,7 +39,7 @@
</div>
<div class="pf-c-card__footer">
{% 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" %}
</a>
{% endif %}

View File

@ -9,6 +9,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
@ -48,11 +49,18 @@ class PasswordStage(ConfigurableStage, Stage):
return PasswordStageForm
@property
def ui_user_settings(self) -> Optional[str]:
def ui_user_settings(self) -> Optional[Challenge]:
if not self.configure_flow:
return None
return reverse(
"authentik_stages_password:user-settings", kwargs={"stage_uuid": self.pk}
return Challenge(
data={
"type": ChallengeTypes.shell.value,
"title": self._meta.verbose_name,
"component": reverse(
"authentik_stages_password:user-settings",
kwargs={"stage_uuid": self.pk},
),
}
)
class Meta:

View File

@ -6,7 +6,7 @@
{% trans 'Reset your password' %}
</div>
<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' %}
</a>
</div>

View File

@ -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

View File

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