Skip to content

Commit

Permalink
Merge pull request #46 from maykinmedia/issue/45-retrieve-urls-from-m…
Browse files Browse the repository at this point in the history
…etadata-file

[#45] Retrieve digid/eherkenning metadata automatically
  • Loading branch information
sergei-maertens authored Oct 23, 2023
2 parents e919dd6 + bca768a commit ee54cb1
Show file tree
Hide file tree
Showing 13 changed files with 689 additions and 40 deletions.
21 changes: 17 additions & 4 deletions digid_eherkenning/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@
from django.utils.translation import gettext_lazy as _

from privates.admin import PrivateMediaMixin
from privates.widgets import PrivateFileWidget
from solo.admin import SingletonModelAdmin

from .models import DigidConfiguration, EherkenningConfiguration


class CustomPrivateFileWidget(PrivateFileWidget):
template_name = "admin/digid_eherkenning/widgets/custom_file_input.html"


class CustomPrivateMediaMixin(PrivateMediaMixin):
private_media_file_widget = CustomPrivateFileWidget


@admin.register(DigidConfiguration)
class DigidConfigurationAdmin(PrivateMediaMixin, SingletonModelAdmin):
class DigidConfigurationAdmin(CustomPrivateMediaMixin, SingletonModelAdmin):
readonly_fields = ("idp_service_entity_id",)
fieldsets = (
(
_("X.509 Certificate"),
Expand All @@ -23,8 +33,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 @@ -71,7 +82,8 @@ class DigidConfigurationAdmin(PrivateMediaMixin, SingletonModelAdmin):


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

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


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

def add_arguments(self, parser):
parser.add_argument(
"config_model",
type=str,
choices=["digid", "eherkenning"],
help="Update the DigiD or Eherkenning configuration metadata.",
)

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

if config.metadata_file_source:
config.save()
self.stdout.write(self.style.SUCCESS("Update was successful"))
else:
self.stdout.write(
self.style.WARNING("Update failed, no metadata file source found")
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Generated by Django 4.2.6 on 2023-10-20 08:40

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 from the configured source URL.",
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 from the configured source URL.",
max_length=255,
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 from the configured source URL.",
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 from the configured source URL.",
max_length=255,
verbose_name="identity provider service entity ID",
),
),
]
78 changes: 74 additions & 4 deletions digid_eherkenning/models/base.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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
Expand All @@ -29,17 +32,29 @@ class BaseConfiguration(SingletonModel):
)
idp_metadata_file = PrivateMediaFileField(
_("identity provider metadata"),
blank=False,
help_text=_("The metadata file of the identity provider."),
blank=True,
help_text=_(
"The metadata file of the identity provider. This is auto populated "
"from the configured source URL."
),
)
idp_service_entity_id = models.CharField(
_("identity provider service entity ID"),
max_length=255,
blank=False,
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."
"'md:EntityDescriptor' node found in the Identity Provider's metadata. "
"This is auto populated from the configured source URL."
),
)
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 +181,62 @@ class Meta:
def __str__(self):
return force_str(self._meta.verbose_name)

def populate_xml_fields(self, urls: dict[str, str], xml: str) -> None:
"""
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 process_metadata_from_xml_source(self) -> tuple[dict[str, str], str]:
"""
Parses the xml metadata
:return a tuple of a dictionary with the useful urls and the xml string itself.
"""
try:
xml = OneLogin_Saml2_IdPMetadataParser.get_metadata(
self.metadata_file_source
)
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,
)
# python3-saml library does not use proper-namespaced exceptions
except Exception as exc:
raise ValidationError(
_("Failed to parse the metadata, got error: {err}").format(err=str(exc))
) from exc

if not (idp := parsed_idp_metadata.get("idp")):
raise ValidationError(
_(
"Could not find any identity provider information in the metadata at the provided URL."
)
)

# sometimes the xml file contains urn instead of a url as an entity ID
# use the provided url instead
urls = {
"entityId": (
entity_id
if not (entity_id := idp.get("entityId")).startswith("urn:")
else self.metadata_file_source
),
"sso_url": idp.get("singleSignOnService", {}).get("url"),
"slo_url": idp.get("singleLogoutService", {}).get("url"),
}

return (urls, xml)

def save(self, *args, **kwargs):
if self.metadata_file_source:
urls, xml = self.process_metadata_from_xml_source()
self.populate_xml_fields(urls, xml)

if self.base_url.endswith("/"):
self.base_url = self.base_url[:-1]
super().save(*args, **kwargs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- Update the django-privates template in order to remove the clear and upload buttons since this field
is automatically updated. User should only be able to download the file. -->

{% if widget.is_initial %}
{% if download_allowed %}
<p class="file-upload">{{ widget.initial_text }}: <a href="{{ url }}">{{ display_value }}</a>
{% else %}
<p class="file-upload">{{ widget.initial_text }}: {{ display_value }}
{% endif %}
{% if not widget.required %}
<span class="clearable-file-input">
{% endif %}
<br />
{% endif %}
1 change: 1 addition & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ django-digid-eherkenning ships with a couple of Django management commands:
* ``generate_digid_metadata``
* ``generate_eherkenning_metadata``
* ``generate_eherkenning_dienstcatalogus``
* ``update_stored_metadata``

For details, call:

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ If this header is not set or empty, we instead get the value from ``REMOTE_ADDR`

**Protecting metadata endpoints**

The metdata URLs are open by design to facilitate sharing these URLs with identity
The metadata URLs are open by design to facilitate sharing these URLs with identity
providers or other interested parties. Because the metadata is generated on the fly,
there is a Denial-of-Service risk. We recommend to protect these URLs at the web-server
level by:
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
Loading

0 comments on commit ee54cb1

Please sign in to comment.