providers/saml: Metadata Import (#432)
* providers/saml: add basic metadata parser * providers/saml: add importer for Singing certificate, validate signature, add tests * providers/saml: add provider name to form, * web: don't use trailing slash for spa URLs * providers/saml: formatting fixes * sources/*: add verbose_name to source serializers * admin: add button launch import modal
This commit is contained in:
parent
5797a3743a
commit
a6d0c8c26c
|
@ -14,4 +14,6 @@ SOURCE_SERIALIZER_FIELDS = [
|
||||||
"enabled",
|
"enabled",
|
||||||
"authentication_flow",
|
"authentication_flow",
|
||||||
"enrollment_flow",
|
"enrollment_flow",
|
||||||
|
"verbose_name",
|
||||||
|
"verbose_name_plural",
|
||||||
]
|
]
|
||||||
|
|
|
@ -53,7 +53,7 @@
|
||||||
{% for flow in object_list %}
|
{% for flow in object_list %}
|
||||||
<tr role="row">
|
<tr role="row">
|
||||||
<th role="columnheader">
|
<th role="columnheader">
|
||||||
<a href="/flows/{{ flow.slug }}/">
|
<a href="/flows/{{ flow.slug }}">
|
||||||
<div><code>{{ flow.slug }}</code></div>
|
<div><code>{{ flow.slug }}</code></div>
|
||||||
<small>{{ flow.name }}</small>
|
<small>{{ flow.name }}</small>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -41,6 +41,17 @@
|
||||||
</ak-modal-button>
|
</ak-modal-button>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<li>
|
||||||
|
<ak-modal-button href="{% url 'authentik_admin:provider-saml-from-metadata' %}">
|
||||||
|
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||||
|
{% trans 'SAML Provider from Metadata' %}<br>
|
||||||
|
<small>
|
||||||
|
{% trans "Create a SAML Provider by importing its Metadata." %}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
<div slot="modal"></div>
|
||||||
|
</ak-modal-button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</ak-dropdown>
|
</ak-dropdown>
|
||||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||||
|
|
|
@ -22,6 +22,7 @@ from authentik.admin.views import (
|
||||||
tokens,
|
tokens,
|
||||||
users,
|
users,
|
||||||
)
|
)
|
||||||
|
from authentik.providers.saml.views import MetadataImportView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
@ -116,6 +117,11 @@ 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(),
|
||||||
|
|
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||||
|
|
||||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||||
|
from authentik.core.api.utils import MetaNameSerializer
|
||||||
from authentik.core.models import Source
|
from authentik.core.models import Source
|
||||||
|
|
||||||
|
|
||||||
class SourceSerializer(ModelSerializer):
|
class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""Source Serializer"""
|
"""Source Serializer"""
|
||||||
|
|
||||||
__type__ = SerializerMethodField(method_name="get_type")
|
__type__ = SerializerMethodField(method_name="get_type")
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
"""authentik SAML IDP Forms"""
|
"""authentik SAML IDP Forms"""
|
||||||
|
|
||||||
|
from xml.etree.ElementTree import ParseError # nosec
|
||||||
|
|
||||||
|
from defusedxml.ElementTree import fromstring
|
||||||
from django import forms
|
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.html import mark_safe
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from authentik.admin.fields import CodeMirrorWidget
|
from authentik.admin.fields import CodeMirrorWidget
|
||||||
from authentik.core.expression import PropertyMappingEvaluator
|
from authentik.core.expression import PropertyMappingEvaluator
|
||||||
|
@ -83,3 +88,22 @@ class SAMLPropertyMappingForm(forms.ModelForm):
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SAMLProviderImportForm(forms.Form):
|
||||||
|
"""Create a SAML Provider from SP Metadata."""
|
||||||
|
|
||||||
|
provider_name = forms.CharField()
|
||||||
|
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"]
|
||||||
|
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""SAML ServiceProvider Metadata Parser and dataclass"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import xmlsec
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.x509 import load_pem_x509_certificate
|
||||||
|
from lxml import etree # nosec
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||||
|
from authentik.providers.saml.utils.encoding import PEM_FOOTER, PEM_HEADER
|
||||||
|
from authentik.sources.saml.processors.constants import (
|
||||||
|
NS_MAP,
|
||||||
|
NS_SAML_METADATA,
|
||||||
|
SAML_BINDING_POST,
|
||||||
|
SAML_BINDING_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def format_pem_certificate(unformatted_cert: str) -> str:
|
||||||
|
"""Format single, inline certificate into PEM Format"""
|
||||||
|
chunks, chunk_size = len(unformatted_cert), 64
|
||||||
|
lines = [PEM_HEADER]
|
||||||
|
for i in range(0, chunks, chunk_size):
|
||||||
|
lines.append(unformatted_cert[i : i + chunk_size]) # noqa: E203
|
||||||
|
lines.append(PEM_FOOTER)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceProviderMetadata:
|
||||||
|
"""SP Metadata Dataclass"""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
|
||||||
|
acs_binding: str
|
||||||
|
acs_location: str
|
||||||
|
|
||||||
|
auth_n_request_signed: bool
|
||||||
|
assertion_signed: bool
|
||||||
|
|
||||||
|
signing_keypair: Optional[CertificateKeyPair] = None
|
||||||
|
|
||||||
|
def to_provider(self, name: str) -> SAMLProvider:
|
||||||
|
"""Create a SAMLProvider instance from the details. `name` is required,
|
||||||
|
as depending on the metadata CertificateKeypairs might have to be created."""
|
||||||
|
provider = SAMLProvider(name=name)
|
||||||
|
provider.issuer = self.entity_id
|
||||||
|
provider.sp_binding = self.acs_binding
|
||||||
|
provider.acs_url = self.acs_location
|
||||||
|
if self.signing_keypair:
|
||||||
|
self.signing_keypair.name = f"Provider {name} - SAML Signing Certificate"
|
||||||
|
self.signing_keypair.save()
|
||||||
|
provider.signing_kp = self.signing_keypair
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceProviderMetadataParser:
|
||||||
|
"""Service-Provider Metadata Parser"""
|
||||||
|
|
||||||
|
def get_signing_cert(self, root: etree.Element) -> Optional[CertificateKeyPair]:
|
||||||
|
"""Extract X509Certificate from metadata, when given."""
|
||||||
|
signing_certs = root.xpath(
|
||||||
|
'//md:SPSSODescriptor/md:KeyDescriptor[@use="signing"]//ds:X509Certificate/text()',
|
||||||
|
namespaces=NS_MAP,
|
||||||
|
)
|
||||||
|
if len(signing_certs) < 1:
|
||||||
|
return None
|
||||||
|
raw_cert = format_pem_certificate(signing_certs[0])
|
||||||
|
# sanity check, make sure the certificate is valid.
|
||||||
|
load_pem_x509_certificate(raw_cert.encode("utf-8"), default_backend())
|
||||||
|
return CertificateKeyPair(
|
||||||
|
certificate_data=raw_cert,
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_signature(self, root: etree.Element, keypair: CertificateKeyPair):
|
||||||
|
"""If Metadata is signed, check validity of signature"""
|
||||||
|
xmlsec.tree.add_ids(root, ["ID"])
|
||||||
|
signature_nodes = root.xpath(
|
||||||
|
"/md:EntityDescriptor/ds:Signature", namespaces=NS_MAP
|
||||||
|
)
|
||||||
|
if len(signature_nodes) != 1:
|
||||||
|
# No Signature
|
||||||
|
return
|
||||||
|
|
||||||
|
signature_node = signature_nodes[0]
|
||||||
|
|
||||||
|
if signature_node is not None:
|
||||||
|
try:
|
||||||
|
ctx = xmlsec.SignatureContext()
|
||||||
|
key = xmlsec.Key.from_memory(
|
||||||
|
keypair.certificate_data,
|
||||||
|
xmlsec.constants.KeyDataFormatCertPem,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
ctx.key = key
|
||||||
|
ctx.verify(signature_node)
|
||||||
|
except xmlsec.VerificationError as exc:
|
||||||
|
raise ValueError("Failed to verify Metadata signature") from exc
|
||||||
|
|
||||||
|
def parse(self, raw_xml: str) -> ServiceProviderMetadata:
|
||||||
|
"""Parse raw XML to ServiceProviderMetadata"""
|
||||||
|
root = etree.fromstring(raw_xml) # nosec
|
||||||
|
|
||||||
|
entity_id = root.attrib["entityID"]
|
||||||
|
sp_sso_descriptors = root.findall(f"{{{NS_SAML_METADATA}}}SPSSODescriptor")
|
||||||
|
if len(sp_sso_descriptors) < 1:
|
||||||
|
raise ValueError("no SPSSODescriptor objects found.")
|
||||||
|
# For now we'll only look at the first descriptor.
|
||||||
|
# Even if multiple descriptors exist, we can only configure one
|
||||||
|
descriptor = sp_sso_descriptors[0]
|
||||||
|
auth_n_request_signed = descriptor.attrib["AuthnRequestsSigned"]
|
||||||
|
assertion_signed = descriptor.attrib["WantAssertionsSigned"]
|
||||||
|
|
||||||
|
acs_services = descriptor.findall(
|
||||||
|
f"{{{NS_SAML_METADATA}}}AssertionConsumerService"
|
||||||
|
)
|
||||||
|
if len(acs_services) < 1:
|
||||||
|
raise ValueError("No AssertionConsumerService found.")
|
||||||
|
|
||||||
|
acs_service = acs_services[0]
|
||||||
|
acs_binding = {
|
||||||
|
SAML_BINDING_REDIRECT: SAMLBindings.REDIRECT,
|
||||||
|
SAML_BINDING_POST: SAMLBindings.POST,
|
||||||
|
}[acs_service.attrib["Binding"]]
|
||||||
|
acs_location = acs_service.attrib["Location"]
|
||||||
|
|
||||||
|
signing_keypair = self.get_signing_cert(root)
|
||||||
|
if signing_keypair:
|
||||||
|
self.check_signature(root, signing_keypair)
|
||||||
|
|
||||||
|
return ServiceProviderMetadata(
|
||||||
|
entity_id=entity_id,
|
||||||
|
acs_binding=acs_binding,
|
||||||
|
acs_location=acs_location,
|
||||||
|
auth_n_request_signed=auth_n_request_signed,
|
||||||
|
assertion_signed=assertion_signed,
|
||||||
|
signing_keypair=signing_keypair,
|
||||||
|
)
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends base_template|default:"generic/form.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block above_form %}
|
||||||
|
<h1>
|
||||||
|
{% trans 'Import SAML Metadata' %}
|
||||||
|
</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block action %}
|
||||||
|
{% trans 'Import Metadata' %}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Test Service-Provider Metadata Parser"""
|
||||||
|
# flake8: noqa
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from authentik.providers.saml.models import SAMLBindings
|
||||||
|
from authentik.providers.saml.processors.metadata_parser import (
|
||||||
|
ServiceProviderMetadataParser,
|
||||||
|
)
|
||||||
|
|
||||||
|
METADATA_SIMPLE = """<?xml version="1.0"?>
|
||||||
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||||
|
validUntil="2020-12-24T15:14:47Z"
|
||||||
|
cacheDuration="PT604800S"
|
||||||
|
entityID="http://localhost:8080/saml/metadata">
|
||||||
|
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||||
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
|
||||||
|
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||||
|
Location="http://localhost:8080/saml/acs"
|
||||||
|
index="1" />
|
||||||
|
|
||||||
|
</md:SPSSODescriptor>
|
||||||
|
</md:EntityDescriptor>"""
|
||||||
|
|
||||||
|
METADATA_CERT = """<?xml version="1.0"?>
|
||||||
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" validUntil="2020-12-24T16:25:48Z" cacheDuration="PT604800S" entityID="http://localhost:8080/apps/user_saml/saml/metadata" ID="pfx67a2a913-dd26-d65e-64c2-503fcd5eea47"><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
|
||||||
|
<ds:Reference URI="#pfx67a2a913-dd26-d65e-64c2-503fcd5eea47"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>dPEwDuMayrdAgjhYvtiy3GTcP0n+BDi3sX+aistTDac=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>MMspOlqURPLT2ELAD5RRHw4mBIWBGWAEedwMM6Dh7kzhJE/1z+dNgobM8e1Lr9Ztd/ygW7oUfwfbUqS8OVCxdI+fJtzai0Jm08/rq8VpNEZqTfALNQqlGDD2Uma63/8iFycrhYkaY8feoyT0ZfHTNuMDxHjtl1l52+q+1mx6ax5w9z/WzuSKBTndg+Gjh0XTN0ynI136MgXrJg4dBFtkIKq9u6PlJW42C+uqAoRoi29Vil6mT/dgctS2ZB118nGFeryN4oi2zgM9lkmWW6E5YtPdQxggKJqR8Zl+XnyHt3nh1X7uWN+691lXO6LG1YXtagD/BSMeUnfMV/dLCptv3w==</ds:SignatureValue>
|
||||||
|
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDBzCCAe+gAwIBAgIQR8PqIN59R5mRygzU9JfD2TANBgkqhkiG9w0BAQsFADArMSkwJwYDVQQDDCBwYXNzYm9vayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTAeFw0yMDA3MDUxMTEzMTRaFw0yMTA3MDYxMTEzMTRaMFQxKTAnBgNVBAMMIHBhc3Nib29rIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMREwDwYDVQQKDAhwYXNzYm9vazEUMBIGA1UECwwLU2VsZi1zaWduZWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC89aKl+gUw7X0d25LY5PQ0DuOlrIQf1iaY/X4uM1FNAaMfAnd653EE+ugerZWvAtRGke2QElVgsvvGfMN6XfH/zSWxkklvXPsuM2HfW6Yv8FcaQRTB2jXBUzU6wNjwF8V8TdFPpbVzQxXOvJyP8Z2YhUNRjy0OQzTV5bD21b2fhpWcNCCSJxIeZQxKjwQNpt6xiGt0JXODgE8hp7psrKBMYa2O3MAmdCu1b0ixbfkam0Vm9vDHUyjRi2N9S7lkiIP2fYEDi0v0zRq9/yV/0MEel4towY+WcNnLvKTsww80vFrgUI4K0r4bS9uBWZaClvycE0ZSMYHZG6TZBCUtEd2JAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAFMbcuq8oL2tfvBFMktv722r2FbbRXs7ky4IE2m42eaGEuhgdmrLsHaH4m3X+1TZgCufMGgoF8iVKQmpeaPSCDPKGW+yue+HLqCk5PIeQISDrEUwWNLx8lza7tm9Xdr1B3Q8/jxv1qtokhzhaBkCvYy92gvIgio5QaFKaFOSIp2Crrhh+R+uvmtronKe8RPx6XEk4EaAvXAfgGUV+xoQ9b54mt8gBwmZuLz86vIQBFSjOmPXEWYHe3FXAsB6i5eJMXtdBF7C3VbL3wBqOqddBPQ6+ojY+cUmNqVYbtXmAcjIveoJDi8Rs4F5pmlhGahnMgW8mqYtbHlUY7ytSUTowXA=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
|
||||||
|
<md:SPSSODescriptor AuthnRequestsSigned="true" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||||
|
<md:KeyDescriptor use="signing">
|
||||||
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:X509Data>
|
||||||
|
<ds:X509Certificate>MIIDBzCCAe+gAwIBAgIQR8PqIN59R5mRygzU9JfD2TANBgkqhkiG9w0BAQsFADArMSkwJwYDVQQDDCBwYXNzYm9vayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTAeFw0yMDA3MDUxMTEzMTRaFw0yMTA3MDYxMTEzMTRaMFQxKTAnBgNVBAMMIHBhc3Nib29rIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMREwDwYDVQQKDAhwYXNzYm9vazEUMBIGA1UECwwLU2VsZi1zaWduZWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC89aKl+gUw7X0d25LY5PQ0DuOlrIQf1iaY/X4uM1FNAaMfAnd653EE+ugerZWvAtRGke2QElVgsvvGfMN6XfH/zSWxkklvXPsuM2HfW6Yv8FcaQRTB2jXBUzU6wNjwF8V8TdFPpbVzQxXOvJyP8Z2YhUNRjy0OQzTV5bD21b2fhpWcNCCSJxIeZQxKjwQNpt6xiGt0JXODgE8hp7psrKBMYa2O3MAmdCu1b0ixbfkam0Vm9vDHUyjRi2N9S7lkiIP2fYEDi0v0zRq9/yV/0MEel4towY+WcNnLvKTsww80vFrgUI4K0r4bS9uBWZaClvycE0ZSMYHZG6TZBCUtEd2JAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAFMbcuq8oL2tfvBFMktv722r2FbbRXs7ky4IE2m42eaGEuhgdmrLsHaH4m3X+1TZgCufMGgoF8iVKQmpeaPSCDPKGW+yue+HLqCk5PIeQISDrEUwWNLx8lza7tm9Xdr1B3Q8/jxv1qtokhzhaBkCvYy92gvIgio5QaFKaFOSIp2Crrhh+R+uvmtronKe8RPx6XEk4EaAvXAfgGUV+xoQ9b54mt8gBwmZuLz86vIQBFSjOmPXEWYHe3FXAsB6i5eJMXtdBF7C3VbL3wBqOqddBPQ6+ojY+cUmNqVYbtXmAcjIveoJDi8Rs4F5pmlhGahnMgW8mqYtbHlUY7ytSUTowXA=</ds:X509Certificate>
|
||||||
|
</ds:X509Data>
|
||||||
|
</ds:KeyInfo>
|
||||||
|
</md:KeyDescriptor>
|
||||||
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||||
|
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8080/apps/user_saml/saml/acs" index="1"/>
|
||||||
|
</md:SPSSODescriptor>
|
||||||
|
</md:EntityDescriptor>"""
|
||||||
|
|
||||||
|
CERT = """-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDBzCCAe+gAwIBAgIQR8PqIN59R5mRygzU9JfD2TANBgkqhkiG9w0BAQsFADAr
|
||||||
|
MSkwJwYDVQQDDCBwYXNzYm9vayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTAeFw0y
|
||||||
|
MDA3MDUxMTEzMTRaFw0yMTA3MDYxMTEzMTRaMFQxKTAnBgNVBAMMIHBhc3Nib29r
|
||||||
|
IFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMREwDwYDVQQKDAhwYXNzYm9vazEUMBIG
|
||||||
|
A1UECwwLU2VsZi1zaWduZWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
|
||||||
|
AQC89aKl+gUw7X0d25LY5PQ0DuOlrIQf1iaY/X4uM1FNAaMfAnd653EE+ugerZWv
|
||||||
|
AtRGke2QElVgsvvGfMN6XfH/zSWxkklvXPsuM2HfW6Yv8FcaQRTB2jXBUzU6wNjw
|
||||||
|
F8V8TdFPpbVzQxXOvJyP8Z2YhUNRjy0OQzTV5bD21b2fhpWcNCCSJxIeZQxKjwQN
|
||||||
|
pt6xiGt0JXODgE8hp7psrKBMYa2O3MAmdCu1b0ixbfkam0Vm9vDHUyjRi2N9S7lk
|
||||||
|
iIP2fYEDi0v0zRq9/yV/0MEel4towY+WcNnLvKTsww80vFrgUI4K0r4bS9uBWZaC
|
||||||
|
lvycE0ZSMYHZG6TZBCUtEd2JAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAFMbcuq8
|
||||||
|
oL2tfvBFMktv722r2FbbRXs7ky4IE2m42eaGEuhgdmrLsHaH4m3X+1TZgCufMGgo
|
||||||
|
F8iVKQmpeaPSCDPKGW+yue+HLqCk5PIeQISDrEUwWNLx8lza7tm9Xdr1B3Q8/jxv
|
||||||
|
1qtokhzhaBkCvYy92gvIgio5QaFKaFOSIp2Crrhh+R+uvmtronKe8RPx6XEk4EaA
|
||||||
|
vXAfgGUV+xoQ9b54mt8gBwmZuLz86vIQBFSjOmPXEWYHe3FXAsB6i5eJMXtdBF7C
|
||||||
|
3VbL3wBqOqddBPQ6+ojY+cUmNqVYbtXmAcjIveoJDi8Rs4F5pmlhGahnMgW8mqYt
|
||||||
|
bHlUY7ytSUTowXA=
|
||||||
|
-----END CERTIFICATE-----"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceProviderMetadataParser(TestCase):
|
||||||
|
"""Test ServiceProviderMetadataParser parsing and creation of SAML Provider"""
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
"""Test simple metadata without Singing"""
|
||||||
|
metadata = ServiceProviderMetadataParser().parse(METADATA_SIMPLE)
|
||||||
|
provider = metadata.to_provider("test")
|
||||||
|
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
|
||||||
|
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
|
||||||
|
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
|
||||||
|
|
||||||
|
def test_with_signing_cert(self):
|
||||||
|
"""Test Metadata with signing cert"""
|
||||||
|
metadata = ServiceProviderMetadataParser().parse(METADATA_CERT)
|
||||||
|
provider = metadata.to_provider("test")
|
||||||
|
self.assertEqual(
|
||||||
|
provider.acs_url, "http://localhost:8080/apps/user_saml/saml/acs"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
provider.issuer, "http://localhost:8080/apps/user_saml/saml/metadata"
|
||||||
|
)
|
||||||
|
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
|
||||||
|
self.assertEqual(provider.signing_kp.certificate_data, CERT)
|
||||||
|
|
||||||
|
def test_with_signing_cert_invalid_signature(self):
|
||||||
|
"""Test Metadata with signing cert (invalid signature)"""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
ServiceProviderMetadataParser().parse(
|
||||||
|
METADATA_CERT.replace("/apps/user_saml", "")
|
||||||
|
)
|
|
@ -1,14 +1,18 @@
|
||||||
"""authentik SAML IDP Views"""
|
"""authentik SAML IDP Views"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls.base import reverse_lazy
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.generic.edit import FormView
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from authentik.core.models import Application, Provider
|
from authentik.core.models import Application, Provider
|
||||||
|
@ -25,9 +29,13 @@ from authentik.lib.utils.urls import redirect_with_qs
|
||||||
from authentik.lib.views import bad_request_message
|
from authentik.lib.views import bad_request_message
|
||||||
from authentik.policies.views import PolicyAccessView
|
from authentik.policies.views import PolicyAccessView
|
||||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||||
|
from authentik.providers.saml.forms import SAMLProviderImportForm
|
||||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||||
|
from authentik.providers.saml.processors.metadata_parser import (
|
||||||
|
ServiceProviderMetadataParser,
|
||||||
|
)
|
||||||
from authentik.providers.saml.processors.request_parser import (
|
from authentik.providers.saml.processors.request_parser import (
|
||||||
AuthNRequest,
|
AuthNRequest,
|
||||||
AuthNRequestParser,
|
AuthNRequestParser,
|
||||||
|
@ -242,3 +250,28 @@ class DescriptorDownloadView(View):
|
||||||
"Content-Disposition"
|
"Content-Disposition"
|
||||||
] = f'attachment; filename="{provider.name}_authentik_meta.xml"'
|
] = f'attachment; filename="{provider.name}_authentik_meta.xml"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataImportView(LoginRequiredMixin, FormView):
|
||||||
|
"""Import Metadata from XML, and create provider"""
|
||||||
|
|
||||||
|
form_class = SAMLProviderImportForm
|
||||||
|
template_name = "providers/saml/import.html"
|
||||||
|
success_url = reverse_lazy("authentik_admin:providers")
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
provider = metadata.to_provider(form.cleaned_data["provider_name"])
|
||||||
|
provider.save()
|
||||||
|
messages.success(self.request, _("Successfully created Provider"))
|
||||||
|
except ValueError:
|
||||||
|
messages.error(self.request, _("Failed to import Metadata."))
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||||
|
from authentik.core.api.utils import MetaNameSerializer
|
||||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
|
||||||
|
|
||||||
class LDAPSourceSerializer(ModelSerializer):
|
class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""LDAP Source Serializer"""
|
"""LDAP Source Serializer"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||||
|
from authentik.core.api.utils import MetaNameSerializer
|
||||||
from authentik.sources.oauth.models import OAuthSource
|
from authentik.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
|
|
||||||
class OAuthSourceSerializer(ModelSerializer):
|
class OAuthSourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""OAuth Source Serializer"""
|
"""OAuth Source Serializer"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.admin.forms.source import SOURCE_FORM_FIELDS
|
from authentik.admin.forms.source import SOURCE_FORM_FIELDS
|
||||||
|
from authentik.core.api.utils import MetaNameSerializer
|
||||||
from authentik.sources.saml.models import SAMLSource
|
from authentik.sources.saml.models import SAMLSource
|
||||||
|
|
||||||
|
|
||||||
class SAMLSourceSerializer(ModelSerializer):
|
class SAMLSourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""SAMLSource Serializer"""
|
"""SAMLSource Serializer"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -5,16 +5,16 @@ import { SLUG_REGEX } from "../elements/router/Route";
|
||||||
import { Interface } from "./Interface";
|
import { Interface } from "./Interface";
|
||||||
|
|
||||||
export const SIDEBAR_ITEMS: SidebarItem[] = [
|
export const SIDEBAR_ITEMS: SidebarItem[] = [
|
||||||
new SidebarItem("Library", "/library/"),
|
new SidebarItem("Library", "/library"),
|
||||||
new SidebarItem("Monitor").children(
|
new SidebarItem("Monitor").children(
|
||||||
new SidebarItem("Overview", "/administration/overview/"),
|
new SidebarItem("Overview", "/administration/overview"),
|
||||||
new SidebarItem("System Tasks", "/administration/tasks/"),
|
new SidebarItem("System Tasks", "/administration/tasks/"),
|
||||||
new SidebarItem("Events", "/events/log/"),
|
new SidebarItem("Events", "/events/log/"),
|
||||||
).when((): Promise<boolean> => {
|
).when((): Promise<boolean> => {
|
||||||
return User.me().then(u => u.is_superuser);
|
return User.me().then(u => u.is_superuser);
|
||||||
}),
|
}),
|
||||||
new SidebarItem("Administration").children(
|
new SidebarItem("Administration").children(
|
||||||
new SidebarItem("Applications", "/applications/").activeWhen(
|
new SidebarItem("Applications", "/applications").activeWhen(
|
||||||
`^/applications/(?<slug>${SLUG_REGEX})/$`
|
`^/applications/(?<slug>${SLUG_REGEX})/$`
|
||||||
),
|
),
|
||||||
new SidebarItem("Sources", "/administration/sources/").activeWhen(
|
new SidebarItem("Sources", "/administration/sources/").activeWhen(
|
||||||
|
@ -31,7 +31,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
|
||||||
return User.me().then(u => u.is_superuser);
|
return User.me().then(u => u.is_superuser);
|
||||||
}),
|
}),
|
||||||
new SidebarItem("Flows").children(
|
new SidebarItem("Flows").children(
|
||||||
new SidebarItem("Flows", "/administration/flows/").activeWhen(`^/flows/(?<slug>${SLUG_REGEX})/$`),
|
new SidebarItem("Flows", "/administration/flows/").activeWhen(`^/flows/(?<slug>${SLUG_REGEX})$`),
|
||||||
new SidebarItem("Stages", "/administration/stages/"),
|
new SidebarItem("Stages", "/administration/stages/"),
|
||||||
new SidebarItem("Prompts", "/administration/stages/prompts/"),
|
new SidebarItem("Prompts", "/administration/stages/prompts/"),
|
||||||
new SidebarItem("Invitations", "/administration/stages/invitations/"),
|
new SidebarItem("Invitations", "/administration/stages/invitations/"),
|
||||||
|
|
|
@ -9,7 +9,7 @@ import "../../elements/buttons/SpinnerButton";
|
||||||
import { TableColumn } from "../../elements/table/Table";
|
import { TableColumn } from "../../elements/table/Table";
|
||||||
|
|
||||||
@customElement("ak-application-list")
|
@customElement("ak-application-list")
|
||||||
export class ApplicationList extends TablePage<Application> {
|
export class ApplicationListPage extends TablePage<Application> {
|
||||||
searchEnabled(): boolean {
|
searchEnabled(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,18 +10,18 @@ import "./pages/flows/FlowViewPage";
|
||||||
|
|
||||||
export const ROUTES: Route[] = [
|
export const ROUTES: Route[] = [
|
||||||
// Prevent infinite Shell loops
|
// Prevent infinite Shell loops
|
||||||
new Route(new RegExp("^/$")).redirect("/library/"),
|
new Route(new RegExp("^/$")).redirect("/library"),
|
||||||
new Route(new RegExp("^#.*")).redirect("/library/"),
|
new Route(new RegExp("^#.*")).redirect("/library"),
|
||||||
new Route(new RegExp("^/library/$"), html`<ak-library></ak-library>`),
|
new Route(new RegExp("^/library$"), html`<ak-library></ak-library>`),
|
||||||
new Route(new RegExp("^/administration/overview/$"), html`<ak-admin-overview></ak-admin-overview>`),
|
new Route(new RegExp("^/administration/overview$"), html`<ak-admin-overview></ak-admin-overview>`),
|
||||||
new Route(new RegExp("^/applications/$"), html`<ak-application-list></ak-application-list>`),
|
new Route(new RegExp("^/applications$"), html`<ak-application-list></ak-application-list>`),
|
||||||
new Route(new RegExp(`^/applications/(?<slug>${SLUG_REGEX})/$`)).then((args) => {
|
new Route(new RegExp(`^/applications/(?<slug>${SLUG_REGEX})$`)).then((args) => {
|
||||||
return html`<ak-application-view .args=${args}></ak-application-view>`;
|
return html`<ak-application-view .args=${args}></ak-application-view>`;
|
||||||
}),
|
}),
|
||||||
new Route(new RegExp(`^/sources/(?<slug>${SLUG_REGEX})/$`)).then((args) => {
|
new Route(new RegExp(`^/sources/(?<slug>${SLUG_REGEX})$`)).then((args) => {
|
||||||
return html`<ak-source-view .args=${args}></ak-source-view>`;
|
return html`<ak-source-view .args=${args}></ak-source-view>`;
|
||||||
}),
|
}),
|
||||||
new Route(new RegExp(`^/flows/(?<slug>${SLUG_REGEX})/$`)).then((args) => {
|
new Route(new RegExp(`^/flows/(?<slug>${SLUG_REGEX})$`)).then((args) => {
|
||||||
return html`<ak-flow-view .args=${args}></ak-flow-view>`;
|
return html`<ak-flow-view .args=${args}></ak-flow-view>`;
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
Reference in New Issue