Skip to content

Commit

Permalink
Merge pull request #83 from maykinmedia/issue/4785-eherkenning-metadata
Browse files Browse the repository at this point in the history
Fix eherkenning metadata
  • Loading branch information
sergei-maertens authored Dec 20, 2024
2 parents 5d6df08 + a86e325 commit ad0b349
Show file tree
Hide file tree
Showing 13 changed files with 443 additions and 166 deletions.
17 changes: 16 additions & 1 deletion digid_eherkenning/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,28 @@ class SectorType(models.TextChoices):


class DigestAlgorithms(models.TextChoices):
sha1 = OneLogin_Saml2_Constants.SHA1, "SHA1"
sha256 = OneLogin_Saml2_Constants.SHA256, "SHA256"
sha384 = OneLogin_Saml2_Constants.SHA384, "SHA384"
sha512 = OneLogin_Saml2_Constants.SHA512, "SHA512"


class SignatureAlgorithms(models.TextChoices):
# Deprecated because of the SHA1 options, which appear to still be used with DigiD
rsa_sha256 = OneLogin_Saml2_Constants.RSA_SHA256, "RSA_SHA256"
rsa_sha384 = OneLogin_Saml2_Constants.RSA_SHA384, "RSA_SHA384"
rsa_sha512 = OneLogin_Saml2_Constants.RSA_SHA512, "RSA_SHA512"


class DeprecatedDigestAlgorithms(models.TextChoices):
# Deprecated because of the SHA1 options, which appear to still be used with DigiD
sha1 = OneLogin_Saml2_Constants.SHA1, "SHA1"
sha256 = OneLogin_Saml2_Constants.SHA256, "SHA256"
sha384 = OneLogin_Saml2_Constants.SHA384, "SHA384"
sha512 = OneLogin_Saml2_Constants.SHA512, "SHA512"


class DeprecatedSignatureAlgorithms(models.TextChoices):
# Deprecated because of the SHA1 options, which appear to still be used with DigiD
dsa_sha1 = OneLogin_Saml2_Constants.DSA_SHA1, "DSA_SHA1"
rsa_sha1 = OneLogin_Saml2_Constants.RSA_SHA1, "RSA_SHA1"
rsa_sha256 = OneLogin_Saml2_Constants.RSA_SHA256, "RSA_SHA256"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class Migration(migrations.Migration):
model_name="eherkenningconfiguration",
name="eh_requested_attributes",
field=models.JSONField(
default=digid_eherkenning.models.eherkenning.get_default_requested_attributes_eherkenning,
default=list,
help_text="A list of additional requested attributes. A single requested attribute can be a string (the name of the attribute) or an object with keys 'name' and 'required', where 'name' is a string and 'required' a boolean'.",
verbose_name="requested attributes",
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.13 on 2024-12-18 14:58

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("digid_eherkenning", "0012_move_config_certificate"),
]

operations = [
migrations.AlterField(
model_name="eherkenningconfiguration",
name="eh_requested_attributes",
field=models.JSONField(
blank=True,
default=list,
help_text="A list of additional requested attributes. A single requested attribute can be a string (the name of the attribute) or an object with keys 'name' and 'required', where 'name' is a string and 'required' a boolean'.",
verbose_name="requested attributes",
),
),
migrations.AlterField(
model_name="eherkenningconfiguration",
name="eidas_requested_attributes",
field=models.JSONField(
blank=True,
default=list,
help_text="A list of additional requested attributes. A single requested attribute can be a string (the name of the attribute) or an object with keys 'name' and 'required', where 'name' is a string and 'required' a boolean'.",
verbose_name="requested attributes",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 4.2.13 on 2024-12-19 10:11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
(
"digid_eherkenning",
"0013_alter_eherkenningconfiguration_eh_requested_attributes_and_more",
),
]

operations = [
migrations.AlterField(
model_name="eherkenningconfiguration",
name="digest_algorithm",
field=models.CharField(
blank=True,
choices=[
("http://www.w3.org/2001/04/xmlenc#sha256", "SHA256"),
("http://www.w3.org/2001/04/xmldsig-more#sha384", "SHA384"),
("http://www.w3.org/2001/04/xmlenc#sha512", "SHA512"),
],
default="http://www.w3.org/2001/04/xmlenc#sha256",
help_text="Digest algorithm. Note that SHA1 is deprecated, but still the default value in the SAMLv2 standard. Warning: there are known issues with single-logout functionality if using anything other than SHA1 due to some hardcoded algorithm.",
max_length=100,
verbose_name="digest algorithm",
),
),
migrations.AlterField(
model_name="eherkenningconfiguration",
name="signature_algorithm",
field=models.CharField(
blank=True,
choices=[
("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "RSA_SHA256"),
("http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", "RSA_SHA384"),
("http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", "RSA_SHA512"),
],
default="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
help_text="Signature algorithm. Note that DSA_SHA1 and RSA_SHA1 are deprecated, but RSA_SHA1 is still the default value in the SAMLv2 standard. Warning: there are known issues with single-logout functionality if using anything other than SHA1 due to some hardcoded algorithm.",
max_length=100,
verbose_name="signature algorithm",
),
),
]
47 changes: 41 additions & 6 deletions digid_eherkenning/models/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import TypeVar

from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
Expand All @@ -12,13 +14,46 @@

from ..choices import (
ConfigTypes,
DigestAlgorithms,
SignatureAlgorithms,
DeprecatedDigestAlgorithms,
DeprecatedSignatureAlgorithms,
XMLContentTypes,
)
from ..exceptions import CertificateProblem
from .certificates import ConfigCertificate

M = TypeVar("M", bound=type[models.Model])


def override_choices(
field: str,
new_choices: type[models.TextChoices],
new_default: models.TextChoices | None = None,
):
"""
Decorator to override the field choices and default for a concrete model.
The :class:`BaseConfiguration` allows choice selection that can be wider than desired
for specific subclasses. Use this decorator on the subclass to narrow them.
:arg field: field name to override. The field must exist on the model.
:arg new_choices: the new choices class to use.
:arg new_default: the new default value to use, optional.
"""

def decorator(cls: M) -> M:
model_field = cls._meta.get_field(field)
assert isinstance(model_field, models.Field)
assert model_field.choices

# replace the choices and default
model_field.choices = new_choices.choices
if new_default is not None:
model_field.default = new_default

return cls

return decorator


class BaseConfiguration(SingletonModel):
idp_metadata_file = PrivateMediaFileField(
Expand Down Expand Up @@ -76,8 +111,8 @@ class BaseConfiguration(SingletonModel):
signature_algorithm = models.CharField(
_("signature algorithm"),
blank=True,
choices=SignatureAlgorithms.choices,
default=SignatureAlgorithms.rsa_sha1,
choices=DeprecatedSignatureAlgorithms.choices,
default=DeprecatedSignatureAlgorithms.rsa_sha1,
help_text=_(
"Signature algorithm. Note that DSA_SHA1 and RSA_SHA1 are deprecated, but "
"RSA_SHA1 is still the default value in the SAMLv2 standard. Warning: "
Expand All @@ -89,8 +124,8 @@ class BaseConfiguration(SingletonModel):
digest_algorithm = models.CharField(
_("digest algorithm"),
blank=True,
choices=DigestAlgorithms.choices,
default=DigestAlgorithms.sha1,
choices=DeprecatedDigestAlgorithms.choices,
default=DeprecatedDigestAlgorithms.sha1,
help_text=_(
"Digest algorithm. Note that SHA1 is deprecated, but still the default "
"value in the SAMLv2 standard. Warning: "
Expand Down
37 changes: 20 additions & 17 deletions digid_eherkenning/models/eherkenning.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,10 @@
from django.db import models
from django.utils.translation import gettext_lazy as _

from ..choices import AssuranceLevels
from ..choices import AssuranceLevels, DigestAlgorithms, SignatureAlgorithms
from ..types import EHerkenningConfig
from ..validators import oin_validator
from .base import BaseConfiguration


def get_default_requested_attributes_eherkenning():
return [
{
"name": "urn:etoegang:1.11:attribute-represented:CompanyName",
"required": True,
"purpose_statements": {
"en": "For testing purposes.",
"nl": "Voor testdoeleinden.",
},
}
]
from .base import BaseConfiguration, override_choices


def get_default_requested_attributes_eidas():
Expand Down Expand Up @@ -59,6 +46,16 @@ def get_default_requested_attributes_eidas():
]


@override_choices(
"signature_algorithm",
new_choices=SignatureAlgorithms,
new_default=SignatureAlgorithms.rsa_sha256,
)
@override_choices(
"digest_algorithm",
new_choices=DigestAlgorithms,
new_default=DigestAlgorithms.sha256,
)
class EherkenningConfiguration(BaseConfiguration):
eh_loa = models.CharField(
_("eHerkenning LoA"),
Expand All @@ -76,7 +73,8 @@ class EherkenningConfiguration(BaseConfiguration):
)
eh_requested_attributes = models.JSONField(
_("requested attributes"),
default=get_default_requested_attributes_eherkenning,
default=list,
blank=True,
help_text=_(
"A list of additional requested attributes. A single requested attribute "
"can be a string (the name of the attribute) or an object with keys 'name' "
Expand Down Expand Up @@ -115,7 +113,8 @@ class EherkenningConfiguration(BaseConfiguration):
)
eidas_requested_attributes = models.JSONField(
_("requested attributes"),
default=get_default_requested_attributes_eidas,
default=list,
blank=True,
help_text=_(
"A list of additional requested attributes. A single requested attribute "
"can be a string (the name of the attribute) or an object with keys 'name' "
Expand Down Expand Up @@ -214,6 +213,10 @@ def as_dict(self) -> EHerkenningConfig:
"service_uuid": str(self.eh_service_uuid),
"service_name": self.service_name,
"attribute_consuming_service_index": self.eh_attribute_consuming_service_index,
# always mark EH as default and EIDAS as not the default. If we ever support
# more assertion consumer services than these two, then we need to expand on
# this logic/configuration.
"mark_default": True,
"service_instance_uuid": str(self.eh_service_instance_uuid),
"service_description": self.service_description,
"service_description_url": self.service_description_url,
Expand Down
5 changes: 4 additions & 1 deletion digid_eherkenning/saml2/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class BaseSaml2Client:
"custom_base_path": None,
}

settings_cls = OneLogin_Saml2_Settings

def __init__(self, conf=None):
self.authn_storage = AuthnRequestStorage(
self.cache_key_prefix, self.cache_timeout
Expand Down Expand Up @@ -203,7 +205,8 @@ def create_config(self, config_dict):
"""
Convert to the format expected by the OneLogin SAML2 library.
"""
return OneLogin_Saml2_Settings(config_dict, **self.saml2_setting_kwargs)
cls = self.settings_cls
return cls(config_dict, **self.saml2_setting_kwargs)

def create_config_dict(self, conf):
"""
Expand Down
65 changes: 64 additions & 1 deletion digid_eherkenning/saml2/eherkenning.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from cryptography.x509 import load_pem_x509_certificate
from lxml.builder import ElementMaker
from lxml.etree import Element, tostring
from onelogin.saml2.metadata import OneLogin_Saml2_Metadata
from onelogin.saml2.settings import OneLogin_Saml2_Settings

from ..models import EherkenningConfiguration
Expand Down Expand Up @@ -406,7 +407,7 @@ def get_metadata_eherkenning_requested_attributes(
conf: ServiceConfig, service_id: str
) -> list[dict]:
# There needs to be a RequestedAttribute element where the name is the ServiceID
# https://afsprakenstelsel.etoegang.nl/display/as/DV+metadata+for+HM
# https://afsprakenstelsel.etoegang.nl/Startpagina/v3/dv-metadata-for-hm
requested_attributes = [{"name": service_id, "isRequired": False}]
for requested_attribute in conf.get("requested_attributes", []):
if isinstance(requested_attribute, dict):
Expand Down Expand Up @@ -447,15 +448,72 @@ def create_attribute_consuming_services(conf: EHerkenningConfig) -> list[dict]:
"serviceDescription": service_description,
"requestedAttributes": requested_attributes,
"language": service.get("language", "nl"),
"mark_default": service.get("mark_default", False),
}
)
return attribute_consuming_services


class CustomOneLogin_Saml2_Metadata(OneLogin_Saml2_Metadata):
"""
Modify the generated metadata to comply with AfsprakenStelsel 1.24a
"""

@staticmethod
def make_attribute_consuming_services(service_provider: dict):
"""
Add an attribute to the default AttributeConsumingService element.
.. note:: the upstream master branch has refactored this interface, so once we
rebase on master (quite a task I think), we will have to deal with this too.
"""
result = super(
CustomOneLogin_Saml2_Metadata, CustomOneLogin_Saml2_Metadata
).make_attribute_consuming_services(service_provider)

attribute_consuming_services = service_provider["attributeConsumingServices"]
if len(attribute_consuming_services) > 1:
# find the ACS that's marked as default - there *must* be one otherwise we
# don't comply with AfsprakenStelsel 1.24a requirements
default_service_index = next(
acs["index"]
for acs in attribute_consuming_services
if acs["mark_default"]
)

# do string replacement, because we can't pass any options to the metadata
# generation to modify this behaviour :/
needle = f'<md:AttributeConsumingService index="{default_service_index}">'
replacement = f'<md:AttributeConsumingService index="{default_service_index}" isDefault="true">'
result = result.replace(needle, replacement, 1)

return result

@staticmethod
def _add_x509_key_descriptors(root, cert: str, use=None):
"""
Override the usage of the 'use' attribute.
This patch is a hack on top of the python3-saml library. We deliberately ignore
any "use" attribute in the generated metadata so that we don't affect the
runtime behaviour.
"""
fixed_use = None # ignore the use parameter entirely.
super(
CustomOneLogin_Saml2_Metadata, CustomOneLogin_Saml2_Metadata
)._add_x509_key_descriptors(root, cert=cert, use=fixed_use)


class CustomOneLogin_Saml2_Settings(OneLogin_Saml2_Settings):
metadata_class = CustomOneLogin_Saml2_Metadata


class eHerkenningClient(BaseSaml2Client):
cache_key_prefix = "eherkenning"
cache_timeout = 60 * 60 # 1 hour

settings_cls = CustomOneLogin_Saml2_Settings

@property
def conf(self) -> EHerkenningConfig:
if not hasattr(self, "_conf"):
Expand All @@ -469,6 +527,11 @@ def create_config_dict(self, conf: EHerkenningConfig) -> EHerkenningSAMLConfig:
config_dict: EHerkenningSAMLConfig = super().create_config_dict(conf)

sp_config = config_dict["sp"]
# may not be included for eHerkenning/EIDAS since AS1.24a, see:
# https://afsprakenstelsel.etoegang.nl/Startpagina/v3/dv-metadata-for-hm
#
# ... Elements not listed in this table MUST NOT be included in the metadata.
del sp_config["NameIDFormat"]

# we have multiple services, so delete the config for the "single service" variant
attribute_consuming_services = create_attribute_consuming_services(conf)
Expand Down
Loading

0 comments on commit ad0b349

Please sign in to comment.