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",
|
||||
"authentication_flow",
|
||||
"enrollment_flow",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
{% for flow in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<a href="/flows/{{ flow.slug }}/">
|
||||
<a href="/flows/{{ flow.slug }}">
|
||||
<div><code>{{ flow.slug }}</code></div>
|
||||
<small>{{ flow.name }}</small>
|
||||
</a>
|
||||
|
|
|
@ -41,6 +41,17 @@
|
|||
</ak-modal-button>
|
||||
</li>
|
||||
{% 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>
|
||||
</ak-dropdown>
|
||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||
|
|
|
@ -22,6 +22,7 @@ from authentik.admin.views import (
|
|||
tokens,
|
||||
users,
|
||||
)
|
||||
from authentik.providers.saml.views import MetadataImportView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
@ -116,6 +117,11 @@ 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(),
|
||||
|
|
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
|||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.core.models import Source
|
||||
|
||||
|
||||
class SourceSerializer(ModelSerializer):
|
||||
class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Source Serializer"""
|
||||
|
||||
__type__ = SerializerMethodField(method_name="get_type")
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
"""authentik SAML IDP Forms"""
|
||||
|
||||
from xml.etree.ElementTree import ParseError # nosec
|
||||
|
||||
from defusedxml.ElementTree import fromstring
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.utils.html import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.admin.fields import CodeMirrorWidget
|
||||
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"""
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
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.http import urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic.edit import FormView
|
||||
from structlog import get_logger
|
||||
|
||||
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.policies.views import PolicyAccessView
|
||||
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.processors.assertion import AssertionProcessor
|
||||
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 (
|
||||
AuthNRequest,
|
||||
AuthNRequestParser,
|
||||
|
@ -242,3 +250,28 @@ class DescriptorDownloadView(View):
|
|||
"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 = 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 authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
||||
|
||||
class LDAPSourceSerializer(ModelSerializer):
|
||||
class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""LDAP Source Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer
|
|||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
class OAuthSourceSerializer(ModelSerializer):
|
||||
class OAuthSourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""OAuth Source Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer
|
|||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.admin.forms.source import SOURCE_FORM_FIELDS
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
class SAMLSourceSerializer(ModelSerializer):
|
||||
class SAMLSourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""SAMLSource Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -5,16 +5,16 @@ import { SLUG_REGEX } from "../elements/router/Route";
|
|||
import { Interface } from "./Interface";
|
||||
|
||||
export const SIDEBAR_ITEMS: SidebarItem[] = [
|
||||
new SidebarItem("Library", "/library/"),
|
||||
new SidebarItem("Library", "/library"),
|
||||
new SidebarItem("Monitor").children(
|
||||
new SidebarItem("Overview", "/administration/overview/"),
|
||||
new SidebarItem("Overview", "/administration/overview"),
|
||||
new SidebarItem("System Tasks", "/administration/tasks/"),
|
||||
new SidebarItem("Events", "/events/log/"),
|
||||
).when((): Promise<boolean> => {
|
||||
return User.me().then(u => u.is_superuser);
|
||||
}),
|
||||
new SidebarItem("Administration").children(
|
||||
new SidebarItem("Applications", "/applications/").activeWhen(
|
||||
new SidebarItem("Applications", "/applications").activeWhen(
|
||||
`^/applications/(?<slug>${SLUG_REGEX})/$`
|
||||
),
|
||||
new SidebarItem("Sources", "/administration/sources/").activeWhen(
|
||||
|
@ -31,7 +31,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
|
|||
return User.me().then(u => u.is_superuser);
|
||||
}),
|
||||
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("Prompts", "/administration/stages/prompts/"),
|
||||
new SidebarItem("Invitations", "/administration/stages/invitations/"),
|
||||
|
|
|
@ -9,7 +9,7 @@ import "../../elements/buttons/SpinnerButton";
|
|||
import { TableColumn } from "../../elements/table/Table";
|
||||
|
||||
@customElement("ak-application-list")
|
||||
export class ApplicationList extends TablePage<Application> {
|
||||
export class ApplicationListPage extends TablePage<Application> {
|
||||
searchEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -10,18 +10,18 @@ import "./pages/flows/FlowViewPage";
|
|||
|
||||
export const ROUTES: Route[] = [
|
||||
// Prevent infinite Shell loops
|
||||
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("^/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/(?<slug>${SLUG_REGEX})/$`)).then((args) => {
|
||||
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("^/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/(?<slug>${SLUG_REGEX})$`)).then((args) => {
|
||||
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>`;
|
||||
}),
|
||||
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>`;
|
||||
}),
|
||||
];
|
||||
|
|
Reference in New Issue