diff --git a/digid_eherkenning/migrations/0008_update_loa_fields.py b/digid_eherkenning/migrations/0008_update_loa_fields.py new file mode 100644 index 0000000..d66b661 --- /dev/null +++ b/digid_eherkenning/migrations/0008_update_loa_fields.py @@ -0,0 +1,73 @@ +# Generated by Django 4.2.10 on 2024-03-08 08:45 + +from django.db import migrations, models + +import digid_eherkenning.choices + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "digid_eherkenning", + "0007_eherkenningconfiguration_service_description_url", + ), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="eherkenningconfiguration", + name="valid_loa", + ), + migrations.RenameField( + model_name="eherkenningconfiguration", + old_name="loa", + new_name="eh_loa", + ), + migrations.AlterField( + model_name="eherkenningconfiguration", + name="eh_loa", + field=models.CharField( + choices=[ + ("urn:etoegang:core:assurance-class:loa1", "Non existent (1)"), + ("urn:etoegang:core:assurance-class:loa2", "Low (2)"), + ("urn:etoegang:core:assurance-class:loa2plus", "Low (2+)"), + ("urn:etoegang:core:assurance-class:loa3", "Substantial (3)"), + ("urn:etoegang:core:assurance-class:loa4", "High (4)"), + ], + default="urn:etoegang:core:assurance-class:loa3", + help_text="Level of Assurance (LoA) to use for the eHerkenning service.", + max_length=100, + verbose_name="eHerkenning LoA", + ), + ), + migrations.AddField( + model_name="eherkenningconfiguration", + name="eidas_loa", + field=models.CharField( + choices=[ + ("urn:etoegang:core:assurance-class:loa1", "Non existent (1)"), + ("urn:etoegang:core:assurance-class:loa2", "Low (2)"), + ("urn:etoegang:core:assurance-class:loa2plus", "Low (2+)"), + ("urn:etoegang:core:assurance-class:loa3", "Substantial (3)"), + ("urn:etoegang:core:assurance-class:loa4", "High (4)"), + ], + default="urn:etoegang:core:assurance-class:loa3", + help_text="Level of Assurance (LoA) to use for the eIDAS service.", + max_length=100, + verbose_name="eIDAS LoA", + ), + ), + migrations.AddConstraint( + model_name="eherkenningconfiguration", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("eh_loa__in", digid_eherkenning.choices.AssuranceLevels), + ("eidas_loa__in", digid_eherkenning.choices.AssuranceLevels), + ) + ), + name="valid_loa", + ), + ), + ] diff --git a/digid_eherkenning/models/eherkenning.py b/digid_eherkenning/models/eherkenning.py index 1342860..4234593 100644 --- a/digid_eherkenning/models/eherkenning.py +++ b/digid_eherkenning/models/eherkenning.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from ..choices import AssuranceLevels +from ..types import EHerkenningConfig from ..validators import oin_validator from .base import BaseConfiguration @@ -60,11 +61,11 @@ def get_default_requested_attributes_eidas(): class EherkenningConfiguration(BaseConfiguration): - loa = models.CharField( - _("LoA"), + eh_loa = models.CharField( + _("eHerkenning LoA"), choices=AssuranceLevels.choices, default=AssuranceLevels.substantial, - help_text=_("Level of Assurance (LoA) to use for all the services."), + help_text=_("Level of Assurance (LoA) to use for the eHerkenning service."), max_length=100, ) eh_attribute_consuming_service_index = models.CharField( @@ -99,6 +100,13 @@ class EherkenningConfiguration(BaseConfiguration): "changing the value is a manual process." ), ) + eidas_loa = models.CharField( + _("eIDAS LoA"), + choices=AssuranceLevels.choices, + default=AssuranceLevels.substantial, + help_text=_("Level of Assurance (LoA) to use for the eIDAS service."), + max_length=100, + ) eidas_attribute_consuming_service_index = models.CharField( _("eIDAS attribute consuming service index"), blank=True, @@ -176,11 +184,15 @@ class Meta: verbose_name = _("Eherkenning/eIDAS configuration") constraints = [ models.constraints.CheckConstraint( - name="valid_loa", check=models.Q(loa__in=AssuranceLevels) + name="valid_loa", + check=models.Q( + models.Q(eh_loa__in=AssuranceLevels) + & models.Q(eidas_loa__in=AssuranceLevels) + ), ), ] - def as_dict(self) -> dict: + def as_dict(self) -> EHerkenningConfig: """ Emit the configuration as a dictionary compatible with the old settings format. """ @@ -215,6 +227,7 @@ def as_dict(self) -> dict: "service_description": self.service_description, "service_description_url": self.service_description_url, "service_url": self.base_url, + "loa": self.eh_loa, "privacy_policy_url": self.privacy_policy, "herkenningsmakelaars_id": self.makelaar_id, "requested_attributes": self.eh_requested_attributes, @@ -247,6 +260,7 @@ def as_dict(self) -> dict: "service_description": self.service_description, "service_description_url": self.service_description_url, "service_url": self.base_url, + "loa": self.eidas_loa, "privacy_policy_url": self.privacy_policy, "herkenningsmakelaars_id": self.makelaar_id, "requested_attributes": self.eidas_requested_attributes, @@ -270,7 +284,6 @@ def as_dict(self) -> dict: "service_entity_id": self.idp_service_entity_id, "oin": self.oin, "services": services, - "loa": self.loa, # optional in runtime code "want_assertions_encrypted": self.want_assertions_encrypted, "want_assertions_signed": self.want_assertions_signed, diff --git a/digid_eherkenning/saml2/eherkenning.py b/digid_eherkenning/saml2/eherkenning.py index 776cdce..25e405f 100644 --- a/digid_eherkenning/saml2/eherkenning.py +++ b/digid_eherkenning/saml2/eherkenning.py @@ -1,7 +1,7 @@ import binascii from base64 import b64encode from io import BytesIO -from typing import List, Literal, Union +from typing import Literal, Union from uuid import uuid4 from django.urls import reverse @@ -12,10 +12,12 @@ from furl.furl import furl from lxml.builder import ElementMaker from lxml.etree import Element, tostring +from onelogin.saml2.settings import OneLogin_Saml2_Settings from ..choices import AssuranceLevels from ..models import EherkenningConfiguration from ..settings import EHERKENNING_DS_XSD +from ..types import EHerkenningConfig, EHerkenningSAMLConfig, ServiceConfig from ..utils import validate_xml from .base import BaseSaml2Client, get_service_description, get_service_name @@ -36,7 +38,7 @@ def generate_dienst_catalogus_metadata(eherkenning_config=None): eherkenning_config = eherkenning_config or EherkenningConfiguration.get_solo() - settings = eherkenning_config.as_dict() + settings: EHerkenningConfig = eherkenning_config.as_dict() # ensure that the single language strings are output in both nl and en for service in settings["services"]: name = service["service_name"] @@ -295,7 +297,7 @@ def create_classifiers_element(classifiers: list) -> ElementMaker: return ESC("Classifiers", *classifiers_elements) -def create_key_descriptor(x509_certificate_content: bytes): +def create_key_descriptor(x509_certificate_content: bytes) -> ElementMaker: certificate = load_pem_x509_certificate(x509_certificate_content) key_name = binascii.hexlify( certificate.fingerprint(certificate.signature_hash_algorithm) @@ -317,7 +319,7 @@ def create_key_descriptor(x509_certificate_content: bytes): return MD("KeyDescriptor", *args, **kwargs) -def create_service_catalogus(conf, validate=True): +def create_service_catalogus(conf: EHerkenningConfig, validate: bool = True) -> bytes: """ https://afsprakenstelsel.etoegang.nl/display/as/Service+catalog """ @@ -366,7 +368,7 @@ def create_service_catalogus(conf, validate=True): service_description, service_description_url, # https://afsprakenstelsel.etoegang.nl/display/as/Level+of+assurance - conf["loa"], + service["loa"], entity_concerned_types_allowed, requested_attributes, herkenningsmakelaars_id, @@ -403,8 +405,8 @@ def create_service_catalogus(conf, validate=True): def get_metadata_eherkenning_requested_attributes( - conf: dict, service_id: str -) -> List[dict]: + 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 requested_attributes = [{"name": service_id, "isRequired": False}] @@ -427,7 +429,7 @@ def get_metadata_eherkenning_requested_attributes( return requested_attributes -def create_attribute_consuming_services(conf: dict) -> list: +def create_attribute_consuming_services(conf: EHerkenningConfig) -> list[dict]: attribute_consuming_services = [] for service in conf["services"]: @@ -466,15 +468,15 @@ def __init__( self.loa = loa @property - def conf(self) -> dict: + def conf(self) -> EHerkenningConfig: if not hasattr(self, "_conf"): db_config = EherkenningConfiguration.get_solo() self._conf = db_config.as_dict() self._conf.setdefault("acs_path", reverse("eherkenning:acs")) return self._conf - def create_config_dict(self, conf): - config_dict = super().create_config_dict(conf) + def create_config_dict(self, conf: EHerkenningConfig) -> EHerkenningSAMLConfig: + config_dict: EHerkenningSAMLConfig = super().create_config_dict(conf) attribute_consuming_services = create_attribute_consuming_services(conf) with conf["cert_file"].open("r") as cert_file, conf["key_file"].open( @@ -504,7 +506,9 @@ def create_config_dict(self, conf): ) return config_dict - def create_config(self, config_dict): + def create_config( + self, config_dict: EHerkenningSAMLConfig + ) -> OneLogin_Saml2_Settings: config_dict["security"].update( { # See comment in the python3-saml for in OneLogin_Saml2_Response.validate_num_assertions (onelogin/saml2/response.py) @@ -515,9 +519,7 @@ def create_config(self, config_dict): "metadataValidUntil": "", "metadataCacheDuration": "", "requestedAuthnContextComparison": "minimum", - "requestedAuthnContext": [ - self.loa or self.conf["loa"], - ], + "requestedAuthnContext": False if not self.loa else [self.loa], } ) return super().create_config(config_dict) diff --git a/digid_eherkenning/types.py b/digid_eherkenning/types.py new file mode 100644 index 0000000..af70bde --- /dev/null +++ b/digid_eherkenning/types.py @@ -0,0 +1,95 @@ +from pathlib import Path +from typing import Optional, TypedDict, Union + + +class ServiceConfig(TypedDict): + service_uuid: str + service_name: str + attribute_consuming_service_index: str + service_instance_uuid: str + service_description: str + service_description_url: str + service_url: str + loa: str + privacy_policy_url: str + herkenningsmakelaars_id: str + requested_attributes: str + service_restrictions_allowed: str + entity_concerned_types_allowed: list[dict] + language: str + classifiers: Optional[list[str]] + + +class EHerkenningConfig(TypedDict): + base_url: str + acs_path: str + entity_id: str + metadata_file: str + cert_file: Path + key_file: Path + service_entity_id: str + oin: str + services: list[ServiceConfig] + want_assertions_encrypted: str + want_assertions_signed: str + key_passphrase: str + signature_algorithm: str + digest_algorithm: str + technical_contact_person_telephone: Optional[str] + technical_contact_person_email: Optional[str] + organization: str + organization_name: str + artifact_resolve_content_type: str + + +class ServiceProviderSAMLConfig(TypedDict): + entityId: str + assertionConsumerService: dict + singleLogoutService: dict + attributeConsumingServices: list[dict] + NameIDFormat: str + x509cert: str + privateKey: str + privateKeyPassphrase: Optional[str] + + +class IdentityProviderSAMLConfig(TypedDict): + entityId: str + singleSignOnService: dict + singleLogoutService: dict + x509cert: str + + +class SecuritySAMLConfig(TypedDict): + nameIdEncrypted: bool + authnRequestsSigned: bool + logoutRequestSigned: bool + logoutResponseSigned: bool + signMetadata: bool + wantMessagesSigned: bool + wantAssertionsSigned: bool + wantAssertionsEncrypted: bool + wantNameId: bool + wantNameIdEncrypted: bool + wantAttributeStatement: bool + requestedAuthnContext: Union[bool, list[str]] + requestedAuthnContextComparison: str + failOnAuthnContextMismatch: bool + metadataValidUntil: Optional[str] + metadataCacheDuration: Optional[str] + allowSingleLabelDomains: bool + signatureAlgorithm: str + digestAlgorithm: str + allowRepeatAttributeName: bool + rejectDeprecatedAlgorithm: bool + disableSignatureWrappingProtection: bool + + +class EHerkenningSAMLConfig(TypedDict): + strict: bool + debug: bool + sp: ServiceProviderSAMLConfig + idp: IdentityProviderSAMLConfig + security: SecuritySAMLConfig + contactPerson: dict + organization: dict diff --git a/digid_eherkenning/views/eherkenning.py b/digid_eherkenning/views/eherkenning.py index 6732357..477f260 100644 --- a/digid_eherkenning/views/eherkenning.py +++ b/digid_eherkenning/views/eherkenning.py @@ -43,7 +43,7 @@ def get_attribute_consuming_service_index(self) -> Optional[str]: # # TODO: It might be a good idea to change this to a post-verb. - # I can't think of any realy attack-vectors, but seems like a good + # I can't think of any relay attack-vectors, but seems like a good # idea anyways. # def get_context_data(self, **kwargs): diff --git a/tests/conftest.py b/tests/conftest.py index bce0893..df740f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,7 +46,8 @@ "service_name": "Example eHerkenning", # TODO: eidas variant? "want_assertions_signed": False, "organization_name": "Example", - "loa": "urn:etoegang:core:assurance-class:loa3", + "eh_loa": "urn:etoegang:core:assurance-class:loa3", + "eidas_loa": "urn:etoegang:core:assurance-class:loa3", "eh_attribute_consuming_service_index": "1", "eidas_attribute_consuming_service_index": "2", "oin": "00000000000000000000", diff --git a/tests/test_dienst_catalogus_creation.py b/tests/test_dienst_catalogus_creation.py index bf5d576..054b6c7 100644 --- a/tests/test_dienst_catalogus_creation.py +++ b/tests/test_dienst_catalogus_creation.py @@ -6,6 +6,7 @@ import pytest from lxml import etree +from digid_eherkenning.choices import AssuranceLevels from digid_eherkenning.models import EherkenningConfiguration from digid_eherkenning.saml2.eherkenning import ( create_service_catalogus, @@ -308,6 +309,8 @@ def test_generate_metadata_all_options_specified(self): self.eherkenning_config.service_description_url = ( "http://test-organisation.nl/service-description/" ) + self.eherkenning_config.eh_loa = AssuranceLevels.high + self.eherkenning_config.eidas_loa = AssuranceLevels.low self.eherkenning_config.eidas_requested_attributes = [ { "name": "urn:etoegang:1.9:attribute:FirstName", @@ -427,7 +430,7 @@ def test_generate_metadata_all_options_specified(self): ".//saml:AuthnContextClassRef", namespaces=NAMESPACES, ) - self.assertEqual("urn:etoegang:core:assurance-class:loa3", loa_node.text) + self.assertEqual("urn:etoegang:core:assurance-class:loa4", loa_node.text) makelaar_id_node = eherkenning_definition_node.find( ".//esc:HerkenningsmakelaarId", @@ -476,7 +479,7 @@ def test_generate_metadata_all_options_specified(self): ".//saml:AuthnContextClassRef", namespaces=NAMESPACES, ) - self.assertEqual("urn:etoegang:core:assurance-class:loa3", loa_node.text) + self.assertEqual("urn:etoegang:core:assurance-class:loa2", loa_node.text) makelaar_id_node = eidas_definition_node.find( ".//esc:HerkenningsmakelaarId", diff --git a/tests/test_eherkenning_views.py b/tests/test_eherkenning_views.py index 6c825e4..983db17 100644 --- a/tests/test_eherkenning_views.py +++ b/tests/test_eherkenning_views.py @@ -51,19 +51,6 @@ def test_login(self, uuid_mock): }, ) - auth_context_class_ref = tree.xpath( - "samlp:RequestedAuthnContext[@Comparison='minimum']/saml:AuthnContextClassRef", - namespaces={ - "samlp": "urn:oasis:names:tc:SAML:2.0:protocol", - "saml": "urn:oasis:names:tc:SAML:2.0:assertion", - }, - )[0] - - self.assertEqual( - auth_context_class_ref.text, - "urn:etoegang:core:assurance-class:loa3", - ) - # Make sure Signature properties are as expected. signature = tree.xpath( "//xmldsig:Signature",