diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py index f0965c067..f52620f5f 100644 --- a/authentik/admin/urls.py +++ b/authentik/admin/urls.py @@ -2,7 +2,6 @@ from django.urls import path from authentik.admin.views import policies, providers, sources, stages -from authentik.providers.saml.views.metadata import MetadataImportView urlpatterns = [ # Sources @@ -25,11 +24,6 @@ urlpatterns = [ 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(), diff --git a/authentik/providers/saml/api.py b/authentik/providers/saml/api.py index e92e8ba41..5d8837897 100644 --- a/authentik/providers/saml/api.py +++ b/authentik/providers/saml/api.py @@ -1,17 +1,33 @@ """SAMLProvider API Views""" +from xml.etree.ElementTree import ParseError # nosec + +from defusedxml.ElementTree import fromstring +from django.http.response import HttpResponse +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.relations import SlugRelatedField 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 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 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 +49,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): @@ -61,11 +84,53 @@ class SAMLProviderViewSet(ModelViewSet): """Return metadata as XML string""" provider = self.get_object() 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(PropertyMappingSerializer): """SAMLPropertyMapping Serializer""" diff --git a/authentik/providers/saml/forms.py b/authentik/providers/saml/forms.py deleted file mode 100644 index 6d258b4f2..000000000 --- a/authentik/providers/saml/forms.py +++ /dev/null @@ -1,78 +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.translation import gettext_lazy as _ - -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 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 9be9559f5..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}" diff --git a/authentik/providers/saml/tests/test_api.py b/authentik/providers/saml/tests/test_api.py new file mode 100644 index 000000000..6e156d20a --- /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() + .pk, + }, + 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() + .pk, + }, + 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/swagger.yaml b/swagger.yaml index 263ba6629..fd51c3569 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -9227,6 +9227,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 diff --git a/web/rollup.config.js b/web/rollup.config.js index 84045077d..2a0f7fae2 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/legacy.ts b/web/src/api/legacy.ts index 8c9986c0b..156bf7fe5 100644 --- a/web/src/api/legacy.ts +++ b/web/src/api/legacy.ts @@ -22,9 +22,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/pages/providers/ProviderListPage.ts b/web/src/pages/providers/ProviderListPage.ts index 5a797e52f..15a71edb7 100644 --- a/web/src/pages/providers/ProviderListPage.ts +++ b/web/src/pages/providers/ProviderListPage.ts @@ -11,6 +11,7 @@ import "../../elements/forms/ProxyForm"; import "./oauth2/OAuth2ProviderForm"; import "./proxy/ProxyProviderForm"; import "./saml/SAMLProviderForm"; +import "./saml/SAMLProviderImportForm"; import { TableColumn } from "../../elements/table/Table"; import { until } from "lit-html/directives/until"; import { PAGE_SIZE } from "../../constants"; diff --git a/web/src/pages/providers/saml/SAMLProviderImportForm.ts b/web/src/pages/providers/saml/SAMLProviderImportForm.ts new file mode 100644 index 000000000..1116e2251 --- /dev/null +++ b/web/src/pages/providers/saml/SAMLProviderImportForm.ts @@ -0,0 +1,64 @@ +import { FlowDesignationEnum, FlowsApi, ProvidersApi, SAMLProvider } from "authentik-api"; +import { gettext } from "django"; +import { customElement } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { ifDefined } from "lit-html/directives/if-defined"; +import { until } from "lit-html/directives/until"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { Form } from "../../../elements/forms/Form"; +import "../../../elements/forms/HorizontalFormElement"; + +@customElement("ak-provider-saml-import-form") +export class SAMLProviderImportForm extends Form { + + getSuccessMessage(): string { + return gettext("Successfully imported provider."); + } + + // eslint-disable-next-line + send = (data: SAMLProvider): Promise => { + const file = this.getFormFile(); + if (!file) { + throw new Error("No form data"); + } + return new ProvidersApi(DEFAULT_CONFIG).providersSamlImportMetadata({ + file: file, + name: data.name, + authorizationFlow: data.authorizationFlow, + }); + }; + + renderForm(): TemplateResult { + return html`
+ + + + + +

${gettext("Flow used when authorizing this provider.")}

+
+ + + + +
`; + } + +} diff --git a/web/src/pages/providers/saml/SAMLProviderViewPage.ts b/web/src/pages/providers/saml/SAMLProviderViewPage.ts index 685f4a794..09f64f808 100644 --- a/web/src/pages/providers/saml/SAMLProviderViewPage.ts +++ b/web/src/pages/providers/saml/SAMLProviderViewPage.ts @@ -12,6 +12,7 @@ import PFFlex from "@patternfly/patternfly/utilities/Flex/flex.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; import AKGlobal from "../../../authentik.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; import "../../../elements/buttons/ModalButton"; import "../../../elements/buttons/SpinnerButton"; @@ -23,7 +24,6 @@ import "./SAMLProviderForm"; import { Page } from "../../../elements/Page"; import { ProvidersApi, SAMLProvider } from "authentik-api"; import { DEFAULT_CONFIG } from "../../../api/Config"; -import { AppURLManager } from "../../../api/legacy"; import { EVENT_REFRESH } from "../../../constants"; import { ifDefined } from "lit-html/directives/if-defined"; @@ -55,7 +55,7 @@ export class SAMLProviderViewPage extends Page { provider?: SAMLProvider; static get styles(): CSSResult[] { - return [PFBase, PFPage, PFFlex, PFDisplay, PFGallery, PFContent, PFCard, PFDescriptionList, PFSizing, AKGlobal]; + return [PFBase, PFPage, PFButton, PFFlex, PFDisplay, PFGallery, PFContent, PFCard, PFDescriptionList, PFSizing, AKGlobal]; } constructor() { @@ -153,27 +153,28 @@ export class SAMLProviderViewPage extends Page { + ${this.provider.assignedApplicationName ? html`
- ${until( - new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({ - id: this.provider.pk || 0, - }).then(m => { - return html``; - }) - )} + ${until(new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({ + id: this.provider.pk || 0, + }).then(m => { + return html``; + }))}
+ ` : html``}
`; }