Skip to content

Commit

Permalink
[#45] Automated metadata file retrieval and parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
vaszig committed Oct 12, 2023
1 parent e919dd6 commit 5cef3c2
Show file tree
Hide file tree
Showing 10 changed files with 548 additions and 6 deletions.
8 changes: 6 additions & 2 deletions digid_eherkenning/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

@admin.register(DigidConfiguration)
class DigidConfigurationAdmin(PrivateMediaMixin, SingletonModelAdmin):
readonly_fields = ("idp_metadata_file", "idp_service_entity_id")
fieldsets = (
(
_("X.509 Certificate"),
Expand All @@ -23,8 +24,9 @@ class DigidConfigurationAdmin(PrivateMediaMixin, SingletonModelAdmin):
_("Identity provider"),
{
"fields": (
"idp_metadata_file",
"metadata_file_source",
"idp_service_entity_id",
"idp_metadata_file",
),
},
),
Expand Down Expand Up @@ -72,6 +74,7 @@ class DigidConfigurationAdmin(PrivateMediaMixin, SingletonModelAdmin):

@admin.register(EherkenningConfiguration)
class EherkenningConfigurationAdmin(PrivateMediaMixin, SingletonModelAdmin):
readonly_fields = ("idp_metadata_file", "idp_service_entity_id")
fieldsets = (
(
_("X.509 Certificate"),
Expand All @@ -86,8 +89,9 @@ class EherkenningConfigurationAdmin(PrivateMediaMixin, SingletonModelAdmin):
_("Identity provider"),
{
"fields": (
"idp_metadata_file",
"metadata_file_source",
"idp_service_entity_id",
"idp_metadata_file",
),
},
),
Expand Down
39 changes: 39 additions & 0 deletions digid_eherkenning/management/commands/update_stored_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from django.core.management import BaseCommand, CommandError

from digid_eherkenning.models.digid import DigidConfiguration
from digid_eherkenning.models.eherkenning import EherkenningConfiguration


class Command(BaseCommand):
help = "Updates the stored metadata file and repopulates the db fields."

def add_arguments(self, parser):
parser.add_argument(
"--digid",
action="store_true",
help="Update the DigiD configuration metadata.",
)
parser.add_argument(
"--eherkenning",
action="store_true",
help="Update the Eherkenning configuration metadata.",
)

def handle(self, **options):
if options["digid"]:
config = DigidConfiguration.get_solo()
elif options["eherkenning"]:
config = EherkenningConfiguration.get_solo()

Check warning on line 26 in digid_eherkenning/management/commands/update_stored_metadata.py

View check run for this annotation

Codecov / codecov/patch

digid_eherkenning/management/commands/update_stored_metadata.py#L25-L26

Added lines #L25 - L26 were not covered by tests
else:
raise CommandError(

Check warning on line 28 in digid_eherkenning/management/commands/update_stored_metadata.py

View check run for this annotation

Codecov / codecov/patch

digid_eherkenning/management/commands/update_stored_metadata.py#L28

Added line #L28 was not covered by tests
"A required argument is missing. Please provide either digid or eherkenning."
)
if config.metadata_file_source:
config.parse_data_from_xml_source()
config.save()

self.stdout.write(self.style.SUCCESS("Update was successful"))
else:
self.stdout.write(

Check warning on line 37 in digid_eherkenning/management/commands/update_stored_metadata.py

View check run for this annotation

Codecov / codecov/patch

digid_eherkenning/management/commands/update_stored_metadata.py#L37

Added line #L37 was not covered by tests
self.style.WARNING("Update failed, no metadata file source found")
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Generated by Django 4.2.6 on 2023-10-12 14:36

from django.db import migrations, models
import privates.fields
import privates.storages


class Migration(migrations.Migration):
dependencies = [
(
"digid_eherkenning",
"0005_alter_eherkenningconfiguration_eh_service_instance_uuid_and_more",
),
]

operations = [
migrations.AddField(
model_name="digidconfiguration",
name="metadata_file_source",
field=models.URLField(
default="",
help_text="The URL-source where the XML metadata file can be retrieved from.",
max_length=255,
verbose_name="metadata file(XML) URL",
),
),
migrations.AddField(
model_name="eherkenningconfiguration",
name="metadata_file_source",
field=models.URLField(
default="",
help_text="The URL-source where the XML metadata file can be retrieved from.",
max_length=255,
verbose_name="metadata file(XML) URL",
),
),
migrations.AlterField(
model_name="digidconfiguration",
name="idp_metadata_file",
field=privates.fields.PrivateMediaFileField(
blank=True,
help_text="The metadata file of the identity provider. This is auto populated by the retrieved metadata XML file.",
null=True,
storage=privates.storages.PrivateMediaFileSystemStorage(),
upload_to="",
verbose_name="identity provider metadata",
),
),
migrations.AlterField(
model_name="digidconfiguration",
name="idp_service_entity_id",
field=models.CharField(
blank=True,
help_text="Example value: 'https://was-preprod1.digid.nl/saml/idp/metadata'. Note that this must match the 'entityID' attribute on the 'md:EntityDescriptor' node found in the Identity Provider's metadata. This is auto populated by the retrieved metadata XML file.",
max_length=255,
null=True,
verbose_name="identity provider service entity ID",
),
),
migrations.AlterField(
model_name="eherkenningconfiguration",
name="idp_metadata_file",
field=privates.fields.PrivateMediaFileField(
blank=True,
help_text="The metadata file of the identity provider. This is auto populated by the retrieved metadata XML file.",
null=True,
storage=privates.storages.PrivateMediaFileSystemStorage(),
upload_to="",
verbose_name="identity provider metadata",
),
),
migrations.AlterField(
model_name="eherkenningconfiguration",
name="idp_service_entity_id",
field=models.CharField(
blank=True,
help_text="Example value: 'https://was-preprod1.digid.nl/saml/idp/metadata'. Note that this must match the 'entityID' attribute on the 'md:EntityDescriptor' node found in the Identity Provider's metadata. This is auto populated by the retrieved metadata XML file.",
max_length=255,
null=True,
verbose_name="identity provider service entity ID",
),
),
]
93 changes: 89 additions & 4 deletions digid_eherkenning/models/base.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _

from onelogin.saml2.constants import OneLogin_Saml2_Constants
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
from privates.fields import PrivateMediaFileField
from simple_certmanager.models import Certificate
from solo.models import SingletonModel

from ..choices import DigestAlgorithms, SignatureAlgorithms, XMLContentTypes
from ..settings import get_setting


class ConfigurationManager(models.Manager):
Expand All @@ -29,17 +34,31 @@ class BaseConfiguration(SingletonModel):
)
idp_metadata_file = PrivateMediaFileField(
_("identity provider metadata"),
blank=False,
help_text=_("The metadata file of the identity provider."),
blank=True,
null=True,
help_text=_(
"The metadata file of the identity provider. This is auto populated "
"by the retrieved metadata XML file."
),
)
idp_service_entity_id = models.CharField(
_("identity provider service entity ID"),
max_length=255,
blank=False,
blank=True,
null=True,
help_text=_(
"Example value: 'https://was-preprod1.digid.nl/saml/idp/metadata'. Note "
"that this must match the 'entityID' attribute on the "
"'md:EntityDescriptor' node found in the Identity Provider's metadata."
"'md:EntityDescriptor' node found in the Identity Provider's metadata. "
"This is auto populated by the retrieved metadata XML file."
),
)
metadata_file_source = models.URLField(
_("metadata file(XML) URL"),
max_length=255,
default="",
help_text=_(
"The URL-source where the XML metadata file can be retrieved from."
),
)
want_assertions_signed = models.BooleanField(
Expand Down Expand Up @@ -166,7 +185,73 @@ class Meta:
def __str__(self):
return force_str(self._meta.verbose_name)

def populate_xml_fields(self, urls, xml):
"""
Populates the idp_metadata_file and idp_service_entity_id fields based on the
fetched xml metadata
"""
self.idp_service_entity_id = urls["entityId"]
content = ContentFile(xml.encode("utf-8"))
self.idp_metadata_file.save("metadata.xml", content, save=False)

def fetch_xml_metadata(self):
"""
Retrieves the xml metadata from the provided url (metadata_file_source)
:return a string of the xml metadata.
"""
return OneLogin_Saml2_IdPMetadataParser.get_metadata(self.metadata_file_source)

def parse_data_from_xml_source(self):
"""
Parses the xml metadata
:return a tuple of a dictionary with the useful urls and the xml string itself.
"""
urls = {}
xml = ""

try:
xml = self.fetch_xml_metadata()
parsed_idp_metadata = OneLogin_Saml2_IdPMetadataParser.parse(
xml,
required_sso_binding=OneLogin_Saml2_Constants.BINDING_HTTP_POST,
required_slo_binding=OneLogin_Saml2_Constants.BINDING_HTTP_POST,
)
except Exception:
raise ValidationError(
_(
"An error has occured while processing the xml file. Make sure the file is valid."
)
)

if idp := parsed_idp_metadata.get("idp"):
urls = {
"entityId": idp.get("entityId"),
"sso_url": idp.get("singleSignOnService", {}).get("url"),
"slo_url": idp.get("singleLogoutService", {}).get("url"),
}

if cache.get(self._meta.object_name):
cache.delete(self._meta.object_name)

return (urls, xml)

def save(self, *args, **kwargs):
if self.pk and self.metadata_file_source:
if cache.get(self._meta.object_name) != self.metadata_file_source:
urls, xml = self.parse_data_from_xml_source()

if urls and xml:
self.populate_xml_fields(urls, xml)
cache.set(
self._meta.object_name,
urls["entityId"],
get_setting("METADATA_URLS_CACHE_TIMEOUT"),
)
else:
raise ValidationError(_("The provided url is not valid"))

Check warning on line 253 in digid_eherkenning/models/base.py

View check run for this annotation

Codecov / codecov/patch

digid_eherkenning/models/base.py#L253

Added line #L253 was not covered by tests

if self.base_url.endswith("/"):
self.base_url = self.base_url[:-1]
super().save(*args, **kwargs)
Expand Down
1 change: 1 addition & 0 deletions digid_eherkenning/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Public settings
class Defaults:
DIGID_SESSION_AGE: int = 60 * 15 # 15 minutes, in seconds
METADATA_URLS_CACHE_TIMEOUT = 86400


def get_setting(name: str):
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@


DIGID_TEST_METADATA_FILE = BASE_DIR / "files" / "digid" / "metadata"
DIGID_TEST_METADATA_FILE_SLO_POST = (
BASE_DIR / "files" / "digid" / "metadata_with_slo_POST"
)
DIGID_TEST_METADATA_FILE_SLO_POST_2 = (
BASE_DIR / "files" / "digid" / "metadata_with_slo_POST_2"
)

DIGID_TEST_KEY_FILE = BASE_DIR / "files" / "snakeoil-cert" / "ssl-cert-snakeoil.key"
DIGID_TEST_CERTIFICATE_FILE = (
BASE_DIR / "files" / "snakeoil-cert" / "ssl-cert-snakeoil.pem"
Expand Down
85 changes: 85 additions & 0 deletions tests/files/digid/metadata_with_slo_POST
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"
ID="_a1d008fa9c840e932100ed323d460eb02b0a2f8d"
entityID="https://was-preprod1.digid.nl/saml/idp/metadata">
<ds:Signature>
<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="#_a1d008fa9c840e932100ed323d460eb02b0a2f8d">
<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#">
<ec:InclusiveNamespaces PrefixList="ds saml samlp xs" />
</ds:Transform>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<ds:DigestValue>DNT82s99BhXBIvewrpNSnBnuACmZFAKg7Ze+rZmflcQ=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>
gDvJjFd221rI7Y6JT6IFNRr9L1JVQgXiOSx62zfy0Qx7wZTjx1ngs+eOcRloHEdIKBd/BCDQGl10VakEmmzB1OYcuR2V5mq7IdR+lIJb+eoKO1dhc6IK+F2vfWCUxphYUDbfWBE0U06YvSI4di2j0CISMXUbbNiO8DeFWI6/NYuiRimONpRomjwz1X+nR1Aaw58A8hrqiYKMZzMDHL2wJ7haK5ZKv3lWtACpSMYcdNXAzo2le9T0IyZjNUlkGtgHH2UyjDUL1OcZnvMSpd3lHFc6HkUfkbrGmJecNJWZyXT+7BH2HxaYFW0jQEYaTw7vyYatYf0HTNqfcN4VePU7Ww==</ds:SignatureValue>
<ds:KeyInfo>
<ds:KeyName>2e9046aba2e95ed07efb655f6f50880ef686e531</ds:KeyName>
</ds:KeyInfo>
</ds:Signature>
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo>
<ds:KeyName>2e9046aba2e95ed07efb655f6f50880ef686e531</ds:KeyName>
<ds:X509Data>
<ds:X509Certificate>MIICsjCCAZqgAwIBAgIJAJhvbn1Aal/aMA0GCSqGSIb3DQEBCwUAMBExDzANBgNV
BAMMBnVidW50dTAeFw0xNzEyMjkxNjI3NDhaFw0yNzEyMjcxNjI3NDhaMBExDzAN
BgNVBAMMBnVidW50dTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAObY
RaZaguRBRowjJraDM8A7VpcWi+aIzYc0003gmz1j+/31pQ7QX12cCFAnyo+6bFlQ
Or8/Qda24yIAsaaj96Z3iA4bWDUARLUD8N6gftI8sZwf1JRW6kgwYn3DiLdhDeKd
8LsrraUzHK/CXccCt5xM6t7gsgBoJvMNmy2ddpuh6T5xLQpREcI9JVqAihRa33ty
PMDk4QzPYjKsshYfFqlpRz6Zb3SMUnqjhRlWKyF/QgviNdrK8w1xlx6ix/oVruOP
P4qSl5vOmGk49CRqWY7IWciD2VHcH04fVfjhwPsh4Dv7uTaMLHwyGvJ8XFjK6yef
aiSODBsnb+bqKe8jWssCAwEAAaMNMAswCQYDVR0TBAIwADANBgkqhkiG9w0BAQsF
AAOCAQEAWOjsjh9NxJ5Lu5bGsNQecxheCtkv6WdTB4O5+z60qVNxQZrwQkPbnTQm
yAfTxR/VubLGqJ3mYU5YsqKJ2Z9Cqvc3wIPXLkruv5A1RwDv0eb0R/UE5nF/nKQR
B3yn9AYVrzu5kNlpFr/hK6brqfznMKPT6K4n9jMdUg9Bek9b2j8vG7L/Jgona43K
e7Ip6lACOP0q5PQvrKIkkdfh5QbZjrCQnUVRsScDh/qjWS01rYUp9z7rfuEuQNt5
TPNMhvmH+podlP9zgONDdwYsjdP59+pFIRebf3Qlkd2nNk6AfL4oFpacr8x7EuHx
QJZ5MqEauEhsXVSP+gFGywjSsYHMHg==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo>
<ds:KeyName>2e9046aba2e95ed07efb655f6f50880ef686e531</ds:KeyName>
<ds:X509Data>
<ds:X509Certificate>MIICsjCCAZqgAwIBAgIJAJhvbn1Aal/aMA0GCSqGSIb3DQEBCwUAMBExDzANBgNV
BAMMBnVidW50dTAeFw0xNzEyMjkxNjI3NDhaFw0yNzEyMjcxNjI3NDhaMBExDzAN
BgNVBAMMBnVidW50dTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAObY
RaZaguRBRowjJraDM8A7VpcWi+aIzYc0003gmz1j+/31pQ7QX12cCFAnyo+6bFlQ
Or8/Qda24yIAsaaj96Z3iA4bWDUARLUD8N6gftI8sZwf1JRW6kgwYn3DiLdhDeKd
8LsrraUzHK/CXccCt5xM6t7gsgBoJvMNmy2ddpuh6T5xLQpREcI9JVqAihRa33ty
PMDk4QzPYjKsshYfFqlpRz6Zb3SMUnqjhRlWKyF/QgviNdrK8w1xlx6ix/oVruOP
P4qSl5vOmGk49CRqWY7IWciD2VHcH04fVfjhwPsh4Dv7uTaMLHwyGvJ8XFjK6yef
aiSODBsnb+bqKe8jWssCAwEAAaMNMAswCQYDVR0TBAIwADANBgkqhkiG9w0BAQsF
AAOCAQEAWOjsjh9NxJ5Lu5bGsNQecxheCtkv6WdTB4O5+z60qVNxQZrwQkPbnTQm
yAfTxR/VubLGqJ3mYU5YsqKJ2Z9Cqvc3wIPXLkruv5A1RwDv0eb0R/UE5nF/nKQR
B3yn9AYVrzu5kNlpFr/hK6brqfznMKPT6K4n9jMdUg9Bek9b2j8vG7L/Jgona43K
e7Ip6lACOP0q5PQvrKIkkdfh5QbZjrCQnUVRsScDh/qjWS01rYUp9z7rfuEuQNt5
TPNMhvmH+podlP9zgONDdwYsjdP59+pFIRebf3Qlkd2nNk6AfL4oFpacr8x7EuHx
QJZ5MqEauEhsXVSP+gFGywjSsYHMHg==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
Location="https://was-preprod1.digid.nl/saml/idp/resolve_artifact" index="0" />
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://preprod1.digid.nl/saml/idp/request_logout" />
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://preprod1.digid.nl/saml/idp/request_logout" />
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://preprod1.digid.nl/saml/idp/request_authentication" />
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://preprod1.digid.nl/saml/idp/request_authentication" />
</md:IDPSSODescriptor>
</md:EntityDescriptor>
Loading

0 comments on commit 5cef3c2

Please sign in to comment.