def test_post_success(self): self.maxDiff = None request = LightRequest(**LIGHT_REQUEST_DICT) self.cache_mock.get_and_remove.return_value = dump_xml(request.export_xml()).decode('utf-8') token, encoded = self.get_token() response = self.client.post(self.url, {'test_token': encoded}) # Context self.assertIn('saml_request', response.context) self.assertEqual(response.context['identity_provider_endpoint'], 'https://test.example.net/identity-provider-endpoint') self.assertEqual(response.context['relay_state'], 'relay123') self.assertEqual(response.context['error'], None) # SAML Request saml_request_xml = b64decode(response.context['saml_request'].encode('utf-8')).decode('utf-8') self.assertIn(request.id, saml_request_xml) # light_request.id preserved self.assertIn('<saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">' 'https://test.example.net/saml/idp.xml</saml2:Issuer>', saml_request_xml) self.assertIn('Destination="http://testserver/IdentityProviderResponse"', saml_request_xml) self.assertIn('<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo>', saml_request_xml) # Rendering self.assertContains(response, 'Redirect to Identity Provider is in progress') self.assertContains(response, '<form class="auto-submit" action="https://test.example.net/identity-provider-endpoint"') self.assertContains(response, '<input type="hidden" name="SAMLRequest" value="{}"'.format( response.context['saml_request'])) self.assertContains(response, '<input type="hidden" name="RelayState" value="relay123"/>') self.assertNotIn(b'An error occurred', response.content)
def test_load_xml_attributes_unexpected_element(self): data = parse_xml(b'<lightRequest><requestedAttributes><myField>data</myField>' b'</requestedAttributes></lightRequest>') with self.assertRaisesMessage(ValidationError, '\'<lightRequest><requestedAttributes><myField>\': ' '"Unexpected element \'myField\'"'): LightRequest.load_xml(data)
def test_load_xml_attribute_values_unexpected_element(self): data = parse_xml(b'<lightRequest><requestedAttributes><attribute><definition>data</definition><foo/>' b'</attribute></requestedAttributes></lightRequest>') with self.assertRaisesMessage(ValidationError, '\'<lightRequest><requestedAttributes><attribute><foo>\': ' '"Unexpected element \'foo\'"'): LightRequest.load_xml(data)
def test_export_xml_minimal_sample(self): request = LightRequest( citizen_country_code='CA', id='test-light-request-id', level_of_assurance=LevelOfAssurance.LOW, requested_attributes={}) with cast(BinaryIO, (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f: data = f.read() self.assertEqual(dump_xml(request.export_xml()), data)
def post(self, request: HttpRequest) -> HttpResponse: """Handle a HTTP POST request.""" try: preset = PRESETS[int(request.POST.get('Request', ''))] except (ValueError, KeyError): return HttpResponseBadRequest() light_request = LightRequest( id=create_xml_uuid(), issuer=CONNECTOR_SETTINGS.service_provider['request_issuer'], level_of_assurance=LevelOfAssurance.LOW, provider_name="Demo Service Provider", sp_type=ServiceProviderType.PUBLIC, relay_state=request.POST.get('RelayState') or None, origin_country_code='EU', citizen_country_code=request.POST.get('Country'), name_id_format=preset.id_format, requested_attributes={name: [] for name in preset.attributes} ) if not light_request.citizen_country_code: # Use a placeholder to get through light request validation. light_request.citizen_country_code = COUNTRY_PLACEHOLDER self.saml_request = SAMLRequest.from_light_request(light_request, '/dest', datetime.utcnow()) signature_options = CONNECTOR_SETTINGS.service_provider['response_signature'] if signature_options and signature_options.get('key_file') and signature_options.get('cert_file'): self.saml_request.sign_request(**signature_options) return self.get(request)
def test_load_xml_attributes_definition_element(self): data = parse_xml(b'<lightRequest><requestedAttributes><attribute>data</attribute>' b'</requestedAttributes></lightRequest>') with self.assertRaisesMessage(ValidationError, "'<lightRequest><requestedAttributes><attribute>': " "'Missing attribute.definition element.'"): LightRequest.load_xml(data) data = parse_xml(b'<lightRequest><requestedAttributes><attribute><foo>data</foo>' b'</attribute></requestedAttributes></lightRequest>') with self.assertRaisesMessage(ValidationError, '\'<lightRequest><requestedAttributes><attribute><foo>\': ' '"Unexpected element \'foo\'"'): LightRequest.load_xml(data)
def test_create_light_request_without_extensions(self): root = Element(Q_NAMES['saml2p:AuthnRequest'], nsmap=EIDAS_NAMESPACES) saml_request = SAMLRequest(ElementTree(root), 'CZ', 'relay123') expected = LightRequest(citizen_country_code='CZ', relay_state='relay123', requested_attributes=OrderedDict()) self.assertEqual(saml_request.create_light_request(), expected)
def test_export_xml_full_sample(self): self.maxDiff = None with cast(BinaryIO, (DATA_DIR / 'light_request.xml').open('rb')) as f: data = f.read() request = LightRequest.load_xml(parse_xml(data)) self.assertEqual(dump_xml(request.export_xml()), data)
def test_get_light_request_success(self): orig_light_request = LightRequest(**LIGHT_REQUEST_DICT) self.cache_mock.get_and_remove.return_value = dump_xml(orig_light_request.export_xml()).decode('utf-8') token, encoded = self.get_token() view = ProxyServiceRequestView() view.request = self.factory.post(self.url, {'test_token': encoded}) view.light_token = token view.storage = IgniteStorage('test.example.net', 1234, 'test-proxy-service-request-cache', '') light_request = view.get_light_request() self.assertEqual(light_request, orig_light_request) self.maxDiff = None self.assertEqual(self.client_mock.mock_calls, [call.connect('test.example.net', 1234), call.get_cache('test-proxy-service-request-cache'), call.get_cache().get_and_remove('request-token-id')])
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 test_from_light_request(self): self.maxDiff = None saml_request = SAMLRequest.from_light_request( LightRequest(**LIGHT_REQUEST_DICT), 'test/destination', datetime(2017, 12, 11, 14, 12, 5, 148000)) with cast(TextIO, (DATA_DIR / 'saml_request.xml').open('r')) as f2: data = f2.read() self.assertXMLEqual( dump_xml(saml_request.document).decode('utf-8'), data) self.assertEqual(saml_request.relay_state, 'relay123') self.assertEqual(saml_request.citizen_country_code, 'CA')
def test_post_remember_country_codes(self): request = LightRequest(**LIGHT_REQUEST_DICT) self.cache_mock.get_and_remove.return_value = dump_xml(request.export_xml()).decode('utf-8') token, encoded = self.get_token() response = self.client.post(self.url, {'test_token': encoded}) self.assertEqual(response.status_code, 200) self.assertEqual( self.client_mock.mock_calls, [ call.connect('test.example.net', 1234), call.get_cache('test-proxy-service-request-cache'), call.get_cache().get_and_remove('request-token-id'), call.connect('test.example.net', 1234), call.get_cache('aux-cache'), call.get_cache().put( 'aux-test-light-request-id', '{"citizen_country": "CA", "origin_country": "CA"}' ), ] )
def test_post_remember_name_id_format(self): request = LightRequest(**LIGHT_REQUEST_DICT) self.cache_mock.get_and_remove.return_value = dump_xml(request.export_xml()).decode('utf-8') token, encoded = self.get_token() response = self.client.post(self.url, {'test_token': encoded}) self.assertEqual(response.status_code, 200) self.assertEqual( self.client_mock.mock_calls, [ call.connect('test.example.net', 1234), call.get_cache('test-proxy-service-request-cache'), call.get_cache().get_and_remove('request-token-id'), call.connect('test.example.net', 1234), call.get_cache('aux-cache'), call.get_cache().put( 'aux-test-light-request-id', '{"name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"}' ), ] )
def test_put_light_request(self): with cast(TextIO, (DATA_DIR / 'light_request.xml').open('r')) as f: data = f.read() request = LightRequest.load_xml(parse_xml(data)) self.storage.put_light_request('abc', request) 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.REQUEST_CACHE_NAME), call.get_cache().put('abc', data) ])
def test_from_light_request_invalid_id(self): self.maxDiff = None with cast(BinaryIO, (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f: request = LightRequest.load_xml(parse_xml(f)) request.id = '0day' with self.assert_validation_error( 'id', "Light request id is not a valid XML id: '0day'"): SAMLRequest.from_light_request( request, 'test/destination', datetime(2017, 12, 11, 14, 12, 5, 148000))
def test_pop_light_request_found(self): with cast(BinaryIO, (DATA_DIR / 'light_request.xml').open('rb')) as f: data = f.read() self.cache_mock.get_and_remove.return_value = data.decode('utf-8') self.assertEqual(LightRequest.load_xml(parse_xml(data)), self.storage.pop_light_request('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.REQUEST_CACHE_NAME), call.get_cache().get_and_remove('abc') ])
def test_create_light_token(self, uuid_mock: MagicMock): view = ServiceProviderRequestView() light_request_data = LIGHT_REQUEST_DICT.copy() view.light_request = LightRequest(**light_request_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 test_post_remember_country_codes(self, uuid_mock): self.maxDiff = None saml_request_xml, saml_request_encoded = self.load_saml_request( signed=True) light_request = LightRequest(**LIGHT_REQUEST_DICT) light_request.issuer = 'https://example.net/EidasNode/ConnectorMetadata' self.cache_mock.get_and_remove.return_value = dump_xml( light_request.export_xml()).decode('utf-8') response = self.client.post( self.url, { 'SAMLRequest': saml_request_encoded, 'RelayState': 'relay123', 'country_param': 'ca' }) self.assertEqual(response.status_code, 200) self.assertSequenceEqual(self.client_mock.mock_calls[-3:], [ call.connect('test.example.net', 1234), call.get_cache('aux-cache'), call.get_cache().put( 'aux-test-saml-request-id', '{"citizen_country": "CA", "origin_country": "CA"}'), ])
def test_load_xml_minimal_sample(self): self.maxDiff = None with cast(BinaryIO, (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f: request = LightRequest.load_xml(parse_xml(f)) self.assertEqual(request.citizen_country_code, 'CA') self.assertEqual(request.id, 'test-light-request-id') self.assertIsNone(request.issuer) self.assertEqual(request.level_of_assurance, LevelOfAssurance.LOW) self.assertIsNone(request.name_id_format) self.assertIsNone(request.provider_name) self.assertIsNone(request.sp_type) self.assertIsNone(request.relay_state) self.assertEqual(request.requested_attributes, {})
def test_create_saml_request_signed(self): light_request = LightRequest(**LIGHT_REQUEST_DICT) token, encoded = self.get_token() view = ProxyServiceRequestView() view.request = self.factory.post(self.url, {'test_token': encoded}) view.light_token = token view.light_request = light_request saml_request = view.create_saml_request('https://test.example.net/saml/idp.xml', SIGNATURE_OPTIONS) root = saml_request.document.getroot() self.assertEqual(root.get('ID'), 'test-light-request-id') self.assertEqual(root.get('IssueInstant'), '2017-12-11T14:12:05.000Z') self.assertEqual(root.find(".//{}".format(Q_NAMES['saml2:Issuer'])).text, 'https://test.example.net/saml/idp.xml') self.assertIsNotNone(root.find('./{}'.format(Q_NAMES['ds:Signature'])))
def test_from_light_request_minimal(self): self.maxDiff = None with cast(BinaryIO, (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f: request = LightRequest.load_xml(parse_xml(f)) request.id = 'test-saml-request-id' saml_request = SAMLRequest.from_light_request( request, 'test/destination', datetime(2017, 12, 11, 14, 12, 5, 148000)) with cast(TextIO, (DATA_DIR / 'saml_request_minimal.xml').open('r')) as f2: data = f2.read() self.assertXMLEqual( dump_xml(saml_request.document).decode('utf-8'), data) self.assertEqual(saml_request.relay_state, None) self.assertEqual(saml_request.citizen_country_code, 'CA')
def put_light_request(self, uid: str, request: LightRequest) -> None: """Store a LightRequest under a unique id.""" data = dump_xml(request.export_xml()).decode('utf-8') LOGGER.debug('Store Light Request to cache: id=%r, data=%s', uid, data) self.get_cache(self.request_cache_name).put(uid, data)
def pop_light_request(self, uid: str) -> Optional[LightRequest]: """Look up a LightRequest by a unique id and then remove it.""" data = self.get_cache(self.request_cache_name).get_and_remove(uid) LOGGER.debug('Got Light Request from cache: id=%r, data=%s', uid, data) return LightRequest().load_xml( parse_xml(data)) if data is not None else None
def test_post_success(self, uuid_mock: MagicMock): self.maxDiff = None saml_request_xml, saml_request_encoded = self.load_saml_request( signed=True) light_request = LightRequest(**LIGHT_REQUEST_DICT) light_request.issuer = 'https://example.net/EidasNode/ConnectorMetadata' self.cache_mock.get_and_remove.return_value = dump_xml( light_request.export_xml()).decode('utf-8') response = self.client.post( self.url, { 'SAMLRequest': saml_request_encoded, 'RelayState': 'relay123', 'country_param': 'ca' }) # Context self.assertIn('token', response.context) self.assertEqual(response.context['token_parameter'], 'test_request_token') self.assertEqual(response.context['eidas_url'], 'http://test.example.net/SpecificConnectorRequest') self.assertEqual(response.context['error'], None) # Token encoded_token = response.context['token'] token = LightToken.decode(encoded_token, 'sha256', 'request-token-secret') self.assertEqual(token.id, 'T0uuid4') self.assertEqual(token.issuer, 'request-token-issuer') self.assertEqual(token.created, datetime(2017, 12, 11, 14, 12, 5)) # Storing light request light_request_data = LIGHT_REQUEST_DICT.copy() light_request_data.update({ 'id': 'test-saml-request-id', 'issuer': 'test-connector-request-issuer', }) light_request = LightRequest(**light_request_data) light_request.requested_attributes = light_request.requested_attributes.copy( ) del light_request.requested_attributes[ 'http://eidas.europa.eu/attributes/naturalperson/AdditionalAttribute'] del light_request.requested_attributes[ 'http://eidas.europa.eu/attributes/legalperson/LegalAdditionalAttribute'] 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-connector-request-cache'), call.get_cache().put( 'T0uuid4', dump_xml(light_request.export_xml()).decode('utf-8')) ]) # Rendering self.assertContains(response, 'Redirect to eIDAS Node is in progress') self.assertContains(response, 'eidas_node/connector/formautosubmit.js') self.assertContains( response, '<form class="auto-submit" ' 'action="http://test.example.net/SpecificConnectorRequest"') self.assertContains( response, '<input type="hidden" name="test_request_token" value="{}"'.format( encoded_token)) self.assertNotIn(b'An error occurred', response.content)
def test_load_xml_unknown_element(self): data = parse_xml(b'<lightRequest><myField>data</myField></lightRequest>') with self.assertRaisesMessage(ValidationError, '\'<lightRequest><myField>\': "Unknown element \'myField\'."'): LightRequest.load_xml(data)
def test_load_xml_wrong_root_element(self): data = parse_xml(b'<lightResponse></lightResponse>') with self.assertRaisesMessage(ValidationError, '\'<lightResponse>\': "Invalid root element \'lightResponse\'."'): LightRequest.load_xml(data)
def test_load_xml_full_sample(self): with cast(BinaryIO, (DATA_DIR / 'light_request.xml').open('rb')) as f: request = LightRequest.load_xml(parse_xml(f)) self.assertEqual(request, LightRequest(**LIGHT_REQUEST_DICT))
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)