def validate_fields(self, required_type: Type, *fields: str, required: bool = True) -> None: """ Validate fields. :param required_type: The required type of the field. :param fields: The fields to validate. :param required: Whether the field is required or can be None. :raise ValidationError: when validation fails. """ for name in fields: value = getattr(self, name) if isinstance(value, str) and not value: value = None # Treat empty strings as None if not isinstance(value, required_type): if required: raise ValidationError({ name: 'Must be {}, not {}.'.format(required_type.__name__, type(value).__name__) }) if value is not None: raise ValidationError({ name: 'Must be {} or None, not {}.'.format( required_type.__name__, type(value).__name__) })
def load_xml(cls: Type[T], root: Union[Element, ElementTree]) -> T: """ Load Light Request from a XML document. :param root: The XML document to load. :raise ValidationError: If the XML document does not have a valid schema. :raise TypeError: If ROOT_ELEMENT class attribute is not defined. """ if not cls.ROOT_ELEMENT: raise TypeError('XMLDataModel subclasses must define ROOT_ELEMENT class attribute.') if hasattr(root, 'getroot'): root = root.getroot() if QName(root.tag).localname != cls.ROOT_ELEMENT: raise ValidationError({get_element_path(root): 'Invalid root element {!r}.'.format(root.tag)}) model = cls() for elm in root: field_name = convert_tag_name_to_field_name(elm.tag) if field_name not in cls.FIELDS: raise ValidationError({get_element_path(elm): 'Unknown element {!r}.'.format(elm.tag)}) deserialize_func = getattr(model, 'deserialize_' + field_name, None) setattr(model, field_name, deserialize_func(elm) if deserialize_func else elm.text) return model
def deserialize_attributes(attributes_elm: Element) -> Dict[str, List[str]]: """Deserialize eIDAS attributes.""" attributes = OrderedDict() # type: Dict[str, List[str]] for attribute in attributes_elm: if QName(attribute.tag).localname != 'attribute': raise ValidationError({ get_element_path(attribute): 'Unexpected element {!r}'.format(attribute.tag) }) if not len(attribute): raise ValidationError({ get_element_path(attribute): 'Missing attribute.definition element.' }) definition = attribute[0] if QName(definition.tag).localname != 'definition': raise ValidationError({ get_element_path(definition): 'Unexpected element {!r}'.format(definition.tag) }) values = attributes[definition.text] = [] for value in attribute[1:]: if QName(value.tag).localname != 'value': raise ValidationError({ get_element_path(value): 'Unexpected element {!r}'.format(value.tag) }) values.append(value.text) return attributes
def validate_attributes(model: DataModel, field_name: str) -> None: """Validate eIDAS attributes.""" model.validate_fields(dict, field_name, required=True) attributes = getattr(model, field_name) # type: Dict[str, List[str]] for key, values in attributes.items(): if not isinstance(key, str) or not key.strip(): raise ValidationError({field_name: 'All keys must be strings.'}) if not isinstance(values, list) or any(not isinstance(value, str) for value in values): raise ValidationError( {field_name: 'All values must be lists of strings.'})
def create_light_request(self) -> LightRequest: """ Convert SAML Request to Light Request. :return: A Light Request. :raise ValidationError: If the SAML Request cannot be parsed correctly. """ request = LightRequest(requested_attributes=OrderedDict(), citizen_country_code=self.citizen_country_code, relay_state=self.relay_state) root = self.document.getroot() if root.tag != Q_NAMES['saml2p:AuthnRequest']: raise ValidationError({get_element_path(root): 'Wrong root element: {!r}'.format(root.tag)}) request.id = root.attrib.get('ID') request.provider_name = root.attrib.get('ProviderName') request.issuer = self.issuer name_id_format = root.find('./{}'.format(Q_NAMES['saml2p:NameIDPolicy'])) if name_id_format is not None: request.name_id_format = NameIdFormat(name_id_format.attrib.get('Format')) level_of_assurance = root.find( './{}/{}'.format(Q_NAMES['saml2p:RequestedAuthnContext'], Q_NAMES['saml2:AuthnContextClassRef'])) if level_of_assurance is not None: request.level_of_assurance = LevelOfAssurance(level_of_assurance.text) extensions = root.find('./{}'.format(Q_NAMES['saml2p:Extensions'])) if extensions is not None: sp_type = extensions.find('./{}'.format(Q_NAMES['eidas:SPType'])) if sp_type is not None: request.sp_type = ServiceProviderType(sp_type.text) sp_country = extensions.find('./{}'.format(Q_NAMES['eidas:SPCountry'])) if sp_country is not None: request.origin_country_code = sp_country.text requested_attributes = request.requested_attributes attributes = extensions.findall( './{}/{}'.format(Q_NAMES['eidas:RequestedAttributes'], Q_NAMES['eidas:RequestedAttribute'])) for attribute in attributes: name = attribute.attrib.get('Name') if not name: raise ValidationError({ get_element_path(attribute): "Missing attribute 'Name'"}) values = requested_attributes[name] = [] for value in attribute.findall('./{}'.format(Q_NAMES['eidas:AttributeValue'])): values.append(value.text) return request
def validate(self) -> None: """Validate this data model.""" self.validate_fields(str, 'id', 'issuer', required=True) self.validate_fields(datetime, 'created', required=True) for field in 'id', 'issuer': if '|' in getattr(self, field): raise ValidationError({field: 'Character "|" not allowed.'})
def _parse_assertion(self, response: LightResponse, assertion: Element, auth_class_map: Optional[Dict[str, LevelOfAssurance]]) -> None: attributes = response.attributes = OrderedDict() name_id_elm = assertion.find('./{}/{}'.format(Q_NAMES['saml2:Subject'], Q_NAMES['saml2:NameID'])) if name_id_elm is not None: response.subject = name_id_elm.text response.subject_name_id_format = NameIdFormat(name_id_elm.get('Format')) attribute_elms = assertion.findall('./{}/{}'.format(Q_NAMES['saml2:AttributeStatement'], Q_NAMES['saml2:Attribute'])) for attribute in attribute_elms: attributes[attribute.get('Name')] = [ elm.text for elm in attribute.findall('./{}'.format(Q_NAMES['saml2:AttributeValue']))] stm_elm = assertion.find('./{}'.format(Q_NAMES['saml2:AuthnStatement'])) if stm_elm is not None: locality_elm = stm_elm.find('./{}'.format(Q_NAMES['saml2:SubjectLocality'])) if locality_elm is not None: response.ip_address = locality_elm.get('Address') authn_class_elm = stm_elm.find('./{}/{}'.format(Q_NAMES['saml2:AuthnContext'], Q_NAMES['saml2:AuthnContextClassRef'])) if authn_class_elm is not None: try: response.level_of_assurance = LevelOfAssurance(authn_class_elm.text) except ValueError: if auth_class_map and authn_class_elm.text in auth_class_map: response.level_of_assurance = auth_class_map[authn_class_elm.text] else: raise ValidationError({get_element_path(authn_class_elm): authn_class_elm.text}) from None
def create_light_response(self, auth_class_map: Dict[str, LevelOfAssurance] = None) -> LightResponse: """Convert SAML response to light response.""" response = LightResponse(attributes=OrderedDict()) root = self.document.getroot() if root.tag != Q_NAMES['saml2p:Response']: raise ValidationError({ get_element_path(root): 'Wrong root element: {!r}'.format(root.tag)}) response.id = root.get('ID') response.in_response_to_id = root.get('InResponseTo') response.issuer = self.issuer response.status = status = Status() status_code_elm = root.find('./{}/{}'.format(Q_NAMES['saml2p:Status'], Q_NAMES['saml2p:StatusCode'])) if status_code_elm is not None: sub_status_code_elm = status_code_elm.find('./{}'.format(Q_NAMES['saml2p:StatusCode'])) status_message_elm = root.find('./{}/{}'.format(Q_NAMES['saml2p:Status'], Q_NAMES['saml2p:StatusMessage'])) status_code = status_code_elm.get('Value') sub_status_code = sub_status_code_elm.get('Value') if sub_status_code_elm is not None else None if status_code == SubStatusCode.VERSION_MISMATCH.value: # VERSION_MISMATCH is a status code in SAML 2 but a sub status code in Light response! status.status_code = StatusCode.REQUESTER status.sub_status_code = SubStatusCode.VERSION_MISMATCH else: status.status_code = StatusCode(status_code) try: status.sub_status_code = SubStatusCode(sub_status_code) except ValueError: # None or a sub status codes not recognized by eIDAS status.sub_status_code = None status.failure = status.status_code != StatusCode.SUCCESS if status_message_elm is not None: status.status_message = status_message_elm.text assertion_elm = self.assertion if assertion_elm is not None: try: self._parse_assertion(response, assertion_elm, auth_class_map) except ValidationError as e: return LightResponse( id=root.get('ID'), in_response_to_id=root.get('InResponseTo'), issuer=self.issuer, status=Status( failure=True, status_code=StatusCode.RESPONDER, status_message='Invalid data of {!r}: {!r}'.format(*e.errors.popitem()))) response.relay_state = self.relay_state return response
def test_errors_attribute(self): self.assertIs(ValidationError(self.ERRORS).errors, self.ERRORS)
def test_str(self): self.assertEqual(str(ValidationError(self.ERRORS)), "Validation failed: {'name': 'Invalid name.'}")
def test_repr(self): self.assertEqual(repr(ValidationError(self.ERRORS)), "ValidationError({'name': 'Invalid name.'})")
def from_light_request(cls: Type[SAMLRequestType], light_request: LightRequest, destination: str, issued: datetime) -> SAMLRequestType: """ Convert Light Request to SAML Request. :param light_request: The light request to convert. :param destination: A URI reference indicating the address to which this request has been sent. :param issued: The UTC time instant of issue of the request. :return: A SAML Request. """ light_request.validate() if not is_xml_id_valid(light_request.id): raise ValidationError({ 'id': 'Light request id is not a valid XML id: {!r}'.format( light_request.id) }) root_attributes = OrderedDict([ ('Consent', 'urn:oasis:names:tc:SAML:2.0:consent:unspecified' ), # optional, default 'unspecified' ('Destination', destination), ('ID', light_request.id), ('IssueInstant', datetime_iso_format_milliseconds(issued) + 'Z'), # UTC ('Version', '2.0'), ('IsPassive', 'false'), # optional, default false ('ForceAuthn', 'true'), # optional, default false ]) if light_request.provider_name is not None: root_attributes['ProviderName'] = light_request.provider_name root = etree.Element(Q_NAMES['saml2p:AuthnRequest'], attrib=root_attributes, nsmap=EIDAS_NAMESPACES) # 1. RequestAbstractType <saml2:Issuer>: if light_request.issuer is not None: SubElement(root, Q_NAMES['saml2:Issuer'], { 'Format': 'urn:oasis:names:tc:SAML:2.0:nameid-format:entity' }).text = light_request.issuer # 2. RequestAbstractType <ds:Signature> skipped # 3. RequestAbstractType <saml2p:Extensions>: extensions = SubElement(root, Q_NAMES['saml2p:Extensions']) if light_request.sp_type: SubElement( extensions, Q_NAMES['eidas:SPType']).text = light_request.sp_type.value # In patched version 2.3.1 of CEF eIDAS Node (according EID-922), origin_country_code is used if light_request.sp_country_code: SubElement(extensions, Q_NAMES['eidas:SPCountry'] ).text = light_request.sp_country_code # In the version 2.4 of CEF eIDAS Node, citizen_country_code is used else: assert light_request.citizen_country_code # mandatory field SubElement(extensions, Q_NAMES['eidas:SPCountry'] ).text = light_request.citizen_country_code attributes = SubElement(extensions, Q_NAMES['eidas:RequestedAttributes']) for name, values in light_request.requested_attributes.items(): attribute = SubElement(attributes, Q_NAMES['eidas:RequestedAttribute'], create_attribute_elm_attributes(name, True)) for value in values: SubElement(attribute, Q_NAMES['eidas:AttributeValue']).text = value # 4. AuthnRequestType <saml2:Subject> skipped # 5. AuthnRequestType <saml2p:NameIDPolicy>: if light_request.name_id_format: SubElement( root, Q_NAMES['saml2p:NameIDPolicy'], { 'AllowCreate': 'true', # optional, default false 'Format': light_request.name_id_format.value }) # 6. AuthnRequestType <saml2:Conditions> skipped # 7. AuthnRequestType <saml2p:RequestedAuthnContext>: SubElement( SubElement(root, Q_NAMES['saml2p:RequestedAuthnContext'], {'Comparison': 'minimum'}), Q_NAMES['saml2:AuthnContextClassRef'] ).text = light_request.level_of_assurance.value # 8: AuthnRequestType <saml2p:Scoping> skipped return cls(ElementTree(root), light_request.citizen_country_code, light_request.relay_state)