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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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