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 test_create_light_response_strip_prefix_needed(self): response = LightResponse(**self.get_light_response_data('CZ/CZ/ff70c9dd-6a05-4068-aaa2-b57be4f328e9')) self.maxDiff = None with patch.object(IdentityProviderResponseView, 'create_light_response', return_value=response): view = CzNiaResponseView() self.assertEqual(view.create_light_response(), LightResponse(**self.get_light_response_data('ff70c9dd-6a05-4068-aaa2-b57be4f328e9')))
def test_create_light_response_do_not_strip_prefix(self): data = self.get_light_response_data('CZ/CZ/ff70c9dd-6a05-4068-aaa2-b57be4f328e9') response = LightResponse(**deepcopy(data)) with patch.object(IdentityProviderResponseView, 'create_light_response', return_value=response): view = CzNiaResponseView() self.assertEqual(view.create_light_response(), LightResponse(**data))
def test_load_xml_with_response_status_failure(self): self.maxDiff = None response = self.create_response(False) with cast(BinaryIO, (DATA_DIR / 'light_response_failure.xml').open('r')) as f: data = f.read() self.assertEqual(LightResponse.load_xml(parse_xml(data)), response)
def pop_light_response(self, uid: str) -> Optional[LightResponse]: """Look up a LightResponse by a unique id and then remove it.""" data = self.get_cache(self.response_cache_name).get_and_remove(uid) LOGGER.debug('Got Light Response from cache: id=%r, data=%s', uid, data) return LightResponse().load_xml( parse_xml(data)) if data is not None else None
def test_rewrite_name_id_persistent(self): light_response_data = LIGHT_RESPONSE_DICT.copy() light_response_data['status'] = Status(**light_response_data['status']) light_response_data['subject_name_id_format'] = NameIdFormat.PERSISTENT view = IdentityProviderResponseView() view.light_response = LightResponse(**light_response_data) view.auxiliary_data = {'name_id_format': NameIdFormat.PERSISTENT.value} view.rewrite_name_id() self.assertEqual(view.light_response.subject_name_id_format, NameIdFormat.PERSISTENT) self.assertEqual(view.light_response.subject, 'CZ/CZ/ff70c9dd-6a05-4068-aaa2-b57be4f328e9')
def test_rewrite_name_id_unspecified_to_transient(self, uuid_mock): light_response_data = LIGHT_RESPONSE_DICT.copy() light_response_data['status'] = Status(**light_response_data['status']) light_response_data['subject_name_id_format'] = NameIdFormat.UNSPECIFIED view = IdentityProviderResponseView() view.light_response = LightResponse(**light_response_data) view.auxiliary_data = {'name_id_format': NameIdFormat.TRANSIENT.value} view.rewrite_name_id() self.assertEqual(view.light_response.subject_name_id_format, NameIdFormat.TRANSIENT) self.assertEqual(view.light_response.subject, '0uuid4')
def test_rewrite_name_id_failure(self): light_response_data = FAILED_LIGHT_RESPONSE_DICT.copy() light_response_data['status'] = Status(**light_response_data['status']) light_response_data['subject_name_id_format'] = NameIdFormat.PERSISTENT view = IdentityProviderResponseView() view.light_response = LightResponse(**light_response_data) view.auxiliary_data = {'name_id_format': NameIdFormat.TRANSIENT.value} view.rewrite_name_id() self.assertEqual(view.light_response.subject_name_id_format, NameIdFormat.PERSISTENT) self.assertIsNone(view.light_response.subject)
def test_post_success(self, uuid_mock: MagicMock): with cast(BinaryIO, (DATA_DIR / 'saml_response.xml').open('rb')) as f: saml_request_xml = f.read() response = self.client.post(self.url, {'SAMLResponse': b64encode(saml_request_xml).decode('ascii'), 'RelayState': 'relay123'}) # Context self.assertIn('token', response.context) self.assertEqual(response.context['token_parameter'], 'test_token') self.assertEqual(response.context['eidas_url'], 'https://test.example.net/SpecificProxyServiceResponse') self.assertEqual(response.context['error'], None) # Token encoded_token = response.context['token'] token = LightToken.decode(encoded_token, 'sha256', 'response-token-secret') self.assertEqual(token.id, 'T0uuid4') self.assertEqual(token.issuer, 'response-token-issuer') self.assertEqual(token.created, datetime(2017, 12, 11, 14, 12, 5)) # Storing light response light_response_data = LIGHT_RESPONSE_DICT.copy() light_response_data.update({ 'status': Status(**light_response_data['status']), 'id': 'test-saml-response-id', # Preserved 'in_response_to_id': 'test-saml-request-id', # Preserved 'issuer': 'https://test.example.net/node-proxy-service-response', # Replaced }) light_response = LightResponse(**light_response_data) self.assertEqual(self.client_class_mock.mock_calls, [call(timeout=66)]) self.assertEqual(self.client_mock.mock_calls, [call.connect('test.example.net', 1234), call.get_cache('test-proxy-service-response-cache'), call.get_cache().put('T0uuid4', dump_xml(light_response.export_xml()).decode('utf-8'))]) # Rendering self.assertContains(response, 'Redirect to eIDAS Node is in progress') self.assertContains(response, '<form class="auto-submit" action="https://test.example.net/SpecificProxyServiceResponse"') self.assertContains(response, '<input type="hidden" name="test_token" value="{}"'.format(encoded_token)) self.assertNotIn(b'An error occurred', response.content)
def test_put_light_response(self): with cast(TextIO, (DATA_DIR / 'light_response.xml').open('r')) as f: data = f.read() response = LightResponse.load_xml(parse_xml(data)) self.storage.put_light_response('abc', response) self.assertEqual(self.client_class_mock.mock_calls, [call(timeout=33)]) self.assertEqual(self.client_mock.mock_calls, [ call.connect(self.HOST, self.PORT), call.get_cache(self.RESPONSE_CACHE_NAME), call.get_cache().put('abc', data) ])
def test_pop_light_response_found(self): with cast(BinaryIO, (DATA_DIR / 'light_response.xml').open('rb')) as f: data = f.read() self.cache_mock.get_and_remove.return_value = data.decode('utf-8') self.assertEqual(LightResponse.load_xml(parse_xml(data)), self.storage.pop_light_response('abc')) self.assertEqual(self.client_class_mock.mock_calls, [call(timeout=33)]) self.assertEqual(self.client_mock.mock_calls, [ call.connect(self.HOST, self.PORT), call.get_cache(self.RESPONSE_CACHE_NAME), call.get_cache().get_and_remove('abc') ])
def test_create_light_token(self, uuid_mock: MagicMock): view = IdentityProviderResponseView() view.request = self.factory.post(self.url) light_response_data = LIGHT_RESPONSE_DICT.copy() light_response_data['status'] = Status(**light_response_data['status']) view.light_response = LightResponse(**light_response_data) token, encoded_token = view.create_light_token('test-token-issuer', 'sha256', 'test-secret') self.assertEqual(token.id, 'T0uuid4') self.assertEqual(token.issuer, 'test-token-issuer') self.assertEqual(token.created, datetime(2017, 12, 11, 16, 12, 5)) self.assertEqual(token.encode('sha256', 'test-secret').decode('ascii'), encoded_token) self.assertEqual(uuid_mock.mock_calls, [call()])
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 put_light_response(self, uid: str, response: LightResponse) -> None: """Store a LightResponse under a unique id.""" data = dump_xml(response.export_xml()).decode('utf-8') LOGGER.debug('Store Light Response to cache: id=%r, data=%s', uid, data) self.get_cache(self.response_cache_name).put(uid, data)
def create_response(self, success: bool) -> LightResponse: data = (LIGHT_RESPONSE_DICT if success else FAILED_LIGHT_RESPONSE_DICT).copy() data['status'] = Status(**data['status']) return LightResponse(**data)
def from_light_response(cls: Type[SAMLResponseType], light_response: LightResponse, audience: Optional[str], destination: Optional[str], issued: datetime, validity: timedelta) -> SAMLResponseType: """Convert light response to SAML response.""" light_response.validate() issue_instant = datetime_iso_format_milliseconds(issued) + 'Z' # UTC valid_until = datetime_iso_format_milliseconds(issued + validity) + 'Z' # UTC root_attributes = { 'ID': light_response.id, 'InResponseTo': light_response.in_response_to_id, 'Version': '2.0', 'IssueInstant': issue_instant, } confirmation_data = { 'InResponseTo': light_response.in_response_to_id, 'NotOnOrAfter': valid_until } conditions = {'NotBefore': issue_instant, 'NotOnOrAfter': valid_until} if destination is not None: root_attributes['Destination'] = destination confirmation_data['Recipient'] = destination root = etree.Element(Q_NAMES['saml2p:Response'], attrib=root_attributes, nsmap=EIDAS_NAMESPACES) # 1. StatusResponseType <saml2:Issuer> optional if light_response.issuer is not None: SubElement(root, Q_NAMES['saml2:Issuer']).text = light_response.issuer # 2. StatusResponseType <ds:Signature> optional, skipped # 3. StatusResponseType <saml2p:Extensions> optional, skipped # 4. StatusResponseType <saml2p:Status> required status = light_response.status assert status is not None status_elm = SubElement(root, Q_NAMES['saml2p:Status']) # 4.1 <saml2p:Status> <saml2p:StatusCode> required status_code = status.status_code sub_status_code = status.sub_status_code if status_code is None: status_code = StatusCode.SUCCESS if not status.failure else StatusCode.RESPONDER # VERSION_MISMATCH is a status code in SAML 2 but a sub status code in Light response! if sub_status_code == SubStatusCode.VERSION_MISMATCH: status_code_value = SubStatusCode.VERSION_MISMATCH.value sub_status_code_value = None else: status_code_value = status_code.value sub_status_code_value = None if sub_status_code is None else sub_status_code.value status_code_elm = SubElement(status_elm, Q_NAMES['saml2p:StatusCode'], {'Value': status_code_value}) if sub_status_code_value is not None: SubElement(status_code_elm, Q_NAMES['saml2p:StatusCode'], {'Value': sub_status_code_value}) # 4.2 <saml2p:Status> <saml2p:StatusMessage> optional if status.status_message is not None: SubElement( status_elm, Q_NAMES['saml2p:StatusMessage']).text = status.status_message # 4.3 <saml2p:Status> <saml2p:StatusDetail> optional, skipped if not status.failure: # 5. AssertionType assertion_elm = SubElement( root, Q_NAMES['saml2:Assertion'], { 'ID': '_' + light_response.id, 'Version': '2.0', 'IssueInstant': issue_instant, }) # 5.1 AssertionType <saml2:Issuer> required SubElement(assertion_elm, Q_NAMES['saml2:Issuer']).text = light_response.issuer # 5.2 AssertionType <ds:Signature> optional, skipped # 5.3 AssertionType <saml2:Subject> optional subject_elm = SubElement(assertion_elm, Q_NAMES['saml2:Subject']) SubElement(subject_elm, Q_NAMES['saml2:NameID'], { 'Format': light_response.subject_name_id_format.value }).text = light_response.subject confirmation_elm = SubElement( subject_elm, Q_NAMES['saml2:SubjectConfirmation'], {'Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer'}) SubElement(confirmation_elm, Q_NAMES['saml2:SubjectConfirmationData'], confirmation_data) # 5.4 AssertionType <saml2:Conditions> optional conditions_elm = SubElement(assertion_elm, Q_NAMES['saml2:Conditions'], conditions) if audience is not None: SubElement( SubElement(conditions_elm, Q_NAMES['saml2:AudienceRestriction']), Q_NAMES['saml2:Audience']).text = audience # 5.5 AssertionType <saml2:Advice> optional, skipped # 5.6 AssertionType <saml2:AttributeStatement> attributes_elm = SubElement(assertion_elm, Q_NAMES['saml2:AttributeStatement']) for name, values in light_response.attributes.items(): attribute = SubElement( attributes_elm, Q_NAMES['saml2:Attribute'], create_attribute_elm_attributes(name, None)) for value in values: SubElement(attribute, Q_NAMES['saml2:AttributeValue']).text = value # 5.7 AssertionType <saml2:AuthnStatement> statement_elm = SubElement(assertion_elm, Q_NAMES['saml2:AuthnStatement'], {'AuthnInstant': issue_instant}) if light_response.ip_address is not None: SubElement(statement_elm, Q_NAMES['saml2:SubjectLocality'], {'Address': light_response.ip_address}) SubElement( SubElement(statement_elm, Q_NAMES['saml2:AuthnContext']), Q_NAMES['saml2:AuthnContextClassRef'] ).text = light_response.level_of_assurance.value return cls(ElementTree(root), light_response.relay_state)
def get_light_response(self, **kwargs) -> LightResponse: light_response_data = LIGHT_RESPONSE_DICT.copy() light_response_data['status'] = Status(**light_response_data['status']) light_response_data.update(**kwargs) return LightResponse(**light_response_data)