diff options
-rw-r--r-- | src/s2repoze/plugins/sp.py | 6 | ||||
-rw-r--r-- | src/saml2/client.py | 536 | ||||
-rw-r--r-- | tests/test_51_client.py | 18 |
3 files changed, 271 insertions, 289 deletions
diff --git a/src/s2repoze/plugins/sp.py b/src/s2repoze/plugins/sp.py index 46d4afda..a0addcb3 100644 --- a/src/s2repoze/plugins/sp.py +++ b/src/s2repoze/plugins/sp.py @@ -344,9 +344,9 @@ class SAML2Plugin(FormPluginBase): try: # Evaluate the response, returns a AuthnResponse instance try: - authresp = self.saml_client.response(post, - self.outstanding_queries, - self.log) + authresp = self.saml_client.authn_response(post, + self.outstanding_queries, + self.log) except Exception, excp: if self.log: self.log.error("Exception: %s" % (excp,)) diff --git a/src/saml2/client.py b/src/saml2/client.py index 3b01f52d..ba759311 100644 --- a/src/saml2/client.py +++ b/src/saml2/client.py @@ -358,62 +358,6 @@ class Saml2Client(object): else: return None - def response(self, post, outstanding, log=None, decode=True, - asynchop=True): - """ Deal with an AuthnResponse or LogoutResponse - - :param post: The reply as a dictionary - :param outstanding: A dictionary with session IDs as keys and - the original web request from the user before redirection - as values. - :param log: where loggin should go. - :param decode: Whether the response is Base64 encoded or not - :param asynchop: Whether the response was return over a asynchronous - connection. SOAP for instance is synchronous - :return: An response.AuthnResponse or response.LogoutResponse instance - """ - # If the request contains a samlResponse, try to validate it - try: - saml_response = post['SAMLResponse'] - except KeyError: - return None - - try: - _ = self.config.entityid - except KeyError: - raise Exception("Missing entity_id specification") - - if log is None: - log = self.logger - - reply_addr = self.service_url() - - resp = None - if saml_response: - try: - resp = response_factory(saml_response, self.config, - reply_addr, outstanding, log, - debug=self.debug, decode=decode, - asynchop=asynchop, - allow_unsolicited=self.allow_unsolicited) - except Exception, exc: - if log: - log.error("%s" % exc) - return None - if log: - log.debug(">> %s", resp) - - resp = resp.verify() - if isinstance(resp, AuthnResponse): - self.users.add_information_about_person(resp.session_info()) - if log: - log.info("--- ADDED person info ----") - elif isinstance(resp, LogoutResponse): - self.handle_logout_response(resp, log) - elif log: - log.error("Response type not supported: %s" % saml2.class_name(resp)) - return resp - def authenticate(self, entityid=None, relay_state="", binding=saml2.BINDING_HTTP_REDIRECT, log=None, vorg="", scoping=None, sign=None): @@ -457,155 +401,64 @@ class Saml2Client(object): raise Exception("Unkown binding type: %s" % binding) return session_id, response - def create_attribute_query(self, session_id, subject_id, destination, - issuer_id=None, attribute=None, sp_name_qualifier=None, - name_qualifier=None, nameid_format=None, sign=False): - """ Constructs an AttributeQuery - - :param session_id: The identifier of the session - :param subject_id: The identifier of the subject - :param destination: To whom the query should be sent - :param issuer_id: Identifier of the issuer - :param attribute: A dictionary of attributes and values that is - asked for. The key are one of 4 variants: - 3-tuple of name_format,name and friendly_name, - 2-tuple of name_format and name, - 1-tuple with name or - just the name as a string. - :param sp_name_qualifier: The unique identifier of the - service provider or affiliation of providers for whom the - identifier was generated. - :param name_qualifier: The unique identifier of the identity - provider that generated the identifier. - :param nameid_format: The format of the name ID - :param sign: Whether the query should be signed or not. - :return: An AttributeQuery instance - """ - - - subject = saml.Subject( - name_id = saml.NameID( - text=subject_id, - format=nameid_format, - sp_name_qualifier=sp_name_qualifier, - name_qualifier=name_qualifier), - ) - - query = samlp.AttributeQuery( - id=session_id, - version=VERSION, - issue_instant=instant(), - destination=destination, - issuer=self._issuer(issuer_id), - subject=subject, - ) - - if sign: - query.signature = pre_signature_part(query.id, self.sec.my_cert, 1) - - if attribute: - query.attribute = do_attributes(attribute) - - if sign: - signed_query = self.sec.sign_attribute_query_using_xmlsec( - "%s" % query) - return samlp.attribute_query_from_string(signed_query) - else: - return query - - - def attribute_query(self, subject_id, destination, issuer_id=None, - attribute=None, sp_name_qualifier=None, name_qualifier=None, - nameid_format=None, log=None, real_id=None): - """ Does a attribute request to an attribute authority, this is - by default done over SOAP. Other bindings could be used but not - supported right now. - - :param subject_id: The identifier of the subject - :param destination: To whom the query should be sent - :param issuer_id: Who is sending this query - :param attribute: A dictionary of attributes and values that is asked for - :param sp_name_qualifier: The unique identifier of the - service provider or affiliation of providers for whom the - identifier was generated. - :param name_qualifier: The unique identifier of the identity - provider that generated the identifier. - :param nameid_format: The format of the name ID - :param log: Function to use for logging - :param real_id: The identifier which is the key to this entity in the - identity database - :return: The attributes returned + def authn_response(self, post, outstanding, log=None, decode=True, + asynchop=True): + """ Deal with an AuthnResponse + + :param post: The reply as a dictionary + :param outstanding: A dictionary with session IDs as keys and + the original web request from the user before redirection + as values. + :param log: where loggin should go. + :param decode: Whether the response is Base64 encoded or not + :param asynchop: Whether the response was return over a asynchronous + connection. SOAP for instance is synchronous + :return: An response.AuthnResponse instance """ + # If the request contains a samlResponse, try to validate it + try: + saml_response = post['SAMLResponse'] + except KeyError: + return None + + try: + _ = self.config.entityid + except KeyError: + raise Exception("Missing entity_id specification") if log is None: log = self.logger - session_id = sid() - issuer = self._issuer(issuer_id) - - request = self.create_attribute_query(session_id, subject_id, - destination, issuer, attribute, sp_name_qualifier, - name_qualifier, nameid_format=nameid_format) - - if log: - log.info("Request, created: %s" % request) - - soapclient = SOAPClient(destination, self.config.key_file, - self.config.cert_file, - ca_certs=self.config.ca_certs) - if log: - log.info("SOAP client initiated") + reply_addr = self.service_url() - try: - response = soapclient.send(request) - except Exception, exc: - if log: - log.info("SoapClient exception: %s" % (exc,)) - return None - - if log: - log.info("SOAP request sent and got response: %s" % response) -# fil = open("response.xml", "w") -# fil.write(response) -# fil.close() - - if response: - if log: - log.info("Verifying response") - + resp = None + if saml_response: try: - # synchronous operation - aresp = attribute_response(self.config, issuer, log=log) + resp = response_factory(saml_response, self.config, + reply_addr, outstanding, log, + debug=self.debug, decode=decode, + asynchop=asynchop, + allow_unsolicited=self.allow_unsolicited) except Exception, exc: if log: - log.error("%s", (exc,)) - return None - - _resp = aresp.loads(response, False, soapclient.response).verify() - if _resp is None: - if log: - log.error("Didn't like the response") + log.error("%s" % exc) return None - - session_info = _resp.session_info() - if session_info: - if real_id is not None: - session_info["name_id"] = real_id - self.users.add_information_about_person(session_info) - if log: - log.info("session: %s" % session_info) - return session_info - else: + log.debug(">> %s", resp) + + resp = resp.verify() + + self.users.add_information_about_person(resp.session_info()) if log: - log.info("No response") - return None - - def global_logout(self, subject_id, reason="", expire=None, # *** - sign=None, log=None, return_to="/"): + log.info("--- ADDED person info ----") + + return resp + + def logout(self, subject_id, reason="", expire=None, + sign=None, log=None, return_to="/"): """Creates a Logout Request. - + :param subject_id: The identifier of the subject that wants to be logged out. :param reason: Why the subject wants to log out @@ -618,7 +471,7 @@ class Saml2Client(object): :return: Depends on which binding is used: If the HTTP redirect binding then a HTTP redirect, if SOAP binding has been used the just the result of that - conversation. + conversation. """ if log is None: log = self.logger @@ -634,7 +487,7 @@ class Saml2Client(object): # Do the local logout anyway self.users.remove_person(subject_id) return 0, "504 Gateway Timeout", [], [] - + # for all where I can use the SOAP binding, do those first not_done = entity_ids[:] response = False @@ -652,12 +505,12 @@ class Saml2Client(object): continue destination = destinations[0] - + if log: log.info("destination to provider: %s" % destination) request = self._logout_request(subject_id, destination, entity_id, reason, expire) - + to_sign = [] #if sign and binding != BINDING_HTTP_REDIRECT: @@ -668,14 +521,14 @@ class Saml2Client(object): request.signature = pre_signature_part(request.id, self.sec.my_cert, 1) to_sign = [(class_name(request), request.id)] - + if log: log.info("REQUEST: %s" % request) request = signed_instance_factory(request, self.sec, to_sign) - + if binding == BINDING_SOAP: - response = send_using_soap(request, destination, + response = send_using_soap(request, destination, self.config.key_file, self.config.cert_file, log=log, @@ -706,70 +559,38 @@ class Saml2Client(object): "not_on_of_after": expire, "sign": sign, "return_to": return_to} - + if binding == BINDING_HTTP_POST: - (head, body) = http_post_message(request, - destination, - rstate) + (head, body) = http_post_message(request, + destination, + rstate) code = "200 OK" else: - (head, body) = http_redirect_message(request, - destination, - rstate) + (head, body) = http_redirect_message(request, + destination, + rstate) code = "302 Found" - + return session_id, code, head, body - + if not_done: # upstream should try later raise LogoutError("%s" % (entity_ids,)) - - return 0, "", [], response - def handle_logout_response(self, response, log): - """ handles a Logout response - - :param response: A response.Response instance - :param log: A logging function - :return: 4-tuple of (session_id of the last sent logout request, - response message, response headers and message) - """ - if log is None: - log = self.logger + return 0, "", [], response - if log: - log.info("state: %s" % (self.state,)) - status = self.state[response.in_response_to] - if log: - log.info("status: %s" % (status,)) - issuer = response.issuer() - if log: - log.info("issuer: %s" % issuer) - del self.state[response.in_response_to] - if status["entity_ids"] == [issuer]: # done - self.users.remove_person(status["subject_id"]) - return 0, "200 Ok", [("Content-type","text/html")], [] - else: - status["entity_ids"].remove(issuer) - return self.global_logout(status["subject_id"], - status["entity_ids"], - status["reason"], - status["not_on_or_after"], - status["sign"], - log, ) - - def logout_response(self, xmlstr, log=None, binding=BINDING_SOAP): # *** + def logout_response(self, xmlstr, log=None, binding=BINDING_SOAP): """ Deal with a LogoutResponse :param xmlstr: The response as a xml string :param log: logging function :param binding: What type of binding this message came through. :return: None if the reply doesn't contain a valid SAML LogoutResponse, - otherwise the reponse if the logout was successful and None if it + otherwise the reponse if the logout was successful and None if it was not. """ - + response = None if log is None: log = self.logger @@ -783,7 +604,7 @@ class Saml2Client(object): if log: log.info("Not supposed to handle this!") return None - + try: response = LogoutResponse(self.sec, return_addr, debug=self.debug, log=log) @@ -791,7 +612,7 @@ class Saml2Client(object): if log: log.info("%s" % exc) return None - + if binding == BINDING_HTTP_REDIRECT: xmlstr = decode_base64_and_inflate(xmlstr) elif binding == BINDING_HTTP_POST: @@ -804,44 +625,73 @@ class Saml2Client(object): if response: response = response.verify() - + if not response: return None - + if log: log.debug(response) - return self.handle_logout_response(response, log) + status = self.state[response.in_response_to] + issuer = response.issuer() + + if log: + log.info("state: %s" % (self.state,)) + log.info("status: %s" % (status,)) + log.info("issuer: %s" % issuer) + + del self.state[response.in_response_to] + + if status["entity_ids"] == [issuer]: # done + self.users.remove_person(status["subject_id"]) + return 0, "200 Ok", [("Content-type","text/html")], [] + else: + status["entity_ids"].remove(issuer) + return self.logout(status["subject_id"], + status["reason"], + status["not_on_or_after"], + status["sign"], + log, + status['return_to']) return response - def http_redirect_logout_request(self, get, subject_id, log=None): - """ Deal with a LogoutRequest received through HTTP redirect + def logout_request(self, request, subject_id, log=None, + binding=BINDING_HTTP_REDIRECT): + """ Deal with a LogoutRequest - :param get: The request as a dictionary + :param request: The request. The format depends on which binding is + used. :param subject_id: the id of the current logged user - :return: a tuple with a list of header tuples (presently only location) - and a status which will be True in case of success or False - otherwise. + :return: What is returned also depends on which binding is used. """ + if log is None: + log = self.logger + + if binding != BINDING_HTTP_REDIRECT: + if log: + log.error("Sorry, only HTTP_REDIRECT is supported for logout") + + return + headers = [] success = False if log is None: log = self.logger try: - saml_request = get['SAMLRequest'] + saml_request = request['SAMLRequest'] except KeyError: return None if saml_request: xml = decode_base64_and_inflate(saml_request) - request = samlp.logout_request_from_string(xml) + logout_request = samlp.logout_request_from_string(xml) if log: - log.debug(request) + log.debug(logout_request) - if request.name_id.text == subject_id: + if logout_request.name_id.text == subject_id: status = samlp.STATUS_SUCCESS self.users.remove_person(subject_id) success = True @@ -849,45 +699,175 @@ class Saml2Client(object): status = samlp.STATUS_REQUEST_DENIED response, destination = self._logout_response( - request.issuer.text, - request.id, + logout_request.issuer.text, + logout_request.id, status ) if log: log.info("RESPONSE: {0:>s}".format(response)) - if 'RelayState' in get: - rstate = get['RelayState'] + if 'RelayState' in request: + rstate = request['RelayState'] else: rstate = "" - - (headers, _body) = http_redirect_message(str(response), - destination, - rstate, 'SAMLResponse') - return headers, success + (headers, _body) = http_redirect_message(str(response), + destination, + rstate, 'SAMLResponse') - def logout_request(self, request, subject_id, log=None, # *** - binding=BINDING_HTTP_REDIRECT): - """ Deal with a LogoutRequest + return headers, success - :param request: The request. The format depends on which binding is - used. - :param subject_id: the id of the current logged user - :return: What is returned also depends on which binding is used. + def create_attribute_query(self, session_id, subject_id, destination, + issuer_id=None, attribute=None, sp_name_qualifier=None, + name_qualifier=None, nameid_format=None, sign=False): + """ Constructs an AttributeQuery + + :param session_id: The identifier of the session + :param subject_id: The identifier of the subject + :param destination: To whom the query should be sent + :param issuer_id: Identifier of the issuer + :param attribute: A dictionary of attributes and values that is + asked for. The key are one of 4 variants: + 3-tuple of name_format,name and friendly_name, + 2-tuple of name_format and name, + 1-tuple with name or + just the name as a string. + :param sp_name_qualifier: The unique identifier of the + service provider or affiliation of providers for whom the + identifier was generated. + :param name_qualifier: The unique identifier of the identity + provider that generated the identifier. + :param nameid_format: The format of the name ID + :param sign: Whether the query should be signed or not. + :return: An AttributeQuery instance + """ + + + subject = saml.Subject( + name_id = saml.NameID( + text=subject_id, + format=nameid_format, + sp_name_qualifier=sp_name_qualifier, + name_qualifier=name_qualifier), + ) + + query = samlp.AttributeQuery( + id=session_id, + version=VERSION, + issue_instant=instant(), + destination=destination, + issuer=self._issuer(issuer_id), + subject=subject, + ) + + if sign: + query.signature = pre_signature_part(query.id, self.sec.my_cert, 1) + + if attribute: + query.attribute = do_attributes(attribute) + + if sign: + signed_query = self.sec.sign_attribute_query_using_xmlsec( + "%s" % query) + return samlp.attribute_query_from_string(signed_query) + else: + return query + + + def attribute_query(self, subject_id, destination, issuer_id=None, + attribute=None, sp_name_qualifier=None, name_qualifier=None, + nameid_format=None, log=None, real_id=None): + """ Does a attribute request to an attribute authority, this is + by default done over SOAP. Other bindings could be used but not + supported right now. + + :param subject_id: The identifier of the subject + :param destination: To whom the query should be sent + :param issuer_id: Who is sending this query + :param attribute: A dictionary of attributes and values that is asked for + :param sp_name_qualifier: The unique identifier of the + service provider or affiliation of providers for whom the + identifier was generated. + :param name_qualifier: The unique identifier of the identity + provider that generated the identifier. + :param nameid_format: The format of the name ID + :param log: Function to use for logging + :param real_id: The identifier which is the key to this entity in the + identity database + :return: The attributes returned """ + if log is None: log = self.logger - if binding == BINDING_HTTP_REDIRECT: - return self.http_redirect_logout_request(request, subject_id, log) + session_id = sid() + issuer = self._issuer(issuer_id) + + request = self.create_attribute_query(session_id, subject_id, + destination, issuer, attribute, sp_name_qualifier, + name_qualifier, nameid_format=nameid_format) + + if log: + log.info("Request, created: %s" % request) + + soapclient = SOAPClient(destination, self.config.key_file, + self.config.cert_file, + ca_certs=self.config.ca_certs) + if log: + log.info("SOAP client initiated") + + try: + response = soapclient.send(request) + except Exception, exc: + if log: + log.info("SoapClient exception: %s" % (exc,)) + return None + + if log: + log.info("SOAP request sent and got response: %s" % response) +# fil = open("response.xml", "w") +# fil.write(response) +# fil.close() + + if response: + if log: + log.info("Verifying response") + + try: + # synchronous operation + aresp = attribute_response(self.config, issuer, log=log) + except Exception, exc: + if log: + log.error("%s", (exc,)) + return None + + _resp = aresp.loads(response, False, soapclient.response).verify() + if _resp is None: + if log: + log.error("Didn't like the response") + return None + + session_info = _resp.session_info() + + if session_info: + if real_id is not None: + session_info["name_id"] = real_id + self.users.add_information_about_person(session_info) + + if log: + log.info("session: %s" % session_info) + return session_info + else: + if log: + log.info("No response") + return None def add_vo_information_about_user(self, subject_id): """ Add information to the knowledge I have about the user. This is for Virtual organizations. - - :param subject_id: The subject identifier + + :param subject_id: The subject identifier :return: A possibly extended knowledge. """ diff --git a/tests/test_51_client.py b/tests/test_51_client.py index 4e54bbe9..8c66362f 100644 --- a/tests/test_51_client.py +++ b/tests/test_51_client.py @@ -262,8 +262,10 @@ class TestClient: resp_str = base64.encodestring(resp_str) - authn_response = self.client.response({"SAMLResponse":resp_str}, - {"id1":"http://foo.example.com/service"}) + authn_response = self.client.authn_response( + {"SAMLResponse":resp_str}, + {"id1":"http://foo.example.com/service"} + ) assert authn_response is not None assert authn_response.issuer() == IDP @@ -300,8 +302,8 @@ class TestClient: resp_str = base64.encodestring(resp_str) - self.client.response({"SAMLResponse":resp_str}, - {"id2":"http://foo.example.com/service"}) + self.client.authn_response({"SAMLResponse":resp_str}, + {"id2":"http://foo.example.com/service"}) # Two persons in the cache assert len(self.client.users.subjects()) == 2 @@ -383,7 +385,7 @@ class TestClient: self.client.users.add_information_about_person(session_info) entity_ids = self.client.users.issuers_of_info("123456") assert entity_ids == ["urn:mace:example.com:saml:roland:idp"] - resp = self.client.global_logout("123456", "Tired", in_a_while(minutes=5)) + resp = self.client.logout("123456", "Tired", in_a_while(minutes=5)) print resp assert resp assert resp[0] # a session_id @@ -427,7 +429,7 @@ class TestClient: assert destinations == ['http://localhost:8088/slo'] # Will raise an error since there is noone at the other end. - raises(LogoutError, 'client.global_logout("123456", "Tired", in_a_while(minutes=5))') + raises(LogoutError, 'client.logout("123456", "Tired", in_a_while(minutes=5))') def test_logout_3(self): """ two or more IdP/AA with BINDING_HTTP_REDIRECT""" @@ -460,7 +462,7 @@ class TestClient: entity_ids = client.users.issuers_of_info("123456") assert _leq(entity_ids, ["urn:mace:example.com:saml:roland:idp", "urn:mace:example.com:saml:roland:aa"]) - resp = client.global_logout("123456", "Tired", in_a_while(minutes=5)) + resp = client.logout("123456", "Tired", in_a_while(minutes=5)) print resp assert resp assert resp[0] # a session_id @@ -571,7 +573,7 @@ class TestClient: resp_str = base64.encodestring(resp_str) self.client.allow_unsolicited = True - authn_response = self.client.response({"SAMLResponse":resp_str}, ()) + authn_response = self.client.authn_response({"SAMLResponse":resp_str}, ()) assert authn_response is not None assert authn_response.issuer() == IDP |