providers/saml: migrate import to API, add API tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-04-01 19:28:12 +02:00
parent 4e3701ca8d
commit 5eb9b95ab5
13 changed files with 305 additions and 198 deletions

View file

@ -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/<int:pk>/update/",
providers.ProviderUpdateView.as_view(),

View file

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

View file

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

View file

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

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

View file

@ -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(
"<slug:application_slug>/metadata/",
metadata.DescriptorDownloadView.as_view(),
name="metadata",
),
]

View file

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

View file

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

View file

@ -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/" },

View file

@ -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}`;
}
}

View file

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

View 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>`;
}
}

View file

@ -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 {
</div>
</div>
</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">
<div class="pf-u-display-flex pf-u-justify-content-center">
<div class="pf-u-w-75">
<div class="pf-c-card">
<div class="pf-c-card__body">
${until(
new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({
${until(new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({
id: this.provider.pk || 0,
}).then(m => {
return html`<ak-codemirror mode="xml" ?readOnly=${true} value="${ifDefined(m.metadata)}"></ak-codemirror>`;
})
)}
}))}
</div>
<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")}
</a>
</div>
</div>
</div>
</div>
` : html``}
</section>
</ak-tabs>`;
}