def __init__(self, outgoing, internal_attributes, config): """ :type outgoing: (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config """ super(SamlBackend, self).__init__(outgoing, internal_attributes) sp_config = SPConfig().load(copy.deepcopy(config["config"]), False) self.sp = Base(sp_config) self.idp_disco_query_param = "entityID" self.config = config self.attribute_profile = config.get("attribute_profile", "saml") self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] self.discosrv = None self.state_id = config["state_id"] try: self.discosrv = config["disco_srv"] except KeyError: pass
def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str :type name: str :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config :param base_url: base url of the service :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) self.config = self.init_config(config) sp_config = SPConfig().load( copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG]), False) self.sp = Base(sp_config) self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV) self.encryption_keys = [] self.outstanding_queries = {} self.idp_blacklist_file = config.get('idp_blacklist_file', None) self.requested_attributes = self.config.get( SAMLBackend.KEY_DYNAMIC_REQUESTED_ATTRIBUTES) sp_keypairs = sp_config.getattr('encryption_keypairs', '') sp_key_file = sp_config.getattr('key_file', '') if sp_keypairs: key_file_paths = [pair['key_file'] for pair in sp_keypairs] elif sp_key_file: key_file_paths = [sp_key_file] else: key_file_paths = [] for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read())
def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str :type name: str :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config :param base_url: base url of the service :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) sp_config = SPConfig().load(copy.deepcopy(config["sp_config"]), False) self.sp = Base(sp_config) self.config = config self.attribute_profile = config.get("attribute_profile", "saml") self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] self.discosrv = config.get("disco_srv") self.encryption_keys = [] key_file_paths = None if 'encryption_keypairs' in self.config[ 'sp_config']: # prioritize explicit encryption keypairs key_file_paths = [ keypair['key_file'] for keypair in self.config['sp_config']['encryption_keypairs'] ] elif 'key_file' in self.config['sp_config']: key_file_paths = [self.config['sp_config']['key_file']] if key_file_paths: for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read())
def __init__(self, environ, start_response, config, cache=None, outgoing=None, discosrv=None, bindings=None): service.Service.__init__(self, environ, start_response) self.sp = Base(config, state_cache=cache) self.environ = environ self.start_response = start_response self.cache = cache self.idp_disco_query_param = "entityID" self.outgoing = outgoing self.discosrv = discosrv if bindings: self.bindings = bindings else: self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] logger.debug("--- SSO ---")
def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str :type name: str :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config :param base_url: base url of the service :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) self.config = self.init_config(config) sp_config = SPConfig().load(copy.deepcopy(config[self.KEY_SP_CONFIG]), False) self.sp = Base(sp_config) self.discosrv = config.get(self.KEY_DISCO_SRV) self.encryption_keys = [] self.outstanding_queries = {} self.idp_blacklist_file = config.get('idp_blacklist_file', None) sp_keypairs = sp_config.getattr('encryption_keypairs', '') sp_key_file = sp_config.getattr('key_file', '') if sp_keypairs: key_file_paths = [pair['key_file'] for pair in sp_keypairs] elif sp_key_file: key_file_paths = [sp_key_file] else: key_file_paths = [] for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read())
def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str :type name: str :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config :param base_url: base url of the service :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) sp_config = SPConfig().load(copy.deepcopy(config["sp_config"]), False) self.sp = Base(sp_config) self.config = config self.attribute_profile = config.get("attribute_profile", "saml") self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] self.discosrv = config.get("disco_srv") self.encryption_keys = [] key_file_paths = None if 'encryption_keypairs' in self.config['sp_config']: # prioritize explicit encryption keypairs key_file_paths = [keypair['key_file'] for keypair in self.config['sp_config']['encryption_keypairs']] elif 'key_file' in self.config['sp_config']: key_file_paths = [self.config['sp_config']['key_file']] if key_file_paths: for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read())
class SAMLBackend(BackendModule, SAMLBaseModule): """ A saml2 backend module (acting as a SP). """ KEY_DISCO_SRV = 'disco_srv' KEY_SAML_DISCOVERY_SERVICE_URL = 'saml_discovery_service_url' KEY_SAML_DISCOVERY_SERVICE_POLICY = 'saml_discovery_service_policy' KEY_SP_CONFIG = 'sp_config' KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn' KEY_MEMORIZE_IDP = 'memorize_idp' KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN = 'use_memorized_idp_when_force_authn' VALUE_ACR_COMPARISON_DEFAULT = 'exact' def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str :type name: str :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config :param base_url: base url of the service :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) self.config = self.init_config(config) sp_config = SPConfig().load( copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG]), False) self.sp = Base(sp_config) self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV) self.encryption_keys = [] self.outstanding_queries = {} self.idp_blacklist_file = config.get('idp_blacklist_file', None) sp_keypairs = sp_config.getattr('encryption_keypairs', '') sp_key_file = sp_config.getattr('key_file', '') if sp_keypairs: key_file_paths = [pair['key_file'] for pair in sp_keypairs] elif sp_key_file: key_file_paths = [sp_key_file] else: key_file_paths = [] for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read()) def get_idp_entity_id(self, context): """ :type context: satosa.context.Context :rtype: str | None :param context: The current context :return: the entity_id of the idp or None """ idps = self.sp.metadata.identity_providers() only_one_idp_in_metadata = ("mdq" not in self.config["sp_config"]["metadata"] and len(idps) == 1) only_idp = only_one_idp_in_metadata and idps[0] target_entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID) force_authn = get_force_authn(context, self.config, self.sp.config) memorized_idp = get_memorized_idp(context, self.config, force_authn) entity_id = only_idp or target_entity_id or memorized_idp or None msg = { "message": "Selected IdP", "only_one": only_idp, "target_entity_id": target_entity_id, "force_authn": force_authn, "memorized_idp": memorized_idp, "entity_id": entity_id, } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.info(logline) return entity_id def start_auth(self, context, internal_req): """ See super class method satosa.backends.base.BackendModule#start_auth :type context: satosa.context.Context :type internal_req: satosa.internal.InternalData :rtype: satosa.response.Response """ entity_id = self.get_idp_entity_id(context) if entity_id is None: # since context is not passed to disco_query # keep the information in the state cookie context.state[Context.KEY_FORCE_AUTHN] = get_force_authn( context, self.config, self.sp.config) return self.disco_query(context) return self.authn_request(context, entity_id) def disco_query(self, context): """ Makes a request to the discovery server :type context: satosa.context.Context :type internal_req: satosa.internal.InternalData :rtype: satosa.response.SeeOther :param context: The current context :param internal_req: The request :return: Response """ endpoints = self.sp.config.getattr("endpoints", "sp") return_url = endpoints["discovery_response"][0][0] disco_url = (context.get_decoration( SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_URL) or self.discosrv) disco_policy = context.get_decoration( SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_POLICY) args = {"return": return_url} if disco_policy: args["policy"] = disco_policy loc = self.sp.create_discovery_service_request(disco_url, self.sp.config.entityid, **args) return SeeOther(loc) def construct_requested_authn_context(self, entity_id): if not self.acr_mapping: return None acr_entry = util.get_dict_defaults(self.acr_mapping, entity_id) if not acr_entry: return None if type(acr_entry) is not dict: acr_entry = { "class_ref": acr_entry, "comparison": self.VALUE_ACR_COMPARISON_DEFAULT, } authn_context = requested_authn_context( acr_entry['class_ref'], comparison=acr_entry.get('comparison', self.VALUE_ACR_COMPARISON_DEFAULT)) return authn_context def authn_request(self, context, entity_id): """ Do an authorization request on idp with given entity id. This is the start of the authorization. :type context: satosa.context.Context :type entity_id: str :rtype: satosa.response.Response :param context: The current context :param entity_id: Target IDP entity id :return: response to the user agent """ # If IDP blacklisting is enabled and the selected IDP is blacklisted, # stop here if self.idp_blacklist_file: with open(self.idp_blacklist_file) as blacklist_file: blacklist_array = json.load(blacklist_file)['blacklist'] if entity_id in blacklist_array: msg = "IdP with EntityID {} is blacklisted".format( entity_id) logline = lu.LOG_FMT.format(id=lu.get_session_id( context.state), message=msg) logger.debug(logline, exc_info=False) raise SATOSAAuthenticationError( context.state, "Selected IdP is blacklisted for this backend") kwargs = {} authn_context = self.construct_requested_authn_context(entity_id) if authn_context: kwargs["requested_authn_context"] = authn_context if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN): kwargs["force_authn"] = get_force_authn(context, self.config, self.sp.config) try: binding, destination = self.sp.pick_binding( "single_sign_on_service", None, "idpsso", entity_id=entity_id) msg = "binding: {}, destination: {}".format(binding, destination) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) acs_endp, response_binding = self.sp.config.getattr( "endpoints", "sp")["assertion_consumer_service"][0] req_id, req = self.sp.create_authn_request( destination, binding=response_binding, **kwargs) relay_state = util.rndstr() ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state) msg = "ht_args: {}".format(ht_args) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) except Exception as exc: msg = "Failed to construct the AuthnRequest for state" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline, exc_info=True) raise SATOSAAuthenticationError( context.state, "Failed to construct the AuthnRequest") from exc if self.sp.config.getattr('allow_unsolicited', 'sp') is False: if req_id in self.outstanding_queries: msg = "Request with duplicate id {}".format(req_id) logline = lu.LOG_FMT.format(id=lu.get_session_id( context.state), message=msg) logger.debug(logline) raise SATOSAAuthenticationError(context.state, msg) self.outstanding_queries[req_id] = req context.state[self.name] = {"relay_state": relay_state} return make_saml_response(binding, ht_args) def authn_response(self, context, binding): """ Endpoint for the idp response :type context: satosa.context,Context :type binding: str :rtype: satosa.response.Response :param context: The current context :param binding: The saml binding type :return: response """ if not context.request["SAMLResponse"]: msg = "Missing Response for state" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) raise SATOSAAuthenticationError(context.state, "Missing Response") try: authn_response = self.sp.parse_authn_request_response( context.request["SAMLResponse"], binding, outstanding=self.outstanding_queries) except Exception as err: msg = "Failed to parse authn request for state" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline, exc_info=True) raise SATOSAAuthenticationError( context.state, "Failed to parse authn request") from err if self.sp.config.getattr('allow_unsolicited', 'sp') is False: req_id = authn_response.in_response_to if req_id not in self.outstanding_queries: msg = "No request with id: {}".format(req_id), logline = lu.LOG_FMT.format(id=lu.get_session_id( context.state), message=msg) logger.debug(logline) raise SATOSAAuthenticationError(context.state, msg) del self.outstanding_queries[req_id] # check if the relay_state matches the cookie state if context.state[ self.name]["relay_state"] != context.request["RelayState"]: msg = "State did not match relay state for state" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) raise SATOSAAuthenticationError(context.state, "State did not match relay state") context.decorate(Context.KEY_METADATA_STORE, self.sp.metadata) if self.config.get(SAMLBackend.KEY_MEMORIZE_IDP): issuer = authn_response.response.issuer.text.strip() context.state[Context.KEY_MEMORIZED_IDP] = issuer context.state.pop(self.name, None) context.state.pop(Context.KEY_FORCE_AUTHN, None) return self.auth_callback_func( context, self._translate_response(authn_response, context.state)) def disco_response(self, context): """ Endpoint for the discovery server response :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response """ info = context.request state = context.state try: entity_id = info["entityID"] except KeyError as err: msg = "No IDP chosen for state" logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) logger.debug(logline, exc_info=True) raise SATOSAAuthenticationError(state, "No IDP chosen") from err return self.authn_request(context, entity_id) def _translate_response(self, response, state): """ Translates a saml authorization response to an internal response :type response: saml2.response.AuthnResponse :rtype: satosa.internal.InternalData :param response: The saml authorization response :return: A translated internal response """ # The response may have been encrypted by the IdP so if we have an # encryption key, try it. if self.encryption_keys: response.parse_assertion(self.encryption_keys) authn_info = response.authn_info()[0] auth_class_ref = authn_info[0] timestamp = response.assertion.authn_statement[0].authn_instant issuer = response.response.issuer.text auth_info = AuthenticationInformation( auth_class_ref, timestamp, issuer, ) # The SAML response may not include a NameID. subject = response.get_subject() name_id = subject.text if subject else None name_id_format = subject.format if subject else None attributes = self.converter.to_internal( self.attribute_profile, response.ava, ) internal_resp = InternalData( auth_info=auth_info, attributes=attributes, subject_type=name_id_format, subject_id=name_id, ) msg = "backend received attributes:\n{}".format( json.dumps(response.ava, indent=4)) logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) logger.debug(logline) return internal_resp def _metadata_endpoint(self, context): """ Endpoint for retrieving the backend metadata :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response with metadata """ msg = "Sending metadata response" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) metadata_string = create_metadata_string(None, self.sp.config, 4, None, None, None, None, None).decode("utf-8") return Response(metadata_string, content="text/xml") def register_endpoints(self): """ See super class method satosa.backends.base.BackendModule#register_endpoints :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))] """ url_map = [] sp_endpoints = self.sp.config.getattr("endpoints", "sp") for endp, binding in sp_endpoints["assertion_consumer_service"]: parsed_endp = urlparse(endp) url_map.append(("^%s$" % parsed_endp.path[1:], functools.partial(self.authn_response, binding=binding))) if binding == BINDING_HTTP_REDIRECT: msg = " ".join([ "AssertionConsumerService endpoint with binding", BINDING_HTTP_REDIRECT, "is not recommended.", "Quoting section 4.1.2 of", "'Profiles for the OASIS Security Assertion Markup Language (SAML) V2.0':", "The HTTP Redirect binding MUST NOT be used,", "as the response will typically exceed the URL length", "permitted by most user agents.", ]) _warnings.warn(msg, UserWarning) if self.discosrv: for endp, binding in sp_endpoints["discovery_response"]: parsed_endp = urlparse(endp) url_map.append( ("^%s$" % parsed_endp.path[1:], self.disco_response)) if self.expose_entityid_endpoint(): parsed_entity_id = urlparse(self.sp.config.entityid) url_map.append(("^{0}".format(parsed_entity_id.path[1:]), self._metadata_endpoint)) return url_map def get_metadata_desc(self): """ See super class satosa.backends.backend_base.BackendModule#get_metadata_desc :rtype: satosa.metadata_creation.description.MetadataDescription """ entity_descriptions = [] idp_entities = self.sp.metadata.with_descriptor("idpsso") for entity_id, entity in idp_entities.items(): description = MetadataDescription( urlsafe_b64encode(entity_id.encode("utf-8")).decode("utf-8")) # Add organization info try: organization_info = entity["organization"] except KeyError: pass else: organization = OrganizationDesc() for name_info in organization_info.get("organization_name", []): organization.add_name(name_info["text"], name_info["lang"]) for display_name_info in organization_info.get( "organization_display_name", []): organization.add_display_name(display_name_info["text"], display_name_info["lang"]) for url_info in organization_info.get("organization_url", []): organization.add_url(url_info["text"], url_info["lang"]) description.organization = organization # Add contact person info try: contact_persons = entity["contact_person"] except KeyError: pass else: for person in contact_persons: person_desc = ContactPersonDesc() person_desc.contact_type = person.get("contact_type") for address in person.get('email_address', []): person_desc.add_email_address(address["text"]) if "given_name" in person: person_desc.given_name = person["given_name"]["text"] if "sur_name" in person: person_desc.sur_name = person["sur_name"]["text"] description.add_contact_person(person_desc) # Add UI info ui_info = self.sp.metadata.extension( entity_id, "idpsso_descriptor", "{}&UIInfo".format(UI_NAMESPACE)) if ui_info: ui_info = ui_info[0] ui_info_desc = UIInfoDesc() for desc in ui_info.get("description", []): ui_info_desc.add_description(desc["text"], desc["lang"]) for name in ui_info.get("display_name", []): ui_info_desc.add_display_name(name["text"], name["lang"]) for logo in ui_info.get("logo", []): ui_info_desc.add_logo(logo["text"], logo["width"], logo["height"], logo.get("lang")) description.ui_info = ui_info_desc entity_descriptions.append(description) return entity_descriptions
class SAMLBackend(BackendModule, SAMLBaseModule): """ A saml2 backend module (acting as a SP). """ KEY_DISCO_SRV = 'disco_srv' KEY_SP_CONFIG = 'sp_config' VALUE_ACR_COMPARISON_DEFAULT = 'exact' def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str :type name: str :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config :param base_url: base url of the service :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) self.config = self.init_config(config) sp_config = SPConfig().load(copy.deepcopy(config[self.KEY_SP_CONFIG]), False) self.sp = Base(sp_config) self.discosrv = config.get(self.KEY_DISCO_SRV) self.encryption_keys = [] self.outstanding_queries = {} self.idp_blacklist_file = config.get('idp_blacklist_file', None) sp_keypairs = sp_config.getattr('encryption_keypairs', '') sp_key_file = sp_config.getattr('key_file', '') if sp_keypairs: key_file_paths = [pair['key_file'] for pair in sp_keypairs] elif sp_key_file: key_file_paths = [sp_key_file] else: key_file_paths = [] for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read()) def start_auth(self, context, internal_req): """ See super class method satosa.backends.base.BackendModule#start_auth :type context: satosa.context.Context :type internal_req: satosa.internal.InternalData :rtype: satosa.response.Response """ target_entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID) if target_entity_id: entity_id = target_entity_id return self.authn_request(context, entity_id) # if there is only one IdP in the metadata, bypass the discovery service idps = self.sp.metadata.identity_providers() if len(idps) == 1 and "mdq" not in self.config["sp_config"]["metadata"]: entity_id = idps[0] return self.authn_request(context, entity_id) return self.disco_query() def disco_query(self): """ Makes a request to the discovery server :type context: satosa.context.Context :type internal_req: satosa.internal.InternalData :rtype: satosa.response.SeeOther :param context: The current context :param internal_req: The request :return: Response """ return_url = self.sp.config.getattr("endpoints", "sp")["discovery_response"][0][0] loc = self.sp.create_discovery_service_request(self.discosrv, self.sp.config.entityid, **{"return": return_url}) return SeeOther(loc) def construct_requested_authn_context(self, entity_id): if not self.acr_mapping: return None acr_entry = util.get_dict_defaults(self.acr_mapping, entity_id) if not acr_entry: return None if type(acr_entry) is not dict: acr_entry = { "class_ref": acr_entry, "comparison": self.VALUE_ACR_COMPARISON_DEFAULT, } authn_context = requested_authn_context( acr_entry['class_ref'], comparison=acr_entry.get( 'comparison', self.VALUE_ACR_COMPARISON_DEFAULT)) return authn_context def authn_request(self, context, entity_id): """ Do an authorization request on idp with given entity id. This is the start of the authorization. :type context: satosa.context.Context :type entity_id: str :rtype: satosa.response.Response :param context: The current context :param entity_id: Target IDP entity id :return: response to the user agent """ # If IDP blacklisting is enabled and the selected IDP is blacklisted, # stop here if self.idp_blacklist_file: with open(self.idp_blacklist_file) as blacklist_file: blacklist_array = json.load(blacklist_file)['blacklist'] if entity_id in blacklist_array: satosa_logging(logger, logging.DEBUG, "IdP with EntityID {} is blacklisted".format(entity_id), context.state, exc_info=False) raise SATOSAAuthenticationError(context.state, "Selected IdP is blacklisted for this backend") kwargs = {} authn_context = self.construct_requested_authn_context(entity_id) if authn_context: kwargs['requested_authn_context'] = authn_context try: binding, destination = self.sp.pick_binding( "single_sign_on_service", None, "idpsso", entity_id=entity_id) satosa_logging(logger, logging.DEBUG, "binding: %s, destination: %s" % (binding, destination), context.state) acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0] req_id, req = self.sp.create_authn_request( destination, binding=response_binding, **kwargs) relay_state = util.rndstr() ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state) satosa_logging(logger, logging.DEBUG, "ht_args: %s" % ht_args, context.state) except Exception as exc: satosa_logging(logger, logging.DEBUG, "Failed to construct the AuthnRequest for state", context.state, exc_info=True) raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from exc if self.sp.config.getattr('allow_unsolicited', 'sp') is False: if req_id in self.outstanding_queries: errmsg = "Request with duplicate id {}".format(req_id) satosa_logging(logger, logging.DEBUG, errmsg, context.state) raise SATOSAAuthenticationError(context.state, errmsg) self.outstanding_queries[req_id] = req context.state[self.name] = {"relay_state": relay_state} return make_saml_response(binding, ht_args) def authn_response(self, context, binding): """ Endpoint for the idp response :type context: satosa.context,Context :type binding: str :rtype: satosa.response.Response :param context: The current context :param binding: The saml binding type :return: response """ if not context.request["SAMLResponse"]: satosa_logging(logger, logging.DEBUG, "Missing Response for state", context.state) raise SATOSAAuthenticationError(context.state, "Missing Response") try: authn_response = self.sp.parse_authn_request_response( context.request["SAMLResponse"], binding, outstanding=self.outstanding_queries) except Exception as err: satosa_logging(logger, logging.DEBUG, "Failed to parse authn request for state", context.state, exc_info=True) raise SATOSAAuthenticationError(context.state, "Failed to parse authn request") from err if self.sp.config.getattr('allow_unsolicited', 'sp') is False: req_id = authn_response.in_response_to if req_id not in self.outstanding_queries: errmsg = "No request with id: {}".format(req_id), satosa_logging(logger, logging.DEBUG, errmsg, context.state) raise SATOSAAuthenticationError(context.state, errmsg) del self.outstanding_queries[req_id] # check if the relay_state matches the cookie state if context.state[self.name]["relay_state"] != context.request["RelayState"]: satosa_logging(logger, logging.DEBUG, "State did not match relay state for state", context.state) raise SATOSAAuthenticationError(context.state, "State did not match relay state") context.decorate(Context.KEY_BACKEND_METADATA_STORE, self.sp.metadata) del context.state[self.name] return self.auth_callback_func(context, self._translate_response(authn_response, context.state)) def disco_response(self, context): """ Endpoint for the discovery server response :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response """ info = context.request state = context.state try: entity_id = info["entityID"] except KeyError as err: satosa_logging(logger, logging.DEBUG, "No IDP chosen for state", state, exc_info=True) raise SATOSAAuthenticationError(state, "No IDP chosen") from err return self.authn_request(context, entity_id) def _translate_response(self, response, state): """ Translates a saml authorization response to an internal response :type response: saml2.response.AuthnResponse :rtype: satosa.internal.InternalData :param response: The saml authorization response :return: A translated internal response """ # The response may have been encrypted by the IdP so if we have an # encryption key, try it. if self.encryption_keys: response.parse_assertion(self.encryption_keys) authn_info = response.authn_info()[0] auth_class_ref = authn_info[0] timestamp = response.assertion.authn_statement[0].authn_instant issuer = response.response.issuer.text auth_info = AuthenticationInformation( auth_class_ref, timestamp, issuer, ) # The SAML response may not include a NameID. subject = response.get_subject() name_id = subject.text if subject else None name_id_format = subject.format if subject else None attributes = self.converter.to_internal( self.attribute_profile, response.ava, ) internal_resp = InternalData( auth_info=auth_info, attributes=attributes, subject_type=name_id_format, subject_id=name_id, ) satosa_logging(logger, logging.DEBUG, "backend received attributes:\n%s" % json.dumps(response.ava, indent=4), state) return internal_resp def _metadata_endpoint(self, context): """ Endpoint for retrieving the backend metadata :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response with metadata """ satosa_logging(logger, logging.DEBUG, "Sending metadata response", context.state) metadata_string = create_metadata_string(None, self.sp.config, 4, None, None, None, None, None).decode("utf-8") return Response(metadata_string, content="text/xml") def register_endpoints(self): """ See super class method satosa.backends.base.BackendModule#register_endpoints :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))] """ url_map = [] sp_endpoints = self.sp.config.getattr("endpoints", "sp") for endp, binding in sp_endpoints["assertion_consumer_service"]: parsed_endp = urlparse(endp) url_map.append(("^%s$" % parsed_endp.path[1:], functools.partial(self.authn_response, binding=binding))) if self.discosrv: for endp, binding in sp_endpoints["discovery_response"]: parsed_endp = urlparse(endp) url_map.append( ("^%s$" % parsed_endp.path[1:], self.disco_response)) if self.expose_entityid_endpoint(): parsed_entity_id = urlparse(self.sp.config.entityid) url_map.append(("^{0}".format(parsed_entity_id.path[1:]), self._metadata_endpoint)) return url_map def get_metadata_desc(self): """ See super class satosa.backends.backend_base.BackendModule#get_metadata_desc :rtype: satosa.metadata_creation.description.MetadataDescription """ entity_descriptions = [] idp_entities = self.sp.metadata.with_descriptor("idpsso") for entity_id, entity in idp_entities.items(): description = MetadataDescription(urlsafe_b64encode(entity_id.encode("utf-8")).decode("utf-8")) # Add organization info try: organization_info = entity["organization"] except KeyError: pass else: organization = OrganizationDesc() for name_info in organization_info.get("organization_name", []): organization.add_name(name_info["text"], name_info["lang"]) for display_name_info in organization_info.get("organization_display_name", []): organization.add_display_name(display_name_info["text"], display_name_info["lang"]) for url_info in organization_info.get("organization_url", []): organization.add_url(url_info["text"], url_info["lang"]) description.organization = organization # Add contact person info try: contact_persons = entity["contact_person"] except KeyError: pass else: for person in contact_persons: person_desc = ContactPersonDesc() person_desc.contact_type = person.get("contact_type") for address in person.get('email_address', []): person_desc.add_email_address(address["text"]) if "given_name" in person: person_desc.given_name = person["given_name"]["text"] if "sur_name" in person: person_desc.sur_name = person["sur_name"]["text"] description.add_contact_person(person_desc) # Add UI info ui_info = self.sp.metadata.extension(entity_id, "idpsso_descriptor", "{}&UIInfo".format(UI_NAMESPACE)) if ui_info: ui_info = ui_info[0] ui_info_desc = UIInfoDesc() for desc in ui_info.get("description", []): ui_info_desc.add_description(desc["text"], desc["lang"]) for name in ui_info.get("display_name", []): ui_info_desc.add_display_name(name["text"], name["lang"]) for logo in ui_info.get("logo", []): ui_info_desc.add_logo(logo["text"], logo["width"], logo["height"], logo.get("lang")) description.ui_info = ui_info_desc entity_descriptions.append(description) return entity_descriptions
class SamlSP(service.Service): def __init__(self, environ, start_response, config, cache=None, outgoing=None, discosrv=None, bindings=None): service.Service.__init__(self, environ, start_response) self.sp = Base(config, state_cache=cache) self.environ = environ self.start_response = start_response self.cache = cache self.idp_disco_query_param = "entityID" self.outgoing = outgoing self.discosrv = discosrv if bindings: self.bindings = bindings else: self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] logger.debug("--- SSO ---") def disco_response(self, *args): """ If I got a useful response from the discovery server, continue with the authentication request. :return: redirect containing the authentication request """ info = self.unpack_redirect() try: entity_id = info[self.idp_disco_query_param] except KeyError: resp = Unauthorized("You must chose an IdP") return resp(self.environ, self.start_response) else: # TODO should I check the state variable ? return self.authn_request(entity_id, info["state"]) def store_state(self, authn_req, relay_state, req_args): # Which page was accessed to get here. came_from = geturl(self.environ) key = str(hash(came_from + self.environ["REMOTE_ADDR"] + str(time.time()))) logger.debug("[sp.challenge] RelayState >> '%s'" % came_from) self.cache[key] = (authn_req, relay_state, req_args) return key def disco_query(self, authn_req, relay_state, req_args): """ This service is expected to always use a discovery service. This is where the response is handled :param authn_req: The Authentication Request :return: A 302 messages redirecting to the discovery service """ state_key = self.store_state(authn_req, relay_state, req_args) _cli = self.sp eid = _cli.config.entityid # Returns list of 2-tuples. dr = _cli.config.getattr("endpoints", "sp")["discovery_response"] # The first value of the first tuple is the one I want. ret = dr[0][0] # Append it to the disco server URL. ret += "?state=%s" % state_key loc = _cli.create_discovery_service_request(self.discosrv, eid, **{"return": ret}) resp = SeeOther(loc) return resp(self.environ, self.start_response) def authn_request(self, entity_id, state_key): _cli = self.sp req_args = self.cache[state_key][2] try: # Picks a binding to use for sending the Request to the IDP. _binding, destination = _cli.pick_binding( "single_sign_on_service", self.bindings, "idpsso", entity_id=entity_id) logger.debug("binding: %s, destination: %s" % (_binding, destination)) # Binding here is the response binding that is which binding the # IDP should use to return the response. acs = _cli.config.getattr("endpoints", "sp")[ "assertion_consumer_service"] # Just pick one. endp, return_binding = acs[0] req_id, req = _cli.create_authn_request(destination, binding=return_binding, **req_args) ht_args = _cli.apply_binding(_binding, "%s" % req, destination, relay_state=state_key) _sid = req_id logger.debug("ht_args: %s" % ht_args) except Exception as exc: logger.exception(exc) resp = ServiceError( "Failed to construct the AuthnRequest: %s" % exc) return resp(self.environ, self.start_response) # Remember the request. self.cache[_sid] = state_key resp = self.response(_binding, ht_args, do_not_start_response=True) return resp(self.environ, self.start_response) def authn_response(self, binding): """ :param binding: Which binding the query came in over :returns: Error response or a response constructed by the transfer function """ _authn_response = self.unpack(binding) if not _authn_response["SAMLResponse"]: logger.info("Missing Response") resp = Unauthorized('Unknown user') return resp(self.environ, self.start_response) binding = service.INV_BINDING_MAP[binding] try: _response = self.sp.parse_authn_request_response( _authn_response["SAMLResponse"], binding, self.cache) except UnknownPrincipal as excp: logger.error("UnknownPrincipal: %s" % (excp,)) resp = ServiceError("UnknownPrincipal: %s" % (excp,)) return resp(self.environ, self.start_response) except UnsupportedBinding as excp: logger.error("UnsupportedBinding: %s" % (excp,)) resp = ServiceError("UnsupportedBinding: %s" % (excp,)) return resp(self.environ, self.start_response) except VerificationError as err: resp = ServiceError("Verification error: %s" % (err,)) return resp(self.environ, self.start_response) except Exception as err: resp = ServiceError("Other error: %s" % (err,)) return resp(self.environ, self.start_response) return self.outgoing(_response, self) def register_endpoints(self): """ Given the configuration, return a set of URL to function mappings. """ url_map = [] sp_endpoints = self.sp.config.getattr("endpoints", "sp") for endp, binding in sp_endpoints["assertion_consumer_service"]: p = urlparse(endp) url_map.append(("^%s?(.*)$" % p.path[1:], ("SP", "authn_response", BINDING_MAP[binding]))) url_map.append(("^%s$" % p.path[1:], ("SP", "authn_response", BINDING_MAP[binding]))) if self.discosrv: for endp, binding in sp_endpoints["discovery_response"]: p = urlparse(endp) url_map.append(("^%s$" % p.path[1:], ("SP", "disco_response", BINDING_MAP[binding]))) return url_map
class SAMLBackend(BackendModule): """ A saml2 backend module (acting as a SP). """ def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str :type name: str :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config :param base_url: base url of the service :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) sp_config = SPConfig().load(copy.deepcopy(config["sp_config"]), False) self.sp = Base(sp_config) self.config = config self.attribute_profile = config.get("attribute_profile", "saml") self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] self.discosrv = config.get("disco_srv") self.encryption_keys = [] key_file_paths = None if 'encryption_keypairs' in self.config['sp_config']: # prioritize explicit encryption keypairs key_file_paths = [keypair['key_file'] for keypair in self.config['sp_config']['encryption_keypairs']] elif 'key_file' in self.config['sp_config']: key_file_paths = [self.config['sp_config']['key_file']] if key_file_paths: for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read()) def start_auth(self, context, internal_req): """ See super class method satosa.backends.base.BackendModule#start_auth :type context: satosa.context.Context :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.Response """ # if there is only one IdP in the metadata, bypass the discovery service idps = self.sp.metadata.identity_providers() if len(idps) == 1 and "mdq" not in self.config["sp_config"]["metadata"]: return self.authn_request(context, idps[0]) try: # find mirrored entity id entity_id = context.internal_data["mirror.target_entity_id"] except KeyError: # redirect to discovery server return self.disco_query() else: entity_id = urlsafe_b64decode(entity_id).decode("utf-8") return self.authn_request(context, entity_id) def disco_query(self): """ Makes a request to the discovery server :type context: satosa.context.Context :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.SeeOther :param context: The current context :param internal_req: The request :return: Response """ return_url = self.sp.config.getattr("endpoints", "sp")["discovery_response"][0][0] loc = self.sp.create_discovery_service_request(self.discosrv, self.sp.config.entityid, **{"return": return_url}) return SeeOther(loc) def authn_request(self, context, entity_id): """ Do an authorization request on idp with given entity id. This is the start of the authorization. :type context: satosa.context.Context :type entity_id: str :rtype: satosa.response.Response :param context: The curretn context :param entity_id: Target IDP entity id :return: response to the user agent """ try: binding, destination = self.sp.pick_binding("single_sign_on_service", self.bindings, "idpsso", entity_id=entity_id) satosa_logging(logger, logging.DEBUG, "binding: %s, destination: %s" % (binding, destination), context.state) acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0] req_id, req = self.sp.create_authn_request(destination, binding=response_binding) relay_state = rndstr() ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state) satosa_logging(logger, logging.DEBUG, "ht_args: %s" % ht_args, context.state) except Exception as exc: satosa_logging(logger, logging.DEBUG, "Failed to construct the AuthnRequest for state", context.state, exc_info=True) raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from exc context.state[self.name] = {"relay_state": relay_state} return make_saml_response(binding, ht_args) def authn_response(self, context, binding): """ Endpoint for the idp response :type context: satosa.context,Context :type binding: str :rtype: satosa.response.Response :param context: The current context :param binding: The saml binding type :return: response """ if not context.request["SAMLResponse"]: satosa_logging(logger, logging.DEBUG, "Missing Response for state", context.state) raise SATOSAAuthenticationError(context.state, "Missing Response") try: authn_response = self.sp.parse_authn_request_response(context.request["SAMLResponse"], binding) except Exception as err: satosa_logging(logger, logging.DEBUG, "Failed to parse authn request for state", context.state, exc_info=True) raise SATOSAAuthenticationError(context.state, "Failed to parse authn request") from err # check if the relay_state matches the cookie state if context.state[self.name]["relay_state"] != context.request["RelayState"]: satosa_logging(logger, logging.DEBUG, "State did not match relay state for state", context.state) raise SATOSAAuthenticationError(context.state, "State did not match relay state") del context.state[self.name] return self.auth_callback_func(context, self._translate_response(authn_response, context.state)) def disco_response(self, context): """ Endpoint for the discovery server response :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response """ info = context.request state = context.state try: entity_id = info["entityID"] except KeyError as err: satosa_logging(logger, logging.DEBUG, "No IDP chosen for state", state, exc_info=True) raise SATOSAAuthenticationError(state, "No IDP chosen") from err return self.authn_request(context, entity_id) def _translate_response(self, response, state): """ Translates a saml authorization response to an internal response :type response: saml2.response.AuthnResponse :rtype: satosa.internal_data.InternalResponse :param response: The saml authorization response :return: A translated internal response """ # The response may have been encrypted by the IdP so if we have an encryption key, try it if self.encryption_keys: response.parse_assertion(self.encryption_keys) authn_info = response.authn_info()[0] auth_class_ref = authn_info[0] timestamp = response.assertion.authn_statement[0].authn_instant issuer = response.response.issuer.text auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) internal_resp = InternalResponse(auth_info=auth_info) internal_resp.user_id = response.get_subject().text internal_resp.attributes = self.converter.to_internal(self.attribute_profile, response.ava) satosa_logging(logger, logging.DEBUG, "received attributes:\n%s" % json.dumps(response.ava, indent=4), state) return internal_resp def _metadata_endpoint(self, context): """ Endpoint for retrieving the backend metadata :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response with metadata """ satosa_logging(logger, logging.DEBUG, "Sending metadata response", context.state) metadata_string = create_metadata_string(None, self.sp.config, 4, None, None, None, None, None).decode("utf-8") return Response(metadata_string, content="text/xml") def register_endpoints(self): """ See super class method satosa.backends.base.BackendModule#register_endpoints :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))] """ url_map = [] sp_endpoints = self.sp.config.getattr("endpoints", "sp") for endp, binding in sp_endpoints["assertion_consumer_service"]: parsed_endp = urlparse(endp) url_map.append(("^%s$" % parsed_endp.path[1:], functools.partial(self.authn_response, binding=binding))) if self.discosrv: for endp, binding in sp_endpoints["discovery_response"]: parsed_endp = urlparse(endp) url_map.append( ("^%s$" % parsed_endp.path[1:], self.disco_response)) return url_map def get_metadata_desc(self): """ See super class satosa.backends.backend_base.BackendModule#get_metadata_desc :rtype: satosa.metadata_creation.description.MetadataDescription """ entity_descriptions = [] idp_entities = self.sp.metadata.with_descriptor("idpsso") for entity_id, entity in idp_entities.items(): description = MetadataDescription(urlsafe_b64encode(entity_id.encode("utf-8")).decode("utf-8")) # Add organization info try: organization_info = entity["organization"] except KeyError: pass else: organization = OrganizationDesc() for name_info in organization_info.get("organization_name", []): organization.add_name(name_info["text"], name_info["lang"]) for display_name_info in organization_info.get("organization_display_name", []): organization.add_display_name(display_name_info["text"], display_name_info["lang"]) for url_info in organization_info.get("organization_url", []): organization.add_url(url_info["text"], url_info["lang"]) description.organization = organization # Add contact person info try: contact_persons = entity["contact_person"] except KeyError: pass else: for person in contact_persons: person_desc = ContactPersonDesc() person_desc.contact_type = person.get("contact_type") for address in person.get('email_address', []): person_desc.add_email_address(address["text"]) if "given_name" in person: person_desc.given_name = person["given_name"]["text"] if "sur_name" in person: person_desc.sur_name = person["sur_name"]["text"] description.add_contact_person(person_desc) # Add UI info ui_info = self.sp.metadata.extension(entity_id, "idpsso_descriptor", "{}&UIInfo".format(UI_NAMESPACE)) if ui_info: ui_info = ui_info[0] ui_info_desc = UIInfoDesc() for desc in ui_info.get("description", []): ui_info_desc.add_description(desc["text"], desc["lang"]) for name in ui_info.get("display_name", []): ui_info_desc.add_display_name(name["text"], name["lang"]) for logo in ui_info.get("logo", []): ui_info_desc.add_logo(logo["text"], logo["width"], logo["height"], logo.get("lang")) description.ui_info = ui_info_desc entity_descriptions.append(description) return entity_descriptions
class SamlBackend(BackendModule): """ A saml2 backend module """ def __init__(self, outgoing, internal_attributes, config): """ :type outgoing: (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config """ super(SamlBackend, self).__init__(outgoing, internal_attributes) sp_config = SPConfig().load(copy.deepcopy(config["config"]), False) self.sp = Base(sp_config) self.idp_disco_query_param = "entityID" self.config = config self.attribute_profile = config.get("attribute_profile", "saml") self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] self.discosrv = None self.state_id = config["state_id"] try: self.discosrv = config["disco_srv"] except KeyError: pass @staticmethod def create_name_id_policy(usr_id_hash_type): """ Creates a name id policy :type usr_id_hash_type: satosa.internal_data.UserIdHashType :rtype: saml2.samlp.NameIDPolicy :param usr_id_hash_type: The internal id hash type :return: A name id policy """ nameid_format = get_saml_name_id_format(usr_id_hash_type) name_id_policy = NameIDPolicy(format=nameid_format) return name_id_policy def start_auth(self, context, internal_req): """ See super class method satosa.backends.base.BackendModule#start_auth :type context: satosa.context.Context :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.Response """ # if there is only one IdP in the metadata, bypass the discovery service idps = self.sp.metadata.identity_providers() if len(idps) == 1: return self.authn_request(context, idps[0], internal_req) try: entity_id = context.internal_data["mirror.target_entity_id"] entity_id = urlsafe_b64decode(entity_id).decode("utf-8") return self.authn_request(context, entity_id, internal_req) except KeyError: return self.disco_query(context, internal_req) def disco_query(self, context, internal_req): """ Makes a request to the discovery server :type context: satosa.context.Context :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.SeeOther :param context: The current context :param internal_req: The request :return: Response """ state = context.state _cli = self.sp eid = _cli.config.entityid # returns list of 2-tuples disco_resp = _cli.config.getattr("endpoints", "sp")["discovery_response"] # The first value of the first tuple is the one I want ret = disco_resp[0][0] loc = _cli.create_discovery_service_request(self.discosrv, eid, **{"return": ret}) return SeeOther(loc) def authn_request(self, context, entity_id, internal_req): """ Do an authorization request on idp with given entity id. This is the start of the authorization. :type context: satosa.context.Context :type entity_id: str :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.Response :param context: The curretn context :param entity_id: Target IDP entity id :param internal_req: The request :return: Response """ _cli = self.sp hash_type = UserIdHashType.persistent.name if "hash_type" in self.config: hash_type = self.config["hash_type"] req_args = {"name_id_policy": self.create_name_id_policy(hash_type)} state = context.state try: # Picks a binding to use for sending the Request to the IDP _binding, destination = _cli.pick_binding( "single_sign_on_service", self.bindings, "idpsso", entity_id=entity_id) satosa_logging(LOGGER, logging.DEBUG, "binding: %s, destination: %s" % (_binding, destination), state) # Binding here is the response binding that is which binding the # IDP should use to return the response. acs = _cli.config.getattr("endpoints", "sp")[ "assertion_consumer_service"] # just pick one endp, return_binding = acs[0] req_id, req = _cli.create_authn_request(destination, binding=return_binding, **req_args) relay_state = rndstr() ht_args = _cli.apply_binding(_binding, "%s" % req, destination, relay_state=relay_state) satosa_logging(LOGGER, logging.DEBUG, "ht_args: %s" % ht_args, state) except Exception as exc: satosa_logging(LOGGER, logging.DEBUG, "Failed to construct the AuthnRequest for state", state, exc_info=True) raise SATOSAAuthenticationError(state, "Failed to construct the AuthnRequest") from exc state.add(self.state_id, relay_state) if _binding == BINDING_HTTP_REDIRECT: for param, value in ht_args["headers"]: if param == "Location": resp = SeeOther(str(value)) break else: satosa_logging(LOGGER, logging.DEBUG, "Parameter error for state", state) raise SATOSAAuthenticationError(state, "Parameter error") else: resp = Response(ht_args["data"], headers=ht_args["headers"]) return resp def authn_response(self, context, binding): """ Endpoint for the idp response :type context: satosa.context,Context :type binding: str :rtype: satosa.response.Response :param context: The current context :param binding: The saml binding type :return: response """ _authn_response = context.request state = context.state if not _authn_response["SAMLResponse"]: satosa_logging(LOGGER, logging.DEBUG, "Missing Response for state", state) raise SATOSAAuthenticationError(state, "Missing Response") try: _response = self.sp.parse_authn_request_response( _authn_response["SAMLResponse"], binding) except Exception as err: satosa_logging(LOGGER, logging.DEBUG, "Failed to parse authn request for state", state, exc_info=True) raise SATOSAAuthenticationError(state, "Failed to parse authn request") from err # check if the relay_state matches the cookie state if state.get(self.state_id) != _authn_response['RelayState']: satosa_logging(LOGGER, logging.DEBUG, "State did not match relay state for state", state) raise SATOSAAuthenticationError(state, "State did not match relay state") context.state.remove(self.state_id) return self.auth_callback_func(context, self._translate_response(_response, context.state)) def disco_response(self, context): """ Endpoint for the discovery server response :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response """ info = context.request state = context.state try: entity_id = info[self.idp_disco_query_param] except KeyError as err: satosa_logging(LOGGER, logging.DEBUG, "No IDP chosen for state", state, exc_info=True) raise SATOSAAuthenticationError(state, "No IDP chosen") from err else: request_info = InternalRequest(None, None) return self.authn_request(context, entity_id, request_info) def _translate_response(self, response, state): """ Translates a saml authorization response to an internal response :type response: saml2.response.AuthnResponse :rtype: satosa.internal_data.InternalResponse :param response: The saml authorization response :return: A translated internal response """ _authn_info = response.authn_info()[0] timestamp = response.assertion.authn_statement[0].authn_instant issuer = response.response.issuer.text auth_class_ref = _authn_info[0] auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) internal_resp = InternalResponse(auth_info=auth_info) internal_resp.set_user_id(response.get_subject().text) if "user_id_params" in self.config: user_id = "" for param in self.config["user_id_params"]: try: user_id += response.ava[param] except Exception as error: raise SATOSAAuthenticationError from error internal_resp.set_user_id(user_id) internal_resp.add_attributes(self.converter.to_internal(self.attribute_profile, response.ava)) satosa_logging(LOGGER, logging.DEBUG, "received attributes:\n%s" % json.dumps(response.ava, indent=4), state) return internal_resp def _metadata(self, context): """ Endpoint for retrieving the backend metadata :type context: satosa.context.Context :rtype: satosa.backends.saml2.MetadataResponse :param context: The current context :return: response with metadata """ satosa_logging(LOGGER, logging.DEBUG, "Sending metadata response", context.state) return MetadataResponse(self.sp.config) def register_endpoints(self): """ See super class method satosa.backends.base.BackendModule#register_endpoints :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))] """ url_map = [] sp_endpoints = self.sp.config.getattr("endpoints", "sp") for endp, binding in sp_endpoints["assertion_consumer_service"]: parsed_endp = urlparse(endp) url_map.append( ("^%s?(.*)$" % parsed_endp.path[1:], (self.authn_response, binding))) url_map.append( ("^%s$" % parsed_endp.path[1:], (self.authn_response, binding))) if "publish_metadata" in self.config: metadata_path = urlparse(self.config["publish_metadata"]) url_map.append(("^%s$" % metadata_path.path[1:], self._metadata)) if self.discosrv: for endp, binding in sp_endpoints["discovery_response"]: parsed_endp = urlparse(endp) url_map.append( ("^%s$" % parsed_endp.path[1:], self.disco_response)) return url_map def get_metadata_desc(self): """ See super class vopaas.backends.backend_base.VOPaaSBackendModule#get_metadata_desc :rtype: satosa.metadata_creation.description.MetadataDescription """ # TODO Only get IDPs metadata_desc = [] for metadata_file in self.sp.metadata.metadata: metadata_file = self.sp.metadata.metadata[metadata_file] entity_ids = [] if metadata_file.entity_descr is None: for entity_descr in metadata_file.entities_descr.entity_descriptor: entity_ids.append(entity_descr.entity_id) else: entity_ids.append(metadata_file.entity_descr.entity_id) entity = metadata_file.entity for entity_id in entity_ids: description = MetadataDescription( urlsafe_b64encode(entity_id.encode("utf-8")).decode("utf-8")) # Add organization info try: organization = OrganizationDesc() organization_info = entity[entity_id]['organization'] for name_info in organization_info.get("organization_name", []): organization.add_name(name_info["text"], name_info["lang"]) for display_name_info in organization_info.get("organization_display_name", []): organization.add_display_name(display_name_info["text"], display_name_info["lang"]) for url_info in organization_info.get("organization_url", []): organization.add_url(url_info["text"], url_info["lang"]) description.set_organization(organization) except: pass # Add contact person info try: contact_persons = entity[entity_id]['contact_person'] for cont_pers in contact_persons: person = ContactPersonDesc() if 'contact_type' in cont_pers: person.contact_type = cont_pers['contact_type'] for address in cont_pers.get('email_address', []): person.add_email_address(address["text"]) if 'given_name' in cont_pers: person.given_name = cont_pers['given_name']['text'] if 'sur_name' in cont_pers: person.sur_name = cont_pers['sur_name']['text'] description.add_contact_person(person) except KeyError: pass # Add ui info try: for idpsso_desc in entity[entity_id]["idpsso_descriptor"]: # TODO Can have more than one ui info? ui_elements = idpsso_desc["extensions"]["extension_elements"] ui_info = UIInfoDesc() for element in ui_elements: if not element["__class__"] == "%s&UIInfo" % UI_NAMESPACE: continue for desc in element.get("description", []): ui_info.add_description(desc["text"], desc["lang"]) for name in element.get("display_name", []): ui_info.add_display_name(name["text"], name["lang"]) for logo in element.get("logo", []): ui_info.add_logo(logo["text"], logo["width"], logo["height"], logo["lang"]) description.set_ui_info(ui_info) except KeyError: pass metadata_desc.append(description) return metadata_desc
class SamlSP(service.Service): def __init__(self, environ, start_response, config, cache=None, outgoing=None, discosrv=None, bindings=None): service.Service.__init__(self, environ, start_response) self.sp = Base(config, state_cache=cache) self.environ = environ self.start_response = start_response self.cache = cache self.idp_disco_query_param = "entityID" self.outgoing = outgoing self.discosrv = discosrv if bindings: self.bindings = bindings else: self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] logger.debug("--- SSO ---") def disco_response(self, *args): """ If I got a useful response from the discovery server, continue with the authentication request. :return: redirect containing the authentication request """ info = self.unpack_redirect() try: entity_id = info[self.idp_disco_query_param] except KeyError: resp = Unauthorized("You must chose an IdP") return resp(self.environ, self.start_response) else: # TODO should I check the state variable ? return self.authn_request(entity_id, info["state"]) def store_state(self, authn_req, relay_state, req_args): # Which page was accessed to get here. came_from = geturl(self.environ) key = str( hash(came_from + self.environ["REMOTE_ADDR"] + str(time.time()))) logger.debug("[sp.challenge] RelayState >> '%s'" % came_from) self.cache[key] = (authn_req, relay_state, req_args) return key def disco_query(self, authn_req, relay_state, req_args): """ This service is expected to always use a discovery service. This is where the response is handled :param authn_req: The Authentication Request :return: A 302 messages redirecting to the discovery service """ state_key = self.store_state(authn_req, relay_state, req_args) _cli = self.sp eid = _cli.config.entityid # Returns list of 2-tuples. dr = _cli.config.getattr("endpoints", "sp")["discovery_response"] # The first value of the first tuple is the one I want. ret = dr[0][0] # Append it to the disco server URL. ret += "?state=%s" % state_key loc = _cli.create_discovery_service_request(self.discosrv, eid, **{"return": ret}) resp = SeeOther(loc) return resp(self.environ, self.start_response) def authn_request(self, entity_id, state_key): _cli = self.sp req_args = self.cache[state_key][2] try: # Picks a binding to use for sending the Request to the IDP. _binding, destination = _cli.pick_binding("single_sign_on_service", self.bindings, "idpsso", entity_id=entity_id) logger.debug("binding: %s, destination: %s" % (_binding, destination)) # Binding here is the response binding that is which binding the # IDP should use to return the response. acs = _cli.config.getattr("endpoints", "sp")["assertion_consumer_service"] # Just pick one. endp, return_binding = acs[0] req_id, req = _cli.create_authn_request(destination, binding=return_binding, **req_args) ht_args = _cli.apply_binding(_binding, "%s" % req, destination, relay_state=state_key) _sid = req_id logger.debug("ht_args: %s" % ht_args) except Exception as exc: logger.exception(exc) resp = ServiceError("Failed to construct the AuthnRequest: %s" % exc) return resp(self.environ, self.start_response) # Remember the request. self.cache[_sid] = state_key resp = self.response(_binding, ht_args, do_not_start_response=True) return resp(self.environ, self.start_response) def authn_response(self, binding): """ :param binding: Which binding the query came in over :returns: Error response or a response constructed by the transfer function """ _authn_response = self.unpack(binding) if not _authn_response["SAMLResponse"]: logger.info("Missing Response") resp = Unauthorized('Unknown user') return resp(self.environ, self.start_response) binding = service.INV_BINDING_MAP[binding] try: _response = self.sp.parse_authn_request_response( _authn_response["SAMLResponse"], binding, self.cache) except UnknownPrincipal as excp: logger.error("UnknownPrincipal: %s" % (excp, )) resp = ServiceError("UnknownPrincipal: %s" % (excp, )) return resp(self.environ, self.start_response) except UnsupportedBinding as excp: logger.error("UnsupportedBinding: %s" % (excp, )) resp = ServiceError("UnsupportedBinding: %s" % (excp, )) return resp(self.environ, self.start_response) except VerificationError as err: resp = ServiceError("Verification error: %s" % (err, )) return resp(self.environ, self.start_response) except Exception as err: resp = ServiceError("Other error: %s" % (err, )) return resp(self.environ, self.start_response) return self.outgoing(_response, self) def register_endpoints(self): """ Given the configuration, return a set of URL to function mappings. """ url_map = [] sp_endpoints = self.sp.config.getattr("endpoints", "sp") for endp, binding in sp_endpoints["assertion_consumer_service"]: p = urlparse(endp) url_map.append(("^%s?(.*)$" % p.path[1:], ("SP", "authn_response", BINDING_MAP[binding]))) url_map.append(("^%s$" % p.path[1:], ("SP", "authn_response", BINDING_MAP[binding]))) if self.discosrv: for endp, binding in sp_endpoints["discovery_response"]: p = urlparse(endp) url_map.append(("^%s$" % p.path[1:], ("SP", "disco_response", BINDING_MAP[binding]))) return url_map
class SamlBackend(BackendModule): """ A saml2 backend module """ def __init__(self, outgoing, internal_attributes, config): """ :type outgoing: (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :param outgoing: Callback should be called by the module after the authorization in the backend is done. :param internal_attributes: Internal attribute map :param config: The module config """ super(SamlBackend, self).__init__(outgoing, internal_attributes) sp_config = SPConfig().load(copy.deepcopy(config["config"]), False) self.sp = Base(sp_config) self.idp_disco_query_param = "entityID" self.config = config self.attribute_profile = config.get("attribute_profile", "saml") self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] self.discosrv = None self.state_id = config["state_id"] try: self.discosrv = config["disco_srv"] except KeyError: pass @staticmethod def create_name_id_policy(usr_id_hash_type): """ Creates a name id policy :type usr_id_hash_type: satosa.internal_data.UserIdHashType :rtype: saml2.samlp.NameIDPolicy :param usr_id_hash_type: The internal id hash type :return: A name id policy """ nameid_format = get_saml_name_id_format(usr_id_hash_type) name_id_policy = NameIDPolicy(format=nameid_format) return name_id_policy def start_auth(self, context, internal_req): """ See super class method satosa.backends.base.BackendModule#start_auth :type context: satosa.context.Context :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.Response """ # if there is only one IdP in the metadata, bypass the discovery service idps = self.sp.metadata.identity_providers() if len(idps) == 1: return self.authn_request(context, idps[0], internal_req) try: entity_id = context.internal_data["mirror.target_entity_id"] entity_id = urlsafe_b64decode(entity_id).decode("utf-8") return self.authn_request(context, entity_id, internal_req) except KeyError: return self.disco_query(context, internal_req) def disco_query(self, context, internal_req): """ Makes a request to the discovery server :type context: satosa.context.Context :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.SeeOther :param context: The current context :param internal_req: The request :return: Response """ state = context.state _cli = self.sp eid = _cli.config.entityid # returns list of 2-tuples disco_resp = _cli.config.getattr("endpoints", "sp")["discovery_response"] # The first value of the first tuple is the one I want ret = disco_resp[0][0] loc = _cli.create_discovery_service_request(self.discosrv, eid, **{"return": ret}) return SeeOther(loc) def authn_request(self, context, entity_id, internal_req): """ Do an authorization request on idp with given entity id. This is the start of the authorization. :type context: satosa.context.Context :type entity_id: str :type internal_req: satosa.internal_data.InternalRequest :rtype: satosa.response.Response :param context: The curretn context :param entity_id: Target IDP entity id :param internal_req: The request :return: Response """ _cli = self.sp hash_type = UserIdHashType.persistent.name if "hash_type" in self.config: hash_type = self.config["hash_type"] req_args = {"name_id_policy": self.create_name_id_policy(hash_type)} state = context.state try: # Picks a binding to use for sending the Request to the IDP _binding, destination = _cli.pick_binding("single_sign_on_service", self.bindings, "idpsso", entity_id=entity_id) satosa_logging( LOGGER, logging.DEBUG, "binding: %s, destination: %s" % (_binding, destination), state) # Binding here is the response binding that is which binding the # IDP should use to return the response. acs = _cli.config.getattr("endpoints", "sp")["assertion_consumer_service"] # just pick one endp, return_binding = acs[0] req_id, req = _cli.create_authn_request(destination, binding=return_binding, **req_args) relay_state = rndstr() ht_args = _cli.apply_binding(_binding, "%s" % req, destination, relay_state=relay_state) satosa_logging(LOGGER, logging.DEBUG, "ht_args: %s" % ht_args, state) except Exception as exc: satosa_logging(LOGGER, logging.DEBUG, "Failed to construct the AuthnRequest for state", state, exc_info=True) raise SATOSAAuthenticationError( state, "Failed to construct the AuthnRequest") from exc state.add(self.state_id, relay_state) if _binding == BINDING_HTTP_REDIRECT: for param, value in ht_args["headers"]: if param == "Location": resp = SeeOther(str(value)) break else: satosa_logging(LOGGER, logging.DEBUG, "Parameter error for state", state) raise SATOSAAuthenticationError(state, "Parameter error") else: resp = Response(ht_args["data"], headers=ht_args["headers"]) return resp def authn_response(self, context, binding): """ Endpoint for the idp response :type context: satosa.context,Context :type binding: str :rtype: satosa.response.Response :param context: The current context :param binding: The saml binding type :return: response """ _authn_response = context.request state = context.state if not _authn_response["SAMLResponse"]: satosa_logging(LOGGER, logging.DEBUG, "Missing Response for state", state) raise SATOSAAuthenticationError(state, "Missing Response") try: _response = self.sp.parse_authn_request_response( _authn_response["SAMLResponse"], binding) except Exception as err: satosa_logging(LOGGER, logging.DEBUG, "Failed to parse authn request for state", state, exc_info=True) raise SATOSAAuthenticationError( state, "Failed to parse authn request") from err # check if the relay_state matches the cookie state if state.get(self.state_id) != _authn_response['RelayState']: satosa_logging(LOGGER, logging.DEBUG, "State did not match relay state for state", state) raise SATOSAAuthenticationError(state, "State did not match relay state") context.state.remove(self.state_id) return self.auth_callback_func( context, self._translate_response(_response, context.state)) def disco_response(self, context): """ Endpoint for the discovery server response :type context: satosa.context.Context :rtype: satosa.response.Response :param context: The current context :return: response """ info = context.request state = context.state try: entity_id = info[self.idp_disco_query_param] except KeyError as err: satosa_logging(LOGGER, logging.DEBUG, "No IDP chosen for state", state, exc_info=True) raise SATOSAAuthenticationError(state, "No IDP chosen") from err else: request_info = InternalRequest(None, None) return self.authn_request(context, entity_id, request_info) def _translate_response(self, response, state): """ Translates a saml authorization response to an internal response :type response: saml2.response.AuthnResponse :rtype: satosa.internal_data.InternalResponse :param response: The saml authorization response :return: A translated internal response """ _authn_info = response.authn_info()[0] timestamp = response.assertion.authn_statement[0].authn_instant issuer = response.response.issuer.text auth_class_ref = _authn_info[0] auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) internal_resp = InternalResponse(auth_info=auth_info) internal_resp.set_user_id(response.get_subject().text) if "user_id_params" in self.config: user_id = "" for param in self.config["user_id_params"]: try: user_id += response.ava[param] except Exception as error: raise SATOSAAuthenticationError from error internal_resp.set_user_id(user_id) internal_resp.add_attributes( self.converter.to_internal(self.attribute_profile, response.ava)) satosa_logging( LOGGER, logging.DEBUG, "received attributes:\n%s" % json.dumps(response.ava, indent=4), state) return internal_resp def _metadata(self, context): """ Endpoint for retrieving the backend metadata :type context: satosa.context.Context :rtype: satosa.backends.saml2.MetadataResponse :param context: The current context :return: response with metadata """ satosa_logging(LOGGER, logging.DEBUG, "Sending metadata response", context.state) return MetadataResponse(self.sp.config) def register_endpoints(self): """ See super class method satosa.backends.base.BackendModule#register_endpoints :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))] """ url_map = [] sp_endpoints = self.sp.config.getattr("endpoints", "sp") for endp, binding in sp_endpoints["assertion_consumer_service"]: parsed_endp = urlparse(endp) url_map.append(("^%s?(.*)$" % parsed_endp.path[1:], (self.authn_response, binding))) url_map.append(("^%s$" % parsed_endp.path[1:], (self.authn_response, binding))) if "publish_metadata" in self.config: metadata_path = urlparse(self.config["publish_metadata"]) url_map.append(("^%s$" % metadata_path.path[1:], self._metadata)) if self.discosrv: for endp, binding in sp_endpoints["discovery_response"]: parsed_endp = urlparse(endp) url_map.append( ("^%s$" % parsed_endp.path[1:], self.disco_response)) return url_map def get_metadata_desc(self): """ See super class vopaas.backends.backend_base.VOPaaSBackendModule#get_metadata_desc :rtype: satosa.metadata_creation.description.MetadataDescription """ # TODO Only get IDPs metadata_desc = [] for metadata_file in self.sp.metadata.metadata: metadata_file = self.sp.metadata.metadata[metadata_file] entity_ids = [] if metadata_file.entity_descr is None: for entity_descr in metadata_file.entities_descr.entity_descriptor: entity_ids.append(entity_descr.entity_id) else: entity_ids.append(metadata_file.entity_descr.entity_id) entity = metadata_file.entity for entity_id in entity_ids: description = MetadataDescription( urlsafe_b64encode( entity_id.encode("utf-8")).decode("utf-8")) # Add organization info try: organization = OrganizationDesc() organization_info = entity[entity_id]['organization'] for name_info in organization_info.get( "organization_name", []): organization.add_name(name_info["text"], name_info["lang"]) for display_name_info in organization_info.get( "organization_display_name", []): organization.add_display_name( display_name_info["text"], display_name_info["lang"]) for url_info in organization_info.get( "organization_url", []): organization.add_url(url_info["text"], url_info["lang"]) description.set_organization(organization) except: pass # Add contact person info try: contact_persons = entity[entity_id]['contact_person'] for cont_pers in contact_persons: person = ContactPersonDesc() if 'contact_type' in cont_pers: person.contact_type = cont_pers['contact_type'] for address in cont_pers.get('email_address', []): person.add_email_address(address["text"]) if 'given_name' in cont_pers: person.given_name = cont_pers['given_name']['text'] if 'sur_name' in cont_pers: person.sur_name = cont_pers['sur_name']['text'] description.add_contact_person(person) except KeyError: pass # Add ui info try: for idpsso_desc in entity[entity_id]["idpsso_descriptor"]: # TODO Can have more than one ui info? ui_elements = idpsso_desc["extensions"][ "extension_elements"] ui_info = UIInfoDesc() for element in ui_elements: if not element[ "__class__"] == "%s&UIInfo" % UI_NAMESPACE: continue for desc in element.get("description", []): ui_info.add_description( desc["text"], desc["lang"]) for name in element.get("display_name", []): ui_info.add_display_name( name["text"], name["lang"]) for logo in element.get("logo", []): ui_info.add_logo(logo["text"], logo["width"], logo["height"], logo["lang"]) description.set_ui_info(ui_info) except KeyError: pass metadata_desc.append(description) return metadata_desc