diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 000000000..2d176f2bc --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,3 @@ +coverage: + precision: 2 + round: up diff --git a/authentik/admin/api/meta.py b/authentik/admin/api/meta.py new file mode 100644 index 000000000..5b89ab898 --- /dev/null +++ b/authentik/admin/api/meta.py @@ -0,0 +1,31 @@ +"""Meta API""" +from drf_yasg.utils import swagger_auto_schema +from rest_framework.fields import CharField +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet + +from authentik.core.api.utils import PassiveSerializer +from authentik.lib.utils.reflection import get_apps + + +class AppSerializer(PassiveSerializer): + """Serialize Application info""" + + name = CharField() + label = CharField() + + +class AppsViewSet(ViewSet): + """Read-only view set list all installed apps""" + + permission_classes = [IsAdminUser] + + @swagger_auto_schema(responses={200: AppSerializer(many=True)}) + def list(self, request: Request) -> Response: + """List current messages and pass into Serializer""" + data = [] + for app in get_apps(): + data.append({"name": app.name, "label": app.verbose_name}) + return Response(AppSerializer(data, many=True).data) diff --git a/authentik/admin/apps.py b/authentik/admin/apps.py index db05f4daa..0c1da4d62 100644 --- a/authentik/admin/apps.py +++ b/authentik/admin/apps.py @@ -7,5 +7,4 @@ class AuthentikAdminConfig(AppConfig): name = "authentik.admin" label = "authentik_admin" - mountpoint = "administration/" verbose_name = "authentik Admin" diff --git a/authentik/admin/fields.py b/authentik/admin/fields.py deleted file mode 100644 index 7337c90c2..000000000 --- a/authentik/admin/fields.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Additional fields""" -import yaml -from django import forms -from django.utils.datastructures import MultiValueDict -from django.utils.translation import gettext_lazy as _ - - -class ArrayFieldSelectMultiple(forms.SelectMultiple): - """This is a Form Widget for use with a Postgres ArrayField. It implements - a multi-select interface that can be given a set of `choices`. - You can provide a `delimiter` keyword argument to specify the delimeter used. - - https://gist.github.com/stephane/00e73c0002de52b1c601""" - - def __init__(self, *args, **kwargs): - # Accept a `delimiter` argument, and grab it (defaulting to a comma) - self.delimiter = kwargs.pop("delimiter", ",") - super().__init__(*args, **kwargs) - - def value_from_datadict(self, data, files, name): - if isinstance(data, MultiValueDict): - # Normally, we'd want a list here, which is what we get from the - # SelectMultiple superclass, but the SimpleArrayField expects to - # get a delimited string, so we're doing a little extra work. - return self.delimiter.join(data.getlist(name)) - - return data.get(name) - - def get_context(self, name, value, attrs): - return super().get_context(name, value.split(self.delimiter), attrs) - - -class CodeMirrorWidget(forms.Textarea): - """Custom Textarea-based Widget that triggers a CodeMirror editor""" - - # CodeMirror mode to enable - mode: str - - template_name = "fields/codemirror.html" - - def __init__(self, *args, mode="yaml", **kwargs): - super().__init__(*args, **kwargs) - self.mode = mode - - def render(self, *args, **kwargs): - attrs = kwargs.setdefault("attrs", {}) - attrs["mode"] = self.mode - return super().render(*args, **kwargs) - - -class InvalidYAMLInput(str): - """Invalid YAML String type""" - - -class YAMLString(str): - """YAML String type""" - - -class YAMLField(forms.JSONField): - """Django's JSON Field converted to YAML""" - - default_error_messages = { - "invalid": _("'%(value)s' value must be valid YAML."), - } - widget = forms.Textarea - - def to_python(self, value): - if self.disabled: - return value - if value in self.empty_values: - return None - if isinstance(value, (list, dict, int, float, YAMLString)): - return value - try: - converted = yaml.safe_load(value) - except yaml.YAMLError: - raise forms.ValidationError( - self.error_messages["invalid"], - code="invalid", - params={"value": value}, - ) - if isinstance(converted, str): - return YAMLString(converted) - if converted is None: - return {} - return converted - - def bound_data(self, data, initial): - if self.disabled: - return initial - try: - return yaml.safe_load(data) - except yaml.YAMLError: - return InvalidYAMLInput(data) - - def prepare_value(self, value): - if isinstance(value, InvalidYAMLInput): - return value - return yaml.dump(value, explicit_start=True, default_flow_style=False) - - def has_changed(self, initial, data): - if super().has_changed(initial, data): - return True - # For purposes of seeing whether something has changed, True isn't the - # same as 1 and the order of keys doesn't matter. - data = self.to_python(data) - return yaml.dump(initial, sort_keys=True) != yaml.dump(data, sort_keys=True) diff --git a/authentik/admin/templates/fields/codemirror.html b/authentik/admin/templates/fields/codemirror.html deleted file mode 100644 index 19040059d..000000000 --- a/authentik/admin/templates/fields/codemirror.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/authentik/admin/templates/generic/create.html b/authentik/admin/templates/generic/create.html deleted file mode 100644 index c1cbe91f1..000000000 --- a/authentik/admin/templates/generic/create.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends base_template|default:"generic/form.html" %} - -{% load authentik_utils %} -{% load i18n %} - -{% block above_form %} -

- {% blocktrans with type=form|form_verbose_name %} - Create {{ type }} - {% endblocktrans %} -

-{% endblock %} - -{% block action %} -{% blocktrans with type=form|form_verbose_name %} -Create {{ type }} -{% endblocktrans %} -{% endblock %} diff --git a/authentik/admin/templates/generic/form.html b/authentik/admin/templates/generic/form.html deleted file mode 100644 index 0dd1c2ad1..000000000 --- a/authentik/admin/templates/generic/form.html +++ /dev/null @@ -1,38 +0,0 @@ -{% load i18n %} -{% load authentik_utils %} -{% load static %} - -{% block content %} -
-
- {% block above_form %} - {% endblock %} -
-
-
-
-
-
-
-
- {% include 'partials/form_horizontal.html' with form=form %} - {% block beneath_form %} - {% endblock %} -
-
-
-
-
-
- -{% endblock %} - -{% block scripts %} -{{ block.super }} -{{ form.media.js }} -{% endblock %} diff --git a/authentik/admin/templates/generic/update.html b/authentik/admin/templates/generic/update.html deleted file mode 100644 index 7b46d40f9..000000000 --- a/authentik/admin/templates/generic/update.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends base_template|default:"generic/form.html" %} - -{% load authentik_utils %} -{% load i18n %} - -{% block above_form %} -

- {% blocktrans with type=form|form_verbose_name|title inst=form.instance %} - Update {{ inst }} - {% endblocktrans %} -

-{% endblock %} - -{% block action %} -{% blocktrans with type=form|form_verbose_name %} -Update {{ type }} -{% endblocktrans %} -{% endblock %} diff --git a/authentik/admin/tests/test_api.py b/authentik/admin/tests/test_api.py index eaebf4fc9..293566875 100644 --- a/authentik/admin/tests/test_api.py +++ b/authentik/admin/tests/test_api.py @@ -71,3 +71,8 @@ class TestAdminAPI(TestCase): """Test metrics API""" response = self.client.get(reverse("authentik_api:admin_metrics-list")) self.assertEqual(response.status_code, 200) + + def test_apps(self): + """Test apps API""" + response = self.client.get(reverse("authentik_api:apps-list")) + self.assertEqual(response.status_code, 200) diff --git a/authentik/admin/tests/test_generated.py b/authentik/admin/tests/test_generated.py deleted file mode 100644 index af63fe1ed..000000000 --- a/authentik/admin/tests/test_generated.py +++ /dev/null @@ -1,66 +0,0 @@ -"""admin tests""" -from importlib import import_module -from typing import Callable - -from django.forms import ModelForm -from django.test import Client, TestCase -from django.urls import reverse -from django.urls.exceptions import NoReverseMatch - -from authentik.admin.urls import urlpatterns -from authentik.core.models import Group, User -from authentik.lib.utils.reflection import get_apps - - -class TestAdmin(TestCase): - """Generic admin tests""" - - def setUp(self): - self.user = User.objects.create_user(username="test") - self.user.ak_groups.add(Group.objects.filter(is_superuser=True).first()) - self.user.save() - self.client = Client() - self.client.force_login(self.user) - - -def generic_view_tester(view_name: str) -> Callable: - """This is used instead of subTest for better visibility""" - - def tester(self: TestAdmin): - try: - full_url = reverse(f"authentik_admin:{view_name}") - response = self.client.get(full_url) - self.assertTrue(response.status_code < 500) - except NoReverseMatch: - pass - - return tester - - -for url in urlpatterns: - method_name = url.name.replace("-", "_") - setattr(TestAdmin, f"test_view_{method_name}", generic_view_tester(url.name)) - - -def generic_form_tester(form: ModelForm) -> Callable: - """Test a form""" - - def tester(self: TestAdmin): - form_inst = form() - self.assertFalse(form_inst.is_valid()) - - return tester - - -# Load the forms module from every app, so we have all forms loaded -for app in get_apps(): - module = app.__module__.replace(".apps", ".forms") - try: - import_module(module) - except ImportError: - pass - -for form_class in ModelForm.__subclasses__(): - setattr( - TestAdmin, f"test_form_{form_class.__name__}", generic_form_tester(form_class) - ) diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py deleted file mode 100644 index 73615509d..000000000 --- a/authentik/admin/urls.py +++ /dev/null @@ -1,74 +0,0 @@ -"""authentik URL Configuration""" -from django.urls import path - -from authentik.admin.views import ( - outposts_service_connections, - policies, - property_mappings, - providers, - sources, - stages, -) -from authentik.providers.saml.views.metadata import MetadataImportView - -urlpatterns = [ - # Sources - path("sources/create/", sources.SourceCreateView.as_view(), name="source-create"), - path( - "sources//update/", - sources.SourceUpdateView.as_view(), - name="source-update", - ), - # Policies - path("policies/create/", policies.PolicyCreateView.as_view(), name="policy-create"), - path( - "policies//update/", - policies.PolicyUpdateView.as_view(), - name="policy-update", - ), - # Providers - path( - "providers/create/", - providers.ProviderCreateView.as_view(), - name="provider-create", - ), - path( - "providers/create/saml/from-metadata/", - MetadataImportView.as_view(), - name="provider-saml-from-metadata", - ), - path( - "providers//update/", - providers.ProviderUpdateView.as_view(), - name="provider-update", - ), - # Stages - path("stages/create/", stages.StageCreateView.as_view(), name="stage-create"), - path( - "stages//update/", - stages.StageUpdateView.as_view(), - name="stage-update", - ), - # Property Mappings - path( - "property-mappings/create/", - property_mappings.PropertyMappingCreateView.as_view(), - name="property-mapping-create", - ), - path( - "property-mappings//update/", - property_mappings.PropertyMappingUpdateView.as_view(), - name="property-mapping-update", - ), - # Outpost Service Connections - path( - "outpost_service_connections/create/", - outposts_service_connections.OutpostServiceConnectionCreateView.as_view(), - name="outpost-service-connection-create", - ), - path( - "outpost_service_connections//update/", - outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(), - name="outpost-service-connection-update", - ), -] diff --git a/authentik/admin/views/outposts_service_connections.py b/authentik/admin/views/outposts_service_connections.py deleted file mode 100644 index 9ffc5db9e..000000000 --- a/authentik/admin/views/outposts_service_connections.py +++ /dev/null @@ -1,44 +0,0 @@ -"""authentik OutpostServiceConnection administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionRequiredMixin - -from authentik.admin.views.utils import InheritanceCreateView, InheritanceUpdateView -from authentik.outposts.models import OutpostServiceConnection - - -class OutpostServiceConnectionCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new OutpostServiceConnection""" - - model = OutpostServiceConnection - permission_required = "authentik_outposts.add_outpostserviceconnection" - - template_name = "generic/create.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created Outpost Service Connection") - - -class OutpostServiceConnectionUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update outpostserviceconnection""" - - model = OutpostServiceConnection - permission_required = "authentik_outposts.change_outpostserviceconnection" - - template_name = "generic/update.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully updated Outpost Service Connection") diff --git a/authentik/admin/views/policies.py b/authentik/admin/views/policies.py deleted file mode 100644 index c08017583..000000000 --- a/authentik/admin/views/policies.py +++ /dev/null @@ -1,44 +0,0 @@ -"""authentik Policy administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionRequiredMixin - -from authentik.admin.views.utils import InheritanceCreateView, InheritanceUpdateView -from authentik.policies.models import Policy - - -class PolicyCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Policy""" - - model = Policy - permission_required = "authentik_policies.add_policy" - - template_name = "generic/create.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created Policy") - - -class PolicyUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update policy""" - - model = Policy - permission_required = "authentik_policies.change_policy" - - template_name = "generic/update.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully updated Policy") diff --git a/authentik/admin/views/property_mappings.py b/authentik/admin/views/property_mappings.py deleted file mode 100644 index 99b3d51e2..000000000 --- a/authentik/admin/views/property_mappings.py +++ /dev/null @@ -1,41 +0,0 @@ -"""authentik PropertyMapping administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionRequiredMixin - -from authentik.admin.views.utils import InheritanceCreateView, InheritanceUpdateView -from authentik.core.models import PropertyMapping - - -class PropertyMappingCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new PropertyMapping""" - - model = PropertyMapping - permission_required = "authentik_core.add_propertymapping" - success_url = "/" - template_name = "generic/create.html" - success_message = _("Successfully created Property Mapping") - - -class PropertyMappingUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update property_mapping""" - - model = PropertyMapping - permission_required = "authentik_core.change_propertymapping" - success_url = "/" - template_name = "generic/update.html" - success_message = _("Successfully updated Property Mapping") diff --git a/authentik/admin/views/providers.py b/authentik/admin/views/providers.py deleted file mode 100644 index f4dd2a45f..000000000 --- a/authentik/admin/views/providers.py +++ /dev/null @@ -1,41 +0,0 @@ -"""authentik Provider administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionRequiredMixin - -from authentik.admin.views.utils import InheritanceCreateView, InheritanceUpdateView -from authentik.core.models import Provider - - -class ProviderCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Provider""" - - model = Provider - permission_required = "authentik_core.add_provider" - success_url = "/" - template_name = "generic/create.html" - success_message = _("Successfully created Provider") - - -class ProviderUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update provider""" - - model = Provider - permission_required = "authentik_core.change_provider" - success_url = "/" - template_name = "generic/update.html" - success_message = _("Successfully updated Provider") diff --git a/authentik/admin/views/sources.py b/authentik/admin/views/sources.py deleted file mode 100644 index 6191cf61f..000000000 --- a/authentik/admin/views/sources.py +++ /dev/null @@ -1,43 +0,0 @@ -"""authentik Source administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionRequiredMixin - -from authentik.admin.views.utils import InheritanceCreateView, InheritanceUpdateView -from authentik.core.models import Source - - -class SourceCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Source""" - - model = Source - permission_required = "authentik_core.add_source" - - success_url = "/" - template_name = "generic/create.html" - success_message = _("Successfully created Source") - - -class SourceUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update source""" - - model = Source - permission_required = "authentik_core.change_source" - - success_url = "/" - template_name = "generic/update.html" - success_message = _("Successfully updated Source") diff --git a/authentik/admin/views/stages.py b/authentik/admin/views/stages.py deleted file mode 100644 index 39ff3ffbb..000000000 --- a/authentik/admin/views/stages.py +++ /dev/null @@ -1,43 +0,0 @@ -"""authentik Stage administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionRequiredMixin - -from authentik.admin.views.utils import InheritanceCreateView, InheritanceUpdateView -from authentik.flows.models import Stage - - -class StageCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Stage""" - - model = Stage - template_name = "generic/create.html" - permission_required = "authentik_flows.add_stage" - - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created Stage") - - -class StageUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update stage""" - - model = Stage - permission_required = "authentik_flows.update_application" - template_name = "generic/update.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully updated Stage") diff --git a/authentik/admin/views/utils.py b/authentik/admin/views/utils.py deleted file mode 100644 index 4c2fe7a38..000000000 --- a/authentik/admin/views/utils.py +++ /dev/null @@ -1,50 +0,0 @@ -"""authentik admin util views""" -from typing import Any - -from django.http import Http404 -from django.views.generic import UpdateView - -from authentik.lib.utils.reflection import all_subclasses -from authentik.lib.views import CreateAssignPermView - - -class InheritanceCreateView(CreateAssignPermView): - """CreateView for objects using InheritanceManager""" - - def get_form_class(self): - provider_type = self.request.GET.get("type") - try: - model = next( - x for x in all_subclasses(self.model) if x.__name__ == provider_type - ) - except StopIteration as exc: - raise Http404 from exc - return model().form - - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - form_cls = self.get_form_class() - if hasattr(form_cls, "template_name"): - kwargs["base_template"] = form_cls.template_name - return kwargs - - -class InheritanceUpdateView(UpdateView): - """UpdateView for objects using InheritanceManager""" - - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - form_cls = self.get_form_class() - if hasattr(form_cls, "template_name"): - kwargs["base_template"] = form_cls.template_name - return kwargs - - def get_form_class(self): - return self.get_object().form - - def get_object(self, queryset=None): - return ( - self.model.objects.filter(pk=self.kwargs.get("pk")) - .select_subclasses() - .first() - ) diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py index 9ee25b019..dce5ee9e6 100644 --- a/authentik/api/v2/urls.py +++ b/authentik/api/v2/urls.py @@ -5,6 +5,7 @@ from drf_yasg.views import get_schema_view from rest_framework import routers from rest_framework.permissions import AllowAny +from authentik.admin.api.meta import AppsViewSet from authentik.admin.api.metrics import AdministrationMetricsViewSet from authentik.admin.api.tasks import TaskViewSet from authentik.admin.api.version import VersionViewSet @@ -103,6 +104,7 @@ router.register("admin/version", VersionViewSet, basename="admin_version") router.register("admin/workers", WorkerViewSet, basename="admin_workers") router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics") router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") +router.register("admin/apps", AppsViewSet, basename="apps") router.register("core/applications", ApplicationViewSet) router.register("core/groups", GroupViewSet) diff --git a/authentik/core/admin.py b/authentik/core/admin.py deleted file mode 100644 index e6ba03829..000000000 --- a/authentik/core/admin.py +++ /dev/null @@ -1,20 +0,0 @@ -"""authentik core admin""" - -from django.apps import AppConfig, apps -from django.contrib import admin -from django.contrib.admin.sites import AlreadyRegistered -from guardian.admin import GuardedModelAdmin - - -def admin_autoregister(app: AppConfig): - """Automatically register all models from app""" - for model in app.get_models(): - try: - admin.site.register(model, GuardedModelAdmin) - except AlreadyRegistered: - pass - - -for _app in apps.get_app_configs(): - if _app.label.startswith("authentik_"): - admin_autoregister(_app) diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 1c6437d7f..fff437bb5 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -124,7 +124,13 @@ class ApplicationViewSet(ModelViewSet): ], responses={200: "Success"}, ) - @action(detail=True, methods=["POST"], parser_classes=(MultiPartParser,)) + @action( + detail=True, + pagination_class=None, + filter_backends=[], + methods=["POST"], + parser_classes=(MultiPartParser,), + ) # pylint: disable=unused-argument def set_icon(self, request: Request, slug: str): """Set application icon""" @@ -140,7 +146,7 @@ class ApplicationViewSet(ModelViewSet): "authentik_core.view_application", ["authentik_events.view_event"] ) @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)}) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=unused-argument def metrics(self, request: Request, slug: str): """Metrics for application logins""" diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index b1a32cc28..af579086a 100644 --- a/authentik/core/api/propertymappings.py +++ b/authentik/core/api/propertymappings.py @@ -1,7 +1,6 @@ """PropertyMapping API Views""" from json import dumps -from django.urls import reverse from drf_yasg.utils import swagger_auto_schema from guardian.shortcuts import get_objects_for_user from rest_framework import mixins @@ -19,9 +18,10 @@ from authentik.core.api.utils import ( PassiveSerializer, TypeCreateSerializer, ) +from authentik.core.expression import PropertyMappingEvaluator from authentik.core.models import PropertyMapping -from authentik.lib.templatetags.authentik_utils import verbose_name from authentik.lib.utils.reflection import all_subclasses +from authentik.managed.api import ManagedSerializer from authentik.policies.api.exec import PolicyTestSerializer @@ -32,29 +32,30 @@ class PropertyMappingTestResultSerializer(PassiveSerializer): successful = BooleanField(read_only=True) -class PropertyMappingSerializer(ModelSerializer, MetaNameSerializer): +class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSerializer): """PropertyMapping Serializer""" - object_type = SerializerMethodField(method_name="get_type") + component = SerializerMethodField() - def get_type(self, obj): - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("propertymapping", "") + def get_component(self, obj: PropertyMapping) -> str: + """Get object's component so that we know how to edit the object""" + return obj.component - def to_representation(self, instance: PropertyMapping): - # pyright: reportGeneralTypeIssues=false - if instance.__class__ == PropertyMapping: - return super().to_representation(instance) - return instance.serializer(instance=instance).data + def validate_expression(self, expression: str) -> str: + """Test Syntax""" + evaluator = PropertyMappingEvaluator() + evaluator.validate(expression) + return expression class Meta: model = PropertyMapping fields = [ "pk", + "managed", "name", "expression", - "object_type", + "component", "verbose_name", "verbose_name_plural", ] @@ -80,17 +81,17 @@ class PropertyMappingViewSet( return PropertyMapping.objects.select_subclasses() @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def types(self, request: Request) -> Response: """Get all creatable property-mapping types""" data = [] for subclass in all_subclasses(self.queryset.model): + subclass: PropertyMapping data.append( { - "name": verbose_name(subclass), + "name": subclass._meta.verbose_name, "description": subclass.__doc__, - "link": reverse("authentik_admin:property-mapping-create") - + f"?type={subclass.__name__}", + "component": subclass.component, } ) return Response(TypeCreateSerializer(data, many=True).data) @@ -100,7 +101,7 @@ class PropertyMappingViewSet( request_body=PolicyTestSerializer(), responses={200: PropertyMappingTestResultSerializer}, ) - @action(detail=True, methods=["POST"]) + @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) # pylint: disable=unused-argument, invalid-name def test(self, request: Request, pk: str) -> Response: """Test Property Mapping""" @@ -116,7 +117,7 @@ class PropertyMappingViewSet( if not users.exists(): raise PermissionDenied() - response_data = {"successful": True} + response_data = {"successful": True, "result": ""} try: result = mapping.evaluate( users.first(), diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py index c22c18531..6e549c04c 100644 --- a/authentik/core/api/providers.py +++ b/authentik/core/api/providers.py @@ -1,5 +1,4 @@ """Provider API Views""" -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins @@ -12,7 +11,6 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.models import Provider -from authentik.lib.templatetags.authentik_utils import verbose_name from authentik.lib.utils.reflection import all_subclasses @@ -22,11 +20,14 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): assigned_application_slug = ReadOnlyField(source="application.slug") assigned_application_name = ReadOnlyField(source="application.name") - object_type = SerializerMethodField() + component = SerializerMethodField() - def get_object_type(self, obj): - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("provider", "") + def get_component(self, obj: Provider): # pragma: no cover + """Get object component so that we know how to edit the object""" + # pyright: reportGeneralTypeIssues=false + if obj.__class__ == Provider: + return "" + return obj.component class Meta: @@ -34,10 +35,9 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): fields = [ "pk", "name", - "application", "authorization_flow", "property_mappings", - "object_type", + "component", "assigned_application_slug", "assigned_application_name", "verbose_name", @@ -67,24 +67,24 @@ class ProviderViewSet( return Provider.objects.select_subclasses() @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def types(self, request: Request) -> Response: """Get all creatable provider types""" data = [] for subclass in all_subclasses(self.queryset.model): + subclass: Provider data.append( { - "name": verbose_name(subclass), + "name": subclass._meta.verbose_name, "description": subclass.__doc__, - "link": reverse("authentik_admin:provider-create") - + f"?type={subclass.__name__}", + "component": subclass().component, } ) data.append( { "name": _("SAML Provider from Metadata"), "description": _("Create a SAML Provider by importing its Metadata."), - "link": reverse("authentik_admin:provider-saml-from-metadata"), + "component": "ak-provider-saml-import-form", } ) return Response(TypeCreateSerializer(data, many=True).data) diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index 94b31c92b..a113d686f 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -1,7 +1,6 @@ """Source API Views""" from typing import Iterable -from django.urls import reverse from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins from rest_framework.decorators import action @@ -14,7 +13,6 @@ from structlog.stdlib import get_logger from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.models import Source from authentik.core.types import UserSettingSerializer -from authentik.lib.templatetags.authentik_utils import verbose_name from authentik.lib.utils.reflection import all_subclasses from authentik.policies.engine import PolicyEngine @@ -24,11 +22,11 @@ LOGGER = get_logger() class SourceSerializer(ModelSerializer, MetaNameSerializer): """Source Serializer""" - object_type = SerializerMethodField() + component = SerializerMethodField() - def get_object_type(self, obj): - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("source", "") + def get_component(self, obj: Source): + """Get object component so that we know how to edit the object""" + return obj.component class Meta: @@ -40,7 +38,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): "enabled", "authentication_flow", "enrollment_flow", - "object_type", + "component", "verbose_name", "verbose_name_plural", "policy_engine_mode", @@ -63,23 +61,24 @@ class SourceViewSet( return Source.objects.select_subclasses() @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def types(self, request: Request) -> Response: """Get all creatable source types""" data = [] for subclass in all_subclasses(self.queryset.model): + subclass: Source + # pyright: reportGeneralTypeIssues=false data.append( { - "name": verbose_name(subclass), + "name": subclass._meta.verbose_name, "description": subclass.__doc__, - "link": reverse("authentik_admin:source-create") - + f"?type={subclass.__name__}", + "component": subclass().component, } ) return Response(TypeCreateSerializer(data, many=True).data) @swagger_auto_schema(responses={200: UserSettingSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def user_settings(self, request: Request) -> Response: """Get all sources the user can configure""" _all_sources: Iterable[Source] = Source.objects.filter( diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index f4ee86451..d8c9c6bdf 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -13,9 +13,10 @@ from authentik.core.api.users import UserSerializer from authentik.core.api.utils import PassiveSerializer from authentik.core.models import Token from authentik.events.models import Event, EventAction +from authentik.managed.api import ManagedSerializer -class TokenSerializer(ModelSerializer): +class TokenSerializer(ManagedSerializer, ModelSerializer): """Token Serializer""" user = UserSerializer(required=False) @@ -25,6 +26,7 @@ class TokenSerializer(ModelSerializer): model = Token fields = [ "pk", + "managed", "identifier", "intent", "user", @@ -66,7 +68,7 @@ class TokenViewSet(ModelViewSet): @permission_required("authentik_core.view_token_key") @swagger_auto_schema(responses={200: TokenViewSerializer(many=False)}) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=unused-argument def view_key(self, request: Request, identifier: str) -> Response: """Return token key and log access""" diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 17b0c706e..d67e890a4 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -93,7 +93,7 @@ class UserViewSet(ModelViewSet): return User.objects.all().exclude(pk=get_anonymous_user().pk) @swagger_auto_schema(responses={200: SessionUserSerializer(many=False)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) # pylint: disable=invalid-name def me(self, request: Request) -> Response: """Get information about current user""" @@ -109,7 +109,7 @@ class UserViewSet(ModelViewSet): @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def metrics(self, request: Request) -> Response: """User metrics per 1h""" serializer = UserMetricsSerializer(True) @@ -120,7 +120,7 @@ class UserViewSet(ModelViewSet): @swagger_auto_schema( responses={"200": LinkSerializer(many=False)}, ) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=invalid-name, unused-argument def recovery(self, request: Request, pk: int) -> Response: """Create a temporary link that a user can use to recover their accounts""" diff --git a/authentik/core/api/utils.py b/authentik/core/api/utils.py index 390955a39..70155b06e 100644 --- a/authentik/core/api/utils.py +++ b/authentik/core/api/utils.py @@ -34,7 +34,7 @@ class TypeCreateSerializer(PassiveSerializer): name = CharField(required=True) description = CharField(required=True) - link = CharField(required=True) + component = CharField(required=True) class CacheSerializer(PassiveSerializer): diff --git a/authentik/core/models.py b/authentik/core/models.py index 838598898..28900030a 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -10,7 +10,6 @@ from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import UserManager as DjangoUserManager from django.db import models from django.db.models import Q, QuerySet -from django.forms import ModelForm from django.http import HttpRequest from django.templatetags.static import static from django.utils.functional import cached_property @@ -188,8 +187,8 @@ class Provider(SerializerModel): return None @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" + def component(self) -> str: + """Return component used to edit this object""" raise NotImplementedError @property @@ -276,8 +275,8 @@ class Source(SerializerModel, PolicyBindingModel): objects = InheritanceManager() @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" + def component(self) -> str: + """Return component used to edit this object""" raise NotImplementedError @property @@ -382,8 +381,8 @@ class PropertyMapping(SerializerModel, ManagedModel): objects = InheritanceManager() @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" + def component(self) -> str: + """Return component used to edit this object""" raise NotImplementedError @property diff --git a/authentik/core/templates/base/skeleton.html b/authentik/core/templates/base/skeleton.html index 4c47e0476..b28a9fa68 100644 --- a/authentik/core/templates/base/skeleton.html +++ b/authentik/core/templates/base/skeleton.html @@ -1,6 +1,5 @@ {% load static %} {% load i18n %} -{% load authentik_utils %} diff --git a/authentik/core/templates/error/generic.html b/authentik/core/templates/error/generic.html index 5ef6538bd..a6bf253e0 100644 --- a/authentik/core/templates/error/generic.html +++ b/authentik/core/templates/error/generic.html @@ -1,7 +1,6 @@ {% extends 'base/skeleton.html' %} {% load i18n %} -{% load authentik_utils %} {% block head %} {{ block.super }} diff --git a/authentik/core/templates/login/base_full.html b/authentik/core/templates/login/base_full.html index 450a969c4..2e2854f73 100644 --- a/authentik/core/templates/login/base_full.html +++ b/authentik/core/templates/login/base_full.html @@ -2,7 +2,6 @@ {% load static %} {% load i18n %} -{% load authentik_utils %} {% block body %}
diff --git a/authentik/core/templates/partials/form_horizontal.html b/authentik/core/templates/partials/form_horizontal.html deleted file mode 100644 index 14baa5286..000000000 --- a/authentik/core/templates/partials/form_horizontal.html +++ /dev/null @@ -1,115 +0,0 @@ -{% load authentik_utils %} -{% load i18n %} - -{% csrf_token %} -{% for field in form %} -{% if field.field.widget|fieldtype == 'HiddenInput' %} -{{ field }} -{% else %} -
- {% if field.field.widget|fieldtype == 'RadioSelect' %} -
- -
-
- {% for c in field %} -
- - -
- {% endfor %} - {% if field.help_text %} -

{{ field.help_text }}

- {% endif %} -
- {% elif field.field.widget|fieldtype == 'Select' or field.field.widget|fieldtype == "SelectMultiple" %} -
- -
-
-
- {{ field|css_class:"pf-c-form-control" }} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} - {% if field.field.widget|fieldtype == 'SelectMultiple' %} -

{% trans 'Hold control/command to select multiple items.' %}

- {% endif %} -
-
- {% elif field.field.widget|fieldtype == 'CheckboxInput' %} -
-
-
- {{ field|css_class:"pf-c-check__input" }} - -
- {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} -
-
- {% elif field.field.widget|fieldtype == "FileInput" %} -
- -
-
-
- {{ field|css_class:"pf-c-form-control" }} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} - {% if field.value %} - - {% blocktrans with current=field.value %} - Currently set to {{current}}. - {% endblocktrans %} - - {% endif %} -
-
- {% else %} -
- -
-
-
- {{ field|css_class:'pf-c-form-control' }} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} -
-
- {% endif %} - {% for error in field.errors %} -

- {{ error }} -

- {% endfor %} -
-{% endif %} -{% endfor %} diff --git a/authentik/core/tests/test_property_mapping_api.py b/authentik/core/tests/test_property_mapping_api.py index 4912e17ed..74da28f5e 100644 --- a/authentik/core/tests/test_property_mapping_api.py +++ b/authentik/core/tests/test_property_mapping_api.py @@ -2,8 +2,10 @@ from json import dumps from django.urls import reverse +from rest_framework.serializers import ValidationError from rest_framework.test import APITestCase +from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.models import PropertyMapping, User @@ -19,7 +21,7 @@ class TestPropertyMappingAPI(APITestCase): self.client.force_login(self.user) def test_test_call(self): - """Test Policy's test endpoint""" + """Test PropertMappings's test endpoint""" response = self.client.post( reverse( "authentik_api:propertymapping-test", kwargs={"pk": self.mapping.pk} @@ -32,3 +34,19 @@ class TestPropertyMappingAPI(APITestCase): response.content.decode(), {"result": dumps({"foo": "bar"}), "successful": True}, ) + + def test_validate(self): + """Test PropertyMappings's validation""" + # Because the root property-mapping has no write operation, we just instantiate + # a serializer and test inline + expr = "return True" + self.assertEqual(PropertyMappingSerializer().validate_expression(expr), expr) + with self.assertRaises(ValidationError): + print(PropertyMappingSerializer().validate_expression("/")) + + def test_types(self): + """Test PropertyMappigns's types endpoint""" + response = self.client.get( + reverse("authentik_api:propertymapping-types"), + ) + self.assertEqual(response.status_code, 200) diff --git a/authentik/core/tests/test_providers_api.py b/authentik/core/tests/test_providers_api.py new file mode 100644 index 000000000..dbfba2594 --- /dev/null +++ b/authentik/core/tests/test_providers_api.py @@ -0,0 +1,24 @@ +"""Test providers API""" +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import PropertyMapping, User + + +class TestProvidersAPI(APITestCase): + """Test providers API""" + + def setUp(self) -> None: + super().setUp() + self.mapping = PropertyMapping.objects.create( + name="dummy", expression="""return {'foo': 'bar'}""" + ) + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_types(self): + """Test Providers's types endpoint""" + response = self.client.get( + reverse("authentik_api:provider-types"), + ) + self.assertEqual(response.status_code, 200) diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py index daa43e672..f600ec67c 100644 --- a/authentik/crypto/api.py +++ b/authentik/crypto/api.py @@ -1,4 +1,5 @@ """Crypto API Views""" +import django_filters from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate @@ -95,11 +96,29 @@ class CertificateGenerationSerializer(PassiveSerializer): validity_days = IntegerField(initial=365) +class CertificateKeyPairFilter(django_filters.FilterSet): + """Filter for certificates""" + + has_key = django_filters.BooleanFilter( + label="Only return certificate-key pairs with keys", method="filter_has_key" + ) + + # pylint: disable=unused-argument + def filter_has_key(self, queryset, name, value): # pragma: no cover + """Only return certificate-key pairs with keys""" + return queryset.exclude(key_data__exact="") + + class Meta: + model = CertificateKeyPair + fields = ["name"] + + class CertificateKeyPairViewSet(ModelViewSet): """CertificateKeyPair Viewset""" queryset = CertificateKeyPair.objects.all() serializer_class = CertificateKeyPairSerializer + filterset_class = CertificateKeyPairFilter @permission_required(None, ["authentik_crypto.add_certificatekeypair"]) @swagger_auto_schema( @@ -125,7 +144,7 @@ class CertificateKeyPairViewSet(ModelViewSet): return Response(serializer.data) @swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)}) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=invalid-name, unused-argument def view_certificate(self, request: Request, pk: str) -> Response: """Return certificate-key pairs certificate and log access""" @@ -140,7 +159,7 @@ class CertificateKeyPairViewSet(ModelViewSet): ) @swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)}) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=invalid-name, unused-argument def view_private_key(self, request: Request, pk: str) -> Response: """Return certificate-key pairs private key and log access""" diff --git a/authentik/events/api/event.py b/authentik/events/api/event.py index 0c59cf10b..b13e28c69 100644 --- a/authentik/events/api/event.py +++ b/authentik/events/api/event.py @@ -11,6 +11,7 @@ from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, Serializer from rest_framework.viewsets import ReadOnlyModelViewSet +from authentik.core.api.utils import TypeCreateSerializer from authentik.events.models import Event, EventAction @@ -144,3 +145,18 @@ class EventViewSet(ReadOnlyModelViewSet): .values("unique_users", "application", "counted_events") .order_by("-counted_events")[:top_n] ) + + @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) + @action(detail=False, pagination_class=None, filter_backends=[]) + def actions(self, request: Request) -> Response: + """Get all actions""" + data = [] + for value, name in EventAction.choices: + data.append( + { + "name": name, + "description": "", + "component": value, + } + ) + return Response(TypeCreateSerializer(data, many=True).data) diff --git a/authentik/events/api/notification_transport.py b/authentik/events/api/notification_transport.py index 7fdb5d260..7893d705e 100644 --- a/authentik/events/api/notification_transport.py +++ b/authentik/events/api/notification_transport.py @@ -63,7 +63,7 @@ class NotificationTransportViewSet(ModelViewSet): responses={200: NotificationTransportTestSerializer(many=False)}, request_body=no_body, ) - @action(detail=True, methods=["post"]) + @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"]) # pylint: disable=invalid-name, unused-argument def test(self, request: Request, pk=None) -> Response: """Send example notification using selected transport. Requires diff --git a/authentik/events/tests/test_api.py b/authentik/events/tests/test_api.py index 7a2812e02..b7b4c15fc 100644 --- a/authentik/events/tests/test_api.py +++ b/authentik/events/tests/test_api.py @@ -10,11 +10,12 @@ from authentik.events.models import Event, EventAction class TestEventsAPI(APITestCase): """Test Event API""" - def test_top_n(self): - """Test top_per_user""" + def setUp(self) -> None: user = User.objects.get(username="akadmin") self.client.force_login(user) + def test_top_n(self): + """Test top_per_user""" event = Event.new(EventAction.AUTHORIZE_APPLICATION) event.save() # We save to ensure nothing is un-saveable response = self.client.get( @@ -22,3 +23,10 @@ class TestEventsAPI(APITestCase): data={"filter_action": EventAction.AUTHORIZE_APPLICATION}, ) self.assertEqual(response.status_code, 200) + + def test_actions(self): + """Test actions""" + response = self.client.get( + reverse("authentik_api:event-actions"), + ) + self.assertEqual(response.status_code, 200) diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index b2b6d84e4..b92273517 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -98,7 +98,7 @@ class FlowViewSet(ModelViewSet): @permission_required(None, ["authentik_flows.view_flow_cache"]) @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def cache_info(self, request: Request) -> Response: """Info about cached flows""" return Response(data={"count": len(cache.keys("flow_*"))}) @@ -178,7 +178,7 @@ class FlowViewSet(ModelViewSet): ), }, ) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=unused-argument def export(self, request: Request, slug: str) -> Response: """Export flow to .akflow file""" @@ -189,7 +189,7 @@ class FlowViewSet(ModelViewSet): return response @swagger_auto_schema(responses={200: FlowDiagramSerializer()}) - @action(detail=True, methods=["get"]) + @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) # pylint: disable=unused-argument def diagram(self, request: Request, slug: str) -> Response: """Return diagram for flow with slug `slug`, in the format used by flowchart.js""" @@ -270,7 +270,13 @@ class FlowViewSet(ModelViewSet): ], responses={200: "Success"}, ) - @action(detail=True, methods=["POST"], parser_classes=(MultiPartParser,)) + @action( + detail=True, + pagination_class=None, + filter_backends=[], + methods=["POST"], + parser_classes=(MultiPartParser,), + ) # pylint: disable=unused-argument def set_background(self, request: Request, slug: str): """Set Flow background""" @@ -285,7 +291,7 @@ class FlowViewSet(ModelViewSet): @swagger_auto_schema( responses={200: LinkSerializer(many=False)}, ) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=unused-argument def execute(self, request: Request, slug: str): """Execute flow for current user""" diff --git a/authentik/flows/api/stages.py b/authentik/flows/api/stages.py index c80a426b5..4d89cb19b 100644 --- a/authentik/flows/api/stages.py +++ b/authentik/flows/api/stages.py @@ -1,7 +1,6 @@ """Flow Stage API Views""" from typing import Iterable -from django.urls import reverse from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins from rest_framework.decorators import action @@ -15,7 +14,6 @@ from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.types import UserSettingSerializer from authentik.flows.api.flows import FlowSerializer 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() @@ -24,12 +22,15 @@ LOGGER = get_logger() class StageSerializer(ModelSerializer, MetaNameSerializer): """Stage Serializer""" - object_type = SerializerMethodField() + component = SerializerMethodField() flow_set = FlowSerializer(many=True, required=False) - def get_object_type(self, obj: Stage) -> str: - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("stage", "") + def get_component(self, obj: Stage) -> str: + """Get object type so that we know how to edit the object""" + # pyright: reportGeneralTypeIssues=false + if obj.__class__ == Stage: + return "" + return obj.component class Meta: @@ -37,7 +38,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer): fields = [ "pk", "name", - "object_type", + "component", "verbose_name", "verbose_name_plural", "flow_set", @@ -61,24 +62,24 @@ class StageViewSet( return Stage.objects.select_subclasses() @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def types(self, request: Request) -> Response: """Get all creatable stage types""" data = [] for subclass in all_subclasses(self.queryset.model, False): + subclass: Stage data.append( { - "name": verbose_name(subclass), + "name": subclass._meta.verbose_name, "description": subclass.__doc__, - "link": reverse("authentik_admin:stage-create") - + f"?type={subclass.__name__}", + "component": subclass().component, } ) data = sorted(data, key=lambda x: x["name"]) return Response(TypeCreateSerializer(data, many=True).data) @swagger_auto_schema(responses={200: UserSettingSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def user_settings(self, request: Request) -> Response: """Get all stages the user can configure""" _all_stages: Iterable[Stage] = Stage.objects.all().select_subclasses() diff --git a/authentik/flows/models.py b/authentik/flows/models.py index 3db3db439..75c0bc930 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, Optional, Type from uuid import uuid4 from django.db import models -from django.forms import ModelForm from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from model_utils.managers import InheritanceManager @@ -60,8 +59,8 @@ class Stage(SerializerModel): raise NotImplementedError @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" + def component(self) -> str: + """Return component used to edit this object""" raise NotImplementedError @property diff --git a/authentik/flows/tests/test_api.py b/authentik/flows/tests/test_api.py index 6af98198e..5eaeff42a 100644 --- a/authentik/flows/tests/test_api.py +++ b/authentik/flows/tests/test_api.py @@ -37,7 +37,7 @@ class TestFlowsAPI(APITestCase): def test_api_serializer(self): """Test that stage serializer returns the correct type""" obj = DummyStage() - self.assertEqual(StageSerializer().get_object_type(obj), "dummy") + self.assertEqual(StageSerializer().get_component(obj), "ak-stage-dummy-form") self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage") def test_api_viewset(self): @@ -90,3 +90,13 @@ class TestFlowsAPI(APITestCase): ) self.assertEqual(response.status_code, 200) self.assertJSONEqual(response.content, {"diagram": DIAGRAM_SHORT_EXPECTED}) + + def test_types(self): + """Test Stage's types endpoint""" + user = User.objects.get(username="akadmin") + self.client.force_login(user) + + response = self.client.get( + reverse("authentik_api:stage-types"), + ) + self.assertEqual(response.status_code, 200) diff --git a/authentik/flows/tests/test_models.py b/authentik/flows/tests/test_models.py deleted file mode 100644 index 864518c39..000000000 --- a/authentik/flows/tests/test_models.py +++ /dev/null @@ -1,31 +0,0 @@ -"""flow model tests""" -from typing import Callable, Type - -from django.forms import ModelForm -from django.test import TestCase - -from authentik.flows.models import Stage -from authentik.flows.stage import StageView - - -class TestStageProperties(TestCase): - """Generic model properties tests""" - - -def stage_tester_factory(model: Type[Stage]) -> Callable: - """Test a form""" - - def tester(self: TestStageProperties): - model_inst = model() - self.assertTrue(issubclass(model_inst.form, ModelForm)) - self.assertTrue(issubclass(model_inst.type, StageView)) - - return tester - - -for stage_type in Stage.__subclasses__(): - setattr( - TestStageProperties, - f"test_stage_{stage_type.__name__}", - stage_tester_factory(stage_type), - ) diff --git a/authentik/flows/transfer/common.py b/authentik/flows/transfer/common.py index eee22f10e..f9d7d79fd 100644 --- a/authentik/flows/transfer/common.py +++ b/authentik/flows/transfer/common.py @@ -22,7 +22,7 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]: "user", "verbose_name", "verbose_name_plural", - "object_type", + "component", "flow_set", "promptstage_set", ) diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py index 27db18023..3c6c7087c 100644 --- a/authentik/lib/expression/evaluator.py +++ b/authentik/lib/expression/evaluator.py @@ -3,8 +3,8 @@ import re from textwrap import indent from typing import Any, Iterable, Optional -from django.core.exceptions import ValidationError from requests import Session +from rest_framework.serializers import ValidationError from sentry_sdk.hub import Hub from sentry_sdk.tracing import Span from structlog.stdlib import get_logger diff --git a/authentik/lib/templatetags/__init__.py b/authentik/lib/templatetags/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/authentik/lib/templatetags/authentik_utils.py b/authentik/lib/templatetags/authentik_utils.py deleted file mode 100644 index a23e59dbc..000000000 --- a/authentik/lib/templatetags/authentik_utils.py +++ /dev/null @@ -1,40 +0,0 @@ -"""authentik lib Templatetags""" - -from django import template -from django.db.models import Model -from structlog.stdlib import get_logger - -register = template.Library() -LOGGER = get_logger() - - -@register.filter("fieldtype") -def fieldtype(field): - """Return classname""" - if isinstance(field.__class__, Model) or issubclass(field.__class__, Model): - return verbose_name(field) - return field.__class__.__name__ - - -@register.filter(name="css_class") -def css_class(field, css): - """Add css class to form field""" - return field.as_widget(attrs={"class": css}) - - -@register.filter -def verbose_name(obj) -> str: - """Return Object's Verbose Name""" - if not obj: - return "" - if hasattr(obj, "verbose_name"): - return obj.verbose_name - return obj._meta.verbose_name - - -@register.filter -def form_verbose_name(obj) -> str: - """Return ModelForm's Object's Verbose Name""" - if not obj: - return "" - return verbose_name(obj._meta.model) diff --git a/authentik/lib/widgets.py b/authentik/lib/widgets.py deleted file mode 100644 index f2104313b..000000000 --- a/authentik/lib/widgets.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Utility Widgets""" -from itertools import groupby - -from django.forms.models import ModelChoiceField, ModelChoiceIterator - - -class GroupedModelChoiceIterator(ModelChoiceIterator): - """ModelChoiceField which groups objects by their verbose_name""" - - def __iter__(self): - if self.field.empty_label is not None: - yield ("", self.field.empty_label) - queryset = self.queryset - # Can't use iterator() when queryset uses prefetch_related() - if not queryset._prefetch_related_lookups: - queryset = queryset.iterator() - # We can't use DB-level sorting as we sort by subclass - queryset = sorted(queryset, key=lambda x: x._meta.verbose_name) - for group, objs in groupby(queryset, key=lambda x: x._meta.verbose_name): - yield (group, [self.choice(obj) for obj in objs]) - - -class GroupedModelChoiceField(ModelChoiceField): - """ModelChoiceField which groups objects by their verbose_name""" - - iterator = GroupedModelChoiceIterator diff --git a/authentik/managed/api.py b/authentik/managed/api.py new file mode 100644 index 000000000..a3e8196c5 --- /dev/null +++ b/authentik/managed/api.py @@ -0,0 +1,8 @@ +"""Serializer mixin for managed models""" +from rest_framework.fields import CharField + + +class ManagedSerializer: + """Managed Serializer""" + + managed = CharField(read_only=True, allow_null=True) diff --git a/authentik/outposts/api/outpost_service_connections.py b/authentik/outposts/api/outpost_service_connections.py index fc545e0fa..4c121f610 100644 --- a/authentik/outposts/api/outpost_service_connections.py +++ b/authentik/outposts/api/outpost_service_connections.py @@ -1,9 +1,12 @@ """Outpost API Views""" from dataclasses import asdict -from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema -from rest_framework import mixins +from kubernetes.client.configuration import Configuration +from kubernetes.config.config_exception import ConfigException +from kubernetes.config.kube_config import load_kube_config_from_dict +from rest_framework import mixins, serializers from rest_framework.decorators import action from rest_framework.fields import BooleanField, CharField, SerializerMethodField from rest_framework.request import Request @@ -16,7 +19,6 @@ from authentik.core.api.utils import ( PassiveSerializer, TypeCreateSerializer, ) -from authentik.lib.templatetags.authentik_utils import verbose_name from authentik.lib.utils.reflection import all_subclasses from authentik.outposts.models import ( DockerServiceConnection, @@ -28,11 +30,11 @@ from authentik.outposts.models import ( class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): """ServiceConnection Serializer""" - object_type = SerializerMethodField() + component = SerializerMethodField() - def get_object_type(self, obj: OutpostServiceConnection) -> str: - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("serviceconnection", "") + def get_component(self, obj: OutpostServiceConnection) -> str: + """Get object component so that we know how to edit the object""" + return obj.component class Meta: @@ -41,7 +43,7 @@ class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): "pk", "name", "local", - "object_type", + "component", "verbose_name", "verbose_name_plural", ] @@ -68,23 +70,24 @@ class ServiceConnectionViewSet( filterset_fields = ["name"] @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def types(self, request: Request) -> Response: """Get all creatable service connection types""" data = [] for subclass in all_subclasses(self.queryset.model): + subclass: OutpostServiceConnection + # pyright: reportGeneralTypeIssues=false data.append( { - "name": verbose_name(subclass), + "name": subclass._meta.verbose_name, "description": subclass.__doc__, - "link": reverse("authentik_admin:outpost-service-connection-create") - + f"?type={subclass.__name__}", + "component": subclass().component, } ) return Response(TypeCreateSerializer(data, many=True).data) @swagger_auto_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) - @action(detail=True) + @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=unused-argument, invalid-name def state(self, request: Request, pk: str) -> Response: """Get the service connection's state""" @@ -115,6 +118,24 @@ class DockerServiceConnectionViewSet(ModelViewSet): class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): """KubernetesServiceConnection Serializer""" + def validate_kubeconfig(self, kubeconfig): + """Validate kubeconfig by attempting to load it""" + if kubeconfig == {}: + if not self.validated_data["local"]: + raise serializers.ValidationError( + _( + "You can only use an empty kubeconfig when connecting to a local cluster." + ) + ) + # Empty kubeconfig is valid + return kubeconfig + config = Configuration() + try: + load_kube_config_from_dict(kubeconfig, client_configuration=config) + except ConfigException: + raise serializers.ValidationError(_("Invalid kubeconfig")) + return kubeconfig + class Meta: model = KubernetesServiceConnection diff --git a/authentik/outposts/forms.py b/authentik/outposts/forms.py deleted file mode 100644 index f8805eb75..000000000 --- a/authentik/outposts/forms.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Outpost forms""" -from django import forms -from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ -from kubernetes.client.configuration import Configuration -from kubernetes.config.config_exception import ConfigException -from kubernetes.config.kube_config import load_kube_config_from_dict - -from authentik.admin.fields import CodeMirrorWidget, YAMLField -from authentik.crypto.models import CertificateKeyPair -from authentik.outposts.models import ( - DockerServiceConnection, - KubernetesServiceConnection, -) - - -class DockerServiceConnectionForm(forms.ModelForm): - """Docker service-connection form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["tls_authentication"].queryset = CertificateKeyPair.objects.filter( - key_data__isnull=False - ) - - class Meta: - - model = DockerServiceConnection - fields = ["name", "local", "url", "tls_verification", "tls_authentication"] - widgets = { - "name": forms.TextInput, - "url": forms.TextInput, - } - labels = { - "url": _("URL"), - "tls_verification": _("TLS Verification Certificate"), - "tls_authentication": _("TLS Authentication Certificate"), - } - - -class KubernetesServiceConnectionForm(forms.ModelForm): - """Kubernetes service-connection form""" - - def clean_kubeconfig(self): - """Validate kubeconfig by attempting to load it""" - kubeconfig = self.cleaned_data["kubeconfig"] - if kubeconfig == {}: - if not self.cleaned_data["local"]: - raise ValidationError( - _("You can only use an empty kubeconfig when local is enabled.") - ) - # Empty kubeconfig is valid - return kubeconfig - config = Configuration() - try: - load_kube_config_from_dict(kubeconfig, client_configuration=config) - except ConfigException: - raise ValidationError(_("Invalid kubeconfig")) - return kubeconfig - - class Meta: - - model = KubernetesServiceConnection - fields = [ - "name", - "local", - "kubeconfig", - ] - widgets = { - "name": forms.TextInput, - "kubeconfig": CodeMirrorWidget, - } - field_classes = { - "kubeconfig": YAMLField, - } diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 9b0680010..442f81371 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -1,14 +1,13 @@ """Outpost models""" from dataclasses import asdict, dataclass, field from datetime import datetime -from typing import Iterable, Optional, Type, Union +from typing import Iterable, Optional, Union from uuid import uuid4 from dacite import from_dict from django.core.cache import cache from django.db import models, transaction from django.db.models.base import Model -from django.forms.models import ModelForm from django.utils.translation import gettext_lazy as _ from docker.client import DockerClient from docker.errors import DockerException @@ -132,8 +131,8 @@ class OutpostServiceConnection(models.Model): raise NotImplementedError @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" + def component(self) -> str: + """Return component used to edit this object""" raise NotImplementedError class Meta: @@ -180,10 +179,8 @@ class DockerServiceConnection(OutpostServiceConnection): ) @property - def form(self) -> Type[ModelForm]: - from authentik.outposts.forms import DockerServiceConnectionForm - - return DockerServiceConnectionForm + def component(self) -> str: + return "ak-service-connection-docker-form" def __str__(self) -> str: return f"Docker Service-Connection {self.name}" @@ -237,10 +234,8 @@ class KubernetesServiceConnection(OutpostServiceConnection): ) @property - def form(self) -> Type[ModelForm]: - from authentik.outposts.forms import KubernetesServiceConnectionForm - - return KubernetesServiceConnectionForm + def component(self) -> str: + return "ak-service-connection-kubernetes-form" def __str__(self) -> str: return f"Kubernetes Service-Connection {self.name}" diff --git a/authentik/admin/views/__init__.py b/authentik/outposts/tests/__init__.py similarity index 100% rename from authentik/admin/views/__init__.py rename to authentik/outposts/tests/__init__.py diff --git a/authentik/outposts/tests/test_api.py b/authentik/outposts/tests/test_api.py new file mode 100644 index 000000000..841f52a15 --- /dev/null +++ b/authentik/outposts/tests/test_api.py @@ -0,0 +1,24 @@ +"""Test outpost service connection API""" +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import PropertyMapping, User + + +class TestOutpostServiceConnectionsAPI(APITestCase): + """Test outpost service connection API""" + + def setUp(self) -> None: + super().setUp() + self.mapping = PropertyMapping.objects.create( + name="dummy", expression="""return {'foo': 'bar'}""" + ) + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_types(self): + """Test OutpostServiceConnections's types endpoint""" + response = self.client.get( + reverse("authentik_api:outpostserviceconnection-types"), + ) + self.assertEqual(response.status_code, 200) diff --git a/authentik/outposts/tests.py b/authentik/outposts/tests/test_sa.py similarity index 100% rename from authentik/outposts/tests.py rename to authentik/outposts/tests/test_sa.py diff --git a/authentik/policies/api/policies.py b/authentik/policies/api/policies.py index 012fe262d..7cde0b361 100644 --- a/authentik/policies/api/policies.py +++ b/authentik/policies/api/policies.py @@ -1,6 +1,5 @@ """policy API Views""" from django.core.cache import cache -from django.urls import reverse from drf_yasg.utils import no_body, swagger_auto_schema from guardian.shortcuts import get_objects_for_user from rest_framework import mixins @@ -19,7 +18,6 @@ from authentik.core.api.utils import ( MetaNameSerializer, TypeCreateSerializer, ) -from authentik.lib.templatetags.authentik_utils import verbose_name from authentik.lib.utils.reflection import all_subclasses from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer from authentik.policies.models import Policy, PolicyBinding @@ -34,16 +32,16 @@ class PolicySerializer(ModelSerializer, MetaNameSerializer): _resolve_inheritance: bool - object_type = SerializerMethodField() + component = SerializerMethodField() bound_to = SerializerMethodField() def __init__(self, *args, resolve_inheritance: bool = True, **kwargs): super().__init__(*args, **kwargs) self._resolve_inheritance = resolve_inheritance - def get_object_type(self, obj: Policy) -> str: - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("policy", "") + def get_component(self, obj: Policy) -> str: + """Get object component so that we know how to edit the object""" + return obj.component def get_bound_to(self, obj: Policy) -> int: """Return objects policy is bound to""" @@ -66,7 +64,7 @@ class PolicySerializer(ModelSerializer, MetaNameSerializer): "pk", "name", "execution_logging", - "object_type", + "component", "verbose_name", "verbose_name_plural", "bound_to", @@ -96,24 +94,24 @@ class PolicyViewSet( ) @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def types(self, request: Request) -> Response: """Get all creatable policy types""" data = [] for subclass in all_subclasses(self.queryset.model): + subclass: Policy data.append( { - "name": verbose_name(subclass), + "name": subclass._meta.verbose_name, "description": subclass.__doc__, - "link": reverse("authentik_admin:policy-create") - + f"?type={subclass.__name__}", + "component": subclass().component, } ) return Response(TypeCreateSerializer(data, many=True).data) @permission_required("authentik_policies.view_policy_cache") @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) - @action(detail=False) + @action(detail=False, pagination_class=None, filter_backends=[]) def cache_info(self, request: Request) -> Response: """Info about cached policies""" return Response(data={"count": len(cache.keys("policy_*"))}) @@ -139,7 +137,7 @@ class PolicyViewSet( request_body=PolicyTestSerializer(), responses={200: PolicyTestResultSerializer()}, ) - @action(detail=True, methods=["POST"]) + @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) # pylint: disable=unused-argument, invalid-name def test(self, request: Request, pk: str) -> Response: """Test policy""" diff --git a/authentik/policies/dummy/forms.py b/authentik/policies/dummy/forms.py deleted file mode 100644 index ed6dae995..000000000 --- a/authentik/policies/dummy/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -"""authentik Policy forms""" - -from django import forms -from django.utils.translation import gettext as _ - -from authentik.policies.dummy.models import DummyPolicy -from authentik.policies.forms import PolicyForm - - -class DummyPolicyForm(PolicyForm): - """DummyPolicyForm Form""" - - class Meta: - - model = DummyPolicy - fields = PolicyForm.Meta.fields + ["result", "wait_min", "wait_max"] - widgets = { - "name": forms.TextInput(), - } - labels = {"result": _("Allow user")} diff --git a/authentik/policies/dummy/models.py b/authentik/policies/dummy/models.py index ee78e7a55..a682d0cc2 100644 --- a/authentik/policies/dummy/models.py +++ b/authentik/policies/dummy/models.py @@ -1,10 +1,8 @@ """Dummy policy""" from random import SystemRandom from time import sleep -from typing import Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger @@ -32,10 +30,8 @@ class DummyPolicy(Policy): return DummyPolicySerializer @property - def form(self) -> Type[ModelForm]: - from authentik.policies.dummy.forms import DummyPolicyForm - - return DummyPolicyForm + def component(self) -> str: # pragma: no cover + return "ak-policy-dummy-form" def passes(self, request: PolicyRequest) -> PolicyResult: """Wait random time then return result""" diff --git a/authentik/policies/dummy/tests.py b/authentik/policies/dummy/tests.py index 8d0cefd5c..c433d0d39 100644 --- a/authentik/policies/dummy/tests.py +++ b/authentik/policies/dummy/tests.py @@ -2,7 +2,6 @@ from django.test import TestCase from guardian.shortcuts import get_anonymous_user -from authentik.policies.dummy.forms import DummyPolicyForm from authentik.policies.dummy.models import DummyPolicy from authentik.policies.engine import PolicyRequest @@ -22,18 +21,3 @@ class TestDummyPolicy(TestCase): result = policy.passes(self.request) self.assertFalse(result.passing) self.assertEqual(result.messages, ("dummy",)) - - def test_form(self): - """test form""" - form = DummyPolicyForm( - data={ - "name": "dummy", - "negate": False, - "order": 0, - "timeout": 1, - "result": True, - "wait_min": 1, - "wait_max": 2, - } - ) - self.assertTrue(form.is_valid()) diff --git a/authentik/policies/event_matcher/forms.py b/authentik/policies/event_matcher/forms.py deleted file mode 100644 index e9707f5db..000000000 --- a/authentik/policies/event_matcher/forms.py +++ /dev/null @@ -1,25 +0,0 @@ -"""authentik Event Matcher Policy forms""" - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from authentik.policies.event_matcher.models import EventMatcherPolicy -from authentik.policies.forms import PolicyForm - - -class EventMatcherPolicyForm(PolicyForm): - """EventMatcherPolicy Form""" - - class Meta: - - model = EventMatcherPolicy - fields = PolicyForm.Meta.fields + [ - "action", - "client_ip", - "app", - ] - widgets = { - "name": forms.TextInput(), - "client_ip": forms.TextInput(), - } - labels = {"client_ip": _("Client IP")} diff --git a/authentik/policies/event_matcher/models.py b/authentik/policies/event_matcher/models.py index 76930c14f..e1aeefb73 100644 --- a/authentik/policies/event_matcher/models.py +++ b/authentik/policies/event_matcher/models.py @@ -1,9 +1,6 @@ """Event Matcher models""" -from typing import Type - from django.apps import apps from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext as _ from rest_framework.serializers import BaseSerializer @@ -63,10 +60,8 @@ class EventMatcherPolicy(Policy): return EventMatcherPolicySerializer @property - def form(self) -> Type[ModelForm]: - from authentik.policies.event_matcher.forms import EventMatcherPolicyForm - - return EventMatcherPolicyForm + def component(self) -> str: + return "ak-policy-event-matcher-form" def passes(self, request: PolicyRequest) -> PolicyResult: if "event" not in request.context: diff --git a/authentik/policies/expiry/forms.py b/authentik/policies/expiry/forms.py deleted file mode 100644 index 08631358d..000000000 --- a/authentik/policies/expiry/forms.py +++ /dev/null @@ -1,22 +0,0 @@ -"""authentik PasswordExpiry Policy forms""" - -from django import forms -from django.utils.translation import gettext as _ - -from authentik.policies.expiry.models import PasswordExpiryPolicy -from authentik.policies.forms import PolicyForm - - -class PasswordExpiryPolicyForm(PolicyForm): - """Edit PasswordExpiryPolicy instances""" - - class Meta: - - model = PasswordExpiryPolicy - fields = PolicyForm.Meta.fields + ["days", "deny_only"] - widgets = { - "name": forms.TextInput(), - "order": forms.NumberInput(), - "days": forms.NumberInput(), - } - labels = {"deny_only": _("Only fail the policy, don't set user's password.")} diff --git a/authentik/policies/expiry/models.py b/authentik/policies/expiry/models.py index eca0d9d63..dd76d91af 100644 --- a/authentik/policies/expiry/models.py +++ b/authentik/policies/expiry/models.py @@ -1,9 +1,7 @@ """authentik password_expiry_policy Models""" from datetime import timedelta -from typing import Type from django.db import models -from django.forms import ModelForm from django.utils.timezone import now from django.utils.translation import gettext as _ from rest_framework.serializers import BaseSerializer @@ -29,10 +27,8 @@ class PasswordExpiryPolicy(Policy): return PasswordExpiryPolicySerializer @property - def form(self) -> Type[ModelForm]: - from authentik.policies.expiry.forms import PasswordExpiryPolicyForm - - return PasswordExpiryPolicyForm + def component(self) -> str: + return "ak-policy-password-expiry-form" def passes(self, request: PolicyRequest) -> PolicyResult: """If password change date is more than x days in the past, call set_unusable_password diff --git a/authentik/policies/expression/api.py b/authentik/policies/expression/api.py index 1ef7a53a8..d4975e097 100644 --- a/authentik/policies/expression/api.py +++ b/authentik/policies/expression/api.py @@ -2,12 +2,19 @@ from rest_framework.viewsets import ModelViewSet from authentik.policies.api.policies import PolicySerializer +from authentik.policies.expression.evaluator import PolicyEvaluator from authentik.policies.expression.models import ExpressionPolicy class ExpressionPolicySerializer(PolicySerializer): """Group Membership Policy Serializer""" + def validate_expression(self, expr: str) -> str: + """validate the syntax of the expression""" + name = "temp-policy" if not self.instance else self.instance.name + PolicyEvaluator(name).validate(expr) + return expr + class Meta: model = ExpressionPolicy fields = PolicySerializer.Meta.fields + ["expression"] diff --git a/authentik/policies/expression/forms.py b/authentik/policies/expression/forms.py deleted file mode 100644 index 4bf2c9a5a..000000000 --- a/authentik/policies/expression/forms.py +++ /dev/null @@ -1,31 +0,0 @@ -"""authentik Expression Policy forms""" - -from django import forms - -from authentik.admin.fields import CodeMirrorWidget -from authentik.policies.expression.evaluator import PolicyEvaluator -from authentik.policies.expression.models import ExpressionPolicy -from authentik.policies.forms import PolicyForm - - -class ExpressionPolicyForm(PolicyForm): - """ExpressionPolicy Form""" - - template_name = "policy/expression/form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - PolicyEvaluator(self.instance.name).validate(expression) - return expression - - class Meta: - - model = ExpressionPolicy - fields = PolicyForm.Meta.fields + [ - "expression", - ] - widgets = { - "name": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } diff --git a/authentik/policies/expression/models.py b/authentik/policies/expression/models.py index f4a114954..755be2fbd 100644 --- a/authentik/policies/expression/models.py +++ b/authentik/policies/expression/models.py @@ -1,8 +1,5 @@ """authentik expression Policy Models""" -from typing import Type - from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext as _ from rest_framework.serializers import BaseSerializer @@ -23,10 +20,8 @@ class ExpressionPolicy(Policy): return ExpressionPolicySerializer @property - def form(self) -> Type[ModelForm]: - from authentik.policies.expression.forms import ExpressionPolicyForm - - return ExpressionPolicyForm + def component(self) -> str: + return "ak-policy-expression-form" def passes(self, request: PolicyRequest) -> PolicyResult: """Evaluate and render expression. Returns PolicyResult(false) on error.""" diff --git a/authentik/policies/expression/templates/policy/expression/form.html b/authentik/policies/expression/templates/policy/expression/form.html deleted file mode 100644 index 540ddf10b..000000000 --- a/authentik/policies/expression/templates/policy/expression/form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/authentik/policies/expression/tests.py b/authentik/policies/expression/tests.py index ceb80bd3a..1b1b1a279 100644 --- a/authentik/policies/expression/tests.py +++ b/authentik/policies/expression/tests.py @@ -1,9 +1,11 @@ """evaluator tests""" -from django.core.exceptions import ValidationError from django.test import TestCase from guardian.shortcuts import get_anonymous_user +from rest_framework.serializers import ValidationError +from rest_framework.test import APITestCase from authentik.policies.exceptions import PolicyException +from authentik.policies.expression.api import ExpressionPolicySerializer from authentik.policies.expression.evaluator import PolicyEvaluator from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.types import PolicyRequest @@ -60,3 +62,16 @@ class TestEvaluator(TestCase): evaluator = PolicyEvaluator("test") with self.assertRaises(ValidationError): evaluator.validate(template) + + +class TestExpressionPolicyAPI(APITestCase): + """Test expression policy's API""" + + def test_validate(self): + """Test ExpressionPolicy's validation""" + # Because the root property-mapping has no write operation, we just instantiate + # a serializer and test inline + expr = "return True" + self.assertEqual(ExpressionPolicySerializer().validate_expression(expr), expr) + with self.assertRaises(ValidationError): + print(ExpressionPolicySerializer().validate_expression("/")) diff --git a/authentik/policies/forms.py b/authentik/policies/forms.py deleted file mode 100644 index 656358fbc..000000000 --- a/authentik/policies/forms.py +++ /dev/null @@ -1,42 +0,0 @@ -"""General fields""" - -from django import forms - -from authentik.core.models import Group -from authentik.lib.widgets import GroupedModelChoiceField -from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel - - -class PolicyBindingForm(forms.ModelForm): - """Form to edit Policy to PolicyBindingModel Binding""" - - target = GroupedModelChoiceField( - queryset=PolicyBindingModel.objects.all().select_subclasses(), - to_field_name="pbm_uuid", - ) - policy = GroupedModelChoiceField( - queryset=Policy.objects.all().order_by("name").select_subclasses(), - required=False, - ) - group = forms.ModelChoiceField( - queryset=Group.objects.all().order_by("name"), required=False - ) - - def __init__(self, *args, **kwargs): # pragma: no cover - super().__init__(*args, **kwargs) - if "target" in self.initial: - self.fields["target"].widget = forms.HiddenInput() - - class Meta: - - model = PolicyBinding - fields = ["enabled", "policy", "group", "user", "target", "order", "timeout"] - - -class PolicyForm(forms.ModelForm): - """Base Policy form""" - - class Meta: - - model = Policy - fields = ["name", "execution_logging"] diff --git a/authentik/policies/hibp/forms.py b/authentik/policies/hibp/forms.py deleted file mode 100644 index 62708ac9a..000000000 --- a/authentik/policies/hibp/forms.py +++ /dev/null @@ -1,19 +0,0 @@ -"""authentik HaveIBeenPwned Policy forms""" - -from django import forms - -from authentik.policies.forms import PolicyForm -from authentik.policies.hibp.models import HaveIBeenPwendPolicy - - -class HaveIBeenPwnedPolicyForm(PolicyForm): - """Edit HaveIBeenPwendPolicy instances""" - - class Meta: - - model = HaveIBeenPwendPolicy - fields = PolicyForm.Meta.fields + ["password_field", "allowed_count"] - widgets = { - "name": forms.TextInput(), - "password_field": forms.TextInput(), - } diff --git a/authentik/policies/hibp/models.py b/authentik/policies/hibp/models.py index de448cd46..4b2032a1d 100644 --- a/authentik/policies/hibp/models.py +++ b/authentik/policies/hibp/models.py @@ -1,9 +1,7 @@ """authentik HIBP Models""" from hashlib import sha1 -from typing import Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext as _ from requests import get from rest_framework.serializers import BaseSerializer @@ -35,10 +33,8 @@ class HaveIBeenPwendPolicy(Policy): return HaveIBeenPwendPolicySerializer @property - def form(self) -> Type[ModelForm]: - from authentik.policies.hibp.forms import HaveIBeenPwnedPolicyForm - - return HaveIBeenPwnedPolicyForm + def component(self) -> str: + return "ak-policy-hibp-form" def passes(self, request: PolicyRequest) -> PolicyResult: """Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5 diff --git a/authentik/policies/models.py b/authentik/policies/models.py index 19045ae78..e7f0a71b6 100644 --- a/authentik/policies/models.py +++ b/authentik/policies/models.py @@ -1,9 +1,7 @@ """Policy base models""" -from typing import Type from uuid import uuid4 from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from model_utils.managers import InheritanceManager from rest_framework.serializers import BaseSerializer @@ -147,8 +145,8 @@ class Policy(SerializerModel, CreatedUpdatedModel): objects = InheritanceAutoManager() @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" + def component(self) -> str: + """Return component used to edit this object""" raise NotImplementedError def __str__(self): diff --git a/authentik/policies/password/forms.py b/authentik/policies/password/forms.py deleted file mode 100644 index df2de293f..000000000 --- a/authentik/policies/password/forms.py +++ /dev/null @@ -1,36 +0,0 @@ -"""authentik Policy forms""" - -from django import forms -from django.utils.translation import gettext as _ - -from authentik.policies.forms import PolicyForm -from authentik.policies.password.models import PasswordPolicy - - -class PasswordPolicyForm(PolicyForm): - """PasswordPolicy Form""" - - class Meta: - - model = PasswordPolicy - fields = PolicyForm.Meta.fields + [ - "password_field", - "amount_uppercase", - "amount_lowercase", - "amount_symbols", - "length_min", - "symbol_charset", - "error_message", - ] - widgets = { - "name": forms.TextInput(), - "password_field": forms.TextInput(), - "symbol_charset": forms.TextInput(), - "error_message": forms.TextInput(), - } - labels = { - "amount_uppercase": _("Minimum amount of Uppercase Characters"), - "amount_lowercase": _("Minimum amount of Lowercase Characters"), - "amount_symbols": _("Minimum amount of Symbols Characters"), - "length_min": _("Minimum Length"), - } diff --git a/authentik/policies/password/models.py b/authentik/policies/password/models.py index e66957cfe..286033eec 100644 --- a/authentik/policies/password/models.py +++ b/authentik/policies/password/models.py @@ -1,9 +1,7 @@ """user field matcher models""" import re -from typing import Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext as _ from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger @@ -38,10 +36,8 @@ class PasswordPolicy(Policy): return PasswordPolicySerializer @property - def form(self) -> Type[ModelForm]: - from authentik.policies.password.forms import PasswordPolicyForm - - return PasswordPolicyForm + def component(self) -> str: + return "ak-policy-password-form" def passes(self, request: PolicyRequest) -> PolicyResult: if self.password_field not in request.context: diff --git a/authentik/policies/reputation/forms.py b/authentik/policies/reputation/forms.py deleted file mode 100644 index c3adecb16..000000000 --- a/authentik/policies/reputation/forms.py +++ /dev/null @@ -1,22 +0,0 @@ -"""authentik reputation request forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ - -from authentik.policies.forms import PolicyForm -from authentik.policies.reputation.models import ReputationPolicy - - -class ReputationPolicyForm(PolicyForm): - """Form to edit ReputationPolicy""" - - class Meta: - - model = ReputationPolicy - fields = PolicyForm.Meta.fields + ["check_ip", "check_username", "threshold"] - widgets = { - "name": forms.TextInput(), - "value": forms.TextInput(), - } - labels = { - "check_ip": _("Check IP"), - } diff --git a/authentik/policies/reputation/models.py b/authentik/policies/reputation/models.py index 2dfaa834e..4f8bfdc6b 100644 --- a/authentik/policies/reputation/models.py +++ b/authentik/policies/reputation/models.py @@ -1,9 +1,6 @@ """authentik reputation request policy""" -from typing import Type - from django.core.cache import cache from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext as _ from rest_framework.serializers import BaseSerializer @@ -30,10 +27,8 @@ class ReputationPolicy(Policy): return ReputationPolicySerializer @property - def form(self) -> Type[ModelForm]: - from authentik.policies.reputation.forms import ReputationPolicyForm - - return ReputationPolicyForm + def component(self) -> str: + return "ak-policy-reputation-form" def passes(self, request: PolicyRequest) -> PolicyResult: remote_ip = get_client_ip(request.http_request) or "255.255.255.255" diff --git a/authentik/policies/templates/policies/denied.html b/authentik/policies/templates/policies/denied.html index eb942e7c6..aa6b08095 100644 --- a/authentik/policies/templates/policies/denied.html +++ b/authentik/policies/templates/policies/denied.html @@ -2,7 +2,6 @@ {% load static %} {% load i18n %} -{% load authentik_utils %} {% block card_title %} {% trans 'Permission denied' %} diff --git a/authentik/policies/tests/test_models.py b/authentik/policies/tests/test_models.py deleted file mode 100644 index 3e13b8528..000000000 --- a/authentik/policies/tests/test_models.py +++ /dev/null @@ -1,30 +0,0 @@ -"""flow model tests""" -from typing import Callable, Type - -from django.forms import ModelForm -from django.test import TestCase - -from authentik.lib.utils.reflection import all_subclasses -from authentik.policies.models import Policy - - -class TestPolicyProperties(TestCase): - """Generic model properties tests""" - - -def policy_tester_factory(model: Type[Policy]) -> Callable: - """Test a form""" - - def tester(self: TestPolicyProperties): - model_inst = model() - self.assertTrue(issubclass(model_inst.form, ModelForm)) - - return tester - - -for policy_type in all_subclasses(Policy): - setattr( - TestPolicyProperties, - f"test_policy_{policy_type.__name__}", - policy_tester_factory(policy_type), - ) diff --git a/authentik/policies/tests/test_policies_api.py b/authentik/policies/tests/test_policies_api.py index 8c3abc941..fc20a0177 100644 --- a/authentik/policies/tests/test_policies_api.py +++ b/authentik/policies/tests/test_policies_api.py @@ -26,3 +26,10 @@ class TestPoliciesAPI(APITestCase): self.assertJSONEqual( response.content.decode(), {"passing": True, "messages": ["dummy"]} ) + + def test_types(self): + """Test Policy's types endpoint""" + response = self.client.get( + reverse("authentik_api:policy-types"), + ) + self.assertEqual(response.status_code, 200) diff --git a/authentik/providers/oauth2/api/provider.py b/authentik/providers/oauth2/api/provider.py index 2e9046529..4a6e5d05d 100644 --- a/authentik/providers/oauth2/api/provider.py +++ b/authentik/providers/oauth2/api/provider.py @@ -1,22 +1,35 @@ """OAuth2Provider API Views""" from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import action from rest_framework.fields import ReadOnlyField from rest_framework.generics import get_object_or_404 from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import Serializer +from rest_framework.serializers import ValidationError from rest_framework.viewsets import ModelViewSet from authentik.core.api.providers import ProviderSerializer +from authentik.core.api.utils import PassiveSerializer from authentik.core.models import Provider -from authentik.providers.oauth2.models import OAuth2Provider +from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider class OAuth2ProviderSerializer(ProviderSerializer): """OAuth2Provider Serializer""" + def validate_jwt_alg(self, value): + """Ensure that when RS256 is selected, a certificate-key-pair is selected""" + if ( + self.initial_data.get("rsa_key", None) is None + and value == JWTAlgorithms.RS256 + ): + raise ValidationError( + _("RS256 requires a Certificate-Key-Pair to be selected.") + ) + return value + class Meta: model = OAuth2Provider @@ -36,7 +49,7 @@ class OAuth2ProviderSerializer(ProviderSerializer): ] -class OAuth2ProviderSetupURLs(Serializer): +class OAuth2ProviderSetupURLs(PassiveSerializer): """OAuth2 Provider Metadata serializer""" issuer = ReadOnlyField() @@ -46,12 +59,6 @@ class OAuth2ProviderSetupURLs(Serializer): provider_info = ReadOnlyField() logout = ReadOnlyField() - def create(self, request: Request) -> Response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - class OAuth2ProviderViewSet(ModelViewSet): """OAuth2Provider Viewset""" diff --git a/authentik/providers/oauth2/api/scope.py b/authentik/providers/oauth2/api/scope.py index 3c4d6a077..6ddc12310 100644 --- a/authentik/providers/oauth2/api/scope.py +++ b/authentik/providers/oauth2/api/scope.py @@ -1,25 +1,19 @@ """OAuth2Provider API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet -from authentik.core.api.utils import MetaNameSerializer +from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.providers.oauth2.models import ScopeMapping -class ScopeMappingSerializer(ModelSerializer, MetaNameSerializer): +class ScopeMappingSerializer(PropertyMappingSerializer): """ScopeMapping Serializer""" class Meta: model = ScopeMapping - fields = [ - "pk", - "name", + fields = PropertyMappingSerializer.Meta.fields + [ "scope_name", "description", - "expression", - "verbose_name", - "verbose_name_plural", ] diff --git a/authentik/providers/oauth2/forms.py b/authentik/providers/oauth2/forms.py deleted file mode 100644 index 92a1dd067..000000000 --- a/authentik/providers/oauth2/forms.py +++ /dev/null @@ -1,101 +0,0 @@ -"""authentik OAuth2 Provider Forms""" - -from django import forms -from django.core.exceptions import ValidationError -from django.utils.translation import gettext as _ - -from authentik.admin.fields import CodeMirrorWidget -from authentik.core.expression import PropertyMappingEvaluator -from authentik.crypto.models import CertificateKeyPair -from authentik.flows.models import Flow, FlowDesignation -from authentik.providers.oauth2.generators import ( - generate_client_id, - generate_client_secret, -) -from authentik.providers.oauth2.models import ( - JWTAlgorithms, - OAuth2Provider, - ScopeMapping, -) - - -class OAuth2ProviderForm(forms.ModelForm): - """OAuth2 Provider form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authorization_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHORIZATION - ) - self.fields["client_id"].initial = generate_client_id() - self.fields["client_secret"].initial = generate_client_secret() - self.fields["rsa_key"].queryset = CertificateKeyPair.objects.exclude( - key_data__exact="" - ) - self.fields["property_mappings"].queryset = ScopeMapping.objects.all() - - def clean_jwt_alg(self): - """Ensure that when RS256 is selected, a certificate-key-pair is selected""" - if ( - self.data["rsa_key"] == "" - and self.cleaned_data["jwt_alg"] == JWTAlgorithms.RS256 - ): - raise ValidationError( - _("RS256 requires a Certificate-Key-Pair to be selected.") - ) - return self.cleaned_data["jwt_alg"] - - class Meta: - model = OAuth2Provider - fields = [ - "name", - "authorization_flow", - "client_type", - "client_id", - "client_secret", - "token_validity", - "jwt_alg", - "property_mappings", - "rsa_key", - "redirect_uris", - "sub_mode", - "include_claims_in_id_token", - "issuer_mode", - ] - widgets = { - "name": forms.TextInput(), - "token_validity": forms.TextInput(), - } - labels = {"property_mappings": _("Scopes")} - help_texts = { - "property_mappings": _( - ( - "Select which scopes can be used by the client. " - "The client stil has to specify the scope to access the data." - ) - ) - } - - -class ScopeMappingForm(forms.ModelForm): - """Form to edit ScopeMappings""" - - template_name = "providers/oauth2/property_mapping_form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - evaluator = PropertyMappingEvaluator() - evaluator.validate(expression) - return expression - - class Meta: - - model = ScopeMapping - fields = ["name", "scope_name", "description", "expression"] - widgets = { - "name": forms.TextInput(), - "scope_name": forms.TextInput(), - "description": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 1539dbf2a..d8594cd11 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -13,7 +13,6 @@ from uuid import uuid4 from dacite import from_dict from django.conf import settings from django.db import models -from django.forms import ModelForm from django.http import HttpRequest from django.utils import dateformat, timezone from django.utils.translation import gettext_lazy as _ @@ -112,10 +111,8 @@ class ScopeMapping(PropertyMapping): ) @property - def form(self) -> Type[ModelForm]: - from authentik.providers.oauth2.forms import ScopeMappingForm - - return ScopeMappingForm + def component(self) -> str: + return "ak-property-mapping-scope-form" @property def serializer(self) -> Type[Serializer]: @@ -285,18 +282,16 @@ class OAuth2Provider(Provider): launch_url = urlparse(main_url) return main_url.replace(launch_url.path, "") + @property + def component(self) -> str: + return "ak-provider-oauth2-form" + @property def serializer(self) -> Type[Serializer]: from authentik.providers.oauth2.api.provider import OAuth2ProviderSerializer return OAuth2ProviderSerializer - @property - def form(self) -> Type[ModelForm]: - from authentik.providers.oauth2.forms import OAuth2ProviderForm - - return OAuth2ProviderForm - def __str__(self): return f"OAuth2 Provider {self.name}" diff --git a/authentik/providers/oauth2/templates/providers/oauth2/end_session.html b/authentik/providers/oauth2/templates/providers/oauth2/end_session.html index a71cd59c4..acf5936d5 100644 --- a/authentik/providers/oauth2/templates/providers/oauth2/end_session.html +++ b/authentik/providers/oauth2/templates/providers/oauth2/end_session.html @@ -2,7 +2,6 @@ {% load static %} {% load i18n %} -{% load authentik_utils %} {% block head %} {{ block.super }} diff --git a/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html b/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html deleted file mode 100644 index 030095abe..000000000 --- a/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/authentik/providers/oauth2/tests/test_api.py b/authentik/providers/oauth2/tests/test_api.py new file mode 100644 index 000000000..6ff1dbfb5 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_api.py @@ -0,0 +1,37 @@ +"""Test oauth2 provider API""" +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import User +from authentik.flows.models import Flow, FlowDesignation +from authentik.providers.oauth2.models import JWTAlgorithms + + +class TestOAuth2ProviderAPI(APITestCase): + """Test oauth2 provider API""" + + def setUp(self) -> None: + super().setUp() + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_validate(self): + """Test OAuth2 Provider validation""" + response = self.client.post( + reverse( + "authentik_api:oauth2provider-list", + ), + data={ + "name": "test", + "jwt_alg": str(JWTAlgorithms.RS256), + "authorization_flow": Flow.objects.filter( + designation=FlowDesignation.AUTHORIZATION + ) + .first() + .pk, + }, + ) + self.assertJSONEqual( + response.content.decode(), + {"jwt_alg": ["RS256 requires a Certificate-Key-Pair to be selected."]}, + ) diff --git a/authentik/providers/proxy/api.py b/authentik/providers/proxy/api.py index ff9fc94a2..1fc0a56f2 100644 --- a/authentik/providers/proxy/api.py +++ b/authentik/providers/proxy/api.py @@ -1,17 +1,16 @@ """ProxyProvider API Views""" from drf_yasg.utils import swagger_serializer_method from rest_framework.fields import CharField, ListField, SerializerMethodField -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer, Serializer +from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet from authentik.core.api.providers import ProviderSerializer +from authentik.core.api.utils import PassiveSerializer from authentik.providers.oauth2.views.provider import ProviderInfoView from authentik.providers.proxy.models import ProxyProvider -class OpenIDConnectConfigurationSerializer(Serializer): +class OpenIDConnectConfigurationSerializer(PassiveSerializer): """rest_framework Serializer for OIDC Configuration""" issuer = CharField() @@ -27,12 +26,6 @@ class OpenIDConnectConfigurationSerializer(Serializer): subject_types_supported = ListField(child=CharField()) token_endpoint_auth_methods_supported = ListField(child=CharField()) - def create(self, request: Request) -> Response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - class ProxyProviderSerializer(ProviderSerializer): """ProxyProvider Serializer""" diff --git a/authentik/providers/proxy/forms.py b/authentik/providers/proxy/forms.py deleted file mode 100644 index e1433d6bc..000000000 --- a/authentik/providers/proxy/forms.py +++ /dev/null @@ -1,50 +0,0 @@ -"""authentik Proxy Provider Forms""" -from django import forms - -from authentik.crypto.models import CertificateKeyPair -from authentik.flows.models import Flow, FlowDesignation -from authentik.providers.proxy.models import ProxyProvider - - -class ProxyProviderForm(forms.ModelForm): - """Proxy Provider form""" - - instance: ProxyProvider - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authorization_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHORIZATION - ) - self.fields["certificate"].queryset = CertificateKeyPair.objects.filter( - key_data__isnull=False - ).exclude(key_data="") - - def save(self, *args, **kwargs): - actual_save = super().save(*args, **kwargs) - self.instance.set_oauth_defaults() - self.instance.save() - return actual_save - - class Meta: - - model = ProxyProvider - fields = [ - "name", - "authorization_flow", - "internal_host", - "internal_host_ssl_validation", - "external_host", - "certificate", - "skip_path_regex", - "basic_auth_enabled", - "basic_auth_user_attribute", - "basic_auth_password_attribute", - ] - widgets = { - "name": forms.TextInput(), - "internal_host": forms.TextInput(), - "external_host": forms.TextInput(), - "basic_auth_user_attribute": forms.TextInput(), - "basic_auth_password_attribute": forms.TextInput(), - } diff --git a/authentik/providers/proxy/models.py b/authentik/providers/proxy/models.py index fb19ba201..3cba47c17 100644 --- a/authentik/providers/proxy/models.py +++ b/authentik/providers/proxy/models.py @@ -5,7 +5,6 @@ from typing import Iterable, Optional, Type from urllib.parse import urljoin from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext as _ from rest_framework.serializers import Serializer @@ -102,10 +101,8 @@ class ProxyProvider(OutpostModel, OAuth2Provider): cookie_secret = models.TextField(default=get_cookie_secret) @property - def form(self) -> Type[ModelForm]: - from authentik.providers.proxy.forms import ProxyProviderForm - - return ProxyProviderForm + def component(self) -> str: + return "ak-provider-proxy-form" @property def serializer(self) -> Type[Serializer]: diff --git a/authentik/providers/saml/api.py b/authentik/providers/saml/api.py index 052de8b5c..5ca0f4463 100644 --- a/authentik/providers/saml/api.py +++ b/authentik/providers/saml/api.py @@ -1,17 +1,35 @@ """SAMLProvider API Views""" +from xml.etree.ElementTree import ParseError # nosec + +from defusedxml.ElementTree import fromstring +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import action -from rest_framework.fields import ReadOnlyField +from rest_framework.fields import CharField, FileField, ReadOnlyField +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import AllowAny +from rest_framework.relations import SlugRelatedField from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer, Serializer +from rest_framework.serializers import ValidationError from rest_framework.viewsets import ModelViewSet +from structlog.stdlib import get_logger +from authentik.api.decorators import permission_required +from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.api.providers import ProviderSerializer -from authentik.core.api.utils import MetaNameSerializer +from authentik.core.api.utils import PassiveSerializer from authentik.core.models import Provider +from authentik.flows.models import Flow, FlowDesignation from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider -from authentik.providers.saml.views.metadata import DescriptorDownloadView +from authentik.providers.saml.processors.metadata import MetadataProcessor +from authentik.providers.saml.processors.metadata_parser import ( + ServiceProviderMetadataParser, +) + +LOGGER = get_logger() class SAMLProviderSerializer(ProviderSerializer): @@ -33,19 +51,26 @@ class SAMLProviderSerializer(ProviderSerializer): "signature_algorithm", "signing_kp", "verification_kp", + "sp_binding", ] -class SAMLMetadataSerializer(Serializer): +class SAMLMetadataSerializer(PassiveSerializer): """SAML Provider Metadata serializer""" metadata = ReadOnlyField() - def create(self, request: Request) -> Response: - raise NotImplementedError - def update(self, request: Request) -> Response: - raise NotImplementedError +class SAMLProviderImportSerializer(PassiveSerializer): + """Import saml provider from XML Metadata""" + + name = CharField(required=True) + # Using SlugField because https://github.com/OpenAPITools/openapi-generator/issues/3278 + authorization_flow = SlugRelatedField( + queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION), + slug_field="slug", + ) + file = FileField() class SAMLProviderViewSet(ModelViewSet): @@ -55,32 +80,70 @@ class SAMLProviderViewSet(ModelViewSet): serializer_class = SAMLProviderSerializer @swagger_auto_schema(responses={200: SAMLMetadataSerializer(many=False)}) - @action(methods=["GET"], detail=True) + @action(methods=["GET"], detail=True, permission_classes=[AllowAny]) # pylint: disable=invalid-name, unused-argument def metadata(self, request: Request, pk: int) -> Response: """Return metadata as XML string""" - provider = self.get_object() + # We don't use self.get_object() on purpose as this view is un-authenticated + provider = get_object_or_404(SAMLProvider, pk=pk) try: - metadata = DescriptorDownloadView.get_metadata(request, provider) + metadata = MetadataProcessor(provider, request).build_entity_descriptor() + if "download" in request._request.GET: + response = HttpResponse(metadata, content_type="application/xml") + response[ + "Content-Disposition" + ] = f'attachment; filename="{provider.name}_authentik_meta.xml"' + return response return Response({"metadata": metadata}) except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member return Response({"metadata": ""}) + @permission_required( + None, + [ + "authentik_providers_saml.add_samlprovider", + "authentik_crypto.add_certificatekeypair", + ], + ) + @swagger_auto_schema( + request_body=SAMLProviderImportSerializer(), + responses={204: "Successfully imported provider", 400: "Bad request"}, + ) + @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) + def import_metadata(self, request: Request) -> Response: + """Create provider from SAML Metadata""" + data = SAMLProviderImportSerializer(data=request.data) + if not data.is_valid(): + raise ValidationError(data.errors) + file = data.validated_data["file"] + # Validate syntax first + try: + fromstring(file.read()) + except ParseError: + raise ValidationError(_("Invalid XML Syntax")) + file.seek(0) + try: + metadata = ServiceProviderMetadataParser().parse(file.read().decode()) + metadata.to_provider( + data.validated_data["name"], data.validated_data["authorization_flow"] + ) + except ValueError as exc: # pragma: no cover + LOGGER.warning(str(exc)) + return ValidationError( + _("Failed to import Metadata: %(message)s" % {"message": str(exc)}), + ) + return Response(status=204) -class SAMLPropertyMappingSerializer(ModelSerializer, MetaNameSerializer): + +class SAMLPropertyMappingSerializer(PropertyMappingSerializer): """SAMLPropertyMapping Serializer""" class Meta: model = SAMLPropertyMapping - fields = [ - "pk", - "name", + fields = PropertyMappingSerializer.Meta.fields + [ "saml_name", "friendly_name", - "expression", - "verbose_name", - "verbose_name_plural", ] diff --git a/authentik/providers/saml/forms.py b/authentik/providers/saml/forms.py deleted file mode 100644 index d70b27b61..000000000 --- a/authentik/providers/saml/forms.py +++ /dev/null @@ -1,115 +0,0 @@ -"""authentik SAML IDP Forms""" - -from xml.etree.ElementTree import ParseError # nosec - -from defusedxml.ElementTree import fromstring -from django import forms -from django.core.exceptions import ValidationError -from django.core.validators import FileExtensionValidator -from django.utils.html import mark_safe -from django.utils.translation import gettext_lazy as _ - -from authentik.admin.fields import CodeMirrorWidget -from authentik.core.expression import PropertyMappingEvaluator -from authentik.crypto.models import CertificateKeyPair -from authentik.flows.models import Flow, FlowDesignation -from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider - - -class SAMLProviderForm(forms.ModelForm): - """SAML Provider form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authorization_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHORIZATION - ) - self.fields["property_mappings"].queryset = SAMLPropertyMapping.objects.all() - self.fields["signing_kp"].queryset = CertificateKeyPair.objects.exclude( - key_data__iexact="" - ) - - class Meta: - - model = SAMLProvider - fields = [ - "name", - "authorization_flow", - "acs_url", - "issuer", - "sp_binding", - "audience", - "signing_kp", - "verification_kp", - "property_mappings", - "name_id_mapping", - "assertion_valid_not_before", - "assertion_valid_not_on_or_after", - "session_valid_not_on_or_after", - "digest_algorithm", - "signature_algorithm", - ] - widgets = { - "name": forms.TextInput(), - "audience": forms.TextInput(), - "issuer": forms.TextInput(), - "assertion_valid_not_before": forms.TextInput(), - "assertion_valid_not_on_or_after": forms.TextInput(), - "session_valid_not_on_or_after": forms.TextInput(), - } - - -class SAMLPropertyMappingForm(forms.ModelForm): - """SAML Property Mapping form""" - - template_name = "providers/saml/property_mapping_form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - evaluator = PropertyMappingEvaluator() - evaluator.validate(expression) - return expression - - class Meta: - - model = SAMLPropertyMapping - fields = ["name", "saml_name", "friendly_name", "expression"] - widgets = { - "name": forms.TextInput(), - "saml_name": forms.TextInput(), - "friendly_name": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } - help_texts = { - "saml_name": mark_safe( - _( - "URN OID used by SAML. This is optional. " - 'Reference.' - " If this property mapping is used for NameID Property, " - "this field is discarded." - ) - ), - } - - -class SAMLProviderImportForm(forms.Form): - """Create a SAML Provider from SP Metadata.""" - - provider_name = forms.CharField() - authorization_flow = forms.ModelChoiceField( - queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION) - ) - metadata = forms.FileField( - validators=[FileExtensionValidator(allowed_extensions=["xml"])] - ) - - def clean_metadata(self): - """Check if the flow is valid XML""" - metadata = self.cleaned_data["metadata"].read() - try: - fromstring(metadata) - except ParseError: - raise ValidationError(_("Invalid XML Syntax")) - self.cleaned_data["metadata"].seek(0) - return self.cleaned_data["metadata"] diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py index 77e900444..b1d77dac9 100644 --- a/authentik/providers/saml/models.py +++ b/authentik/providers/saml/models.py @@ -3,7 +3,6 @@ from typing import Optional, Type from urllib.parse import urlparse from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer from structlog.stdlib import get_logger @@ -171,10 +170,8 @@ class SAMLProvider(Provider): return SAMLProviderSerializer @property - def form(self) -> Type[ModelForm]: - from authentik.providers.saml.forms import SAMLProviderForm - - return SAMLProviderForm + def component(self) -> str: + return "ak-provider-saml-form" def __str__(self): return f"SAML Provider {self.name}" @@ -192,10 +189,8 @@ class SAMLPropertyMapping(PropertyMapping): friendly_name = models.TextField(default=None, blank=True, null=True) @property - def form(self) -> Type[ModelForm]: - from authentik.providers.saml.forms import SAMLPropertyMappingForm - - return SAMLPropertyMappingForm + def component(self) -> str: + return "ak-property-mapping-saml-form" @property def serializer(self) -> Type[Serializer]: diff --git a/authentik/providers/saml/templates/providers/saml/import.html b/authentik/providers/saml/templates/providers/saml/import.html deleted file mode 100644 index d4c72334c..000000000 --- a/authentik/providers/saml/templates/providers/saml/import.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends base_template|default:"generic/form.html" %} - -{% load i18n %} - -{% block above_form %} -

-{% trans 'Import SAML Metadata' %} -

-{% endblock %} - -{% block action %} -{% trans 'Import Metadata' %} -{% endblock %} diff --git a/authentik/providers/saml/templates/providers/saml/property_mapping_form.html b/authentik/providers/saml/templates/providers/saml/property_mapping_form.html deleted file mode 100644 index 030095abe..000000000 --- a/authentik/providers/saml/templates/providers/saml/property_mapping_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/authentik/providers/saml/tests/test_api.py b/authentik/providers/saml/tests/test_api.py new file mode 100644 index 000000000..3e34edacc --- /dev/null +++ b/authentik/providers/saml/tests/test_api.py @@ -0,0 +1,115 @@ +"""SAML Provider API Tests""" +from tempfile import TemporaryFile + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Application, User +from authentik.flows.models import Flow, FlowDesignation +from authentik.providers.saml.models import SAMLProvider +from authentik.providers.saml.tests.test_metadata import METADATA_SIMPLE + + +class TestSAMLProviderAPI(APITestCase): + """SAML Provider API Tests""" + + def setUp(self) -> None: + super().setUp() + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_metadata(self): + """Test metadata export (normal)""" + provider = SAMLProvider.objects.create( + name="test", + authorization_flow=Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ), + ) + Application.objects.create(name="test", provider=provider, slug="test") + response = self.client.get( + reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}), + ) + self.assertEqual(200, response.status_code) + + def test_metadata_download(self): + """Test metadata export (download)""" + provider = SAMLProvider.objects.create( + name="test", + authorization_flow=Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ), + ) + Application.objects.create(name="test", provider=provider, slug="test") + response = self.client.get( + reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}) + + "?download", + ) + self.assertEqual(200, response.status_code) + self.assertIn("Content-Disposition", response) + + def test_metadata_invalid(self): + """Test metadata export (invalid)""" + # Provider without application + provider = SAMLProvider.objects.create( + name="test", + authorization_flow=Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ), + ) + response = self.client.get( + reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}), + ) + self.assertEqual(200, response.status_code) + + def test_import_success(self): + """Test metadata import (success case)""" + with TemporaryFile() as metadata: + metadata.write(METADATA_SIMPLE.encode()) + metadata.seek(0) + response = self.client.post( + reverse("authentik_api:samlprovider-import-metadata"), + { + "file": metadata, + "name": "test", + "authorization_flow": Flow.objects.filter( + designation=FlowDesignation.AUTHORIZATION + ) + .first() + .slug, + }, + format="multipart", + ) + self.assertEqual(204, response.status_code) + # We don't test the actual object being created here, that has its own tests + + def test_import_failed(self): + """Test metadata import (invalid xml)""" + with TemporaryFile() as metadata: + metadata.write(b"invalid") + metadata.seek(0) + response = self.client.post( + reverse("authentik_api:samlprovider-import-metadata"), + { + "file": metadata, + "name": "test", + "authorization_flow": Flow.objects.filter( + designation=FlowDesignation.AUTHORIZATION + ) + .first() + .slug, + }, + format="multipart", + ) + self.assertEqual(400, response.status_code) + + def test_import_invalid(self): + """Test metadata import (invalid input)""" + response = self.client.post( + reverse("authentik_api:samlprovider-import-metadata"), + { + "name": "test", + }, + format="multipart", + ) + self.assertEqual(400, response.status_code) diff --git a/authentik/providers/saml/urls.py b/authentik/providers/saml/urls.py index 2b27fcdc3..26a2169a4 100644 --- a/authentik/providers/saml/urls.py +++ b/authentik/providers/saml/urls.py @@ -1,7 +1,7 @@ """authentik SAML IDP URLs""" from django.urls import path -from authentik.providers.saml.views import metadata, sso +from authentik.providers.saml.views import sso urlpatterns = [ # SSO Bindings @@ -21,9 +21,4 @@ urlpatterns = [ sso.SAMLSSOBindingInitView.as_view(), name="sso-init", ), - path( - "/metadata/", - metadata.DescriptorDownloadView.as_view(), - name="metadata", - ), ] diff --git a/authentik/providers/saml/views/metadata.py b/authentik/providers/saml/views/metadata.py deleted file mode 100644 index 3cf24b2db..000000000 --- a/authentik/providers/saml/views/metadata.py +++ /dev/null @@ -1,81 +0,0 @@ -"""authentik SAML IDP Views""" - -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404 -from django.utils.translation import gettext_lazy as _ -from django.views import View -from django.views.generic.edit import FormView -from structlog.stdlib import get_logger - -from authentik.core.models import Application, Provider -from authentik.lib.views import bad_request_message -from authentik.providers.saml.forms import SAMLProviderImportForm -from authentik.providers.saml.models import SAMLProvider -from authentik.providers.saml.processors.metadata import MetadataProcessor -from authentik.providers.saml.processors.metadata_parser import ( - ServiceProviderMetadataParser, -) - -LOGGER = get_logger() - - -class DescriptorDownloadView(View): - """Replies with the XML Metadata IDSSODescriptor.""" - - @staticmethod - def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str: - """Return rendered XML Metadata""" - return MetadataProcessor(provider, request).build_entity_descriptor() - - def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: - """Replies with the XML Metadata IDSSODescriptor.""" - application = get_object_or_404(Application, slug=application_slug) - provider: SAMLProvider = get_object_or_404( - SAMLProvider, pk=application.provider_id - ) - try: - metadata = DescriptorDownloadView.get_metadata(request, provider) - except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member - return bad_request_message( - request, "Provider is not assigned to an application." - ) - else: - response = HttpResponse(metadata, content_type="application/xml") - response[ - "Content-Disposition" - ] = f'attachment; filename="{provider.name}_authentik_meta.xml"' - return response - - -class MetadataImportView(LoginRequiredMixin, FormView): - """Import Metadata from XML, and create provider""" - - form_class = SAMLProviderImportForm - template_name = "providers/saml/import.html" - success_url = "/" - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_superuser: - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form: SAMLProviderImportForm) -> HttpResponse: - try: - metadata = ServiceProviderMetadataParser().parse( - form.cleaned_data["metadata"].read().decode() - ) - metadata.to_provider( - form.cleaned_data["provider_name"], - form.cleaned_data["authorization_flow"], - ) - messages.success(self.request, _("Successfully created Provider")) - except ValueError as exc: - LOGGER.warning(str(exc)) - messages.error( - self.request, - _("Failed to import Metadata: %(message)s" % {"message": str(exc)}), - ) - return super().form_invalid(form) - return super().form_valid(form) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 39fe5439b..584a6385c 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -78,7 +78,6 @@ AUTHENTICATION_BACKENDS = [ # Application definition INSTALLED_APPS = [ - "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", diff --git a/authentik/root/urls.py b/authentik/root/urls.py index 95c0a1646..1dd9a9a3a 100644 --- a/authentik/root/urls.py +++ b/authentik/root/urls.py @@ -1,9 +1,7 @@ """authentik URL Configuration""" from django.conf import settings from django.conf.urls.static import static -from django.contrib import admin from django.urls import include, path -from django.views.generic import RedirectView from structlog.stdlib import get_logger from authentik.core.views import error @@ -11,13 +9,6 @@ from authentik.lib.utils.reflection import get_apps from authentik.root.monitoring import LiveView, MetricsView, ReadyView LOGGER = get_logger() -admin.autodiscover() -admin.site.login = RedirectView.as_view( - pattern_name="authentik_flows:default-authentication" -) -admin.site.logout = RedirectView.as_view( - pattern_name="authentik_flows:default-invalidation" -) handler400 = error.BadRequestView.as_view() handler403 = error.ForbiddenView.as_view() @@ -54,7 +45,6 @@ for _authentik_app in get_apps(): ) urlpatterns += [ - path("administration/django/", admin.site.urls), path("metrics/", MetricsView.as_view(), name="metrics"), path("-/health/live/", LiveView.as_view(), name="health-live"), path("-/health/ready/", ReadyView.as_view(), name="health-ready"), diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index 01aa37d1c..cb89f0e44 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -8,11 +8,11 @@ from rest_framework.decorators import action from rest_framework.fields import DateTimeField from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.api.sources import SourceSerializer -from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer +from authentik.core.api.utils import PassiveSerializer from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource @@ -70,18 +70,13 @@ class LDAPSourceViewSet(ModelViewSet): ) -class LDAPPropertyMappingSerializer(ModelSerializer, MetaNameSerializer): +class LDAPPropertyMappingSerializer(PropertyMappingSerializer): """LDAP PropertyMapping Serializer""" class Meta: model = LDAPPropertyMapping - fields = [ - "pk", - "name", - "expression", + fields = PropertyMappingSerializer.Meta.fields + [ "object_field", - "verbose_name", - "verbose_name_plural", ] diff --git a/authentik/sources/ldap/forms.py b/authentik/sources/ldap/forms.py deleted file mode 100644 index ae2088421..000000000 --- a/authentik/sources/ldap/forms.py +++ /dev/null @@ -1,89 +0,0 @@ -"""authentik LDAP Forms""" - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from authentik.admin.fields import CodeMirrorWidget -from authentik.core.expression import PropertyMappingEvaluator -from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource - - -class LDAPSourceForm(forms.ModelForm): - """LDAPSource Form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all() - self.fields[ - "property_mappings_group" - ].queryset = LDAPPropertyMapping.objects.all() - - class Meta: - - model = LDAPSource - fields = [ - # we don't use all common fields, as we don't use flows for this - "name", - "slug", - "enabled", - "policy_engine_mode", - # -- start of our custom fields - "server_uri", - "start_tls", - "bind_cn", - "bind_password", - "base_dn", - "sync_users", - "sync_users_password", - "sync_groups", - "property_mappings", - "property_mappings_group", - "additional_user_dn", - "additional_group_dn", - "user_object_filter", - "group_object_filter", - "group_membership_field", - "object_uniqueness_field", - "sync_parent_group", - ] - labels = {"property_mappings_group": _("Group property mappings")} - widgets = { - "name": forms.TextInput(), - "server_uri": forms.TextInput(), - "bind_cn": forms.TextInput(), - "bind_password": forms.TextInput(), - "base_dn": forms.TextInput(), - "additional_user_dn": forms.TextInput(), - "additional_group_dn": forms.TextInput(), - "user_object_filter": forms.TextInput(), - "group_object_filter": forms.TextInput(), - "group_membership_field": forms.TextInput(), - "object_uniqueness_field": forms.TextInput(), - } - - -class LDAPPropertyMappingForm(forms.ModelForm): - """LDAP Property Mapping form""" - - template_name = "ldap/property_mapping_form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - evaluator = PropertyMappingEvaluator() - evaluator.validate(expression) - return expression - - class Meta: - - model = LDAPPropertyMapping - fields = ["name", "object_field", "expression"] - widgets = { - "name": forms.TextInput(), - "ldap_property": forms.TextInput(), - "object_field": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } - help_texts = { - "object_field": _("Field of the user object this value is written to.") - } diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index 9d74eb73d..bdea6abf0 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -2,7 +2,6 @@ from typing import Optional, Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from ldap3 import ALL, Connection, Server from rest_framework.serializers import Serializer @@ -73,10 +72,8 @@ class LDAPSource(Source): ) @property - def form(self) -> Type[ModelForm]: - from authentik.sources.ldap.forms import LDAPSourceForm - - return LDAPSourceForm + def component(self) -> str: + return "ak-source-ldap-form" @property def serializer(self) -> Type[Serializer]: @@ -119,10 +116,8 @@ class LDAPPropertyMapping(PropertyMapping): object_field = models.TextField() @property - def form(self) -> Type[ModelForm]: - from authentik.sources.ldap.forms import LDAPPropertyMappingForm - - return LDAPPropertyMappingForm + def component(self) -> str: + return "ak-property-mapping-ldap-form" @property def serializer(self) -> Type[Serializer]: diff --git a/authentik/sources/ldap/templates/ldap/property_mapping_form.html b/authentik/sources/ldap/templates/ldap/property_mapping_form.html deleted file mode 100644 index 030095abe..000000000 --- a/authentik/sources/ldap/templates/ldap/property_mapping_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/authentik/sources/oauth/api/source.py b/authentik/sources/oauth/api/source.py index 0534088eb..0d4b65029 100644 --- a/authentik/sources/oauth/api/source.py +++ b/authentik/sources/oauth/api/source.py @@ -1,10 +1,28 @@ """OAuth Source Serializer""" from django.urls.base import reverse_lazy -from rest_framework.fields import SerializerMethodField +from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method +from rest_framework.decorators import action +from rest_framework.fields import BooleanField, CharField, SerializerMethodField +from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from authentik.core.api.sources import SourceSerializer +from authentik.core.api.utils import PassiveSerializer from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.types.manager import MANAGER + + +class SourceTypeSerializer(PassiveSerializer): + """Serializer for SourceType""" + + name = CharField(required=True) + slug = CharField(required=True) + urls_customizable = BooleanField() + request_token_url = CharField(read_only=True, allow_null=True) + authorization_url = CharField(read_only=True, allow_null=True) + access_token_url = CharField(read_only=True, allow_null=True) + profile_url = CharField(read_only=True, allow_null=True) class OAuthSourceSerializer(SourceSerializer): @@ -22,6 +40,13 @@ class OAuthSourceSerializer(SourceSerializer): return relative_url return self.context["request"].build_absolute_uri(relative_url) + type = SerializerMethodField() + + @swagger_serializer_method(serializer_or_field=SourceTypeSerializer) + def get_type(self, instace: OAuthSource) -> SourceTypeSerializer: + """Get source's type configuration""" + return SourceTypeSerializer(instace.type).data + class Meta: model = OAuthSource fields = SourceSerializer.Meta.fields + [ @@ -33,8 +58,8 @@ class OAuthSourceSerializer(SourceSerializer): "consumer_key", "consumer_secret", "callback_url", + "type", ] - extra_kwargs = {"consumer_secret": {"write_only": True}} class OAuthSourceViewSet(ModelViewSet): @@ -43,3 +68,12 @@ class OAuthSourceViewSet(ModelViewSet): queryset = OAuthSource.objects.all() serializer_class = OAuthSourceSerializer lookup_field = "slug" + + @swagger_auto_schema(responses={200: SourceTypeSerializer(many=True)}) + @action(detail=False, pagination_class=None, filter_backends=[]) + def source_types(self, request: Request) -> Response: + """Get all creatable source types""" + data = [] + for source_type in MANAGER.get(): + data.append(SourceTypeSerializer(source_type).data) + return Response(data) diff --git a/authentik/sources/oauth/forms.py b/authentik/sources/oauth/forms.py deleted file mode 100644 index bdcd17ab1..000000000 --- a/authentik/sources/oauth/forms.py +++ /dev/null @@ -1,138 +0,0 @@ -"""authentik oauth_client forms""" - -from django import forms - -from authentik.flows.models import Flow, FlowDesignation -from authentik.sources.oauth.models import OAuthSource -from authentik.sources.oauth.types.manager import MANAGER - - -class OAuthSourceForm(forms.ModelForm): - """OAuthSource Form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authentication_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHENTICATION - ) - self.fields["authentication_flow"].required = True - self.fields["enrollment_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.ENROLLMENT - ) - self.fields["enrollment_flow"].required = True - if hasattr(self.Meta, "overrides"): - for overide_field, overide_value in getattr(self.Meta, "overrides").items(): - self.fields[overide_field].initial = overide_value - self.fields[overide_field].widget.attrs["readonly"] = "readonly" - - class Meta: - - model = OAuthSource - fields = [ - "name", - "slug", - "enabled", - "policy_engine_mode", - "authentication_flow", - "enrollment_flow", - "provider_type", - "request_token_url", - "authorization_url", - "access_token_url", - "profile_url", - "consumer_key", - "consumer_secret", - ] - widgets = { - "name": forms.TextInput(), - "consumer_key": forms.TextInput(), - "consumer_secret": forms.TextInput(), - "provider_type": forms.Select(choices=MANAGER.get_name_tuple()), - } - - -class GitHubOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for GitHub""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "github", - "request_token_url": "", - "authorization_url": "https://github.com/login/oauth/authorize", - "access_token_url": "https://github.com/login/oauth/access_token", - "profile_url": "https://api.github.com/user", - } - - -class TwitterOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Twitter""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "twitter", - "request_token_url": "https://api.twitter.com/oauth/request_token", - "authorization_url": "https://api.twitter.com/oauth/authenticate", - "access_token_url": "https://api.twitter.com/oauth/access_token", - "profile_url": ( - "https://api.twitter.com/1.1/account/" - "verify_credentials.json?include_email=true" - ), - } - - -class FacebookOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Facebook""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "facebook", - "request_token_url": "", - "authorization_url": "https://www.facebook.com/v7.0/dialog/oauth", - "access_token_url": "https://graph.facebook.com/v7.0/oauth/access_token", - "profile_url": "https://graph.facebook.com/v7.0/me?fields=id,name,email", - } - - -class DiscordOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Discord""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "discord", - "request_token_url": "", - "authorization_url": "https://discord.com/api/oauth2/authorize", - "access_token_url": "https://discord.com/api/oauth2/token", - "profile_url": "https://discord.com/api/users/@me", - } - - -class GoogleOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Google""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "google", - "request_token_url": "", - "authorization_url": "https://accounts.google.com/o/oauth2/auth", - "access_token_url": "https://accounts.google.com/o/oauth2/token", - "profile_url": "https://www.googleapis.com/oauth2/v1/userinfo", - } - - -class AzureADOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for AzureAD""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "azure-ad", - "request_token_url": "", - "authorization_url": "https://login.microsoftonline.com/common/oauth2/authorize", - "access_token_url": "https://login.microsoftonline.com/common/oauth2/token", - "profile_url": "https://graph.windows.net/myorganization/me?api-version=1.6", - } diff --git a/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py b/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py index 7452ef5b3..6c3bba15a 100644 --- a/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py +++ b/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py @@ -47,4 +47,11 @@ class Migration(migrations.Migration): verbose_name="Profile URL", ), ), + migrations.AlterModelOptions( + name="oauthsource", + options={ + "verbose_name": "OAuth Source", + "verbose_name_plural": "OAuth Sources", + }, + ), ] diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index cb9024b84..8fc39f4de 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -1,8 +1,7 @@ """OAuth Client models""" -from typing import Optional, Type +from typing import TYPE_CHECKING, Optional, Type from django.db import models -from django.forms import ModelForm from django.templatetags.static import static from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -11,6 +10,9 @@ from rest_framework.serializers import Serializer from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UILoginButton, UserSettingSerializer +if TYPE_CHECKING: + from authentik.sources.oauth.types.manager import SourceType + class OAuthSource(Source): """Login using a Generic OAuth provider.""" @@ -43,10 +45,15 @@ class OAuthSource(Source): consumer_secret = models.TextField() @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import OAuthSourceForm + def type(self) -> "SourceType": + """Return the provider instance for this source""" + from authentik.sources.oauth.types.manager import MANAGER - return OAuthSourceForm + return MANAGER.find_type(self) + + @property + def component(self) -> str: + return "ak-source-oauth-form" @property def serializer(self) -> Type[Serializer]: @@ -79,19 +86,13 @@ class OAuthSource(Source): class Meta: - verbose_name = _("Generic OAuth Source") - verbose_name_plural = _("Generic OAuth Sources") + verbose_name = _("OAuth Source") + verbose_name_plural = _("OAuth Sources") class GitHubOAuthSource(OAuthSource): """Social Login using GitHub.com or a GitHub-Enterprise Instance.""" - @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import GitHubOAuthSourceForm - - return GitHubOAuthSourceForm - class Meta: abstract = True @@ -102,12 +103,6 @@ class GitHubOAuthSource(OAuthSource): class TwitterOAuthSource(OAuthSource): """Social Login using Twitter.com""" - @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import TwitterOAuthSourceForm - - return TwitterOAuthSourceForm - class Meta: abstract = True @@ -118,12 +113,6 @@ class TwitterOAuthSource(OAuthSource): class FacebookOAuthSource(OAuthSource): """Social Login using Facebook.com.""" - @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import FacebookOAuthSourceForm - - return FacebookOAuthSourceForm - class Meta: abstract = True @@ -134,12 +123,6 @@ class FacebookOAuthSource(OAuthSource): class DiscordOAuthSource(OAuthSource): """Social Login using Discord.""" - @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import DiscordOAuthSourceForm - - return DiscordOAuthSourceForm - class Meta: abstract = True @@ -150,12 +133,6 @@ class DiscordOAuthSource(OAuthSource): class GoogleOAuthSource(OAuthSource): """Social Login using Google or Gsuite.""" - @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import GoogleOAuthSourceForm - - return GoogleOAuthSourceForm - class Meta: abstract = True @@ -166,12 +143,6 @@ class GoogleOAuthSource(OAuthSource): class AzureADOAuthSource(OAuthSource): """Social Login using Azure AD.""" - @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import AzureADOAuthSourceForm - - return AzureADOAuthSourceForm - class Meta: abstract = True @@ -182,12 +153,6 @@ class AzureADOAuthSource(OAuthSource): class OpenIDOAuthSource(OAuthSource): """Login using a Generic OpenID-Connect compliant provider.""" - @property - def form(self) -> Type[ModelForm]: - from authentik.sources.oauth.forms import OAuthSourceForm - - return OAuthSourceForm - class Meta: abstract = True diff --git a/authentik/sources/oauth/types/azure_ad.py b/authentik/sources/oauth/types/azure_ad.py index 697bc94fa..1e23516d4 100644 --- a/authentik/sources/oauth/types/azure_ad.py +++ b/authentik/sources/oauth/types/azure_ad.py @@ -3,11 +3,10 @@ from typing import Any from uuid import UUID from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback -@MANAGER.source(kind=RequestKind.CALLBACK, name="Azure AD") class AzureADOAuthCallback(OAuthCallback): """AzureAD OAuth2 Callback""" @@ -26,3 +25,18 @@ class AzureADOAuthCallback(OAuthCallback): "email": mail, "name": info.get("displayName"), } + + +@MANAGER.type() +class AzureADType(SourceType): + """Azure AD Type definition""" + + callback_view = AzureADOAuthCallback + name = "Azure AD" + slug = "azure-ad" + + urls_customizable = True + + authorization_url = "https://login.microsoftonline.com/common/oauth2/authorize" + access_token_url = "https://login.microsoftonline.com/common/oauth2/token" # nosec + profile_url = "https://graph.windows.net/myorganization/me?api-version=1.6" diff --git a/authentik/sources/oauth/types/discord.py b/authentik/sources/oauth/types/discord.py index b50aafa77..00bac79fd 100644 --- a/authentik/sources/oauth/types/discord.py +++ b/authentik/sources/oauth/types/discord.py @@ -2,12 +2,11 @@ from typing import Any from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect -@MANAGER.source(kind=RequestKind.REDIRECT, name="Discord") class DiscordOAuthRedirect(OAuthRedirect): """Discord OAuth2 Redirect""" @@ -17,7 +16,6 @@ class DiscordOAuthRedirect(OAuthRedirect): } -@MANAGER.source(kind=RequestKind.CALLBACK, name="Discord") class DiscordOAuth2Callback(OAuthCallback): """Discord OAuth2 Callback""" @@ -32,3 +30,17 @@ class DiscordOAuth2Callback(OAuthCallback): "email": info.get("email", None), "name": info.get("username"), } + + +@MANAGER.type() +class DiscordType(SourceType): + """Discord Type definition""" + + callback_view = DiscordOAuth2Callback + redirect_view = DiscordOAuthRedirect + name = "Discord" + slug = "discord" + + authorization_url = "https://discord.com/api/oauth2/authorize" + access_token_url = "https://discord.com/api/oauth2/token" # nosec + profile_url = "https://discord.com/api/users/@me" diff --git a/authentik/sources/oauth/types/facebook.py b/authentik/sources/oauth/types/facebook.py index 3956413da..ab27d6b6f 100644 --- a/authentik/sources/oauth/types/facebook.py +++ b/authentik/sources/oauth/types/facebook.py @@ -5,12 +5,11 @@ from facebook import GraphAPI from authentik.sources.oauth.clients.oauth2 import OAuth2Client from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect -@MANAGER.source(kind=RequestKind.REDIRECT, name="Facebook") class FacebookOAuthRedirect(OAuthRedirect): """Facebook OAuth2 Redirect""" @@ -28,7 +27,6 @@ class FacebookOAuth2Client(OAuth2Client): return api.get_object("me", fields="id,name,email") -@MANAGER.source(kind=RequestKind.CALLBACK, name="Facebook") class FacebookOAuth2Callback(OAuthCallback): """Facebook OAuth2 Callback""" @@ -45,3 +43,17 @@ class FacebookOAuth2Callback(OAuthCallback): "email": info.get("email"), "name": info.get("name"), } + + +@MANAGER.type() +class FacebookType(SourceType): + """Facebook Type definition""" + + callback_view = FacebookOAuth2Callback + redirect_view = FacebookOAuthRedirect + name = "Facebook" + slug = "facebook" + + authorization_url = "https://www.facebook.com/v7.0/dialog/oauth" + access_token_url = "https://graph.facebook.com/v7.0/oauth/access_token" # nosec + profile_url = "https://graph.facebook.com/v7.0/me?fields=id,name,email" diff --git a/authentik/sources/oauth/types/github.py b/authentik/sources/oauth/types/github.py index 420fa6ba1..c830d4919 100644 --- a/authentik/sources/oauth/types/github.py +++ b/authentik/sources/oauth/types/github.py @@ -2,11 +2,10 @@ from typing import Any from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback -@MANAGER.source(kind=RequestKind.CALLBACK, name="GitHub") class GitHubOAuth2Callback(OAuthCallback): """GitHub OAuth2 Callback""" @@ -21,3 +20,18 @@ class GitHubOAuth2Callback(OAuthCallback): "email": info.get("email"), "name": info.get("name"), } + + +@MANAGER.type() +class GitHubType(SourceType): + """GitHub Type definition""" + + callback_view = GitHubOAuth2Callback + name = "GitHub" + slug = "github" + + urls_customizable = True + + authorization_url = "https://github.com/login/oauth/authorize" + access_token_url = "https://github.com/login/oauth/access_token" # nosec + profile_url = "https://api.github.com/user" diff --git a/authentik/sources/oauth/types/google.py b/authentik/sources/oauth/types/google.py index c7d1ba8c8..e69004254 100644 --- a/authentik/sources/oauth/types/google.py +++ b/authentik/sources/oauth/types/google.py @@ -2,12 +2,11 @@ from typing import Any from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect -@MANAGER.source(kind=RequestKind.REDIRECT, name="Google") class GoogleOAuthRedirect(OAuthRedirect): """Google OAuth2 Redirect""" @@ -17,7 +16,6 @@ class GoogleOAuthRedirect(OAuthRedirect): } -@MANAGER.source(kind=RequestKind.CALLBACK, name="Google") class GoogleOAuth2Callback(OAuthCallback): """Google OAuth2 Callback""" @@ -32,3 +30,17 @@ class GoogleOAuth2Callback(OAuthCallback): "email": info.get("email"), "name": info.get("name"), } + + +@MANAGER.type() +class GoogleType(SourceType): + """Google Type definition""" + + callback_view = GoogleOAuth2Callback + redirect_view = GoogleOAuthRedirect + name = "Google" + slug = "google" + + authorization_url = "https://accounts.google.com/o/oauth2/auth" + access_token_url = "https://accounts.google.com/o/oauth2/token" # nosec + profile_url = "https://www.googleapis.com/oauth2/v1/userinfo" diff --git a/authentik/sources/oauth/types/manager.py b/authentik/sources/oauth/types/manager.py index ea51b7c5b..b1b921912 100644 --- a/authentik/sources/oauth/types/manager.py +++ b/authentik/sources/oauth/types/manager.py @@ -1,16 +1,17 @@ """Source type manager""" from enum import Enum -from typing import Callable +from typing import TYPE_CHECKING, Callable, Optional -from django.utils.text import slugify from structlog.stdlib import get_logger -from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect LOGGER = get_logger() +if TYPE_CHECKING: + from authentik.sources.oauth.models import OAuthSource + class RequestKind(Enum): """Enum of OAuth Request types""" @@ -19,46 +20,67 @@ class RequestKind(Enum): REDIRECT = "redirect" +class SourceType: + """Source type, allows overriding of urls and views per type""" + + callback_view = OAuthCallback + redirect_view = OAuthRedirect + name: str + slug: str + + urls_customizable = False + + request_token_url: Optional[str] = None + authorization_url: Optional[str] = None + access_token_url: Optional[str] = None + profile_url: Optional[str] = None + + class SourceTypeManager: """Manager to hold all Source types.""" - __source_types: dict[RequestKind, dict[str, Callable]] = {} - __names: list[str] = [] + __sources: list[SourceType] = [] - def source(self, kind: RequestKind, name: str): + def type(self): """Class decorator to register classes inline.""" def inner_wrapper(cls): - if kind.value not in self.__source_types: - self.__source_types[kind.value] = {} - self.__source_types[kind.value][slugify(name)] = cls - self.__names.append(name) + self.__sources.append(cls) return cls return inner_wrapper + def get(self): + """Get a list of all source types""" + return self.__sources + def get_name_tuple(self): """Get list of tuples of all registered names""" - return [(slugify(x), x) for x in set(self.__names)] + return [(x.slug, x.name) for x in self.__sources] - def find(self, source: OAuthSource, kind: RequestKind) -> Callable: - """Find fitting Source Type""" - if kind.value in self.__source_types: - if source.provider_type in self.__source_types[kind.value]: - return self.__source_types[kind.value][source.provider_type] + def find_type(self, source: "OAuthSource") -> SourceType: + """Find type based on source""" + found_type = None + for src_type in self.__sources: + if src_type.slug == source.provider_type: + return src_type + if not found_type: + found_type = SourceType() LOGGER.warning( "no matching type found, using default", wanted=source.provider_type, - have=self.__source_types[kind.value].keys(), + have=[x.name for x in self.__sources], ) - # Return defaults - if kind == RequestKind.CALLBACK: - return OAuthCallback - if kind == RequestKind.REDIRECT: - return OAuthRedirect - raise KeyError( - f"Provider Type {source.provider_type} (type {kind.value}) not found." - ) + return found_type + + def find(self, source: "OAuthSource", kind: RequestKind) -> Callable: + """Find fitting Source Type""" + found_type = self.find_type(source) + if kind == RequestKind.CALLBACK: + return found_type.callback_view + if kind == RequestKind.REDIRECT: + return found_type.redirect_view + raise ValueError MANAGER = SourceTypeManager() diff --git a/authentik/sources/oauth/types/oidc.py b/authentik/sources/oauth/types/oidc.py index 7fafaead2..e2acf4b63 100644 --- a/authentik/sources/oauth/types/oidc.py +++ b/authentik/sources/oauth/types/oidc.py @@ -2,12 +2,11 @@ from typing import Any from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect -@MANAGER.source(kind=RequestKind.REDIRECT, name="OpenID Connect") class OpenIDConnectOAuthRedirect(OAuthRedirect): """OpenIDConnect OAuth2 Redirect""" @@ -17,7 +16,6 @@ class OpenIDConnectOAuthRedirect(OAuthRedirect): } -@MANAGER.source(kind=RequestKind.CALLBACK, name="OpenID Connect") class OpenIDConnectOAuth2Callback(OAuthCallback): """OpenIDConnect OAuth2 Callback""" @@ -35,3 +33,15 @@ class OpenIDConnectOAuth2Callback(OAuthCallback): "email": info.get("email"), "name": info.get("name"), } + + +@MANAGER.type() +class OpenIDConnectType(SourceType): + """OpenIDConnect Type definition""" + + callback_view = OpenIDConnectOAuth2Callback + redirect_view = OpenIDConnectOAuthRedirect + name = "OpenID Connect" + slug = "openid-connect" + + urls_customizable = True diff --git a/authentik/sources/oauth/types/reddit.py b/authentik/sources/oauth/types/reddit.py index 868bb23dc..74c777e6d 100644 --- a/authentik/sources/oauth/types/reddit.py +++ b/authentik/sources/oauth/types/reddit.py @@ -5,12 +5,11 @@ from requests.auth import HTTPBasicAuth from authentik.sources.oauth.clients.oauth2 import OAuth2Client from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect -@MANAGER.source(kind=RequestKind.REDIRECT, name="reddit") class RedditOAuthRedirect(OAuthRedirect): """Reddit OAuth2 Redirect""" @@ -30,7 +29,6 @@ class RedditOAuth2Client(OAuth2Client): return super().get_access_token(auth=auth) -@MANAGER.source(kind=RequestKind.CALLBACK, name="reddit") class RedditOAuth2Callback(OAuthCallback): """Reddit OAuth2 Callback""" @@ -48,3 +46,17 @@ class RedditOAuth2Callback(OAuthCallback): "name": info.get("name"), "password": None, } + + +@MANAGER.type() +class RedditType(SourceType): + """Reddit Type definition""" + + callback_view = RedditOAuth2Callback + redirect_view = RedditOAuthRedirect + name = "reddit" + slug = "reddit" + + authorization_url = "https://accounts.google.com/o/oauth2/auth" + access_token_url = "https://accounts.google.com/o/oauth2/token" # nosec + profile_url = "https://www.googleapis.com/oauth2/v1/userinfo" diff --git a/authentik/sources/oauth/types/twitter.py b/authentik/sources/oauth/types/twitter.py index ff4105ee6..df1ed1a9f 100644 --- a/authentik/sources/oauth/types/twitter.py +++ b/authentik/sources/oauth/types/twitter.py @@ -2,11 +2,10 @@ from typing import Any from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback -@MANAGER.source(kind=RequestKind.CALLBACK, name="Twitter") class TwitterOAuthCallback(OAuthCallback): """Twitter OAuth2 Callback""" @@ -21,3 +20,20 @@ class TwitterOAuthCallback(OAuthCallback): "email": info.get("email", None), "name": info.get("name"), } + + +@MANAGER.type() +class TwitterType(SourceType): + """Twitter Type definition""" + + callback_view = TwitterOAuthCallback + name = "Twitter" + slug = "twitter" + + request_token_url = "https://api.twitter.com/oauth/request_token" # nosec + authorization_url = "https://api.twitter.com/oauth/authenticate" + access_token_url = "https://api.twitter.com/oauth/access_token" # nosec + profile_url = ( + "https://api.twitter.com/1.1/account/" + "verify_credentials.json?include_email=true" + ) diff --git a/authentik/sources/saml/forms.py b/authentik/sources/saml/forms.py deleted file mode 100644 index 0a4773b3a..000000000 --- a/authentik/sources/saml/forms.py +++ /dev/null @@ -1,63 +0,0 @@ -"""authentik SAML SP Forms""" - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from authentik.crypto.models import CertificateKeyPair -from authentik.flows.models import Flow, FlowDesignation -from authentik.sources.saml.models import SAMLSource - - -class SAMLSourceForm(forms.ModelForm): - """SAML Provider form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields["pre_authentication_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.STAGE_CONFIGURATION - ) - self.fields["authentication_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHENTICATION - ) - self.fields["enrollment_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.ENROLLMENT - ) - self.fields["signing_kp"].queryset = CertificateKeyPair.objects.filter( - certificate_data__isnull=False, - key_data__isnull=False, - ) - - class Meta: - - model = SAMLSource - fields = [ - "name", - "slug", - "enabled", - "policy_engine_mode", - "pre_authentication_flow", - "authentication_flow", - "enrollment_flow", - "issuer", - "sso_url", - "slo_url", - "binding_type", - "name_id_policy", - "allow_idp_initiated", - "signing_kp", - "digest_algorithm", - "signature_algorithm", - "temporary_user_delete_after", - ] - widgets = { - "name": forms.TextInput(), - "issuer": forms.TextInput(), - "sso_url": forms.TextInput(), - "slo_url": forms.TextInput(), - "temporary_user_delete_after": forms.TextInput(), - } - labels = { - "name_id_policy": _("Name ID Policy"), - "allow_idp_initiated": _("Allow IDP-initiated logins"), - } diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index 64f2b3f63..d35685aac 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -2,7 +2,6 @@ from typing import Type from django.db import models -from django.forms import ModelForm from django.http import HttpRequest from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -146,10 +145,8 @@ class SAMLSource(Source): ) @property - def form(self) -> Type[ModelForm]: - from authentik.sources.saml.forms import SAMLSourceForm - - return SAMLSourceForm + def component(self) -> str: + return "ak-source-saml-form" @property def serializer(self) -> Type[Serializer]: diff --git a/authentik/stages/authenticator_static/forms.py b/authentik/stages/authenticator_static/forms.py deleted file mode 100644 index 95e6b3447..000000000 --- a/authentik/stages/authenticator_static/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Static Authenticator forms""" -from django import forms - -from authentik.stages.authenticator_static.models import AuthenticatorStaticStage - - -class AuthenticatorStaticStageForm(forms.ModelForm): - """Static Authenticator Stage setup form""" - - class Meta: - - model = AuthenticatorStaticStage - fields = ["name", "configure_flow", "token_count"] - - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/authenticator_static/models.py b/authentik/stages/authenticator_static/models.py index d0497b65a..125abb02f 100644 --- a/authentik/stages/authenticator_static/models.py +++ b/authentik/stages/authenticator_static/models.py @@ -2,7 +2,6 @@ from typing import Optional, Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -33,12 +32,8 @@ class AuthenticatorStaticStage(ConfigurableStage, Stage): return AuthenticatorStaticStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.authenticator_static.forms import ( - AuthenticatorStaticStageForm, - ) - - return AuthenticatorStaticStageForm + def component(self) -> str: + return "ak-stage-authenticator-static-form" @property def ui_user_settings(self) -> Optional[UserSettingSerializer]: diff --git a/authentik/stages/authenticator_totp/forms.py b/authentik/stages/authenticator_totp/forms.py deleted file mode 100644 index 98ebe481e..000000000 --- a/authentik/stages/authenticator_totp/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -"""OTP Time forms""" -from django import forms - -from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage - - -class AuthenticatorTOTPStageForm(forms.ModelForm): - """OTP Time-based Stage setup form""" - - class Meta: - - model = AuthenticatorTOTPStage - fields = ["name", "configure_flow", "digits"] - - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/authenticator_totp/models.py b/authentik/stages/authenticator_totp/models.py index 80f5044f4..daf69d015 100644 --- a/authentik/stages/authenticator_totp/models.py +++ b/authentik/stages/authenticator_totp/models.py @@ -2,7 +2,6 @@ from typing import Optional, Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -38,10 +37,8 @@ class AuthenticatorTOTPStage(ConfigurableStage, Stage): return AuthenticatorTOTPStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.authenticator_totp.forms import AuthenticatorTOTPStageForm - - return AuthenticatorTOTPStageForm + def component(self) -> str: + return "ak-stage-authenticator-totp-form" @property def ui_user_settings(self) -> Optional[UserSettingSerializer]: diff --git a/authentik/stages/authenticator_validate/forms.py b/authentik/stages/authenticator_validate/forms.py deleted file mode 100644 index 16223a326..000000000 --- a/authentik/stages/authenticator_validate/forms.py +++ /dev/null @@ -1,43 +0,0 @@ -"""OTP Validate stage forms""" -from django import forms - -from authentik.flows.models import NotConfiguredAction -from authentik.stages.authenticator_validate.models import ( - AuthenticatorValidateStage, - DeviceClasses, -) - - -class AuthenticatorValidateStageForm(forms.ModelForm): - """OTP Validate stage forms""" - - def clean_not_configured_action(self): - """Ensure that a configuration stage is set when not_configured_action is configure""" - not_configured_action = self.cleaned_data.get("not_configured_action") - configuration_stage = self.cleaned_data.get("configuration_stage") - if ( - not_configured_action == NotConfiguredAction.CONFIGURE - and configuration_stage is None - ): - raise forms.ValidationError( - ( - 'When "Not configured action" is set to "Configure", ' - "you must set a configuration stage." - ) - ) - return not_configured_action - - class Meta: - - model = AuthenticatorValidateStage - fields = [ - "name", - "not_configured_action", - "device_classes", - "configuration_stage", - ] - - widgets = { - "name": forms.TextInput(), - "device_classes": forms.SelectMultiple(choices=DeviceClasses.choices), - } diff --git a/authentik/stages/authenticator_validate/migrations/0007_auto_20210403_0927.py b/authentik/stages/authenticator_validate/migrations/0007_auto_20210403_0927.py new file mode 100644 index 000000000..bebb772ae --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0007_auto_20210403_0927.py @@ -0,0 +1,32 @@ +# Generated by Django 3.1.7 on 2021-04-03 09:27 + +import django.contrib.postgres.fields +from django.db import migrations, models + +import authentik.stages.authenticator_validate.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_authenticator_validate", "0006_auto_20210301_1757"), + ] + + operations = [ + migrations.AlterField( + model_name="authenticatorvalidatestage", + name="device_classes", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ("static", "Static"), + ("totp", "TOTP"), + ("webauthn", "WebAuthn"), + ] + ), + default=authentik.stages.authenticator_validate.models.default_device_classes, + help_text="Device classes which can be used to authenticate", + size=None, + ), + ), + ] diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index dce7e0b59..321d51128 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -3,7 +3,6 @@ from typing import Type from django.contrib.postgres.fields.array import ArrayField from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -52,7 +51,7 @@ class AuthenticatorValidateStage(Stage): ) device_classes = ArrayField( - models.TextField(), + models.TextField(choices=DeviceClasses.choices), help_text=_("Device classes which can be used to authenticate"), default=default_device_classes, ) @@ -74,12 +73,8 @@ class AuthenticatorValidateStage(Stage): return AuthenticatorValidateStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.authenticator_validate.forms import ( - AuthenticatorValidateStageForm, - ) - - return AuthenticatorValidateStageForm + def component(self) -> str: + return "ak-stage-authenticator-validate-form" class Meta: diff --git a/authentik/stages/authenticator_webauthn/forms.py b/authentik/stages/authenticator_webauthn/forms.py deleted file mode 100644 index 881bf54d7..000000000 --- a/authentik/stages/authenticator_webauthn/forms.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Webauthn stage forms""" -from django import forms - -from authentik.stages.authenticator_webauthn.models import ( - AuthenticateWebAuthnStage, - WebAuthnDevice, -) - - -class AuthenticateWebAuthnStageForm(forms.ModelForm): - """OTP Time-based Stage setup form""" - - class Meta: - - model = AuthenticateWebAuthnStage - fields = ["name"] - - widgets = { - "name": forms.TextInput(), - } - - -class DeviceEditForm(forms.ModelForm): - """Form to edit webauthn device""" - - class Meta: - - model = WebAuthnDevice - fields = ["name"] - - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/authenticator_webauthn/models.py b/authentik/stages/authenticator_webauthn/models.py index fdae8dd28..8358653fc 100644 --- a/authentik/stages/authenticator_webauthn/models.py +++ b/authentik/stages/authenticator_webauthn/models.py @@ -3,7 +3,6 @@ from typing import Optional, Type from django.contrib.auth import get_user_model from django.db import models -from django.forms import ModelForm from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views import View @@ -34,12 +33,8 @@ class AuthenticateWebAuthnStage(ConfigurableStage, Stage): return AuthenticatorWebAuthnStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.authenticator_webauthn.forms import ( - AuthenticateWebAuthnStageForm, - ) - - return AuthenticateWebAuthnStageForm + def component(self) -> str: + return "ak-stage-authenticator-webauthn-form" @property def ui_user_settings(self) -> Optional[UserSettingSerializer]: diff --git a/authentik/stages/captcha/forms.py b/authentik/stages/captcha/forms.py deleted file mode 100644 index 7902bcbd7..000000000 --- a/authentik/stages/captcha/forms.py +++ /dev/null @@ -1,18 +0,0 @@ -"""authentik captcha stage forms""" -from django import forms - -from authentik.stages.captcha.models import CaptchaStage - - -class CaptchaStageForm(forms.ModelForm): - """Form to edit CaptchaStage Instance""" - - class Meta: - - model = CaptchaStage - fields = ["name", "public_key", "private_key"] - widgets = { - "name": forms.TextInput(), - "public_key": forms.TextInput(), - "private_key": forms.TextInput(), - } diff --git a/authentik/stages/captcha/models.py b/authentik/stages/captcha/models.py index d2a0fe501..17d4a721e 100644 --- a/authentik/stages/captcha/models.py +++ b/authentik/stages/captcha/models.py @@ -2,7 +2,6 @@ from typing import Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -37,10 +36,8 @@ class CaptchaStage(Stage): return CaptchaStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.captcha.forms import CaptchaStageForm - - return CaptchaStageForm + def component(self) -> str: + return "ak-stage-captcha-form" class Meta: diff --git a/authentik/stages/consent/forms.py b/authentik/stages/consent/forms.py deleted file mode 100644 index f61e483fd..000000000 --- a/authentik/stages/consent/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -"""authentik consent stage forms""" -from django import forms - -from authentik.stages.consent.models import ConsentStage - - -class ConsentStageForm(forms.ModelForm): - """Form to edit ConsentStage Instance""" - - class Meta: - - model = ConsentStage - fields = ["name", "mode", "consent_expire_in"] - widgets = { - "name": forms.TextInput(), - "consent_expire_in": forms.TextInput(), - } diff --git a/authentik/stages/consent/models.py b/authentik/stages/consent/models.py index 4a96944e2..9c2fa5433 100644 --- a/authentik/stages/consent/models.py +++ b/authentik/stages/consent/models.py @@ -2,7 +2,6 @@ from typing import Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -51,10 +50,8 @@ class ConsentStage(Stage): return ConsentStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.consent.forms import ConsentStageForm - - return ConsentStageForm + def component(self) -> str: + return "ak-stage-consent-form" class Meta: diff --git a/authentik/stages/deny/forms.py b/authentik/stages/deny/forms.py deleted file mode 100644 index d1c66e646..000000000 --- a/authentik/stages/deny/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""authentik flows deny forms""" -from django import forms - -from authentik.stages.deny.models import DenyStage - - -class DenyStageForm(forms.ModelForm): - """Form to create/edit DenyStage instances""" - - class Meta: - - model = DenyStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/deny/models.py b/authentik/stages/deny/models.py index 5c201de7d..b403679a0 100644 --- a/authentik/stages/deny/models.py +++ b/authentik/stages/deny/models.py @@ -1,7 +1,6 @@ """deny stage models""" from typing import Type -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -25,10 +24,8 @@ class DenyStage(Stage): return DenyStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.deny.forms import DenyStageForm - - return DenyStageForm + def component(self) -> str: + return "ak-stage-deny-form" class Meta: diff --git a/authentik/stages/deny/tests.py b/authentik/stages/deny/tests.py index 22b4babc9..f87d4601a 100644 --- a/authentik/stages/deny/tests.py +++ b/authentik/stages/deny/tests.py @@ -9,7 +9,6 @@ from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import FlowPlan from authentik.flows.views import SESSION_KEY_PLAN -from authentik.stages.deny.forms import DenyStageForm from authentik.stages.deny.models import DenyStage @@ -52,8 +51,3 @@ class TestUserDenyStage(TestCase): "type": ChallengeTypes.NATIVE.value, }, ) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(DenyStageForm(data).is_valid(), True) diff --git a/authentik/stages/dummy/forms.py b/authentik/stages/dummy/forms.py deleted file mode 100644 index 92420cdd6..000000000 --- a/authentik/stages/dummy/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""authentik administration forms""" -from django import forms - -from authentik.stages.dummy.models import DummyStage - - -class DummyStageForm(forms.ModelForm): - """Form to create/edit Dummy Stage""" - - class Meta: - - model = DummyStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/dummy/models.py b/authentik/stages/dummy/models.py index 344868a82..2e19ec22a 100644 --- a/authentik/stages/dummy/models.py +++ b/authentik/stages/dummy/models.py @@ -1,7 +1,6 @@ """dummy stage models""" from typing import Type -from django.forms import ModelForm from django.utils.translation import gettext as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -27,10 +26,8 @@ class DummyStage(Stage): return DummyStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.dummy.forms import DummyStageForm - - return DummyStageForm + def component(self) -> str: + return "ak-stage-dummy-form" class Meta: diff --git a/authentik/stages/dummy/tests.py b/authentik/stages/dummy/tests.py index 02dd11876..b943d391e 100644 --- a/authentik/stages/dummy/tests.py +++ b/authentik/stages/dummy/tests.py @@ -5,7 +5,6 @@ from django.utils.encoding import force_str from authentik.core.models import User from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding -from authentik.stages.dummy.forms import DummyStageForm from authentik.stages.dummy.models import DummyStage @@ -49,8 +48,3 @@ class TestDummyStage(TestCase): force_str(response.content), {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, ) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(DummyStageForm(data).is_valid(), True) diff --git a/authentik/stages/email/api.py b/authentik/stages/email/api.py index 2a41c0f82..116538b80 100644 --- a/authentik/stages/email/api.py +++ b/authentik/stages/email/api.py @@ -37,3 +37,5 @@ class EmailStageViewSet(ModelViewSet): queryset = EmailStage.objects.all() serializer_class = EmailStageSerializer + + # TODO: Validate connection settings when use_global_settings is unchecked diff --git a/authentik/stages/email/forms.py b/authentik/stages/email/forms.py deleted file mode 100644 index 5e87b6544..000000000 --- a/authentik/stages/email/forms.py +++ /dev/null @@ -1,41 +0,0 @@ -"""authentik administration forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ - -from authentik.stages.email.models import EmailStage, get_template_choices - - -class EmailStageForm(forms.ModelForm): - """Form to create/edit Email Stage""" - - template = forms.ChoiceField(choices=get_template_choices) - - class Meta: - - model = EmailStage - fields = [ - "name", - "use_global_settings", - "token_expiry", - "subject", - "template", - "host", - "port", - "username", - "password", - "use_tls", - "use_ssl", - "timeout", - "from_address", - ] - widgets = { - "name": forms.TextInput(), - "host": forms.TextInput(), - "subject": forms.TextInput(), - "username": forms.TextInput(), - "password": forms.TextInput(), - } - labels = { - "use_tls": _("Use TLS"), - "use_ssl": _("Use SSL"), - } diff --git a/authentik/stages/email/models.py b/authentik/stages/email/models.py index 936952b15..65c155f4b 100644 --- a/authentik/stages/email/models.py +++ b/authentik/stages/email/models.py @@ -7,7 +7,6 @@ from django.conf import settings from django.core.mail import get_connection from django.core.mail.backends.base import BaseEmailBackend from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -31,6 +30,7 @@ class EmailTemplates(models.TextChoices): ) +# TODO: Create api for choices def get_template_choices(): """Get all available Email templates, including dynamically mounted ones. Directories are taken from TEMPLATES.DIR setting""" @@ -95,10 +95,8 @@ class EmailStage(Stage): return EmailStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.email.forms import EmailStageForm - - return EmailStageForm + def component(self) -> str: + return "ak-stage-email-form" @property def backend(self) -> BaseEmailBackend: diff --git a/authentik/stages/email/templates/email/password_reset.html b/authentik/stages/email/templates/email/password_reset.html index 3d6659ad4..d83a0cee1 100644 --- a/authentik/stages/email/templates/email/password_reset.html +++ b/authentik/stages/email/templates/email/password_reset.html @@ -1,6 +1,5 @@ {% extends "email/base.html" %} -{% load authentik_utils %} {% load i18n %} {% load humanize %} diff --git a/authentik/stages/identification/forms.py b/authentik/stages/identification/forms.py deleted file mode 100644 index 29e9f37b0..000000000 --- a/authentik/stages/identification/forms.py +++ /dev/null @@ -1,38 +0,0 @@ -"""authentik flows identification forms""" -from django import forms -from structlog.stdlib import get_logger - -from authentik.admin.fields import ArrayFieldSelectMultiple -from authentik.flows.models import Flow, FlowDesignation -from authentik.stages.identification.models import IdentificationStage, UserFields - -LOGGER = get_logger() - - -class IdentificationStageForm(forms.ModelForm): - """Form to create/edit IdentificationStage instances""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["enrollment_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.ENROLLMENT - ) - self.fields["recovery_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.RECOVERY - ) - - class Meta: - - model = IdentificationStage - fields = [ - "name", - "user_fields", - "case_insensitive_matching", - "show_matched_user", - "enrollment_flow", - "recovery_flow", - ] - widgets = { - "name": forms.TextInput(), - "user_fields": ArrayFieldSelectMultiple(choices=UserFields.choices), - } diff --git a/authentik/stages/identification/models.py b/authentik/stages/identification/models.py index a7b2f4218..c00f3e2b8 100644 --- a/authentik/stages/identification/models.py +++ b/authentik/stages/identification/models.py @@ -3,7 +3,6 @@ from typing import Type from django.contrib.postgres.fields import ArrayField from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -84,10 +83,8 @@ class IdentificationStage(Stage): return IdentificationStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.identification.forms import IdentificationStageForm - - return IdentificationStageForm + def component(self) -> str: + return "ak-stage-identification-form" class Meta: diff --git a/authentik/stages/invitation/forms.py b/authentik/stages/invitation/forms.py deleted file mode 100644 index a34bbe740..000000000 --- a/authentik/stages/invitation/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""authentik flows invitation forms""" -from django import forms - -from authentik.stages.invitation.models import InvitationStage - - -class InvitationStageForm(forms.ModelForm): - """Form to create/edit InvitationStage instances""" - - class Meta: - - model = InvitationStage - fields = ["name", "continue_flow_without_invitation"] - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/invitation/models.py b/authentik/stages/invitation/models.py index 7d97a0ebb..9e013b1f6 100644 --- a/authentik/stages/invitation/models.py +++ b/authentik/stages/invitation/models.py @@ -3,7 +3,6 @@ from typing import Type from uuid import uuid4 from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -40,10 +39,8 @@ class InvitationStage(Stage): return InvitationStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.invitation.forms import InvitationStageForm - - return InvitationStageForm + def component(self) -> str: + return "ak-stage-invitation-form" class Meta: diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py index c0df4b6f7..121f76269 100644 --- a/authentik/stages/invitation/tests.py +++ b/authentik/stages/invitation/tests.py @@ -14,7 +14,6 @@ from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN -from authentik.stages.invitation.forms import InvitationStageForm from authentik.stages.invitation.models import Invitation, InvitationStage from authentik.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND @@ -36,11 +35,6 @@ class TestUserLoginStage(TestCase): self.stage = InvitationStage.objects.create(name="invitation") FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(InvitationStageForm(data).is_valid(), True) - @patch( "authentik.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK, diff --git a/authentik/stages/password/forms.py b/authentik/stages/password/forms.py deleted file mode 100644 index 5fba58db9..000000000 --- a/authentik/stages/password/forms.py +++ /dev/null @@ -1,39 +0,0 @@ -"""authentik administration forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ - -from authentik.flows.models import Flow, FlowDesignation -from authentik.stages.password.models import PasswordStage - - -def get_authentication_backends(): - """Return all available authentication backends as tuple set""" - return [ - ( - "django.contrib.auth.backends.ModelBackend", - _("authentik-internal Userdatabase"), - ), - ( - "authentik.sources.ldap.auth.LDAPBackend", - _("authentik LDAP"), - ), - ] - - -class PasswordStageForm(forms.ModelForm): - """Form to create/edit Password Stages""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["configure_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.STAGE_CONFIGURATION - ) - - class Meta: - - model = PasswordStage - fields = ["name", "backends", "configure_flow", "failed_attempts_before_cancel"] - widgets = { - "name": forms.TextInput(), - "backends": forms.SelectMultiple(choices=get_authentication_backends()), - } diff --git a/authentik/stages/password/migrations/0005_auto_20210402_2221.py b/authentik/stages/password/migrations/0005_auto_20210402_2221.py new file mode 100644 index 000000000..d88061dd3 --- /dev/null +++ b/authentik/stages/password/migrations/0005_auto_20210402_2221.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.7 on 2021-04-02 22:21 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_password", "0004_auto_20200925_1057"), + ] + + operations = [ + migrations.AlterField( + model_name="passwordstage", + name="backends", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ( + "django.contrib.auth.backends.ModelBackend", + "authentik-internal Userdatabase", + ), + ("authentik.sources.ldap.auth.LDAPBackend", "authentik LDAP"), + ] + ), + help_text="Selection of backends to test the password against.", + size=None, + ), + ), + ] diff --git a/authentik/stages/password/models.py b/authentik/stages/password/models.py index a4c8619fc..a4e14e51f 100644 --- a/authentik/stages/password/models.py +++ b/authentik/stages/password/models.py @@ -3,7 +3,6 @@ from typing import Optional, Type from django.contrib.postgres.fields import ArrayField from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -12,11 +11,25 @@ from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, Stage +def get_authentication_backends(): + """Return all available authentication backends as tuple set""" + return [ + ( + "django.contrib.auth.backends.ModelBackend", + _("authentik-internal Userdatabase"), + ), + ( + "authentik.sources.ldap.auth.LDAPBackend", + _("authentik LDAP"), + ), + ] + + class PasswordStage(ConfigurableStage, Stage): """Prompts the user for their password, and validates it against the configured backends.""" backends = ArrayField( - models.TextField(), + models.TextField(choices=get_authentication_backends()), help_text=_("Selection of backends to test the password against."), ) failed_attempts_before_cancel = models.IntegerField( @@ -42,10 +55,8 @@ class PasswordStage(ConfigurableStage, Stage): return PasswordStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.password.forms import PasswordStageForm - - return PasswordStageForm + def component(self) -> str: + return "ak-stage-password-form" @property def ui_user_settings(self) -> Optional[UserSettingSerializer]: diff --git a/authentik/stages/prompt/forms.py b/authentik/stages/prompt/forms.py deleted file mode 100644 index 9f3b22bde..000000000 --- a/authentik/stages/prompt/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Prompt forms""" -from django import forms - -from authentik.stages.prompt.models import PromptStage - - -class PromptStageForm(forms.ModelForm): - """Form to create/edit Prompt Stage instances""" - - class Meta: - - model = PromptStage - fields = ["name", "fields", "validation_policies"] - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index c5a00095f..686931103 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -3,7 +3,6 @@ from typing import Type from uuid import uuid4 from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.fields import ( @@ -144,10 +143,8 @@ class PromptStage(Stage): return PromptStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.prompt.forms import PromptStageForm - - return PromptStageForm + def component(self) -> str: + return "ak-stage-prompt-form" class Meta: diff --git a/authentik/stages/user_delete/forms.py b/authentik/stages/user_delete/forms.py deleted file mode 100644 index daf2e6fe0..000000000 --- a/authentik/stages/user_delete/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -"""authentik flows delete forms""" -from django import forms - -from authentik.stages.user_delete.models import UserDeleteStage - - -class UserDeleteStageForm(forms.ModelForm): - """Form to delete/edit UserDeleteStage instances""" - - class Meta: - - model = UserDeleteStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } - - -class UserDeleteForm(forms.Form): - """Confirmation form to ensure user knows they are deleting their profile""" diff --git a/authentik/stages/user_delete/models.py b/authentik/stages/user_delete/models.py index 7e791e8c0..f8a6cf72f 100644 --- a/authentik/stages/user_delete/models.py +++ b/authentik/stages/user_delete/models.py @@ -1,7 +1,6 @@ """delete stage models""" from typing import Type -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -26,10 +25,8 @@ class UserDeleteStage(Stage): return UserDeleteStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.user_delete.forms import UserDeleteStageForm - - return UserDeleteStageForm + def component(self) -> str: + return "ak-stage-user-delete-form" class Meta: diff --git a/authentik/stages/user_delete/stage.py b/authentik/stages/user_delete/stage.py index 03c34afd6..15945dbe2 100644 --- a/authentik/stages/user_delete/stage.py +++ b/authentik/stages/user_delete/stage.py @@ -2,31 +2,25 @@ from django.contrib import messages from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ -from django.views.generic import FormView from structlog.stdlib import get_logger from authentik.core.models import User from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import StageView -from authentik.stages.user_delete.forms import UserDeleteForm LOGGER = get_logger() -class UserDeleteStageView(FormView, StageView): +class UserDeleteStageView(StageView): """Finalise unenrollment flow by deleting the user object.""" - form_class = UserDeleteForm - def get(self, request: HttpRequest) -> HttpResponse: + """Delete currently pending user""" if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: message = _("No Pending User.") messages.error(request, message) LOGGER.debug(message) return self.executor.stage_invalid() - return super().get(request) - - def form_valid(self, form: UserDeleteForm) -> HttpResponse: user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] user.delete() LOGGER.debug("Deleted user", user=user) diff --git a/authentik/stages/user_delete/tests.py b/authentik/stages/user_delete/tests.py index 75d876b1c..48d0a86b7 100644 --- a/authentik/stages/user_delete/tests.py +++ b/authentik/stages/user_delete/tests.py @@ -73,24 +73,6 @@ class TestUserDeleteStage(TestCase): reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) ) self.assertEqual(response.status_code, 200) - - def test_user_delete_post(self): - """Test User delete (actual)""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.post( - reverse( - "authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - {}, - ) - self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, diff --git a/authentik/stages/user_login/forms.py b/authentik/stages/user_login/forms.py deleted file mode 100644 index 8693a7892..000000000 --- a/authentik/stages/user_login/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -"""authentik flows login forms""" -from django import forms - -from authentik.stages.user_login.models import UserLoginStage - - -class UserLoginStageForm(forms.ModelForm): - """Form to create/edit UserLoginStage instances""" - - class Meta: - - model = UserLoginStage - fields = ["name", "session_duration"] - widgets = { - "name": forms.TextInput(), - "session_duration": forms.TextInput(), - } diff --git a/authentik/stages/user_login/models.py b/authentik/stages/user_login/models.py index db885dbd3..4d47a449f 100644 --- a/authentik/stages/user_login/models.py +++ b/authentik/stages/user_login/models.py @@ -2,7 +2,6 @@ from typing import Type from django.db import models -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -37,10 +36,8 @@ class UserLoginStage(Stage): return UserLoginStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.user_login.forms import UserLoginStageForm - - return UserLoginStageForm + def component(self) -> str: + return "ak-stage-user-login-form" class Meta: diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index d05e6dc07..b2e636802 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -13,7 +13,6 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from authentik.stages.user_login.forms import UserLoginStageForm from authentik.stages.user_login.models import UserLoginStage @@ -112,10 +111,3 @@ class TestUserLoginStage(TestCase): "type": ChallengeTypes.NATIVE.value, }, ) - - def test_form(self): - """Test Form""" - data = {"name": "test", "session_duration": "seconds=0"} - self.assertEqual(UserLoginStageForm(data).is_valid(), True) - data = {"name": "test", "session_duration": "123"} - self.assertEqual(UserLoginStageForm(data).is_valid(), False) diff --git a/authentik/stages/user_logout/forms.py b/authentik/stages/user_logout/forms.py deleted file mode 100644 index 44d3cba4c..000000000 --- a/authentik/stages/user_logout/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""authentik flows logout forms""" -from django import forms - -from authentik.stages.user_logout.models import UserLogoutStage - - -class UserLogoutStageForm(forms.ModelForm): - """Form to create/edit UserLogoutStage instances""" - - class Meta: - - model = UserLogoutStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/user_logout/models.py b/authentik/stages/user_logout/models.py index 8f07a5236..ff92f545b 100644 --- a/authentik/stages/user_logout/models.py +++ b/authentik/stages/user_logout/models.py @@ -1,7 +1,6 @@ """logout stage models""" from typing import Type -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -25,10 +24,8 @@ class UserLogoutStage(Stage): return UserLogoutStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.user_logout.forms import UserLogoutStageForm - - return UserLogoutStageForm + def component(self) -> str: + return "ak-stage-user-logout-form" class Meta: diff --git a/authentik/stages/user_logout/tests.py b/authentik/stages/user_logout/tests.py index 4923dff93..4eb464f6a 100644 --- a/authentik/stages/user_logout/tests.py +++ b/authentik/stages/user_logout/tests.py @@ -9,7 +9,6 @@ from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.views import SESSION_KEY_PLAN from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from authentik.stages.user_logout.forms import UserLogoutStageForm from authentik.stages.user_logout.models import UserLogoutStage @@ -51,8 +50,3 @@ class TestUserLogoutStage(TestCase): force_str(response.content), {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, ) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(UserLogoutStageForm(data).is_valid(), True) diff --git a/authentik/stages/user_write/forms.py b/authentik/stages/user_write/forms.py deleted file mode 100644 index 685afc57d..000000000 --- a/authentik/stages/user_write/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""authentik flows write forms""" -from django import forms - -from authentik.stages.user_write.models import UserWriteStage - - -class UserWriteStageForm(forms.ModelForm): - """Form to write/edit UserWriteStage instances""" - - class Meta: - - model = UserWriteStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/stages/user_write/models.py b/authentik/stages/user_write/models.py index f4d8b63c4..eb37a89f6 100644 --- a/authentik/stages/user_write/models.py +++ b/authentik/stages/user_write/models.py @@ -1,7 +1,6 @@ """write stage models""" from typing import Type -from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer @@ -26,10 +25,8 @@ class UserWriteStage(Stage): return UserWriteStageView @property - def form(self) -> Type[ModelForm]: - from authentik.stages.user_write.forms import UserWriteStageForm - - return UserWriteStageForm + def component(self) -> str: + return "ak-stage-user-write-form" class Meta: diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py index bd83b2e72..00cce0ac8 100644 --- a/authentik/stages/user_write/tests.py +++ b/authentik/stages/user_write/tests.py @@ -15,7 +15,6 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT -from authentik.stages.user_write.forms import UserWriteStageForm from authentik.stages.user_write.models import UserWriteStage @@ -135,8 +134,3 @@ class TestUserWriteStage(TestCase): "type": ChallengeTypes.NATIVE.value, }, ) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(UserWriteStageForm(data).is_valid(), True) diff --git a/swagger.yaml b/swagger.yaml index 209392d4a..283d604f2 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -20,6 +20,25 @@ securityDefinitions: security: - token: [] paths: + /admin/apps/: + get: + operationId: admin_apps_list + description: List current messages and pass into Serializer + parameters: [] + responses: + '200': + description: '' + schema: + type: array + items: + $ref: '#/definitions/App' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - admin + parameters: [] /admin/metrics/: get: operationId: admin_metrics_list @@ -2055,42 +2074,7 @@ paths: get: operationId: core_users_me description: Get information about current user - parameters: - - name: username - in: query - description: '' - required: false - type: string - - name: name - in: query - description: '' - required: false - type: string - - name: is_active - 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 + parameters: [] responses: '200': description: '' @@ -2107,42 +2091,7 @@ paths: get: operationId: core_users_metrics description: User metrics per 1h - parameters: - - name: username - in: query - description: '' - required: false - type: string - - name: name - in: query - description: '' - required: false - type: string - - name: is_active - 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 + parameters: [] responses: '200': description: '' @@ -2290,6 +2239,16 @@ paths: operationId: crypto_certificatekeypairs_list description: CertificateKeyPair Viewset parameters: + - name: name + in: query + description: '' + required: false + type: string + - name: has_key + in: query + description: '' + required: false + type: string - name: ordering in: query description: Which field to use when ordering the results. @@ -2670,6 +2629,25 @@ paths: tags: - events parameters: [] + /events/events/actions/: + get: + operationId: events_events_actions + description: Get all actions + parameters: [] + responses: + '200': + description: '' + schema: + type: array + items: + $ref: '#/definitions/TypeCreate' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - events + parameters: [] /events/events/top_per_user/: get: operationId: events_events_top_per_user @@ -3850,47 +3828,7 @@ paths: get: operationId: flows_instances_cache_info description: Info about cached flows - parameters: - - name: flow_uuid - in: query - description: '' - required: false - type: string - - name: name - in: query - description: '' - required: false - type: string - - name: slug - in: query - description: '' - required: false - type: string - - name: designation - 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 + parameters: [] responses: '200': description: '' @@ -4953,32 +4891,7 @@ paths: get: operationId: outposts_service_connections_all_types description: Get all creatable service connection types - 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 + parameters: [] responses: '200': description: '' @@ -5564,37 +5477,7 @@ paths: get: operationId: policies_all_cache_info description: Info about cached policies - parameters: - - name: bindings__isnull - in: query - description: '' - required: false - type: string - - name: promptstage__isnull - 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 + parameters: [] responses: '200': description: '' @@ -5611,37 +5494,7 @@ paths: get: operationId: policies_all_types description: Get all creatable policy types - parameters: - - name: bindings__isnull - in: query - description: '' - required: false - type: string - - name: promptstage__isnull - 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 + parameters: [] responses: '200': description: '' @@ -7821,32 +7674,7 @@ paths: get: operationId: propertymappings_all_types description: Get all creatable property-mapping types - parameters: - - name: managed__isnull - 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 + parameters: [] responses: '200': description: '' @@ -8617,32 +8445,7 @@ paths: get: operationId: providers_all_types description: Get all creatable provider types - parameters: - - name: application__isnull - 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 + parameters: [] responses: '200': description: '' @@ -9217,6 +9020,42 @@ paths: tags: - providers parameters: [] + /providers/saml/import_metadata/: + post: + operationId: providers_saml_import_metadata + description: Create provider from SAML Metadata + parameters: + - name: name + in: formData + required: true + type: string + minLength: 1 + - name: authorization_flow + in: formData + required: true + type: string + format: slug + pattern: ^[-a-zA-Z0-9_]+$ + - name: file + in: formData + required: true + type: file + responses: + '204': + description: Successfully imported provider + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + consumes: + - multipart/form-data + tags: + - providers + parameters: [] /providers/saml/{id}/: get: operationId: providers_saml_read @@ -9438,27 +9277,7 @@ paths: get: operationId: sources_all_types description: Get all creatable source types - parameters: - - 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 + parameters: [] responses: '200': description: '' @@ -9477,27 +9296,7 @@ paths: get: operationId: sources_all_user_settings description: Get all sources the user can configure - parameters: - - 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 + parameters: [] responses: '200': description: '' @@ -9881,6 +9680,25 @@ paths: tags: - sources parameters: [] + /sources/oauth/source_types/: + get: + operationId: sources_oauth_source_types + description: Get all creatable source types + parameters: [] + responses: + '200': + description: '' + schema: + type: array + items: + $ref: '#/definitions/SourceType' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - sources + parameters: [] /sources/oauth/{slug}/: get: operationId: sources_oauth_read @@ -10495,32 +10313,7 @@ paths: get: operationId: stages_all_types description: Get all creatable stage types - 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 + parameters: [] responses: '200': description: '' @@ -10539,32 +10332,7 @@ paths: 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 + parameters: [] responses: '200': description: '' @@ -14457,6 +14225,20 @@ definitions: code: description: Error code type: string + App: + required: + - name + - label + type: object + properties: + name: + title: Name + type: string + minLength: 1 + label: + title: Label + type: string + minLength: 1 Coordinate: type: object properties: @@ -14642,7 +14424,6 @@ definitions: Provider: required: - name - - application - authorization_flow type: object properties: @@ -14654,9 +14435,6 @@ definitions: title: Name type: string minLength: 1 - application: - title: Application - type: string authorization_flow: title: Authorization flow description: Flow used when authorizing this provider. @@ -14668,8 +14446,8 @@ definitions: type: string format: uuid uniqueItems: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true assigned_application_slug: @@ -14834,6 +14612,15 @@ definitions: type: string format: uuid readOnly: true + managed: + title: Managed by authentik + description: Objects which are managed by authentik. These objects are created + and updated automatically. This is flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the API, + but expect changes to be overwritten in a later update. + type: string + minLength: 1 + x-nullable: true identifier: title: Identifier type: string @@ -15030,6 +14817,25 @@ definitions: title: Expires type: string format: date-time + TypeCreate: + required: + - name + - description + - component + type: object + properties: + name: + title: Name + type: string + minLength: 1 + description: + title: Description + type: string + minLength: 1 + component: + title: Component + type: string + minLength: 1 EventTopPerUser: required: - application @@ -15318,8 +15124,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -15446,7 +15252,6 @@ definitions: OAuth2Provider: required: - name - - application - authorization_flow type: object properties: @@ -15458,9 +15263,6 @@ definitions: title: Name type: string minLength: 1 - application: - title: Application - type: string authorization_flow: title: Authorization flow description: Flow used when authorizing this provider. @@ -15472,8 +15274,8 @@ definitions: type: string format: uuid uniqueItems: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true assigned_application_slug: @@ -15804,8 +15606,8 @@ definitions: description: If enabled, use the local connection. Required Docker socket/Kubernetes Integration type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -15816,25 +15618,6 @@ definitions: title: Verbose name plural type: string readOnly: true - TypeCreate: - required: - - name - - description - - link - type: object - properties: - name: - title: Name - type: string - minLength: 1 - description: - title: Description - type: string - minLength: 1 - link: - title: Link - type: string - minLength: 1 ServiceConnectionState: type: object properties: @@ -15867,8 +15650,8 @@ definitions: description: If enabled, use the local connection. Required Docker socket/Kubernetes Integration type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -15919,8 +15702,8 @@ definitions: description: If enabled, use the local connection. Required Docker socket/Kubernetes Integration type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -15953,8 +15736,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16060,8 +15843,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16106,8 +15889,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16222,8 +16005,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16259,8 +16042,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16304,8 +16087,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16372,8 +16155,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16413,8 +16196,8 @@ definitions: description: When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged. type: boolean - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16496,6 +16279,15 @@ definitions: type: string format: uuid readOnly: true + managed: + title: Managed by authentik + description: Objects which are managed by authentik. These objects are created + and updated automatically. This is flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the API, + but expect changes to be overwritten in a later update. + type: string + minLength: 1 + x-nullable: true name: title: Name type: string @@ -16504,8 +16296,8 @@ definitions: title: Expression type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -16540,6 +16332,15 @@ definitions: type: string format: uuid readOnly: true + managed: + title: Managed by authentik + description: Objects which are managed by authentik. These objects are created + and updated automatically. This is flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the API, + but expect changes to be overwritten in a later update. + type: string + minLength: 1 + x-nullable: true name: title: Name type: string @@ -16548,10 +16349,10 @@ definitions: title: Expression type: string minLength: 1 - object_field: - title: Object field + component: + title: Component type: string - minLength: 1 + readOnly: true verbose_name: title: Verbose name type: string @@ -16560,11 +16361,15 @@ definitions: title: Verbose name plural type: string readOnly: true + object_field: + title: Object field + type: string + minLength: 1 SAMLPropertyMapping: required: - name - - saml_name - expression + - saml_name type: object properties: pk: @@ -16572,10 +16377,35 @@ definitions: type: string format: uuid readOnly: true + managed: + title: Managed by authentik + description: Objects which are managed by authentik. These objects are created + and updated automatically. This is flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the API, + but expect changes to be overwritten in a later update. + type: string + minLength: 1 + x-nullable: true name: title: Name type: string minLength: 1 + expression: + title: Expression + type: string + minLength: 1 + component: + title: Component + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true saml_name: title: SAML Name type: string @@ -16584,10 +16414,39 @@ definitions: title: Friendly name type: string x-nullable: true + ScopeMapping: + required: + - name + - expression + - scope_name + type: object + properties: + pk: + title: Pm uuid + type: string + format: uuid + readOnly: true + managed: + title: Managed by authentik + description: Objects which are managed by authentik. These objects are created + and updated automatically. This is flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the API, + but expect changes to be overwritten in a later update. + type: string + minLength: 1 + x-nullable: true + name: + title: Name + type: string + minLength: 1 expression: title: Expression type: string minLength: 1 + component: + title: Component + type: string + readOnly: true verbose_name: title: Verbose name type: string @@ -16596,22 +16455,6 @@ definitions: title: Verbose name plural type: string readOnly: true - ScopeMapping: - required: - - name - - scope_name - - expression - type: object - properties: - pk: - title: Pm uuid - type: string - format: uuid - readOnly: true - name: - title: Name - type: string - minLength: 1 scope_name: title: Scope name description: Scope used by the client @@ -16622,18 +16465,6 @@ definitions: description: Description shown to the user when consenting. If left empty, the user won't be informed. type: string - expression: - title: Expression - type: string - minLength: 1 - verbose_name: - title: Verbose name - type: string - readOnly: true - verbose_name_plural: - title: Verbose name plural - type: string - readOnly: true OAuth2ProviderSetupURLs: type: object properties: @@ -16664,7 +16495,6 @@ definitions: ProxyProvider: required: - name - - application - authorization_flow - internal_host - external_host @@ -16678,9 +16508,6 @@ definitions: title: Name type: string minLength: 1 - application: - title: Application - type: string authorization_flow: title: Authorization flow description: Flow used when authorizing this provider. @@ -16692,8 +16519,8 @@ definitions: type: string format: uuid uniqueItems: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true assigned_application_slug: @@ -16752,7 +16579,6 @@ definitions: SAMLProvider: required: - name - - application - authorization_flow - acs_url type: object @@ -16765,9 +16591,6 @@ definitions: title: Name type: string minLength: 1 - application: - title: Application - type: string authorization_flow: title: Authorization flow description: Flow used when authorizing this provider. @@ -16779,8 +16602,8 @@ definitions: type: string format: uuid uniqueItems: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true assigned_application_slug: @@ -16870,6 +16693,14 @@ definitions: type: string format: uuid x-nullable: true + sp_binding: + title: Service Provider Binding + description: This determines how authentik sends the response back to the + Service Provider. + type: string + enum: + - redirect + - post SAMLMetadata: type: object properties: @@ -16960,8 +16791,8 @@ definitions: type: string format: uuid x-nullable: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17040,8 +16871,8 @@ definitions: type: string format: uuid x-nullable: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17143,6 +16974,49 @@ definitions: type: string format: date-time readOnly: true + SourceType: + description: Get source's type configuration + required: + - name + - slug + - urls_customizable + type: object + properties: + name: + title: Name + type: string + minLength: 1 + slug: + title: Slug + type: string + minLength: 1 + urls_customizable: + title: Urls customizable + type: boolean + request_token_url: + title: Request token url + type: string + readOnly: true + minLength: 1 + x-nullable: true + authorization_url: + title: Authorization url + type: string + readOnly: true + minLength: 1 + x-nullable: true + access_token_url: + title: Access token url + type: string + readOnly: true + minLength: 1 + x-nullable: true + profile_url: + title: Profile url + type: string + readOnly: true + minLength: 1 + x-nullable: true OAuthSource: required: - name @@ -17188,8 +17062,8 @@ definitions: type: string format: uuid x-nullable: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17247,6 +17121,8 @@ definitions: title: Callback url type: string readOnly: true + type: + $ref: '#/definitions/SourceType' UserOAuthSourceConnection: required: - user @@ -17310,8 +17186,8 @@ definitions: type: string format: uuid x-nullable: true - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17419,8 +17295,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17462,8 +17338,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17505,8 +17381,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17534,7 +17410,10 @@ definitions: items: title: Device classes type: string - minLength: 1 + enum: + - static + - totp + - webauthn configuration_stage: title: Configuration stage description: Stage used to configure Authenticator when user doesn't have @@ -17557,8 +17436,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17596,8 +17475,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17636,8 +17515,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17678,8 +17557,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17708,8 +17587,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17738,8 +17617,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -17822,8 +17701,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -18114,8 +17993,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -18151,8 +18030,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -18173,7 +18052,9 @@ definitions: items: title: Backends type: string - minLength: 1 + enum: + - django.contrib.auth.backends.ModelBackend + - authentik.sources.ldap.auth.LDAPBackend configure_flow: title: Configure flow description: Flow used by an authenticated user to configure this Stage. If @@ -18257,8 +18138,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -18299,8 +18180,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -18329,8 +18210,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -18365,8 +18246,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: @@ -18395,8 +18276,8 @@ definitions: title: Name type: string minLength: 1 - object_type: - title: Object type + component: + title: Component type: string readOnly: true verbose_name: diff --git a/tests/e2e/test_provider_saml.py b/tests/e2e/test_provider_saml.py index df6dac35e..b05c5439c 100644 --- a/tests/e2e/test_provider_saml.py +++ b/tests/e2e/test_provider_saml.py @@ -56,9 +56,10 @@ class TestProviderSAML(SeleniumTestCase): "SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", "SP_METADATA_URL": ( self.url( - "authentik_providers_saml:metadata", - application_slug=provider.application.slug, + "authentik_api:samlprovider-metadata", + pk=provider.pk, ) + + "?download" ), }, ) diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index b332f6784..3d7c3bca4 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -49,7 +49,6 @@ class SeleniumTestCase(StaticLiveServerTestCase): def setUp(self): super().setUp() self.wait_timeout = 60 - makedirs("selenium_screenshots/", exist_ok=True) self.driver = self._get_driver() self.driver.maximize_window() self.driver.implicitly_wait(30) @@ -85,6 +84,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): def tearDown(self): if "TF_BUILD" in environ: + makedirs("selenium_screenshots/", exist_ok=True) screenshot_file = ( f"selenium_screenshots/{self.__class__.__name__}_{time()}.png" ) diff --git a/web/rollup.config.js b/web/rollup.config.js index 98034e240..3517791e9 100644 --- a/web/rollup.config.js +++ b/web/rollup.config.js @@ -12,6 +12,7 @@ const resources = [ { src: "node_modules/@patternfly/patternfly/patternfly-base.css", dest: "dist/" }, { src: "node_modules/@patternfly/patternfly/patternfly.min.css", dest: "dist/" }, + { src: "node_modules/@patternfly/patternfly/patternfly.min.css.map", dest: "dist/" }, { src: "src/authentik.css", dest: "dist/" }, { src: "node_modules/@patternfly/patternfly/assets/*", dest: "dist/assets/" }, diff --git a/web/src/api/Users.ts b/web/src/api/Users.ts index f134d8272..64c30b16b 100644 --- a/web/src/api/Users.ts +++ b/web/src/api/Users.ts @@ -4,7 +4,7 @@ import { DEFAULT_CONFIG } from "./Config"; let _globalMePromise: Promise; export function me(): Promise { if (!_globalMePromise) { - _globalMePromise = new CoreApi(DEFAULT_CONFIG).coreUsersMe({}).catch((ex) => { + _globalMePromise = new CoreApi(DEFAULT_CONFIG).coreUsersMe().catch((ex) => { if (ex.status === 401 || ex.status === 403) { window.location.assign("/"); } diff --git a/web/src/api/legacy.ts b/web/src/api/legacy.ts index bd6a9dc33..871491be5 100644 --- a/web/src/api/legacy.ts +++ b/web/src/api/legacy.ts @@ -1,31 +1,3 @@ -export class AdminURLManager { - - static policies(rest: string): string { - return `/administration/policies/${rest}`; - } - - static providers(rest: string): string { - return `/administration/providers/${rest}`; - } - - static propertyMappings(rest: string): string { - return `/administration/property-mappings/${rest}`; - } - - static outpostServiceConnections(rest: string): string { - return `/administration/outpost_service_connections/${rest}`; - } - - static stages(rest: string): string { - return `/administration/stages/${rest}`; - } - - static sources(rest: string): string { - return `/administration/sources/${rest}`; - } - -} - export class AppURLManager { static sourceSAML(slug: string, rest: string): string { @@ -34,9 +6,6 @@ export class AppURLManager { static sourceOAuth(slug: string, action: string): string { return `/source/oauth/${action}/${slug}/`; } - static providerSAML(rest: string): string { - return `/application/saml/${rest}`; - } } diff --git a/web/src/elements/CodeMirror.ts b/web/src/elements/CodeMirror.ts index 219776dfb..3f8d397b3 100644 --- a/web/src/elements/CodeMirror.ts +++ b/web/src/elements/CodeMirror.ts @@ -1,5 +1,4 @@ -import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; -import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import { css, CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; import CodeMirror from "codemirror"; import "codemirror/addon/display/autorefresh"; @@ -25,12 +24,16 @@ export class CodeMirrorTextarea extends LitElement { editor?: CodeMirror.EditorFromTextArea; - private _value?: string; + _value?: string; @property() set value(v: string) { if (v === null) return; - this._value = v.toString(); + if (this.editor) { + this.editor.setValue(v); + } else { + this._value = v; + } } get value(): string { @@ -45,18 +48,18 @@ export class CodeMirrorTextarea extends LitElement { } private getInnerValue(): string { - if (!this.shadowRoot) { + if (!this.editor) { return ""; } - const ta = this.shadowRoot?.querySelector("textarea"); - if (!ta) { - return ""; - } - return ta.value; + return this.editor.getValue(); } static get styles(): CSSResult[] { - return [PFForm, CodeMirrorStyle, CodeMirrorTheme]; + return [CodeMirrorStyle, CodeMirrorTheme, css` + .CodeMirror-wrap pre { + word-break: break-word !important; + } + `]; } firstUpdated(): void { @@ -70,6 +73,8 @@ export class CodeMirrorTextarea extends LitElement { lineNumbers: false, readOnly: this.readOnly, autoRefresh: true, + lineWrapping: true, + value: this._value }); this.editor.on("blur", () => { this.editor?.save(); @@ -77,6 +82,6 @@ export class CodeMirrorTextarea extends LitElement { } render(): TemplateResult { - return html``; + return html``; } } diff --git a/web/src/elements/charts/UserChart.ts b/web/src/elements/charts/UserChart.ts index 944e0ab1c..877b1da66 100644 --- a/web/src/elements/charts/UserChart.ts +++ b/web/src/elements/charts/UserChart.ts @@ -8,7 +8,7 @@ import { DEFAULT_CONFIG } from "../../api/Config"; export class UserChart extends AKChart { apiRequest(): Promise { - return new CoreApi(DEFAULT_CONFIG).coreUsersMetrics({}); + return new CoreApi(DEFAULT_CONFIG).coreUsersMetrics(); } getDatasets(data: UserMetrics): Chart.ChartDataSets[] { diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index f7b77ea29..ee0ebf3c7 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -12,7 +12,7 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import { MessageLevel } from "../messages/Message"; import { IronFormElement } from "@polymer/iron-form/iron-form"; -import { camelToSnake } from "../../utils"; +import { camelToSnake, convertToSlug } from "../../utils"; import { ValidationError } from "authentik-api/src"; export class APIError extends Error { @@ -47,6 +47,28 @@ export class Form extends LitElement { return this.successMessage; } + updated(): void { + this.shadowRoot?.querySelectorAll("input[name=name]").forEach(nameInput => { + const form = nameInput.closest("form"); + if (form === null) { + return; + } + const slugField = form.querySelector("input[name=slug]"); + if (!slugField) { + return; + } + // Only attach handler if the slug is already equal to the name + // if not, they are probably completely different and shouldn't update + // each other + if (convertToSlug(nameInput.value) !== slugField.value) { + return; + } + nameInput.addEventListener("input", () => { + slugField.value = convertToSlug(nameInput.value); + }); + }); + } + /** * Reset the inner iron-form */ @@ -162,11 +184,7 @@ export class Form extends LitElement {
`; } - render(): TemplateResult { - const rect = this.getBoundingClientRect(); - if (rect.x + rect.y + rect.width + rect.height === 0) { - return html``; - } + renderVisible(): TemplateResult { return html` { this.submit(ev); }}> ${this.renderNonFieldErrors()} @@ -174,4 +192,12 @@ export class Form extends LitElement { `; } + render(): TemplateResult { + const rect = this.getBoundingClientRect(); + if (rect.x + rect.y + rect.width + rect.height === 0) { + return html``; + } + return this.renderVisible(); + } + } diff --git a/web/src/elements/forms/FormGroup.ts b/web/src/elements/forms/FormGroup.ts new file mode 100644 index 000000000..0109be284 --- /dev/null +++ b/web/src/elements/forms/FormGroup.ts @@ -0,0 +1,51 @@ +import { css, CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import AKGlobal from "../../authentik.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; + +@customElement("ak-form-group") +export class FormGroup extends LitElement { + + @property({ type: Boolean }) + expanded = false; + + static get styles(): CSSResult[] { + return [PFBase, PFForm, PFButton, PFFormControl, AKGlobal, css` + slot[name=body][hidden] { + display: none !important; + } + `]; + } + + render(): TemplateResult { + return html`
+
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
`; + } + +} diff --git a/web/src/elements/forms/ProxyForm.ts b/web/src/elements/forms/ProxyForm.ts new file mode 100644 index 000000000..e2b19536b --- /dev/null +++ b/web/src/elements/forms/ProxyForm.ts @@ -0,0 +1,47 @@ +import { customElement, html, property, TemplateResult } from "lit-element"; +import { Form } from "./Form"; + +@customElement("ak-proxy-form") +export class ProxyForm extends Form { + + @property() + type!: string; + + @property({attribute: false}) + args: Record = {}; + + @property({attribute: false}) + typeMap: Record = {}; + + submit(ev: Event): Promise | undefined { + return (this.shadowRoot?.firstElementChild as Form).submit(ev); + } + + reset(): void { + (this.shadowRoot?.firstElementChild as Form | undefined)?.reset(); + } + + getSuccessMessage(): string { + return (this.shadowRoot?.firstElementChild as Form).getSuccessMessage(); + } + + async requestUpdate(name?: PropertyKey | undefined, oldValue?: unknown): Promise { + const result = await super.requestUpdate(name, oldValue); + await (this.shadowRoot?.firstElementChild as Form | undefined)?.requestUpdate(); + return result; + } + + renderVisible(): TemplateResult { + let elementName = this.type; + if (this.type in this.typeMap) { + elementName = this.typeMap[this.type]; + } + const el = document.createElement(elementName); + for (const k in this.args) { + el.setAttribute(k, this.args[k] as string); + (el as unknown as Record)[k] = this.args[k]; + } + return html`${el}`; + } + +} diff --git a/web/src/pages/TestPage.ts b/web/src/pages/TestPage.ts new file mode 100644 index 000000000..00043f98b --- /dev/null +++ b/web/src/pages/TestPage.ts @@ -0,0 +1,86 @@ +import { CSSResult, customElement, html, LitElement, TemplateResult } from "lit-element"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import AKGlobal from "../authentik.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import "../elements/forms/FormGroup"; + +@customElement("ak-test-page") +export class TestPage extends LitElement { + + static get styles(): CSSResult[] { + return [PFBase, PFForm, PFButton, PFFormControl, AKGlobal]; + } + + render(): TemplateResult { + return html`
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+ + + foo + +
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+
`; + } + +} diff --git a/web/src/pages/admin-overview/cards/FlowCacheStatusCard.ts b/web/src/pages/admin-overview/cards/FlowCacheStatusCard.ts index 52a55ecc5..c802e3dda 100644 --- a/web/src/pages/admin-overview/cards/FlowCacheStatusCard.ts +++ b/web/src/pages/admin-overview/cards/FlowCacheStatusCard.ts @@ -9,7 +9,7 @@ import "../../../elements/forms/ConfirmationForm"; export class FlowCacheStatusCard extends AdminStatusCard { getPrimaryValue(): Promise { - return new FlowsApi(DEFAULT_CONFIG).flowsInstancesCacheInfo({}).then((value) => { + return new FlowsApi(DEFAULT_CONFIG).flowsInstancesCacheInfo().then((value) => { return value.count || 0; }); } diff --git a/web/src/pages/admin-overview/cards/PolicyCacheStatusCard.ts b/web/src/pages/admin-overview/cards/PolicyCacheStatusCard.ts index 00df454c2..74e1ac1ce 100644 --- a/web/src/pages/admin-overview/cards/PolicyCacheStatusCard.ts +++ b/web/src/pages/admin-overview/cards/PolicyCacheStatusCard.ts @@ -10,7 +10,7 @@ import "../../../elements/forms/ConfirmationForm"; export class PolicyCacheStatusCard extends AdminStatusCard { getPrimaryValue(): Promise { - return new PoliciesApi(DEFAULT_CONFIG).policiesAllCacheInfo({}).then((value) => { + return new PoliciesApi(DEFAULT_CONFIG).policiesAllCacheInfo().then((value) => { return value.count || 0; }); } diff --git a/web/src/pages/flows/BoundStagesList.ts b/web/src/pages/flows/BoundStagesList.ts index 2f7100a58..991046f46 100644 --- a/web/src/pages/flows/BoundStagesList.ts +++ b/web/src/pages/flows/BoundStagesList.ts @@ -5,6 +5,7 @@ import { Table, TableColumn } from "../../elements/table/Table"; import "../../elements/forms/DeleteForm"; import "../../elements/forms/ModalForm"; +import "../../elements/forms/ProxyForm"; import "./StageBindingForm"; import "../../elements/Tabs"; import "../../elements/buttons/ModalButton"; @@ -15,7 +16,6 @@ import { until } from "lit-html/directives/until"; import { PAGE_SIZE } from "../../constants"; import { FlowsApi, FlowStageBinding, StagesApi } from "authentik-api"; import { DEFAULT_CONFIG } from "../../api/Config"; -import { AdminURLManager } from "../../api/legacy"; import { ifDefined } from "lit-html/directives/if-defined"; @customElement("ak-bound-stages-list") @@ -49,12 +49,24 @@ export class BoundStagesList extends Table { html`${item.stageObj?.name}`, html`${item.stageObj?.verboseName}`, html` - - + + + ${gettext("Update")} + + + ${gettext(`Update ${item.stageObj?.verboseName}`)} + + + + + ${gettext("Update")} @@ -130,10 +142,10 @@ export class BoundStagesList extends Table {