# """ A plugin that allows you to use SAML2 SSO as authentication and SAML2 attribute aggregations as metadata collector in your WSGI application. """ import logging import sys import platform import shelve import traceback import saml2 import six from saml2.samlp import Extensions from saml2 import xmldsig as ds from six import StringIO from six.moves.urllib import parse from paste.httpexceptions import HTTPSeeOther, HTTPRedirection from paste.httpexceptions import HTTPNotImplemented from paste.httpexceptions import HTTPInternalServerError from paste.request import parse_dict_querystring from paste.request import construct_url from saml2.extension.pefim import SPCertEnc from saml2.httputil import getpath, SeeOther from saml2.client_base import ECP_SERVICE, MIME_PAOS from zope.interface import implementer from repoze.who.interfaces import IChallenger, IIdentifier, IAuthenticator from repoze.who.interfaces import IMetadataProvider from saml2 import ecp, BINDING_HTTP_REDIRECT, element_to_extension_element from saml2 import BINDING_HTTP_POST from saml2.client import Saml2Client from saml2.ident import code, decode from saml2.s_utils import sid from saml2.config import config_factory from saml2.profile import paos # from saml2.population import Population # from saml2.attribute_resolver import AttributeResolver logger = logging.getLogger(__name__) PAOS_HEADER_INFO = 'ver="%s";"%s"' % (paos.NAMESPACE, ECP_SERVICE) def construct_came_from(environ): """ The URL that the user used when the process where interupted for single-sign-on processing. """ came_from = environ.get("PATH_INFO") qstr = environ.get("QUERY_STRING", "") if qstr: came_from += "?" + qstr return came_from def exception_trace(tag, exc, log): message = traceback.format_exception(*sys.exc_info()) log.error("[%s] ExcList: %s" % (tag, "".join(message))) log.error("[%s] Exception: %s" % (tag, exc)) class ECP_response(object): code = 200 title = "OK" def __init__(self, content): self.content = content # noinspection PyUnusedLocal def __call__(self, environ, start_response): start_response( "%s %s" % (self.code, self.title), [("Content-Type", "text/xml")] ) return [self.content] @implementer(IChallenger, IIdentifier, IAuthenticator, IMetadataProvider) class SAML2Plugin(object): def __init__( self, rememberer_name, config, saml_client, wayf, cache, sid_store=None, discovery="", idp_query_param="", sid_store_cert=None, ): self.rememberer_name = rememberer_name self.wayf = wayf self.saml_client = saml_client self.conf = config self.cache = cache self.discosrv = discovery self.idp_query_param = idp_query_param self.logout_endpoints = [ parse.urlparse(ep).path for ep in config.endpoint("single_logout_service") ] try: self.metadata = self.conf.metadata except KeyError: self.metadata = None if sid_store: self.outstanding_queries = shelve.open( sid_store, writeback=True, protocol=2 ) else: self.outstanding_queries = {} if sid_store_cert: self.outstanding_certs = shelve.open( sid_store_cert, writeback=True, protocol=2 ) else: self.outstanding_certs = {} self.iam = platform.node() def _get_rememberer(self, environ): rememberer = environ["repoze.who.plugins"][self.rememberer_name] return rememberer #### IIdentifier #### def remember(self, environ, identity): rememberer = self._get_rememberer(environ) return rememberer.remember(environ, identity) #### IIdentifier #### def forget(self, environ, identity): rememberer = self._get_rememberer(environ) return rememberer.forget(environ, identity) def _get_post(self, environ): """ Get the posted information :param environ: A dictionary with environment variables """ body = "" try: length = int(environ.get("CONTENT_LENGTH", "0")) except ValueError: length = 0 if length != 0: body = environ["wsgi.input"].read(length) # get the POST variables environ["s2repoze.body"] = body # store the request body for later # use by pysaml2 environ["wsgi.input"] = StringIO(body) # restore the request body # as a stream so that everything seems untouched post = parse.parse_qs(body) # parse the POST fields into a dict logger.debug("identify post: %s", post) return post def _wayf_redirect(self, came_from): sid_ = sid() self.outstanding_queries[sid_] = came_from logger.info("Redirect to WAYF function: %s", self.wayf) return -1, HTTPSeeOther(headers=[("Location", "%s?%s" % (self.wayf, sid_))]) # noinspection PyUnusedLocal def _pick_idp(self, environ, came_from): """ If more than one idp and if none is selected, I have to do wayf or disco """ # check headers to see if it's an ECP request # headers = { # 'Accept' : 'text/html; application/vnd.paos+xml', # 'PAOS' : 'ver="%s";"%s"' % (paos.NAMESPACE, # SERVICE) # } _cli = self.saml_client logger.info("[_pick_idp] %s", environ) if "HTTP_PAOS" in environ: if environ["HTTP_PAOS"] == PAOS_HEADER_INFO: if MIME_PAOS in environ["HTTP_ACCEPT"]: # Where should I redirect the user to # entityid -> the IdP to use # relay_state -> when back from authentication logger.info("- ECP client detected -") _relay_state = construct_came_from(environ) _entityid = _cli.config.ecp_endpoint(environ["REMOTE_ADDR"]) if not _entityid: return -1, HTTPInternalServerError(detail="No IdP to talk to") logger.info("IdP to talk to: %s", _entityid) return ecp.ecp_auth_request(_cli, _entityid, _relay_state) else: return -1, HTTPInternalServerError(detail="Faulty Accept header") else: return -1, HTTPInternalServerError(detail="unknown ECP version") idps = self.metadata.with_descriptor("idpsso") logger.info("IdP URL: %s", idps) idp_entity_id = query = None for key in ["s2repoze.body", "QUERY_STRING"]: query = environ.get(key) if query: try: _idp_entity_id = dict(parse.parse_qs(query))[self.idp_query_param][0] if _idp_entity_id in idps: idp_entity_id = _idp_entity_id break except KeyError: logger.debug("No IdP entity ID in query: %s", query) pass if idp_entity_id is None: if len(idps) == 1: # idps is a dictionary idp_entity_id = idps.keys()[0] elif not len(idps): return -1, HTTPInternalServerError(detail="Misconfiguration") else: idp_entity_id = "" logger.info("ENVIRON: %s", environ) if self.wayf: if query: try: wayf_selected = dict(parse.parse_qs(query))["wayf_selected"][0] except KeyError: return self._wayf_redirect(came_from) idp_entity_id = wayf_selected else: return self._wayf_redirect(came_from) elif self.discosrv: if query: idp_entity_id = _cli.parse_discovery_service_response( query=environ.get("QUERY_STRING") ) else: sid_ = sid() self.outstanding_queries[sid_] = came_from logger.debug("Redirect to Discovery Service function") eid = _cli.config.entityid ret = _cli.config.getattr("endpoints", "sp")[ "discovery_response" ][0][0] ret += "?sid=%s" % sid_ loc = _cli.create_discovery_service_request( self.discosrv, eid, **{"return": ret} ) return -1, SeeOther(loc) else: return -1, HTTPNotImplemented(detail="No WAYF or DJ present!") logger.info("Chosen IdP: '%s'", idp_entity_id) return 0, idp_entity_id #### IChallenger #### # noinspection PyUnusedLocal def challenge(self, environ, _status, _app_headers, _forget_headers): _cli = self.saml_client if "REMOTE_USER" in environ: name_id = decode(environ["REMOTE_USER"]) _cli = self.saml_client path_info = environ["PATH_INFO"] if "samlsp.logout" in environ: responses = _cli.global_logout(name_id) return self._handle_logout(responses) if "samlsp.pending" in environ: response = environ["samlsp.pending"] if isinstance(response, HTTPRedirection): response.headers += _forget_headers return response # logger = environ.get('repoze.who.logger','') # Which page was accessed to get here came_from = construct_came_from(environ) environ["myapp.came_from"] = came_from logger.debug("[sp.challenge] RelayState >> '%s'", came_from) # Am I part of a virtual organization or more than one ? try: vorg_name = environ["myapp.vo"] except KeyError: try: vorg_name = _cli.vorg._name except AttributeError: vorg_name = "" logger.info("[sp.challenge] VO: %s", vorg_name) # If more than one idp and if none is selected, I have to do wayf (done, response) = self._pick_idp(environ, came_from) # Three cases: -1 something went wrong or Discovery service used # 0 I've got an IdP to send a request to # >0 ECP in progress logger.debug("_idp_pick returned: %s", done) if done == -1: return response elif done > 0: self.outstanding_queries[done] = came_from return ECP_response(response) else: entity_id = response logger.info("[sp.challenge] entity_id: %s", entity_id) # Do the AuthnRequest _binding = BINDING_HTTP_REDIRECT try: srvs = _cli.metadata.single_sign_on_service(entity_id, _binding) logger.debug("srvs: %s", srvs) dest = srvs[0]["location"] logger.debug("destination: %s", dest) extensions = None cert = None if _cli.config.generate_cert_func is not None: cert_str, req_key_str = _cli.config.generate_cert_func() cert = {"cert": cert_str, "key": req_key_str} spcertenc = SPCertEnc( x509_data=ds.X509Data( x509_certificate=ds.X509Certificate(text=cert_str) ) ) extensions = Extensions( extension_elements=[element_to_extension_element(spcertenc)] ) if _cli.authn_requests_signed: _sid = sid() req_id, msg_str = _cli.create_authn_request( dest, vorg=vorg_name, sign=_cli.authn_requests_signed, message_id=_sid, extensions=extensions, ) _sid = req_id else: req_id, req = _cli.create_authn_request( dest, vorg=vorg_name, sign=False, extensions=extensions, ) msg_str = "%s" % req _sid = req_id if cert is not None: self.outstanding_certs[_sid] = cert ht_args = _cli.apply_binding( _binding, msg_str, destination=dest, relay_state=came_from, sign=_cli.authn_requests_signed, ) logger.debug("ht_args: %s", ht_args) except Exception as exc: logger.exception(exc) raise Exception("Failed to construct the AuthnRequest: %s" % exc) try: ret = _cli.config.getattr("endpoints", "sp")["discovery_response"][0][0] if (environ["PATH_INFO"]) in ret and ret.split(environ["PATH_INFO"])[ 1 ] == "": query = parse.parse_qs(environ["QUERY_STRING"]) sid = query["sid"][0] came_from = self.outstanding_queries[sid] except: pass # remember the request self.outstanding_queries[_sid] = came_from if not ht_args["data"] and ht_args["headers"][0][0] == "Location": logger.debug("redirect to: %s", ht_args["headers"][0][1]) return HTTPSeeOther(headers=ht_args["headers"]) else: return ht_args["data"] def _construct_identity(self, session_info): cni = code(session_info["name_id"]) identity = { "login": cni, "password": "", "repoze.who.userid": cni, "user": session_info["ava"], } logger.debug("Identity: %s", identity) return identity def _eval_authn_response(self, environ, post, binding=BINDING_HTTP_POST): logger.info("Got AuthN response, checking..") logger.info("Outstanding: %s", self.outstanding_queries) try: # Evaluate the response, returns a AuthnResponse instance try: authresp = self.saml_client.parse_authn_request_response( post["SAMLResponse"][0], binding, self.outstanding_queries, self.outstanding_certs, ) except Exception as excp: logger.exception("Exception: %s" % (excp,)) raise session_info = authresp.session_info() except TypeError as excp: logger.exception("Exception: %s" % (excp,)) return None if session_info["came_from"]: logger.debug("came_from << %s", session_info["came_from"]) try: path, query = session_info["came_from"].split("?") environ["PATH_INFO"] = path environ["QUERY_STRING"] = query except ValueError: environ["PATH_INFO"] = session_info["came_from"] logger.info("Session_info: %s", session_info) return session_info def do_ecp_response(self, body, environ): response, _relay_state = ecp.handle_ecp_authn_response(self.saml_client, body) environ["s2repoze.relay_state"] = _relay_state.text session_info = response.session_info() logger.info("Session_info: %s", session_info) return session_info #### IIdentifier #### def identify(self, environ): """ Tries to do the identification """ # logger = environ.get('repoze.who.logger', '') session_info = None uri = environ.get("REQUEST_URI", construct_url(environ)) query = parse_dict_querystring(environ) logger.debug("[sp.identify] uri: %s", uri) logger.debug("[sp.identify] query: %s", query) is_request = "SAMLRequest" in query is_response = "SAMLResponse" in query has_content_length = environ.get("CONTENT_LENGTH") if not has_content_length and not is_request and not is_response: logger.debug("[identify] get or empty post") return None if is_request or is_response: post = query binding = BINDING_HTTP_REDIRECT else: post = self._get_post(environ) binding = BINDING_HTTP_POST try: logger.debug("[sp.identify] post keys: %s", post.keys()) except (TypeError, IndexError): pass try: path = getpath(environ) logout = False if path in self.logout_endpoints: logout = True if logout and is_request: print("logout request received") if binding == BINDING_HTTP_REDIRECT: saml_request = post["SAMLRequest"] else: saml_request = post["SAMLRequest"][0] try: response = self.saml_client.handle_logout_request( saml_request, self.saml_client.users.subjects()[0], binding ) environ["samlsp.pending"] = self._handle_logout(response) return {} except: import traceback traceback.print_exc() elif not is_response: logger.info("[sp.identify] --- NOT SAMLResponse ---") # Not for me, put the post back where next in line can find it environ["post.fieldstorage"] = post # restore wsgi.input incase that is needed # only of s2repoze.body is present if "s2repoze.body" in environ: environ["wsgi.input"] = StringIO(environ["s2repoze.body"]) return {} else: logger.info("[sp.identify] --- SAMLResponse ---") # check for SAML2 authN response try: if logout: response = self.saml_client.parse_logout_request_response( post["SAMLResponse"][0], binding ) if response: action = self.saml_client.handle_logout_response(response) if type(action) == dict: request = self._handle_logout(action) else: request = HTTPSeeOther(headers=[("Location", "/")]) if request: environ["samlsp.pending"] = request return {} else: session_info = self._eval_authn_response( environ, post, binding=binding ) except Exception as err: environ["s2repoze.saml_error"] = err return {} except TypeError as exc: # might be a ECP (=SOAP) response body = environ.get("s2repoze.body", None) if body: # might be a ECP response try: session_info = self.do_ecp_response(body, environ) except Exception as err: environ["post.fieldstorage"] = post environ["s2repoze.saml_error"] = err return {} else: exception_trace("sp.identity", exc, logger) environ["post.fieldstorage"] = post return {} if session_info: environ["s2repoze.sessioninfo"] = session_info identity_info = self._construct_identity(session_info) else: identity_info = None return identity_info # IMetadataProvider def add_metadata(self, environ, identity): """ Add information to the knowledge I have about the user """ name_id = identity["repoze.who.userid"] if isinstance(name_id, six.string_types): try: # Make sure that userids authenticated by another plugin # don't cause problems here. name_id = decode(name_id) except: pass _cli = self.saml_client logger.debug("[add_metadata] for %s", name_id) try: logger.debug("Issuers: %s", _cli.users.sources(name_id)) except KeyError: pass if "user" not in identity: identity["user"] = {} try: (ava, _) = _cli.users.get_identity(name_id) # now = time.gmtime() logger.debug("[add_metadata] adds: %s", ava) identity["user"].update(ava) except KeyError: pass if "pysaml2_vo_expanded" not in identity and _cli.vorg: # is this a Virtual Organization situation for vo in _cli.vorg.values(): try: if vo.do_aggregation(name_id): # Get the extended identity identity["user"] = _cli.users.get_identity(name_id)[0] # Only do this once, mark that the identity has been # expanded identity["pysaml2_vo_expanded"] = 1 except KeyError: logger.exception( "Failed to do attribute aggregation, " "missing common attribute" ) logger.debug("[add_metadata] returns: %s", dict(identity)) if not identity["user"]: # remove cookie and demand re-authentication pass # used 2 times : one to get the ticket, the other to validate it @staticmethod def _service_url(environ, qstr=None): if qstr is not None: url = construct_url(environ, querystring=qstr) else: url = construct_url(environ) return url #### IAuthenticatorPlugin #### # noinspection PyUnusedLocal def authenticate(self, environ, identity=None): if identity: if ( identity.get("user") and environ.get("s2repoze.sessioninfo") and identity.get("user") == environ.get("s2repoze.sessioninfo").get("ava") ): return identity.get("login") tktuser = identity.get("repoze.who.plugins.auth_tkt.userid", None) if tktuser and self.saml_client.is_logged_in(decode(tktuser)): return tktuser return None else: return None @staticmethod def _handle_logout(responses): if "data" in responses: ht_args = responses else: ht_args = responses[responses.keys()[0]][1] if not ht_args["data"] and ht_args["headers"][0][0] == "Location": logger.debug("redirect to: %s", ht_args["headers"][0][1]) return HTTPSeeOther(headers=ht_args["headers"]) else: return ht_args["data"] def make_plugin( remember_name=None, # plugin for remember cache="", # cache # Which virtual organization to support virtual_organization="", saml_conf="", wayf="", sid_store="", identity_cache="", discovery="", idp_query_param="", ): if saml_conf == "": raise ValueError("must include saml_conf in configuration") if remember_name is None: raise ValueError("must include remember_name in configuration") conf = config_factory("sp", saml_conf) scl = Saml2Client( config=conf, identity_cache=identity_cache, virtual_organization=virtual_organization, ) plugin = SAML2Plugin( remember_name, conf, scl, wayf, cache, sid_store, discovery, idp_query_param ) return plugin