providers/saml: migrate import to API, add API tests
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		| @ -2,7 +2,6 @@ | |||||||
| from django.urls import path | from django.urls import path | ||||||
|  |  | ||||||
| from authentik.admin.views import policies, providers, sources, stages | from authentik.admin.views import policies, providers, sources, stages | ||||||
| from authentik.providers.saml.views.metadata import MetadataImportView |  | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     # Sources |     # Sources | ||||||
| @ -25,11 +24,6 @@ urlpatterns = [ | |||||||
|         providers.ProviderCreateView.as_view(), |         providers.ProviderCreateView.as_view(), | ||||||
|         name="provider-create", |         name="provider-create", | ||||||
|     ), |     ), | ||||||
|     path( |  | ||||||
|         "providers/create/saml/from-metadata/", |  | ||||||
|         MetadataImportView.as_view(), |  | ||||||
|         name="provider-saml-from-metadata", |  | ||||||
|     ), |  | ||||||
|     path( |     path( | ||||||
|         "providers/<int:pk>/update/", |         "providers/<int:pk>/update/", | ||||||
|         providers.ProviderUpdateView.as_view(), |         providers.ProviderUpdateView.as_view(), | ||||||
|  | |||||||
| @ -1,17 +1,33 @@ | |||||||
| """SAMLProvider API Views""" | """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 drf_yasg.utils import swagger_auto_schema | ||||||
| from rest_framework.decorators import action | 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.request import Request | ||||||
| from rest_framework.response import Response | 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 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.propertymappings import PropertyMappingSerializer | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
|  | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import Provider | 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.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): | class SAMLProviderSerializer(ProviderSerializer): | ||||||
| @ -33,19 +49,26 @@ class SAMLProviderSerializer(ProviderSerializer): | |||||||
|             "signature_algorithm", |             "signature_algorithm", | ||||||
|             "signing_kp", |             "signing_kp", | ||||||
|             "verification_kp", |             "verification_kp", | ||||||
|  |             "sp_binding", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class SAMLMetadataSerializer(Serializer): | class SAMLMetadataSerializer(PassiveSerializer): | ||||||
|     """SAML Provider Metadata serializer""" |     """SAML Provider Metadata serializer""" | ||||||
|  |  | ||||||
|     metadata = ReadOnlyField() |     metadata = ReadOnlyField() | ||||||
|  |  | ||||||
|     def create(self, request: Request) -> Response: |  | ||||||
|         raise NotImplementedError |  | ||||||
|  |  | ||||||
|     def update(self, request: Request) -> Response: | class SAMLProviderImportSerializer(PassiveSerializer): | ||||||
|         raise NotImplementedError |     """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): | class SAMLProviderViewSet(ModelViewSet): | ||||||
| @ -61,11 +84,53 @@ class SAMLProviderViewSet(ModelViewSet): | |||||||
|         """Return metadata as XML string""" |         """Return metadata as XML string""" | ||||||
|         provider = self.get_object() |         provider = self.get_object() | ||||||
|         try: |         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}) |             return Response({"metadata": metadata}) | ||||||
|         except Provider.application.RelatedObjectDoesNotExist:  # pylint: disable=no-member |         except Provider.application.RelatedObjectDoesNotExist:  # pylint: disable=no-member | ||||||
|             return Response({"metadata": ""}) |             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): | class SAMLPropertyMappingSerializer(PropertyMappingSerializer): | ||||||
|     """SAMLPropertyMapping Serializer""" |     """SAMLPropertyMapping Serializer""" | ||||||
|  | |||||||
| @ -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"] |  | ||||||
| @ -3,7 +3,6 @@ from typing import Optional, Type | |||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.forms import ModelForm |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| @ -171,10 +170,8 @@ class SAMLProvider(Provider): | |||||||
|         return SAMLProviderSerializer |         return SAMLProviderSerializer | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def form(self) -> Type[ModelForm]: |     def component(self) -> str: | ||||||
|         from authentik.providers.saml.forms import SAMLProviderForm |         return "ak-provider-saml-form" | ||||||
|  |  | ||||||
|         return SAMLProviderForm |  | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return f"SAML Provider {self.name}" |         return f"SAML Provider {self.name}" | ||||||
|  | |||||||
							
								
								
									
										115
									
								
								authentik/providers/saml/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								authentik/providers/saml/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							| @ -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) | ||||||
| @ -1,7 +1,7 @@ | |||||||
| """authentik SAML IDP URLs""" | """authentik SAML IDP URLs""" | ||||||
| from django.urls import path | from django.urls import path | ||||||
|  |  | ||||||
| from authentik.providers.saml.views import metadata, sso | from authentik.providers.saml.views import sso | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     # SSO Bindings |     # SSO Bindings | ||||||
| @ -21,9 +21,4 @@ urlpatterns = [ | |||||||
|         sso.SAMLSSOBindingInitView.as_view(), |         sso.SAMLSSOBindingInitView.as_view(), | ||||||
|         name="sso-init", |         name="sso-init", | ||||||
|     ), |     ), | ||||||
|     path( |  | ||||||
|         "<slug:application_slug>/metadata/", |  | ||||||
|         metadata.DescriptorDownloadView.as_view(), |  | ||||||
|         name="metadata", |  | ||||||
|     ), |  | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -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) |  | ||||||
							
								
								
									
										36
									
								
								swagger.yaml
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								swagger.yaml
									
									
									
									
									
								
							| @ -9227,6 +9227,42 @@ paths: | |||||||
|       tags: |       tags: | ||||||
|         - providers |         - providers | ||||||
|     parameters: [] |     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}/: |   /providers/saml/{id}/: | ||||||
|     get: |     get: | ||||||
|       operationId: providers_saml_read |       operationId: providers_saml_read | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ const resources = [ | |||||||
|  |  | ||||||
|     { src: "node_modules/@patternfly/patternfly/patternfly-base.css", dest: "dist/" }, |     { 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", dest: "dist/" }, | ||||||
|  |     { src: "node_modules/@patternfly/patternfly/patternfly.min.css.map", dest: "dist/" }, | ||||||
|     { src: "src/authentik.css", dest: "dist/" }, |     { src: "src/authentik.css", dest: "dist/" }, | ||||||
|  |  | ||||||
|     { src: "node_modules/@patternfly/patternfly/assets/*", dest: "dist/assets/" }, |     { src: "node_modules/@patternfly/patternfly/assets/*", dest: "dist/assets/" }, | ||||||
|  | |||||||
| @ -22,9 +22,6 @@ export class AppURLManager { | |||||||
|     static sourceOAuth(slug: string, action: string): string { |     static sourceOAuth(slug: string, action: string): string { | ||||||
|         return `/source/oauth/${action}/${slug}/`; |         return `/source/oauth/${action}/${slug}/`; | ||||||
|     } |     } | ||||||
|     static providerSAML(rest: string): string { |  | ||||||
|         return `/application/saml/${rest}`; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ import "../../elements/forms/ProxyForm"; | |||||||
| import "./oauth2/OAuth2ProviderForm"; | import "./oauth2/OAuth2ProviderForm"; | ||||||
| import "./proxy/ProxyProviderForm"; | import "./proxy/ProxyProviderForm"; | ||||||
| import "./saml/SAMLProviderForm"; | import "./saml/SAMLProviderForm"; | ||||||
|  | import "./saml/SAMLProviderImportForm"; | ||||||
| import { TableColumn } from "../../elements/table/Table"; | import { TableColumn } from "../../elements/table/Table"; | ||||||
| import { until } from "lit-html/directives/until"; | import { until } from "lit-html/directives/until"; | ||||||
| import { PAGE_SIZE } from "../../constants"; | import { PAGE_SIZE } from "../../constants"; | ||||||
|  | |||||||
							
								
								
									
										64
									
								
								web/src/pages/providers/saml/SAMLProviderImportForm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								web/src/pages/providers/saml/SAMLProviderImportForm.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<SAMLProvider> { | ||||||
|  |  | ||||||
|  |     getSuccessMessage(): string { | ||||||
|  |         return gettext("Successfully imported provider."); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // eslint-disable-next-line | ||||||
|  |     send = (data: SAMLProvider): Promise<void> => { | ||||||
|  |         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`<form class="pf-c-form pf-m-horizontal"> | ||||||
|  |             <ak-form-element-horizontal | ||||||
|  |                 label=${gettext("Name")} | ||||||
|  |                 ?required=${true} | ||||||
|  |                 name="name"> | ||||||
|  |                 <input type="text" class="pf-c-form-control" required> | ||||||
|  |             </ak-form-element-horizontal> | ||||||
|  |             <ak-form-element-horizontal | ||||||
|  |                 label=${gettext("Authorization flow")} | ||||||
|  |                 ?required=${true} | ||||||
|  |                 name="authorizationFlow"> | ||||||
|  |                 <select class="pf-c-form-control"> | ||||||
|  |                     ${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({ | ||||||
|  |                         ordering: "pk", | ||||||
|  |                         designation: FlowDesignationEnum.Authorization, | ||||||
|  |                     }).then(flows => { | ||||||
|  |                         return flows.results.map(flow => { | ||||||
|  |                             return html`<option value=${ifDefined(flow.pk)}>${flow.name}</option>`; | ||||||
|  |                         }); | ||||||
|  |                     }))} | ||||||
|  |                 </select> | ||||||
|  |                 <p class="pf-c-form__helper-text">${gettext("Flow used when authorizing this provider.")}</p> | ||||||
|  |             </ak-form-element-horizontal> | ||||||
|  |  | ||||||
|  |             <ak-form-element-horizontal | ||||||
|  |                 label=${gettext("Metadata")} | ||||||
|  |                 name="flow"> | ||||||
|  |                 <input type="file" value="" class="pf-c-form-control"> | ||||||
|  |             </ak-form-element-horizontal> | ||||||
|  |         </form>`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @ -12,6 +12,7 @@ import PFFlex from "@patternfly/patternfly/utilities/Flex/flex.css"; | |||||||
| import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; | import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; | ||||||
| import AKGlobal from "../../../authentik.css"; | import AKGlobal from "../../../authentik.css"; | ||||||
| import PFBase from "@patternfly/patternfly/patternfly-base.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/ModalButton"; | ||||||
| import "../../../elements/buttons/SpinnerButton"; | import "../../../elements/buttons/SpinnerButton"; | ||||||
| @ -23,7 +24,6 @@ import "./SAMLProviderForm"; | |||||||
| import { Page } from "../../../elements/Page"; | import { Page } from "../../../elements/Page"; | ||||||
| import { ProvidersApi, SAMLProvider } from "authentik-api"; | import { ProvidersApi, SAMLProvider } from "authentik-api"; | ||||||
| import { DEFAULT_CONFIG } from "../../../api/Config"; | import { DEFAULT_CONFIG } from "../../../api/Config"; | ||||||
| import { AppURLManager } from "../../../api/legacy"; |  | ||||||
| import { EVENT_REFRESH } from "../../../constants"; | import { EVENT_REFRESH } from "../../../constants"; | ||||||
| import { ifDefined } from "lit-html/directives/if-defined"; | import { ifDefined } from "lit-html/directives/if-defined"; | ||||||
|  |  | ||||||
| @ -55,7 +55,7 @@ export class SAMLProviderViewPage extends Page { | |||||||
|     provider?: SAMLProvider; |     provider?: SAMLProvider; | ||||||
|  |  | ||||||
|     static get styles(): CSSResult[] { |     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() { |     constructor() { | ||||||
| @ -153,27 +153,28 @@ export class SAMLProviderViewPage extends Page { | |||||||
|                         </div> |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|                 </section> |                 </section> | ||||||
|  |                 ${this.provider.assignedApplicationName ? html` | ||||||
|                 <section slot="page-3" data-tab-title="${gettext("Metadata")}" class="pf-c-page__main-section pf-m-no-padding-mobile"> |                 <section slot="page-3" data-tab-title="${gettext("Metadata")}" class="pf-c-page__main-section pf-m-no-padding-mobile"> | ||||||
|                     <div class="pf-u-display-flex pf-u-justify-content-center"> |                     <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||||
|                         <div class="pf-u-w-75"> |                         <div class="pf-u-w-75"> | ||||||
|                             <div class="pf-c-card"> |                             <div class="pf-c-card"> | ||||||
|                                 <div class="pf-c-card__body"> |                                 <div class="pf-c-card__body"> | ||||||
|                                     ${until( |                                     ${until(new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({ | ||||||
|                                         new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({ |                                         id: this.provider.pk || 0, | ||||||
|                                             id: this.provider.pk || 0, |                                     }).then(m => { | ||||||
|                                         }).then(m => { |                                         return html`<ak-codemirror mode="xml" ?readOnly=${true} value="${ifDefined(m.metadata)}"></ak-codemirror>`; | ||||||
|                                             return html`<ak-codemirror mode="xml" ?readOnly=${true} value="${ifDefined(m.metadata)}"></ak-codemirror>`; |                                     }))} | ||||||
|                                         }) |  | ||||||
|                                     )} |  | ||||||
|                                 </div> |                                 </div> | ||||||
|                                 <div class="pf-c-card__footer"> |                                 <div class="pf-c-card__footer"> | ||||||
|                                     <a class="pf-c-button pf-m-primary" target="_blank" href="${AppURLManager.providerSAML(`${this.provider.assignedApplicationSlug}/metadata/`)}"> |                                     <a class="pf-c-button pf-m-primary" target="_blank" | ||||||
|  |                                         href="/api/v2beta/providers/saml/${this.provider.pk}/metadata/?download"> | ||||||
|                                         ${gettext("Download")} |                                         ${gettext("Download")} | ||||||
|                                     </a> |                                     </a> | ||||||
|                                 </div> |                                 </div> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|  |                 ` : html``} | ||||||
|                 </section> |                 </section> | ||||||
|             </ak-tabs>`; |             </ak-tabs>`; | ||||||
|     } |     } | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer