From 0ddf568cbfdc1d25aae80f6f17b2b3427b84df7d Mon Sep 17 00:00:00 2001 From: peppelinux Date: Fri, 19 Apr 2019 10:03:02 +0200 Subject: [PATCH 001/401] encrypt assertion in frontend's authnresponse --- example/plugins/frontends/saml2_frontend.yaml.example | 2 ++ src/satosa/frontends/saml2.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/example/plugins/frontends/saml2_frontend.yaml.example b/example/plugins/frontends/saml2_frontend.yaml.example index 02bae5f8c..87bc4203f 100644 --- a/example/plugins/frontends/saml2_frontend.yaml.example +++ b/example/plugins/frontends/saml2_frontend.yaml.example @@ -47,6 +47,8 @@ config: fail_on_missing_requested: false lifetime: {minutes: 15} name_form: urn:oasis:names:tc:SAML:2.0:attrname-format:uri + encrypt_assertion: false + encrypted_advice_attributes: false acr_mapping: "": default-LoA "https://accounts.google.com": LoA1 diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 68ca31ff4..d51b6c389 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -348,6 +348,8 @@ def _handle_authn_response(self, context, internal_response, idp): sign_response = sp_policy.get('sign_response', True) sign_alg = sp_policy.get('sign_alg', 'SIG_RSA_SHA256') digest_alg = sp_policy.get('digest_alg', 'DIGEST_SHA256') + encrypt_assertion = sp_policy.get('encrypt_assertion', False) + encrypted_advice_attributes = sp_policy.get('encrypted_advice_attributes', False) # Construct arguments for method create_authn_response # on IdP Server instance @@ -357,6 +359,8 @@ def _handle_authn_response(self, context, internal_response, idp): 'authn' : auth_info, 'sign_response' : sign_response, 'sign_assertion': sign_assertion, + 'encrypt_assertion': encrypt_assertion, + 'encrypted_advice_attributes': encrypted_advice_attributes } # Add the SP details From cd4344d6bb59278b8ee25d544a7ff3a47a49e726 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Fri, 14 Jun 2019 13:05:52 +0200 Subject: [PATCH 002/401] [Microservice] ldap_attribute_store_no_pool --- .../ldap_attribute_store_no_pool.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/satosa/micro_services/ldap_attribute_store_no_pool.py diff --git a/src/satosa/micro_services/ldap_attribute_store_no_pool.py b/src/satosa/micro_services/ldap_attribute_store_no_pool.py new file mode 100644 index 000000000..fd0c7c234 --- /dev/null +++ b/src/satosa/micro_services/ldap_attribute_store_no_pool.py @@ -0,0 +1,194 @@ +""" +SATOSA microservice that uses an identifier asserted by +the home organization SAML IdP as a key to search an LDAP +directory for a record and then consume attributes from +the record and assert them to the receiving SP. +""" + +from satosa.micro_services.base import ResponseMicroService +from satosa.logging_util import satosa_logging +from satosa.response import Redirect + +import logging +import ldap3 +import urllib + +from ldap3.core.exceptions import LDAPException + +from . ldap_attribute_store import (LdapAttributeStore, + LdapAttributeStoreError) + +logger = logging.getLogger(__name__) + +class LdapAttributeStoreNoPool(LdapAttributeStore): + """ + Use identifier provided by the backend authentication service + to lookup a person record in LDAP and obtain attributes + to assert about the user to the frontend receiving service. + """ + + def _ldap_connection_factory(self, config): + """ + Use the input configuration to instantiate and return + a ldap3 Connection object. + """ + ldap_url = config['ldap_url'] + bind_dn = config['bind_dn'] + bind_password = config['bind_password'] + + if not ldap_url: + raise LdapAttributeStoreError("ldap_url is not configured") + if not bind_dn: + raise LdapAttributeStoreError("bind_dn is not configured") + if not bind_password: + raise LdapAttributeStoreError("bind_password is not configured") + + server = ldap3.Server(config['ldap_url']) + + satosa_logging(logger, logging.DEBUG, "Creating a new LDAP connection", None) + satosa_logging(logger, logging.DEBUG, "Using LDAP URL {}".format(ldap_url), None) + satosa_logging(logger, logging.DEBUG, "Using bind DN {}".format(bind_dn), None) + + try: + connection = ldap3.Connection( + server, + bind_dn, + bind_password, + auto_bind=False, # creates anonymous session open and bound to the server with a synchronous communication strategy + client_strategy=ldap3.RESTARTABLE, + read_only=True, + version=3) + satosa_logging(logger, logging.DEBUG, "Successfully connected to LDAP server", None) + + except LDAPException as e: + msg = "Caught exception when connecting to LDAP server: {}".format(e) + satosa_logging(logger, logging.ERROR, msg, None) + raise LdapAttributeStoreError(msg) + + return connection + + def process(self, context, data): + """ + Default interface for microservices. Process the input data for + the input context. + """ + self.context = context + + # Find the entityID for the SP that initiated the flow. + try: + sp_entity_id = context.state.state_dict['SATOSA_BASE']['requester'] + except KeyError as err: + satosa_logging(logger, logging.ERROR, "Unable to determine the entityID for the SP requester", context.state) + return super().process(context, data) + + satosa_logging(logger, logging.DEBUG, "entityID for the SP requester is {}".format(sp_entity_id), context.state) + + # Get the configuration for the SP. + if sp_entity_id in self.config.keys(): + config = self.config[sp_entity_id] + else: + config = self.config['default'] + + satosa_logging(logger, logging.DEBUG, "Using config {}".format(self._filter_config(config)), context.state) + + # Ignore this SP entirely if so configured. + if config['ignore']: + satosa_logging(logger, logging.INFO, "Ignoring SP {}".format(sp_entity_id), None) + return super().process(context, data) + + # The list of values for the LDAP search filters that will be tried in order to find the + # LDAP directory record for the user. + filter_values = [] + + # Loop over the configured list of identifiers from the IdP to consider and find + # asserted values to construct the ordered list of values for the LDAP search filters. + for candidate in config['ordered_identifier_candidates']: + value = self._construct_filter_value(candidate, data) + # If we have constructed a non empty value then add it as the next filter value + # to use when searching for the user record. + if value: + filter_values.append(value) + satosa_logging(logger, logging.DEBUG, "Added search filter value {} to list of search filters".format(value), context.state) + + # Initialize an empty LDAP record. The first LDAP record found using the ordered + # list of search filter values will be the record used. + record = None + results = None + exp_msg = '' + + for filter_val in filter_values: + connection = config['connection'] + search_filter = '({0}={1})'.format(config['ldap_identifier_attribute'], filter_val) + # show ldap filter + satosa_logging(logger, logging.INFO, "LDAP query for {}".format(search_filter), context.state) + satosa_logging(logger, logging.DEBUG, "Constructed search filter {}".format(search_filter), context.state) + + try: + # message_id only works in REUSABLE async connection strategy + results = connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys()) + except LDAPException as err: + exp_msg = "Caught LDAP exception: {}".format(err) + except LdapAttributeStoreError as err: + exp_msg = "Caught LDAP Attribute Store exception: {}".format(err) + except Exception as err: + exp_msg = "Caught unhandled exception: {}".format(err) + + if exp_msg: + satosa_logging(logger, logging.ERROR, exp_msg, context.state) + return super().process(context, data) + + if not results: + satosa_logging(logger, logging.DEBUG, "Querying LDAP server: Nop results for {}.".format(filter_val), context.state) + continue + responses = connection.entries + + satosa_logging(logger, logging.DEBUG, "Done querying LDAP server", context.state) + satosa_logging(logger, logging.DEBUG, "LDAP server returned {} records".format(len(responses)), context.state) + + # for now consider only the first record found (if any) + if len(responses) > 0: + if len(responses) > 1: + satosa_logging(logger, logging.WARN, "LDAP server returned {} records using search filter value {}".format(len(responses), filter_val), context.state) + record = responses[0] + break + + # Before using a found record, if any, to populate attributes + # clear any attributes incoming to this microservice if so configured. + if config['clear_input_attributes']: + satosa_logging(logger, logging.DEBUG, "Clearing values for these input attributes: {}".format(data.attributes), context.state) + data.attributes = {} + + # this adapts records with different search and conenction strategy (sync without pool) + r = dict() + r['dn'] = record.entry_dn + r['attributes'] = record.entry_attributes_as_dict + record = r + # ends adaptation + + # Use a found record, if any, to populate attributes and input for NameID + if record: + satosa_logging(logger, logging.DEBUG, "Using record with DN {}".format(record["dn"]), context.state) + satosa_logging(logger, logging.DEBUG, "Record with DN {} has attributes {}".format(record["dn"], record["attributes"]), context.state) + + # Populate attributes as configured. + self._populate_attributes(config, record, context, data) + + # Populate input for NameID if configured. SATOSA core does the hashing of input + # to create a persistent NameID. + self._populate_input_for_name_id(config, record, context, data) + + else: + satosa_logging(logger, logging.WARN, "No record found in LDAP so no attributes will be added", context.state) + on_ldap_search_result_empty = config['on_ldap_search_result_empty'] + if on_ldap_search_result_empty: + # Redirect to the configured URL with + # the entityIDs for the target SP and IdP used by the user + # as query string parameters (URL encoded). + encoded_sp_entity_id = urllib.parse.quote_plus(sp_entity_id) + encoded_idp_entity_id = urllib.parse.quote_plus(data.auth_info.issuer) + url = "{}?sp={}&idp={}".format(on_ldap_search_result_empty, encoded_sp_entity_id, encoded_idp_entity_id) + satosa_logging(logger, logging.INFO, "Redirecting to {}".format(url), context.state) + return Redirect(url) + + satosa_logging(logger, logging.DEBUG, "Returning data.attributes {}".format(str(data.attributes)), context.state) + return ResponseMicroService.process(self, context, data) From 7d782c4c7409963021af39c59d3da7d27fcf25e0 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Mon, 17 Jun 2019 11:51:30 +0200 Subject: [PATCH 003/401] Refactore following https://github.com/IdentityPython/SATOSA/pull/240#pullrequestreview-249964366 --- .../ldap_attribute_store.yaml.example | 14 +- .../micro_services/ldap_attribute_store.py | 120 +++++++---- .../ldap_attribute_store_no_pool.py | 194 ------------------ 3 files changed, 87 insertions(+), 241 deletions(-) delete mode 100644 src/satosa/micro_services/ldap_attribute_store_no_pool.py diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 36d4ff637..f392d115c 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -6,6 +6,15 @@ config: bind_dn: cn=admin,dc=example,dc=org bind_password: xxxxxxxx search_base: ou=People,dc=example,dc=org + read_only : true + version : 3 + + # see ldap3 client_strategies + client_strategy : RESTARTABLE + auto_bind : true + pool_size : 10 + pool_keepalive : 10 + search_return_attributes: # Format is LDAP attribute name : internal attribute name sn: surname @@ -20,10 +29,10 @@ config: pool_keepalive: 10 ordered_identifier_candidates: # Ordered list of identifiers to use when constructing the - # search filter to find the user record in LDAP directory. + # search filter to find the user record in LDAP directory. # This example searches in order for eduPersonUniqueId, eduPersonPrincipalName # combined with SAML persistent NameID, eduPersonPrincipalName - # combined with eduPersonTargetedId, eduPersonPrincipalName, + # combined with eduPersonTargetedId, eduPersonPrincipalName, # SAML persistent NameID, and eduPersonTargetedId. - attribute_names: [epuid] - attribute_names: [eppn, name_id] @@ -62,4 +71,3 @@ config: # The microservice may be configured to ignore a particular SP. https://another.sp.myserver.edu: ignore: true - diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index a409a289e..b28c16076 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -5,7 +5,7 @@ the record and assert them to the receiving SP. """ -import satosa.micro_services.base +from satosa.micro_services.base import ResponseMicroService from satosa.logging_util import satosa_logging from satosa.response import Redirect from satosa.exception import SATOSAError @@ -17,15 +17,18 @@ from ldap3.core.exceptions import LDAPException + logger = logging.getLogger(__name__) + class LdapAttributeStoreError(SATOSAError): """ LDAP attribute store error """ pass -class LdapAttributeStore(satosa.micro_services.base.ResponseMicroService): + +class LdapAttributeStore(ResponseMicroService): """ Use identifier provided by the backend authentication service to lookup a person record in LDAP and obtain attributes @@ -41,11 +44,15 @@ class LdapAttributeStore(satosa.micro_services.base.ResponseMicroService): 'ldap_url' : None, 'on_ldap_search_result_empty' : None, 'ordered_identifier_candidates' : None, - 'pool_size' : 10, - 'pool_keepalive' : 10, 'search_base' : None, 'search_return_attributes' : None, - 'user_id_from_attrs' : [] + 'user_id_from_attrs' : [], + 'read_only' : True, + 'version' : 3, + 'auto_bind' : False, + 'client_strategy' : ldap3.RESTARTABLE, + 'pool_size' : 10, + 'pool_keepalive' : 10, } def __init__(self, config, *args, **kwargs): @@ -80,7 +87,8 @@ def __init__(self, config, *args, **kwargs): # Initialize configuration using module defaults then update # with configuration defaults and then per-SP overrides. - sp_config = copy.deepcopy(LdapAttributeStore.config_defaults) + # sp_config = copy.deepcopy(LdapAttributeStore.config_defaults) + sp_config = copy.deepcopy(self.config_defaults) if 'default' in self.config: sp_config.update(self.config['default']) sp_config.update(config[sp]) @@ -247,27 +255,35 @@ def _ldap_connection_factory(self, config): if not bind_password: raise LdapAttributeStoreError("bind_password is not configured") - pool_size = config['pool_size'] - pool_keepalive = config['pool_keepalive'] - server = ldap3.Server(config['ldap_url']) satosa_logging(logger, logging.DEBUG, "Creating a new LDAP connection", None) satosa_logging(logger, logging.DEBUG, "Using LDAP URL {}".format(ldap_url), None) satosa_logging(logger, logging.DEBUG, "Using bind DN {}".format(bind_dn), None) + + pool_size = config['pool_size'] + pool_keepalive = config['pool_keepalive'] satosa_logging(logger, logging.DEBUG, "Using pool size {}".format(pool_size), None) satosa_logging(logger, logging.DEBUG, "Using pool keep alive {}".format(pool_keepalive), None) + auto_bind = config['auto_bind'] + client_strategy = config['client_strategy'] + read_only = config['read_only'] + version = config['version'] + try: connection = ldap3.Connection( server, bind_dn, bind_password, - auto_bind=True, - client_strategy=ldap3.REUSABLE, + auto_bind=auto_bind, + client_strategy=client_strategy, + read_only=read_only, + version=version, pool_size=pool_size, pool_keepalive=pool_keepalive - ) + ) + satosa_logging(logger, logging.DEBUG, "Successfully connected to LDAP server", None) except LDAPException as e: msg = "Caught exception when connecting to LDAP server: {}".format(e) @@ -400,47 +416,63 @@ def process(self, context, data): # Initialize an empty LDAP record. The first LDAP record found using the ordered # list of search filter values will be the record used. record = None + results = None + exp_msg = None - try: + for filter_val in filter_values: connection = config['connection'] - - for filter_val in filter_values: - if record: - break - - search_filter = '({0}={1})'.format(config['ldap_identifier_attribute'], filter_val) - satosa_logging(logger, logging.DEBUG, "Constructed search filter {}".format(search_filter), context.state) - - satosa_logging(logger, logging.DEBUG, "Querying LDAP server...", context.state) - message_id = connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys()) - responses = connection.get_response(message_id)[0] - satosa_logging(logger, logging.DEBUG, "Done querying LDAP server", context.state) - satosa_logging(logger, logging.DEBUG, "LDAP server returned {} records".format(len(responses)), context.state) - - # for now consider only the first record found (if any) - if len(responses) > 0: - if len(responses) > 1: - satosa_logging(logger, logging.WARN, "LDAP server returned {} records using search filter value {}".format(len(responses), filter_val), context.state) - record = responses[0] - break - except LDAPException as err: - satosa_logging(logger, logging.ERROR, "Caught LDAP exception: {}".format(err), context.state) - except LdapAttributeStoreError as err: - satosa_logging(logger, logging.ERROR, "Caught LDAP Attribute Store exception: {}".format(err), context.state) - except Exception as err: - satosa_logging(logger, logging.ERROR, "Caught unhandled exception: {}".format(err), context.state) - else: - err = None - finally: - if err: + search_filter = '({0}={1})'.format(config['ldap_identifier_attribute'], filter_val) + # show ldap filter + satosa_logging(logger, logging.INFO, "LDAP query for {}".format(search_filter), context.state) + satosa_logging(logger, logging.DEBUG, "Constructed search filter {}".format(search_filter), context.state) + + try: + # message_id only works in REUSABLE async connection strategy + results = connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys()) + except LDAPException as err: + exp_msg = "Caught LDAP exception: {}".format(err) + except LdapAttributeStoreError as err: + exp_msg = "Caught LDAP Attribute Store exception: {}".format(err) + except Exception as err: + exp_msg = "Caught unhandled exception: {}".format(err) + + if exp_msg: + satosa_logging(logger, logging.ERROR, exp_msg, context.state) return super().process(context, data) + if not results: + satosa_logging(logger, logging.DEBUG, "Querying LDAP server: Nop results for {}.".format(filter_val), context.state) + continue + + if isinstance(results, bool): + responses = connection.entries + else: + responses = connection.get_response(results)[0] + + satosa_logging(logger, logging.DEBUG, "Done querying LDAP server", context.state) + satosa_logging(logger, logging.DEBUG, "LDAP server returned {} records".format(len(responses)), context.state) + + # for now consider only the first record found (if any) + if len(responses) > 0: + if len(responses) > 1: + satosa_logging(logger, logging.WARN, "LDAP server returned {} records using search filter value {}".format(len(responses), filter_val), context.state) + record = responses[0] + break + # Before using a found record, if any, to populate attributes # clear any attributes incoming to this microservice if so configured. if config['clear_input_attributes']: satosa_logging(logger, logging.DEBUG, "Clearing values for these input attributes: {}".format(data.attributes), context.state) data.attributes = {} + # this adapts records with different search and connection strategy (sync without pool), it should be tested with anonimous bind with message_id + if isinstance(results, bool): + drec = dict() + drec['dn'] = record.entry_dn if hasattr(record, 'entry_dn') else '' + drec['attributes'] = record.entry_attributes_as_dict if hasattr(record, 'entry_attributes_as_dict') else {} + record = drec + # ends adaptation + # Use a found record, if any, to populate attributes and input for NameID if record: satosa_logging(logger, logging.DEBUG, "Using record with DN {}".format(record["dn"]), context.state) @@ -467,4 +499,4 @@ def process(self, context, data): return Redirect(url) satosa_logging(logger, logging.DEBUG, "Returning data.attributes {}".format(str(data.attributes)), context.state) - return super().process(context, data) + return ResponseMicroService.process(self, context, data) diff --git a/src/satosa/micro_services/ldap_attribute_store_no_pool.py b/src/satosa/micro_services/ldap_attribute_store_no_pool.py deleted file mode 100644 index fd0c7c234..000000000 --- a/src/satosa/micro_services/ldap_attribute_store_no_pool.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -SATOSA microservice that uses an identifier asserted by -the home organization SAML IdP as a key to search an LDAP -directory for a record and then consume attributes from -the record and assert them to the receiving SP. -""" - -from satosa.micro_services.base import ResponseMicroService -from satosa.logging_util import satosa_logging -from satosa.response import Redirect - -import logging -import ldap3 -import urllib - -from ldap3.core.exceptions import LDAPException - -from . ldap_attribute_store import (LdapAttributeStore, - LdapAttributeStoreError) - -logger = logging.getLogger(__name__) - -class LdapAttributeStoreNoPool(LdapAttributeStore): - """ - Use identifier provided by the backend authentication service - to lookup a person record in LDAP and obtain attributes - to assert about the user to the frontend receiving service. - """ - - def _ldap_connection_factory(self, config): - """ - Use the input configuration to instantiate and return - a ldap3 Connection object. - """ - ldap_url = config['ldap_url'] - bind_dn = config['bind_dn'] - bind_password = config['bind_password'] - - if not ldap_url: - raise LdapAttributeStoreError("ldap_url is not configured") - if not bind_dn: - raise LdapAttributeStoreError("bind_dn is not configured") - if not bind_password: - raise LdapAttributeStoreError("bind_password is not configured") - - server = ldap3.Server(config['ldap_url']) - - satosa_logging(logger, logging.DEBUG, "Creating a new LDAP connection", None) - satosa_logging(logger, logging.DEBUG, "Using LDAP URL {}".format(ldap_url), None) - satosa_logging(logger, logging.DEBUG, "Using bind DN {}".format(bind_dn), None) - - try: - connection = ldap3.Connection( - server, - bind_dn, - bind_password, - auto_bind=False, # creates anonymous session open and bound to the server with a synchronous communication strategy - client_strategy=ldap3.RESTARTABLE, - read_only=True, - version=3) - satosa_logging(logger, logging.DEBUG, "Successfully connected to LDAP server", None) - - except LDAPException as e: - msg = "Caught exception when connecting to LDAP server: {}".format(e) - satosa_logging(logger, logging.ERROR, msg, None) - raise LdapAttributeStoreError(msg) - - return connection - - def process(self, context, data): - """ - Default interface for microservices. Process the input data for - the input context. - """ - self.context = context - - # Find the entityID for the SP that initiated the flow. - try: - sp_entity_id = context.state.state_dict['SATOSA_BASE']['requester'] - except KeyError as err: - satosa_logging(logger, logging.ERROR, "Unable to determine the entityID for the SP requester", context.state) - return super().process(context, data) - - satosa_logging(logger, logging.DEBUG, "entityID for the SP requester is {}".format(sp_entity_id), context.state) - - # Get the configuration for the SP. - if sp_entity_id in self.config.keys(): - config = self.config[sp_entity_id] - else: - config = self.config['default'] - - satosa_logging(logger, logging.DEBUG, "Using config {}".format(self._filter_config(config)), context.state) - - # Ignore this SP entirely if so configured. - if config['ignore']: - satosa_logging(logger, logging.INFO, "Ignoring SP {}".format(sp_entity_id), None) - return super().process(context, data) - - # The list of values for the LDAP search filters that will be tried in order to find the - # LDAP directory record for the user. - filter_values = [] - - # Loop over the configured list of identifiers from the IdP to consider and find - # asserted values to construct the ordered list of values for the LDAP search filters. - for candidate in config['ordered_identifier_candidates']: - value = self._construct_filter_value(candidate, data) - # If we have constructed a non empty value then add it as the next filter value - # to use when searching for the user record. - if value: - filter_values.append(value) - satosa_logging(logger, logging.DEBUG, "Added search filter value {} to list of search filters".format(value), context.state) - - # Initialize an empty LDAP record. The first LDAP record found using the ordered - # list of search filter values will be the record used. - record = None - results = None - exp_msg = '' - - for filter_val in filter_values: - connection = config['connection'] - search_filter = '({0}={1})'.format(config['ldap_identifier_attribute'], filter_val) - # show ldap filter - satosa_logging(logger, logging.INFO, "LDAP query for {}".format(search_filter), context.state) - satosa_logging(logger, logging.DEBUG, "Constructed search filter {}".format(search_filter), context.state) - - try: - # message_id only works in REUSABLE async connection strategy - results = connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys()) - except LDAPException as err: - exp_msg = "Caught LDAP exception: {}".format(err) - except LdapAttributeStoreError as err: - exp_msg = "Caught LDAP Attribute Store exception: {}".format(err) - except Exception as err: - exp_msg = "Caught unhandled exception: {}".format(err) - - if exp_msg: - satosa_logging(logger, logging.ERROR, exp_msg, context.state) - return super().process(context, data) - - if not results: - satosa_logging(logger, logging.DEBUG, "Querying LDAP server: Nop results for {}.".format(filter_val), context.state) - continue - responses = connection.entries - - satosa_logging(logger, logging.DEBUG, "Done querying LDAP server", context.state) - satosa_logging(logger, logging.DEBUG, "LDAP server returned {} records".format(len(responses)), context.state) - - # for now consider only the first record found (if any) - if len(responses) > 0: - if len(responses) > 1: - satosa_logging(logger, logging.WARN, "LDAP server returned {} records using search filter value {}".format(len(responses), filter_val), context.state) - record = responses[0] - break - - # Before using a found record, if any, to populate attributes - # clear any attributes incoming to this microservice if so configured. - if config['clear_input_attributes']: - satosa_logging(logger, logging.DEBUG, "Clearing values for these input attributes: {}".format(data.attributes), context.state) - data.attributes = {} - - # this adapts records with different search and conenction strategy (sync without pool) - r = dict() - r['dn'] = record.entry_dn - r['attributes'] = record.entry_attributes_as_dict - record = r - # ends adaptation - - # Use a found record, if any, to populate attributes and input for NameID - if record: - satosa_logging(logger, logging.DEBUG, "Using record with DN {}".format(record["dn"]), context.state) - satosa_logging(logger, logging.DEBUG, "Record with DN {} has attributes {}".format(record["dn"], record["attributes"]), context.state) - - # Populate attributes as configured. - self._populate_attributes(config, record, context, data) - - # Populate input for NameID if configured. SATOSA core does the hashing of input - # to create a persistent NameID. - self._populate_input_for_name_id(config, record, context, data) - - else: - satosa_logging(logger, logging.WARN, "No record found in LDAP so no attributes will be added", context.state) - on_ldap_search_result_empty = config['on_ldap_search_result_empty'] - if on_ldap_search_result_empty: - # Redirect to the configured URL with - # the entityIDs for the target SP and IdP used by the user - # as query string parameters (URL encoded). - encoded_sp_entity_id = urllib.parse.quote_plus(sp_entity_id) - encoded_idp_entity_id = urllib.parse.quote_plus(data.auth_info.issuer) - url = "{}?sp={}&idp={}".format(on_ldap_search_result_empty, encoded_sp_entity_id, encoded_idp_entity_id) - satosa_logging(logger, logging.INFO, "Redirecting to {}".format(url), context.state) - return Redirect(url) - - satosa_logging(logger, logging.DEBUG, "Returning data.attributes {}".format(str(data.attributes)), context.state) - return ResponseMicroService.process(self, context, data) From ebf9f5f51efbcba6b06f4136614aa675915709d5 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 9 Jul 2019 14:30:28 +0300 Subject: [PATCH 004/401] Improve stage now for new releases Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 662c2fea7..78fc92735 100644 --- a/.travis.yml +++ b/.travis.yml @@ -32,7 +32,7 @@ jobs: repo: IdentityPython/SATOSA branch: master - - stage: Deploy new version + - stage: Deploy new release script: skip deploy: - provider: releases From b888722d4c9c41e033691e23b5cd46b55e63ad7a Mon Sep 17 00:00:00 2001 From: peppelinux Date: Wed, 10 Jul 2019 10:05:00 +0200 Subject: [PATCH 005/401] [LDAP store] better logger info --- src/satosa/micro_services/ldap_attribute_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index b28c16076..51eb33e79 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -441,7 +441,7 @@ def process(self, context, data): return super().process(context, data) if not results: - satosa_logging(logger, logging.DEBUG, "Querying LDAP server: Nop results for {}.".format(filter_val), context.state) + satosa_logging(logger, logging.DEBUG, "Querying LDAP server: No results for {}.".format(filter_val), context.state) continue if isinstance(results, bool): @@ -450,7 +450,7 @@ def process(self, context, data): responses = connection.get_response(results)[0] satosa_logging(logger, logging.DEBUG, "Done querying LDAP server", context.state) - satosa_logging(logger, logging.DEBUG, "LDAP server returned {} records".format(len(responses)), context.state) + satosa_logging(logger, logging.INFO, "LDAP server returned {} records".format(len(responses)), context.state) # for now consider only the first record found (if any) if len(responses) > 0: From ec7522a63090a4d2ac0f205ff47d987ec19c892c Mon Sep 17 00:00:00 2001 From: rhoerbe Date: Mon, 3 Jun 2019 11:52:34 +0200 Subject: [PATCH 006/401] fix minor bug in ModuleRouter/configuration check --- src/satosa/routing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/routing.py b/src/satosa/routing.py index c0b12fa84..babd76ffd 100644 --- a/src/satosa/routing.py +++ b/src/satosa/routing.py @@ -50,7 +50,7 @@ def __init__(self, frontends, backends, micro_services): module """ - if not frontends and not backends: + if not frontends or not backends: raise ValueError("Need at least one frontend and one backend") backend_names = [backend.name for backend in backends] From e64bbcfe74d539e06de923303214f758b2d28b23 Mon Sep 17 00:00:00 2001 From: rhoerbe Date: Sat, 13 Jul 2019 18:59:15 +0200 Subject: [PATCH 007/401] add sequence diagrams for SAML-to-SAML operation --- doc/internals/authnrequ_flow.png | Bin 0 -> 204997 bytes doc/internals/authnrequ_flow.src | 50 ++++++++++++++++++++++++++ doc/internals/authnrequ_state.png | Bin 0 -> 51753 bytes doc/internals/authnresp_flow.png | Bin 0 -> 105901 bytes doc/internals/authnresp_flow.src | 57 ++++++++++++++++++++++++++++++ doc/internals/authnresp_state.png | Bin 0 -> 47402 bytes doc/internals/authnresp_state.src | 32 +++++++++++++++++ doc/internals/init_sequence.png | Bin 0 -> 52387 bytes doc/internals/init_sequence.src | 20 +++++++++++ 9 files changed, 159 insertions(+) create mode 100644 doc/internals/authnrequ_flow.png create mode 100644 doc/internals/authnrequ_flow.src create mode 100644 doc/internals/authnrequ_state.png create mode 100644 doc/internals/authnresp_flow.png create mode 100644 doc/internals/authnresp_flow.src create mode 100644 doc/internals/authnresp_state.png create mode 100644 doc/internals/authnresp_state.src create mode 100644 doc/internals/init_sequence.png create mode 100644 doc/internals/init_sequence.src diff --git a/doc/internals/authnrequ_flow.png b/doc/internals/authnrequ_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..e116a23f77b6cb4d857e37443bc9b786b07a9c4b GIT binary patch literal 204997 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 Ef12RpDgXcg literal 0 HcmV?d00001 diff --git a/doc/internals/authnrequ_flow.src b/doc/internals/authnrequ_flow.src new file mode 100644 index 000000000..4131581a3 --- /dev/null +++ b/doc/internals/authnrequ_flow.src @@ -0,0 +1,50 @@ +# Render with https://www.websequencediagrams.com + +title SATOSA SAML Authn Request +# v3.4.8 + + +note right of Gunicorn: GET \n/saml2/sso/redirect +Gunicorn->+WsgiApplication\n(SATOSABase): __call__ +WsgiApplication\n(SATOSABase)->*Context: +WsgiApplication\n(SATOSABase)->WsgiApplication\n(SATOSABase): unpack_request()\n-> Context.request + +WsgiApplication\n(SATOSABase)->+WsgiApplication\n(SATOSABase): run(Context) +WsgiApplication\n(SATOSABase)->WsgiApplication\n(SATOSABase): _load_state(Context) +WsgiApplication\n(SATOSABase)->+ModuleRouter: endpoint_routing(context) +note right of ModuleRouter, SAMLFrontend\n(Frontendmodule): endpoint function=handle_authn_request +ModuleRouter-->-WsgiApplication\n(SATOSABase): endpoint function + +WsgiApplication\n(SATOSABase)-->+WsgiApplication\n(SATOSABase): _run_bound_endpoint(\nhandle_authn_request) +WsgiApplication\n(SATOSABase)->+SAMLFrontend\n(Frontendmodule): handle_authn_request +SAMLFrontend\n(Frontendmodule)->+SAMLFrontend\n(Frontendmodule): _handle_authn_request +SAMLFrontend\n(Frontendmodule)->SAMLFrontend\n(Frontendmodule): _create_state_data +SAMLFrontend\n(Frontendmodule)->*InternalData: +SAMLFrontend\n(Frontendmodule)->SAMLFrontend\n(Frontendmodule): _get_approved_attributes +SAMLFrontend\n(Frontendmodule)->+WsgiApplication\n(SATOSABase): auth_req_callback_func + +note over ModuleRouter, SAMLBackend + Incorrect notation: Looping over Request Micro Services is in fact a recursive design: + Each microservice calls the next in the list, and the last one calls _auth_req_finish(). +end note +loop for all Request Micro Services + WsgiApplication\n(SATOSABase)->+Instances of \nRequestMicroService: process + Instances of \nRequestMicroService->+WsgiApplication\n(SATOSABase): _auth_req_finish + WsgiApplication\n(SATOSABase)->+ModuleRouter:backend_routing + ModuleRouter-->-WsgiApplication\n(SATOSABase): Backend + WsgiApplication\n(SATOSABase)->+SAMLBackend: start_auth + SAMLBackend->+SAMLBackend: authn_request + SAMLBackend->*SeeOther\n(Response): + SAMLBackend-->-SAMLBackend: + SAMLBackend-->-WsgiApplication\n(SATOSABase): + WsgiApplication\n(SATOSABase)-->-Instances of \nRequestMicroService: + Instances of \nRequestMicroService-->-WsgiApplication\n(SATOSABase): +end +WsgiApplication\n(SATOSABase)-->-SAMLFrontend\n(Frontendmodule): + +SAMLFrontend\n(Frontendmodule)-->-SAMLFrontend\n(Frontendmodule): +SAMLFrontend\n(Frontendmodule)-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->WsgiApplication\n(SATOSABase): _save_state(Context) +WsgiApplication\n(SATOSABase)-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)-->-Gunicorn: diff --git a/doc/internals/authnrequ_state.png b/doc/internals/authnrequ_state.png new file mode 100644 index 0000000000000000000000000000000000000000..d25e67a27b3cf7c768b8640c09564fbf562a5e20 GIT binary patch literal 51753 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�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: preceding\nAuthnRequest +Gunicorn->+WsgiApplication\n(SATOSABase): __call__ +WsgiApplication\n(SATOSABase)->*Context: +WsgiApplication\n(SATOSABase)->WsgiApplication\n(SATOSABase): unpack_request() +WsgiApplication\n(SATOSABase)->+WsgiApplication\n(SATOSABase): run(Context) +WsgiApplication\n(SATOSABase)->+WsgiApplication\n(SATOSABase): _load_state(Context) + SATOSA_STATE-->WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->+ModuleRouter: endpoint_routing(context) -> endpoint +ModuleRouter-->-WsgiApplication\n(SATOSABase): authn_response + +WsgiApplication\n(SATOSABase)-->+WsgiApplication\n(SATOSABase): _run_bound_endpoint\n(\authn_response) +WsgiApplication\n(SATOSABase)->+SAMLBackend\n(Backendmodule): authn_response +SAMLBackend\n(Backendmodule)->+SAMLBackend\n(Backendmodule): _translate_response +SAMLBackend\n(Backendmodule)->SAMLBackend\n(Backendmodule): saml2.sigver.\n_check_signature +SAMLBackend\n(Backendmodule)->*InternalData: +SAMLBackend\n(Backendmodule)-->-SAMLBackend\n(Backendmodule): +SAMLBackend\n(Backendmodule)->+WsgiApplication\n(SATOSABase): _auth_resp_callback_func + +note over Context, SAMLFrontend + Incorrect notation: Looping over Response Micro Services is in fact a recursive design: + Each microservice calls the next in the list, and the last one calls _handle_authn_response(). +end note +loop for all Response Micro Services + WsgiApplication\n(SATOSABase)->+Instances of \nRequestMicroService: process + Instances of \nRequestMicroService->+WsgiApplication\n(SATOSABase): _auth_resp_finish + WsgiApplication\n(SATOSABase)->+ModuleRouter:frontend_routing + ModuleRouter-->-WsgiApplication\n(SATOSABase): Frontend + WsgiApplication\n(SATOSABase)->+SAMLFrontend: handle_authn_response + SAMLFrontend->+SAMLFrontend: _handle_authn_response + SAMLFrontend->SAMLFrontend: load_state + SAMLFrontend->+SAMLFrontend: _get_approved_attributes + SAMLFrontend->+SAMLFrontend: _filter_attributes + SAMLFrontend->*Response: + SAMLFrontend-->-SAMLFrontend: + SAMLFrontend-->-SAMLFrontend: Response + SAMLFrontend-->-WsgiApplication\n(SATOSABase): Response + WsgiApplication\n(SATOSABase)-->-SAMLFrontend: Response + SAMLFrontend-->-WsgiApplication\n(SATOSABase): Response + WsgiApplication\n(SATOSABase)-->-Instances of \nRequestMicroService: Response + Instances of \nRequestMicroService-->-WsgiApplication\n(SATOSABase): Response +end + +WsgiApplication\n(SATOSABase)-->-SAMLBackend\n(Backendmodule): +SAMLBackend\n(Backendmodule)-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->+WsgiApplication\n(SATOSABase): _save_state(Context) + destroy SATOSA_STATE +WsgiApplication\n(SATOSABase)-->WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)-->-Gunicorn: + diff --git a/doc/internals/authnresp_state.png b/doc/internals/authnresp_state.png new file mode 100644 index 0000000000000000000000000000000000000000..67faac790ff380d85850a88f4b86da2be662e346 GIT binary patch literal 47402 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 z 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 literal 0 HcmV?d00001 diff --git a/doc/internals/authnresp_state.src b/doc/internals/authnresp_state.src new file mode 100644 index 000000000..5bdf19431 --- /dev/null +++ b/doc/internals/authnresp_state.src @@ -0,0 +1,32 @@ +# Render with https://www.websequencediagrams.com + +title SATOSA SAML Authn Response (focus on SATOSA_STATE) +# v3.4.8 + +note right of Gunicorn: GET \nsaml2/acs/post +Gunicorn->*SATOSA_STATE: preceding\nAuthnRequest +Gunicorn->+WsgiApplication\n(SATOSABase): __call__ +WsgiApplication\n(SATOSABase)->*Context: +WsgiApplication\n(SATOSABase)->WsgiApplication\n(SATOSABase): unpack_request() +WsgiApplication\n(SATOSABase)->+WsgiApplication\n(SATOSABase): run(Context) +WsgiApplication\n(SATOSABase)->+WsgiApplication\n(SATOSABase): _load_state(Context) + SATOSA_STATE-->WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)-->-WsgiApplication\n(SATOSABase): + +WsgiApplication\n(SATOSABase)->+SAMLBackend\n(Backendmodule): authn_response +SAMLBackend\n(Backendmodule)-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->+WsgiApplication\n(SATOSABase): _save_state(Context) + note over WsgiApplication\n(SATOSABase), WsgiApplication\n(SATOSABase) + PR #234 proposes to keep STATOS_STATE \nif CONTEXT_STATE_DELETE + end note + destroy SATOSA_STATE +WsgiApplication\n(SATOSABase)-->WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)-->-Gunicorn: + + +note over A,B: text1 +note left of A: text2 +note right of A + multiline + text +end note diff --git a/doc/internals/init_sequence.png b/doc/internals/init_sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..ff08c170dcb83875ee2449b18f38759832d6ea87 GIT binary patch literal 52387 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 FzX8=cL;e5& literal 0 HcmV?d00001 diff --git a/doc/internals/init_sequence.src b/doc/internals/init_sequence.src new file mode 100644 index 000000000..4cc869264 --- /dev/null +++ b/doc/internals/init_sequence.src @@ -0,0 +1,20 @@ +# Render with https://www.websequencediagrams.com + +title SATOSA Initialization Sequence +# v3.4.8 + +wsgi.py->*SATOSAConfig: +wsgi.py->*WsgiApplication\n(SATOSABase): proxy_server.\nmake_app(SATOSAConfig) +WsgiApplication\n(SATOSABase)->+plugin_loader: load_backends(SATOSAConfig, \n_auth_resp_callback_func, internal_attributes) +plugin_loader->*SAMLBackend: +plugin_loader-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->+plugin_loader: load_frontends() +plugin_loader->*SAMLFrontend: +plugin_loader-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->+plugin_loader: load_request_microservices() +plugin_loader->*RequestMicroservice: +plugin_loader-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->+plugin_loader: load_response_microservices() +plugin_loader->*ResponseMicroservice: +plugin_loader-->-WsgiApplication\n(SATOSABase): +WsgiApplication\n(SATOSABase)->*ModuleRouter: From ec4bd2291bf15b2a0150a7b2342a5f401b9cefc7 Mon Sep 17 00:00:00 2001 From: rhoerbe Date: Sun, 14 Jul 2019 21:52:08 +0200 Subject: [PATCH 008/401] fix misspelled/misleading debug message --- src/satosa/micro_services/custom_routing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index 7a488e626..b67d908a2 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -88,6 +88,6 @@ def process(self, context, data): data.requester, target_entity_id, allow_rules) return super().process(context, data) - logging.debug("Requester '%s' is not allowed by target entity '%s' due to no deny all rule in '%s'", + logger.debug("Requester '%s' is not allowed by target entity '%s' due to final deny all rule in '%s'", data.requester, target_entity_id, deny_rules) raise SATOSAError("Requester is not allowed by target provider") From 032ebeccbee260bb1f3a40a745ce2c8f8b983230 Mon Sep 17 00:00:00 2001 From: Christos Kanellopoulos Date: Sun, 28 Jul 2019 20:58:25 +0300 Subject: [PATCH 009/401] Introduce three configuration options for the Facebook OAuth backend Introduce three configuration options for the Facebook backend The Facebook backend does not request any scopes from Facebook. As a result, the default scope (public_profile) is assumed and the user is asked to give permission only for that. This has the effect that even though in the query to the Facebook graph API we might be asking for the user's e-mail, the graph API will return only the user data that are part of the public profile. Furthermore, if a user has given permission for some scopes, then the the user response is remembered by Facebook and even if the application changes its configuration and requests for more scopes, Facebook will not take this into account, unless the 'auth_type' parameter is set to 'rerequest' This commit introduces three new configuration options: - 'auth_type' expects list of auth_types. If none is provided, then the 'auth_type' parameter will not be sent to the authorization_endpoint. - 'scope' expects a list of scopes. If none is provided, then the scope element will not be added and 'public_profile' will be assumed (as it happens now) - 'graph_endpoint' expects the endpoint for the Facebook graph API. Up to now the value of the graph_endpoint was hardcoded in the actual code of the backend. In order to retain backwards compatibility if the configuration option for the 'graph_endpoint' is not set, then it is assumed to have the value that used to be hardcoded. --- .../backends/facebook_backend.yaml.example | 9 +++- src/satosa/backends/oauth.py | 54 +++++++++++++------ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/example/plugins/backends/facebook_backend.yaml.example b/example/plugins/backends/facebook_backend.yaml.example index b5a410bb0..645bb3089 100644 --- a/example/plugins/backends/facebook_backend.yaml.example +++ b/example/plugins/backends/facebook_backend.yaml.example @@ -5,9 +5,16 @@ config: base_url: client_config: client_id: + # See https://developers.facebook.com/docs/facebook-login for + # information on valid values for auth_type + auth_type: [] + scope: [public_profile, email] fields: [id, name, first_name, last_name, middle_name, picture, email, verified, gender, timezone, locale, updated_time] response_type: code - server_info: {authorization_endpoint: 'https://www.facebook.com/dialog/oauth', token_endpoint: 'https://graph.facebook.com/v2.5/oauth/access_token'} + server_info: + authorization_endpoint: 'https://www.facebook.com/dialog/oauth' + token_endpoint: 'https://graph.facebook.com/v3.3/oauth/access_token' + graph_endpoint: 'https://graph.facebook.com/v3.3/me' entity_info: organization: display_name: diff --git a/src/satosa/backends/oauth.py b/src/satosa/backends/oauth.py index aabc1c0e0..9136ce6d4 100644 --- a/src/satosa/backends/oauth.py +++ b/src/satosa/backends/oauth.py @@ -74,15 +74,19 @@ def start_auth(self, context, internal_request, get_state=stateID): :type internal_request: satosa.internal.InternalData :rtype satosa.response.Redirect """ - oauth_state = get_state(self.config["base_url"], rndstr().encode()) - - state_data = dict(state=oauth_state) - context.state[self.name] = state_data - - request_args = {"redirect_uri": self.redirect_url, "state": oauth_state} + request_args = self.get_request_args(get_state=get_state) + context.state[self.name] = {"state": request_args["state"]} cis = self.consumer.construct_AuthorizationRequest(request_args=request_args) return Redirect(cis.request(self.consumer.authorization_endpoint)) + def get_request_args(self, get_state=stateID): + oauth_state = get_state(self.config["base_url"], rndstr().encode()) + request_args = { + "redirect_uri": self.redirect_url, + "state": oauth_state, + } + return request_args + def register_endpoints(self): """ Creates a list of all the endpoints this backend module needs to listen to. In this case @@ -178,6 +182,12 @@ class FacebookBackend(_OAuthBackend): Backend module for facebook. """ + """ + The default graph endpoint is for backward compatibility with previous versions of the + Facebook backend in which the graph endpoint was hardcoded in the code. + """ + DEFAULT_GRAPH_ENDPOINT = "https://graph.facebook.com/v2.5/me" + def __init__(self, outgoing, internal_attributes, config, base_url, name): """ Constructor. @@ -201,6 +211,20 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): config["verify_accesstoken_state"] = False super().__init__(outgoing, internal_attributes, config, base_url, name, "facebook", "id") + def get_request_args(self, get_state=stateID): + request_args = super().get_request_args(get_state=get_state) + + client_id = self.config["client_config"]["client_id"] + extra_args = { + arg_name: arg_val + for arg_name in ["auth_type", "scope"] + for arg_val in [self.config.get(arg_name, [])] + if arg_val + } + extra_args.update({"client_id": client_id}) + request_args.update(extra_args) + return request_args + def auth_info(self, request): """ Creates the SATOSA authentication information object. @@ -224,8 +248,8 @@ def user_information(self, access_token): :param access_token: The access token to be used to retrieve the data. :return: Dictionary with attribute name as key and attribute value as value. """ - payload = {'access_token': access_token} - url = "https://graph.facebook.com/v2.5/me" + payload = {"access_token": access_token} + url = self.config["server_info"].get("graph_endpoint", self.DEFAULT_GRAPH_ENDPOINT) if self.config["fields"]: payload["fields"] = ",".join(self.config["fields"]) resp = requests.get(url, params=payload) @@ -258,14 +282,14 @@ def get_metadata_desc_for_oauth_backend(entity_id, config): # Add contact person information for contact_person in entity_info.get("contact_person", []): person = ContactPersonDesc() - if 'contact_type' in contact_person: - person.contact_type = contact_person['contact_type'] - for address in contact_person.get('email_address', []): + if "contact_type" in contact_person: + person.contact_type = contact_person["contact_type"] + for address in contact_person.get("email_address", []): person.add_email_address(address) - if 'given_name' in contact_person: - person.given_name = contact_person['given_name'] - if 'sur_name' in contact_person: - person.sur_name = contact_person['sur_name'] + if "given_name" in contact_person: + person.given_name = contact_person["given_name"] + if "sur_name" in contact_person: + person.sur_name = contact_person["sur_name"] description.add_contact_person(person) From afa985cbdfcb724781befef2e79633bcc7f9deb4 Mon Sep 17 00:00:00 2001 From: Christos Kanellopoulos Date: Fri, 26 Jul 2019 20:57:04 +0200 Subject: [PATCH 010/401] Fixes the examples for the attribute_generation micro_service In the example the ordering of target_provider and requester was the reverse from what is used in the code. --- .../microservices/attribute_generation.yaml.example | 4 ++-- src/satosa/micro_services/attribute_generation.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/example/plugins/microservices/attribute_generation.yaml.example b/example/plugins/microservices/attribute_generation.yaml.example index 44b5e208b..a1c65c91b 100644 --- a/example/plugins/microservices/attribute_generation.yaml.example +++ b/example/plugins/microservices/attribute_generation.yaml.example @@ -2,8 +2,8 @@ module: satosa.micro_services.attribute_generation.AddSyntheticAttributes name: AddSyntheticAttributes config: synthetic_attributes: - target_provider1: - requester1: + requester1: + target_provider1: eduPersonAffiliation: member;employee default: default: diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index 57ee8dd08..485491554 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -61,8 +61,8 @@ class AddSyntheticAttributes(ResponseMicroService): name: AddSyntheticAttributes config: synthetic_attributes: - target_provider1: - requester1: + requester1: + target_provider1: eduPersonAffiliation: member;employee default: default: @@ -72,8 +72,8 @@ class AddSyntheticAttributes(ResponseMicroService): ``` The use of "" and 'default' is synonymous. Attribute rules are not -overloaded or inherited. For instance a response from "target_provider1" -and requester1 in the above config will generate a (static) attribute +overloaded or inherited. For instance a response for "requester1" +from target_provider1 in the above config will generate a (static) attribute set of 'member' and 'employee' for the eduPersonAffiliation attribute and nothing else. Note that synthetic attributes override existing attributes if present. From 0862c6bff3ced102aa92b4b6fac6f357fb75d691 Mon Sep 17 00:00:00 2001 From: Robert Ellegate Date: Wed, 31 Jul 2019 10:31:58 -0400 Subject: [PATCH 011/401] FB plugin example missing mandatory client_secret --- example/plugins/backends/facebook_backend.yaml.example | 1 + 1 file changed, 1 insertion(+) diff --git a/example/plugins/backends/facebook_backend.yaml.example b/example/plugins/backends/facebook_backend.yaml.example index 645bb3089..6f99f471a 100644 --- a/example/plugins/backends/facebook_backend.yaml.example +++ b/example/plugins/backends/facebook_backend.yaml.example @@ -15,6 +15,7 @@ config: authorization_endpoint: 'https://www.facebook.com/dialog/oauth' token_endpoint: 'https://graph.facebook.com/v3.3/oauth/access_token' graph_endpoint: 'https://graph.facebook.com/v3.3/me' + client_secret: entity_info: organization: display_name: From 461cc143339f81b9227ee231a3d30c99c8284e3f Mon Sep 17 00:00:00 2001 From: peppelinux Date: Mon, 17 Jun 2019 10:40:16 +0200 Subject: [PATCH 012/401] Add a multivalue example for static attributes plugin config Signed-off-by: Ivan Kanakarakis --- .../plugins/microservices/static_attributes.yaml.example | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/example/plugins/microservices/static_attributes.yaml.example b/example/plugins/microservices/static_attributes.yaml.example index a7a87c3d4..b6de5130d 100644 --- a/example/plugins/microservices/static_attributes.yaml.example +++ b/example/plugins/microservices/static_attributes.yaml.example @@ -4,8 +4,10 @@ config: static_attributes: organisation: Example Org. schachomeorganization: example.com - schachomeorganizationtype: urn:schac:homeOrganizationType:eu:higherEducationInstitution + schachomeorganizationtype: + - "urn:schac:homeOrganizationType:eu:higherEducationInstitution" + - "urn:schac:homeOrganizationType:eu:educationInstitution" organizationname: Example Organization noreduorgacronym: EO countryname: SE - friendlycountryname: Sweden \ No newline at end of file + friendlycountryname: Sweden From 4f35ab115b4e24103a1b8cb209a88665ab08cb21 Mon Sep 17 00:00:00 2001 From: Christos Kanellopoulos Date: Sun, 11 Aug 2019 14:40:20 +0200 Subject: [PATCH 013/401] Adds a backend for BitBucket --- .../backends/bitbucket_backend.yaml.example | 30 +++ src/satosa/backends/bitbucket.py | 85 ++++++++ tests/satosa/backends/test_bitbucket.py | 194 ++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 example/plugins/backends/bitbucket_backend.yaml.example create mode 100644 src/satosa/backends/bitbucket.py create mode 100644 tests/satosa/backends/test_bitbucket.py diff --git a/example/plugins/backends/bitbucket_backend.yaml.example b/example/plugins/backends/bitbucket_backend.yaml.example new file mode 100644 index 000000000..699a17b1e --- /dev/null +++ b/example/plugins/backends/bitbucket_backend.yaml.example @@ -0,0 +1,30 @@ +module: satosa.backends.bitbucket.BitBucketBackend +name: bitbucket +config: + authz_page: bitbucket/auth/callback + base_url: + client_config: + client_id: + client_secret: + scope: ["account", "email"] + response_type: code + allow_signup: false + server_info: { + authorization_endpoint: 'https://bitbucket.org/site/oauth2/authorize', + token_endpoint: 'https://bitbucket.org/site/oauth2/access_token', + user_endpoint: 'https://api.bitbucket.org/2.0/user' + } + entity_info: + organization: + display_name: + - ["BitBucket", "en"] + name: + - ["BitBucket", "en"] + url: + - ["https://www.bitbucket.com/", "en"] + ui_info: + description: + - ["Login to a service using your BitBucket credentials", "en"] + display_name: + - ["BitBucket", "en"] + diff --git a/src/satosa/backends/bitbucket.py b/src/satosa/backends/bitbucket.py new file mode 100644 index 000000000..6932ce901 --- /dev/null +++ b/src/satosa/backends/bitbucket.py @@ -0,0 +1,85 @@ +""" +OAuth backend for BitBucket +""" +import json +import logging +import requests + +from oic.utils.authn.authn_context import UNSPECIFIED +from oic.oauth2.consumer import stateID + +from satosa.backends.oauth import _OAuthBackend +from satosa.internal import AuthenticationInformation + +logger = logging.getLogger(__name__) + + +class BitBucketBackend(_OAuthBackend): + """BitBucket OAuth 2.0 backend""" + + logprefix = "BitBucket Backend:" + + def __init__(self, outgoing, internal_attributes, config, base_url, name): + """BitBucket backend constructor + :param outgoing: Callback should be called by the module after the + authorization in the backend is done. + :param internal_attributes: Mapping dictionary between SATOSA internal + attribute names and the names returned by underlying IdP's/OP's as + well as what attributes the calling SP's and RP's expects namevice. + :param config: configuration parameters for the module. + :param base_url: base url of the service + :param name: name of the plugin + :type outgoing: + (satosa.context.Context, satosa.internal.InternalData) -> + satosa.response.Response + :type internal_attributes: dict[string, dict[str, str | list[str]]] + :type config: dict[str, dict[str, str] | list[str] | str] + :type base_url: str + :type name: str + """ + config.setdefault('response_type', 'code') + config['verify_accesstoken_state'] = False + super().__init__(outgoing, internal_attributes, config, base_url, + name, 'bitbucket', 'account_id') + + def get_request_args(self, get_state=stateID): + request_args = super().get_request_args(get_state=get_state) + + client_id = self.config["client_config"]["client_id"] + extra_args = { + arg_name: arg_val + for arg_name in ["auth_type", "scope"] + for arg_val in [self.config.get(arg_name, [])] + if arg_val + } + extra_args.update({"client_id": client_id}) + request_args.update(extra_args) + return request_args + + def auth_info(self, request): + return AuthenticationInformation( + UNSPECIFIED, None, + self.config['server_info']['authorization_endpoint']) + + def user_information(self, access_token): + url = self.config['server_info']['user_endpoint'] + email_url = "{}/emails".format(url) + headers = {'Authorization': 'Bearer {}'.format(access_token)} + resp = requests.get(url, headers=headers) + data = json.loads(resp.text) + if 'email' in self.config['scope']: + resp = requests.get(email_url, headers=headers) + emails = json.loads(resp.text) + data.update({ + 'email': [e for e in [d.get('email') + for d in emails.get('values') + if d.get('is_primary') + ] + ], + 'email_confirmed': [e for e in [d.get('email') + for d in emails.get('values') + if d.get('is_confirmed') + ] + ] + }) + return data diff --git a/tests/satosa/backends/test_bitbucket.py b/tests/satosa/backends/test_bitbucket.py new file mode 100644 index 000000000..192c55a84 --- /dev/null +++ b/tests/satosa/backends/test_bitbucket.py @@ -0,0 +1,194 @@ +import json +from unittest.mock import Mock +from urllib.parse import urlparse, parse_qsl + +import pytest +import responses + +from saml2.saml import NAMEID_FORMAT_TRANSIENT + +from satosa.backends.bitbucket import BitBucketBackend +from satosa.internal import InternalData + +BB_USER_RESPONSE = { + "account_id": "bb_id", + "is_staff": False, + "username": "bb_username", + "nickname": "bb_username", + "display_name": "bb_first_name bb_last_name", + "has_2fa_enabled": False, + "created_on": "2019-10-12T09:14:00+0000" +} +BB_USER_EMAIL_RESPONSE = { + "values": [ + { + "email": "bb_username@example.com", + "is_confirmed": True, + "is_primary": True + }, + { + "email": "bb_username_1@example.com", + "is_confirmed": True, + "is_primary": False + }, + { + "email": "bb_username_2@example.com", + "is_confirmed": False, + "is_primary": False + } + ] +} +BASE_URL = "https://client.example.com" +AUTHZ_PAGE = 'bitbucket' +CLIENT_ID = "bitbucket_client_id" +BB_CONFIG = { + 'server_info': { + 'authorization_endpoint': + 'https://bitbucket.org/site/oauth2/authorize', + 'token_endpoint': 'https://bitbucket.org/site/oauth2/access_token', + 'user_endpoint': 'https://api.bitbucket.org/2.0/user' + }, + 'client_secret': 'bitbucket_secret', + 'base_url': BASE_URL, + 'state_encryption_ key': 'state_encryption_key', + 'encryption_key': 'encryption_key', + 'authz_page': AUTHZ_PAGE, + 'client_config': {'client_id': CLIENT_ID}, + 'scope': ["account", "email"] + +} +BB_RESPONSE_CODE = "the_bb_code" + +INTERNAL_ATTRIBUTES = { + 'attributes': { + 'mail': {'bitbucket': ['email']}, + 'subject-id': {'bitbucket': ['account_id']}, + 'displayname': {'bitbucket': ['display_name']}, + 'name': {'bitbucket': ['display_name']}, + } +} + +mock_get_state = Mock(return_value="abcdef") + + +class TestBitBucketBackend(object): + @pytest.fixture(autouse=True) + def create_backend(self): + self.bb_backend = BitBucketBackend(Mock(), INTERNAL_ATTRIBUTES, + BB_CONFIG, "base_url", "bitbucket") + + @pytest.fixture + def incoming_authn_response(self, context): + context.path = 'bitbucket/sso/redirect' + state_data = dict(state=mock_get_state.return_value) + context.state[self.bb_backend.name] = state_data + context.request = { + "code": BB_RESPONSE_CODE, + "state": mock_get_state.return_value + } + + return context + + def setup_bitbucket_response(self): + _user_endpoint = BB_CONFIG['server_info']['user_endpoint'] + responses.add(responses.GET, + _user_endpoint, + body=json.dumps(BB_USER_RESPONSE), + status=200, + content_type='application/json') + + responses.add(responses.GET, + '{}/emails'.format(_user_endpoint), + body=json.dumps(BB_USER_EMAIL_RESPONSE), + status=200, + content_type='application/json') + + def assert_expected_attributes(self): + expected_attributes = { + "subject-id": [BB_USER_RESPONSE["account_id"]], + "name": [BB_USER_RESPONSE["display_name"]], + "displayname": [BB_USER_RESPONSE["display_name"]], + "mail": [BB_USER_EMAIL_RESPONSE["values"][0]["email"]], + } + + context, internal_resp = self.bb_backend \ + .auth_callback_func \ + .call_args[0] + assert internal_resp.attributes == expected_attributes + + def assert_token_request(self, request_args, state, **kwargs): + assert request_args["code"] == BB_RESPONSE_CODE + assert request_args["redirect_uri"] == "%s/%s" % (BASE_URL, AUTHZ_PAGE) + assert request_args["state"] == mock_get_state.return_value + assert state == mock_get_state.return_value + + def test_register_endpoints(self): + url_map = self.bb_backend.register_endpoints() + expected_url_map = [('^bitbucket$', self.bb_backend._authn_response)] + assert url_map == expected_url_map + + def test_start_auth(self, context): + context.path = 'bitbucket/sso/redirect' + internal_request = InternalData( + subject_type=NAMEID_FORMAT_TRANSIENT, requester='test_requester' + ) + + resp = self.bb_backend.start_auth(context, + internal_request, + mock_get_state) + login_url = resp.message + assert login_url.startswith( + BB_CONFIG["server_info"]["authorization_endpoint"]) + expected_params = { + "client_id": CLIENT_ID, + "state": mock_get_state.return_value, + "response_type": "code", + "scope": " ".join(BB_CONFIG["scope"]), + "redirect_uri": "%s/%s" % (BASE_URL, AUTHZ_PAGE) + } + actual_params = dict(parse_qsl(urlparse(login_url).query)) + assert actual_params == expected_params + + @responses.activate + def test_authn_response(self, incoming_authn_response): + self.setup_bitbucket_response() + + mock_do_access_token_request = Mock( + return_value={"access_token": "bb access token"}) + self.bb_backend.consumer.do_access_token_request = \ + mock_do_access_token_request + + self.bb_backend._authn_response(incoming_authn_response) + assert self.bb_backend.name not in incoming_authn_response.state + + self.assert_expected_attributes() + self.assert_token_request(**mock_do_access_token_request.call_args[1]) + + @responses.activate + def test_entire_flow(self, context): + """ + Tests start of authentication (incoming auth req) and receiving auth + response. + """ + responses.add(responses.POST, + BB_CONFIG["server_info"]["token_endpoint"], + body=json.dumps({"access_token": "qwerty", + "token_type": "bearer", + "expires_in": 9999999999999}), + status=200, + content_type='application/json') + self.setup_bitbucket_response() + + context.path = 'bitbucket/sso/redirect' + internal_request = InternalData( + subject_type=NAMEID_FORMAT_TRANSIENT, requester='test_requester' + ) + + self.bb_backend.start_auth(context, internal_request, mock_get_state) + context.request = { + "code": BB_RESPONSE_CODE, + "state": mock_get_state.return_value + } + self.bb_backend._authn_response(context) + assert self.bb_backend.name not in context.state + self.assert_expected_attributes() From 333a745f6fab68a1c98943bb0be78fda1c130699 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 27 Aug 2019 16:05:23 +0300 Subject: [PATCH 014/401] Format code Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 15 +++++++------ tests/satosa/backends/test_saml2.py | 34 ++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index e4df21619..3b6ab4c6b 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -227,10 +227,10 @@ def _run_bound_endpoint(self, context, spec): return spec(context) except SATOSAAuthenticationError as error: error.error_id = uuid.uuid4().urn - msg = "ERROR_ID [{err_id}]\nSTATE:\n{state}".format(err_id=error.error_id, - state=json.dumps( - error.state.state_dict, - indent=4)) + state = json.dumps(error.state.state_dict, indent=4) + msg = "ERROR_ID [{err_id}]\nSTATE:\n{state}".format( + err_id=error.error_id, state=state + ) satosa_logging(logger, logging.ERROR, msg, error.state, exc_info=True) return self._handle_satosa_authentication_error(error) @@ -243,9 +243,10 @@ def _load_state(self, context): """ try: state = cookie_to_state( - context.cookie, - self.config["COOKIE_STATE_NAME"], - self.config["STATE_ENCRYPTION_KEY"]) + context.cookie, + self.config["COOKIE_STATE_NAME"], + self.config["STATE_ENCRYPTION_KEY"], + ) except SATOSAStateError as e: msg_tmpl = 'Failed to decrypt state {state} with {error}' msg = msg_tmpl.format(state=context.cookie, error=str(e)) diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index 39e7e31fb..981998fbd 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -233,30 +233,44 @@ def test_authn_response_no_name_id(self, context, idp_conf, sp_conf): assert backend.name not in context.state def test_authn_response_with_encrypted_assertion(self, sp_conf, context): - with open(os.path.join(TEST_RESOURCE_BASE_PATH, - "idp_metadata_for_encrypted_signed_auth_response.xml")) as idp_metadata_file: + with open(os.path.join( + TEST_RESOURCE_BASE_PATH, + "idp_metadata_for_encrypted_signed_auth_response.xml" + )) as idp_metadata_file: sp_conf["metadata"]["inline"] = [idp_metadata_file.read()] - samlbackend = SAMLBackend(Mock(), INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, - "disco_srv": DISCOSRV_URL}, - "base_url", "samlbackend") + + samlbackend = SAMLBackend( + Mock(), + INTERNAL_ATTRIBUTES, + {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, + "base_url", + "samlbackend", + ) response_binding = BINDING_HTTP_REDIRECT relay_state = "test relay state" - with open(os.path.join(TEST_RESOURCE_BASE_PATH, - "auth_response_with_encrypted_signed_assertion.xml")) as auth_response_file: + with open(os.path.join( + TEST_RESOURCE_BASE_PATH, + "auth_response_with_encrypted_signed_assertion.xml" + )) as auth_response_file: auth_response = auth_response_file.read() + context.request = {"SAMLResponse": deflate_and_base64_encode(auth_response), "RelayState": relay_state} context.state[self.samlbackend.name] = {"relay_state": relay_state} - with open(os.path.join(TEST_RESOURCE_BASE_PATH, "encryption_key.pem")) as encryption_key_file: + with open( + os.path.join(TEST_RESOURCE_BASE_PATH, "encryption_key.pem") + ) as encryption_key_file: samlbackend.encryption_keys = [encryption_key_file.read()] assertion_issued_at = 1479315212 with patch('saml2.validate.time_util.shift_time') as mock_shift_time, \ patch('saml2.validate.time_util.utc_now') as mock_utc_now: mock_utc_now.return_value = assertion_issued_at + 1 - mock_shift_time.side_effect = [datetime.utcfromtimestamp(assertion_issued_at + 1), - datetime.utcfromtimestamp(assertion_issued_at - 1)] + mock_shift_time.side_effect = [ + datetime.utcfromtimestamp(assertion_issued_at + 1), + datetime.utcfromtimestamp(assertion_issued_at - 1), + ] samlbackend.authn_response(context, response_binding) context, internal_resp = samlbackend.auth_callback_func.call_args[0] From fcd61a2d13ad6e7a1dd0e8998ae9490d8ef2ce30 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 27 Aug 2019 16:05:46 +0300 Subject: [PATCH 015/401] Remove logging around state-cookie loading Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 3 --- src/satosa/state.py | 5 ----- tests/satosa/backends/test_saml2.py | 1 + 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index 3b6ab4c6b..9daaaf193 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -248,9 +248,6 @@ def _load_state(self, context): self.config["STATE_ENCRYPTION_KEY"], ) except SATOSAStateError as e: - msg_tmpl = 'Failed to decrypt state {state} with {error}' - msg = msg_tmpl.format(state=context.cookie, error=str(e)) - satosa_logging(logger, logging.WARNING, msg, None) state = State() finally: context.state = state diff --git a/src/satosa/state.py b/src/satosa/state.py index ad266f837..47b8476b2 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -75,17 +75,12 @@ def cookie_to_state(cookie_str, name, encryption_key): except KeyError as e: msg_tmpl = 'No cookie named {name} in {data}' msg = msg_tmpl.format(name=name, data=cookie_str) - logger.exception(msg) raise SATOSAStateError(msg) from e except ValueError as e: msg_tmpl = 'Failed to process {name} from {data}' msg = msg_tmpl.format(name=name, data=cookie_str) - logger.exception(msg) raise SATOSAStateError(msg) from e else: - msg_tmpl = 'Loading state from cookie {data}' - msg = msg_tmpl.format(data=cookie_str) - satosa_logging(logger, logging.DEBUG, msg, state) return state diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index 981998fbd..e5e2d905c 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -239,6 +239,7 @@ def test_authn_response_with_encrypted_assertion(self, sp_conf, context): )) as idp_metadata_file: sp_conf["metadata"]["inline"] = [idp_metadata_file.read()] + sp_conf["entityid"] = "https://federation-dev-1.scienceforum.sc/Saml2/proxy_saml2_backend.xml" samlbackend = SAMLBackend( Mock(), INTERNAL_ATTRIBUTES, From dcec6e57a30d18f9f9502b9725cb603fff229fc7 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 27 Aug 2019 16:30:51 +0300 Subject: [PATCH 016/401] Log the loaded state and its source Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/satosa/base.py b/src/satosa/base.py index 9daaaf193..74257c853 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -251,6 +251,9 @@ def _load_state(self, context): state = State() finally: context.state = state + msg_tmpl = 'Loaded state {state} from cookie {cookie}' + msg = msg_tmpl.format(state=state, cookie=context.cookie) + logger.info(msg) def _save_state(self, resp, context): """ From 4fbbe793e8b4110993a00fd4a059819ddc7640d9 Mon Sep 17 00:00:00 2001 From: Christos Kanellopoulos Date: Sat, 31 Aug 2019 11:07:07 +0200 Subject: [PATCH 017/401] Fixes support for extra_scopes in the OIDC frontend _get_approved_attributes called scope2claims with one argument, the scopes the client had requested. scope2claims can receive two arguments, the second being the extra_scope_dict. If that is not defined, then scope2claims will use only SCOPE2CLAIMS, which has only the openid, profile, email, address, phone and offine_access scopes. This commit changes the call to scope2claims to include also the extra_scopes that may have been added in the Provider's configuration. Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/openid_connect.py | 6 +- tests/satosa/frontends/test_openid_connect.py | 220 ++++++++++++++++-- 2 files changed, 201 insertions(+), 25 deletions(-) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 4c8f27dda..bd5972511 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -259,7 +259,11 @@ def provider_config(self, context): return Response(self.provider.provider_configuration.to_json(), content="application/json") def _get_approved_attributes(self, provider_supported_claims, authn_req): - requested_claims = list(scope2claims(authn_req["scope"]).keys()) + requested_claims = list( + scope2claims( + authn_req["scope"], self.config["provider"].get("extra_scopes") + ).keys() + ) if "claims" in authn_req: for k in ["id_token", "userinfo"]: if k in authn_req["claims"]: diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index d95b2fe5b..62d6dcaf4 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -1,6 +1,7 @@ """ Tests for the SAML frontend module src/frontends/saml2.py. """ +import copy import json from base64 import urlsafe_b64encode from collections import Counter @@ -28,7 +29,19 @@ BASE_URL = "https://op.example.com" CLIENT_ID = "client1" CLIENT_SECRET = "client_secret" - +EXTRA_CLAIMS = { + "eduPersonScopedAffiliation": { + "saml": ["eduPersonScopedAffiliation"], + "openid": ["eduperson_scoped_affiliation"], + }, + "eduPersonPrincipalName": { + "saml": ["eduPersonPrincipalName"], + "openid": ["eduperson_principal_name"], + }, +} +EXTRA_SCOPES = { + "eduperson": ["eduperson_scoped_affiliation", "eduperson_principal_name"] +} class TestOpenIDConnectFrontend(object): @pytest.fixture @@ -43,6 +56,19 @@ def frontend_config(self, signing_key_path): return config + @pytest.fixture + def frontend_config_with_extra_scopes(self, signing_key_path): + config = { + "signing_key_path": signing_key_path, + "provider": { + "response_types_supported": ["code", "id_token", "code id_token token"], + "scopes_supported": ["openid", "email"], + "extra_scopes": EXTRA_SCOPES, + }, + } + + return config + def create_frontend(self, frontend_config): # will use in-memory storage instance = OpenIDConnectFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, @@ -50,10 +76,28 @@ def create_frontend(self, frontend_config): instance.register_endpoints(["foo_backend"]) return instance + def create_frontend_with_extra_scopes(self, frontend_config_with_extra_scopes): + # will use in-memory storage + internal_attributes_with_extra_scopes = copy.deepcopy(INTERNAL_ATTRIBUTES) + internal_attributes_with_extra_scopes["attributes"].update(EXTRA_CLAIMS) + instance = OpenIDConnectFrontend( + lambda ctx, req: None, + internal_attributes_with_extra_scopes, + frontend_config_with_extra_scopes, + BASE_URL, + "oidc_frontend_with_extra_scopes", + ) + instance.register_endpoints(["foo_backend"]) + return instance + @pytest.fixture def frontend(self, frontend_config): return self.create_frontend(frontend_config) + @pytest.fixture + def frontend_with_extra_scopes(self, frontend_config_with_extra_scopes): + return self.create_frontend_with_extra_scopes(frontend_config_with_extra_scopes) + @pytest.fixture def authn_req(self): state = "my_state" @@ -65,6 +109,23 @@ def authn_req(self): nonce=nonce, claims=claims_req) return req + @pytest.fixture + def authn_req_with_extra_scopes(self): + state = "my_state" + nonce = "nonce" + redirect_uri = "https://client.example.com" + claims_req = ClaimsRequest(id_token=Claims(email=None)) + req = AuthorizationRequest( + client_id=CLIENT_ID, + state=state, + scope="openid email eduperson", + response_type="id_token", + redirect_uri=redirect_uri, + nonce=nonce, + claims=claims_req, + ) + return req + def insert_client_in_client_db(self, frontend, redirect_uri, extra_metadata={}): frontend.provider.clients = { CLIENT_ID: {"response_types": ["code", "id_token"], @@ -73,7 +134,12 @@ def insert_client_in_client_db(self, frontend, redirect_uri, extra_metadata={}): frontend.provider.clients[CLIENT_ID].update(extra_metadata) def insert_user_in_user_db(self, frontend, user_id): - frontend.user_db[user_id] = {"email": "tester@example.com"} + user_attributes = AttributeMapper(frontend.internal_attributes).to_internal( + "saml", USERS["testuser1"] + ) + frontend.user_db[user_id] = frontend.converter.from_internal( + "openid", user_attributes + ) def create_access_token(self, frontend, user_id, auth_req): sub = frontend.provider.authz_state.get_subject_identifier('pairwise', user_id, 'client1.example.com') @@ -84,9 +150,13 @@ def create_access_token(self, frontend, user_id, auth_req): def setup_for_authn_response(self, context, frontend, auth_req): context.state[frontend.name] = {"oidc_request": auth_req.to_urlencoded()} - auth_info = AuthenticationInformation(PASSWORD, "2015-09-30T12:21:37Z", "unittest_idp.xml") + auth_info = AuthenticationInformation( + PASSWORD, "2015-09-30T12:21:37Z", "unittest_idp.xml" + ) internal_response = InternalData(auth_info=auth_info) - internal_response.attributes = AttributeMapper(INTERNAL_ATTRIBUTES).to_internal("saml", USERS["testuser1"]) + internal_response.attributes = AttributeMapper( + frontend.internal_attributes + ).to_internal("saml", USERS["testuser1"]) internal_response.subject_id = USERS["testuser1"]["eduPersonTargetedID"][0] return internal_response @@ -123,6 +193,28 @@ def test_handle_authn_request(self, context, frontend, authn_req): assert internal_req.subject_type == 'pairwise' assert internal_req.attributes == ["mail"] + def test_handle_authn_request_with_extra_scopes( + self, context, frontend_with_extra_scopes, authn_req_with_extra_scopes + ): + client_name = "test client" + self.insert_client_in_client_db( + frontend_with_extra_scopes, + authn_req_with_extra_scopes["redirect_uri"], + {"client_name": client_name}, + ) + + context.request = dict(parse_qsl(authn_req_with_extra_scopes.to_urlencoded())) + frontend_with_extra_scopes.handle_authn_request(context) + internal_req = frontend_with_extra_scopes._handle_authn_request(context) + assert internal_req.requester == authn_req_with_extra_scopes["client_id"] + assert internal_req.requester_name == [{"lang": "en", "text": client_name}] + assert internal_req.subject_type == "pairwise" + assert sorted(internal_req.attributes) == [ + "eduPersonPrincipalName", + "eduPersonScopedAffiliation", + "mail", + ] + def test_get_approved_attributes(self, frontend): claims_req = ClaimsRequest(id_token=Claims(email=None), userinfo=Claims(userinfo_claim=None)) req = AuthorizationRequest(scope="openid profile", claims=claims_req) @@ -199,9 +291,64 @@ def test_provider_configuration_endpoint(self, context, frontend): provider_config_dict = provider_config.to_dict() scopes_supported = provider_config_dict.pop("scopes_supported") + assert "eduperson" not in scopes_supported assert all(scope in scopes_supported for scope in ["openid", "email"]) assert provider_config_dict == expected_capabilities + def test_provider_configuration_endpoint_with_extra_scopes( + self, context, frontend_with_extra_scopes + ): + expected_capabilities = { + "response_types_supported": ["code", "id_token", "code id_token token"], + "jwks_uri": "{}/{}/jwks".format(BASE_URL, frontend_with_extra_scopes.name), + "authorization_endpoint": "{}/foo_backend/{}/authorization".format( + BASE_URL, frontend_with_extra_scopes.name + ), + "token_endpoint": "{}/{}/token".format( + BASE_URL, frontend_with_extra_scopes.name + ), + "userinfo_endpoint": "{}/{}/userinfo".format( + BASE_URL, frontend_with_extra_scopes.name + ), + "id_token_signing_alg_values_supported": ["RS256"], + "response_modes_supported": ["fragment", "query"], + "subject_types_supported": ["pairwise"], + "claim_types_supported": ["normal"], + "claims_parameter_supported": True, + "request_parameter_supported": False, + "request_uri_parameter_supported": False, + "claims_supported": [ + "email", + "eduperson_scoped_affiliation", + "eduperson_principal_name", + ], + "grant_types_supported": ["authorization_code", "implicit"], + "issuer": BASE_URL, + "require_request_uri_registration": False, + "token_endpoint_auth_methods_supported": ["client_secret_basic"], + "version": "3.0", + } + + http_response = frontend_with_extra_scopes.provider_config(context) + provider_config = ProviderConfigurationResponse().deserialize( + http_response.message, "json" + ) + + provider_config_dict = provider_config.to_dict() + scopes_supported = provider_config_dict.pop("scopes_supported") + assert all( + scope in scopes_supported for scope in ["openid", "email", "eduperson"] + ) + + # FIXME why is this needed? + expected_capabilities["claims_supported"] = set( + expected_capabilities["claims_supported"] + ) + provider_config_dict["claims_supported"] = set( + provider_config_dict["claims_supported"] + ) + assert provider_config_dict == expected_capabilities + def test_jwks(self, context, frontend): http_response = frontend.jwks(context) jwks = json.loads(http_response.message) @@ -307,7 +454,7 @@ def test_token_endpoint_with_invalid_code(self, context, frontend, authn_req): assert parsed_message["error"] == "invalid_grant" def test_userinfo_endpoint(self, context, frontend, authn_req): - user_id = "user1" + user_id = USERS["testuser1"]["eduPersonTargetedID"][0] self.insert_client_in_client_db(frontend, authn_req["redirect_uri"]) self.insert_user_in_user_db(frontend, user_id) @@ -317,14 +464,28 @@ def test_userinfo_endpoint(self, context, frontend, authn_req): context.request_authorization = "Bearer {}".format(token) response = frontend.userinfo_endpoint(context) parsed = OpenIDSchema().deserialize(response.message, "json") - assert parsed["email"] == "tester@example.com" - - def test_userinfo_without_token(self, context, frontend): + assert parsed["email"] == "test@example.com" + + def test_userinfo_endpoint_with_extra_scopes( + self, context, frontend_with_extra_scopes, authn_req_with_extra_scopes + ): + user_id = USERS["testuser1"]["eduPersonTargetedID"][0] + self.insert_client_in_client_db( + frontend_with_extra_scopes, authn_req_with_extra_scopes["redirect_uri"] + ) + self.insert_user_in_user_db(frontend_with_extra_scopes, user_id) + + token = self.create_access_token( + frontend_with_extra_scopes, user_id, authn_req_with_extra_scopes + ) context.request = {} - context.request_authorization = "" - - response = frontend.userinfo_endpoint(context) - assert response.status == "401 Unauthorized" + context.request_authorization = "Bearer {}".format(token) + response = frontend_with_extra_scopes.userinfo_endpoint(context) + parsed = OpenIDSchema().deserialize(response.message, "json") + assert parsed["email"] == "test@example.com" + # TODO + assert parsed["eduperson_scoped_affiliation"] == ["student@example.com"] + assert parsed["eduperson_principal_name"] == ["test@example.com"] def test_userinfo_with_invalid_token(self, context, frontend): context.request = {} @@ -333,32 +494,41 @@ def test_userinfo_with_invalid_token(self, context, frontend): response = frontend.userinfo_endpoint(context) assert response.status == "401 Unauthorized" - def test_full_flow(self, context, frontend): + def test_full_flow(self, context, frontend_with_extra_scopes): redirect_uri = "https://client.example.com/redirect" response_type = "code id_token token" mock_callback = Mock() - frontend.auth_req_callback_func = mock_callback + frontend_with_extra_scopes.auth_req_callback_func = mock_callback # discovery - http_response = frontend.provider_config(context) + http_response = frontend_with_extra_scopes.provider_config(context) provider_config = ProviderConfigurationResponse().deserialize(http_response.message, "json") # client registration registration_request = RegistrationRequest(redirect_uris=[redirect_uri], response_types=[response_type]) context.request = registration_request.to_dict() - http_response = frontend.client_registration(context) + http_response = frontend_with_extra_scopes.client_registration(context) registration_response = RegistrationResponse().deserialize(http_response.message, "json") # authentication request - authn_req = AuthorizationRequest(redirect_uri=redirect_uri, client_id=registration_response["client_id"], - response_type=response_type, scope="openid email", state="state", - nonce="nonce") + authn_req = AuthorizationRequest( + redirect_uri=redirect_uri, + client_id=registration_response["client_id"], + response_type=response_type, + scope="openid email eduperson", + state="state", + nonce="nonce", + ) context.request = dict(parse_qsl(authn_req.to_urlencoded())) - frontend.handle_authn_request(context) + frontend_with_extra_scopes.handle_authn_request(context) assert mock_callback.call_count == 1 # fake authentication response from backend - internal_response = self.setup_for_authn_response(context, frontend, authn_req) - http_response = frontend.handle_authn_response(context, internal_response) + internal_response = self.setup_for_authn_response( + context, frontend_with_extra_scopes, authn_req + ) + http_response = frontend_with_extra_scopes.handle_authn_response( + context, internal_response + ) authn_resp = AuthorizationResponse().deserialize(urlparse(http_response.message).fragment, "urlencoded") assert "code" in authn_resp assert "access_token" in authn_resp @@ -370,7 +540,7 @@ def test_full_flow(self, context, frontend): basic_auth = urlsafe_b64encode(credentials.encode("utf-8")).decode("utf-8") context.request_authorization = "Basic {}".format(basic_auth) - http_response = frontend.token_endpoint(context) + http_response = frontend_with_extra_scopes.token_endpoint(context) parsed = AccessTokenResponse().deserialize(http_response.message, "json") assert "access_token" in parsed assert "id_token" in parsed @@ -378,6 +548,8 @@ def test_full_flow(self, context, frontend): # userinfo request context.request = {} context.request_authorization = "Bearer {}".format(parsed["access_token"]) - http_response = frontend.userinfo_endpoint(context) + http_response = frontend_with_extra_scopes.userinfo_endpoint(context) parsed = OpenIDSchema().deserialize(http_response.message, "json") assert "email" in parsed + assert "eduperson_principal_name" in parsed + assert "eduperson_scoped_affiliation" in parsed From b8c55455ceb7f718689eb67ee52f1faa1a318d27 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Sat, 14 Sep 2019 15:23:36 -0500 Subject: [PATCH 018/401] Add CO attribute scope to state for SAMLVirtualCoFrontend Add logic to store the configured CO attribute scope for an instance of SAMLVirtualCoFrontend in the state so that microservices can easily access it. --- src/satosa/frontends/saml2.py | 6 ++++++ tests/satosa/frontends/test_saml2.py | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 12484f955..d866c3dd1 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -702,6 +702,7 @@ class SAMLVirtualCoFrontend(SAMLFrontend): KEY_CO_NAME = 'co_name' KEY_CO_ENTITY_ID = 'co_entity_id' KEY_CO_ATTRIBUTES = 'co_static_saml_attributes' + KEY_CO_ATTRIBUTE_SCOPE = 'co_attribute_scope' KEY_CONTACT_PERSON = 'contact_person' KEY_ENCODEABLE_NAME = 'encodeable_name' KEY_ORGANIZATION = 'organization' @@ -774,6 +775,11 @@ def _create_state_data(self, context, resp_args, relay_state): state[self.KEY_CO_ENTITY_ID] = context.get_decoration( self.KEY_CO_ENTITY_ID) + co_config = self._get_co_config(context) + state[self.KEY_CO_ATTRIBUTE_SCOPE] = co_config.get( + self.KEY_CO_ATTRIBUTE_SCOPE, + None) + return state def _get_co_config(self, context): diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index 5054658ae..3e89fd2fa 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -412,6 +412,7 @@ class TestSAMLVirtualCoFrontend(TestSAMLFrontend): CO_C = "countryname" CO_CO = "friendlycountryname" CO_NOREDUORGACRONYM = "noreduorgacronym" + CO_SCOPE = "messproject.org" CO_STATIC_SAML_ATTRIBUTES = { CO_O: ["Medium Energy Synchrotron Source"], CO_C: ["US"], @@ -438,7 +439,8 @@ def frontend(self, idp_conf, sp_conf): # SAML attributes so their presence in a SAML Response can be tested. collab_org = { "encodeable_name": self.CO, - "co_static_saml_attributes": self.CO_STATIC_SAML_ATTRIBUTES + "co_static_saml_attributes": self.CO_STATIC_SAML_ATTRIBUTES, + "co_attribute_scope": self.CO_SCOPE } # Use the dynamically updated idp_conf fixture, the configured @@ -491,6 +493,8 @@ def test_create_state_data(self, frontend, context, idp_conf): expected_entityid = "{}/{}".format(idp_conf['entityid'], self.CO) assert state[frontend.KEY_CO_ENTITY_ID] == expected_entityid + assert state[frontend.KEY_CO_ATTRIBUTE_SCOPE] == self.CO_SCOPE + def test_get_co_name(self, frontend, context): co_name = frontend._get_co_name(context) assert co_name == self.CO From d013eb12bb6302860e4dc3d2341e64a55a94077c Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 11 Jul 2019 13:06:06 -0500 Subject: [PATCH 019/401] Applied flake8 to ldap_attribute_store.py Applied flake8 to ldap_attribute_store.py since it was written before the project adopted flake8. No functional changes in this commit. --- .../micro_services/ldap_attribute_store.py | 322 ++++++++++-------- 1 file changed, 186 insertions(+), 136 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 51eb33e79..844f240c7 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -36,23 +36,23 @@ class LdapAttributeStore(ResponseMicroService): """ config_defaults = { - 'bind_dn' : None, - 'bind_password' : None, - 'clear_input_attributes' : False, - 'ignore' : False, - 'ldap_identifier_attribute' : None, - 'ldap_url' : None, - 'on_ldap_search_result_empty' : None, - 'ordered_identifier_candidates' : None, - 'search_base' : None, - 'search_return_attributes' : None, - 'user_id_from_attrs' : [], - 'read_only' : True, - 'version' : 3, - 'auto_bind' : False, - 'client_strategy' : ldap3.RESTARTABLE, - 'pool_size' : 10, - 'pool_keepalive' : 10, + 'bind_dn': None, + 'bind_password': None, + 'clear_input_attributes': False, + 'ignore': False, + 'ldap_identifier_attribute': None, + 'ldap_url': None, + 'on_ldap_search_result_empty': None, + 'ordered_identifier_candidates': None, + 'search_base': None, + 'search_return_attributes': None, + 'user_id_from_attrs': [], + 'read_only': True, + 'version': 3, + 'auto_bind': False, + 'client_strategy': ldap3.RESTARTABLE, + 'pool_size': 10, + 'pool_keepalive': 10, } def __init__(self, config, *args, **kwargs): @@ -75,7 +75,7 @@ def __init__(self, config, *args, **kwargs): # Process the default configuration first then any per-SP overrides. sp_list = ['default'] - sp_list.extend([ key for key in config.keys() if key != 'default' ]) + sp_list.extend([key for key in config.keys() if key != 'default']) connections = {} @@ -104,25 +104,29 @@ def __init__(self, config, *args, **kwargs): if connection_params in connections: sp_config['connection'] = connections[connection_params] - satosa_logging(logger, logging.DEBUG, "Reusing LDAP connection for SP {}".format(sp), None) + msg = "Reusing LDAP connection for SP {}".format(sp) + satosa_logging(logger, logging.DEBUG, msg, None) else: try: connection = self._ldap_connection_factory(sp_config) connections[connection_params] = connection sp_config['connection'] = connection - satosa_logging(logger, logging.DEBUG, "Created new LDAP connection for SP {}".format(sp), None) - except LdapAttributeStoreError as e: + msg = "Created new LDAP connection for SP {}".format(sp) + satosa_logging(logger, logging.DEBUG, msg, None) + except LdapAttributeStoreError: # It is acceptable to not have a default LDAP connection # but all SP overrides must have a connection, either # inherited from the default or directly configured. if sp != 'default': - msg = "No LDAP connection can be initialized for SP {}".format(sp) + msg = "No LDAP connection can be initialized for SP {}" + msg = msg.format(sp) satosa_logging(logger, logging.ERROR, msg, None) raise LdapAttributeStoreError(msg) self.config[sp] = sp_config - satosa_logging(logger, logging.INFO, "LDAP Attribute Store microservice initialized", None) + msg = "LDAP Attribute Store microservice initialized" + satosa_logging(logger, logging.INFO, msg, None) def _construct_filter_value(self, candidate, data): """ @@ -156,23 +160,27 @@ def _construct_filter_value(self, candidate, data): string is any other value it will be directly concatenated. """ context = self.context + state = context.state attributes = data.attributes - satosa_logging(logger, logging.DEBUG, "Input attributes {}".format(attributes), context.state) + msg = "Input attributes {}".format(attributes) + satosa_logging(logger, logging.DEBUG, msg, state) # Get the values configured list of identifier names for this candidate - # and substitute None if there are no values for a configured identifier. + # and substitute None if there are no values for a configured + # identifier. values = [] for identifier_name in candidate['attribute_names']: v = attributes.get(identifier_name, None) if isinstance(v, list): v = v[0] values.append(v) - satosa_logging(logger, logging.DEBUG, "Found candidate values {}".format(values), context.state) + msg = "Found candidate values {}".format(values) + satosa_logging(logger, logging.DEBUG, msg, state) - # If one of the configured identifier names is name_id then if there is also a configured - # name_id_format add the value for the NameID of that format if it was asserted by the IdP - # or else add the value None. + # If one of the configured identifier names is name_id then if there is + # also a configured name_id_format add the value for the NameID of that + # format if it was asserted by the IdP or else add the value None. if 'name_id' in candidate['attribute_names']: candidate_nameid_value = None candidate_name_id_format = candidate.get('name_id_format') @@ -183,24 +191,30 @@ def _construct_filter_value(self, candidate, data): and candidate_name_id_format and candidate_name_id_format == name_id_format ): - satosa_logging(logger, logging.DEBUG, "IdP asserted NameID {}".format(name_id_value), context.state) + msg = "IdP asserted NameID {}".format(name_id_value) + satosa_logging(logger, logging.DEBUG, msg, state) candidate_nameid_value = name_id_value - # Only add the NameID value asserted by the IdP if it is not already - # in the list of values. This is necessary because some non-compliant IdPs - # have been known, for example, to assert the value of eduPersonPrincipalName - # in the value for SAML2 persistent NameID as well as asserting - # eduPersonPrincipalName. + # Only add the NameID value asserted by the IdP if it is not + # already in the list of values. This is necessary because some + # non-compliant IdPs have been known, for example, to assert the + # value of eduPersonPrincipalName in the value for SAML2 persistent + # NameID as well as asserting eduPersonPrincipalName. if candidate_nameid_value not in values: - satosa_logging(logger, logging.DEBUG, "Added NameID {} to candidate values".format(candidate_nameid_value), context.state) + msg = "Added NameID {} to candidate values" + msg = msg.format(candidate_nameid_value) + satosa_logging(logger, logging.DEBUG, msg, state) values.append(candidate_nameid_value) else: - satosa_logging(logger, logging.WARN, "NameID {} value also asserted as attribute value".format(candidate_nameid_value), context.state) + msg = "NameID {} value also asserted as attribute value" + msg = msg.format(candidate_nameid_value) + satosa_logging(logger, logging.WARN, msg, state) - # If no value was asserted by the IdP for one of the configured list of identifier names - # for this candidate then go onto the next candidate. + # If no value was asserted by the IdP for one of the configured list of + # identifier names for this candidate then go onto the next candidate. if None in values: - satosa_logging(logger, logging.DEBUG, "Candidate is missing value so skipping", context.state) + msg = "Candidate is missing value so skipping" + satosa_logging(logger, logging.DEBUG, msg, state) return None # All values for the configured list of attribute names are present @@ -211,13 +225,15 @@ def _construct_filter_value(self, candidate, data): scope = data.auth_info.issuer else: scope = candidate['add_scope'] - satosa_logging(logger, logging.DEBUG, "Added scope {} to values".format(scope), context.state) + msg = "Added scope {} to values".format(scope) + satosa_logging(logger, logging.DEBUG, msg, state) values.append(scope) # Concatenate all values to create the filter value. value = ''.join(values) - satosa_logging(logger, logging.DEBUG, "Constructed filter value {}".format(value), context.state) + msg = "Constructed filter value {}".format(value) + satosa_logging(logger, logging.DEBUG, msg, state) return value @@ -234,7 +250,8 @@ def _filter_config(self, config, fields=None): filter_fields = fields or filter_fields_default return dict( map( - lambda key: (key, '' if key in filter_fields else config[key]), + lambda key: (key, '' if key in filter_fields + else config[key]), config.keys() ) ) @@ -257,14 +274,21 @@ def _ldap_connection_factory(self, config): server = ldap3.Server(config['ldap_url']) - satosa_logging(logger, logging.DEBUG, "Creating a new LDAP connection", None) - satosa_logging(logger, logging.DEBUG, "Using LDAP URL {}".format(ldap_url), None) - satosa_logging(logger, logging.DEBUG, "Using bind DN {}".format(bind_dn), None) + msg = "Creating a new LDAP connection" + satosa_logging(logger, logging.DEBUG, msg, None) + + msg = "Using LDAP URL {}".format(ldap_url) + satosa_logging(logger, logging.DEBUG, msg, None) + + msg = "Using bind DN {}".format(bind_dn) + satosa_logging(logger, logging.DEBUG, msg, None) pool_size = config['pool_size'] pool_keepalive = config['pool_keepalive'] - satosa_logging(logger, logging.DEBUG, "Using pool size {}".format(pool_size), None) - satosa_logging(logger, logging.DEBUG, "Using pool keep alive {}".format(pool_keepalive), None) + msg = "Using pool size {}".format(pool_size) + satosa_logging(logger, logging.DEBUG, msg, None) + msg = "Using pool keep alive {}".format(pool_keepalive) + satosa_logging(logger, logging.DEBUG, msg, None) auto_bind = config['auto_bind'] client_strategy = config['client_strategy'] @@ -283,14 +307,17 @@ def _ldap_connection_factory(self, config): pool_size=pool_size, pool_keepalive=pool_keepalive ) - satosa_logging(logger, logging.DEBUG, "Successfully connected to LDAP server", None) + msg = "Successfully connected to LDAP server" + satosa_logging(logger, logging.DEBUG, msg, None) except LDAPException as e: - msg = "Caught exception when connecting to LDAP server: {}".format(e) + msg = "Caught exception when connecting to LDAP server: {}" + msg = msg.format(e) satosa_logging(logger, logging.ERROR, msg, None) raise LdapAttributeStoreError(msg) - satosa_logging(logger, logging.DEBUG, "Successfully connected to LDAP server", None) + msg = "Successfully connected to LDAP server" + satosa_logging(logger, logging.DEBUG, msg, None) return connection @@ -298,76 +325,60 @@ def _populate_attributes(self, config, record, context, data): """ Use a record found in LDAP to populate attributes. """ + state = context.state + attributes = data.attributes + search_return_attributes = config['search_return_attributes'] for attr in search_return_attributes.keys(): if attr in record["attributes"]: if record["attributes"][attr]: - data.attributes[search_return_attributes[attr]] = record["attributes"][attr] - satosa_logging( - logger, - logging.DEBUG, - "Setting internal attribute {} with values {}".format( - search_return_attributes[attr], - record["attributes"][attr] - ), - context.state - ) + internal_attr = search_return_attributes[attr] + value = record["attributes"][attr] + attributes[internal_attr] = value + msg = "Setting internal attribute {} with values {}" + msg = msg.format(internal_attr, value) + satosa_logging(logger, logging.DEBUG, msg, state) else: - satosa_logging( - logger, - logging.DEBUG, - "Not setting internal attribute {} because value {} is null or empty".format( - search_return_attributes[attr], - record["attributes"][attr] - ), - context.state - ) + msg = "Not setting internal attribute {} because value {}" + msg = msg + " is null or empty" + msg = msg.format(internal_attr, value) + satosa_logging(logger, logging.DEBUG, msg, state) def _populate_input_for_name_id(self, config, record, context, data): """ Use a record found in LDAP to populate input for NameID generation. """ + state = context.state + user_id = "" user_id_from_attrs = config['user_id_from_attrs'] for attr in user_id_from_attrs: if attr in record["attributes"]: value = record["attributes"][attr] if isinstance(value, list): - # Use a default sort to ensure some predictability since the - # LDAP directory server may return multi-valued attributes - # in any order. + # Use a default sort to ensure some predictability since + # the # LDAP directory server may return multi-valued + # attributes in any order. value.sort() user_id += "".join(value) - satosa_logging( - logger, - logging.DEBUG, - "Added attribute {} with values {} to input for NameID".format(attr, value), - context.state - ) + msg = "Added attribute {} with values {} " + msg = msg + "to input for NameID" + msg = msg.format(attr, value) + satosa_logging(logger, logging.DEBUG, msg, state) else: user_id += value - satosa_logging( - logger, - logging.DEBUG, - "Added attribute {} with value {} to input for NameID".format(attr, value), - context.state - ) + msg = "Added attribute {} with value {} to input " + msg = msg + "for NameID" + msg = msg.format(attr, value) + satosa_logging(logger, logging.DEBUG, msg, state) if not user_id: - satosa_logging( - logger, - logging.WARNING, - "Input for NameID is empty so not overriding default", - context.state - ) + msg = "Input for NameID is empty so not overriding default" + satosa_logging(logger, logging.WARNING, msg, state) else: data.subject_id = user_id - satosa_logging( - logger, - logging.DEBUG, - "Input for NameID is {}".format(data.subject_id), - context.state - ) + msg = "Input for NameID is {}".format(data.subject_id) + satosa_logging(logger, logging.DEBUG, msg, state) def process(self, context, data): """ @@ -375,15 +386,18 @@ def process(self, context, data): the input context. """ self.context = context + state = context.state # Find the entityID for the SP that initiated the flow. try: sp_entity_id = context.state.state_dict['SATOSA_BASE']['requester'] except KeyError as err: - satosa_logging(logger, logging.ERROR, "Unable to determine the entityID for the SP requester", context.state) + msg = "Unable to determine the entityID for the SP requester" + satosa_logging(logger, logging.ERROR, msg, state) return super().process(context, data) - satosa_logging(logger, logging.DEBUG, "entityID for the SP requester is {}".format(sp_entity_id), context.state) + msg = "entityID for the SP requester is {}".format(sp_entity_id) + satosa_logging(logger, logging.DEBUG, msg, state) # Get the configuration for the SP. if sp_entity_id in self.config.keys(): @@ -391,48 +405,61 @@ def process(self, context, data): else: config = self.config['default'] - satosa_logging(logger, logging.DEBUG, "Using config {}".format(self._filter_config(config)), context.state) + msg = "Using config {}".format(self._filter_config(config)) + satosa_logging(logger, logging.DEBUG, msg, state) # Ignore this SP entirely if so configured. if config['ignore']: - satosa_logging(logger, logging.INFO, "Ignoring SP {}".format(sp_entity_id), None) + msg = "Ignoring SP {}".format(sp_entity_id) + satosa_logging(logger, logging.INFO, msg, state) return super().process(context, data) - # The list of values for the LDAP search filters that will be tried in order to find the - # LDAP directory record for the user. + # The list of values for the LDAP search filters that will be tried in + # order to find the LDAP directory record for the user. filter_values = [] - # Loop over the configured list of identifiers from the IdP to consider and find - # asserted values to construct the ordered list of values for the LDAP search filters. + # Loop over the configured list of identifiers from the IdP to consider + # and find asserted values to construct the ordered list of values for + # the LDAP search filters. for candidate in config['ordered_identifier_candidates']: value = self._construct_filter_value(candidate, data) - # If we have constructed a non empty value then add it as the next filter value - # to use when searching for the user record. + # If we have constructed a non empty value then add it as the next + # filter value to use when searching for the user record. if value: filter_values.append(value) - satosa_logging(logger, logging.DEBUG, "Added search filter value {} to list of search filters".format(value), context.state) + msg = "Added search filter value {} to list of search filters" + msg = msg.format(value) + satosa_logging(logger, logging.DEBUG, msg, state) - # Initialize an empty LDAP record. The first LDAP record found using the ordered - # list of search filter values will be the record used. + # Initialize an empty LDAP record. The first LDAP record found using + # the ordered # list of search filter values will be the record used. record = None results = None exp_msg = None for filter_val in filter_values: connection = config['connection'] - search_filter = '({0}={1})'.format(config['ldap_identifier_attribute'], filter_val) - # show ldap filter - satosa_logging(logger, logging.INFO, "LDAP query for {}".format(search_filter), context.state) - satosa_logging(logger, logging.DEBUG, "Constructed search filter {}".format(search_filter), context.state) + ldap_ident_attr = config['ldap_identifier_attribute'] + search_filter = '({0}={1})'.format(ldap_ident_attr, filter_val) + # Show ldap filter. + msg = "LDAP query for {}".format(search_filter) + satosa_logging(logger, logging.INFO, msg, state) + msg = "Constructed search filter {}".format(search_filter) + satosa_logging(logger, logging.DEBUG, msg, state) try: - # message_id only works in REUSABLE async connection strategy - results = connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys()) + # message_id only works in REUSABLE async connection strategy. + attributes = config['search_return_attributes'].keys() + results = connection.search(config['search_base'], + search_filter, + attributes=attributes + ) except LDAPException as err: exp_msg = "Caught LDAP exception: {}".format(err) except LdapAttributeStoreError as err: - exp_msg = "Caught LDAP Attribute Store exception: {}".format(err) + exp_msg = "Caught LDAP Attribute Store exception: {}" + exp_msg = exp_msg.format(err) except Exception as err: exp_msg = "Caught unhandled exception: {}".format(err) @@ -441,7 +468,9 @@ def process(self, context, data): return super().process(context, data) if not results: - satosa_logging(logger, logging.DEBUG, "Querying LDAP server: No results for {}.".format(filter_val), context.state) + msg = "Querying LDAP server: No results for {}." + msg = msg.format(filter_val) + satosa_logging(logger, logging.DEBUG, msg, state) continue if isinstance(results, bool): @@ -449,54 +478,75 @@ def process(self, context, data): else: responses = connection.get_response(results)[0] - satosa_logging(logger, logging.DEBUG, "Done querying LDAP server", context.state) - satosa_logging(logger, logging.INFO, "LDAP server returned {} records".format(len(responses)), context.state) + msg = "Done querying LDAP server" + satosa_logging(logger, logging.DEBUG, msg, state) + msg = "LDAP server returned {} records".format(len(responses)) + satosa_logging(logger, logging.INFO, msg, state) - # for now consider only the first record found (if any) + # For now consider only the first record found (if any). if len(responses) > 0: if len(responses) > 1: - satosa_logging(logger, logging.WARN, "LDAP server returned {} records using search filter value {}".format(len(responses), filter_val), context.state) + msg = "LDAP server returned {} records using search filter" + msg = msg + " value {}" + msg = msg.format(len(responses), filter_val) + satosa_logging(logger, logging.WARN, msg, state) record = responses[0] break # Before using a found record, if any, to populate attributes # clear any attributes incoming to this microservice if so configured. if config['clear_input_attributes']: - satosa_logging(logger, logging.DEBUG, "Clearing values for these input attributes: {}".format(data.attributes), context.state) + msg = "Clearing values for these input attributes: {}" + msg = msg.format(data.attributes) + satosa_logging(logger, logging.DEBUG, msg, state) data.attributes = {} - # this adapts records with different search and connection strategy (sync without pool), it should be tested with anonimous bind with message_id + # This adapts records with different search and connection strategy + # (sync without pool), it should be tested with anonimous bind with + # message_id. if isinstance(results, bool): drec = dict() drec['dn'] = record.entry_dn if hasattr(record, 'entry_dn') else '' - drec['attributes'] = record.entry_attributes_as_dict if hasattr(record, 'entry_attributes_as_dict') else {} + drec['attributes'] = (record.entry_attributes_as_dict if + hasattr(record, 'entry_attributes_as_dict') + else {}) record = drec - # ends adaptation + # Ends adaptation. - # Use a found record, if any, to populate attributes and input for NameID + # Use a found record, if any, to populate attributes and input for + # NameID if record: - satosa_logging(logger, logging.DEBUG, "Using record with DN {}".format(record["dn"]), context.state) - satosa_logging(logger, logging.DEBUG, "Record with DN {} has attributes {}".format(record["dn"], record["attributes"]), context.state) + msg = "Using record with DN {}".format(record["dn"]) + satosa_logging(logger, logging.DEBUG, msg, state) + msg = "Record with DN {} has attributes {}" + msg = msg.format(record["dn"], record["attributes"]) + satosa_logging(logger, logging.DEBUG, msg, state) # Populate attributes as configured. self._populate_attributes(config, record, context, data) - # Populate input for NameID if configured. SATOSA core does the hashing of input - # to create a persistent NameID. + # Populate input for NameID if configured. SATOSA core does the + # hashing of input to create a persistent NameID. self._populate_input_for_name_id(config, record, context, data) else: - satosa_logging(logger, logging.WARN, "No record found in LDAP so no attributes will be added", context.state) + msg = "No record found in LDAP so no attributes will be added" + satosa_logging(logger, logging.WARN, msg, state) on_ldap_search_result_empty = config['on_ldap_search_result_empty'] if on_ldap_search_result_empty: # Redirect to the configured URL with # the entityIDs for the target SP and IdP used by the user # as query string parameters (URL encoded). encoded_sp_entity_id = urllib.parse.quote_plus(sp_entity_id) - encoded_idp_entity_id = urllib.parse.quote_plus(data.auth_info.issuer) - url = "{}?sp={}&idp={}".format(on_ldap_search_result_empty, encoded_sp_entity_id, encoded_idp_entity_id) - satosa_logging(logger, logging.INFO, "Redirecting to {}".format(url), context.state) + issuer = data.auth_info.issuer + encoded_idp_entity_id = urllib.parse.quote_plus(issuer) + url = "{}?sp={}&idp={}".format(on_ldap_search_result_empty, + encoded_sp_entity_id, + encoded_idp_entity_id) + msg = "Redirecting to {}".format(url) + satosa_logging(logger, logging.INFO, msg, state) return Redirect(url) - satosa_logging(logger, logging.DEBUG, "Returning data.attributes {}".format(str(data.attributes)), context.state) + msg = "Returning data.attributes {}".format(str(data.attributes)) + satosa_logging(logger, logging.DEBUG, msg, state) return ResponseMicroService.process(self, context, data) From 01c0defdb7b9eaec9c9e24a0bcd7c38df1ac47b9 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 11 Jul 2019 14:12:36 -0500 Subject: [PATCH 020/401] Separate LDAP query return attributes from mapping to internal attributes The config option search_return_attributes for the LDAP attribute store conflated what attribute values to return from the LDAP query with how those values should be mapped to internal attributes. This commit separates the functionality by introducing two new config options, query_return_attributes and ldap_to_internal_map. The search_return_attributes option is still supported for backwards compatibility. --- .../ldap_attribute_store.yaml.example | 20 ++++++++++++++++++- .../micro_services/ldap_attribute_store.py | 18 +++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index f392d115c..f882799d2 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -9,12 +9,30 @@ config: read_only : true version : 3 - # see ldap3 client_strategies + # See ldap3 client_strategies. client_strategy : RESTARTABLE auto_bind : true pool_size : 10 pool_keepalive : 10 + # Attributes to return from LDAP query. + query_return_attributes: + - sn + - givenName + - mail + - employeeNumber + - isMemberOf + + # LDAP attribute to internal attribute mapping. + ldap_to_internal_map: + sn: surname + givenName: givenname + mail: mail + employeeNumber: employeenumber + isMemberOf: ismemberof + + # Deprecated. Use query_return_attributes and + # ldap_to_internal_map instead. search_return_attributes: # Format is LDAP attribute name : internal attribute name sn: surname diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 844f240c7..541a1822c 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -42,9 +42,11 @@ class LdapAttributeStore(ResponseMicroService): 'ignore': False, 'ldap_identifier_attribute': None, 'ldap_url': None, + 'ldap_to_internal_map': None, 'on_ldap_search_result_empty': None, 'ordered_identifier_candidates': None, 'search_base': None, + 'query_return_attributes': None, 'search_return_attributes': None, 'user_id_from_attrs': [], 'read_only': True, @@ -328,11 +330,15 @@ def _populate_attributes(self, config, record, context, data): state = context.state attributes = data.attributes - search_return_attributes = config['search_return_attributes'] - for attr in search_return_attributes.keys(): + if config['ldap_to_internal_map']: + ldap_to_internal_map = config['ldap_to_internal_map'] + else: + # Deprecated configuration. Will be removed in future. + ldap_to_internal_map = config['search_return_attributes'] + for attr in ldap_to_internal_map.keys(): if attr in record["attributes"]: if record["attributes"][attr]: - internal_attr = search_return_attributes[attr] + internal_attr = ldap_to_internal_map[attr] value = record["attributes"][attr] attributes[internal_attr] = value msg = "Setting internal attribute {} with values {}" @@ -450,7 +456,11 @@ def process(self, context, data): try: # message_id only works in REUSABLE async connection strategy. - attributes = config['search_return_attributes'].keys() + if config['query_return_attributes']: + attributes = config['query_return_attributes'] + else: + # Deprecated configuration. Will be removed in future. + attributes = config['search_return_attributes'].keys() results = connection.search(config['search_base'], search_filter, attributes=attributes From bd3684b34f3a3b7479b8fa770acd8d4278310b71 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 11 Jul 2019 14:30:04 -0500 Subject: [PATCH 021/401] LDAP attribute store add found record to context Added logic so that the LDAP attribute store will add the found record to the context so that microservices that are called later can use it if so desired. --- src/satosa/micro_services/ldap_attribute_store.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 541a1822c..b6deee345 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -20,6 +20,8 @@ logger = logging.getLogger(__name__) +KEY_FOUND_LDAP_RECORD = 'ldap_attribute_store_found_record' + class LdapAttributeStoreError(SATOSAError): """ @@ -539,6 +541,11 @@ def process(self, context, data): # hashing of input to create a persistent NameID. self._populate_input_for_name_id(config, record, context, data) + # Add the record to the context so that later microservices + # may use it if required. + context.decorate(KEY_FOUND_LDAP_RECORD, record) + msg = "Added record {} to context".format(record) + satosa_logging(logger, logging.DEBUG, msg, state) else: msg = "No record found in LDAP so no attributes will be added" satosa_logging(logger, logging.WARN, msg, state) From fb18057486208270a0c6d18e163a5cefdd5efd01 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 11 Jul 2019 14:58:41 -0500 Subject: [PATCH 022/401] Revert to REUSABLE client strategy as default for ldap3 connection Revert to using ldap3.REUSABLE as the default client strategy and fix configuration to allow setting the client strategy. --- .../ldap_attribute_store.yaml.example | 9 ++++--- .../micro_services/ldap_attribute_store.py | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index f882799d2..a11bf01be 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -9,11 +9,14 @@ config: read_only : true version : 3 - # See ldap3 client_strategies. + # See ldap3 client_strategies. The default + # is REUSABLE. client_strategy : RESTARTABLE auto_bind : true - pool_size : 10 - pool_keepalive : 10 + # Specify pool size and keepalive when using + # REUSABLE client strategy. Defaults are 10 and 10. + #pool_size : 10 + #pool_keepalive : 10 # Attributes to return from LDAP query. query_return_attributes: diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index b6deee345..a3b6f3e90 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -54,7 +54,7 @@ class LdapAttributeStore(ResponseMicroService): 'read_only': True, 'version': 3, 'auto_bind': False, - 'client_strategy': ldap3.RESTARTABLE, + 'client_strategy': 'REUSABLE', 'pool_size': 10, 'pool_keepalive': 10, } @@ -287,18 +287,26 @@ def _ldap_connection_factory(self, config): msg = "Using bind DN {}".format(bind_dn) satosa_logging(logger, logging.DEBUG, msg, None) - pool_size = config['pool_size'] - pool_keepalive = config['pool_keepalive'] - msg = "Using pool size {}".format(pool_size) - satosa_logging(logger, logging.DEBUG, msg, None) - msg = "Using pool keep alive {}".format(pool_keepalive) - satosa_logging(logger, logging.DEBUG, msg, None) - auto_bind = config['auto_bind'] - client_strategy = config['client_strategy'] read_only = config['read_only'] version = config['version'] + client_strategy_string = config['client_strategy'] + client_strategy_map = {'SYNC': ldap3.SYNC, + 'ASYNC': ldap3.ASYNC, + 'LDIF': ldap3.LDIF, + 'RESTARTABLE': ldap3.RESTARTABLE, + 'REUSABLE': ldap3.REUSABLE} + client_strategy = client_strategy_map[client_strategy_string] + + pool_size = config['pool_size'] + pool_keepalive = config['pool_keepalive'] + if client_strategy == ldap3.REUSABLE: + msg = "Using pool size {}".format(pool_size) + satosa_logging(logger, logging.DEBUG, msg, None) + msg = "Using pool keep alive {}".format(pool_keepalive) + satosa_logging(logger, logging.DEBUG, msg, None) + try: connection = ldap3.Connection( server, From 96c9cfe5a3e5096f436f0bd604952c04a046e0f1 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 12 Sep 2019 17:49:08 -0500 Subject: [PATCH 023/401] Better default for auto_bind argument to ldap3.Connection object A Python False is not an acceptable value for the auto_bind argument to the ldap3.Connection object. This commit sets the default value to a module defined constant that makes the most sense when trying to preserve the REUSABLE strategy as the default (for now), and allows full configuration by defining a mapping between configuration string values and the ldap3 module constants, as is done for the client_strategy. --- src/satosa/micro_services/ldap_attribute_store.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index a3b6f3e90..36658e4e5 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -17,7 +17,6 @@ from ldap3.core.exceptions import LDAPException - logger = logging.getLogger(__name__) KEY_FOUND_LDAP_RECORD = 'ldap_attribute_store_found_record' @@ -53,7 +52,7 @@ class LdapAttributeStore(ResponseMicroService): 'user_id_from_attrs': [], 'read_only': True, 'version': 3, - 'auto_bind': False, + 'auto_bind': 'AUTO_BIND_TLS_BEFORE_BIND', 'client_strategy': 'REUSABLE', 'pool_size': 10, 'pool_keepalive': 10, @@ -287,7 +286,15 @@ def _ldap_connection_factory(self, config): msg = "Using bind DN {}".format(bind_dn) satosa_logging(logger, logging.DEBUG, msg, None) - auto_bind = config['auto_bind'] + auto_bind_string = config['auto_bind'] + auto_bind_map = { + 'AUTO_BIND_NONE': ldap3.AUTO_BIND_NONE, + 'AUTO_BIND_NO_TLS': ldap3.AUTO_BIND_NO_TLS, + 'AUTO_BIND_TLS_AFTER_BIND': ldap3.AUTO_BIND_TLS_AFTER_BIND, + 'AUTO_BIND_TLS_BEFORE_BIND': ldap3.AUTO_BIND_TLS_BEFORE_BIND + } + auto_bind = auto_bind_map[auto_bind_string] + read_only = config['read_only'] version = config['version'] From e7c585c4f363a1a996a519f7ae306130c4c63d12 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:16:21 +0300 Subject: [PATCH 024/401] Format and cleanup example Signed-off-by: Ivan Kanakarakis --- .../ldap_attribute_store.yaml.example | 86 +++++++++++-------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index a11bf01be..43dd20e1f 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -1,22 +1,27 @@ module: LdapAttributeStore name: LdapAttributeStore config: + + # The microservice may be configured per SP. + # The configuration key is the entityID of the SP. + # The empty key ("") specifies the default configuration "": ldap_url: ldaps://ldap.example.org bind_dn: cn=admin,dc=example,dc=org bind_password: xxxxxxxx search_base: ou=People,dc=example,dc=org - read_only : true - version : 3 + read_only: true + auto_bind: true + version: 3 - # See ldap3 client_strategies. The default - # is REUSABLE. - client_strategy : RESTARTABLE - auto_bind : true - # Specify pool size and keepalive when using - # REUSABLE client strategy. Defaults are 10 and 10. - #pool_size : 10 - #pool_keepalive : 10 + ## See ldap3 client_strategies. The default is REUSABLE. + client_strategy: RESTARTABLE + ## Specify pool settings when using REUSABLE client strategy. + # pool_size: number of open connection; default: 10 + pool_size: 10 + # pool_keepalive: seconds to wait between calls to server to keep the + # connection alive; default: 10 + pool_keepalive: 10 # Attributes to return from LDAP query. query_return_attributes: @@ -34,61 +39,72 @@ config: employeeNumber: employeenumber isMemberOf: ismemberof - # Deprecated. Use query_return_attributes and - # ldap_to_internal_map instead. + # Deprecated. + # Use query_return_attributes and ldap_to_internal_map instead. + # Format is LDAP attribute name: internal attribute name search_return_attributes: - # Format is LDAP attribute name : internal attribute name sn: surname givenName: givenname mail: mail employeeNumber: employeenumber isMemberOf: ismemberof - # LDAP connection pool size - pool_size: 10 - # LDAP connection pool seconds to wait between calls out to server - # to keep the connection alive (uses harmless Abandon(0) call) - pool_keepalive: 10 + + # Ordered list of identifiers to use when constructing the search filter + # to find the user record in LDAP directory. + # + # This example searches in order for eduPersonUniqueId, + # eduPersonPrincipalName combined with SAML persistent NameID, + # eduPersonPrincipalName combined with eduPersonTargetedId, + # eduPersonPrincipalName, SAML persistent NameID, and + # eduPersonTargetedId. ordered_identifier_candidates: - # Ordered list of identifiers to use when constructing the - # search filter to find the user record in LDAP directory. - # This example searches in order for eduPersonUniqueId, eduPersonPrincipalName - # combined with SAML persistent NameID, eduPersonPrincipalName - # combined with eduPersonTargetedId, eduPersonPrincipalName, - # SAML persistent NameID, and eduPersonTargetedId. - - attribute_names: [epuid] - - attribute_names: [eppn, name_id] + - attribute_names: + - epuid + - attribute_names: + - eppn + - name_id name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - - attribute_names: [eppn, edupersontargetedid] - - attribute_names: [eppn] - - attribute_names: [name_id] + - attribute_names: + - eppn + - edupersontargetedid + - attribute_names: + - eppn + - attribute_names: + - name_id name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent add_scope: issuer_entityid - - attribute_names: [edupersontargetedid] + - attribute_names: + - edupersontargetedid add_scope: issuer_entityid + ldap_identifier_attribute: uid + # Whether to clear values for attributes incoming # to this microservice. Default is no or false. clear_input_attributes: no + # List of LDAP attributes to use as input to hashing to create # NameID. user_id_from_attrs: - employeeNumber + # Where to redirect the browser if no record is returned # from LDAP. The default is not to redirect. on_ldap_search_result_empty: https://my.vo.org/please/go/enroll - # Configuration may also be done per-SP with any - # missing parameters taken from the default if any. + + # The microservice may be configured per SP. # The configuration key is the entityID of the SP. - # - # For example: + # Αny missing parameters are looked up from the default configuration. https://sp.myserver.edu/shibboleth-sp: search_base: ou=People,o=MyVO,dc=example,dc=org search_return_attributes: employeeNumber: employeenumber ordered_identifier_candidates: - - attribute_names: [eppn] + - attribute_names: + - eppn user_id_from_attrs: - uid + # The microservice may be configured to ignore a particular SP. https://another.sp.myserver.edu: ignore: true From fd9d10bb2d8f564cdfa40711e2851e02328567ac Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:16:51 +0300 Subject: [PATCH 025/401] Sort imports Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/ldap_attribute_store.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 36658e4e5..d335b131c 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -5,18 +5,19 @@ the record and assert them to the receiving SP. """ -from satosa.micro_services.base import ResponseMicroService -from satosa.logging_util import satosa_logging -from satosa.response import Redirect -from satosa.exception import SATOSAError - import copy import logging -import ldap3 import urllib +import ldap3 from ldap3.core.exceptions import LDAPException +from satosa.exception import SATOSAError +from satosa.logging_util import satosa_logging +from satosa.micro_services.base import ResponseMicroService +from satosa.response import Redirect + + logger = logging.getLogger(__name__) KEY_FOUND_LDAP_RECORD = 'ldap_attribute_store_found_record' From eb341a1226b3b158680bb6a2346360b327e3d621 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:17:54 +0300 Subject: [PATCH 026/401] Format code Signed-off-by: Ivan Kanakarakis --- .../micro_services/ldap_attribute_store.py | 218 +++++++++--------- 1 file changed, 110 insertions(+), 108 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index d335b131c..25a4090bb 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -20,13 +20,14 @@ logger = logging.getLogger(__name__) -KEY_FOUND_LDAP_RECORD = 'ldap_attribute_store_found_record' +KEY_FOUND_LDAP_RECORD = "ldap_attribute_store_found_record" class LdapAttributeStoreError(SATOSAError): """ LDAP attribute store error """ + pass @@ -38,39 +39,39 @@ class LdapAttributeStore(ResponseMicroService): """ config_defaults = { - 'bind_dn': None, - 'bind_password': None, - 'clear_input_attributes': False, - 'ignore': False, - 'ldap_identifier_attribute': None, - 'ldap_url': None, - 'ldap_to_internal_map': None, - 'on_ldap_search_result_empty': None, - 'ordered_identifier_candidates': None, - 'search_base': None, - 'query_return_attributes': None, - 'search_return_attributes': None, - 'user_id_from_attrs': [], - 'read_only': True, - 'version': 3, - 'auto_bind': 'AUTO_BIND_TLS_BEFORE_BIND', - 'client_strategy': 'REUSABLE', - 'pool_size': 10, - 'pool_keepalive': 10, - } + "bind_dn": None, + "bind_password": None, + "clear_input_attributes": False, + "ignore": False, + "ldap_identifier_attribute": None, + "ldap_url": None, + "ldap_to_internal_map": None, + "on_ldap_search_result_empty": None, + "ordered_identifier_candidates": None, + "search_base": None, + "query_return_attributes": None, + "search_return_attributes": None, + "user_id_from_attrs": [], + "read_only": True, + "version": 3, + "auto_bind": "AUTO_BIND_TLS_BEFORE_BIND", + "client_strategy": "REUSABLE", + "pool_size": 10, + "pool_keepalive": 10, + } def __init__(self, config, *args, **kwargs): super().__init__(*args, **kwargs) - if 'default' in config and "" in config: + if "default" in config and "" in config: msg = """Use either 'default' or "" in config but not both""" satosa_logging(logger, logging.ERROR, msg, None) raise LdapAttributeStoreError(msg) if "" in config: - config['default'] = config.pop("") + config["default"] = config.pop("") - if 'default' not in config: + if "default" not in config: msg = "No default configuration is present" satosa_logging(logger, logging.ERROR, msg, None) raise LdapAttributeStoreError(msg) @@ -78,8 +79,8 @@ def __init__(self, config, *args, **kwargs): self.config = {} # Process the default configuration first then any per-SP overrides. - sp_list = ['default'] - sp_list.extend([key for key in config.keys() if key != 'default']) + sp_list = ["default"] + sp_list.extend([key for key in config.keys() if key != "default"]) connections = {} @@ -93,35 +94,35 @@ def __init__(self, config, *args, **kwargs): # with configuration defaults and then per-SP overrides. # sp_config = copy.deepcopy(LdapAttributeStore.config_defaults) sp_config = copy.deepcopy(self.config_defaults) - if 'default' in self.config: - sp_config.update(self.config['default']) + if "default" in self.config: + sp_config.update(self.config["default"]) sp_config.update(config[sp]) # Tuple to index existing LDAP connections so they can be # re-used if there are no changes in parameters. connection_params = ( - sp_config['bind_dn'], - sp_config['bind_password'], - sp_config['ldap_url'], - sp_config['search_base'] - ) + sp_config["bind_dn"], + sp_config["bind_password"], + sp_config["ldap_url"], + sp_config["search_base"], + ) if connection_params in connections: - sp_config['connection'] = connections[connection_params] + sp_config["connection"] = connections[connection_params] msg = "Reusing LDAP connection for SP {}".format(sp) satosa_logging(logger, logging.DEBUG, msg, None) else: try: connection = self._ldap_connection_factory(sp_config) connections[connection_params] = connection - sp_config['connection'] = connection + sp_config["connection"] = connection msg = "Created new LDAP connection for SP {}".format(sp) satosa_logging(logger, logging.DEBUG, msg, None) except LdapAttributeStoreError: # It is acceptable to not have a default LDAP connection # but all SP overrides must have a connection, either # inherited from the default or directly configured. - if sp != 'default': + if sp != "default": msg = "No LDAP connection can be initialized for SP {}" msg = msg.format(sp) satosa_logging(logger, logging.ERROR, msg, None) @@ -174,7 +175,7 @@ def _construct_filter_value(self, candidate, data): # and substitute None if there are no values for a configured # identifier. values = [] - for identifier_name in candidate['attribute_names']: + for identifier_name in candidate["attribute_names"]: v = attributes.get(identifier_name, None) if isinstance(v, list): v = v[0] @@ -185,9 +186,9 @@ def _construct_filter_value(self, candidate, data): # If one of the configured identifier names is name_id then if there is # also a configured name_id_format add the value for the NameID of that # format if it was asserted by the IdP or else add the value None. - if 'name_id' in candidate['attribute_names']: + if "name_id" in candidate["attribute_names"]: candidate_nameid_value = None - candidate_name_id_format = candidate.get('name_id_format') + candidate_name_id_format = candidate.get("name_id_format") name_id_value = data.subject_id name_id_format = data.subject_type if ( @@ -224,17 +225,17 @@ def _construct_filter_value(self, candidate, data): # All values for the configured list of attribute names are present # so we can create a value. Add a scope if configured # to do so. - if 'add_scope' in candidate: - if candidate['add_scope'] == 'issuer_entityid': + if "add_scope" in candidate: + if candidate["add_scope"] == "issuer_entityid": scope = data.auth_info.issuer else: - scope = candidate['add_scope'] + scope = candidate["add_scope"] msg = "Added scope {} to values".format(scope) satosa_logging(logger, logging.DEBUG, msg, state) values.append(scope) # Concatenate all values to create the filter value. - value = ''.join(values) + value = "".join(values) msg = "Constructed filter value {}".format(value) satosa_logging(logger, logging.DEBUG, msg, state) @@ -246,28 +247,24 @@ def _filter_config(self, config, fields=None): Filter sensitive details like passwords from a configuration dictionary. """ - filter_fields_default = [ - 'bind_password', - 'connection' - ] + filter_fields_default = ["bind_password", "connection"] filter_fields = fields or filter_fields_default return dict( map( - lambda key: (key, '' if key in filter_fields - else config[key]), - config.keys() - ) + lambda key: (key, "" if key in filter_fields else config[key]), + config.keys(), ) + ) def _ldap_connection_factory(self, config): """ Use the input configuration to instantiate and return a ldap3 Connection object. """ - ldap_url = config['ldap_url'] - bind_dn = config['bind_dn'] - bind_password = config['bind_password'] + ldap_url = config["ldap_url"] + bind_dn = config["bind_dn"] + bind_password = config["bind_password"] if not ldap_url: raise LdapAttributeStoreError("ldap_url is not configured") @@ -276,7 +273,7 @@ def _ldap_connection_factory(self, config): if not bind_password: raise LdapAttributeStoreError("bind_password is not configured") - server = ldap3.Server(config['ldap_url']) + server = ldap3.Server(config["ldap_url"]) msg = "Creating a new LDAP connection" satosa_logging(logger, logging.DEBUG, msg, None) @@ -287,28 +284,30 @@ def _ldap_connection_factory(self, config): msg = "Using bind DN {}".format(bind_dn) satosa_logging(logger, logging.DEBUG, msg, None) - auto_bind_string = config['auto_bind'] + auto_bind_string = config["auto_bind"] auto_bind_map = { - 'AUTO_BIND_NONE': ldap3.AUTO_BIND_NONE, - 'AUTO_BIND_NO_TLS': ldap3.AUTO_BIND_NO_TLS, - 'AUTO_BIND_TLS_AFTER_BIND': ldap3.AUTO_BIND_TLS_AFTER_BIND, - 'AUTO_BIND_TLS_BEFORE_BIND': ldap3.AUTO_BIND_TLS_BEFORE_BIND - } + "AUTO_BIND_NONE": ldap3.AUTO_BIND_NONE, + "AUTO_BIND_NO_TLS": ldap3.AUTO_BIND_NO_TLS, + "AUTO_BIND_TLS_AFTER_BIND": ldap3.AUTO_BIND_TLS_AFTER_BIND, + "AUTO_BIND_TLS_BEFORE_BIND": ldap3.AUTO_BIND_TLS_BEFORE_BIND, + } auto_bind = auto_bind_map[auto_bind_string] - read_only = config['read_only'] - version = config['version'] + read_only = config["read_only"] + version = config["version"] - client_strategy_string = config['client_strategy'] - client_strategy_map = {'SYNC': ldap3.SYNC, - 'ASYNC': ldap3.ASYNC, - 'LDIF': ldap3.LDIF, - 'RESTARTABLE': ldap3.RESTARTABLE, - 'REUSABLE': ldap3.REUSABLE} + client_strategy_string = config["client_strategy"] + client_strategy_map = { + "SYNC": ldap3.SYNC, + "ASYNC": ldap3.ASYNC, + "LDIF": ldap3.LDIF, + "RESTARTABLE": ldap3.RESTARTABLE, + "REUSABLE": ldap3.REUSABLE, + } client_strategy = client_strategy_map[client_strategy_string] - pool_size = config['pool_size'] - pool_keepalive = config['pool_keepalive'] + pool_size = config["pool_size"] + pool_keepalive = config["pool_keepalive"] if client_strategy == ldap3.REUSABLE: msg = "Using pool size {}".format(pool_size) satosa_logging(logger, logging.DEBUG, msg, None) @@ -317,16 +316,16 @@ def _ldap_connection_factory(self, config): try: connection = ldap3.Connection( - server, - bind_dn, - bind_password, - auto_bind=auto_bind, - client_strategy=client_strategy, - read_only=read_only, - version=version, - pool_size=pool_size, - pool_keepalive=pool_keepalive - ) + server, + bind_dn, + bind_password, + auto_bind=auto_bind, + client_strategy=client_strategy, + read_only=read_only, + version=version, + pool_size=pool_size, + pool_keepalive=pool_keepalive, + ) msg = "Successfully connected to LDAP server" satosa_logging(logger, logging.DEBUG, msg, None) @@ -348,11 +347,11 @@ def _populate_attributes(self, config, record, context, data): state = context.state attributes = data.attributes - if config['ldap_to_internal_map']: - ldap_to_internal_map = config['ldap_to_internal_map'] + if config["ldap_to_internal_map"]: + ldap_to_internal_map = config["ldap_to_internal_map"] else: # Deprecated configuration. Will be removed in future. - ldap_to_internal_map = config['search_return_attributes'] + ldap_to_internal_map = config["search_return_attributes"] for attr in ldap_to_internal_map.keys(): if attr in record["attributes"]: if record["attributes"][attr]: @@ -376,7 +375,7 @@ def _populate_input_for_name_id(self, config, record, context, data): state = context.state user_id = "" - user_id_from_attrs = config['user_id_from_attrs'] + user_id_from_attrs = config["user_id_from_attrs"] for attr in user_id_from_attrs: if attr in record["attributes"]: value = record["attributes"][attr] @@ -414,7 +413,7 @@ def process(self, context, data): # Find the entityID for the SP that initiated the flow. try: - sp_entity_id = context.state.state_dict['SATOSA_BASE']['requester'] + sp_entity_id = context.state.state_dict["SATOSA_BASE"]["requester"] except KeyError as err: msg = "Unable to determine the entityID for the SP requester" satosa_logging(logger, logging.ERROR, msg, state) @@ -427,13 +426,13 @@ def process(self, context, data): if sp_entity_id in self.config.keys(): config = self.config[sp_entity_id] else: - config = self.config['default'] + config = self.config["default"] msg = "Using config {}".format(self._filter_config(config)) satosa_logging(logger, logging.DEBUG, msg, state) # Ignore this SP entirely if so configured. - if config['ignore']: + if config["ignore"]: msg = "Ignoring SP {}".format(sp_entity_id) satosa_logging(logger, logging.INFO, msg, state) return super().process(context, data) @@ -445,7 +444,7 @@ def process(self, context, data): # Loop over the configured list of identifiers from the IdP to consider # and find asserted values to construct the ordered list of values for # the LDAP search filters. - for candidate in config['ordered_identifier_candidates']: + for candidate in config["ordered_identifier_candidates"]: value = self._construct_filter_value(candidate, data) # If we have constructed a non empty value then add it as the next @@ -463,9 +462,9 @@ def process(self, context, data): exp_msg = None for filter_val in filter_values: - connection = config['connection'] - ldap_ident_attr = config['ldap_identifier_attribute'] - search_filter = '({0}={1})'.format(ldap_ident_attr, filter_val) + connection = config["connection"] + ldap_ident_attr = config["ldap_identifier_attribute"] + search_filter = "({0}={1})".format(ldap_ident_attr, filter_val) # Show ldap filter. msg = "LDAP query for {}".format(search_filter) satosa_logging(logger, logging.INFO, msg, state) @@ -474,15 +473,14 @@ def process(self, context, data): try: # message_id only works in REUSABLE async connection strategy. - if config['query_return_attributes']: - attributes = config['query_return_attributes'] + if config["query_return_attributes"]: + attributes = config["query_return_attributes"] else: # Deprecated configuration. Will be removed in future. - attributes = config['search_return_attributes'].keys() - results = connection.search(config['search_base'], - search_filter, - attributes=attributes - ) + attributes = config["search_return_attributes"].keys() + results = connection.search( + config["search_base"], search_filter, attributes=attributes + ) except LDAPException as err: exp_msg = "Caught LDAP exception: {}".format(err) except LdapAttributeStoreError as err: @@ -523,7 +521,7 @@ def process(self, context, data): # Before using a found record, if any, to populate attributes # clear any attributes incoming to this microservice if so configured. - if config['clear_input_attributes']: + if config["clear_input_attributes"]: msg = "Clearing values for these input attributes: {}" msg = msg.format(data.attributes) satosa_logging(logger, logging.DEBUG, msg, state) @@ -534,10 +532,12 @@ def process(self, context, data): # message_id. if isinstance(results, bool): drec = dict() - drec['dn'] = record.entry_dn if hasattr(record, 'entry_dn') else '' - drec['attributes'] = (record.entry_attributes_as_dict if - hasattr(record, 'entry_attributes_as_dict') - else {}) + drec["dn"] = record.entry_dn if hasattr(record, "entry_dn") else "" + drec["attributes"] = ( + record.entry_attributes_as_dict + if hasattr(record, "entry_attributes_as_dict") + else {} + ) record = drec # Ends adaptation. @@ -565,7 +565,7 @@ def process(self, context, data): else: msg = "No record found in LDAP so no attributes will be added" satosa_logging(logger, logging.WARN, msg, state) - on_ldap_search_result_empty = config['on_ldap_search_result_empty'] + on_ldap_search_result_empty = config["on_ldap_search_result_empty"] if on_ldap_search_result_empty: # Redirect to the configured URL with # the entityIDs for the target SP and IdP used by the user @@ -573,9 +573,11 @@ def process(self, context, data): encoded_sp_entity_id = urllib.parse.quote_plus(sp_entity_id) issuer = data.auth_info.issuer encoded_idp_entity_id = urllib.parse.quote_plus(issuer) - url = "{}?sp={}&idp={}".format(on_ldap_search_result_empty, - encoded_sp_entity_id, - encoded_idp_entity_id) + url = "{}?sp={}&idp={}".format( + on_ldap_search_result_empty, + encoded_sp_entity_id, + encoded_idp_entity_id, + ) msg = "Redirecting to {}".format(url) satosa_logging(logger, logging.INFO, msg, state) return Redirect(url) From e9a947f37b0e805ec14ef98534557d9fd3e934b8 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:19:33 +0300 Subject: [PATCH 027/401] Remove unneeded pass statement Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/ldap_attribute_store.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 25a4090bb..23d133be8 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -28,8 +28,6 @@ class LdapAttributeStoreError(SATOSAError): LDAP attribute store error """ - pass - class LdapAttributeStore(ResponseMicroService): """ From e1f334834545c16a3b1c8c98875e0c5533160d9a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:22:29 +0300 Subject: [PATCH 028/401] Redo _construct_filter_value Signed-off-by: Ivan Kanakarakis --- .../micro_services/ldap_attribute_store.py | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 23d133be8..cb4c87748 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -131,7 +131,9 @@ def __init__(self, config, *args, **kwargs): msg = "LDAP Attribute Store microservice initialized" satosa_logging(logger, logging.INFO, msg, None) - def _construct_filter_value(self, candidate, data): + def _construct_filter_value( + self, candidate, name_id_value, name_id_format, issuer, attributes + ): """ Construct and return a LDAP directory search filter value from the candidate identifier. @@ -162,24 +164,16 @@ def _construct_filter_value(self, candidate, data): entityID for the IdP will be concatenated to "scope" the value. If the string is any other value it will be directly concatenated. """ - context = self.context - state = context.state - - attributes = data.attributes - msg = "Input attributes {}".format(attributes) - satosa_logging(logger, logging.DEBUG, msg, state) - # Get the values configured list of identifier names for this candidate # and substitute None if there are no values for a configured # identifier. - values = [] - for identifier_name in candidate["attribute_names"]: - v = attributes.get(identifier_name, None) - if isinstance(v, list): - v = v[0] - values.append(v) + values = [ + attr_value[0] if isinstance(attr_value, list) else attr_value + for identifier_name in candidate["attribute_names"] + for attr_value in [attributes.get(identifier_name)] + ] msg = "Found candidate values {}".format(values) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, None) # If one of the configured identifier names is name_id then if there is # also a configured name_id_format add the value for the NameID of that @@ -187,15 +181,13 @@ def _construct_filter_value(self, candidate, data): if "name_id" in candidate["attribute_names"]: candidate_nameid_value = None candidate_name_id_format = candidate.get("name_id_format") - name_id_value = data.subject_id - name_id_format = data.subject_type if ( name_id_value and candidate_name_id_format and candidate_name_id_format == name_id_format ): msg = "IdP asserted NameID {}".format(name_id_value) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, None) candidate_nameid_value = name_id_value # Only add the NameID value asserted by the IdP if it is not @@ -206,37 +198,38 @@ def _construct_filter_value(self, candidate, data): if candidate_nameid_value not in values: msg = "Added NameID {} to candidate values" msg = msg.format(candidate_nameid_value) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, None) values.append(candidate_nameid_value) else: msg = "NameID {} value also asserted as attribute value" msg = msg.format(candidate_nameid_value) - satosa_logging(logger, logging.WARN, msg, state) + satosa_logging(logger, logging.WARN, msg, None) # If no value was asserted by the IdP for one of the configured list of # identifier names for this candidate then go onto the next candidate. if None in values: msg = "Candidate is missing value so skipping" - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, None) return None # All values for the configured list of attribute names are present # so we can create a value. Add a scope if configured # to do so. if "add_scope" in candidate: - if candidate["add_scope"] == "issuer_entityid": - scope = data.auth_info.issuer - else: - scope = candidate["add_scope"] + scope = ( + issuer + if candidate["add_scope"] == "issuer_entityid" + else candidate["add_scope"] + ) msg = "Added scope {} to values".format(scope) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, None) values.append(scope) # Concatenate all values to create the filter value. value = "".join(values) msg = "Constructed filter value {}".format(value) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, None) return value @@ -443,7 +436,13 @@ def process(self, context, data): # and find asserted values to construct the ordered list of values for # the LDAP search filters. for candidate in config["ordered_identifier_candidates"]: - value = self._construct_filter_value(candidate, data) + value = self._construct_filter_value( + candidate, + data.subject_id, + data.subject_type, + data.auth_info.issuer, + data.attriutes, + ) # If we have constructed a non empty value then add it as the next # filter value to use when searching for the user record. From 39bbbc70ca3cd864e27637995c1f6e3caf91b3c1 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:26:17 +0300 Subject: [PATCH 029/401] Redo _filter_config Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/ldap_attribute_store.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index cb4c87748..163d48758 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -239,14 +239,12 @@ def _filter_config(self, config, fields=None): dictionary. """ filter_fields_default = ["bind_password", "connection"] - filter_fields = fields or filter_fields_default - return dict( - map( - lambda key: (key, "" if key in filter_fields else config[key]), - config.keys(), - ) - ) + result = { + field: "" if field in filter_fields else value + for field, value in config.items() + } + return result def _ldap_connection_factory(self, config): """ From 92fc670a1643bb409be03da715f3b776dee95c88 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:27:57 +0300 Subject: [PATCH 030/401] Redo _populate_attributes Signed-off-by: Ivan Kanakarakis --- .../micro_services/ldap_attribute_store.py | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 163d48758..0c7e1ddb9 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -329,32 +329,24 @@ def _ldap_connection_factory(self, config): return connection - def _populate_attributes(self, config, record, context, data): + def _populate_attributes(self, config, record): """ Use a record found in LDAP to populate attributes. """ - state = context.state - attributes = data.attributes - - if config["ldap_to_internal_map"]: - ldap_to_internal_map = config["ldap_to_internal_map"] - else: + ldap_to_internal_map = ( + config["ldap_to_internal_map"] + if config["ldap_to_internal_map"] # Deprecated configuration. Will be removed in future. - ldap_to_internal_map = config["search_return_attributes"] - for attr in ldap_to_internal_map.keys(): - if attr in record["attributes"]: - if record["attributes"][attr]: - internal_attr = ldap_to_internal_map[attr] - value = record["attributes"][attr] - attributes[internal_attr] = value - msg = "Setting internal attribute {} with values {}" - msg = msg.format(internal_attr, value) - satosa_logging(logger, logging.DEBUG, msg, state) - else: - msg = "Not setting internal attribute {} because value {}" - msg = msg + " is null or empty" - msg = msg.format(internal_attr, value) - satosa_logging(logger, logging.DEBUG, msg, state) + else config["search_return_attributes"] + ) + + new_attr_values = { + internal_attr: value + for attr, internal_attr in ldap_to_internal_map.items() + for value in [record["attributes"].get(attr)] + if value + } + return new_attr_values def _populate_input_for_name_id(self, config, record, context, data): """ @@ -546,7 +538,9 @@ def process(self, context, data): satosa_logging(logger, logging.DEBUG, msg, state) # Populate attributes as configured. - self._populate_attributes(config, record, context, data) + new_attrs = self._populate_attributes(config, record) + msg = "Updating internal attributes with new values {}".format(new_attrs) + satosa_logging(logger, logging.DEBUG, msg, None) # Populate input for NameID if configured. SATOSA core does the # hashing of input to create a persistent NameID. From 7f8c8db20e5b1e7256232caecceb4928d380521c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:31:27 +0300 Subject: [PATCH 031/401] Redo _populate_input_for_name_id Signed-off-by: Ivan Kanakarakis --- .../micro_services/ldap_attribute_store.py | 46 ++++++------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 0c7e1ddb9..b2633e248 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -348,41 +348,21 @@ def _populate_attributes(self, config, record): } return new_attr_values - def _populate_input_for_name_id(self, config, record, context, data): + def _populate_input_for_name_id(self, config, record, data): """ Use a record found in LDAP to populate input for NameID generation. """ - state = context.state - - user_id = "" user_id_from_attrs = config["user_id_from_attrs"] - for attr in user_id_from_attrs: - if attr in record["attributes"]: - value = record["attributes"][attr] - if isinstance(value, list): - # Use a default sort to ensure some predictability since - # the # LDAP directory server may return multi-valued - # attributes in any order. - value.sort() - user_id += "".join(value) - msg = "Added attribute {} with values {} " - msg = msg + "to input for NameID" - msg = msg.format(attr, value) - satosa_logging(logger, logging.DEBUG, msg, state) - else: - user_id += value - msg = "Added attribute {} with value {} to input " - msg = msg + "for NameID" - msg = msg.format(attr, value) - satosa_logging(logger, logging.DEBUG, msg, state) - if not user_id: - msg = "Input for NameID is empty so not overriding default" - satosa_logging(logger, logging.WARNING, msg, state) - else: - data.subject_id = user_id - msg = "Input for NameID is {}".format(data.subject_id) - satosa_logging(logger, logging.DEBUG, msg, state) + user_ids = [ + sorted_list_value + for attr in user_id_from_attrs + for value in [record["attributes"].get(attr)] + if value + for list_value in [value if type(value) is list else [value]] + for sorted_list_value in sorted(list_value) + ] + return user_ids def process(self, context, data): """ @@ -544,7 +524,11 @@ def process(self, context, data): # Populate input for NameID if configured. SATOSA core does the # hashing of input to create a persistent NameID. - self._populate_input_for_name_id(config, record, context, data) + user_ids = self._populate_input_for_name_id(config, record, data) + if user_ids: + data.subject_id = "".join(user_ids) + msg = "NameID value is {}".format(data.subject_id) + satosa_logging(logger, logging.DEBUG, msg, None) # Add the record to the context so that later microservices # may use it if required. From a58068110b6ea8a2f0bb3cc51c625cc25c4a0376 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 14 Sep 2019 00:34:42 +0300 Subject: [PATCH 032/401] Redo process Signed-off-by: Ivan Kanakarakis --- .../micro_services/ldap_attribute_store.py | 150 ++++++++---------- 1 file changed, 68 insertions(+), 82 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index b2633e248..c05774cf0 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -369,58 +369,45 @@ def process(self, context, data): Default interface for microservices. Process the input data for the input context. """ - self.context = context - state = context.state - - # Find the entityID for the SP that initiated the flow. - try: - sp_entity_id = context.state.state_dict["SATOSA_BASE"]["requester"] - except KeyError as err: - msg = "Unable to determine the entityID for the SP requester" - satosa_logging(logger, logging.ERROR, msg, state) - return super().process(context, data) - - msg = "entityID for the SP requester is {}".format(sp_entity_id) - satosa_logging(logger, logging.DEBUG, msg, state) - - # Get the configuration for the SP. - if sp_entity_id in self.config.keys(): - config = self.config[sp_entity_id] - else: - config = self.config["default"] - - msg = "Using config {}".format(self._filter_config(config)) - satosa_logging(logger, logging.DEBUG, msg, state) + issuer = data.auth_info.issuer + requester = data.requester + config = self.config.get(requester) or self.config["default"] + msg = { + "message": "entityID for the involved entities", + "requester": requester, + "issuer": issuer, + "config": self._filter_config(config), + } + satosa_logging(logger, logging.DEBUG, msg, context.state) # Ignore this SP entirely if so configured. if config["ignore"]: - msg = "Ignoring SP {}".format(sp_entity_id) - satosa_logging(logger, logging.INFO, msg, state) + msg = "Ignoring SP {}".format(requester) + satosa_logging(logger, logging.INFO, msg, context.state) return super().process(context, data) # The list of values for the LDAP search filters that will be tried in # order to find the LDAP directory record for the user. - filter_values = [] - - # Loop over the configured list of identifiers from the IdP to consider - # and find asserted values to construct the ordered list of values for - # the LDAP search filters. - for candidate in config["ordered_identifier_candidates"]: - value = self._construct_filter_value( - candidate, - data.subject_id, - data.subject_type, - data.auth_info.issuer, - data.attriutes, - ) - + filter_values = [ + filter_value + for candidate in config["ordered_identifier_candidates"] + # Consider and find asserted values to construct the ordered list + # of values for the LDAP search filters. + for filter_value in [ + self._construct_filter_value( + candidate, + data.subject_id, + data.subject_type, + issuer, + data.attriutes, + ) + ] # If we have constructed a non empty value then add it as the next # filter value to use when searching for the user record. - if value: - filter_values.append(value) - msg = "Added search filter value {} to list of search filters" - msg = msg.format(value) - satosa_logging(logger, logging.DEBUG, msg, state) + if filter_value + ] + msg = {"message": "Search filters", "filter_values": filter_values} + satosa_logging(logger, logging.DEBUG, msg, context.state) # Initialize an empty LDAP record. The first LDAP record found using # the ordered # list of search filter values will be the record used. @@ -432,19 +419,19 @@ def process(self, context, data): connection = config["connection"] ldap_ident_attr = config["ldap_identifier_attribute"] search_filter = "({0}={1})".format(ldap_ident_attr, filter_val) - # Show ldap filter. - msg = "LDAP query for {}".format(search_filter) - satosa_logging(logger, logging.INFO, msg, state) - msg = "Constructed search filter {}".format(search_filter) - satosa_logging(logger, logging.DEBUG, msg, state) - + msg = { + "message": "LDAP query with constructed search filter", + "search filter": search_filter, + } + satosa_logging(logger, logging.DEBUG, msg, context.state) + + attributes = ( + config["query_return_attributes"] + if config["query_return_attributes"] + # Deprecated configuration. Will be removed in future. + else config["search_return_attributes"].keys() + ) try: - # message_id only works in REUSABLE async connection strategy. - if config["query_return_attributes"]: - attributes = config["query_return_attributes"] - else: - # Deprecated configuration. Will be removed in future. - attributes = config["search_return_attributes"].keys() results = connection.search( config["search_base"], search_filter, attributes=attributes ) @@ -463,7 +450,7 @@ def process(self, context, data): if not results: msg = "Querying LDAP server: No results for {}." msg = msg.format(filter_val) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, context.state) continue if isinstance(results, bool): @@ -472,9 +459,9 @@ def process(self, context, data): responses = connection.get_response(results)[0] msg = "Done querying LDAP server" - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, context.state) msg = "LDAP server returned {} records".format(len(responses)) - satosa_logging(logger, logging.INFO, msg, state) + satosa_logging(logger, logging.INFO, msg, context.state) # For now consider only the first record found (if any). if len(responses) > 0: @@ -482,7 +469,7 @@ def process(self, context, data): msg = "LDAP server returned {} records using search filter" msg = msg + " value {}" msg = msg.format(len(responses), filter_val) - satosa_logging(logger, logging.WARN, msg, state) + satosa_logging(logger, logging.WARN, msg, context.state) record = responses[0] break @@ -491,31 +478,31 @@ def process(self, context, data): if config["clear_input_attributes"]: msg = "Clearing values for these input attributes: {}" msg = msg.format(data.attributes) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, context.state) data.attributes = {} # This adapts records with different search and connection strategy # (sync without pool), it should be tested with anonimous bind with # message_id. if isinstance(results, bool): - drec = dict() - drec["dn"] = record.entry_dn if hasattr(record, "entry_dn") else "" - drec["attributes"] = ( - record.entry_attributes_as_dict - if hasattr(record, "entry_attributes_as_dict") - else {} - ) - record = drec - # Ends adaptation. + record = { + "dn": record.entry_dn if hasattr(record, "entry_dn") else "", + "attributes": ( + record.entry_attributes_as_dict + if hasattr(record, "entry_attributes_as_dict") + else {} + ), + } # Use a found record, if any, to populate attributes and input for # NameID if record: - msg = "Using record with DN {}".format(record["dn"]) - satosa_logging(logger, logging.DEBUG, msg, state) - msg = "Record with DN {} has attributes {}" - msg = msg.format(record["dn"], record["attributes"]) - satosa_logging(logger, logging.DEBUG, msg, state) + msg = { + "message": "Using record with DN and attributes", + "DN": record["dn"], + "attributes": record["attributes"], + } + satosa_logging(logger, logging.DEBUG, msg, context.state) # Populate attributes as configured. new_attrs = self._populate_attributes(config, record) @@ -534,17 +521,16 @@ def process(self, context, data): # may use it if required. context.decorate(KEY_FOUND_LDAP_RECORD, record) msg = "Added record {} to context".format(record) - satosa_logging(logger, logging.DEBUG, msg, state) + satosa_logging(logger, logging.DEBUG, msg, context.state) else: msg = "No record found in LDAP so no attributes will be added" - satosa_logging(logger, logging.WARN, msg, state) + satosa_logging(logger, logging.WARN, msg, context.state) on_ldap_search_result_empty = config["on_ldap_search_result_empty"] if on_ldap_search_result_empty: # Redirect to the configured URL with # the entityIDs for the target SP and IdP used by the user # as query string parameters (URL encoded). - encoded_sp_entity_id = urllib.parse.quote_plus(sp_entity_id) - issuer = data.auth_info.issuer + encoded_sp_entity_id = urllib.parse.quote_plus(requester) encoded_idp_entity_id = urllib.parse.quote_plus(issuer) url = "{}?sp={}&idp={}".format( on_ldap_search_result_empty, @@ -552,9 +538,9 @@ def process(self, context, data): encoded_idp_entity_id, ) msg = "Redirecting to {}".format(url) - satosa_logging(logger, logging.INFO, msg, state) + satosa_logging(logger, logging.INFO, msg, context.state) return Redirect(url) - msg = "Returning data.attributes {}".format(str(data.attributes)) - satosa_logging(logger, logging.DEBUG, msg, state) - return ResponseMicroService.process(self, context, data) + msg = "Returning data.attributes {}".format(data.attributes) + satosa_logging(logger, logging.DEBUG, msg, context.state) + return super().process(context, data) From d62946c453a314b2446517bfb496549b0b4c9b69 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Fri, 13 Sep 2019 17:50:06 -0500 Subject: [PATCH 033/401] Better consumption of attributes from returned record Better consumption of the attributes from the returned record to take into account handling of attributes from LDAP that include attribute options. Also included new option to determine whether attributes resolved from LDAP should overwrite existing internal attributes, the default, or be merged. --- .../micro_services/ldap_attribute_store.py | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index c05774cf0..f4a25d701 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -12,6 +12,8 @@ import ldap3 from ldap3.core.exceptions import LDAPException +from collections import defaultdict + from satosa.exception import SATOSAError from satosa.logging_util import satosa_logging from satosa.micro_services.base import ResponseMicroService @@ -46,6 +48,7 @@ class LdapAttributeStore(ResponseMicroService): "ldap_to_internal_map": None, "on_ldap_search_result_empty": None, "ordered_identifier_candidates": None, + "overwrite_existing_attributes": True, "search_base": None, "query_return_attributes": None, "search_return_attributes": None, @@ -333,6 +336,13 @@ def _populate_attributes(self, config, record): """ Use a record found in LDAP to populate attributes. """ + + ldap_attributes = record.get("attributes", None) + if not ldap_attributes: + msg = "No attributes returned with LDAP record" + satosa_logging(logger, logging.DEBUG, msg, None) + return + ldap_to_internal_map = ( config["ldap_to_internal_map"] if config["ldap_to_internal_map"] @@ -340,13 +350,21 @@ def _populate_attributes(self, config, record): else config["search_return_attributes"] ) - new_attr_values = { - internal_attr: value - for attr, internal_attr in ldap_to_internal_map.items() - for value in [record["attributes"].get(attr)] - if value - } - return new_attr_values + attributes = defaultdict(list) + + for attr, values in ldap_attributes.items(): + internal_attr = ldap_to_internal_map.get(attr, None) + if not internal_attr and ';' in attr: + internal_attr = ldap_to_internal_map.get(attr.split(';')[0], + None) + + if internal_attr and values: + attributes[internal_attr].extend(values) + msg = "Recording internal attribute {} with values {}" + msg = msg.format(internal_attr, attributes[internal_attr]) + satosa_logging(logger, logging.DEBUG, msg, None) + + return attributes def _populate_input_for_name_id(self, config, record, data): """ @@ -399,7 +417,7 @@ def process(self, context, data): data.subject_id, data.subject_type, issuer, - data.attriutes, + data.attributes, ) ] # If we have constructed a non empty value then add it as the next @@ -506,8 +524,12 @@ def process(self, context, data): # Populate attributes as configured. new_attrs = self._populate_attributes(config, record) - msg = "Updating internal attributes with new values {}".format(new_attrs) - satosa_logging(logger, logging.DEBUG, msg, None) + + overwrite = config["overwrite_existing_attributes"] + for attr, values in new_attrs.items(): + if not overwrite: + values = list(set(data.attributes.get(attr, []) + values)) + data.attributes[attr] = values # Populate input for NameID if configured. SATOSA core does the # hashing of input to create a persistent NameID. From 83a323027b3cc2a14fea6231add522efc23f9296 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 18 Sep 2019 01:20:00 +0300 Subject: [PATCH 034/401] Format code Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/ldap_attribute_store.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index f4a25d701..ae7634429 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -354,9 +354,8 @@ def _populate_attributes(self, config, record): for attr, values in ldap_attributes.items(): internal_attr = ldap_to_internal_map.get(attr, None) - if not internal_attr and ';' in attr: - internal_attr = ldap_to_internal_map.get(attr.split(';')[0], - None) + if not internal_attr and ";" in attr: + internal_attr = ldap_to_internal_map.get(attr.split(";")[0], None) if internal_attr and values: attributes[internal_attr].extend(values) From 4cfd932cf4d7f8cc9f1be39c0133d2ced4064225 Mon Sep 17 00:00:00 2001 From: Hannah Sebuliba Date: Thu, 19 Sep 2019 20:58:06 +0300 Subject: [PATCH 035/401] Improve current logging for SATOSA (#275) Remove satosa_logging for the routing module --- src/satosa/routing.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/satosa/routing.py b/src/satosa/routing.py index babd76ffd..f54415178 100644 --- a/src/satosa/routing.py +++ b/src/satosa/routing.py @@ -6,7 +6,6 @@ from .context import SATOSABadContextError from .exception import SATOSAError -from .logging_util import satosa_logging logger = logging.getLogger(__name__) @@ -66,9 +65,9 @@ def __init__(self, frontends, backends, micro_services): else: self.micro_services = {} - logger.debug("Loaded backends with endpoints: %s" % backends) - logger.debug("Loaded frontends with endpoints: %s" % frontends) - logger.debug("Loaded micro services with endpoints: %s" % micro_services) + logger.debug("Loaded backends with endpoints: {}".format(backends)) + logger.debug("Loaded frontends with endpoints: {}".format(frontends)) + logger.debug("Loaded micro services with endpoints: {}".format(micro_services)) def backend_routing(self, context): """ @@ -80,7 +79,9 @@ def backend_routing(self, context): :param context: The request context :return: backend """ - satosa_logging(logger, logging.DEBUG, "Routing to backend: %s " % context.target_backend, context.state) + msg = "Routing to backend: {backend}".format(backend=context.target_backend) + logline = "[{id}] {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logger.debug(logline) backend = self.backends[context.target_backend]["instance"] context.state[STATE_KEY] = context.target_frontend return backend @@ -97,7 +98,9 @@ def frontend_routing(self, context): """ target_frontend = context.state[STATE_KEY] - satosa_logging(logger, logging.DEBUG, "Routing to frontend: %s " % target_frontend, context.state) + msg = "Routing to frontend: {frontend}".format(frontend=target_frontend) + logline = "[{id}] {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logger.debug(logline) context.target_frontend = target_frontend frontend = self.frontends[context.target_frontend]["instance"] return frontend @@ -109,7 +112,9 @@ def _find_registered_endpoint_for_module(self, module, context): msg = "Found registered endpoint: module name:'{name}', endpoint: {endpoint}".format( name=module["instance"].name, endpoint=context.path) - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = "[{id}] {message}".format( + id=context.state.get("SESSION_ID"), message=msg) + logger.debug(logline) return spec return None @@ -136,17 +141,24 @@ def endpoint_routing(self, context): :return: registered endpoint and bound parameters """ if context.path is None: - satosa_logging(logger, logging.DEBUG, "Context did not contain a path!", context.state) + msg = "Context did not contain a path!" + logline = "[{id}] {message}".format( + id=context.state.get("SESSION_ID"), message=msg) + logger.debug(logline) raise SATOSABadContextError("Context did not contain any path") - satosa_logging(logger, logging.DEBUG, "Routing path: %s" % context.path, context.state) + msg = "Routing path: {path}".format(path=context.path) + logline = "[{id}] {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logger.debug(logline) path_split = context.path.split("/") backend = path_split[0] if backend in self.backends: context.target_backend = backend else: - satosa_logging(logger, logging.DEBUG, "Unknown backend %s" % backend, context.state) + msg = "Unknown backend {}".format(backend) + logline = "[{id} {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logger.debug(logline) try: name, frontend_endpoint = self._find_registered_endpoint(context, self.frontends) From d730199ee4b6de6731343825b4122f0c9eb76c14 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 19 Sep 2019 21:40:40 +0300 Subject: [PATCH 036/401] Avoid using relative imports Signed-off-by: Ivan Kanakarakis --- src/satosa/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/context.py b/src/satosa/context.py index 2574d29a4..16947235e 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -1,4 +1,4 @@ -from .exception import SATOSAError +from satosa.exception import SATOSAError class SATOSABadContextError(SATOSAError): From 053df986ae1e45a938d9de97495474c2aef30530 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 19 Sep 2019 21:41:14 +0300 Subject: [PATCH 037/401] Extract the log format and session id derivation Signed-off-by: Ivan Kanakarakis --- src/satosa/logging_util.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/satosa/logging_util.py b/src/satosa/logging_util.py index 931c7e585..0a64a55e4 100644 --- a/src/satosa/logging_util.py +++ b/src/satosa/logging_util.py @@ -1,10 +1,18 @@ -""" -Python logging package -""" from uuid import uuid4 + # The state key for saving the session id in the state LOGGER_STATE_KEY = "SESSION_ID" +LOG_FMT = "[{id}] {message}" + + +def get_session_id(state): + session_id = ( + "UNKNOWN" + if state is None + else state.get(LOGGER_STATE_KEY, uuid4().urn) + ) + return session_id def satosa_logging(logger, level, message, state, **kwargs): @@ -22,12 +30,6 @@ def satosa_logging(logger, level, message, state, **kwargs): :param state: The current state :param kwargs: set exc_info=True to get an exception stack trace in the log """ - if state is None: - session_id = "UNKNOWN" - else: - try: - session_id = state[LOGGER_STATE_KEY] - except KeyError: - session_id = uuid4().urn - state[LOGGER_STATE_KEY] = session_id - logger.log(level, "[{id}] {msg}".format(id=session_id, msg=message), **kwargs) + session_id = get_session_id(state) + logline = LOG_FMT.format(id=session_id, message=message) + logger.log(level, logline, **kwargs) From 4065cfd7ac2c1802977408f5f8b57d83202bcd9e Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 19 Sep 2019 21:42:37 +0300 Subject: [PATCH 038/401] Make use of log format and session id derivation to replace satosa_logging Signed-off-by: Ivan Kanakarakis --- src/satosa/routing.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/satosa/routing.py b/src/satosa/routing.py index f54415178..77e50beb1 100644 --- a/src/satosa/routing.py +++ b/src/satosa/routing.py @@ -4,8 +4,11 @@ import logging import re -from .context import SATOSABadContextError -from .exception import SATOSAError +from satosa.context import SATOSABadContextError +from satosa.exception import SATOSAError + +import satosa.logging_util as lu + logger = logging.getLogger(__name__) @@ -80,7 +83,7 @@ def backend_routing(self, context): :return: backend """ msg = "Routing to backend: {backend}".format(backend=context.target_backend) - logline = "[{id}] {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) backend = self.backends[context.target_backend]["instance"] context.state[STATE_KEY] = context.target_frontend @@ -99,7 +102,7 @@ def frontend_routing(self, context): target_frontend = context.state[STATE_KEY] msg = "Routing to frontend: {frontend}".format(frontend=target_frontend) - logline = "[{id}] {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) context.target_frontend = target_frontend frontend = self.frontends[context.target_frontend]["instance"] @@ -110,10 +113,11 @@ def _find_registered_endpoint_for_module(self, module, context): match = re.search(regex, context.path) if match is not None: msg = "Found registered endpoint: module name:'{name}', endpoint: {endpoint}".format( - name=module["instance"].name, - endpoint=context.path) - logline = "[{id}] {message}".format( - id=context.state.get("SESSION_ID"), message=msg) + name=module["instance"].name, endpoint=context.path + ) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) logger.debug(logline) return spec @@ -142,13 +146,14 @@ def endpoint_routing(self, context): """ if context.path is None: msg = "Context did not contain a path!" - logline = "[{id}] {message}".format( - id=context.state.get("SESSION_ID"), message=msg) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) logger.debug(logline) raise SATOSABadContextError("Context did not contain any path") msg = "Routing path: {path}".format(path=context.path) - logline = "[{id}] {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) path_split = context.path.split("/") backend = path_split[0] @@ -157,7 +162,9 @@ def endpoint_routing(self, context): context.target_backend = backend else: msg = "Unknown backend {}".format(backend) - logline = "[{id} {message}".format(id=context.state.get("SESSION_ID"), message=msg) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) logger.debug(logline) try: From 03d3dd7a3b9ca5a749a70cc4c93b6c870376a77d Mon Sep 17 00:00:00 2001 From: rhoerbe Date: Sat, 13 Jul 2019 11:45:41 +0200 Subject: [PATCH 039/401] fix PEP-8 violations in src/satosa excluding subdirs checking tool: flake8 --max-line-length 119 --- src/satosa/attribute_mapping.py | 7 +++---- src/satosa/base.py | 9 +++++---- src/satosa/context.py | 5 +---- src/satosa/deprecated.py | 2 +- src/satosa/internal_data.py | 7 ------- src/satosa/metadata_creation/description.py | 2 +- src/satosa/metadata_creation/saml_metadata.py | 9 ++------- src/satosa/plugin_loader.py | 13 +++++++++---- src/satosa/proxy_server.py | 9 ++++----- src/satosa/response.py | 2 +- src/satosa/routing.py | 4 ++-- src/satosa/scripts/satosa_saml_metadata.py | 1 - 12 files changed, 29 insertions(+), 41 deletions(-) diff --git a/src/satosa/attribute_mapping.py b/src/satosa/attribute_mapping.py index e09a079f6..b859562d7 100644 --- a/src/satosa/attribute_mapping.py +++ b/src/satosa/attribute_mapping.py @@ -98,7 +98,7 @@ def to_internal(self, attribute_profile, external_dict): external_dict) if attribute_values: # Only insert key if it has some values logger.debug("backend attribute '%s' mapped to %s" % (external_attribute_name, - internal_attribute_name)) + internal_attribute_name)) internal_dict[internal_attribute_name] = attribute_values else: logger.debug("skipped backend attribute '%s': no value found", external_attribute_name) @@ -122,7 +122,7 @@ def _render_attribute_template(self, template, data): t = Template(template, cache_enabled=True, imports=["from satosa.attribute_mapping import scope"]) try: return t.render(**data).split(self.multivalue_separator) - except (NameError, TypeError) as e: + except (NameError, TypeError): return [] def _handle_template_attributes(self, attribute_profile, internal_dict): @@ -187,8 +187,7 @@ def from_internal(self, attribute_profile, internal_dict): if attribute_profile not in attribute_mapping: # skip this internal attribute if we have no mapping in the specified profile logger.debug("no mapping found for '%s' in attribute profile '%s'" % - (internal_attribute_name, - attribute_profile)) + (internal_attribute_name, attribute_profile)) continue external_attribute_names = self.from_internal_attributes[internal_attribute_name][attribute_profile] diff --git a/src/satosa/base.py b/src/satosa/base.py index 74257c853..a5724fdf5 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -72,10 +72,11 @@ def __init__(self, config): self.request_micro_services = [] logger.info("Loading micro services...") if "MICRO_SERVICES" in self.config: - self.request_micro_services.extend(load_request_microservices(self.config.get("CUSTOM_PLUGIN_MODULE_PATHS"), - self.config["MICRO_SERVICES"], - self.config["INTERNAL_ATTRIBUTES"], - self.config["BASE"])) + self.request_micro_services.extend(load_request_microservices( + self.config.get("CUSTOM_PLUGIN_MODULE_PATHS"), + self.config["MICRO_SERVICES"], + self.config["INTERNAL_ATTRIBUTES"], + self.config["BASE"])) self._link_micro_services(self.request_micro_services, self._auth_req_finish) self.response_micro_services.extend( diff --git a/src/satosa/context.py b/src/satosa/context.py index 16947235e..2413624d2 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -8,12 +8,9 @@ class SATOSABadContextError(SATOSAError): pass -""" -Holds methods for sending internal data through the satosa proxy -""" class Context(object): """ - Holds information about the current request. + Holds methods for sharing proxy data through the current request """ KEY_BACKEND_METADATA_STORE = 'metadata_store' KEY_TARGET_ENTITYID = 'target_entity_id' diff --git a/src/satosa/deprecated.py b/src/satosa/deprecated.py index e8067c11a..2ab16c6ed 100644 --- a/src/satosa/deprecated.py +++ b/src/satosa/deprecated.py @@ -93,7 +93,7 @@ class UserIdHashType(Enum): unspecified = 6 def __getattr__(self, name): - if name is not "_value_": + if name != "_value_": msg = "UserIdHashType is deprecated and will be removed." _warnings.warn(msg, DeprecationWarning) return self.__getattribute__(name) diff --git a/src/satosa/internal_data.py b/src/satosa/internal_data.py index 7e3a8e89e..f18b13a74 100644 --- a/src/satosa/internal_data.py +++ b/src/satosa/internal_data.py @@ -1,12 +1,5 @@ import warnings as _warnings -from satosa.internal import InternalData -from satosa.internal import AuthenticationInformation -from satosa.deprecated import UserIdHashType -from satosa.deprecated import UserIdHasher -from satosa.deprecated import InternalRequest -from satosa.deprecated import InternalResponse - _warnings.warn( "internal_data is deprecated; use satosa.internal instead.", diff --git a/src/satosa/metadata_creation/description.py b/src/satosa/metadata_creation/description.py index ac243c278..26abdd555 100644 --- a/src/satosa/metadata_creation/description.py +++ b/src/satosa/metadata_creation/description.py @@ -91,7 +91,7 @@ def add_logo(self, text, width, height, lang=None): :param lang: language """ - logo_entry ={"text": text, "width": width, "height": height} + logo_entry = {"text": text, "width": width, "height": height} if lang: logo_entry["lang"] = lang self._logos.append(logo_entry) diff --git a/src/satosa/metadata_creation/saml_metadata.py b/src/satosa/metadata_creation/saml_metadata.py index ef06b7976..7989ef2a9 100644 --- a/src/satosa/metadata_creation/saml_metadata.py +++ b/src/satosa/metadata_creation/saml_metadata.py @@ -74,14 +74,9 @@ def _create_frontend_metadata(frontend_modules, backend_modules): for co_name in co_names: logger.info("Creating metadata for CO {}".format(co_name)) idp_config = copy.deepcopy(frontend.config["idp_config"]) - idp_config = frontend._add_endpoints_to_config( - idp_config, - co_name, - backend.name) + idp_config = frontend._add_endpoints_to_config(idp_config, co_name, backend.name) idp_config = frontend._add_entity_id(idp_config, co_name) - idp_config = frontend._overlay_for_saml_metadata( - idp_config, - co_name) + idp_config = frontend._overlay_for_saml_metadata(idp_config, co_name) entity_desc = _create_entity_descriptor(idp_config) frontend_metadata[frontend.name].append(entity_desc) diff --git a/src/satosa/plugin_loader.py b/src/satosa/plugin_loader.py index 3eee104e8..a4306c25b 100644 --- a/src/satosa/plugin_loader.py +++ b/src/satosa/plugin_loader.py @@ -41,8 +41,11 @@ def load_backends(config, callback, internal_attributes): :param callback: Function that will be called by the backend after the authentication is done. :return: A list of backend modules """ - backend_modules = _load_plugins(config.get("CUSTOM_PLUGIN_MODULE_PATHS"), config["BACKEND_MODULES"], backend_filter, - config["BASE"], internal_attributes, callback) + backend_modules = _load_plugins( + config.get("CUSTOM_PLUGIN_MODULE_PATHS"), + config["BACKEND_MODULES"], + backend_filter, config["BASE"], + internal_attributes, callback) logger.info("Setup backends: %s" % [backend.name for backend in backend_modules]) return backend_modules @@ -184,7 +187,8 @@ def _load_plugins(plugin_paths, plugins, plugin_filter, base_url, internal_attri def _load_endpoint_module(plugin_config, plugin_filter): _mandatory_params = ("name", "module", "config") if not all(k in plugin_config for k in _mandatory_params): - raise SATOSAConfigurationError("Missing mandatory plugin configuration parameter: {}".format(_mandatory_params)) + raise SATOSAConfigurationError( + "Missing mandatory plugin configuration parameter: {}".format(_mandatory_params)) return _load_plugin_module(plugin_config, plugin_filter) @@ -202,7 +206,8 @@ def _load_plugin_module(plugin_config, plugin_filter): def _load_microservice(plugin_config, plugin_filter): _mandatory_params = ("name", "module") if not all(k in plugin_config for k in _mandatory_params): - raise SATOSAConfigurationError("Missing mandatory plugin configuration parameter: {}".format(_mandatory_params)) + raise SATOSAConfigurationError( + "Missing mandatory plugin configuration parameter: {}".format(_mandatory_params)) return _load_plugin_module(plugin_config, plugin_filter) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 3c8259978..8c85ca08e 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -99,7 +99,8 @@ def __call__(self, environ, start_response, debug=False): context.path = path # copy wsgi.input stream to allow it to be re-read later by satosa plugins - # see: http://stackoverflow.com/questions/1783383/how-do-i-copy-wsgi-input-if-i-want-to-process-post-data-more-than-once + # see: http://stackoverflow.com/ + # questions/1783383/how-do-i-copy-wsgi-input-if-i-want-to-process-post-data-more-than-once content_length = int(environ.get('CONTENT_LENGTH', '0') or '0') body = io.BytesIO(environ['wsgi.input'].read(content_length)) environ['wsgi.input'] = body @@ -115,9 +116,7 @@ def __call__(self, environ, start_response, debug=False): raise resp return resp(environ, start_response) except SATOSANoBoundEndpointError: - resp = NotFound( - "The Service or Identity Provider" - "you requested could not be found.") + resp = NotFound("The Service or Identity Provider you requested could not be found.") return resp(environ, start_response) except Exception as err: if type(err) != UnknownSystemEntity: @@ -142,7 +141,7 @@ def make_app(satosa_config): root_logger.setLevel(logging.DEBUG) try: - pkg = pkg_resources.get_distribution(module.__name__) + _ = pkg_resources.get_distribution(module.__name__) logger.info("Running SATOSA version %s", pkg_resources.get_distribution("SATOSA").version) except (NameError, pkg_resources.DistributionNotFound): diff --git a/src/satosa/response.py b/src/satosa/response.py index bc78d002d..b672b0e40 100644 --- a/src/satosa/response.py +++ b/src/satosa/response.py @@ -112,4 +112,4 @@ class Unauthorized(Response): _status = "401 Unauthorized" def __init__(self, message, headers=None, content=None): - super().__init__(message, headers=headers, content=content) \ No newline at end of file + super().__init__(message, headers=headers, content=content) diff --git a/src/satosa/routing.py b/src/satosa/routing.py index 77e50beb1..317b047f9 100644 --- a/src/satosa/routing.py +++ b/src/satosa/routing.py @@ -169,7 +169,7 @@ def endpoint_routing(self, context): try: name, frontend_endpoint = self._find_registered_endpoint(context, self.frontends) - except ModuleRouter.UnknownEndpoint as e: + except ModuleRouter.UnknownEndpoint: pass else: context.target_frontend = name @@ -177,7 +177,7 @@ def endpoint_routing(self, context): try: name, micro_service_endpoint = self._find_registered_endpoint(context, self.micro_services) - except ModuleRouter.UnknownEndpoint as e: + except ModuleRouter.UnknownEndpoint: pass else: context.target_micro_service = name diff --git a/src/satosa/scripts/satosa_saml_metadata.py b/src/satosa/scripts/satosa_saml_metadata.py index 0f31e61f0..20e4ae4f9 100644 --- a/src/satosa/scripts/satosa_saml_metadata.py +++ b/src/satosa/scripts/satosa_saml_metadata.py @@ -5,7 +5,6 @@ from saml2.sigver import security_context from ..metadata_creation.saml_metadata import create_entity_descriptors -from ..metadata_creation.saml_metadata import create_signed_entities_descriptor from ..metadata_creation.saml_metadata import create_signed_entity_descriptor from ..satosa_config import SATOSAConfig From b5ce12a1144ef9eb84c2cb56121fcd41df0e2fbf Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 19 Sep 2019 23:30:17 +0300 Subject: [PATCH 040/401] Cleanup the docker start.sh script Signed-off-by: Ivan Kanakarakis --- docker/start.sh | 58 +++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/docker/start.sh b/docker/start.sh index 294e5b6f9..f45091e1d 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,46 +1,48 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh -# for Click library to work in satosa-saml-metadata -export LC_ALL=C.UTF-8 -export LANG=C.UTF-8 - -# exit immediately on failure set -e -if [ -z "${DATA_DIR}" ]; then - DATA_DIR=/opt/satosa/etc -fi +# for Click library to work in satosa-saml-metadata +export LC_ALL="C.UTF-8" +export LANG="C.UTF-8" -if [ ! -d "${DATA_DIR}" ]; then - mkdir -p "${DATA_DIR}" +if [ -z "${DATA_DIR}" ] +then DATA_DIR=/opt/satosa/etc fi -if [ -z "${PROXY_PORT}" ]; then - PROXY_PORT="8000" +if [ ! -d "${DATA_DIR}" ] +then mkdir -p "${DATA_DIR}" fi -if [ -z "${METADATA_DIR}" ]; then - METADATA_DIR="${DATA_DIR}" +if [ -z "${PROXY_PORT}" ] +then PROXY_PORT="8000" fi -cd ${DATA_DIR} - -mkdir -p ${METADATA_DIR} +if [ -z "${METADATA_DIR}" ] +then METADATA_DIR="${DATA_DIR}" +fi -if [ ! -d ${DATA_DIR}/attributemaps ]; then - cp -pr /opt/satosa/attributemaps ${DATA_DIR}/attributemaps +if [ ! -d "${DATA_DIR}/attributemaps" ] +then cp -pr /opt/satosa/attributemaps "${DATA_DIR}/attributemaps" fi -# Activate virtualenv +# activate virtualenv . /opt/satosa/bin/activate -# generate metadata for front- (IdP) and back-end (SP) and write it to mounted volume - -satosa-saml-metadata proxy_conf.yaml ${DATA_DIR}/metadata.key ${DATA_DIR}/metadata.crt --dir ${METADATA_DIR} +# generate metadata for frontend(IdP interface) and backend(SP interface) +# write the result to mounted volume +mkdir -p "${METADATA_DIR}" +satosa-saml-metadata \ + "${DATA_DIR}/proxy_conf.yaml" \ + "${DATA_DIR}/metadata.key" \ + "${DATA_DIR}/metadata.crt" \ + --dir "${METADATA_DIR}" # start the proxy -if [[ -f https.key && -f https.crt ]]; then # if HTTPS cert is available, use it - exec gunicorn -b0.0.0.0:${PROXY_PORT} --keyfile https.key --certfile https.crt satosa.wsgi:app -else - exec gunicorn -b0.0.0.0:${PROXY_PORT} satosa.wsgi:app +# if HTTPS cert is available, use it +https_key="${DATA_DIR}/https.key" +https_crt="${DATA_DIR}/https.crt" +if [ -f "$https_key" && -f "$https_crt" ] +then exec gunicorn -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app --keyfile "$https_key" --certfile "$https_crt" +else exec gunicorn -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app fi From e11afa5351ddf472f6fa8bb8bcb4ce8390ae0cac Mon Sep 17 00:00:00 2001 From: rhoerbe Date: Mon, 3 Jun 2019 12:43:02 +0200 Subject: [PATCH 041/401] Allow users to provide a gunicorn configuration file By setting the environment variable GUNICORN_CONF, users can specify the location of a gunicorn configuration file that will be picked up when docker invokes start.sh Signed-off-by: Ivan Kanakarakis --- docker/start.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker/start.sh b/docker/start.sh index f45091e1d..b80d22157 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -38,11 +38,15 @@ satosa-saml-metadata \ "${DATA_DIR}/metadata.crt" \ --dir "${METADATA_DIR}" +if [ -f "$GUNICORN_CONF" ] +then conf_opt="--config ${GUNICORN_CONF}" +fi + # start the proxy # if HTTPS cert is available, use it https_key="${DATA_DIR}/https.key" https_crt="${DATA_DIR}/https.crt" if [ -f "$https_key" && -f "$https_crt" ] -then exec gunicorn -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app --keyfile "$https_key" --certfile "$https_crt" -else exec gunicorn -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app +then exec gunicorn $conf_opt -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app --keyfile "$https_key" --certfile "$https_crt" +else exec gunicorn $conf_opt -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app fi From 608c7183217eb9afa36dc3454361067c714ba38c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 20 Sep 2019 00:56:55 +0300 Subject: [PATCH 042/401] Restructure invokation options Signed-off-by: Ivan Kanakarakis --- docker/start.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docker/start.sh b/docker/start.sh index b80d22157..452398663 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -38,15 +38,21 @@ satosa-saml-metadata \ "${DATA_DIR}/metadata.crt" \ --dir "${METADATA_DIR}" +# if the user provided a gunicorn configuration, use it if [ -f "$GUNICORN_CONF" ] then conf_opt="--config ${GUNICORN_CONF}" fi -# start the proxy # if HTTPS cert is available, use it https_key="${DATA_DIR}/https.key" https_crt="${DATA_DIR}/https.crt" -if [ -f "$https_key" && -f "$https_crt" ] -then exec gunicorn $conf_opt -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app --keyfile "$https_key" --certfile "$https_crt" -else exec gunicorn $conf_opt -b0.0.0.0:"${PROXY_PORT}" satosa.wsgi:app +if [ -f "$https_key" -a -f "$https_crt" ] +then https_opts="--keyfile ${https_key} --certfile ${https_crt}" fi + +# start the proxy +exec gunicorn $conf_opt \ + -b 0.0.0.0:"${PROXY_PORT}" \ + satosa.wsgi:app \ + $https_opts \ + ; From a7173d7b0730ff8d239aa62fc99937a3464ffb32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Fri, 12 Apr 2019 12:13:42 +0200 Subject: [PATCH 043/401] Add ability to add chain for https certificates The canonical example for this is Let's Encrypt Signed-off-by: Ivan Kanakarakis --- docker/start.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker/start.sh b/docker/start.sh index 452398663..845eb94bc 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -50,9 +50,16 @@ if [ -f "$https_key" -a -f "$https_crt" ] then https_opts="--keyfile ${https_key} --certfile ${https_crt}" fi +# if a chain is available, use it +chain_pem="${DATA_DIR}/chain.pem" +if [ -f "$chain_pem" ] +then chain_opts="--ca-certs chain.pem" +fi + # start the proxy exec gunicorn $conf_opt \ -b 0.0.0.0:"${PROXY_PORT}" \ satosa.wsgi:app \ $https_opts \ + $chain_opts \ ; From 6cb716d8e938ee7f960c16a04b2648e6725b718f Mon Sep 17 00:00:00 2001 From: John Paraskevopoulos Date: Wed, 25 Sep 2019 22:25:58 +0300 Subject: [PATCH 044/401] Make sure gunicorn runs inside $DATA_DIR path - Changes directory to $DATA_DIR to execute the gunicorn command in the docker entry cmd script. This ensures that satosa will find proxy_conf.yaml and the relevant files (plugins etc) - Solves issue where docker image was broken after #278 --- docker/start.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/start.sh b/docker/start.sh index 845eb94bc..6f7335f3a 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -57,9 +57,9 @@ then chain_opts="--ca-certs chain.pem" fi # start the proxy -exec gunicorn $conf_opt \ +(cd $DATA_DIR; exec gunicorn $conf_opt \ -b 0.0.0.0:"${PROXY_PORT}" \ satosa.wsgi:app \ $https_opts \ $chain_opts \ - ; + ;) From 37f225dce6dfb23637552c39ed03e4289cb0319e Mon Sep 17 00:00:00 2001 From: John Paraskevopoulos Date: Mon, 30 Sep 2019 12:42:04 +0300 Subject: [PATCH 045/401] Use gunicorn chdir option in docker start script - Use gunicorn chdir option as fallback `conf_opt` in case no gunicorn config file is provided. This avoids running `cd` to $DATA_DIR. --- docker/start.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker/start.sh b/docker/start.sh index 6f7335f3a..dd57b2ee2 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -41,6 +41,7 @@ satosa-saml-metadata \ # if the user provided a gunicorn configuration, use it if [ -f "$GUNICORN_CONF" ] then conf_opt="--config ${GUNICORN_CONF}" +else conf_opt="--chdir ${DATA_DIR}" fi # if HTTPS cert is available, use it @@ -57,9 +58,9 @@ then chain_opts="--ca-certs chain.pem" fi # start the proxy -(cd $DATA_DIR; exec gunicorn $conf_opt \ +exec gunicorn $conf_opt \ -b 0.0.0.0:"${PROXY_PORT}" \ satosa.wsgi:app \ $https_opts \ $chain_opts \ - ;) + ; From d86dbbc84a30ec13ec8b466bdd2a58b7ec372245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Tue, 1 Oct 2019 07:40:26 +0200 Subject: [PATCH 046/401] Fixed authorization token header --- src/satosa/backends/orcid.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/satosa/backends/orcid.py b/src/satosa/backends/orcid.py index dacf3ac11..716dfd636 100644 --- a/src/satosa/backends/orcid.py +++ b/src/satosa/backends/orcid.py @@ -96,8 +96,7 @@ def user_information(self, access_token, orcid, name): url = urljoin(base_url, '{}/person'.format(orcid)) headers = { 'Accept': 'application/orcid+json', - 'Authorization type': 'Bearer', - 'Access token': access_token, + 'Authorization': "Bearer {}".format(access_token) } r = requests.get(url, headers=headers) r = r.json() From 2a4da5b0b413667b2a446d30483c144c39b5b29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Tue, 1 Oct 2019 07:41:48 +0200 Subject: [PATCH 047/401] Fixed read address attribute --- src/satosa/backends/orcid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/backends/orcid.py b/src/satosa/backends/orcid.py index 716dfd636..756a3cca6 100644 --- a/src/satosa/backends/orcid.py +++ b/src/satosa/backends/orcid.py @@ -102,7 +102,7 @@ def user_information(self, access_token, orcid, name): r = r.json() emails, addresses = r['emails']['email'], r['addresses']['address'] ret = dict( - address=', '.join([e['address'] for e in addresses]), + address=', '.join([e['country']['value'] for e in addresses]), displayname=name, edupersontargetedid=orcid, orcid=orcid, mail=' '.join([e['email'] for e in emails]), From 2ac9e457426c16113764c8a0fe954fd1bbad258b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Tue, 1 Oct 2019 07:47:44 +0200 Subject: [PATCH 048/401] Added orcid backend tests --- tests/satosa/backends/test_orcid.py | 188 ++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tests/satosa/backends/test_orcid.py diff --git a/tests/satosa/backends/test_orcid.py b/tests/satosa/backends/test_orcid.py new file mode 100644 index 000000000..643875f68 --- /dev/null +++ b/tests/satosa/backends/test_orcid.py @@ -0,0 +1,188 @@ +import json +from unittest.mock import Mock +from urllib.parse import urljoin, urlparse, parse_qsl + +import pytest +import responses + +from saml2.saml import NAMEID_FORMAT_TRANSIENT + +from satosa.backends.orcid import OrcidBackend +from satosa.context import Context +from satosa.internal import InternalData +from satosa.response import Response + +ORCID_PERSON_ID = "0000-0000-0000-0000" +ORCID_PERSON_GIVEN_NAME = "orcid_given_name" +ORCID_PERSON_FAMILY_NAME = "orcid_family_name" +ORCID_PERSON_NAME = "{} {}".format(ORCID_PERSON_GIVEN_NAME, ORCID_PERSON_FAMILY_NAME) +ORCID_PERSON_EMAIL = "orcid_email" +ORCID_PERSON_COUNTRY = "XX" + +class TestOrcidBackend(object): + @pytest.fixture(autouse=True) + def create_backend(self, internal_attributes, backend_config): + self.orcid_backend = OrcidBackend(Mock(), internal_attributes, backend_config, backend_config["base_url"], "orcid") + + @pytest.fixture + def backend_config(self): + return { + "authz_page": 'orcid/auth/callback', + "base_url": "https://client.example.com", + "client_config": {"client_id": "orcid_client_id"}, + "client_secret": "orcid_secret", + "scope": ["/authenticate"], + "response_type": "code", + "server_info": { + "authorization_endpoint": "https://orcid.org/oauth/authorize", + "token_endpoint": "https://pub.orcid.org/oauth/token", + "user_info": "https://pub.orcid.org/v2.0/" + } + } + + @pytest.fixture + def internal_attributes(self): + return { + "attributes": { + "address": { "orcid": ["address"] }, + "displayname": { "orcid": ["name"] }, + "edupersontargetedid": {"orcid": ["orcid"]}, + "givenname": {"orcid": ["givenname"]}, + "mail": {"orcid": ["mail"]}, + "name": { "orcid": ["name"] }, + "surname": {"orcid": ["surname"]}, + } + } + + @pytest.fixture + def userinfo(self): + return { + "name": { + "given-names": { "value": ORCID_PERSON_GIVEN_NAME }, + "family-name": { "value": ORCID_PERSON_FAMILY_NAME }, + }, + "emails": { + "email": [ + { "email": ORCID_PERSON_EMAIL, "verified": True, "primary": True } + ] + }, + "addresses": { + "address": [ + { "country": { "value": ORCID_PERSON_COUNTRY } } + ] + } + } + + @pytest.fixture + def userinfo_private(self): + return { + "name": { + "given-names": { "value": ORCID_PERSON_GIVEN_NAME }, + "family-name": { "value": ORCID_PERSON_FAMILY_NAME }, + }, + "emails": { + "email": [ + ] + }, + "addresses": { + "address": [ + ] + } + } + + + def assert_expected_attributes(self, user_claims, actual_attributes): + print(user_claims) + print(actual_attributes) + + expected_attributes = { + "address": [ORCID_PERSON_COUNTRY], + "displayname": [ORCID_PERSON_NAME], + "edupersontargetedid": [ORCID_PERSON_ID], + "givenname": [ORCID_PERSON_GIVEN_NAME], + "mail": [ORCID_PERSON_EMAIL], + "name": [ORCID_PERSON_NAME], + "surname": [ORCID_PERSON_FAMILY_NAME], + } + + assert actual_attributes == expected_attributes + + def setup_token_endpoint(self, token_endpoint_url): + token_response = { + "access_token": "orcid_access_token", + "token_type": "bearer", + "expires_in": 9999999999999, + "name": ORCID_PERSON_NAME, + "orcid": ORCID_PERSON_ID + } + + responses.add(responses.POST, + token_endpoint_url, + body=json.dumps(token_response), + status=200, + content_type="application/json") + + def setup_userinfo_endpoint(self, userinfo_endpoint_url, userinfo): + responses.add(responses.GET, + urljoin(userinfo_endpoint_url, '{}/person'.format(ORCID_PERSON_ID)), + body=json.dumps(userinfo), + status=200, + content_type="application/json") + + @pytest.fixture + def incoming_authn_response(self, context, backend_config): + context.path = backend_config["authz_page"] + context.request = { + "code": "the_orcid_code", + } + + return context + + def test_start_auth(self, context, backend_config): + auth_response = self.orcid_backend.start_auth(context, None) + assert isinstance(auth_response, Response) + + login_url = auth_response.message + parsed = urlparse(login_url) + assert login_url.startswith(backend_config["server_info"]["authorization_endpoint"]) + auth_params = dict(parse_qsl(parsed.query)) + assert auth_params["scope"] == " ".join(backend_config["scope"]) + assert auth_params["response_type"] == backend_config["response_type"] + assert auth_params["client_id"] == backend_config["client_config"]["client_id"] + assert auth_params["redirect_uri"] == backend_config["base_url"] + "/" + backend_config["authz_page"] + + @responses.activate + def test_authn_response(self, backend_config, userinfo, incoming_authn_response): + self.setup_token_endpoint(backend_config["server_info"]["token_endpoint"]) + self.setup_userinfo_endpoint(backend_config["server_info"]["user_info"], userinfo) + + self.orcid_backend._authn_response(incoming_authn_response) + + args = self.orcid_backend.auth_callback_func.call_args[0] + assert isinstance(args[0], Context) + assert isinstance(args[1], InternalData) + + self.assert_expected_attributes(userinfo, args[1].attributes) + + @responses.activate + def test_user_information(self, context, backend_config, userinfo): + self.setup_userinfo_endpoint(backend_config["server_info"]["user_info"], userinfo) + + user_attributes = self.orcid_backend.user_information("orcid_access_token", ORCID_PERSON_ID, ORCID_PERSON_NAME) + + assert user_attributes["address"] == ORCID_PERSON_COUNTRY + assert user_attributes["displayname"] == ORCID_PERSON_NAME + assert user_attributes["edupersontargetedid"] == ORCID_PERSON_ID + assert user_attributes["orcid"] == ORCID_PERSON_ID + assert user_attributes["mail"] == ORCID_PERSON_EMAIL + assert user_attributes["givenname"] == ORCID_PERSON_GIVEN_NAME + assert user_attributes["surname"] == ORCID_PERSON_FAMILY_NAME + + @responses.activate + def test_user_information_private(self, context, backend_config, userinfo_private): + self.setup_userinfo_endpoint(backend_config["server_info"]["user_info"], userinfo_private) + + user_attributes = self.orcid_backend.user_information("orcid_access_token", ORCID_PERSON_ID, ORCID_PERSON_NAME) + + assert user_attributes["address"] == "" + assert user_attributes["mail"] == "" From 9d5f2fe7474b3e72ee25178ce39ca85f5e0a9397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Tue, 1 Oct 2019 08:17:18 +0200 Subject: [PATCH 049/401] PEP8 code style --- tests/satosa/backends/test_orcid.py | 92 +++++++++++++++++++---------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/tests/satosa/backends/test_orcid.py b/tests/satosa/backends/test_orcid.py index 643875f68..09f381ed1 100644 --- a/tests/satosa/backends/test_orcid.py +++ b/tests/satosa/backends/test_orcid.py @@ -1,16 +1,13 @@ import json -from unittest.mock import Mock -from urllib.parse import urljoin, urlparse, parse_qsl - import pytest import responses -from saml2.saml import NAMEID_FORMAT_TRANSIENT - from satosa.backends.orcid import OrcidBackend from satosa.context import Context from satosa.internal import InternalData from satosa.response import Response +from unittest.mock import Mock +from urllib.parse import urljoin, urlparse, parse_qsl ORCID_PERSON_ID = "0000-0000-0000-0000" ORCID_PERSON_GIVEN_NAME = "orcid_given_name" @@ -19,10 +16,17 @@ ORCID_PERSON_EMAIL = "orcid_email" ORCID_PERSON_COUNTRY = "XX" + class TestOrcidBackend(object): @pytest.fixture(autouse=True) def create_backend(self, internal_attributes, backend_config): - self.orcid_backend = OrcidBackend(Mock(), internal_attributes, backend_config, backend_config["base_url"], "orcid") + self.orcid_backend = OrcidBackend( + Mock(), + internal_attributes, + backend_config, + backend_config["base_url"], + "orcid" + ) @pytest.fixture def backend_config(self): @@ -44,12 +48,12 @@ def backend_config(self): def internal_attributes(self): return { "attributes": { - "address": { "orcid": ["address"] }, - "displayname": { "orcid": ["name"] }, + "address": {"orcid": ["address"]}, + "displayname": {"orcid": ["name"]}, "edupersontargetedid": {"orcid": ["orcid"]}, "givenname": {"orcid": ["givenname"]}, "mail": {"orcid": ["mail"]}, - "name": { "orcid": ["name"] }, + "name": {"orcid": ["name"]}, "surname": {"orcid": ["surname"]}, } } @@ -58,17 +62,21 @@ def internal_attributes(self): def userinfo(self): return { "name": { - "given-names": { "value": ORCID_PERSON_GIVEN_NAME }, - "family-name": { "value": ORCID_PERSON_FAMILY_NAME }, + "given-names": {"value": ORCID_PERSON_GIVEN_NAME}, + "family-name": {"value": ORCID_PERSON_FAMILY_NAME}, }, "emails": { "email": [ - { "email": ORCID_PERSON_EMAIL, "verified": True, "primary": True } + { + "email": ORCID_PERSON_EMAIL, + "verified": True, + "primary": True + } ] }, "addresses": { "address": [ - { "country": { "value": ORCID_PERSON_COUNTRY } } + {"country": {"value": ORCID_PERSON_COUNTRY}} ] } } @@ -77,8 +85,8 @@ def userinfo(self): def userinfo_private(self): return { "name": { - "given-names": { "value": ORCID_PERSON_GIVEN_NAME }, - "family-name": { "value": ORCID_PERSON_FAMILY_NAME }, + "given-names": {"value": ORCID_PERSON_GIVEN_NAME}, + "family-name": {"value": ORCID_PERSON_FAMILY_NAME}, }, "emails": { "email": [ @@ -90,7 +98,6 @@ def userinfo_private(self): } } - def assert_expected_attributes(self, user_claims, actual_attributes): print(user_claims) print(actual_attributes) @@ -116,18 +123,22 @@ def setup_token_endpoint(self, token_endpoint_url): "orcid": ORCID_PERSON_ID } - responses.add(responses.POST, - token_endpoint_url, - body=json.dumps(token_response), - status=200, - content_type="application/json") + responses.add( + responses.POST, + token_endpoint_url, + body=json.dumps(token_response), + status=200, + content_type="application/json" + ) def setup_userinfo_endpoint(self, userinfo_endpoint_url, userinfo): - responses.add(responses.GET, - urljoin(userinfo_endpoint_url, '{}/person'.format(ORCID_PERSON_ID)), - body=json.dumps(userinfo), - status=200, - content_type="application/json") + responses.add( + responses.GET, + urljoin(userinfo_endpoint_url, '{}/person'.format(ORCID_PERSON_ID)), + body=json.dumps(userinfo), + status=200, + content_type="application/json" + ) @pytest.fixture def incoming_authn_response(self, context, backend_config): @@ -149,7 +160,10 @@ def test_start_auth(self, context, backend_config): assert auth_params["scope"] == " ".join(backend_config["scope"]) assert auth_params["response_type"] == backend_config["response_type"] assert auth_params["client_id"] == backend_config["client_config"]["client_id"] - assert auth_params["redirect_uri"] == backend_config["base_url"] + "/" + backend_config["authz_page"] + assert auth_params["redirect_uri"] == "{}/{}".format( + backend_config["base_url"], + backend_config["authz_page"] + ) @responses.activate def test_authn_response(self, backend_config, userinfo, incoming_authn_response): @@ -166,9 +180,16 @@ def test_authn_response(self, backend_config, userinfo, incoming_authn_response) @responses.activate def test_user_information(self, context, backend_config, userinfo): - self.setup_userinfo_endpoint(backend_config["server_info"]["user_info"], userinfo) + self.setup_userinfo_endpoint( + backend_config["server_info"]["user_info"], + userinfo + ) - user_attributes = self.orcid_backend.user_information("orcid_access_token", ORCID_PERSON_ID, ORCID_PERSON_NAME) + user_attributes = self.orcid_backend.user_information( + "orcid_access_token", + ORCID_PERSON_ID, + ORCID_PERSON_NAME + ) assert user_attributes["address"] == ORCID_PERSON_COUNTRY assert user_attributes["displayname"] == ORCID_PERSON_NAME @@ -180,9 +201,16 @@ def test_user_information(self, context, backend_config, userinfo): @responses.activate def test_user_information_private(self, context, backend_config, userinfo_private): - self.setup_userinfo_endpoint(backend_config["server_info"]["user_info"], userinfo_private) - - user_attributes = self.orcid_backend.user_information("orcid_access_token", ORCID_PERSON_ID, ORCID_PERSON_NAME) + self.setup_userinfo_endpoint( + backend_config["server_info"]["user_info"], + userinfo_private + ) + + user_attributes = self.orcid_backend.user_information( + "orcid_access_token", + ORCID_PERSON_ID, + ORCID_PERSON_NAME + ) assert user_attributes["address"] == "" assert user_attributes["mail"] == "" From 8faf893982a26f112a7aececd63f8c924ed372f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Mon, 7 Oct 2019 13:41:34 +0200 Subject: [PATCH 050/401] Added state parameter to ORCID authorization request --- src/satosa/backends/orcid.py | 57 ++++++++++++----------------- tests/satosa/backends/test_orcid.py | 24 +++++++++--- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/satosa/backends/orcid.py b/src/satosa/backends/orcid.py index 756a3cca6..aaa18b7e5 100644 --- a/src/satosa/backends/orcid.py +++ b/src/satosa/backends/orcid.py @@ -13,7 +13,7 @@ from satosa.backends.oauth import _OAuthBackend from satosa.internal import InternalData from satosa.internal import AuthenticationInformation -from satosa.response import Redirect +from satosa.util import rndstr logger = logging.getLogger(__name__) @@ -45,22 +45,15 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): outgoing, internal_attributes, config, base_url, name, 'orcid', 'orcid') - def start_auth(self, context, internal_request, get_state=stateID): - """ - :param get_state: Generates a state to be used in authentication call - - :type get_state: Callable[[str, bytes], str] - :type context: satosa.context.Context - :type internal_request: satosa.internal.InternalData - :rtype satosa.response.Redirect - """ - request_args = dict( - client_id=self.config['client_config']['client_id'], - redirect_uri=self.redirect_url, - scope=' '.join(self.config['scope']), ) - cis = self.consumer.construct_AuthorizationRequest( - request_args=request_args) - return Redirect(cis.request(self.consumer.authorization_endpoint)) + def get_request_args(self, get_state=stateID): + oauth_state = get_state(self.config["base_url"], rndstr().encode()) + request_args = { + "client_id": self.config['client_config']['client_id'], + "redirect_uri": self.redirect_url, + "scope": ' '.join(self.config['scope']), + "state": oauth_state, + } + return request_args def auth_info(self, requrest): return AuthenticationInformation( @@ -68,27 +61,25 @@ def auth_info(self, requrest): self.config['server_info']['authorization_endpoint']) def _authn_response(self, context): + state_data = context.state[self.name] aresp = self.consumer.parse_response( AuthorizationResponse, info=json.dumps(context.request)) - url = self.config['server_info']['token_endpoint'] - data = dict( - grant_type='authorization_code', - code=aresp['code'], - redirect_uri=self.redirect_url, - client_id=self.config['client_config']['client_id'], - client_secret=self.config['client_secret'], ) - headers = {'Accept': 'application/json'} + self._verify_state(aresp, state_data, context.state) + + rargs = {"code": aresp["code"], "redirect_uri": self.redirect_url, + "state": state_data["state"]} + + atresp = self.consumer.do_access_token_request( + request_args=rargs, state=aresp['state']) - r = requests.post(url, data=data, headers=headers) - response = r.json() - token = response['access_token'] - orcid, name = response['orcid'], response['name'] - user_info = self.user_information(token, orcid, name) - auth_info = self.auth_info(context.request) - internal_response = InternalData(auth_info=auth_info) + user_info = self.user_information( + atresp['access_token'], atresp['orcid'], atresp['name']) + internal_response = InternalData( + auth_info=self.auth_info(context.request)) internal_response.attributes = self.converter.to_internal( self.external_type, user_info) - internal_response.subject_id = orcid + internal_response.subject_id = user_info[self.user_id_attr] + del context.state[self.name] return self.auth_callback_func(context, internal_response) def user_information(self, access_token, orcid, name): diff --git a/tests/satosa/backends/test_orcid.py b/tests/satosa/backends/test_orcid.py index 09f381ed1..5120d4e89 100644 --- a/tests/satosa/backends/test_orcid.py +++ b/tests/satosa/backends/test_orcid.py @@ -12,10 +12,13 @@ ORCID_PERSON_ID = "0000-0000-0000-0000" ORCID_PERSON_GIVEN_NAME = "orcid_given_name" ORCID_PERSON_FAMILY_NAME = "orcid_family_name" -ORCID_PERSON_NAME = "{} {}".format(ORCID_PERSON_GIVEN_NAME, ORCID_PERSON_FAMILY_NAME) +ORCID_PERSON_NAME = "{} {}".format( + ORCID_PERSON_GIVEN_NAME, ORCID_PERSON_FAMILY_NAME) ORCID_PERSON_EMAIL = "orcid_email" ORCID_PERSON_COUNTRY = "XX" +mock_get_state = Mock(return_value="abcdef") + class TestOrcidBackend(object): @pytest.fixture(autouse=True) @@ -134,7 +137,8 @@ def setup_token_endpoint(self, token_endpoint_url): def setup_userinfo_endpoint(self, userinfo_endpoint_url, userinfo): responses.add( responses.GET, - urljoin(userinfo_endpoint_url, '{}/person'.format(ORCID_PERSON_ID)), + urljoin(userinfo_endpoint_url, + '{}/person'.format(ORCID_PERSON_ID)), body=json.dumps(userinfo), status=200, content_type="application/json" @@ -143,19 +147,24 @@ def setup_userinfo_endpoint(self, userinfo_endpoint_url, userinfo): @pytest.fixture def incoming_authn_response(self, context, backend_config): context.path = backend_config["authz_page"] + state_data = dict(state=mock_get_state.return_value) + context.state[self.orcid_backend.name] = state_data context.request = { "code": "the_orcid_code", + "state": mock_get_state.return_value } return context def test_start_auth(self, context, backend_config): - auth_response = self.orcid_backend.start_auth(context, None) + auth_response = self.orcid_backend.start_auth( + context, None, mock_get_state) assert isinstance(auth_response, Response) login_url = auth_response.message parsed = urlparse(login_url) - assert login_url.startswith(backend_config["server_info"]["authorization_endpoint"]) + assert login_url.startswith( + backend_config["server_info"]["authorization_endpoint"]) auth_params = dict(parse_qsl(parsed.query)) assert auth_params["scope"] == " ".join(backend_config["scope"]) assert auth_params["response_type"] == backend_config["response_type"] @@ -164,11 +173,14 @@ def test_start_auth(self, context, backend_config): backend_config["base_url"], backend_config["authz_page"] ) + assert auth_params["state"] == mock_get_state.return_value @responses.activate def test_authn_response(self, backend_config, userinfo, incoming_authn_response): - self.setup_token_endpoint(backend_config["server_info"]["token_endpoint"]) - self.setup_userinfo_endpoint(backend_config["server_info"]["user_info"], userinfo) + self.setup_token_endpoint( + backend_config["server_info"]["token_endpoint"]) + self.setup_userinfo_endpoint( + backend_config["server_info"]["user_info"], userinfo) self.orcid_backend._authn_response(incoming_authn_response) From 6e074b87a678e1a245e10544ddceaf7acff4cbde Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 8 Oct 2019 23:49:19 +0300 Subject: [PATCH 051/401] Restore satosa.internal_data compatibility Signed-off-by: Ivan Kanakarakis --- src/satosa/internal_data.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/satosa/internal_data.py b/src/satosa/internal_data.py index f18b13a74..7e3a8e89e 100644 --- a/src/satosa/internal_data.py +++ b/src/satosa/internal_data.py @@ -1,5 +1,12 @@ import warnings as _warnings +from satosa.internal import InternalData +from satosa.internal import AuthenticationInformation +from satosa.deprecated import UserIdHashType +from satosa.deprecated import UserIdHasher +from satosa.deprecated import InternalRequest +from satosa.deprecated import InternalResponse + _warnings.warn( "internal_data is deprecated; use satosa.internal instead.", From 65a1b478e6de2c3f92f511671efc100862dd1774 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 8 Oct 2019 23:48:50 +0300 Subject: [PATCH 052/401] Use saml2.extension.mdui in place of saml2.extension.ui Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 2 +- src/satosa/frontends/saml2.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index e74b00f07..d942817ac 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -12,7 +12,7 @@ from saml2 import BINDING_HTTP_REDIRECT from saml2.client_base import Base from saml2.config import SPConfig -from saml2.extension.ui import NAMESPACE as UI_NAMESPACE +from saml2.extension.mdui import NAMESPACE as UI_NAMESPACE from saml2.metadata import create_metadata_string from saml2.authn_context import requested_authn_context diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index d866c3dd1..b65003054 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -17,7 +17,7 @@ from saml2 import SAMLError, xmldsig from saml2.config import IdPConfig -from saml2.extension.ui import NAMESPACE as UI_NAMESPACE +from saml2.extension.mdui import NAMESPACE as UI_NAMESPACE from saml2.metadata import create_metadata_string from saml2.saml import NameID from saml2.saml import NAMEID_FORMAT_TRANSIENT From 70f586471107ea199b462efa5ad7c0657d468657 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 2 Oct 2019 06:16:01 +0000 Subject: [PATCH 053/401] Improve logging - satosa.attribute_mapping Signed-off-by: Ivan Kanakarakis --- src/satosa/attribute_mapping.py | 38 ++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/satosa/attribute_mapping.py b/src/satosa/attribute_mapping.py index b859562d7..ebb008bc0 100644 --- a/src/satosa/attribute_mapping.py +++ b/src/satosa/attribute_mapping.py @@ -58,7 +58,8 @@ def to_internal_filter(self, attribute_profile, external_attribute_names): try: profile_mapping = self.to_internal_attributes[attribute_profile] except KeyError: - logger.warn("no attribute mapping found for the given attribute profile '%s'", attribute_profile) + logline = "no attribute mapping found for the given attribute profile {}".format(attribute_profile) + logger.warn(logline) # no attributes since the given profile is not configured return [] @@ -88,8 +89,10 @@ def to_internal(self, attribute_profile, external_dict): for internal_attribute_name, mapping in self.from_internal_attributes.items(): if attribute_profile not in mapping: - logger.debug("no attribute mapping found for internal attribute '%s' the attribute profile '%s'" % ( - internal_attribute_name, attribute_profile)) + logline = "no attribute mapping found for internal attribute {internal} the attribute profile {attribute}".format( + internal=internal_attribute_name, attribute=attribute_profile + ) + logger.debug(logline) # skip this internal attribute if we have no mapping in the specified profile continue @@ -97,12 +100,16 @@ def to_internal(self, attribute_profile, external_dict): attribute_values = self._collate_attribute_values_by_priority_order(external_attribute_name, external_dict) if attribute_values: # Only insert key if it has some values - logger.debug("backend attribute '%s' mapped to %s" % (external_attribute_name, - internal_attribute_name)) + logline = "backend attribute {external} mapped to {internal}".format( + external=external_attribute_name, internal=internal_attribute_name + ) + logger.debug(logline) internal_dict[internal_attribute_name] = attribute_values else: - logger.debug("skipped backend attribute '%s': no value found", external_attribute_name) - + logline = "skipped backend attribute {}: no value found".format( + external_attribute_name + ) + logger.debug(logline) internal_dict = self._handle_template_attributes(attribute_profile, internal_dict) return internal_dict @@ -181,20 +188,27 @@ def from_internal(self, attribute_profile, internal_dict): try: attribute_mapping = self.from_internal_attributes[internal_attribute_name] except KeyError: - logger.debug("no attribute mapping found for the internal attribute '%s'", internal_attribute_name) + logline = "no attribute mapping found for the internal attribute {}".format( + internal_attribute_name + ) + logger.debug(logline) continue if attribute_profile not in attribute_mapping: # skip this internal attribute if we have no mapping in the specified profile - logger.debug("no mapping found for '%s' in attribute profile '%s'" % - (internal_attribute_name, attribute_profile)) + logline = "no mapping found for '{internal}' in attribute profile '{attribute}'".format( + internal=internal_attribute_name, attribute=attribute_profile + ) + logger.debug(logline) continue external_attribute_names = self.from_internal_attributes[internal_attribute_name][attribute_profile] # select the first attribute name external_attribute_name = external_attribute_names[0] - logger.debug("frontend attribute %s mapped from %s" % (external_attribute_name, - internal_attribute_name)) + logline = "frontend attribute {external} mapped from {internal}".format( + external=external_attribute_name, internal=internal_attribute_name + ) + logger.debug(logline) if self.separator in external_attribute_name: nested_attribute_names = external_attribute_name.split(self.separator) From 60691848a9f3ac5db3b109aca0c16ff90d602983 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Thu, 3 Oct 2019 09:29:54 +0000 Subject: [PATCH 054/401] Improve logging - satosa.base Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index a5724fdf5..ae041ab0e 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -14,7 +14,6 @@ from .context import Context from .exception import SATOSAConfigurationError from .exception import SATOSAError, SATOSAAuthenticationError, SATOSAUnknownError -from .logging_util import satosa_logging from .micro_services.account_linking import AccountLinking from .micro_services.consent import Consent from .plugin_loader import load_backends, load_frontends @@ -24,6 +23,7 @@ from satosa.deprecated import hash_attributes +import satosa.logging_util as lu logger = logging.getLogger(__name__) @@ -136,8 +136,9 @@ def _auth_req_callback_func(self, context, internal_request): "filter": internal_request.attributes or [], "requester_name": internal_request.requester_name, }) - satosa_logging(logger, logging.INFO, - "Requesting provider: {}".format(internal_request.requester), state) + msg = "Requesting provider: {}".format(internal_request.requester) + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.info(logline) if self.request_micro_services: return self.request_micro_services[0].process(context, internal_request) @@ -232,7 +233,8 @@ def _run_bound_endpoint(self, context, spec): msg = "ERROR_ID [{err_id}]\nSTATE:\n{state}".format( err_id=error.error_id, state=state ) - satosa_logging(logger, logging.ERROR, msg, error.state, exc_info=True) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline, error.state, exc_info=True) return self._handle_satosa_authentication_error(error) def _load_state(self, context): @@ -252,9 +254,9 @@ def _load_state(self, context): state = State() finally: context.state = state - msg_tmpl = 'Loaded state {state} from cookie {cookie}' - msg = msg_tmpl.format(state=state, cookie=context.cookie) - logger.info(msg) + msg = "Loaded state {state} from cookie {cookie}".format(state=state, cookie=context.cookie) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) def _save_state(self, resp, context): """ @@ -289,17 +291,19 @@ def run(self, context): except SATOSANoBoundEndpointError: raise except SATOSAError: - satosa_logging(logger, logging.ERROR, "Uncaught SATOSA error ", context.state, - exc_info=True) + msg = "Uncaught SATOSA error" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline, exc_info=True) raise except UnknownSystemEntity as err: - satosa_logging(logger, logging.ERROR, - "configuration error: unknown system entity " + str(err), - context.state, exc_info=False) + msg = "configuration error: unknown system entity " + str(err) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline, exc_info=False) raise except Exception as err: - satosa_logging(logger, logging.ERROR, "Uncaught exception", context.state, - exc_info=True) + msg = "Uncaught exception" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline, exc_info=True) raise SATOSAUnknownError("Unknown error") from err return resp From 6d739a71b96617b26a26e3ca9dc84380d4494971 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Thu, 10 Oct 2019 09:33:03 +0000 Subject: [PATCH 055/401] Improve logging - satosa.satosa_config Signed-off-by: Ivan Kanakarakis --- src/satosa/satosa_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/satosa/satosa_config.py b/src/satosa/satosa_config.py index a55565d33..973fb88ba 100644 --- a/src/satosa/satosa_config.py +++ b/src/satosa/satosa_config.py @@ -147,11 +147,11 @@ def _load_yaml(self, config_file): with open(config_file) as f: return yaml.safe_load(f.read()) except yaml.YAMLError as exc: - logger.error("Could not parse config as YAML: {}", str(exc)) + logger.error("Could not parse config as YAML: {}".format(exc)) if hasattr(exc, 'problem_mark'): mark = exc.problem_mark - logger.error("Error position: (%s:%s)" % (mark.line + 1, mark.column + 1)) + logger.error("Error position: ({line}:{column})".format(line=mark.line + 1, column=mark.column + 1)) except IOError as e: - logger.debug("Could not open config file: {}", str(e)) + logger.debug("Could not open config file: {}".format(e)) return None From e8f3ead7ca77d43fb461a0a4c0084d4c353468e5 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Thu, 3 Oct 2019 14:52:37 +0000 Subject: [PATCH 056/401] Improve logging - satosa.plugin_loader Signed-off-by: Ivan Kanakarakis --- src/satosa/plugin_loader.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/satosa/plugin_loader.py b/src/satosa/plugin_loader.py index a4306c25b..65c535de2 100644 --- a/src/satosa/plugin_loader.py +++ b/src/satosa/plugin_loader.py @@ -46,7 +46,7 @@ def load_backends(config, callback, internal_attributes): config["BACKEND_MODULES"], backend_filter, config["BASE"], internal_attributes, callback) - logger.info("Setup backends: %s" % [backend.name for backend in backend_modules]) + logger.info("Setup backends: {}".format([backend.name for backend in backend_modules])) return backend_modules @@ -67,7 +67,7 @@ def load_frontends(config, callback, internal_attributes): """ frontend_modules = _load_plugins(config.get("CUSTOM_PLUGIN_MODULE_PATHS"), config["FRONTEND_MODULES"], frontend_filter, config["BASE"], internal_attributes, callback) - logger.info("Setup frontends: %s" % [frontend.name for frontend in frontend_modules]) + logger.info("Setup frontends: {}".format([frontend.name for frontend in frontend_modules])) return frontend_modules @@ -147,7 +147,7 @@ def _load_plugin_config(config): except YAMLError as exc: if hasattr(exc, 'problem_mark'): mark = exc.problem_mark - logger.error("Error position: (%s:%s)" % (mark.line + 1, mark.column + 1)) + logger.error("Error position: ({line}:{column})".format(line=mark.line + 1, column=mark.column + 1)) raise SATOSAConfigurationError("The configuration is corrupt.") from exc @@ -257,7 +257,7 @@ def load_request_microservices(plugin_path, plugins, internal_attributes, base_u """ request_services = _load_microservices(plugin_path, plugins, _request_micro_service_filter, internal_attributes, base_url) - logger.info("Loaded request micro services: %s" % [type(k).__name__ for k in request_services]) + logger.info("Loaded request micro services: {}".format([type(k).__name__ for k in request_services])) return request_services @@ -278,5 +278,5 @@ def load_response_microservices(plugin_path, plugins, internal_attributes, base_ """ response_services = _load_microservices(plugin_path, plugins, _response_micro_service_filter, internal_attributes, base_url) - logger.info("Loaded response micro services: %s" % [type(k).__name__ for k in response_services]) + logger.info("Loaded response micro services:{}".format([type(k).__name__ for k in response_services])) return response_services From 0d81c40048b8a318540bddf4a4036dbb3ecca540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rainer=20H=C3=B6rbe?= Date: Wed, 23 Oct 2019 21:52:28 +0200 Subject: [PATCH 057/401] fix logging: higher severity and write absolute path in log message config file not found to be logged with severity error. The file path should be absolute to explain relative path errors --- src/satosa/satosa_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/satosa_config.py b/src/satosa/satosa_config.py index 973fb88ba..d3b414520 100644 --- a/src/satosa/satosa_config.py +++ b/src/satosa/satosa_config.py @@ -144,7 +144,7 @@ def _load_yaml(self, config_file): :return: Loaded config """ try: - with open(config_file) as f: + with open(os.path.abspath(config_file)) as f: return yaml.safe_load(f.read()) except yaml.YAMLError as exc: logger.error("Could not parse config as YAML: {}".format(exc)) @@ -152,6 +152,6 @@ def _load_yaml(self, config_file): mark = exc.problem_mark logger.error("Error position: ({line}:{column})".format(line=mark.line + 1, column=mark.column + 1)) except IOError as e: - logger.debug("Could not open config file: {}".format(e)) + logger.error("Could not open config file: {}".format(e)) return None From d33fc66756255bb5fc35e36801f210176c4ab5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rainer=20H=C3=B6rbe?= Date: Thu, 24 Oct 2019 21:13:15 +0200 Subject: [PATCH 058/401] add example file for custom_routing micros service an example has been missing so far. Example limited to DecideIfRequesterIsAllowed function of the micro service --- .../plugins/microservices/custom_routing.yaml.example | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 example/plugins/microservices/custom_routing.yaml.example diff --git a/example/plugins/microservices/custom_routing.yaml.example b/example/plugins/microservices/custom_routing.yaml.example new file mode 100644 index 000000000..b6b0d9c3d --- /dev/null +++ b/example/plugins/microservices/custom_routing.yaml.example @@ -0,0 +1,11 @@ +module: satosa.micro_services.custom_routing.DecideIfRequesterIsAllowed +name: RequesterDecider +config: + rules: + target_entity_id1: + allow: ["requester1", "requester2"] + target_entity_id2: + deny: ["requester3"] + target_entity_id3: + allow: ["requester1"] + deny: ["*"] From c0d29d149ba5434e68fd06643a2be6730989056c Mon Sep 17 00:00:00 2001 From: sebulibah Date: Tue, 8 Oct 2019 08:10:55 +0000 Subject: [PATCH 059/401] modified oauth logger --- src/satosa/backends/oauth.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/satosa/backends/oauth.py b/src/satosa/backends/oauth.py index 9136ce6d4..2308f1eee 100644 --- a/src/satosa/backends/oauth.py +++ b/src/satosa/backends/oauth.py @@ -6,14 +6,15 @@ from base64 import urlsafe_b64encode import requests + from oic.oauth2.consumer import Consumer, stateID from oic.oauth2.message import AuthorizationResponse from oic.utils.authn.authn_context import UNSPECIFIED +import satosa.logging_util as lu from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.exception import SATOSAAuthenticationError -from satosa.logging_util import satosa_logging from satosa.response import Redirect from satosa.util import rndstr from satosa.metadata_creation.description import ( @@ -112,8 +113,9 @@ def _verify_state(self, resp, state_data, state): is_known_state = "state" in resp and "state" in state_data and resp["state"] == state_data["state"] if not is_known_state: received_state = resp.get("state", "") - satosa_logging(logger, logging.DEBUG, - "Missing or invalid state [%s] in response!" % received_state, state) + msg = "Missing or invalid state [{}] in response!".format(received_state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.debug(logline) raise SATOSAAuthenticationError(state, "Missing or invalid state [%s] in response!" % received_state) From cb228d26b26e63824838a40a2d0d8d14887ef530 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Tue, 8 Oct 2019 09:49:02 +0000 Subject: [PATCH 060/401] changed openid_connect logger --- src/satosa/backends/openid_connect.py | 35 ++++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/satosa/backends/openid_connect.py b/src/satosa/backends/openid_connect.py index 60a17d7a3..87772f565 100644 --- a/src/satosa/backends/openid_connect.py +++ b/src/satosa/backends/openid_connect.py @@ -13,14 +13,15 @@ from oic.utils.authn.authn_context import UNSPECIFIED from oic.utils.authn.client import CLIENT_AUTHN_METHOD +import satosa.logging_util as lu from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from .base import BackendModule from .oauth import get_metadata_desc_for_oauth_backend from ..exception import SATOSAAuthenticationError, SATOSAError -from ..logging_util import satosa_logging from ..response import Redirect + logger = logging.getLogger(__name__) NONCE_KEY = "oidc_nonce" @@ -118,10 +119,9 @@ def _verify_nonce(self, nonce, context): """ backend_state = context.state[self.name] if nonce != backend_state[NONCE_KEY]: - satosa_logging(logger, logging.DEBUG, - "Missing or invalid nonce in authn response for state: %s" % - backend_state, - context.state) + msg = "Missing or invalid nonce in authn response for state: {}".format(backend_state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) raise SATOSAAuthenticationError(context.state, "Missing or invalid nonce in authn response") def _get_tokens(self, authn_response, context): @@ -156,9 +156,13 @@ def _check_error_response(self, response, context): :raise SATOSAAuthenticationError: if the response is an OAuth error response """ if "error" in response: - satosa_logging(logger, logging.DEBUG, "%s error: %s %s" % - (type(response).__name__, response["error"], response.get("error_description", "")), - context.state) + msg = "{name} error: {error} {description}".format( + name=type(response).__name__, + error=response["error"], + description=response.get("error_description", ""), + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) raise SATOSAAuthenticationError(context.state, "Access denied") def _get_userinfo(self, state, context): @@ -181,10 +185,9 @@ def response_endpoint(self, context, *args): backend_state = context.state[self.name] authn_resp = self.client.parse_response(AuthorizationResponse, info=context.request, sformat="dict") if backend_state[STATE_KEY] != authn_resp["state"]: - satosa_logging(logger, logging.DEBUG, - "Missing or invalid state in authn response for state: %s" % - backend_state, - context.state) + msg = "Missing or invalid state in authn response for state: {}".format(backend_state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) raise SATOSAAuthenticationError(context.state, "Missing or invalid state in authn response") self._check_error_response(authn_resp, context) @@ -200,11 +203,15 @@ def response_endpoint(self, context, *args): userinfo = self._get_userinfo(authn_resp["state"], context) if not id_token_claims and not userinfo: - satosa_logging(logger, logging.ERROR, "No id_token or userinfo, nothing to do..", context.state) + msg = "No id_token or userinfo, nothing to do.." + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) raise SATOSAAuthenticationError(context.state, "No user info available.") all_user_claims = dict(list(userinfo.items()) + list(id_token_claims.items())) - satosa_logging(logger, logging.DEBUG, "UserInfo: %s" % all_user_claims, context.state) + msg = "UserInfo: {}".format(all_user_claims) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) del context.state[self.name] internal_resp = self._translate_response(all_user_claims, self.client.authorization_endpoint) return self.auth_callback_func(context, internal_resp) From 375ec665656c95137837d5f33511922a5a3e23a9 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Tue, 8 Oct 2019 13:03:28 +0000 Subject: [PATCH 061/401] modified logging for satosa/backends/saml2 --- src/satosa/backends/saml2.py | 94 +++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index d942817ac..024e948d8 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -16,6 +16,7 @@ from saml2.metadata import create_metadata_string from saml2.authn_context import requested_authn_context +import satosa.logging_util as lu import satosa.util as util from satosa.base import SAMLBaseModule from satosa.base import SAMLEIDASBaseModule @@ -23,14 +24,12 @@ from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.exception import SATOSAAuthenticationError -from satosa.logging_util import satosa_logging from satosa.response import SeeOther, Response from satosa.saml_util import make_saml_response from satosa.metadata_creation.description import ( MetadataDescription, OrganizationDesc, ContactPersonDesc, UIInfoDesc ) from satosa.backends.base import BackendModule - from satosa.deprecated import SAMLInternalResponse @@ -150,18 +149,16 @@ def get_idp_entity_id(self, context): memorized_idp = get_memorized_idp(context, self.config, force_authn) entity_id = only_idp or target_entity_id or memorized_idp or None - satosa_logging( - logger, logging.INFO, - { - "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, - }, - context.state, - ) + 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): @@ -256,7 +253,9 @@ def authn_request(self, context, entity_id): 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) + 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 = {} @@ -270,25 +269,33 @@ def authn_request(self, context, entity_id): 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) + "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) + 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) + 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: - satosa_logging(logger, logging.DEBUG, "Failed to construct the AuthnRequest for state", context.state, - exc_info=True) + 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: - errmsg = "Request with duplicate id {}".format(req_id) - satosa_logging(logger, logging.DEBUG, errmsg, context.state) - raise SATOSAAuthenticationError(context.state, errmsg) + 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} @@ -306,7 +313,9 @@ def authn_response(self, context, binding): :return: response """ if not context.request["SAMLResponse"]: - satosa_logging(logger, logging.DEBUG, "Missing Response for state", context.state) + 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: @@ -314,22 +323,25 @@ def authn_response(self, context, binding): 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) + 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: - errmsg = "No request with id: {}".format(req_id), - satosa_logging(logger, logging.DEBUG, errmsg, context.state) - raise SATOSAAuthenticationError(context.state, errmsg) + 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"]: - satosa_logging(logger, logging.DEBUG, - "State did not match relay state for state", context.state) + 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_BACKEND_METADATA_STORE, self.sp.metadata) @@ -356,7 +368,9 @@ def disco_response(self, context): try: entity_id = info["entityID"] except KeyError as err: - satosa_logging(logger, logging.DEBUG, "No IDP chosen for state", state, exc_info=True) + 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) @@ -401,9 +415,11 @@ def _translate_response(self, response, state): subject_id=name_id, ) - satosa_logging(logger, logging.DEBUG, - "backend received attributes:\n%s" % - json.dumps(response.ava, indent=4), state) + 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): @@ -415,7 +431,9 @@ def _metadata_endpoint(self, context): :param context: The current context :return: response with metadata """ - satosa_logging(logger, logging.DEBUG, "Sending metadata response", context.state) + 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") From 0b391fcab8bbc15f34d1f1469c8637d93d37b4db Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 16 Oct 2019 08:01:31 +0000 Subject: [PATCH 062/401] Modify logging for proxy_server Signed-off-by: Ivan Kanakarakis --- src/satosa/proxy_server.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 8c85ca08e..71e0e6935 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -41,7 +41,8 @@ def unpack_post(environ, content_length): elif "application/json" in environ["CONTENT_TYPE"]: data = json.loads(post_body) - logger.debug("unpack_post:: %s", data) + logline = "unpack_post:: {}".format(data) + logger.debug(logline) return data @@ -57,7 +58,8 @@ def unpack_request(environ, content_length=0): elif environ["REQUEST_METHOD"] == "POST": data = unpack_post(environ, content_length) - logger.debug("read request data: %s", data) + logline = "read request data: {}".format(data) + logger.debug(logline) return data @@ -120,7 +122,8 @@ def __call__(self, environ, start_response, debug=False): return resp(environ, start_response) except Exception as err: if type(err) != UnknownSystemEntity: - logger.exception("%s" % err) + logline = "{}".format(err) + logger.exception(logline) if debug: raise @@ -142,11 +145,14 @@ def make_app(satosa_config): try: _ = pkg_resources.get_distribution(module.__name__) - logger.info("Running SATOSA version %s", - pkg_resources.get_distribution("SATOSA").version) + logline = "Running SATOSA version {}".format( + pkg_resources.get.get_distribution("SATOSA").version + ) + logger.info(logline) except (NameError, pkg_resources.DistributionNotFound): pass return ToBytesMiddleware(WsgiApplication(satosa_config)) except Exception: - logger.exception("Failed to create WSGI app.") + logline = "Failed to create WSGI app." + logger.exception(logline) raise From 4eb1620c3c81067ee0200f7f052160233583e7e5 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Mon, 14 Oct 2019 10:16:27 +0000 Subject: [PATCH 063/401] modified logger for ping.py Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/ping.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/satosa/frontends/ping.py b/src/satosa/frontends/ping.py index d0381210a..8eda3948c 100644 --- a/src/satosa/frontends/ping.py +++ b/src/satosa/frontends/ping.py @@ -1,18 +1,19 @@ +import logging + +import satosa.logging_util as lu import satosa.micro_services.base from satosa.logging_util import satosa_logging - from satosa.response import Response -import logging logger = logging.getLogger(__name__) + class PingFrontend(satosa.frontends.base.FrontendModule): """ SATOSA frontend that responds to a query with a simple 200 OK, intended to be used as a simple heartbeat monitor. """ - logprefix = "PING:" def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): super().__init__(auth_req_callback_func, internal_attributes, base_url, name) @@ -50,9 +51,9 @@ def register_endpoints(self, backend_names): def ping_endpoint(self, context): """ """ - logprefix = PingFrontend.logprefix - satosa_logging(logger, logging.DEBUG, "{} ping returning 200 OK".format(logprefix), context.state) + msg = "Ping returning 200 OK" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) msg = " " - return Response(msg) From 0cde0955d93698cb617dd0f6f018219926e09445 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 23 Oct 2019 13:32:29 +0000 Subject: [PATCH 064/401] improved logging for primary_identifier Signed-off-by: Ivan Kanakarakis --- .../micro_services/primary_identifier.py | 112 +++++++++++++----- 1 file changed, 84 insertions(+), 28 deletions(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index c1a2e9d23..97b68baef 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -5,16 +5,18 @@ the value for a configured attribute, for example uid. """ -import satosa.micro_services.base -from satosa.logging_util import satosa_logging -from satosa.response import Redirect - import copy import logging import urllib.parse +import satosa.logging_util as lu +import satosa.micro_services.base +from satosa.response import Redirect + + logger = logging.getLogger(__name__) + class PrimaryIdentifier(satosa.micro_services.base.ResponseMicroService): """ Use a configured ordered list of attributes to construct a primary @@ -39,17 +41,23 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): context = self.context attributes = data.attributes - satosa_logging(logger, logging.DEBUG, "{} Input attributes {}".format(logprefix, attributes), context.state) + msg = "{} Input attributes {}".format(logprefix, attributes) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) value = None for candidate in ordered_identifier_candidates: - satosa_logging(logger, logging.DEBUG, "{} Considering candidate {}".format(logprefix, candidate), context.state) + msg = "{} Considering candidate {}".format(logprefix, candidate) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Get the values asserted by the IdP for the configured list of attribute names for this candidate # and substitute None if the IdP did not assert any value for a configured attribute. values = [ attributes.get(attribute_name, [None])[0] for attribute_name in candidate['attribute_names'] ] - satosa_logging(logger, logging.DEBUG, "{} Found candidate values {}".format(logprefix, values), context.state) + msg = "{} Found candidate values {}".format(logprefix, values) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # If one of the configured attribute names is name_id then if there is also a configured # name_id_format add the value for the NameID of that format if it was asserted by the IdP @@ -65,7 +73,9 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): and candidate_name_id_format and candidate_name_id_format == name_id_format ): - satosa_logging(logger, logging.DEBUG, "{} IdP asserted NameID {}".format(logprefix, name_id_value), context.state) + msg = "{} IdP asserted NameID {}".format(logprefix, name_id_value) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) candidate_nameid_value = name_id_value # Only add the NameID value asserted by the IdP if it is not already @@ -74,15 +84,25 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): # in the value for SAML2 persistent NameID as well as asserting # eduPersonPrincipalName. if candidate_nameid_value not in values: - satosa_logging(logger, logging.DEBUG, "{} Added NameID {} to candidate values".format(logprefix, candidate_nameid_value), context.state) + msg = "{} Added NameID {} to candidate values".format( + logprefix, candidate_nameid_value + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) values.append(candidate_nameid_value) else: - satosa_logging(logger, logging.WARN, "{} NameID {} value also asserted as attribute value".format(logprefix, candidate_nameid_value), context.state) + msg = "{} NameID {} value also asserted as attribute value".format( + logprefix, candidate_nameid_value + ) + logline = logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warn(logline) # If no value was asserted by the IdP for one of the configured list of attribute names # for this candidate then go onto the next candidate. if None in values: - satosa_logging(logger, logging.DEBUG, "{} Candidate is missing value so skipping".format(logprefix), context.state) + msg = "{} Candidate is missing value so skipping".format(logprefix) + logline = logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) continue # All values for the configured list of attribute names are present @@ -93,7 +113,9 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): scope = data.auth_info.issuer else: scope = candidate['add_scope'] - satosa_logging(logger, logging.DEBUG, "{} Added scope {} to values".format(logprefix, scope), context.state) + msg = "{} Added scope {} to values".format(logprefix, scope) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) values.append(scope) # Concatenate all values to create the primary identifier. @@ -110,34 +132,46 @@ def process(self, context, data): # that is passed during initialization. config = self.config - satosa_logging(logger, logging.DEBUG, "{} Using default configuration {}".format(logprefix, config), context.state) + msg = "{} Using default configuration {}".format(logprefix, config) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Find the entityID for the SP that initiated the flow try: spEntityID = context.state.state_dict['SATOSA_BASE']['requester'] except KeyError as err: - satosa_logging(logger, logging.ERROR, "{} Unable to determine the entityID for the SP requester".format(logprefix), context.state) + msg = "{} Unable to determine the entityID for the SP requester".format(logprefix) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) return super().process(context, data) - satosa_logging(logger, logging.DEBUG, "{} entityID for the SP requester is {}".format(logprefix, spEntityID), context.state) + msg = "{} entityID for the SP requester is {}".format(logprefix, spEntityID) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Find the entityID for the IdP that issued the assertion try: idpEntityID = data.auth_info.issuer except KeyError as err: - satosa_logging(logger, logging.ERROR, "{} Unable to determine the entityID for the IdP issuer".format(logprefix), context.state) + msg = "{} Unable to determine the entityID for the IdP issuer".format(logprefix) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) return super().process(context, data) # Examine our configuration to determine if there is a per-IdP configuration if idpEntityID in self.config: config = self.config[idpEntityID] - satosa_logging(logger, logging.DEBUG, "{} For IdP {} using configuration {}".format(logprefix, idpEntityID, config), context.state) + msg = "{} For IdP {} using configuration {}".format(logprefix, idpEntityID, config) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Examine our configuration to determine if there is a per-SP configuration. # An SP configuration overrides an IdP configuration when there is a conflict. if spEntityID in self.config: config = self.config[spEntityID] - satosa_logging(logger, logging.DEBUG, "{} For SP {} using configuration {}".format(logprefix, spEntityID, config), context.state) + msg = "{} For SP {} using configuration {}".format(logprefix, spEntityID, config) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Obtain configuration details from the per-SP configuration or the default configuration try: @@ -169,20 +203,28 @@ def process(self, context, data): on_error = None except KeyError as err: - satosa_logging(logger, logging.ERROR, "{} Configuration '{}' is missing".format(logprefix, err), context.state) + msg = "{} Configuration '{}' is missing".format(logprefix, err) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) return super().process(context, data) # Ignore this SP entirely if so configured. if ignore: - satosa_logging(logger, logging.INFO, "{} Ignoring SP {}".format(logprefix, spEntityID), context.state) + msg = "{} Ignoring SP {}".format(logprefix, spEntityID) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) return super().process(context, data) # Construct the primary identifier. - satosa_logging(logger, logging.DEBUG, "{} Constructing primary identifier".format(logprefix), context.state) + msg = "{} Constructing primary identifier".format(logprefix) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) primary_identifier_val = self.constructPrimaryIdentifier(data, ordered_identifier_candidates) if not primary_identifier_val: - satosa_logging(logger, logging.WARN, "{} No primary identifier found".format(logprefix), context.state) + msg = "{} No primary identifier found".format(logprefix) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warn(logline) if on_error: # Redirect to the configured error handling service with # the entityIDs for the target SP and IdP used by the user @@ -190,19 +232,33 @@ def process(self, context, data): encodedSpEntityID = urllib.parse.quote_plus(spEntityID) encodedIdpEntityID = urllib.parse.quote_plus(data.auth_info.issuer) url = "{}?sp={}&idp={}".format(on_error, encodedSpEntityID, encodedIdpEntityID) - satosa_logging(logger, logging.INFO, "{} Redirecting to {}".format(logprefix, url), context.state) + msg = "{} Redirecting to {}".format(logprefix, url) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) return Redirect(url) - satosa_logging(logger, logging.INFO, "{} Found primary identifier: {}".format(logprefix, primary_identifier_val), context.state) + msg = "{} Found primary identifier: {}".format(logprefix, primary_identifier_val) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) # Clear input attributes if so configured. if clear_input_attributes: - satosa_logging(logger, logging.DEBUG, "{} Clearing values for these input attributes: {}".format(logprefix, data.attributes), context.state) + msg = "{} Clearing values for these input attributes: {}".format( + logprefix, data.attribute_names + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) data.attributes = {} # Set the primary identifier attribute to the value found. data.attributes[primary_identifier] = primary_identifier_val - satosa_logging(logger, logging.DEBUG, "{} Setting attribute {} to value {}".format(logprefix, primary_identifier, primary_identifier_val), context.state) - - satosa_logging(logger, logging.DEBUG, "{} returning data.attributes {}".format(logprefix, str(data.attributes)), context.state) + msg = "{} Setting attribute {} to value {}".format( + logprefix, primary_identifier, primary_identifier_val + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + + msg = "{} returning data.attributes {}".format(logprefix, str(data.attributes)) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.statestate), message=msg) + logger.debug(logline) return super().process(context, data) From 8740840c1c5484f6ee46225b78bf76fb39d07144 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 23 Oct 2019 10:03:47 +0000 Subject: [PATCH 065/401] improved logging for custom_routing Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/custom_routing.py | 27 ++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index b67d908a2..d903502be 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -2,11 +2,11 @@ from base64 import urlsafe_b64encode from satosa.context import Context - from .base import RequestMicroService from ..exception import SATOSAConfigurationError from ..exception import SATOSAError + logger = logging.getLogger(__name__) @@ -62,32 +62,37 @@ def _b64_url(self, data): def process(self, context, data): target_entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID) if None is target_entity_id: - msg_tpl = "{name} can only be used when a target entityid is set" - msg = msg_tpl.format(name=self.__class__.__name__) + msg = "{name} can only be used when a target entityid is set".format( + name=self.__class__.__name__ + ) logger.error(msg) raise SATOSAError(msg) target_specific_rules = self.rules.get(target_entity_id) # default to allowing everything if there are no specific rules if not target_specific_rules: - logging.debug("Requester '%s' allowed by default to target entity '%s' due to no entity specific rules", - data.requester, target_entity_id) + logger.debug("Requester '{}' allowed by default to target entity '{}' due to no entity specific rules".format( + data.requester, target_entity_id + )) return super().process(context, data) # deny rules takes precedence deny_rules = target_specific_rules.get("deny", []) if data.requester in deny_rules: - logging.debug("Requester '%s' is not allowed by target entity '%s' due to deny rules '%s'", data.requester, - target_entity_id, deny_rules) + logger.debug("Requester '{}' is not allowed by target entity '{}' due to deny rules '{}'".format( + data.requester, target_entity_id, deny_rules + )) raise SATOSAError("Requester is not allowed by target provider") allow_rules = target_specific_rules.get("allow", []) allow_all = "*" in allow_rules if data.requester in allow_rules or allow_all: - logging.debug("Requester '%s' allowed by target entity '%s' due to allow rules '%s", - data.requester, target_entity_id, allow_rules) + logger.debug("Requester '{}' allowed by target entity '{}' due to allow rules '{}".format( + data.requester, target_entity_id, allow_rules + )) return super().process(context, data) - logger.debug("Requester '%s' is not allowed by target entity '%s' due to final deny all rule in '%s'", - data.requester, target_entity_id, deny_rules) + logger.debug("Requester '{}' is not allowed by target entity '{}' due to final deny all rule in '{}'".format( + data.requester, target_entity_id, deny_rules + )) raise SATOSAError("Requester is not allowed by target provider") From 0baecc39f9a35484087aa056271770a42b21767e Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 23 Oct 2019 09:28:04 +0000 Subject: [PATCH 066/401] improved logging for custom_logging Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/custom_logging.py | 55 ++++++++++++++------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/src/satosa/micro_services/custom_logging.py b/src/satosa/micro_services/custom_logging.py index 928214f8e..c82d03449 100644 --- a/src/satosa/micro_services/custom_logging.py +++ b/src/satosa/micro_services/custom_logging.py @@ -2,16 +2,17 @@ SATOSA microservice that outputs log in custom format. """ -from .base import ResponseMicroService -from satosa.logging_util import satosa_logging -from base64 import urlsafe_b64encode, urlsafe_b64decode - -import json import copy +import json import logging +import satosa.logging_util as lu +from .base import ResponseMicroService + + logger = logging.getLogger(__name__) + class CustomLoggingService(ResponseMicroService): """ Use context and data object to create custom log output @@ -21,7 +22,7 @@ class CustomLoggingService(ResponseMicroService): def __init__(self, config, *args, **kwargs): super().__init__(*args, **kwargs) self.config = config - + def process(self, context, data): logprefix = CustomLoggingService.logprefix @@ -30,18 +31,26 @@ def process(self, context, data): config = self.config configClean = copy.deepcopy(config) - satosa_logging(logger, logging.DEBUG, "{} Using default configuration {}".format(logprefix, configClean), context.state) + msg = "{} Using default configuration {}".format(logprefix, configClean) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Find the entityID for the SP that initiated the flow and target IdP try: spEntityID = context.state.state_dict['SATOSA_BASE']['requester'] idpEntityID = data.auth_info.issuer except KeyError as err: - satosa_logging(logger, logging.ERROR, "{} Unable to determine the entityID's for the IdP or SP".format(logprefix), context.state) + msg = "{} Unable to determine the entityID's for the IdP or SP".format(logprefix) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) return super().process(context, data) - satosa_logging(logger, logging.DEBUG, "{} entityID for the SP requester is {}".format(logprefix, spEntityID), context.state) - satosa_logging(logger, logging.ERROR, "{} entityID for the target IdP is {}".format(logprefix, idpEntityID), context.state) + msg = "{} entityID for the SP requester is {}".format(logprefix, spEntityID) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + msg = "{} entityID for the target IdP is {}".format(logprefix, idpEntityID) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) # Obtain configuration details from the per-SP configuration or the default configuration try: @@ -57,17 +66,25 @@ def process(self, context, data): except KeyError as err: - satosa_logging(logger, logging.ERROR, "{} Configuration '{}' is missing".format(logprefix, err), context.state) + msg = "{} Configuration '{}' is missing".format(logprefix, err) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) return super().process(context, data) record = None try: - satosa_logging(logger, logging.DEBUG, "{} Using context {}".format(logprefix, context), context.state) - satosa_logging(logger, logging.DEBUG, "{} Using data {}".format(logprefix, data.to_dict()), context.state) + msg = "{} Using context {}".format(logprefix, context) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + msg = "{} Using data {}".format(logprefix, data.to_dict()) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Open log_target file - satosa_logging(logger, logging.DEBUG, "{} Opening log_target file {}".format(logprefix, log_target), context.state) + msg = "{} Opening log_target file {}".format(logprefix, log_target) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) loghandle = open(log_target,"a") # This is where the logging magic happens @@ -78,15 +95,19 @@ def process(self, context, data): log['idp'] = idpEntityID log['sp'] = spEntityID log['attr'] = { key: data.to_dict()['attr'].get(key) for key in attrs } - + print(json.dumps(log), file=loghandle, end="\n") except Exception as err: - satosa_logging(logger, logging.ERROR, "{} Caught exception: {0}".format(logprefix, err), None) + msg = "{} Caught exception: {}".format(logprefix, err) + logline = lu.LOG_FMT.format(id=lu.get_session_id(None), message=msg) + logger.error(logline) return super().process(context, data) else: - satosa_logging(logger, logging.DEBUG, "{} Closing log_target file".format(logprefix), context.state) + msg = "{} Closing log_target file".format(logprefix) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Close log_target file loghandle.close() From a5a9bd81b3f238f043655183f42cc74e5eb3dbb5 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 18:10:45 +0200 Subject: [PATCH 067/401] Release version 4.5.0 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2ea170237..822397659 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.4.0 +current_version = 4.5.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 389c3c6ee..cf0b9110e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 4.5.0 (2019-11-05) + +- add options in samlofrontend to encrypt assertion from AuthnResponse +- use saml2.extension.mdui in place of saml2.extension.ui +- improve log handling +- remove logging around state-cookie loading +- print the absolute path of the configuration when failing to read it +- error out if no backend or frontend is configured +- frontends: oidc: support extra_scopes +- frontends: SAMLVirtualCoFrontend: add attribute scope +- backends: orcid: add state parameter to authorization request +- backends: orcid: fix read address attribute +- backends: orcid: fix authorization token header +- backends: bitbucket: new oauth2 backend +- backends: facebook: add more configuration options +- micro-services: improve the ldap_attribute_store +- build: refactor the start.sh docker script +- build: improve travis stages for new releases +- docs: add sequence diagrams for SAML-to-SAML flow +- docs: improve configuration docs +- docs: improve micro-service docs +- misc: correct typos + + ## 4.4.0 (2019-07-09) Trigger new version build to automatically upload to PyPI, From d743a4cca32bfb7488292ba7564a316036e51418 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 18:23:03 +0200 Subject: [PATCH 068/401] Fix typo in changelog Signed-off-by: Ivan Kanakarakis --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf0b9110e..5e308373e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 4.5.0 (2019-11-05) -- add options in samlofrontend to encrypt assertion from AuthnResponse +- add options in saml-frontend to encrypt assertion from AuthnResponse - use saml2.extension.mdui in place of saml2.extension.ui - improve log handling - remove logging around state-cookie loading From c7b2e92e2a1644c50c602f7400eb5d9653628dd2 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 18:27:37 +0200 Subject: [PATCH 069/401] Drop support for python 3.5 Signed-off-by: Ivan Kanakarakis --- .travis.yml | 1 - CHANGELOG.md | 5 +++++ setup.py | 1 - tox.ini | 1 - 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 78fc92735..77214ff2c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,6 @@ script: jobs: include: - - python: 3.5 - python: 3.6 - python: 3.7 - python: pypy3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e308373e..e3e65a432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 4.6.0 + +- build: drop support for python 3.5 + + ## 4.5.0 (2019-11-05) - add options in saml-frontend to encrypt assertion from AuthnResponse diff --git a/setup.py b/setup.py index 2b68c089d..9a5ce73a6 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,6 @@ zip_safe=False, classifiers=[ "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", ], diff --git a/tox.ini b/tox.ini index 6fa03483b..4d69d943e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = - py35 py36 py37 pypy3 From 60f50fd246aa2bb1ab0161518eb5bd2121a6d8fb Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 19:20:05 +0200 Subject: [PATCH 070/401] Properly change the package version Signed-off-by: Ivan Kanakarakis --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2b68c089d..37204c982 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='4.4.0', + version='4.5.1', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 7a658cc3417f3d51642065cfa3f6e5e11958be67 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 7 Nov 2019 16:12:19 +0200 Subject: [PATCH 071/401] Fix deprecation warning about escaped chars Python3 interprets string literals as Unicode strings. This means that \S is treated as an escaped Unicode character. To fix this the RegEx patterns should be declared as raw strings. Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/saml2.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 8026e6dc8..bf0a3c7c7 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -691,8 +691,12 @@ def _register_endpoints(self, providers): for binding, endp in self.endpoints[endp_category].items(): valid_providers = "|^".join(providers) parsed_endp = urlparse(endp) - url_map.append(("(^%s)/\S+/%s" % (valid_providers, parsed_endp.path), - functools.partial(self.handle_authn_request, binding_in=binding))) + url_map.append( + ( + r"(^{})/\S+/{}".format(valid_providers, parsed_endp.path), + functools.partial(self.handle_authn_request, binding_in=binding) + ) + ) return url_map From 7f61b4011a00a1b89c1b67922a92da09b1689fc9 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 7 Nov 2019 15:36:26 +0200 Subject: [PATCH 072/401] Fix deprecation warnings Signed-off-by: Ivan Kanakarakis --- tests/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/util.py b/tests/util.py index 12d9ec4ca..e6ac37d46 100644 --- a/tests/util.py +++ b/tests/util.py @@ -469,7 +469,7 @@ def handle_response(self, context): auth_info = AuthenticationInformation("test", str(datetime.now()), "test_issuer") internal_resp = InternalData(auth_info=auth_info) internal_resp.attributes = context.request - internal_resp.user_id = "test_user" + internal_resp.subject_id = "test_user" return self.auth_callback_func(context, internal_resp) From 20a31dcc3db5d1ee1622413d6e1743469a066cb4 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 7 Nov 2019 16:39:02 +0200 Subject: [PATCH 073/401] Remove PytestWarnings about collecting test classes The classes under tests.util are not destined to hold test cases. Signed-off-by: Ivan Kanakarakis --- tests/util.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/util.py b/tests/util.py index e6ac37d46..0e1f5f9fb 100644 --- a/tests/util.py +++ b/tests/util.py @@ -456,6 +456,8 @@ def register_endpoints(self, backend_names): class TestBackend(BackendModule): + __test__ = False + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): super().__init__(auth_callback_func, internal_attributes, base_url, name) @@ -474,6 +476,8 @@ def handle_response(self, context): class TestFrontend(FrontendModule): + __test__ = False + def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): super().__init__(auth_req_callback_func, internal_attributes, base_url, name) @@ -492,6 +496,8 @@ def handle_authn_response(self, context, internal_resp): class TestRequestMicroservice(RequestMicroService): + __test__ = False + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -503,6 +509,8 @@ def callback(self): class TestResponseMicroservice(ResponseMicroService): + __test__ = False + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From 714ea2ce03abc38d08b27161a8916a0e20a2884b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 14:19:39 +0200 Subject: [PATCH 074/401] Format code Signed-off-by: Ivan Kanakarakis --- src/satosa/internal.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index d37d5b05d..f7d50522e 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -101,9 +101,7 @@ def __init__( self.requester_name = ( requester_name if requester_name is not None - else [ - {"text": requester, "lang": "en"} - ] + else [{"text": requester, "lang": "en"}] ) self.subject_id = ( subject_id From df8c5b691363e22e7f491659cb0914c90242a088 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 11:21:28 +0200 Subject: [PATCH 075/401] Introduce the _Datafy class Signed-off-by: Ivan Kanakarakis --- src/satosa/internal.py | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index f7d50522e..2feb904e0 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -2,6 +2,81 @@ import warnings as _warnings +from collections import UserDict + + +class _Datafy(UserDict): + _DEPRECATED_TO_NEW_MEMBERS = {} + + def _get_new_key(self, old_key): + new_key = self.__class__._DEPRECATED_TO_NEW_MEMBERS.get(old_key, old_key) + is_key_deprecated = old_key != new_key + if is_key_deprecated: + msg = "'{old_key}' is deprecated; use '{new_key}' instead.".format( + old_key=old_key, new_key=new_key + ) + _warnings.warn(msg, DeprecationWarning) + return new_key + + def __setitem__(self, key, value): + new_key = self._get_new_key(key) + return super().__setitem__(new_key, value) + + def __getitem__(self, key): + new_key = self._get_new_key(key) + value = super().__getitem__(new_key) + return value + + def __setattr__(self, key, value): + if key == "data": + return super().__setattr__(key, value) + + self.__setitem__(key, value) + + def __getattr__(self, key): + if key == "data": + return self.data + + try: + value = self.__getitem__(key) + except KeyError as e: + msg = "'{type}' object has no attribute '{attr}'".format( + type=type(self), attr=key + ) + raise AttributeError(msg) from e + return value + + def to_dict(self): + """ + Converts an object to a dict + :rtype: dict[str, str] + :return: A dict representation of the object + """ + data = { + key: value + for key, value_obj in self.items() + for value in [ + value_obj.to_dict() if hasattr(value_obj, "to_dict") else value_obj + ] + } + data.update( + { + key: data.get(value) + for key, value in self.__class__._DEPRECATED_TO_NEW_MEMBERS.items() + } + ) + return data + + @classmethod + def from_dict(cls, data): + """ + :type data: dict[str, str] + :rtype: satosa.internal.AuthenticationInformation + :param data: A dict representation of an object + :return: An object + """ + instance = cls(**data.copy()) + return instance class AuthenticationInformation(object): From ec12ff40bc3f389b91a5eabadf1b8135917e3429 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 14:15:08 +0200 Subject: [PATCH 076/401] Datafy the objects under satosa.internal module Signed-off-by: Ivan Kanakarakis --- src/satosa/internal.py | 67 ++++++++++-------------------------------- 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index 2feb904e0..2fedab4f4 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -79,12 +79,14 @@ def from_dict(cls, data): return instance -class AuthenticationInformation(object): +class AuthenticationInformation(_Datafy): """ Class that holds information about the authentication """ - def __init__(self, auth_class_ref=None, timestamp=None, issuer=None): + def __init__( + self, auth_class_ref=None, timestamp=None, issuer=None, *args, **kwargs + ): """ Initiate the data carrier @@ -96,6 +98,7 @@ def __init__(self, auth_class_ref=None, timestamp=None, issuer=None): :param timestamp: Time when the authentication was done :param issuer: Where the authentication was done """ + super().__init__(self, *args, **kwargs) self.auth_class_ref = auth_class_ref self.timestamp = timestamp self.issuer = issuer @@ -130,11 +133,18 @@ def __repr__(self): return str(self.to_dict()) -class InternalData(object): +class InternalData(_Datafy): """ A base class for the data carriers between frontends/backends """ + _DEPRECATED_TO_NEW_MEMBERS = { + "name_id": "subject_id", + "user_id": "subject_id", + "user_id_hash_type": "subject_type", + "approved_attributes": "attributes", + } + def __init__( self, auth_info=None, @@ -147,6 +157,8 @@ def __init__( user_id_hash_type=None, name_id=None, approved_attributes=None, + *args, + **kwargs, ): """ :param auth_info: @@ -171,6 +183,7 @@ def __init__( :type name_id: str :type approved_attributes: dict """ + super().__init__(self, *args, **kwargs) self.auth_info = auth_info or AuthenticationInformation() self.requester = requester self.requester_name = ( @@ -251,53 +264,5 @@ def from_dict(cls, data): ) return instance - @property - def user_id(self): - msg = "user_id is deprecated; use subject_id instead." - _warnings.warn(msg, DeprecationWarning) - return self.subject_id - - @user_id.setter - def user_id(self, value): - msg = "user_id is deprecated; use subject_id instead." - _warnings.warn(msg, DeprecationWarning) - self.subject_id = value - - @property - def user_id_hash_type(self): - msg = "user_id_hash_type is deprecated; use subject_type instead." - _warnings.warn(msg, DeprecationWarning) - return self.subject_type - - @user_id_hash_type.setter - def user_id_hash_type(self, value): - msg = "user_id_hash_type is deprecated; use subject_type instead." - _warnings.warn(msg, DeprecationWarning) - self.subject_type = value - - @property - def approved_attributes(self): - msg = "approved_attributes is deprecated; use attributes instead." - _warnings.warn(msg, DeprecationWarning) - return self.attributes - - @approved_attributes.setter - def approved_attributes(self, value): - msg = "approved_attributes is deprecated; use attributes instead." - _warnings.warn(msg, DeprecationWarning) - self.attributes = value - - @property - def name_id(self): - msg = "name_id is deprecated; use subject_id instead." - _warnings.warn(msg, DeprecationWarning) - return self.subject_id - - @name_id.setter - def name_id(self, value): - msg = "name_id is deprecated; use subject_id instead." - _warnings.warn(msg, DeprecationWarning) - self.subject_id = value - def __repr__(self): return str(self.to_dict()) From 9e53d9500467b2d68bd2daf6ccbdc872b27e1165 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 14:17:15 +0200 Subject: [PATCH 077/401] Remove the from_dict, to_dict and __repr__ custom methods Signed-off-by: Ivan Kanakarakis --- src/satosa/internal.py | 81 ------------------------------------------ 1 file changed, 81 deletions(-) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index 2fedab4f4..11beea111 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -103,35 +103,6 @@ def __init__( self.timestamp = timestamp self.issuer = issuer - def to_dict(self): - """ - Converts an AuthenticationInformation object to a dict - :rtype: dict[str, str] - :return: A dict representation of the object - """ - return { - "auth_class_ref": self.auth_class_ref, - "timestamp": self.timestamp, - "issuer": self.issuer, - } - - @classmethod - def from_dict(cls, data): - """ - :type data: dict[str, str] - :rtype: satosa.internal.AuthenticationInformation - :param data: A dict representation of an AuthenticationInformation object - :return: An AuthenticationInformation object - """ - return cls( - auth_class_ref=data.get("auth_class_ref"), - timestamp=data.get("timestamp"), - issuer=data.get("issuer"), - ) - - def __repr__(self): - return str(self.to_dict()) - class InternalData(_Datafy): """ @@ -214,55 +185,3 @@ def __init__( if approved_attributes is not None else {} ) - - def to_dict(self): - """ - Converts an InternalData object to a dict - :rtype: dict[str, str] - :return: A dict representation of the object - """ - data = { - "auth_info": self.auth_info.to_dict(), - "requester": self.requester, - "requester_name": self.requester_name, - "attributes": self.attributes, - "subject_id": self.subject_id, - "subject_type": self.subject_type, - } - data.update( - { - "user_id": self.subject_id, - "hash_type": self.subject_type, - "name_id": self.subject_id, - "approved_attributes": self.attributes, - } - ) - return data - - @classmethod - def from_dict(cls, data): - """ - :type data: dict[str, str] - :rtype: satosa.internal.InternalData - :param data: A dict representation of an InternalData object - :return: An InternalData object - """ - auth_info = AuthenticationInformation.from_dict( - data.get("auth_info", {}) - ) - instance = cls( - auth_info=auth_info, - requester=data.get("requester"), - requester_name=data.get("requester_name"), - subject_id=data.get("subject_id"), - subject_type=data.get("subject_type"), - attributes=data.get("attributes"), - user_id=data.get("user_id"), - user_id_hash_type=data.get("hash_type"), - name_id=data.get("name_id"), - approved_attributes=data.get("approved_attributes"), - ) - return instance - - def __repr__(self): - return str(self.to_dict()) From 63a7cd537e4c485dd18652c2d108186d940f817a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 14:19:04 +0200 Subject: [PATCH 078/401] Improve initialization InternalData auth_info member Signed-off-by: Ivan Kanakarakis --- src/satosa/internal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index 11beea111..2302a3da2 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -155,7 +155,11 @@ def __init__( :type approved_attributes: dict """ super().__init__(self, *args, **kwargs) - self.auth_info = auth_info or AuthenticationInformation() + self.auth_info = ( + auth_info + if isinstance(auth_info, AuthenticationInformation) + else AuthenticationInformation(**(auth_info or {})) + ) self.requester = requester self.requester_name = ( requester_name From d650fa2e25f7da6c9a03c18c8ff9d20996648732 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 5 Nov 2019 19:00:29 +0200 Subject: [PATCH 079/401] Format docs Signed-off-by: Ivan Kanakarakis --- doc/mod_wsgi.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/mod_wsgi.md b/doc/mod_wsgi.md index 8fd553606..8605c7abb 100644 --- a/doc/mod_wsgi.md +++ b/doc/mod_wsgi.md @@ -12,7 +12,7 @@ yum install httpd mod_ssl httpd-devel python34 python34-devel yum install xmlsec1-openssl gcc curl ``` -Install the latest production release of pip and use it to install the latest +Install the latest production release of pip and use it to install the latest production release of mod\_wsgi: ``` @@ -120,7 +120,7 @@ BACKEND_MODULES: - "/etc/satosa/plugins/saml2_backend.yaml" FRONTEND_MODULES: - - "/etc/satosa/plugins/ping_frontend.yaml" + - "/etc/satosa/plugins/ping_frontend.yaml" - "/etc/satosa/plugins/saml2_frontend.yaml" MICRO_SERVICES: @@ -151,7 +151,7 @@ LOGGING: propagate: no root: level: INFO - handlers: + handlers: - console ``` From 93560d9be0e94d89318bbf61dc4e5f9428ca9676 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 7 Nov 2019 17:19:50 +0200 Subject: [PATCH 080/401] Release version 5.0.0 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 8 +++++++- setup.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 822397659..d8f89e635 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.5.0 +current_version = 5.0.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e65a432..269b6c3b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ # Changelog -## 4.6.0 +## 5.0.0 (2019-11-07) +*Notice*: Support for python 3.5 has been dropped. + +- Add a dict-like interface to the internal objects +- Fix escaped chars in RegEx strings +- tests: fix warnings - build: drop support for python 3.5 +- misc: typos and formatting ## 4.5.0 (2019-11-05) diff --git a/setup.py b/setup.py index a4c30fd94..f49472639 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='4.5.1', + version='5.0.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 2142007470ab4fe96c9f69694154829b7a78f441 Mon Sep 17 00:00:00 2001 From: John Paraskevopoulos Date: Fri, 15 Nov 2019 15:57:31 +0200 Subject: [PATCH 081/401] Use Ubuntu 18.04 for Docker image - Bumps ubuntu version to 18.04 for the docker image. This version uses python 3.6 which is now our lowest python version supported - Fixes #310 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 569819afc..0c3a3d078 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:16.04 +FROM ubuntu:18.04 RUN apt-get update && \ apt-get -y dist-upgrade && \ From 31b002f79fea1047e6aea2e74c2e593f68549872 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 19 Nov 2019 17:29:38 +0200 Subject: [PATCH 082/401] Reformat Dockerfile Signed-off-by: Ivan Kanakarakis --- Dockerfile | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0c3a3d078..7d598098d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,25 @@ FROM ubuntu:18.04 -RUN apt-get update && \ - apt-get -y dist-upgrade && \ - apt-get install -y --no-install-recommends \ - git \ - python3-dev \ - build-essential \ - python3-pip \ - libffi-dev \ - libssl-dev \ - xmlsec1 \ - libyaml-dev && \ - apt-get clean +RUN apt-get update \ + && apt-get -y dist-upgrade \ + && apt-get install -y --no-install-recommends \ + git \ + python3-dev \ + build-essential \ + python3-pip \ + libffi-dev \ + libssl-dev \ + xmlsec1 \ + libyaml-dev \ + && apt-get clean RUN mkdir -p /src/satosa COPY . /src/satosa COPY docker/setup.sh /setup.sh COPY docker/start.sh /start.sh RUN chmod +x /setup.sh /start.sh \ - && sync \ - && /setup.sh + && sync \ + && /setup.sh COPY docker/attributemaps /opt/satosa/attributemaps From cbb3e5970c14e8adc5792ff9b26b4b5cf638892b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 19 Nov 2019 17:29:46 +0200 Subject: [PATCH 083/401] Use debian:stable-slim as the base image This allows us to keep following the base image updates without the need to be explicitly updating the tag. Signed-off-by: Ivan Kanakarakis --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7d598098d..b9ce5c88a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:18.04 +FROM debian:stable-slim RUN apt-get update \ && apt-get -y dist-upgrade \ From 35bb80cf04c17907ad7e3c2454b720b9133c0d9e Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 19 Nov 2019 17:30:30 +0200 Subject: [PATCH 084/401] Remove unneeded dependencies and use stable python3 Signed-off-by: Ivan Kanakarakis --- Dockerfile | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9ce5c88a..de81ccf9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,14 +3,10 @@ FROM debian:stable-slim RUN apt-get update \ && apt-get -y dist-upgrade \ && apt-get install -y --no-install-recommends \ - git \ - python3-dev \ - build-essential \ + python3 \ python3-pip \ - libffi-dev \ - libssl-dev \ + python3-venv \ xmlsec1 \ - libyaml-dev \ && apt-get clean RUN mkdir -p /src/satosa From fe950bcf19db03d2018ed970199c342724eededc Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 21 Nov 2019 21:02:03 +0200 Subject: [PATCH 085/401] Use python3 build-in venv module Signed-off-by: Ivan Kanakarakis --- docker/setup.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docker/setup.sh b/docker/setup.sh index da45625be..3545c5156 100755 --- a/docker/setup.sh +++ b/docker/setup.sh @@ -1,8 +1,10 @@ -#!/bin/bash +#!/bin/sh -pip3 install --upgrade virtualenv +set -e -virtualenv -p python3 /opt/satosa -/opt/satosa/bin/pip install --upgrade pip setuptools -/opt/satosa/bin/pip install /src/satosa/ +VENV_DIR=/opt/satosa +python3 -m venv "$VENV_DIR" + +"${VENV_DIR}/bin/pip" install --upgrade pip +"${VENV_DIR}/bin/pip" install -e /src/satosa/ From 8631f228f116bf71b7987e0ee7475db65cd8e11b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 21 Nov 2019 22:16:18 +0200 Subject: [PATCH 086/401] Set proper order of options Signed-off-by: Ivan Kanakarakis --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index de81ccf9d..775c477cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ FROM debian:stable-slim -RUN apt-get update \ +RUN apt-get -y update \ && apt-get -y dist-upgrade \ - && apt-get install -y --no-install-recommends \ + && apt-get -y --no-install-recommends install \ python3 \ python3-pip \ python3-venv \ xmlsec1 \ - && apt-get clean + && apt-get -y clean RUN mkdir -p /src/satosa COPY . /src/satosa From be721b17cf2b8f7b7cae90ad4b0d93ddda0e19c9 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 21 Nov 2019 22:30:22 +0200 Subject: [PATCH 087/401] Upgrade at the start and autoremove at the end Signed-off-by: Ivan Kanakarakis --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 775c477cc..c61d06a39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,14 @@ FROM debian:stable-slim RUN apt-get -y update \ + && apt-get -y upgrade \ && apt-get -y dist-upgrade \ && apt-get -y --no-install-recommends install \ python3 \ python3-pip \ python3-venv \ xmlsec1 \ + && apt-get -y autoremove \ && apt-get -y clean RUN mkdir -p /src/satosa From 83eb4aafb8938bc0964dc3391f9cf4be8c9b6e96 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Mon, 25 Nov 2019 13:08:25 -0600 Subject: [PATCH 088/401] Fix typo in log format. Fix a typo in the log format introduced during recent logging refactoring. --- src/satosa/micro_services/primary_identifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 97b68baef..8b41b65c5 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -259,6 +259,6 @@ def process(self, context, data): logger.debug(logline) msg = "{} returning data.attributes {}".format(logprefix, str(data.attributes)) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.statestate), message=msg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) return super().process(context, data) From 933c0b923c04f106894906eddcbcdfafa032c99e Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 25 Nov 2019 22:30:47 +0200 Subject: [PATCH 089/401] Fix tests for newer oic versions oic>=1.1.2 introduces new fields for the provider config. We do not need exact equality, we only need to make sure that the fields we expect are there. Signed-off-by: Ivan Kanakarakis --- tests/satosa/frontends/test_openid_connect.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index 62d6dcaf4..257ce777b 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -293,7 +293,10 @@ def test_provider_configuration_endpoint(self, context, frontend): scopes_supported = provider_config_dict.pop("scopes_supported") assert "eduperson" not in scopes_supported assert all(scope in scopes_supported for scope in ["openid", "email"]) - assert provider_config_dict == expected_capabilities + + provider_items = provider_config_dict.items() + expected_items = expected_capabilities.items() + assert all(item in provider_items for item in expected_items) def test_provider_configuration_endpoint_with_extra_scopes( self, context, frontend_with_extra_scopes @@ -340,14 +343,9 @@ def test_provider_configuration_endpoint_with_extra_scopes( scope in scopes_supported for scope in ["openid", "email", "eduperson"] ) - # FIXME why is this needed? - expected_capabilities["claims_supported"] = set( - expected_capabilities["claims_supported"] - ) - provider_config_dict["claims_supported"] = set( - provider_config_dict["claims_supported"] - ) - assert provider_config_dict == expected_capabilities + provider_items = provider_config_dict.items() + expected_items = expected_capabilities.items() + assert all(item in provider_items for item in expected_items) def test_jwks(self, context, frontend): http_response = frontend.jwks(context) From d311c640a5ea50a1e9d544ed0fcb67f32a3b8f5d Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Tue, 26 Nov 2019 07:34:20 -0600 Subject: [PATCH 090/401] Better handling of single-value attribute by LdapAttributeStore Better handling of single-value attributes from LDAP by the LdapAttributeStore microservice. The ldap3 module response object includes a raw_attributes dictionary and an attributes dictionary. The attributes dictionary is populated with formatted values using formatters based on standard schema syntaxes. If a particular attribute in the schema is defined as multi valued, the attribute value in the attributes dictionary is a list, but if the attribute in the schema is not multi valued, the value in the attributes dictionary is a single value. --- src/satosa/micro_services/ldap_attribute_store.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index ae7634429..cd86b3ba7 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -12,8 +12,6 @@ import ldap3 from ldap3.core.exceptions import LDAPException -from collections import defaultdict - from satosa.exception import SATOSAError from satosa.logging_util import satosa_logging from satosa.micro_services.base import ResponseMicroService @@ -350,7 +348,7 @@ def _populate_attributes(self, config, record): else config["search_return_attributes"] ) - attributes = defaultdict(list) + attributes = {} for attr, values in ldap_attributes.items(): internal_attr = ldap_to_internal_map.get(attr, None) @@ -358,7 +356,11 @@ def _populate_attributes(self, config, record): internal_attr = ldap_to_internal_map.get(attr.split(";")[0], None) if internal_attr and values: - attributes[internal_attr].extend(values) + attributes[internal_attr] = ( + values + if isinstance(values, list) + else [values] + ) msg = "Recording internal attribute {} with values {}" msg = msg.format(internal_attr, attributes[internal_attr]) satosa_logging(logger, logging.DEBUG, msg, None) From f8ddf5c74612a6389364fae3a66fb6e5bcafea06 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Tue, 26 Nov 2019 11:38:29 -0600 Subject: [PATCH 091/401] Add tests for LDAP attribute store microservice Tests and necessary changes to support tests for the LDAP attribute store microservice. The tests use the MOCK_SYNC client strategy from the ldap3 module to provide a mock LDAP directory server. --- .../micro_services/ldap_attribute_store.py | 3 +- .../test_ldap_attribute_store.py | 111 ++++++++++++++++++ tests/test_requirements.txt | 1 + 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/satosa/micro_services/test_ldap_attribute_store.py diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index cd86b3ba7..d957dff78 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -293,6 +293,7 @@ def _ldap_connection_factory(self, config): "LDIF": ldap3.LDIF, "RESTARTABLE": ldap3.RESTARTABLE, "REUSABLE": ldap3.REUSABLE, + "MOCK_SYNC": ldap3.MOCK_SYNC } client_strategy = client_strategy_map[client_strategy_string] @@ -503,7 +504,7 @@ def process(self, context, data): # This adapts records with different search and connection strategy # (sync without pool), it should be tested with anonimous bind with # message_id. - if isinstance(results, bool): + if isinstance(results, bool) and record: record = { "dn": record.entry_dn if hasattr(record, "entry_dn") else "", "attributes": ( diff --git a/tests/satosa/micro_services/test_ldap_attribute_store.py b/tests/satosa/micro_services/test_ldap_attribute_store.py new file mode 100644 index 000000000..ef672296e --- /dev/null +++ b/tests/satosa/micro_services/test_ldap_attribute_store.py @@ -0,0 +1,111 @@ +import pytest + +from copy import deepcopy + +from satosa.internal import AuthenticationInformation +from satosa.internal import InternalData +from satosa.micro_services.ldap_attribute_store import LdapAttributeStore +from satosa.context import Context + + +class TestLdapAttributeStore: + ldap_attribute_store_config = { + 'default': { + 'auto_bind': 'AUTO_BIND_NO_TLS', + 'client_strategy': 'MOCK_SYNC', + 'ldap_url': 'ldap://satosa.example.com', + 'bind_dn': 'uid=readonly_user,ou=system,dc=example,dc=com', + 'bind_password': 'password', + 'search_base': 'ou=people,dc=example,dc=com', + 'query_return_attributes': [ + 'givenName', + 'sn', + 'mail', + 'employeeNumber', + 'voPersonID' + ], + 'ldap_to_internal_map': { + 'givenName': 'givenname', + 'sn': 'sn', + 'mail': 'mail', + 'employeeNumber': 'employeenumber', + 'voPersonID': 'vopersonid' + }, + 'clear_input_attributes': True, + 'ordered_identifier_candidates': [ + {'attribute_names': ['uid']} + ], + 'ldap_identifier_attribute': 'uid' + } + } + + ldap_person_records = [ + ['employeeNumber=1000,ou=people,dc=example,dc=com', { + 'employeeNumber': '1000', + 'cn': 'Jane Baxter', + 'givenName': 'Jane', + 'sn': 'Baxter', + 'uid': 'jbaxter', + 'mail': 'jbaxter@example.com', + 'voPersonID': 'EX1000' + } + ], + ['employeeNumber=1001,ou=people,dc=example,dc=com', { + 'employeeNumber': '1001', + 'cn': 'Booker Lawson', + 'givenName': 'Booker', + 'sn': 'Lawson', + 'uid': 'booker.lawson', + 'mail': 'blawson@example.com', + 'voPersonID': 'EX1001' + } + ], + ] + + @pytest.fixture + def ldap_attribute_store(self): + store = LdapAttributeStore(self.ldap_attribute_store_config, + name="test_ldap_attribute_store", + base_url="https://satosa.example.com") + + # Mock up the 'next' microservice to be called. + store.next = lambda ctx, data: data + + # We need to explicitly bind when using the MOCK_SYNC client strategy. + connection = store.config['default']['connection'] + connection.bind() + + # Populate example records. + for dn, attributes in self.ldap_person_records: + attributes = deepcopy(attributes) + connection.strategy.add_entry(dn, attributes) + + return store + + def test_attributes_general(self, ldap_attribute_store): + ldap_to_internal_map = (self.ldap_attribute_store_config['default'] + ['ldap_to_internal_map']) + + for dn, attributes in self.ldap_person_records: + # Mock up the internal response the LDAP attribute store is + # expecting to receive. + response = InternalData(auth_info=AuthenticationInformation()) + + # The LDAP attribute store configuration and the mock records + # expect to use a LDAP search filter for the uid attribute. + uid = attributes['uid'] + response.attributes = {'uid': uid} + + context = Context() + context.state = dict() + + ldap_attribute_store.process(context, response) + + # Verify that the LDAP attribute store has retrieved the mock + # records from the mock LDAP server and has added the appropriate + # internal attributes. + for ldap_attr, ldap_value in attributes.items(): + if ldap_attr in ldap_to_internal_map: + internal_attr = ldap_to_internal_map[ldap_attr] + response_attr = response.attributes[internal_attr] + assert(ldap_value in response_attr) diff --git a/tests/test_requirements.txt b/tests/test_requirements.txt index fa12a992b..bf7f30deb 100644 --- a/tests/test_requirements.txt +++ b/tests/test_requirements.txt @@ -1,3 +1,4 @@ pytest responses beautifulsoup4 +ldap3 From c797a0aa5afa0cd59a84c34b01ff171155a01030 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Wed, 27 Nov 2019 05:53:03 -0600 Subject: [PATCH 092/401] Added schema for mock LDAP server used for tests Added logic so that the mock LDAP server used for tests includes the standard OpenLDAP 2.4 schema. --- .../micro_services/ldap_attribute_store.py | 28 +++++++++++-------- .../test_ldap_attribute_store.py | 14 ++++------ 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index d957dff78..333254648 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -263,7 +263,22 @@ def _ldap_connection_factory(self, config): if not bind_password: raise LdapAttributeStoreError("bind_password is not configured") - server = ldap3.Server(config["ldap_url"]) + client_strategy_string = config["client_strategy"] + client_strategy_map = { + "SYNC": ldap3.SYNC, + "ASYNC": ldap3.ASYNC, + "LDIF": ldap3.LDIF, + "RESTARTABLE": ldap3.RESTARTABLE, + "REUSABLE": ldap3.REUSABLE, + "MOCK_SYNC": ldap3.MOCK_SYNC + } + client_strategy = client_strategy_map[client_strategy_string] + + args = {'host': config["ldap_url"]} + if client_strategy == ldap3.MOCK_SYNC: + args['get_info'] = ldap3.OFFLINE_SLAPD_2_4 + + server = ldap3.Server(**args) msg = "Creating a new LDAP connection" satosa_logging(logger, logging.DEBUG, msg, None) @@ -286,17 +301,6 @@ def _ldap_connection_factory(self, config): read_only = config["read_only"] version = config["version"] - client_strategy_string = config["client_strategy"] - client_strategy_map = { - "SYNC": ldap3.SYNC, - "ASYNC": ldap3.ASYNC, - "LDIF": ldap3.LDIF, - "RESTARTABLE": ldap3.RESTARTABLE, - "REUSABLE": ldap3.REUSABLE, - "MOCK_SYNC": ldap3.MOCK_SYNC - } - client_strategy = client_strategy_map[client_strategy_string] - pool_size = config["pool_size"] pool_keepalive = config["pool_keepalive"] if client_strategy == ldap3.REUSABLE: diff --git a/tests/satosa/micro_services/test_ldap_attribute_store.py b/tests/satosa/micro_services/test_ldap_attribute_store.py index ef672296e..e3af1a7f5 100644 --- a/tests/satosa/micro_services/test_ldap_attribute_store.py +++ b/tests/satosa/micro_services/test_ldap_attribute_store.py @@ -7,6 +7,8 @@ from satosa.micro_services.ldap_attribute_store import LdapAttributeStore from satosa.context import Context +import logging +logging.basicConfig(level=logging.DEBUG) class TestLdapAttributeStore: ldap_attribute_store_config = { @@ -21,15 +23,13 @@ class TestLdapAttributeStore: 'givenName', 'sn', 'mail', - 'employeeNumber', - 'voPersonID' + 'employeeNumber' ], 'ldap_to_internal_map': { 'givenName': 'givenname', 'sn': 'sn', 'mail': 'mail', - 'employeeNumber': 'employeenumber', - 'voPersonID': 'vopersonid' + 'employeeNumber': 'employeenumber' }, 'clear_input_attributes': True, 'ordered_identifier_candidates': [ @@ -46,8 +46,7 @@ class TestLdapAttributeStore: 'givenName': 'Jane', 'sn': 'Baxter', 'uid': 'jbaxter', - 'mail': 'jbaxter@example.com', - 'voPersonID': 'EX1000' + 'mail': 'jbaxter@example.com' } ], ['employeeNumber=1001,ou=people,dc=example,dc=com', { @@ -56,8 +55,7 @@ class TestLdapAttributeStore: 'givenName': 'Booker', 'sn': 'Lawson', 'uid': 'booker.lawson', - 'mail': 'blawson@example.com', - 'voPersonID': 'EX1001' + 'mail': 'blawson@example.com' } ], ] From 7a2fc8ecc947ac60110591b64aeff29c4d6e2def Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 12 Dec 2019 14:57:10 -0600 Subject: [PATCH 093/401] Add mapping for OIDC multivalue claims Core OIDC claims define the representation of their values when those can have multiple values. We should respect those forms. This mapping defines if and how the values should be combined. For all other claims, we return the same values as the ones that have been set in the internal representation. Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/openid_connect.py | 52 ++++++++++++++++++- tests/flows/test_oidc-saml.py | 8 ++- tests/flows/test_saml-oidc.py | 3 +- tests/satosa/frontends/test_openid_connect.py | 4 +- tests/users.py | 8 +++ 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index bd5972511..9e3871c18 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -3,6 +3,7 @@ """ import json import logging +from collections import defaultdict from urllib.parse import urlencode, urlparse from jwkest.jwk import rsa_load, RSAKey @@ -127,8 +128,8 @@ def handle_authn_response(self, context, internal_resp, extra_id_token_claims=No auth_req = self._get_authn_request_from_state(context.state) - attributes = self.converter.from_internal("openid", internal_resp.attributes) - self.user_db[internal_resp.subject_id] = {k: v[0] for k, v in attributes.items()} + claims = self.converter.from_internal("openid", internal_resp.attributes) + self.user_db[internal_resp.subject_id] = dict(combine_claim_values(claims.items())) auth_resp = self.provider.authorize( auth_req, internal_resp.subject_id, @@ -378,3 +379,50 @@ def userinfo_endpoint(self, context): response = Unauthorized(error_resp.to_json(), headers=[("WWW-Authenticate", AccessToken.BEARER_TOKEN_TYPE)], content="application/json") return response + + +def combine_return_input(values): + return values + + +def combine_select_first_value(values): + return values[0] + + +def combine_join_by_space(values): + return " ".join(values) + + +combine_values_by_claim = defaultdict( + lambda: combine_return_input, + { + "sub": combine_select_first_value, + "name": combine_select_first_value, + "given_name": combine_join_by_space, + "family_name": combine_join_by_space, + "middle_name": combine_join_by_space, + "nickname": combine_select_first_value, + "preferred_username": combine_select_first_value, + "profile": combine_select_first_value, + "picture": combine_select_first_value, + "website": combine_select_first_value, + "email": combine_select_first_value, + "email_verified": combine_select_first_value, + "gender": combine_select_first_value, + "birthdate": combine_select_first_value, + "zoneinfo": combine_select_first_value, + "locale": combine_select_first_value, + "phone_number": combine_select_first_value, + "phone_number_verified": combine_select_first_value, + "address": combine_select_first_value, + "updated_at": combine_select_first_value, + }, +) + + +def combine_claim_values(claim_items): + claims = ( + (name, combine_values_by_claim[name](values)) + for name, values in claim_items + ) + return claims diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index 7cf96a443..2d51c9dd6 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -15,8 +15,10 @@ from satosa.proxy_server import make_app from satosa.satosa_config import SATOSAConfig from tests.users import USERS +from tests.users import OIDC_USERS from tests.util import FakeIdP + CLIENT_ID = "client1" REDIRECT_URI = "https://client.example.com/cb" @@ -97,4 +99,8 @@ def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_ signing_key = RSAKey(key=rsa_load(oidc_frontend_config["config"]["signing_key_path"]), use="sig", alg="RS256") id_token_claims = JWS().verify_compact(resp_dict["id_token"], keys=[signing_key]) - assert all((k, v[0]) in id_token_claims.items() for k, v in USERS[subject_id].items()) + + assert all( + (name, values) in id_token_claims.items() + for name, values in OIDC_USERS[subject_id].items() + ) diff --git a/tests/flows/test_saml-oidc.py b/tests/flows/test_saml-oidc.py index 1569a36a0..b0068cc50 100644 --- a/tests/flows/test_saml-oidc.py +++ b/tests/flows/test_saml-oidc.py @@ -11,6 +11,7 @@ from satosa.proxy_server import make_app from satosa.satosa_config import SATOSAConfig from tests.users import USERS +from tests.users import OIDC_USERS from tests.util import FakeSP @@ -43,7 +44,7 @@ def run_test(self, satosa_config_dict, sp_conf, oidc_backend_config, frontend_co parsed_auth_req = dict(parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query)) # create auth resp - id_token_claims = {k: v[0] for k, v in USERS[subject_id].items()} + id_token_claims = {k: v for k, v in OIDC_USERS[subject_id].items()} id_token_claims["sub"] = subject_id id_token_claims["iat"] = time.time() id_token_claims["exp"] = time.time() + 3600 diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index 257ce777b..b33a16703 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -22,6 +22,8 @@ from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from tests.users import USERS +from tests.users import OIDC_USERS + INTERNAL_ATTRIBUTES = { "attributes": {"mail": {"saml": ["email"], "openid": ["email"]}} @@ -174,7 +176,7 @@ def test_handle_authn_response(self, context, frontend, authn_req): assert id_token["nonce"] == authn_req["nonce"] assert id_token["aud"] == [authn_req["client_id"]] assert "sub" in id_token - assert id_token["email"] == USERS["testuser1"]["email"][0] + assert id_token["email"] == OIDC_USERS["testuser1"]["email"] assert frontend.name not in context.state def test_handle_authn_request(self, context, frontend, authn_req): diff --git a/tests/users.py b/tests/users.py index f7e5e2b63..22e8e309a 100644 --- a/tests/users.py +++ b/tests/users.py @@ -1,6 +1,9 @@ """ A static dictionary with SAML testusers that can be used as response. """ + +from satosa.frontends.openid_connect import combine_claim_values + USERS = { "testuser1": { "sn": ["Testsson 1"], @@ -20,3 +23,8 @@ "norEduPersonNIN": ["SE199012315555"] } } + +OIDC_USERS = { + id: dict(combine_claim_values(attributes.items())) + for id, attributes in USERS.items() +} From 44391d6f94d4ef7415400fca3844bd3e7fc19e42 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 16 Oct 2019 09:03:32 +0000 Subject: [PATCH 094/401] modify logging for satosa.state --- src/satosa/state.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/satosa/state.py b/src/satosa/state.py index 47b8476b2..c9f7c0bce 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -15,7 +15,8 @@ from Cryptodome.Cipher import AES from .exception import SATOSAStateError -from .logging_util import satosa_logging + +import satosa.logging_util as lu logger = logging.getLogger(__name__) @@ -44,9 +45,11 @@ def state_to_cookie(state, name, path, encryption_key): cookie_data = "" if state.delete else state.urlstate(encryption_key) max_age = 0 if state.delete else STATE_COOKIE_MAX_AGE - satosa_logging(logger, logging.DEBUG, - "Saving state as cookie, secure: %s, max-age: %s, path: %s" % - (STATE_COOKIE_SECURE, STATE_COOKIE_MAX_AGE, path), state) + msg = "Saving state as cookie, secure: {secure}, max-age: {max_age}, path: {path}".format( + secure=STATE_COOKIE_SECURE, max_age=STATE_COOKIE_MAX_AGE, path=path + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.debug(logline) cookie = SimpleCookie() cookie[name] = cookie_data cookie[name]["secure"] = STATE_COOKIE_SECURE From ed32896095adf6b4433d4ce21169e4444c9b2d17 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Fri, 18 Oct 2019 15:21:11 +0000 Subject: [PATCH 095/401] modified logging for satosa.util Signed-off-by: Ivan Kanakarakis --- src/satosa/util.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/satosa/util.py b/src/satosa/util.py index a5d24bc4f..9b5d63fc1 100644 --- a/src/satosa/util.py +++ b/src/satosa/util.py @@ -6,8 +6,6 @@ import random import string -from satosa.logging_util import satosa_logging - logger = logging.getLogger(__name__) @@ -52,11 +50,11 @@ def check_set_dict_defaults(dic, spec): else: is_value_valid = _val == value if not is_value_valid: - satosa_logging( - logger, logging.WARNING, - "Incompatible configuration value '{}' for '{}'." - " Value shoud be: {}".format(_val, path, value), - {}) + logline = ( + "Incompatible configuration value '{value}' for '{path}'. " + "Value shoud be: {expected}" + ).format(value=_val, path=path, expected=value) + logger.warning(logline) return dic From e2017467c38abaf3ab333ff072a004857bc51cfb Mon Sep 17 00:00:00 2001 From: sebulibah Date: Tue, 22 Oct 2019 13:02:54 +0000 Subject: [PATCH 096/401] modified logging for satosa.frontends.openid_connect Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/openid_connect.py | 30 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 9e3871c18..e93cf4998 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -22,12 +22,12 @@ from pyop.util import should_fragment_encode from .base import FrontendModule -from ..logging_util import satosa_logging from ..response import BadRequest, Created from ..response import SeeOther, Response from ..response import Unauthorized from ..util import rndstr +import satosa.logging_util as lu from satosa.internal import InternalData from satosa.deprecated import oidc_subject_type_to_hash_type @@ -155,7 +155,9 @@ def handle_backend_error(self, exception): else: error_resp = AuthorizationErrorResponse(error="access_denied", error_description=exception.message) - satosa_logging(logger, logging.DEBUG, exception.message, exception.state) + msg = exception.message + logline = lu.LOG_FMT.format(id=lu.get_session_id(exception.state), message=msg) + logger.debug(logline) return SeeOther(error_resp.request(auth_req["redirect_uri"], should_fragment_encode(auth_req))) def register_endpoints(self, backend_names): @@ -172,8 +174,12 @@ def register_endpoints(self, backend_names): # similar to SAML entity discovery # this can be circumvented with a custom RequestMicroService which handles the routing based on something # in the authentication request - logger.warn("More than one backend is configured, make sure to provide a custom routing micro service to " - "determine which backend should be used per request.") + logline = ( + "More than one backend is configured, " + "make sure to provide a custom routing micro service " + "to determine which backend should be used per request." + ) + logger.warning(logline) else: backend_name = backend_names[0] @@ -281,14 +287,16 @@ def _handle_authn_request(self, context): :return: the internal request """ request = urlencode(context.request) - satosa_logging(logger, logging.DEBUG, "Authn req from client: {}".format(request), - context.state) + msg = "Authn req from client: {}".format(request) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) try: authn_req = self.provider.parse_authentication_request(request) except InvalidAuthenticationRequest as e: - satosa_logging(logger, logging.ERROR, "Error in authn req: {}".format(str(e)), - context.state) + msg = "Error in authn req: {}".format(str(e)) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) error_url = e.to_error_url() if error_url: @@ -355,13 +363,15 @@ def token_endpoint(self, context): response = self.provider.handle_token_request(urlencode(context.request), headers) return Response(response.to_json(), content="application/json") except InvalidClientAuthentication as e: - logger.debug('invalid client authentication at token endpoint', exc_info=True) + logline = "invalid client authentication at token endpoint" + logger.debug(logline, exc_info=True) error_resp = TokenErrorResponse(error='invalid_client', error_description=str(e)) response = Unauthorized(error_resp.to_json(), headers=[("WWW-Authenticate", "Basic")], content="application/json") return response except OAuthError as e: - logger.debug('invalid request: %s', str(e), exc_info=True) + logline = "invalid request: {}".format(str(e)) + logger.debug(logline, exc_info=True) error_resp = TokenErrorResponse(error=e.oauth_error, error_description=str(e)) return BadRequest(error_resp.to_json(), content="application/json") From 0241476076087485ce3459c6a280c34512717900 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 23 Oct 2019 14:14:40 +0000 Subject: [PATCH 097/401] modified logging for saml_metadata --- src/satosa/metadata_creation/saml_metadata.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/satosa/metadata_creation/saml_metadata.py b/src/satosa/metadata_creation/saml_metadata.py index 7989ef2a9..1a9e1d730 100644 --- a/src/satosa/metadata_creation/saml_metadata.py +++ b/src/satosa/metadata_creation/saml_metadata.py @@ -26,7 +26,8 @@ def _create_backend_metadata(backend_modules): for plugin_module in backend_modules: if isinstance(plugin_module, SAMLBackend): - logger.info("Generating SAML backend '%s' metadata", plugin_module.name) + logline = "Generating SAML backend '{}' metadata".format(plugin_module.name) + logger.info(logline) backend_metadata[plugin_module.name] = [_create_entity_descriptor(plugin_module.config["sp_config"])] return backend_metadata @@ -60,7 +61,10 @@ def _create_frontend_metadata(frontend_modules, backend_modules): for frontend in frontend_modules: if isinstance(frontend, SAMLMirrorFrontend): for backend in backend_modules: - logger.info("Creating metadata for frontend '%s' and backend '%s'".format(frontend.name, backend.name)) + logline = "Creating metadata for frontend '{}' and backend '{}'".format( + frontend.name, backend.name + ) + logger.info(logline) meta_desc = backend.get_metadata_desc() for desc in meta_desc: entity_desc = _create_entity_descriptor( @@ -72,7 +76,8 @@ def _create_frontend_metadata(frontend_modules, backend_modules): co_names = frontend._co_names_from_config() for co_name in co_names: - logger.info("Creating metadata for CO {}".format(co_name)) + logline = "Creating metadata for CO {}".format(co_name) + logger.info(logline) idp_config = copy.deepcopy(frontend.config["idp_config"]) idp_config = frontend._add_endpoints_to_config(idp_config, co_name, backend.name) idp_config = frontend._add_entity_id(idp_config, co_name) From e4694e6a93328dd9aa89867bb51c0c1dd90ea215 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 23 Oct 2019 08:21:58 +0000 Subject: [PATCH 098/401] improved logging for micro_services.consent Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/consent.py | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/satosa/micro_services/consent.py b/src/satosa/micro_services/consent.py index 7841c5993..968b28327 100644 --- a/src/satosa/micro_services/consent.py +++ b/src/satosa/micro_services/consent.py @@ -12,11 +12,12 @@ from jwkest.jws import JWS from requests.exceptions import ConnectionError +import satosa.logging_util as lu from satosa.internal import InternalData -from satosa.logging_util import satosa_logging from satosa.micro_services.base import ResponseMicroService from satosa.response import Redirect + logger = logging.getLogger(__name__) STATE_KEY = "CONSENT" @@ -63,17 +64,22 @@ def _handle_consent_response(self, context): try: consent_attributes = self._verify_consent(hash_id) except ConnectionError as e: - satosa_logging(logger, logging.ERROR, - "Consent service is not reachable, no consent given.", context.state) + msg = "Consent service is not reachable, no consent given." + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) # Send an internal_response without any attributes consent_attributes = None if consent_attributes is None: - satosa_logging(logger, logging.INFO, "Consent was NOT given", context.state) + msg = "Consent was NOT given" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) # If consent was not given, then don't send any attributes consent_attributes = [] else: - satosa_logging(logger, logging.INFO, "Consent was given", context.state) + msg = "Consent was given" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) internal_response.attributes = self._filter_attributes(internal_response.attributes, consent_attributes) return self._end_consent(context, internal_response) @@ -94,8 +100,9 @@ def _approve_new_consent(self, context, internal_response, id_hash): try: ticket = self._consent_registration(consent_args) except (ConnectionError, UnexpectedResponseError) as e: - satosa_logging(logger, logging.ERROR, "Consent request failed, no consent given: {}".format(str(e)), - context.state) + msg = "Consent request failed, no consent given: {}".format(str(e)) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) # Send an internal_response without any attributes internal_response.attributes = {} return self._end_consent(context, internal_response) @@ -125,15 +132,18 @@ def process(self, context, internal_response): # Check if consent is already given consent_attributes = self._verify_consent(id_hash) except requests.exceptions.ConnectionError as e: - satosa_logging(logger, logging.ERROR, - "Consent service is not reachable, no consent given.", context.state) + msg = "Consent service is not reachable, no consent is given." + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) # Send an internal_response without any attributes internal_response.attributes = {} return self._end_consent(context, internal_response) # Previous consent was given if consent_attributes is not None: - satosa_logging(logger, logging.DEBUG, "Previous consent was given", context.state) + msg = "Previous consent was given" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) internal_response.attributes = self._filter_attributes(internal_response.attributes, consent_attributes) return self._end_consent(context, internal_response) From 7ededfccc8050e83ee41453d045c0dac9e1c741d Mon Sep 17 00:00:00 2001 From: sebulibah Date: Wed, 23 Oct 2019 07:47:50 +0000 Subject: [PATCH 099/401] improved logging for account_linking --- src/satosa/micro_services/account_linking.py | 30 ++++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/satosa/micro_services/account_linking.py b/src/satosa/micro_services/account_linking.py index 0e849f067..7305c3d79 100644 --- a/src/satosa/micro_services/account_linking.py +++ b/src/satosa/micro_services/account_linking.py @@ -10,10 +10,10 @@ from satosa.internal import InternalData from ..exception import SATOSAAuthenticationError -from ..logging_util import satosa_logging from ..micro_services.base import ResponseMicroService from ..response import Redirect +import satosa.logging_util as lu logger = logging.getLogger(__name__) @@ -53,8 +53,9 @@ def _handle_al_response(self, context): status_code, message = self._get_uuid(context, internal_response.auth_info.issuer, internal_response.attributes['issuer_user_id']) if status_code == 200: - satosa_logging(logger, logging.INFO, "issuer/id pair is linked in AL service", - context.state) + msg = "issuer/id pair is linked in AL service" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) internal_response.subject_id = message if self.id_to_attr: internal_response.attributes[self.id_to_attr] = [message] @@ -64,8 +65,9 @@ def _handle_al_response(self, context): else: # User selected not to link their accounts, so the internal.response.subject_id is based on the # issuers id/sub which is fine - satosa_logging(logger, logging.INFO, "User selected to not link their identity in AL service", - context.state) + msg = "User selected to not link their identity in AL service" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) del context.state[self.name] return super().process(context, internal_response) @@ -94,15 +96,17 @@ def process(self, context, internal_response): # Store the issuer subject_id/sub because we'll need it in handle_al_response internal_response.attributes['issuer_user_id'] = internal_response.subject_id if status_code == 200: - satosa_logging(logger, logging.INFO, "issuer/id pair is linked in AL service", - context.state) + msg = "issuer/id pair is linked in AL service" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) internal_response.subject_id = message data['user_id'] = message if self.id_to_attr: internal_response.attributes[self.id_to_attr] = [message] else: - satosa_logging(logger, logging.INFO, "issuer/id pair is not linked in AL service. Got a ticket", - context.state) + msg = "issuer/id pair is not linked in AL service. Got a ticket" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) data['ticket'] = message jws = JWS(json.dumps(data), alg=self.signing_key.alg).sign_compact([self.signing_key]) context.state[self.name] = internal_response.to_dict() @@ -137,12 +141,14 @@ def _get_uuid(self, context, issuer, id): response = requests.get(request) except Exception as con_exc: msg = "Could not connect to account linking service" - satosa_logging(logger, logging.CRITICAL, msg, context.state, exc_info=True) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.critical(logline) raise SATOSAAuthenticationError(context.state, msg) from con_exc if response.status_code not in [200, 404]: - msg = "Got status code '%s' from account linking service" % (response.status_code) - satosa_logging(logger, logging.CRITICAL, msg, context.state) + msg = "Got status code '{}' from account linking service".format(response.status_code) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.critical(logline) raise SATOSAAuthenticationError(context.state, msg) return response.status_code, response.text From 669c1a7e39f0a4076b49dc07bf836dd2fec94104 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Tue, 22 Oct 2019 12:22:10 +0000 Subject: [PATCH 100/401] improved logging for satosa.frontends.saml2 Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/saml2.py | 108 ++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 37 deletions(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index bf0a3c7c7..6ce12a476 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -30,13 +30,13 @@ from satosa.base import SAMLBaseModule from satosa.context import Context from .base import FrontendModule -from ..logging_util import satosa_logging from ..response import Response from ..response import ServiceError from ..saml_util import make_saml_response from satosa.exception import SATOSAError import satosa.util as util +import satosa.logging_util as lu from satosa.internal import InternalData from satosa.deprecated import saml_name_id_format_to_hash_type from satosa.deprecated import hash_type_to_saml_name_id_format @@ -190,7 +190,9 @@ def _handle_authn_request(self, context, binding_in, idp): """ req_info = idp.parse_authn_request(context.request["SAMLRequest"], binding_in) authn_req = req_info.message - satosa_logging(logger, logging.DEBUG, "%s" % authn_req, context.state) + msg = "{}".format(authn_req) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # keep the ForceAuthn value to be used by plugins context.decorate(Context.KEY_FORCE_AUTHN, authn_req.force_authn) @@ -198,7 +200,9 @@ def _handle_authn_request(self, context, binding_in, idp): try: resp_args = idp.response_args(authn_req) except SAMLError as e: - satosa_logging(logger, logging.ERROR, "Could not find necessary info about entity: %s" % e, context.state) + msg = "Could not find necessary info about entity: {}".format(e) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) return ServiceError("Incorrect request from requester: %s" % e) requester = resp_args["sp_entity_id"] @@ -273,7 +277,9 @@ def _get_approved_attributes(self, idp, idp_policy, sp_entity_id, state): attribute_filter = list(idp_policy.restrict(all_attributes, sp_entity_id, idp.metadata).keys()) break attribute_filter = self.converter.to_internal_filter(self.attribute_profile, attribute_filter) - satosa_logging(logger, logging.DEBUG, "Filter: %s" % attribute_filter, state) + msg = "Filter: {}".format(attribute_filter) + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.debug(logline) return attribute_filter def _filter_attributes(self, idp, internal_response, context,): @@ -343,8 +349,9 @@ def _handle_authn_response(self, context, internal_response, idp): name_qualifier=None, ) - dbgmsg = "returning attributes %s" % json.dumps(ava) - satosa_logging(logger, logging.DEBUG, dbgmsg, context.state) + msg = "returning attributes {}".format(json.dumps(ava)) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) policies = self.idp_config.get( 'service', {}).get('idp', {}).get('policy', {}) @@ -376,22 +383,26 @@ def _handle_authn_response(self, context, internal_response, idp): try: args['sign_alg'] = getattr(xmldsig, sign_alg) except AttributeError as e: - errmsg = "Unsupported sign algorithm %s" % sign_alg - satosa_logging(logger, logging.ERROR, errmsg, context.state) - raise Exception(errmsg) from e + msg = "Unsupported sign algorithm {}".format(sign_alg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise Exception(msg) from e else: - dbgmsg = "signing with algorithm %s" % args['sign_alg'] - satosa_logging(logger, logging.DEBUG, dbgmsg, context.state) + msg = "signing with algorithm {}".format(args['sign_alg']) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) try: args['digest_alg'] = getattr(xmldsig, digest_alg) except AttributeError as e: - errmsg = "Unsupported digest algorithm %s" % digest_alg - satosa_logging(logger, logging.ERROR, errmsg, context.state) - raise Exception(errmsg) from e + msg = "Unsupported digest algorithm {}".format(digest_alg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise Exception(msg) from e else: - dbgmsg = "using digest algorithm %s" % args['digest_alg'] - satosa_logging(logger, logging.DEBUG, dbgmsg, context.state) + msg = "using digest algorithm {}".format(args['digest_alg']) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) resp = idp.create_authn_response(**args) http_args = idp.apply_binding( @@ -426,7 +437,9 @@ def _handle_backend_error(self, exception, idp): http_args = idp.apply_binding(resp_args["binding"], str(error_resp), resp_args["destination"], relay_state, response=True) - satosa_logging(logger, logging.DEBUG, "HTTPargs: %s" % http_args, exception.state) + msg = "HTTPSards: {}".format(http_args) + logline = lu.LOG_FMT.format(id=lu.get_session_id(exception.state), message=msg) + logger.debug(logline) return make_saml_response(resp_args["binding"], http_args) def _metadata_endpoint(self, context): @@ -438,7 +451,9 @@ def _metadata_endpoint(self, context): :param context: The current context :return: response with metadata """ - satosa_logging(logger, logging.DEBUG, "Sending metadata response", context.state) + 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.idp.config, 4, None, None, None, None, None).decode("utf-8") return Response(metadata_string, content="text/xml") @@ -478,15 +493,21 @@ def _set_common_domain_cookie(self, internal_response, http_args, context): cookie = SimpleCookie(context.cookie) if '_saml_idp' in cookie: common_domain_cookie = cookie['_saml_idp'] - satosa_logging(logger, logging.DEBUG, "Found existing common domain cookie {}".format(common_domain_cookie), context.state) + msg = "Found existing common domain cookie {}".format(common_domain_cookie) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) space_separated_b64_idp_string = unquote(common_domain_cookie.value) b64_idp_list = space_separated_b64_idp_string.split() idp_list = [urlsafe_b64decode(b64_idp).decode('utf-8') for b64_idp in b64_idp_list] else: - satosa_logging(logger, logging.DEBUG, "No existing common domain cookie found", context.state) + msg = "No existing common domain cookie found" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) idp_list = [] - satosa_logging(logger, logging.DEBUG, "Common domain cookie list of IdPs is {}".format(idp_list), context.state) + msg = "Common domain cookie list of IdPs is {}".format(idp_list) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Identity the current IdP just used for authentication in this flow. this_flow_idp = internal_response.auth_info.issuer @@ -496,8 +517,12 @@ def _set_common_domain_cookie(self, internal_response, http_args, context): # Append the current IdP. idp_list.append(this_flow_idp) - satosa_logging(logger, logging.DEBUG, "Added IdP {} to common domain cookie list of IdPs".format(this_flow_idp), context.state) - satosa_logging(logger, logging.DEBUG, "Common domain cookie list of IdPs is now {}".format(idp_list), context.state) + msg = "Added IdP {} to common domain cookie list of IdPs".format(this_flow_idp) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + msg = "Common domain cookie list of IdPs is now {}".format(idp_list) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Construct the cookie. b64_idp_list = [urlsafe_b64encode(idp.encode()).decode("utf-8") for idp in idp_list] @@ -523,7 +548,9 @@ def _set_common_domain_cookie(self, internal_response, http_args, context): cookie['_saml_idp']['secure'] = True # Set the cookie. - satosa_logging(logger, logging.DEBUG, "Setting common domain cookie with {}".format(cookie.output()), context.state) + msg = "Setting common domain cookie with {}".format(cookie.output()) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) http_args['headers'].append(tuple(cookie.output().split(": ", 1))) def _build_idp_config_endpoints(self, config, providers): @@ -840,10 +867,12 @@ def _get_co_name(self, context): """ try: co_name = context.state[self.name][self.KEY_CO_NAME] - logger.debug("Found CO {} from state".format(co_name)) + logline = "Found CO {} from state".format(co_name) + logger.debug(logline) except KeyError: co_name = self._get_co_name_from_path(context) - logger.debug("Found CO {} from request path".format(co_name)) + logline = "Found CO {} from request path".format(co_name) + logger.debug(logline) return co_name @@ -985,9 +1014,9 @@ def _create_co_virtual_idp(self, context): if co_name not in co_names: msg = "CO {} not in configured list of COs {}".format(co_name, co_names) - satosa_logging(logger, logging.WARN, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warn(logline) raise SATOSAError(msg) - # Make a copy of the general IdP config that we will then overwrite # with mappings between SAML bindings and CO specific URL endpoints, # and the entityID for the CO virtual IdP. @@ -1038,8 +1067,10 @@ def _register_endpoints(self, backend_names): # Create a regex pattern that will match any of the backend names. backend_url_pattern = "|^".join(backend_names) - logger.debug("Input backend names are {}".format(backend_names)) - logger.debug("Created backend regex '{}'".format(backend_url_pattern)) + logline = "Input backend names are {}".format(backend_names) + logger.debug(logline) + logline = "Created backend regex '{}'".format(backend_url_pattern) + logger.debug(logline) # Hold a list of tuples containing URL regex patterns and the callables # that handle them. @@ -1047,18 +1078,19 @@ def _register_endpoints(self, backend_names): # Loop over IdP endpoint categories, e.g., single_sign_on_service. for endpoint_category in self.endpoints: - logger.debug("Examining endpoint category {}".format( - endpoint_category)) + logline = "Examining endpoint category {}".format(endpoint_category) + logger.debug(logline) # For each endpoint category loop of the bindings and their # assigned endpoints. for binding, endpoint in self.endpoints[endpoint_category].items(): - logger.debug("Found binding {} and endpoint {}".format(binding, - endpoint)) + logline = "Found binding {} and endpoint {}".format(binding, endpoint) + logger.debug(logline) # Parse out the path from the endpoint. endpoint_path = urlparse(endpoint).path - logger.debug("Using path {}".format(endpoint_path)) + logline = "Using path {}".format(endpoint_path) + logger.debug(logline) # Use the backend URL pattern and the endpoint path to create # a regex that will match and that includes a pattern for @@ -1067,7 +1099,8 @@ def _register_endpoints(self, backend_names): backend_url_pattern, co_name_pattern, endpoint_path) - logger.debug("Created URL regex {}".format(regex_pattern)) + logline = "Created URL regex {}".format(regex_pattern) + logger.debug(logline) # Map the regex pattern to a callable. the_callable = functools.partial(self.handle_authn_request, @@ -1076,6 +1109,7 @@ def _register_endpoints(self, backend_names): mapping = (regex_pattern, the_callable) url_to_callable_mappings.append(mapping) - logger.debug("Adding mapping {}".format(mapping)) + logline = "Adding mapping {}".format(mapping) + logger.debug(logline) return url_to_callable_mappings From 946e6ccd1bf0c94c18fb77557c2426d0dd2ad8ed Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 17 Dec 2019 03:55:13 +0200 Subject: [PATCH 101/401] Release version 6.0.0 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 10 ++++++++++ setup.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d8f89e635..7e9032288 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 5.0.0 +current_version = 6.0.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 269b6c3b0..cbfd0a0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 6.0.0 (2019-12-17) + +- properly support mutliple values when converting internal attributes to OIDC + claims. For all claims other than the ones define in OIDC core specification, + the same values as the ones that have been set in the internal representation + will be returned. +- improve log handling +- micro-services: Better handling of single-value attribute by LdapAttributeStore + + ## 5.0.0 (2019-11-07) *Notice*: Support for python 3.5 has been dropped. diff --git a/setup.py b/setup.py index f49472639..aa964f6d6 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='5.0.0', + version='6.0.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 4ca7a225e2b0cab5598ec3c6180f4da4d90cd4af Mon Sep 17 00:00:00 2001 From: Simone Visconti Date: Tue, 24 Dec 2019 09:51:51 +0100 Subject: [PATCH 102/401] Use LinkedIn API v2 Update the LinkedIn backend to use API v2. LinkedIn API v1 is now deprecated. --- .../backends/linkedin_backend.yaml.example | 5 ++-- src/satosa/backends/linkedin.py | 24 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/example/plugins/backends/linkedin_backend.yaml.example b/example/plugins/backends/linkedin_backend.yaml.example index 482d76bee..fad144b23 100644 --- a/example/plugins/backends/linkedin_backend.yaml.example +++ b/example/plugins/backends/linkedin_backend.yaml.example @@ -6,12 +6,13 @@ config: client_config: client_id: 12345678 client_secret: a2s3d4f5g6h7j8k9 - scope: [r_basicprofile,r_emailaddress,rw_company_admin,w_share] + scope: [r_liteprofile,r_emailaddress] response_type: code server_info: { authorization_endpoint: 'https://www.linkedin.com/oauth/v2/authorization', token_endpoint: 'https://www.linkedin.com/oauth/v2/accessToken', - user_info: 'https://api.linkedin.com/v1/people/~' + user_info: 'https://api.linkedin.com/v2/me', + email_info: 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))' } entity_info: organization: diff --git a/src/satosa/backends/linkedin.py b/src/satosa/backends/linkedin.py index cbaa2ea39..f78a58cad 100644 --- a/src/satosa/backends/linkedin.py +++ b/src/satosa/backends/linkedin.py @@ -87,25 +87,35 @@ def _authn_response(self, context): code=aresp['code'], redirect_uri=self.redirect_url, client_id=self.config['client_config']['client_id'], - client_secret=self.config['client_secret'], ) + client_secret=self.config['client_secret']) r = requests.post(url, data=data) response = r.json() if self.config.get('verify_accesstoken_state', True): self._verify_state(response, state_data, context.state) - user_info = self.user_information(response["access_token"]) + user_info = self.user_information(response["access_token"], 'user_info') auth_info = self.auth_info(context.request) + user_email_response = self.user_information(response["access_token"], 'email_info') + + user_email = { + "emailAddress": [ + element['handle~']['emailAddress'] + for element in user_email_response['elements'] + ] + } + + user_info.update(user_email) internal_response = InternalData(auth_info=auth_info) internal_response.attributes = self.converter.to_internal( self.external_type, user_info) + internal_response.subject_id = user_info[self.user_id_attr] del context.state[self.name] return self.auth_callback_func(context, internal_response) - def user_information(self, access_token): - url = self.config['server_info']['user_info'] + def user_information(self, access_token, api): + url = self.config['server_info'][api] headers = {'Authorization': 'Bearer {}'.format(access_token)} - params = {'format': 'json'} - r = requests.get(url, params=params, headers=headers) - return r.json() + r = requests.get(url, headers=headers) + return r.json() \ No newline at end of file From 0d521b160c779cd1d962eb232692034c0155df9d Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 24 Dec 2019 10:56:49 +0200 Subject: [PATCH 103/401] Format code Signed-off-by: Ivan Kanakarakis --- .../plugins/backends/linkedin_backend.yaml.example | 13 +++++++------ src/satosa/backends/linkedin.py | 7 +++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/example/plugins/backends/linkedin_backend.yaml.example b/example/plugins/backends/linkedin_backend.yaml.example index fad144b23..a5d207a88 100644 --- a/example/plugins/backends/linkedin_backend.yaml.example +++ b/example/plugins/backends/linkedin_backend.yaml.example @@ -6,14 +6,15 @@ config: client_config: client_id: 12345678 client_secret: a2s3d4f5g6h7j8k9 - scope: [r_liteprofile,r_emailaddress] + scope: + - r_liteprofile + - r_emailaddress response_type: code - server_info: { - authorization_endpoint: 'https://www.linkedin.com/oauth/v2/authorization', - token_endpoint: 'https://www.linkedin.com/oauth/v2/accessToken', - user_info: 'https://api.linkedin.com/v2/me', + server_info: + authorization_endpoint: 'https://www.linkedin.com/oauth/v2/authorization' + token_endpoint: 'https://www.linkedin.com/oauth/v2/accessToken' + user_info: 'https://api.linkedin.com/v2/me' email_info: 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))' - } entity_info: organization: display_name: diff --git a/src/satosa/backends/linkedin.py b/src/satosa/backends/linkedin.py index f78a58cad..06a5cbac8 100644 --- a/src/satosa/backends/linkedin.py +++ b/src/satosa/backends/linkedin.py @@ -94,18 +94,17 @@ def _authn_response(self, context): if self.config.get('verify_accesstoken_state', True): self._verify_state(response, state_data, context.state) - user_info = self.user_information(response["access_token"], 'user_info') auth_info = self.auth_info(context.request) user_email_response = self.user_information(response["access_token"], 'email_info') - + user_info = self.user_information(response["access_token"], 'user_info') user_email = { "emailAddress": [ element['handle~']['emailAddress'] for element in user_email_response['elements'] ] } - user_info.update(user_email) + internal_response = InternalData(auth_info=auth_info) internal_response.attributes = self.converter.to_internal( self.external_type, user_info) @@ -118,4 +117,4 @@ def user_information(self, access_token, api): url = self.config['server_info'][api] headers = {'Authorization': 'Bearer {}'.format(access_token)} r = requests.get(url, headers=headers) - return r.json() \ No newline at end of file + return r.json() From 56ebe1e17aafa533f633edef0958fd52b2452eb0 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Tue, 18 Feb 2020 07:13:52 -0600 Subject: [PATCH 104/401] Document name_id_format value 'None' for SAML SP backend example Document that a name_id_format value for 'None' for the SAML SP backend will cause the SATOSA SP backend to send an authentication request with a NameIDPolicy element that does not include a Format attribute. --- example/plugins/backends/saml2_backend.yaml.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index 2bbc7026b..a71dfd0d4 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -53,6 +53,9 @@ config: discovery_response: - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + # A name_id_format of 'None' will cause the authentication request to not + # include a Format attribute in the NameIDPolicy. + # name_id_format: 'None' name_id_format_allow_create: true # disco_srv must be defined if there is more than one IdP in the metadata specified above disco_srv: http://disco.example.com From 0a36b07ba852f54b14cff5b5e306e81a494ae931 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Fri, 21 Feb 2020 10:42:55 -0600 Subject: [PATCH 105/401] Specify pool name when using REUSABLE client strategy Add code to choose a random name to be used as the pool name when using the REUSABLE client strategy. Without this enhancement two instances of the microservice connecting to distinct servers will share a single connection pool. --- .../micro_services/ldap_attribute_store.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 333254648..624357081 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -7,6 +7,8 @@ import copy import logging +import random +import string import urllib import ldap3 @@ -303,6 +305,8 @@ def _ldap_connection_factory(self, config): pool_size = config["pool_size"] pool_keepalive = config["pool_keepalive"] + pool_name = ''.join(random.sample(string.ascii_lowercase, 6)) + if client_strategy == ldap3.REUSABLE: msg = "Using pool size {}".format(pool_size) satosa_logging(logger, logging.DEBUG, msg, None) @@ -318,6 +322,7 @@ def _ldap_connection_factory(self, config): client_strategy=client_strategy, read_only=read_only, version=version, + pool_name=pool_name, pool_size=pool_size, pool_keepalive=pool_keepalive, ) @@ -358,7 +363,8 @@ def _populate_attributes(self, config, record): for attr, values in ldap_attributes.items(): internal_attr = ldap_to_internal_map.get(attr, None) if not internal_attr and ";" in attr: - internal_attr = ldap_to_internal_map.get(attr.split(";")[0], None) + internal_attr = ldap_to_internal_map.get(attr.split(";")[0], + None) if internal_attr and values: attributes[internal_attr] = ( @@ -439,8 +445,14 @@ def process(self, context, data): results = None exp_msg = None + connection = config["connection"] + msg = { + "message": "LDAP server host", + "server host": connection.server.host, + } + satosa_logging(logger, logging.DEBUG, msg, context.state) + for filter_val in filter_values: - connection = config["connection"] ldap_ident_attr = config["ldap_identifier_attribute"] search_filter = "({0}={1})".format(ldap_ident_attr, filter_val) msg = { From 4979be89083fcdee5a9d4328ce6c9ac64d853b0b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 24 Feb 2020 22:06:15 +0200 Subject: [PATCH 106/401] Set the session-id in state This fixes the logger that kept getting a new uuid4 as a session-id for each log call. Signed-off-by: Ivan Kanakarakis --- src/satosa/logging_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/logging_util.py b/src/satosa/logging_util.py index 0a64a55e4..d1aa2e212 100644 --- a/src/satosa/logging_util.py +++ b/src/satosa/logging_util.py @@ -30,6 +30,6 @@ def satosa_logging(logger, level, message, state, **kwargs): :param state: The current state :param kwargs: set exc_info=True to get an exception stack trace in the log """ - session_id = get_session_id(state) + state[LOGGER_STATE_KEY] = session_id = get_session_id(state) logline = LOG_FMT.format(id=session_id, message=message) logger.log(level, logline, **kwargs) From b00f9ae056ccab2020a016d2f0f1189f7ac0a788 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 24 Feb 2020 22:20:46 +0200 Subject: [PATCH 107/401] Set state session-id only when state is present Signed-off-by: Ivan Kanakarakis --- src/satosa/logging_util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/satosa/logging_util.py b/src/satosa/logging_util.py index d1aa2e212..46572cc1b 100644 --- a/src/satosa/logging_util.py +++ b/src/satosa/logging_util.py @@ -30,6 +30,8 @@ def satosa_logging(logger, level, message, state, **kwargs): :param state: The current state :param kwargs: set exc_info=True to get an exception stack trace in the log """ - state[LOGGER_STATE_KEY] = session_id = get_session_id(state) + session_id = get_session_id(state) + if state is not None: + state[LOGGER_STATE_KEY] = session_id logline = LOG_FMT.format(id=session_id, message=message) logger.log(level, logline, **kwargs) From e6bd518359731548b7d7e05ee86c039c23149fc4 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 25 Feb 2020 16:44:50 +0200 Subject: [PATCH 108/401] Move session-id management in state The session-id should not be handled by the logging helpers. Ideally, this should be part of the context. In the future we will restructure this, to be there. Signed-off-by: Ivan Kanakarakis --- src/satosa/logging_util.py | 13 +------------ src/satosa/state.py | 25 +++++++++++++++++++++---- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/satosa/logging_util.py b/src/satosa/logging_util.py index 46572cc1b..638adfd90 100644 --- a/src/satosa/logging_util.py +++ b/src/satosa/logging_util.py @@ -1,17 +1,8 @@ -from uuid import uuid4 - - -# The state key for saving the session id in the state -LOGGER_STATE_KEY = "SESSION_ID" LOG_FMT = "[{id}] {message}" def get_session_id(state): - session_id = ( - "UNKNOWN" - if state is None - else state.get(LOGGER_STATE_KEY, uuid4().urn) - ) + session_id = getattr(state, "session_id", None) or "UNKNOWN" return session_id @@ -31,7 +22,5 @@ def satosa_logging(logger, level, message, state, **kwargs): :param kwargs: set exc_info=True to get an exception stack trace in the log """ session_id = get_session_id(state) - if state is not None: - state[LOGGER_STATE_KEY] = session_id logline = LOG_FMT.format(id=session_id, message=message) logger.log(level, logline, **kwargs) diff --git a/src/satosa/state.py b/src/satosa/state.py index c9f7c0bce..c86a4e62c 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -9,14 +9,17 @@ import logging from collections import UserDict from http.cookies import SimpleCookie -from lzma import LZMADecompressor, LZMACompressor +from uuid import uuid4 + +from lzma import LZMACompressor +from lzma import LZMADecompressor from Cryptodome import Random from Cryptodome.Cipher import AES -from .exception import SATOSAStateError - import satosa.logging_util as lu +from satosa.exception import SATOSAStateError + logger = logging.getLogger(__name__) @@ -24,6 +27,8 @@ STATE_COOKIE_MAX_AGE = 1200 STATE_COOKIE_SECURE = True +_SESSION_ID_KEY = "SESSION_ID" + def state_to_cookie(state, name, path, encryption_key): """ @@ -178,6 +183,7 @@ def __init__(self, urlstate_data=None, encryption_key=None): """ self.delete = False + urlstate_data = {} if urlstate_data is None else urlstate_data if urlstate_data and not encryption_key: raise ValueError("If an 'urlstate_data' is supplied 'encrypt_key' must be specified.") @@ -192,7 +198,18 @@ def __init__(self, urlstate_data=None, encryption_key=None): urlstate_data = urlstate_data.decode("UTF-8") urlstate_data = json.loads(urlstate_data) - super().__init__(urlstate_data or {}) + session_id = ( + urlstate_data[_SESSION_ID_KEY] + if urlstate_data and _SESSION_ID_KEY in urlstate_data + else uuid4().urn + ) + urlstate_data[_SESSION_ID_KEY] = session_id + + super().__init__(urlstate_data) + + @property + def session_id(self): + return self.data.get(_SESSION_ID_KEY) def urlstate(self, encryption_key): """ From 7f93dcf95ce7228844a1f6d8969eea2164052e5c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 27 Feb 2020 00:19:50 +0200 Subject: [PATCH 109/401] Set minimum pysaml2 version PySAML2 had a recent vulnerability report. Setting the minimum version to the fixed release will make sure we always get a safe version. See, CVE-2020-5390 on XML Signature Wrapping (XSW) vulnerability. Signed-off-by: Ivan Kanakarakis --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index aa964f6d6..a7b728da9 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ package_dir={'': 'src'}, install_requires=[ "pyop >= 3.0.1", - "pysaml2", + "pysaml2 >= 5.0.0", "pycryptodomex", "requests", "PyYAML", From 7f5f0a1f04dc8616fc0dfae9076e32c2ce43fe8f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 6 Feb 2020 20:56:58 +0200 Subject: [PATCH 110/401] Add SameSite cookie attribute with compatibility support Recently, the cookie policies changes and a new attribute was introduced; namely the "SameSite" attribute. The "SameSite" attribute defines whether cookies should be sent in a request, depending on many factors, including the type of request, who is sending the request, who will receive the request and how strict the scope is set by this attribute's value. Read more in auth0.com's excellent article: https://auth0.com/blog/browser-behavior-changes-what-developers-need-to-know/ To keep up to date with the standards, SATOSA sets SameSite to None. This means that cookies are allowed to be sent across domains, without taking into account the SameSite policy. There are, however, multiple problems with this new attribute. - Python's http.cookies library only supports this attribute starting with Python 3.8 version. Earlier versions need to be monkey-patched to be able to set that attribute. To mitigate this, we add a new module that provides an interface for cookies; namele "cookies.py" monkey-patches the http.cookies library to provide access to this new attribute. - Not all browsers understand correctly the "None" value for the SameSite attribute. Different browsers behave differently, or even ignore the cookie altogether. To mitigate this, instead of trying to identify the browser vendor and version by inspecting the user-agent string (which is a fragile approach, as the user-agent is user-configurable), we introduce a WSGI middleware that duplicates the cookies that SATOSA releases (primary cookies) and removes the SameSite attribute (creating fallback cookies). This allows incompatible browsers that would drop the primary cookies that have the SameSite attribute set, to return the fallback cookies. At the same time, the middleware will identify when the primary cookie is not received and will try to recreate it from the fallback. This allows the application (SATOSA) to only care about a single cookie, and not mess with custom code to handle the incompatible cases. The new dependency is called "cookies-samesite-compat": - https://github.com/c00kiemon5ter/cookies_samesite_compat - https://pypi.org/project/cookies-samesite-compat/ Signed-off-by: Ivan Kanakarakis --- example/proxy_conf.yaml.example | 5 +++++ setup.py | 3 ++- src/satosa/cookies.py | 6 ++++++ src/satosa/proxy_server.py | 9 ++++++++- src/satosa/state.py | 5 +++-- 5 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 src/satosa/cookies.py diff --git a/example/proxy_conf.yaml.example b/example/proxy_conf.yaml.example index 67c50eefd..0289a60b5 100644 --- a/example/proxy_conf.yaml.example +++ b/example/proxy_conf.yaml.example @@ -14,6 +14,11 @@ FRONTEND_MODULES: - "plugins/frontends/saml2_frontend.yaml" MICRO_SERVICES: - "plugins/microservices/static_attributes.yaml" + +cookies_samesite_compat: [ + ("SATOSA_STATE", "SATOSA_STATE_LEGACY"), +] + LOGGING: version: 1 formatters: diff --git a/setup.py b/setup.py index a7b728da9..c91da28fa 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,8 @@ "gunicorn", "Werkzeug", "click", - "pystache" + "pystache", + "cookies-samesite-compat", ], extras_require={ "ldap": ["ldap3"] diff --git a/src/satosa/cookies.py b/src/satosa/cookies.py new file mode 100644 index 000000000..718fdb784 --- /dev/null +++ b/src/satosa/cookies.py @@ -0,0 +1,6 @@ +import http.cookies as _http_cookies + + +_http_cookies.Morsel._reserved["samesite"] = "SameSite" + +SimpleCookie = _http_cookies.SimpleCookie diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 71e0e6935..c1c12d2cc 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -7,6 +7,7 @@ import pkg_resources +from cookies_samesite_compat import CookiesSameSiteCompatMiddleware from .base import SATOSABase from .context import Context from .response import ServiceError, NotFound @@ -151,7 +152,13 @@ def make_app(satosa_config): logger.info(logline) except (NameError, pkg_resources.DistributionNotFound): pass - return ToBytesMiddleware(WsgiApplication(satosa_config)) + + res1 = WsgiApplication(satosa_config) + res2 = CookiesSameSiteCompatMiddleware(res1, satosa_config) + res3 = ToBytesMiddleware(res2) + res = res3 + + return res except Exception: logline = "Failed to create WSGI app." logger.exception(logline) diff --git a/src/satosa/state.py b/src/satosa/state.py index c86a4e62c..9aa427a12 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -8,7 +8,7 @@ import json import logging from collections import UserDict -from http.cookies import SimpleCookie +from satosa.cookies import SimpleCookie from uuid import uuid4 from lzma import LZMACompressor @@ -38,7 +38,7 @@ def state_to_cookie(state, name, path, encryption_key): :type name: str :type path: str :type encryption_key: str - :rtype: http.cookies.SimpleCookie + :rtype: satosa.cookies.SimpleCookie :param state: The state to save :param name: Name identifier of the cookie @@ -57,6 +57,7 @@ def state_to_cookie(state, name, path, encryption_key): logger.debug(logline) cookie = SimpleCookie() cookie[name] = cookie_data + cookie[name]["samesite"] = "None" cookie[name]["secure"] = STATE_COOKIE_SECURE cookie[name]["path"] = path cookie[name]["max-age"] = max_age From 7a895b19f43538cf0b36c55c2e6a349c5f119f38 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 27 Feb 2020 13:00:46 +0200 Subject: [PATCH 111/401] Report log after taking action Signed-off-by: Ivan Kanakarakis --- src/satosa/state.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/satosa/state.py b/src/satosa/state.py index 9aa427a12..b7bac7954 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -50,17 +50,19 @@ def state_to_cookie(state, name, path, encryption_key): cookie_data = "" if state.delete else state.urlstate(encryption_key) max_age = 0 if state.delete else STATE_COOKIE_MAX_AGE - msg = "Saving state as cookie, secure: {secure}, max-age: {max_age}, path: {path}".format( - secure=STATE_COOKIE_SECURE, max_age=STATE_COOKIE_MAX_AGE, path=path - ) - logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) - logger.debug(logline) cookie = SimpleCookie() cookie[name] = cookie_data cookie[name]["samesite"] = "None" cookie[name]["secure"] = STATE_COOKIE_SECURE cookie[name]["path"] = path cookie[name]["max-age"] = max_age + + msg = "Saved state in cookie {name} with properties {props}".format( + name=name, props=list(cookie[name].items()) + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.debug(logline) + return cookie From 5c43a11b2e977702073e86f0e7aacb0a3262315a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 27 Feb 2020 12:50:08 +0200 Subject: [PATCH 112/401] Set the Secure attribute of the cookie, always Signed-off-by: Ivan Kanakarakis --- src/satosa/state.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/satosa/state.py b/src/satosa/state.py index b7bac7954..d81a7773a 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -25,7 +25,6 @@ # TODO MOVE TO CONFIG STATE_COOKIE_MAX_AGE = 1200 -STATE_COOKIE_SECURE = True _SESSION_ID_KEY = "SESSION_ID" @@ -53,7 +52,7 @@ def state_to_cookie(state, name, path, encryption_key): cookie = SimpleCookie() cookie[name] = cookie_data cookie[name]["samesite"] = "None" - cookie[name]["secure"] = STATE_COOKIE_SECURE + cookie[name]["secure"] = True cookie[name]["path"] = path cookie[name]["max-age"] = max_age From b4dabe703a4c0e9a91cfe086944c1b579d6b2f3a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 27 Feb 2020 13:00:22 +0200 Subject: [PATCH 113/401] Fix misc typos Signed-off-by: Ivan Kanakarakis --- doc/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/README.md b/doc/README.md index cfdf02e06..6722fa413 100644 --- a/doc/README.md +++ b/doc/README.md @@ -36,15 +36,15 @@ in the [example directory](../example). | Parameter name | Data type | Example value | Description | | -------------- | --------- | ------------- | ----------- | | `BASE` | string | `https://proxy.example.com` | base url of the proxy | -| `COOKIE_STATE_NAME` | string | `satosa_state` | name of cooke SATOSA uses for preserving state between requests | +| `COOKIE_STATE_NAME` | string | `satosa_state` | name of the cookie SATOSA uses for preserving state between requests | | `CONTEXT_STATE_DELETE` | bool | `True` | controls whether SATOSA will delete the state cookie after receiving the authentication response from the upstream IdP| -| `STATE_ENCRYPTION_KEY` | string | `52fddd3528a44157` | key used for encrypting the state cookie, will be overriden by the environment variable `SATOSA_STATE_ENCRYPTION_KEY` if it is set | +| `STATE_ENCRYPTION_KEY` | string | `52fddd3528a44157` | key used for encrypting the state cookie, will be overridden by the environment variable `SATOSA_STATE_ENCRYPTION_KEY` if it is set | | `INTERNAL_ATTRIBUTES` | string | `example/internal_attributes.yaml` | path to attribute mapping | `CUSTOM_PLUGIN_MODULE_PATHS` | string[] | `[example/plugins/backends, example/plugins/frontends]` | list of directory paths containing any front-/backend plugin modules | | `BACKEND_MODULES` | string[] | `[openid_connect_backend.yaml, saml2_backend.yaml]` | list of plugin configuration file paths, describing enabled backends | | `FRONTEND_MODULES` | string[] | `[saml2_frontend.yaml, openid_connect_frontend.yaml]` | list of plugin configuration file paths, describing enabled frontends | | `MICRO_SERVICES` | string[] | `[statistics_service.yaml]` | list of plugin configuration file paths, describing enabled microservices | -| `USER_ID_HASH_SALT` | string | `61a89d2db0b9e1e2` | **DEPRECATED - use the hasher micro-service** salt used when creating the persistent user identifier, will be overriden by the environment variable `SATOSA_USER_ID_HASH_SALT` if it is set | +| `USER_ID_HASH_SALT` | string | `61a89d2db0b9e1e2` | **DEPRECATED - use the hasher micro-service** salt used when creating the persistent user identifier, will be overridden by the environment variable `SATOSA_USER_ID_HASH_SALT` if it is set | | `LOGGING` | dict | see [Python logging.conf](https://docs.python.org/3/library/logging.config.html) | optional configuration of application logging | From 42c1616e1559702315e324b326b3b602951a43f8 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 28 Feb 2020 18:51:28 +0200 Subject: [PATCH 114/401] Release version 6.1.0 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 13 +++++++++++++ setup.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 7e9032288..b29a4f3fa 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 6.0.0 +current_version = 6.1.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index cbfd0a0ec..d22f425d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 6.1.0 (2020-02-28) + +- Set the SameSite cookie attribute to "None" +- Add compatibility support for the SameSite attribute for incompatible + browsers +- Set the Secure attribute of the cookie, always +- Set minimum pysaml2 version to make sure we get a version patched for + CVE-2020-5390 +- Fix typos and improve documetation +- Set the session-id when state is created +- Use LinkedIn API v2 + + ## 6.0.0 (2019-12-17) - properly support mutliple values when converting internal attributes to OIDC diff --git a/setup.py b/setup.py index c91da28fa..0f9fb46eb 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='6.0.0', + version='6.1.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 016d9d17b7f5a4af41216ebcfb28c001c9bf5305 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 27 Feb 2020 12:52:07 +0200 Subject: [PATCH 115/401] Make the cookie a session-cookie To have the the cookie removed immediately after use, the CONTEXT_STATE_DELETE configuration option should be set to `True`. Signed-off-by: Ivan Kanakarakis --- src/satosa/state.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/satosa/state.py b/src/satosa/state.py index d81a7773a..6aaa5154b 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -23,9 +23,6 @@ logger = logging.getLogger(__name__) -# TODO MOVE TO CONFIG -STATE_COOKIE_MAX_AGE = 1200 - _SESSION_ID_KEY = "SESSION_ID" @@ -47,14 +44,13 @@ def state_to_cookie(state, name, path, encryption_key): """ cookie_data = "" if state.delete else state.urlstate(encryption_key) - max_age = 0 if state.delete else STATE_COOKIE_MAX_AGE cookie = SimpleCookie() cookie[name] = cookie_data cookie[name]["samesite"] = "None" cookie[name]["secure"] = True cookie[name]["path"] = path - cookie[name]["max-age"] = max_age + cookie[name]["max-age"] = 0 if state.delete else "" msg = "Saved state in cookie {name} with properties {props}".format( name=name, props=list(cookie[name].items()) From a23d7a770a86e3365f20e5a8b9bb07a7d9c69211 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 1 Mar 2020 16:11:52 +0200 Subject: [PATCH 116/401] Add version module and expose version Signed-off-by: Ivan Kanakarakis --- src/satosa/__init__.py | 11 ++--------- src/satosa/proxy_server.py | 13 +++---------- src/satosa/version.py | 11 +++++++++++ 3 files changed, 16 insertions(+), 19 deletions(-) create mode 100644 src/satosa/version.py diff --git a/src/satosa/__init__.py b/src/satosa/__init__.py index 52adfa0d1..895e0166f 100644 --- a/src/satosa/__init__.py +++ b/src/satosa/__init__.py @@ -1,11 +1,4 @@ # -*- coding: utf-8 -*- -""" - satosa - ~~~~~~~~~~~~~~~~ +"""SATOSA: An any to any Single Sign On (SSO) proxy.""" - An any to any Single Sign On (SSO) proxy. - Has support for SAML2, OpenID Connect and some OAUth2 variants. - - :copyright: (c) 2016 by UmeÃ¥ University. - :license: APACHE 2.0, see LICENSE for more details. -""" +from .version import version as __version__ diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index c1c12d2cc..66a9154c6 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -5,9 +5,9 @@ import sys from urllib.parse import parse_qsl -import pkg_resources - from cookies_samesite_compat import CookiesSameSiteCompatMiddleware + +import satosa from .base import SATOSABase from .context import Context from .response import ServiceError, NotFound @@ -144,14 +144,7 @@ def make_app(satosa_config): root_logger.addHandler(stderr_handler) root_logger.setLevel(logging.DEBUG) - try: - _ = pkg_resources.get_distribution(module.__name__) - logline = "Running SATOSA version {}".format( - pkg_resources.get.get_distribution("SATOSA").version - ) - logger.info(logline) - except (NameError, pkg_resources.DistributionNotFound): - pass + logger.info("Running SATOSA version {v}".format(v=satosa.__version__)) res1 = WsgiApplication(satosa_config) res2 = CookiesSameSiteCompatMiddleware(res1, satosa_config) diff --git a/src/satosa/version.py b/src/satosa/version.py new file mode 100644 index 000000000..8025c9e3c --- /dev/null +++ b/src/satosa/version.py @@ -0,0 +1,11 @@ +import pkg_resources as _pkg_resources + + +def _parse_version(): + data = _pkg_resources.get_distribution('satosa') + value = _pkg_resources.parse_version(data.version) + return value + + +version_info = _parse_version() +version = str(version_info) From 589a79a05024324e42c1fc50d28eda659b3a0c93 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 3 Mar 2020 00:54:24 +0200 Subject: [PATCH 117/401] Remove references to specific micro_services from core Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 14 -------------- tests/satosa/test_base.py | 12 ------------ 2 files changed, 26 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index ae041ab0e..a593af616 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -14,8 +14,6 @@ from .context import Context from .exception import SATOSAConfigurationError from .exception import SATOSAError, SATOSAAuthenticationError, SATOSAUnknownError -from .micro_services.account_linking import AccountLinking -from .micro_services.consent import Consent from .plugin_loader import load_backends, load_frontends from .plugin_loader import load_request_microservices, load_response_microservices from .routing import ModuleRouter, SATOSANoBoundEndpointError @@ -84,7 +82,6 @@ def __init__(self, config): self.config["MICRO_SERVICES"], self.config["INTERNAL_ATTRIBUTES"], self.config["BASE"])) - self._verify_response_micro_services(self.response_micro_services) self._link_micro_services(self.response_micro_services, self._auth_resp_finish) self.module_router = ModuleRouter(frontends, backends, @@ -99,17 +96,6 @@ def _link_micro_services(self, micro_services, finisher): micro_services[-1].next = finisher - def _verify_response_micro_services(self, response_micro_services): - account_linking_index = next((i for i in range(len(response_micro_services)) - if isinstance(response_micro_services[i], AccountLinking)), -1) - if account_linking_index > 0: - raise SATOSAConfigurationError("Account linking must be configured first in the list of micro services") - - consent_index = next((i for i in range(len(response_micro_services)) - if isinstance(response_micro_services[i], Consent)), -1) - if consent_index != -1 and consent_index < len(response_micro_services) - 1: - raise SATOSAConfigurationError("Consent must be configured last in the list of micro services") - def _auth_req_callback_func(self, context, internal_request): """ This function is called by a frontend module when an authorization request has been diff --git a/tests/satosa/test_base.py b/tests/satosa/test_base.py index 0cb365742..fe46d59fb 100644 --- a/tests/satosa/test_base.py +++ b/tests/satosa/test_base.py @@ -29,18 +29,6 @@ def test_full_initialisation(self, satosa_config): assert len(base.request_micro_services) == 1 assert len(base.response_micro_services) == 1 - def test_constuctor_should_raise_exception_if_account_linking_is_not_first_in_micro_service_list( - self, satosa_config, account_linking_module_config): - satosa_config["MICRO_SERVICES"].append(account_linking_module_config) - with pytest.raises(SATOSAConfigurationError): - SATOSABase(satosa_config) - - def test_constuctor_should_raise_exception_if_consent_is_not_last_in_micro_service_list( - self, satosa_config, consent_module_config): - satosa_config["MICRO_SERVICES"].insert(0, consent_module_config) - with pytest.raises(SATOSAConfigurationError): - SATOSABase(satosa_config) - def test_auth_resp_callback_func_user_id_from_attrs_is_used_to_override_user_id(self, context, satosa_config): satosa_config["INTERNAL_ATTRIBUTES"]["user_id_from_attrs"] = ["user_id", "domain"] base = SATOSABase(satosa_config) From 136a17aa552fc11143581cf80260b882854be9c9 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 3 Mar 2020 00:55:05 +0200 Subject: [PATCH 118/401] Move all state management of Consent micro-service into the consent module Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 13 +------------ src/satosa/micro_services/consent.py | 18 +++++++++++------- tests/satosa/micro_services/test_consent.py | 7 ++++--- tests/satosa/test_base.py | 19 ------------------- 4 files changed, 16 insertions(+), 41 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index a593af616..ff5865ec7 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -9,8 +9,6 @@ from saml2.s_utils import UnknownSystemEntity from satosa import util -from satosa.micro_services import consent - from .context import Context from .exception import SATOSAConfigurationError from .exception import SATOSAError, SATOSAAuthenticationError, SATOSAUnknownError @@ -112,16 +110,7 @@ def _auth_req_callback_func(self, context, internal_request): """ state = context.state state[STATE_KEY] = {"requester": internal_request.requester} - # TODO consent module should manage any state it needs by itself - try: - state_dict = context.state[consent.STATE_KEY] - except KeyError: - state_dict = context.state[consent.STATE_KEY] = {} - finally: - state_dict.update({ - "filter": internal_request.attributes or [], - "requester_name": internal_request.requester_name, - }) + msg = "Requesting provider: {}".format(internal_request.requester) logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) logger.info(logline) diff --git a/src/satosa/micro_services/consent.py b/src/satosa/micro_services/consent.py index 968b28327..40e2d37a2 100644 --- a/src/satosa/micro_services/consent.py +++ b/src/satosa/micro_services/consent.py @@ -91,7 +91,7 @@ def _approve_new_consent(self, context, internal_response, id_hash): "attr": internal_response.attributes, "id": id_hash, "redirect_endpoint": "%s/consent%s" % (self.base_url, self.endpoint), - "requester_name": context.state[STATE_KEY]["requester_name"] + "requester_name": internal_response.requester_name, } if self.locked_attr: consent_args["locked_attrs"] = [self.locked_attr] @@ -122,11 +122,15 @@ def process(self, context, internal_response): :param internal_response: the response :return: response """ - consent_state = context.state[STATE_KEY] - - internal_response.attributes = self._filter_attributes(internal_response.attributes, consent_state["filter"]) - id_hash = self._get_consent_id(internal_response.requester, internal_response.subject_id, - internal_response.attributes) + context.state[STATE_KEY] = context.state.get(STATE_KEY, {}) + consent_filter = internal_response.attributes or [] + internal_response.attributes = self._filter_attributes( + internal_response.attributes, consent_filter + ) + id_hash = self._get_consent_id( + internal_response.requester, internal_response.subject_id, + internal_response.attributes, + ) try: # Check if consent is already given @@ -225,7 +229,7 @@ def _end_consent(self, context, internal_response): :param internal_response: the response :return: response """ - del context.state[STATE_KEY] + context.state.pop(STATE_KEY, None) return super().process(context, internal_response) def register_endpoints(self): diff --git a/tests/satosa/micro_services/test_consent.py b/tests/satosa/micro_services/test_consent.py index 247b74868..6d9bf21b2 100644 --- a/tests/satosa/micro_services/test_consent.py +++ b/tests/satosa/micro_services/test_consent.py @@ -152,7 +152,7 @@ def test_consent_full_flow(self, context, consent_config, internal_response, int consent_verify_endpoint_regex, consent_registration_endpoint_regex): expected_ticket = "my_ticket" - requester_name = [{"lang": "en", "text": "test requester"}] + requester_name = internal_response.requester_name context.state[consent.STATE_KEY] = {"filter": internal_request.attributes, "requester_name": requester_name} @@ -189,7 +189,8 @@ def test_consent_not_given(self, context, consent_config, internal_response, int responses.add(responses.GET, consent_registration_endpoint_regex, status=200, body=expected_ticket) - context.state[consent.STATE_KEY] = {"filter": [], "requester_name": None} + requester_name = internal_response.requester_name + context.state[consent.STATE_KEY] = {} resp = self.consent_module.process(context, internal_response) @@ -198,7 +199,7 @@ def test_consent_not_given(self, context, consent_config, internal_response, int internal_response, consent_config["sign_key"], self.consent_module.base_url, - None) + requester_name) new_context = Context() new_context.state = context.state diff --git a/tests/satosa/test_base.py b/tests/satosa/test_base.py index fe46d59fb..713160aca 100644 --- a/tests/satosa/test_base.py +++ b/tests/satosa/test_base.py @@ -3,16 +3,11 @@ import pytest -from saml2.saml import NAMEID_FORMAT_TRANSIENT -from saml2.saml import NAMEID_FORMAT_PERSISTENT - import satosa from satosa import util from satosa.base import SATOSABase -from satosa.exception import SATOSAConfigurationError from satosa.internal import AuthenticationInformation from satosa.internal import InternalData -from satosa.micro_services import consent from satosa.satosa_config import SATOSAConfig @@ -44,20 +39,6 @@ def test_auth_resp_callback_func_user_id_from_attrs_is_used_to_override_user_id( expected_user_id = "user@example.com" assert internal_resp.subject_id == expected_user_id - def test_auth_req_callback_stores_state_for_consent(self, context, satosa_config): - base = SATOSABase(satosa_config) - - context.target_backend = satosa_config["BACKEND_MODULES"][0]["name"] - requester_name = [{"lang": "en", "text": "Test EN"}, {"lang": "sv", "text": "Test SV"}] - internal_req = InternalData( - subject_type=NAMEID_FORMAT_TRANSIENT, requester_name=requester_name, - ) - internal_req.attributes = ["attr1", "attr2"] - base._auth_req_callback_func(context, internal_req) - - assert context.state[consent.STATE_KEY]["requester_name"] == internal_req.requester_name - assert context.state[consent.STATE_KEY]["filter"] == internal_req.attributes - def test_auth_resp_callback_func_hashes_all_specified_attributes(self, context, satosa_config): satosa_config["INTERNAL_ATTRIBUTES"]["hash"] = ["user_id", "mail"] base = SATOSABase(satosa_config) From 7c7f4ff8371f2f1ff5cf60dd02290e17fa41de0e Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 4 Mar 2020 16:35:17 +0200 Subject: [PATCH 119/401] Do not filter attributes before reaching the Consent Service Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/consent.py | 7 ++----- tests/satosa/micro_services/test_consent.py | 21 --------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/src/satosa/micro_services/consent.py b/src/satosa/micro_services/consent.py index 40e2d37a2..afad940e2 100644 --- a/src/satosa/micro_services/consent.py +++ b/src/satosa/micro_services/consent.py @@ -123,12 +123,9 @@ def process(self, context, internal_response): :return: response """ context.state[STATE_KEY] = context.state.get(STATE_KEY, {}) - consent_filter = internal_response.attributes or [] - internal_response.attributes = self._filter_attributes( - internal_response.attributes, consent_filter - ) id_hash = self._get_consent_id( - internal_response.requester, internal_response.subject_id, + internal_response.requester, + internal_response.subject_id, internal_response.attributes, ) diff --git a/tests/satosa/micro_services/test_consent.py b/tests/satosa/micro_services/test_consent.py index 6d9bf21b2..514367300 100644 --- a/tests/satosa/micro_services/test_consent.py +++ b/tests/satosa/micro_services/test_consent.py @@ -217,27 +217,6 @@ def test_filter_attributes(self): filtered_attributes = self.consent_module._filter_attributes(ATTRIBUTES, FILTER) assert Counter(filtered_attributes.keys()) == Counter(FILTER) - @responses.activate - def test_manage_consent_filters_attributes_before_send_to_consent_service(self, context, internal_request, - internal_response, - consent_verify_endpoint_regex): - approved_attributes = ["foo", "bar"] - # fake previous consent - responses.add(responses.GET, consent_verify_endpoint_regex, status=200, - body=json.dumps(approved_attributes)) - - attributes = {"foo": "123", "bar": "456", "abc": "should be filtered"} - internal_response.attributes = attributes - - context.state[consent.STATE_KEY] = {"filter": approved_attributes} - self.consent_module.process(context, internal_response) - - consent_hash = urlparse(responses.calls[0].request.url).path.split("/")[2] - expected_hash = self.consent_module._get_consent_id(internal_response.requester, internal_response.subject_id, - {k: v for k, v in attributes.items() if - k in approved_attributes}) - assert consent_hash == expected_hash - @responses.activate def test_manage_consent_without_filter_passes_through_all_attributes(self, context, internal_response, consent_verify_endpoint_regex): From 70549c7a6f0d272f64f4b525f8b84478fab11076 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 6 Mar 2020 01:49:13 +0200 Subject: [PATCH 120/401] Update doc to reference subject instead of user Signed-off-by: Ivan Kanakarakis --- doc/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/README.md b/doc/README.md index 6722fa413..29a2a9182 100644 --- a/doc/README.md +++ b/doc/README.md @@ -106,18 +106,18 @@ attributes (in the proxy backend) <-> internal <-> returned attributes (from the ### user_id_from_attrs -The user identifier generated by the backend module can be overridden by +The subject identifier generated by the backend module can be overridden by specifying a list of internal attribute names under the `user_id_from_attrs` key. The attribute values of the attributes specified in this list will be -concatenated and hashed to be used as the user identifier. +concatenated and used as the subject identifier. ### user_id_to_attr -To store the user identifier in a specific internal attribute, the internal +To store the subject identifier in a specific internal attribute, the internal attribute name can be specified in `user_id_to_attr`. When the [ALService](https://github.com/its-dirg/ALservice) is used for account linking, the `user_id_to_attr` configuration parameter should be set, since that -service will overwrite the user identifier generated by the proxy. +service will overwrite the subject identifier generated by the proxy. ### hash **DEPRECATED - use the hasher micro-service** From f5bef2d5951e9da2d4412155849a8593e30a676f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 16:22:27 +0200 Subject: [PATCH 121/401] Rearrange config settings - Remove CUSTOM_PLUGIN_MODULE_PATHS. This is a hack to lookup modules outside the PYTHON_PATH. It should not be used. - Group cookie-state settings - Group path settings to other/relative files; but at the same time, add new lines to make jumping between the settings eaiser. Signed-off-by: Ivan Kanakarakis --- example/proxy_conf.yaml.example | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/example/proxy_conf.yaml.example b/example/proxy_conf.yaml.example index 0289a60b5..058d8987c 100644 --- a/example/proxy_conf.yaml.example +++ b/example/proxy_conf.yaml.example @@ -1,24 +1,23 @@ -#--- SATOSA Config ---# BASE: https://example.com -INTERNAL_ATTRIBUTES: "internal_attributes.yaml" + COOKIE_STATE_NAME: "SATOSA_STATE" CONTEXT_STATE_DELETE: yes STATE_ENCRYPTION_KEY: "asdASD123" -CUSTOM_PLUGIN_MODULE_PATHS: - - "plugins/backends" - - "plugins/frontends" - - "plugins/micro_services" + +cookies_samesite_compat: + - ["SATOSA_STATE", "SATOSA_STATE_LEGACY"] + +INTERNAL_ATTRIBUTES: "internal_attributes.yaml" + BACKEND_MODULES: - "plugins/backends/saml2_backend.yaml" + FRONTEND_MODULES: - "plugins/frontends/saml2_frontend.yaml" + MICRO_SERVICES: - "plugins/microservices/static_attributes.yaml" -cookies_samesite_compat: [ - ("SATOSA_STATE", "SATOSA_STATE_LEGACY"), -] - LOGGING: version: 1 formatters: From 78dc701f95b21d531ccbc00f698d17af63083477 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 16:26:07 +0200 Subject: [PATCH 122/401] Reset example logging configuration - Make the formatter useful - Add example handlers - Set all related packages' loggers to DEBUG - Set the root logger and handler Signed-off-by: Ivan Kanakarakis --- example/proxy_conf.yaml.example | 48 ++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/example/proxy_conf.yaml.example b/example/proxy_conf.yaml.example index 058d8987c..760714b00 100644 --- a/example/proxy_conf.yaml.example +++ b/example/proxy_conf.yaml.example @@ -22,26 +22,50 @@ LOGGING: version: 1 formatters: simple: - format: "[%(asctime)-19.19s] [%(levelname)-5.5s]: %(message)s" + format: "[%(asctime)s] [%(levelname)s] [%(name)s.%(funcName)s] %(message)s" handlers: - console: + stdout: class: logging.StreamHandler + stream: "ext://sys.stdout" level: DEBUG formatter: simple - stream: ext://sys.stdout - info_file_handler: - class: logging.handlers.RotatingFileHandler - level: INFO + syslog: + class: logging.handlers.SysLogHandler + address: "/dev/log" + level: DEBUG + formatter: simple + debug_file: + class: logging.FileHandler + filename: satosa-debug.log + encoding: utf8 + level: DEBUG + formatter: simple + error_file: + class: logging.FileHandler + filename: satosa-error.log + encoding: utf8 + level: ERROR formatter: simple - filename: info.log + info_file: + class: logging.FileHandler + filename: satosa-info.log + encoding: utf8 maxBytes: 10485760 # 10MB backupCount: 20 - encoding: utf8 + level: INFO + formatter: simple loggers: satosa: level: DEBUG - handlers: [console] - propagate: no + saml2: + level: DEBUG + oidcendpoint: + level: DEBUG + pyop: + level: DEBUG + oic: + level: DEBUG root: - level: INFO - handlers: [info_file_handler] + level: DEBUG + handlers: + - stdout From a1be76265bee5f64117d5bb3c3009bc8126b31fc Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 22:03:02 +0200 Subject: [PATCH 123/401] Set default logger Signed-off-by: Ivan Kanakarakis --- src/satosa/proxy_server.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 66a9154c6..868ffd5b1 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -134,15 +134,25 @@ def __call__(self, environ, start_response, debug=False): def make_app(satosa_config): try: - if "LOGGING" in satosa_config: - logging.config.dictConfig(satosa_config["LOGGING"]) - else: - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.setLevel(logging.DEBUG) - - root_logger = logging.getLogger("") - root_logger.addHandler(stderr_handler) - root_logger.setLevel(logging.DEBUG) + default_logging_config = { + "version": 1, + "formatters": { + "simple": { + "format": "[%(asctime)s] [%(levelname)s] [%(name)s.%(funcName)s] %(message)s" + } + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "level": "DEBUG", + "formatter": "simple", + } + }, + "loggers": {"satosa": {"level": "DEBUG"}}, + "root": {"level": "DEBUG", "handlers": ["stdout"]}, + } + logging.config.dictConfig(satosa_config.get("LOGGING", default_logging_config)) logger.info("Running SATOSA version {v}".format(v=satosa.__version__)) From 7e213e88eadc811e5781e861ca3a3dd10bb500fd Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 22:53:32 +0200 Subject: [PATCH 124/401] Add flake8 preferences Signed-off-by: Ivan Kanakarakis --- setup.cfg | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 224a77957..88673863b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,25 @@ [metadata] -description-file = README.md \ No newline at end of file +description-file = README.md + + +[flake8] +max-line-length = 88 +author-attribute = forbidden +no-accept-encodings = True +assertive-snakecase = True +# assertive-test-pattern = +inline-quotes = " +multiline-quotes = """ +docstring-quotes = """ +application-import-names = satosa + +hang_closing = false +doctests = false +max-complexity = 10 +exclude = + .git + __pycache__ + doc/source/conf.py + docs/source/conf.py + build + dist From 61ce2534e130ebc14cbd35bec80b7cab3d04d659 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 22:56:59 +0200 Subject: [PATCH 125/401] Format code in wsgi module Signed-off-by: Ivan Kanakarakis --- src/satosa/wsgi.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/satosa/wsgi.py b/src/satosa/wsgi.py index e5e9e1948..f53751ed1 100644 --- a/src/satosa/wsgi.py +++ b/src/satosa/wsgi.py @@ -17,13 +17,14 @@ def main(): global app - parser = argparse.ArgumentParser(description='Process some integers.') - parser.add_argument('port', type=int) - parser.add_argument('--keyfile', type=str) - parser.add_argument('--certfile', type=str) - parser.add_argument('--host', type=str) - parser.add_argument('-d', action='store_true', dest="debug", - help="enable debug mode.") + parser = argparse.ArgumentParser(description="Process some integers.") + parser.add_argument("port", type=int) + parser.add_argument("--keyfile", type=str) + parser.add_argument("--certfile", type=str) + parser.add_argument("--host", type=str) + parser.add_argument( + "-d", action='store_true', dest="debug", help="enable debug mode." + ) args = parser.parse_args() if (args.keyfile and not args.certfile) or (args.certfile and not args.keyfile): @@ -45,5 +46,5 @@ def main(): run_simple('localhost', args.port, app, ssl_context=ssl_context) -if __name__ == '__main__': +if __name__ == "__main__": main() From e29c6e9434ceaa53e121ab082a8dca0eccbb444d Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 22:58:00 +0200 Subject: [PATCH 126/401] Remove debug middleware Signed-off-by: Ivan Kanakarakis --- src/satosa/wsgi.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/satosa/wsgi.py b/src/satosa/wsgi.py index f53751ed1..502048e9a 100644 --- a/src/satosa/wsgi.py +++ b/src/satosa/wsgi.py @@ -1,9 +1,7 @@ import argparse -import functools import os import sys -from werkzeug.debug import DebuggedApplication from werkzeug.serving import run_simple from satosa.proxy_server import make_app @@ -22,18 +20,12 @@ def main(): parser.add_argument("--keyfile", type=str) parser.add_argument("--certfile", type=str) parser.add_argument("--host", type=str) - parser.add_argument( - "-d", action='store_true', dest="debug", help="enable debug mode." - ) args = parser.parse_args() if (args.keyfile and not args.certfile) or (args.certfile and not args.keyfile): print("Both keyfile and certfile must be specified for HTTPS.") sys.exit() - if args.debug: - app.app = functools.partial(app.app, debug=True) - app = DebuggedApplication(app) if (args.keyfile and args.certfile): ssl_context = (args.certfile, args.keyfile) From 702e121d225bdea96ad5df00f55b6c4310775a5d Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 22:59:03 +0200 Subject: [PATCH 127/401] Fail with status code 1 Signed-off-by: Ivan Kanakarakis --- src/satosa/wsgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/wsgi.py b/src/satosa/wsgi.py index 502048e9a..402fd626c 100644 --- a/src/satosa/wsgi.py +++ b/src/satosa/wsgi.py @@ -24,7 +24,7 @@ def main(): if (args.keyfile and not args.certfile) or (args.certfile and not args.keyfile): print("Both keyfile and certfile must be specified for HTTPS.") - sys.exit() + sys.exit(1) if (args.keyfile and args.certfile): From 57e2f1b0724ed7975528e8e8cf5c8582e1f27e9f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 7 Mar 2020 22:59:28 +0200 Subject: [PATCH 128/401] Follow a single execution path Signed-off-by: Ivan Kanakarakis --- src/satosa/wsgi.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/satosa/wsgi.py b/src/satosa/wsgi.py index 402fd626c..86220eb06 100644 --- a/src/satosa/wsgi.py +++ b/src/satosa/wsgi.py @@ -26,16 +26,13 @@ def main(): print("Both keyfile and certfile must be specified for HTTPS.") sys.exit(1) - - if (args.keyfile and args.certfile): - ssl_context = (args.certfile, args.keyfile) - else: - ssl_context = None - - if args.host: - run_simple(args.host, args.port, app, ssl_context=ssl_context) - else: - run_simple('localhost', args.port, app, ssl_context=ssl_context) + ssl_context = ( + (args.certfile, args.keyfile) + if args.keyfile and args.certfile + else None + ) + host = args.host or "localhost" + run_simple(host, args.port, app, ssl_context=ssl_context) if __name__ == "__main__": From 2e5b0f8441fd709c4df240a3c41792602e3db784 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 1 Mar 2020 16:41:03 +0200 Subject: [PATCH 129/401] Remove deprecated modules and options Signed-off-by: Ivan Kanakarakis --- README.md | 21 +- doc/README.md | 8 - doc/mod_wsgi.md | 2 - src/satosa/backends/saml2.py | 1 - src/satosa/base.py | 26 +-- src/satosa/deprecated.py | 272 ------------------------- src/satosa/frontends/openid_connect.py | 1 - src/satosa/frontends/saml2.py | 2 - src/satosa/internal_data.py | 14 -- tests/satosa/test_base.py | 20 -- 10 files changed, 11 insertions(+), 356 deletions(-) delete mode 100644 src/satosa/deprecated.py delete mode 100644 src/satosa/internal_data.py diff --git a/README.md b/README.md index cfa4ae5dd..daefcd7e3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.org/IdentityPython/SATOSA.svg?branch=travis)](https://travis-ci.org/IdentityPython/SATOSA) [![PyPI](https://img.shields.io/pypi/v/SATOSA.svg)](https://pypi.python.org/pypi/SATOSA) -A configurable proxy for translating between different authentication protocols such as SAML2, +A configurable proxy for translating between different authentication protocols such as SAML2, OpenID Connect and OAuth2. # Table of Contents @@ -19,7 +19,6 @@ OpenID Connect and OAuth2. - [attributes](doc/README.md#attributes) - [user_id_from_attrs](doc/README.md#user_id_from_attrs) - [user_id_to_attr](doc/README.md#user_id_to_attr) - - [hash](doc/README.md#hash) - [Plugins](doc/README.md#plugins) - [SAML2 plugins](doc/README.md#saml_plugin) - [Metadata](doc/README.md#metadata) @@ -36,26 +35,26 @@ OpenID Connect and OAuth2. # Use cases -In this section a set of use cases for the proxy is presented. +In this section a set of use cases for the proxy is presented. ## SAML2<->SAML2 -There are SAML2 service providers for example Box which is not able to handle multiple identity -providers. For more information about how to set up, configure and run such a proxy instance +There are SAML2 service providers for example Box which is not able to handle multiple identity +providers. For more information about how to set up, configure and run such a proxy instance please visit [Single Service Provider<->Multiple Identity providers](doc/one-to-many.md) -If an identity provider can not communicate with service providers in for example a federation the +If an identity provider can not communicate with service providers in for example a federation the can convert request and make the communication possible. ## SAML2<->Social logins -This setup makes it possible to connect a SAML2 service provider to multiple social media identity -providers such as Google and Facebook. The proxy makes it possible to mirror a identity provider by -generating SAML2 metadata corresponding that provider and create dynamic endpoint which +This setup makes it possible to connect a SAML2 service provider to multiple social media identity +providers such as Google and Facebook. The proxy makes it possible to mirror a identity provider by +generating SAML2 metadata corresponding that provider and create dynamic endpoint which are connected to a single identity provider. -For more information about how to set up, configure and run such a proxy instance please visit +For more information about how to set up, configure and run such a proxy instance please visit [SAML2<->Social logins](doc/SAML2-to-Social_logins.md) ## SAML2<->OIDC -The proxy is able to act as a proxy between a SAML2 service provider and a OpenID connect provider +The proxy is able to act as a proxy between a SAML2 service provider and a OpenID connect provider [SAML2<->OIDC](doc/saml2-to-oidc.md) # Contact diff --git a/doc/README.md b/doc/README.md index 29a2a9182..11f12b9bd 100644 --- a/doc/README.md +++ b/doc/README.md @@ -44,7 +44,6 @@ in the [example directory](../example). | `BACKEND_MODULES` | string[] | `[openid_connect_backend.yaml, saml2_backend.yaml]` | list of plugin configuration file paths, describing enabled backends | | `FRONTEND_MODULES` | string[] | `[saml2_frontend.yaml, openid_connect_frontend.yaml]` | list of plugin configuration file paths, describing enabled frontends | | `MICRO_SERVICES` | string[] | `[statistics_service.yaml]` | list of plugin configuration file paths, describing enabled microservices | -| `USER_ID_HASH_SALT` | string | `61a89d2db0b9e1e2` | **DEPRECATED - use the hasher micro-service** salt used when creating the persistent user identifier, will be overridden by the environment variable `SATOSA_USER_ID_HASH_SALT` if it is set | | `LOGGING` | dict | see [Python logging.conf](https://docs.python.org/3/library/logging.config.html) | optional configuration of application logging | @@ -120,13 +119,6 @@ linking, the `user_id_to_attr` configuration parameter should be set, since that service will overwrite the subject identifier generated by the proxy. -### hash **DEPRECATED - use the hasher micro-service** -The proxy can hash any attribute value (e.g., for obfuscation) before passing -it on to the client. The `hash` key should contain a list of all attribute names -for which the corresponding attribute values should be hashed before being -returned to the client. - - ## Plugins The authentication protocol specific communication is handled by different plugins, divided into frontends (receiving requests from clients) and backends (sending requests diff --git a/doc/mod_wsgi.md b/doc/mod_wsgi.md index 8605c7abb..e739028dc 100644 --- a/doc/mod_wsgi.md +++ b/doc/mod_wsgi.md @@ -110,8 +110,6 @@ BASE: https://some.host.org STATE_ENCRYPTION_KEY: fazmC8yELv38f9PF0kbS -USER_ID_HASH_SALT: i7tmt34rzb2QRDgN1Ggy - INTERNAL_ATTRIBUTES: "/etc/satosa/internal_attributes.yaml" COOKIE_STATE_NAME: "SATOSA_STATE" diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 024e948d8..c2e39f17a 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -30,7 +30,6 @@ MetadataDescription, OrganizationDesc, ContactPersonDesc, UIInfoDesc ) from satosa.backends.base import BackendModule -from satosa.deprecated import SAMLInternalResponse logger = logging.getLogger(__name__) diff --git a/src/satosa/base.py b/src/satosa/base.py index ff5865ec7..d458293e1 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -4,7 +4,6 @@ import json import logging import uuid -import warnings as _warnings from saml2.s_utils import UnknownSystemEntity @@ -17,10 +16,9 @@ from .routing import ModuleRouter, SATOSANoBoundEndpointError from .state import cookie_to_state, SATOSAStateError, State, state_to_cookie -from satosa.deprecated import hash_attributes - import satosa.logging_util as lu + logger = logging.getLogger(__name__) STATE_KEY = "SATOSA_BASE" @@ -41,22 +39,6 @@ def __init__(self, config): """ self.config = config - for option in ["USER_ID_HASH_SALT"]: - if option in self.config: - msg = ( - "'{opt}' configuration option is deprecated." - " Use the hasher microservice instead." - ).format(opt=option) - _warnings.warn(msg, DeprecationWarning) - - for option in ["hash"]: - if option in self.config["INTERNAL_ATTRIBUTES"]: - msg = ( - "'{opt}' configuration option is deprecated." - " Use the hasher microservice instead." - ).format(opt=option) - _warnings.warn(msg, DeprecationWarning) - logger.info("Loading backend modules...") backends = load_backends(self.config, self._auth_resp_callback_func, self.config["INTERNAL_ATTRIBUTES"]) @@ -130,12 +112,6 @@ def _auth_resp_finish(self, context, internal_response): if user_id_to_attr: internal_response.attributes[user_id_to_attr] = [internal_response.subject_id] - hash_attributes( - self.config["INTERNAL_ATTRIBUTES"].get("hash", []), - internal_response.attributes, - self.config.get("USER_ID_HASH_SALT", ""), - ) - # remove all session state unless CONTEXT_STATE_DELETE is False context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True) context.request = None diff --git a/src/satosa/deprecated.py b/src/satosa/deprecated.py deleted file mode 100644 index 2ab16c6ed..000000000 --- a/src/satosa/deprecated.py +++ /dev/null @@ -1,272 +0,0 @@ -import datetime -import warnings as _warnings -from enum import Enum - -from saml2.saml import NAMEID_FORMAT_TRANSIENT -from saml2.saml import NAMEID_FORMAT_PERSISTENT -from saml2.saml import NAMEID_FORMAT_EMAILADDRESS -from saml2.saml import NAMEID_FORMAT_UNSPECIFIED - -from satosa.internal import AuthenticationInformation as _AuthenticationInformation -from satosa.internal import InternalData as _InternalData -from satosa import util - - -class InternalRequest(_InternalData): - def __init__(self, user_id_hash_type, requester, requester_name=None): - msg = ( - "InternalRequest is deprecated." - " Use satosa.internal.InternalData class instead." - ) - _warnings.warn(msg, DeprecationWarning) - super().__init__( - user_id_hash_type=user_id_hash_type, - requester=requester, - requester_name=requester_name, - ) - - @classmethod - def from_dict(cls, data): - instance = cls( - user_id_hash_type=data.get("hash_type"), - requester=data.get("requester"), - requester_name=data.get("requester_name"), - ) - return instance - - -class InternalResponse(_InternalData): - def __init__(self, auth_info=None): - msg = ( - "InternalResponse is deprecated." - " Use satosa.internal.InternalData class instead." - ) - _warnings.warn(msg, DeprecationWarning) - auth_info = auth_info or _AuthenticationInformation() - super().__init__(auth_info=auth_info) - - @classmethod - def from_dict(cls, data): - """ - :type data: dict[str, dict[str, str] | str] - :rtype: satosa.internal_data.InternalResponse - :param data: A dict representation of an InternalResponse object - :return: An InternalResponse object - """ - auth_info = _AuthenticationInformation.from_dict(data.get("auth_info")) - instance = cls(auth_info=auth_info) - instance.user_id_hash_type = data.get("hash_type") - instance.attributes = data.get("attributes", {}) - instance.user_id = data.get("user_id") - instance.requester = data.get("requester") - return instance - - -class SAMLInternalResponse(InternalResponse): - """ - Like the parent InternalResponse, holds internal representation of - service related data, but includes additional details relevant to - SAML interoperability. - - :type name_id: instance of saml2.saml.NameID from pysaml2 - """ - - def __init__(self, auth_info=None): - msg = ( - "SAMLInternalResponse is deprecated." - " Use satosa.internal.InternalData class instead." - ) - _warnings.warn(msg, DeprecationWarning) - super().__init__(auth_info=auth_info) - - -class UserIdHashType(Enum): - """ - All different user id hash types - """ - - transient = 1 - persistent = 2 - pairwise = 3 - public = 4 - emailaddress = 5 - unspecified = 6 - - def __getattr__(self, name): - if name != "_value_": - msg = "UserIdHashType is deprecated and will be removed." - _warnings.warn(msg, DeprecationWarning) - return self.__getattribute__(name) - - @classmethod - def from_string(cls, str): - msg = "UserIdHashType is deprecated and will be removed." - _warnings.warn(msg, DeprecationWarning) - try: - return getattr(cls, str) - except AttributeError: - raise ValueError("Unknown hash type '{}'".format(str)) - - -class UserIdHasher(object): - """ - Class for creating different user id types - """ - - STATE_KEY = "IDHASHER" - - @staticmethod - def save_state(internal_request, state): - """ - Saves all necessary information needed by the UserIdHasher - - :type internal_request: satosa.internal_data.InternalRequest - - :param internal_request: The request - :param state: The current state - """ - state_data = {"hash_type": internal_request.user_id_hash_type} - state[UserIdHasher.STATE_KEY] = state_data - - @staticmethod - def hash_data(salt, value): - """ - Hashes a value together with a salt. - :type salt: str - :type value: str - :param salt: hash salt - :param value: value to hash together with the salt - :return: hash value (SHA512) - """ - msg = "UserIdHasher is deprecated; use satosa.util.hash_data instead." - _warnings.warn(msg, DeprecationWarning) - return util.hash_data(salt, value) - - @staticmethod - def hash_type(state): - state_data = state[UserIdHasher.STATE_KEY] - hash_type = state_data["hash_type"] - return hash_type - - @staticmethod - def hash_id(salt, user_id, requester, state): - """ - Sets a user id to the internal_response, - in the format specified by the internal response - - :type salt: str - :type user_id: str - :type requester: str - :type state: satosa.state.State - :rtype: str - - :param salt: A salt string for the ID hashing - :param user_id: the user id - :param user_id_hash_type: Hashing type - :param state: The current state - :return: the internal_response containing the hashed user ID - """ - hash_type_to_format = { - NAMEID_FORMAT_TRANSIENT: "{id}{req}{time}", - NAMEID_FORMAT_PERSISTENT: "{id}{req}", - "pairwise": "{id}{req}", - "public": "{id}", - NAMEID_FORMAT_EMAILADDRESS: "{id}", - NAMEID_FORMAT_UNSPECIFIED: "{id}", - } - - format_args = { - "id": user_id, - "req": requester, - "time": datetime.datetime.utcnow().timestamp(), - } - - hash_type = UserIdHasher.hash_type(state) - try: - fmt = hash_type_to_format[hash_type] - except KeyError as e: - raise ValueError("Unknown hash type: {}".format(hash_type)) from e - else: - user_id = fmt.format(**format_args) - - hasher = ( - (lambda salt, value: value) - if hash_type - in [NAMEID_FORMAT_EMAILADDRESS, NAMEID_FORMAT_UNSPECIFIED] - else util.hash_data - ) - return hasher(salt, user_id) - - -def saml_name_id_format_to_hash_type(name_format): - """ - Translate pySAML2 name format to satosa format - - :type name_format: str - :rtype: satosa.internal_data.UserIdHashType - :param name_format: SAML2 name format - :return: satosa format - """ - msg = "saml_name_id_format_to_hash_type is deprecated and will be removed." - _warnings.warn(msg, DeprecationWarning) - - name_id_format_to_hash_type = { - NAMEID_FORMAT_TRANSIENT: UserIdHashType.transient, - NAMEID_FORMAT_PERSISTENT: UserIdHashType.persistent, - NAMEID_FORMAT_EMAILADDRESS: UserIdHashType.emailaddress, - NAMEID_FORMAT_UNSPECIFIED: UserIdHashType.unspecified, - } - - return name_id_format_to_hash_type.get( - name_format, UserIdHashType.transient - ) - - -def hash_type_to_saml_name_id_format(hash_type): - """ - Translate satosa format to pySAML2 name format - - :type hash_type: satosa.internal_data.UserIdHashType - :rtype: str - :param hash_type: satosa format - :return: pySAML2 name format - """ - msg = "hash_type_to_saml_name_id_format is deprecated and will be removed." - _warnings.warn(msg, DeprecationWarning) - - hash_type_to_name_id_format = { - UserIdHashType.transient: NAMEID_FORMAT_TRANSIENT, - UserIdHashType.persistent: NAMEID_FORMAT_PERSISTENT, - UserIdHashType.emailaddress: NAMEID_FORMAT_EMAILADDRESS, - UserIdHashType.unspecified: NAMEID_FORMAT_UNSPECIFIED, - } - - return hash_type_to_name_id_format.get(hash_type, NAMEID_FORMAT_PERSISTENT) - - -def oidc_subject_type_to_hash_type(subject_type): - msg = "oidc_subject_type_to_hash_type is deprecated and will be removed." - _warnings.warn(msg, DeprecationWarning) - - if subject_type == "public": - return UserIdHashType.public - - return UserIdHashType.pairwise - - -def hash_attributes(hash_attributes, internal_attributes, salt): - msg = ( - "'USER_ID_HASH_SALT' configuration option is deprecated." - " 'hash' configuration option is deprecated." - " Use the hasher microservice instead." - ) - _warnings.warn(msg, DeprecationWarning) - - # Hash all attributes specified in INTERNAL_ATTRIBUTES["hash"] - for attribute in hash_attributes: - # hash all attribute values individually - if attribute in internal_attributes: - hashed_values = [ - util.hash_data(salt, v) for v in internal_attributes[attribute] - ] - internal_attributes[attribute] = hashed_values diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index e93cf4998..1e0d20793 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -29,7 +29,6 @@ import satosa.logging_util as lu from satosa.internal import InternalData -from satosa.deprecated import oidc_subject_type_to_hash_type logger = logging.getLogger(__name__) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 6ce12a476..259a1728a 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -38,8 +38,6 @@ import satosa.logging_util as lu from satosa.internal import InternalData -from satosa.deprecated import saml_name_id_format_to_hash_type -from satosa.deprecated import hash_type_to_saml_name_id_format logger = logging.getLogger(__name__) diff --git a/src/satosa/internal_data.py b/src/satosa/internal_data.py deleted file mode 100644 index 7e3a8e89e..000000000 --- a/src/satosa/internal_data.py +++ /dev/null @@ -1,14 +0,0 @@ -import warnings as _warnings - -from satosa.internal import InternalData -from satosa.internal import AuthenticationInformation -from satosa.deprecated import UserIdHashType -from satosa.deprecated import UserIdHasher -from satosa.deprecated import InternalRequest -from satosa.deprecated import InternalResponse - - -_warnings.warn( - "internal_data is deprecated; use satosa.internal instead.", - DeprecationWarning, -) diff --git a/tests/satosa/test_base.py b/tests/satosa/test_base.py index 713160aca..0f2a35f50 100644 --- a/tests/satosa/test_base.py +++ b/tests/satosa/test_base.py @@ -1,10 +1,8 @@ -import copy from unittest.mock import Mock import pytest import satosa -from satosa import util from satosa.base import SATOSABase from satosa.internal import AuthenticationInformation from satosa.internal import InternalData @@ -39,24 +37,6 @@ def test_auth_resp_callback_func_user_id_from_attrs_is_used_to_override_user_id( expected_user_id = "user@example.com" assert internal_resp.subject_id == expected_user_id - def test_auth_resp_callback_func_hashes_all_specified_attributes(self, context, satosa_config): - satosa_config["INTERNAL_ATTRIBUTES"]["hash"] = ["user_id", "mail"] - base = SATOSABase(satosa_config) - - attributes = {"user_id": ["user"], "mail": ["user@example.com", "user@otherdomain.com"]} - internal_resp = InternalData(auth_info=AuthenticationInformation("", "", "")) - internal_resp.attributes = copy.copy(attributes) - internal_resp.subject_id = "test_user" - context.state[satosa.base.STATE_KEY] = {"requester": "test_requester"} - context.state[satosa.routing.STATE_KEY] = satosa_config["FRONTEND_MODULES"][0]["name"] - - base._auth_resp_callback_func(context, internal_resp) - for attr in satosa_config["INTERNAL_ATTRIBUTES"]["hash"]: - assert internal_resp.attributes[attr] == [ - util.hash_data(satosa_config.get("USER_ID_HASH_SALT", ""), v) - for v in attributes[attr] - ] - def test_auth_resp_callback_func_respects_user_id_to_attr(self, context, satosa_config): satosa_config["INTERNAL_ATTRIBUTES"]["user_id_to_attr"] = "user_id" base = SATOSABase(satosa_config) From 0f198742c7271c48d4ff80683ae4d91006193709 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 10 Mar 2020 08:39:30 +0200 Subject: [PATCH 130/401] Fix info_file logging handler example Signed-off-by: Ivan Kanakarakis --- example/proxy_conf.yaml.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/proxy_conf.yaml.example b/example/proxy_conf.yaml.example index 760714b00..d6937f594 100644 --- a/example/proxy_conf.yaml.example +++ b/example/proxy_conf.yaml.example @@ -47,7 +47,7 @@ LOGGING: level: ERROR formatter: simple info_file: - class: logging.FileHandler + class: logging.handlers.RotatingFileHandler filename: satosa-info.log encoding: utf8 maxBytes: 10485760 # 10MB From e48d9bb6ae633bd3c7f10b8e4bb428183e006ecc Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 13 Mar 2020 21:48:50 +0200 Subject: [PATCH 131/401] Remove deprecated internal data properties Signed-off-by: Ivan Kanakarakis --- src/satosa/internal.py | 45 +++--------------------------------------- 1 file changed, 3 insertions(+), 42 deletions(-) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index 2302a3da2..38b82acfb 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -109,13 +109,6 @@ class InternalData(_Datafy): A base class for the data carriers between frontends/backends """ - _DEPRECATED_TO_NEW_MEMBERS = { - "name_id": "subject_id", - "user_id": "subject_id", - "user_id_hash_type": "subject_type", - "approved_attributes": "attributes", - } - def __init__( self, auth_info=None, @@ -124,10 +117,6 @@ def __init__( subject_id=None, subject_type=None, attributes=None, - user_id=None, - user_id_hash_type=None, - name_id=None, - approved_attributes=None, *args, **kwargs, ): @@ -138,10 +127,6 @@ def __init__( :param subject_id: :param subject_type: :param attributes: - :param user_id: - :param user_id_hash_type: - :param name_id: - :param approved_attributes: :type auth_info: AuthenticationInformation :type requester: str @@ -149,10 +134,6 @@ def __init__( :type subject_id: str :type subject_type: str :type attributes: dict - :type user_id: str - :type user_id_hash_type: str - :type name_id: str - :type approved_attributes: dict """ super().__init__(self, *args, **kwargs) self.auth_info = ( @@ -166,26 +147,6 @@ def __init__( if requester_name is not None else [{"text": requester, "lang": "en"}] ) - self.subject_id = ( - subject_id - if subject_id is not None - else user_id - if user_id is not None - else name_id - if name_id is not None - else None - ) - self.subject_type = ( - subject_type - if subject_type is not None - else user_id_hash_type - if user_id_hash_type is not None - else None - ) - self.attributes = ( - attributes - if attributes is not None - else approved_attributes - if approved_attributes is not None - else {} - ) + self.subject_id = subject_id + self.subject_type = subject_type + self.attributes = attributes if attributes is not None else {} From fd50aa336865060f35a577096ea1ff5a3469e98d Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 13 Mar 2020 15:37:53 +0200 Subject: [PATCH 132/401] Add KEY_METADATA_STORE to store the saml2 metadata store KEY_METADATA_STORE will hold the metadata store for saml2 backends and frontends. Accessing it that through the Context object will allow one to peek into the metadata of the corresponding backend or frontend. Previously, KEY_BACKEND_METADATA_STORE was used for this reason, but was available only on the backend. It is now a deprecated alias. Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 2 +- src/satosa/context.py | 14 ++++++++++---- src/satosa/frontends/saml2.py | 1 + 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index c2e39f17a..13349ee37 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -343,7 +343,7 @@ def authn_response(self, context, binding): logger.debug(logline) raise SATOSAAuthenticationError(context.state, "State did not match relay state") - context.decorate(Context.KEY_BACKEND_METADATA_STORE, self.sp.metadata) + 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 diff --git a/src/satosa/context.py b/src/satosa/context.py index 2413624d2..196cb6f4d 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -1,3 +1,5 @@ +from warnings import warn as _warn + from satosa.exception import SATOSAError @@ -12,7 +14,7 @@ class Context(object): """ Holds methods for sharing proxy data through the current request """ - KEY_BACKEND_METADATA_STORE = 'metadata_store' + KEY_METADATA_STORE = 'metadata_store' KEY_TARGET_ENTITYID = 'target_entity_id' KEY_FORCE_AUTHN = 'force_authn' KEY_MEMORIZED_IDP = 'memorized_idp' @@ -28,9 +30,13 @@ def __init__(self): self.cookie = None self.state = None - def __repr__(self): - from pprint import pformat - return pformat(vars(self)) + @property + def KEY_BACKEND_METADATA_STORE(self): + msg = "'{old_key}' is deprecated; use '{new_key}' instead.".format( + old_key="KEY_BACKEND_METADATA_STORE", new_key="KEY_METADATA_STORE" + ) + _warn(msg, DeprecationWarning) + return Context.KEY_METADATA_STORE @property def path(self): diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 259a1728a..168dddc66 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -247,6 +247,7 @@ def _handle_authn_request(self, context, binding_in, idp): idp, idp_policy, requester, context.state ) + context.decorate(Context.KEY_METADATA_STORE, self.idp.metadata) return self.auth_req_callback_func(context, internal_req) def _get_approved_attributes(self, idp, idp_policy, sp_entity_id, state): From b96f6fad5f57380689d547d274ded644bb6d6e86 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 17 Mar 2020 23:09:07 +0200 Subject: [PATCH 133/401] Remove hardcoded OS Signed-off-by: Ivan Kanakarakis --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 77214ff2c..20fc9b290 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,3 @@ -dist: xenial addons: apt: packages: From cf81b9fadee841e4aa8eec7427e1937788705835 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 17 Mar 2020 23:22:27 +0200 Subject: [PATCH 134/401] Test on python 3.8 Signed-off-by: Ivan Kanakarakis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 20fc9b290..5552b3586 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,7 @@ jobs: include: - python: 3.6 - python: 3.7 + - python: 3.8 - python: pypy3 - stage: Deploy latest version From f3f2ae9e30a84de4181813e31fc2e1728427f552 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 17 Mar 2020 23:22:37 +0200 Subject: [PATCH 135/401] Test on python 3.9-dev Signed-off-by: Ivan Kanakarakis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 5552b3586..bfc190c9a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,7 @@ jobs: - python: 3.6 - python: 3.7 - python: 3.8 + - python: 3.9-dev - python: pypy3 - stage: Deploy latest version From c6bb74a22730bbda80f4123af9b3f6a7a74eac32 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 18 Mar 2020 16:23:26 +0200 Subject: [PATCH 136/401] Replace deprecated key skip_cleanup with cleanup jobs.include.deploy: deprecated key skip_cleanup (not supported in dpl v2, use cleanup) See, https://docs.travis-ci.com/user/deployment-v2#cleaning-up-the-git-working-directory > By default your Git working directory will not be cleaned up before the deploy step, so it might contain left over artifacts from previous steps. Signed-off-by: Ivan Kanakarakis --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index bfc190c9a..47e745bb7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,7 +38,6 @@ jobs: - provider: releases api_key: secure: EOM9qDlyGQrD6NXs8KKMNr2htFXU/H47tO051aA3RKWQrEk7paLXYTDSbQiEq3W9yLg+fifDb0qVqAcFhnV4OWf5ArP++khjaiKQCHYoTaoKIRrTch+12Unq22FEgNj0SYd3HX+CKkG2WpyMoBQAiChgaouDnYIPOvCoqfCxiJzj5e/l5Qomt31smUgZSYhqeDPvX0lN6LP47OLrzsEGDvVxz/fb+EMK3mkCppgPwsB2zy849dER7ofHD6uJiYhY3jP4oCHDBv6GdzqxgMIyDD4zJYh9qCfy1kAwOwc7CYInrELk8GK+YwLFRKMXdTMHu4nYUTgTAJeiXgX6n7oEUfvj4ip+UJ2MfsLdaX7MmgRb2sVStlYjqLWgVR1sZThKmDTH1SzztmZFcNjXBg5Yvs8zPKe+955AoL/EG+pu0ZapFTIrsW7Wq7dCSiXhUkdJ3E/3RZqawqDhTHmrQEiG2j4N2B90SeK7TcXncr7TxaQMwjRpUpkDHmNQPMW3TEHyjEVlTKjzeCmvJEzu/n2oDR12kD6FL5oh4lkMIzIIQqVtp09cB9IJXEO0ww3elIbjZPhMASOocwvoFWM/m9ZTH8i2NjulWuIsnPj9AMmQ8hryR+nqSmkK942D+/9W0/ZHX4rzZ4/6hpEwAi+2+BNnS9yPk1zP4LNMy5FA4NwCV14= - skip_cleanup: true on: repo: IdentityPython/SATOSA tags: true From 22bff16fba357ee4b870b083ea2f8eefc88a63b7 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 17 Mar 2020 23:21:54 +0200 Subject: [PATCH 137/401] Separate stages for GH, PyPI and DockerHub Signed-off-by: Ivan Kanakarakis --- .travis.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 47e745bb7..ab49d92fd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,8 +23,9 @@ jobs: - python: 3.9-dev - python: pypy3 - - stage: Deploy latest version + - stage: Deploy latest image on DockerHub script: skip + if: branch = master deploy: - provider: script script: scripts/travis_create_docker_image_branch.sh @@ -32,7 +33,8 @@ jobs: repo: IdentityPython/SATOSA branch: master - - stage: Deploy new release + - stage: Deploy new release on GitHub + if: tag IS present script: skip deploy: - provider: releases @@ -41,6 +43,11 @@ jobs: on: repo: IdentityPython/SATOSA tags: true + + - stage: Deploy new release on PyPI + if: tag IS present + script: skip + deploy: - provider: pypi distributions: sdist bdist_wheel user: Lundberg @@ -49,12 +56,16 @@ jobs: on: repo: IdentityPython/SATOSA tags: true + + - stage: Deploy new release on DockerHub + if: tag IS present + script: skip + deploy: - provider: script script: scripts/travis_create_docker_image_tag.sh on: repo: IdentityPython/SATOSA tags: true - if: tag IS present env: global: From 5fcbc753e54a0029ead24cd6041b490534ade042 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 18 Mar 2020 16:11:42 +0200 Subject: [PATCH 138/401] Update CI process for docker images and tags References: https://docs.travis-ci.com/user/build-stages/ https://docs.travis-ci.com/user/build-stages/share-docker-image/ https://docs.travis-ci.com/user/conditions-v1 https://docs.travis-ci.com/user/conditional-builds-stages-jobs/ Signed-off-by: Ivan Kanakarakis --- .travis.yml | 51 +++++++++++++------- scripts/travis_create_docker_image_branch.sh | 9 ---- scripts/travis_create_docker_image_tag.sh | 15 ------ 3 files changed, 33 insertions(+), 42 deletions(-) delete mode 100755 scripts/travis_create_docker_image_branch.sh delete mode 100755 scripts/travis_create_docker_image_tag.sh diff --git a/.travis.yml b/.travis.yml index ab49d92fd..c99756d06 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,15 +23,39 @@ jobs: - python: 3.9-dev - python: pypy3 - - stage: Deploy latest image on DockerHub - script: skip + - stage: Build docker image by commit and deploy on DockerHub + script: + - set -e + - docker build -f Dockerfile -t "${REPO}:${TRAVIS_COMMIT}" . + - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - docker push "$REPO" + + - stage: Tag docker image with branch + if: branch IS present + script: + - set -e + - docker pull "${REPO}:${TRAVIS_COMMIT}" + - docker tag "${REPO}:${TRAVIS_COMMIT}" "${REPO}:latest" + - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - docker push "$REPO" + + - stage: Tag docker image as latest if: branch = master - deploy: - - provider: script - script: scripts/travis_create_docker_image_branch.sh - on: - repo: IdentityPython/SATOSA - branch: master + script: + - set -e + - docker pull "${REPO}:${TRAVIS_COMMIT}" + - docker tag "${REPO}:${TRAVIS_COMMIT}" "${REPO}:latest" + - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - docker push "$REPO" + + - stage: Tag docker image with git-tag + if: tag IS present + script: + - set -e + - docker pull "${REPO}:${TRAVIS_COMMIT}" + - docker tag "${REPO}:${TRAVIS_COMMIT}" "${REPO}:${TRAVIS_TAG}" + - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - docker push "$REPO" - stage: Deploy new release on GitHub if: tag IS present @@ -57,17 +81,8 @@ jobs: repo: IdentityPython/SATOSA tags: true - - stage: Deploy new release on DockerHub - if: tag IS present - script: skip - deploy: - - provider: script - script: scripts/travis_create_docker_image_tag.sh - on: - repo: IdentityPython/SATOSA - tags: true - env: global: - secure: ymdbaVVKJFq193xn+pr7sRyjkcOBtpm6eu+A9QsdtzO6vhaj+MhFfsjWAJbGiaSvX691bLd+9kFqH76HViN1LbmkCujBm2+4k0DBSakb70T/81UNNpYGy4iIYzCKaWPPSwvFGfRjNY14RILEpOI8NCkJuDMuO7CiGkwOtmlOdP/tYdV9T3p36Hgpwa/0U5kIahqwnbBKiwjGGhI8YA4Ik01P4biEv3Fr++jS4dhzMe+hYjWDXW+bksf9OikbtJkPzHlZxCDgFH4yNY1TH6P3X/B8NLTrvpNZOj2GgQoZBDrTEM+RLdaLQ8EYcrJaEaOZs65Jicpw5Ycz8DHUuBXwlSiG1g/VJlzxYchGxnLguVyEELEm7p7vhDFYNOROL3J4PpY8E1+L834xzmhCqbHM2kHB2WeiIob0j1Hq7U1802tFuM+tu8P4gdEyGxstQaIehiTI/VQEJm+sKB1W5xtDQokrnMyiQfJy4K7T4ZrONV/gVhb85ayS6eF/Xu1vr/5s/fWyQOxNKvoeEiO6VVoLTWNPEysTewLFc8o7HcE/Qnv/67IwuK/vx0ZlESbNCRgTfqyWpn5vybyWmgo9aUC51hDiVQtZfVeaoF/Xtg2yxVn/4C1aPybpA2Oacll8LjyYwyoCeH3naD0j9Msy4izny2PF7MTT4iNbtwhRoAAqXic= - secure: loJ+Bfind3tbEVrWqEalZT5bMqGFrMewo3jDwH9iJEw28tl+PasTCvCOJRsOomtdMp2QZh8e5wwnL1m7mkHWZaBDMxAg2mXlEv2W817SyAKkgFVnjXr8FJK4kjGAA5l2WXWKo7HKs2lOygZaDxj67i4htvg6cIxVf3dnI+MHpN5CONBfF6cXkFGMZoW+uc2diApyvIVCzte0JZkp6ZepWiyjelPl38pgWlD9elJEUaut0qKGZHtsRnLgTOzbBl49FV4lzCqt7wBnnwwQpTtvEyRW47O/VMYORAFFXpgUDPejE37+bf1wS6hlr0vSHFSUKILQWUH0l09+BPrxpoRj5SYkFD18xvqlWDNrNoANSMgRm/8cL1ucd7T5N03lKtNpaKT2ejHPj6Hu86mXFvcxcZnIcH7ppmXjZU2xfI2ytmmqxXysYeiCc6RgClmFBf3lnZz7iaHVrL8tU1x+eDzEQKvDbYHQnO9+4xXY37PH4ViJJEDoLq3NGhKxbDJ4oMgtz0mrjdWm8a1nWXIm8QTs2+oIhf+HrCpdqE8FfKnI7OyM8C+cwraApY77cZ9xfBqJGDQIgX3c+syB1ufVxY/DPDOXTysRUUHyWVgJeaL8EJEiMVnZMoGliY7QtnBznOglxynekIIaaZ5FMfh8hwA0pQ5idruqrtzVkBQoq8CdHfk= + - REPO: "satosa/satosa" diff --git a/scripts/travis_create_docker_image_branch.sh b/scripts/travis_create_docker_image_branch.sh deleted file mode 100755 index 13da052b4..000000000 --- a/scripts/travis_create_docker_image_branch.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -set -e - -docker login -u $DOCKER_USER -p $DOCKER_PASS -export REPO=satosa/satosa -export TAG=`if [ "$TRAVIS_BRANCH" == "master" ]; then echo "latest"; else echo $TRAVIS_BRANCH ; fi` -docker build -f Dockerfile -t $REPO:$TAG . -docker push $REPO diff --git a/scripts/travis_create_docker_image_tag.sh b/scripts/travis_create_docker_image_tag.sh deleted file mode 100755 index 504d2d41f..000000000 --- a/scripts/travis_create_docker_image_tag.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -set -e - -# Travis does not know which branch the repo is on when building a tag -# Make sure to only call this script when building tags - -docker login -u $DOCKER_USER -p $DOCKER_PASS -export REPO=satosa/satosa -export TAG=latest -docker build -f Dockerfile -t $REPO:$TAG . -if [ -n "$TRAVIS_TAG" ]; then - docker tag $REPO:$TAG $REPO:$TRAVIS_TAG -fi -docker push $REPO From e073d1ad2229ceb114055b04e8643217dff83535 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 20 Mar 2020 19:34:51 +0200 Subject: [PATCH 139/401] Remove unused start_proxy script Signed-off-by: Ivan Kanakarakis --- scripts/start_proxy.py | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 scripts/start_proxy.py diff --git a/scripts/start_proxy.py b/scripts/start_proxy.py deleted file mode 100644 index 1d9af1162..000000000 --- a/scripts/start_proxy.py +++ /dev/null @@ -1,12 +0,0 @@ -import re -import sys - -from gunicorn.app.wsgiapp import run - -print('\n'.join(sys.path)) -# use this entrypoint to start the proxy from the IDE - -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(run()) - From 6fcba5220074a75ac076f4d3c4f44e711547f9d4 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 20 Mar 2020 19:27:41 +0200 Subject: [PATCH 140/401] Add stage to display env-var information References: https://docs.travis-ci.com/user/environment-variables/ https://config.travis-ci.com/ref/env Signed-off-by: Ivan Kanakarakis --- .travis.yml | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index c99756d06..31685b2d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,39 +23,60 @@ jobs: - python: 3.9-dev - python: pypy3 + - stage: Expose env-var information + script: | + cat < Date: Wed, 18 Mar 2020 19:48:51 +0200 Subject: [PATCH 141/401] Combine env-vars and use them References: https://docs.travis-ci.com/user/environment-variables/ https://config.travis-ci.com/ref/env Signed-off-by: Ivan Kanakarakis --- .travis.yml | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index 31685b2d7..0a8b8bcc4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,39 +42,43 @@ jobs: TRAVIS_PULL_REQUEST_SLUG: $TRAVIS_PULL_REQUEST_SLUG DOCKER_REPO: $DOCKER_REPO + DOCKER_TAG_COMMIT: $DOCKER_TAG_COMMIT + DOCKER_TAG_BRANCH: $DOCKER_TAG_BRANCH + DOCKER_TAG_GITTAG: $DOCKER_TAG_GITTAG + DOCKER_TAG_LATEST: $DOCKER_TAG_LATEST EOF - stage: Build docker image by commit and deploy on DockerHub script: - set -e - - docker build -f Dockerfile -t "${DOCKER_REPO}:${TRAVIS_COMMIT}" . + - docker build -f Dockerfile -t "$DOCKER_TAG_COMMIT" . - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" - docker push "$DOCKER_REPO" - - stage: Tag docker image with branch + - stage: Tag docker image with branch name if: branch IS present script: - set -e - - docker pull "${DOCKER_REPO}:${TRAVIS_COMMIT}" - - docker tag "${DOCKER_REPO}:${TRAVIS_COMMIT}" "${DOCKER_REPO}:latest" + - docker pull "$DOCKER_TAG_COMMIT" + - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_BRANCH" - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" - docker push "$DOCKER_REPO" - - stage: Tag docker image as latest - if: branch = master + - stage: Tag docker image with git-tag + if: tag IS present script: - set -e - - docker pull "${DOCKER_REPO}:${TRAVIS_COMMIT}" - - docker tag "${DOCKER_REPO}:${TRAVIS_COMMIT}" "${DOCKER_REPO}:latest" + - docker pull "$DOCKER_TAG_COMMIT" + - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_GITTAG" - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" - docker push "$DOCKER_REPO" - - stage: Tag docker image with git-tag - if: tag IS present + - stage: Tag docker image as latest + if: branch = master script: - set -e - - docker pull "${DOCKER_REPO}:${TRAVIS_COMMIT}" - - docker tag "${DOCKER_REPO}:${TRAVIS_COMMIT}" "${DOCKER_REPO}:${TRAVIS_TAG}" + - docker pull "$DOCKER_TAG_COMMIT" + - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_LATEST" - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" - docker push "$DOCKER_REPO" @@ -107,3 +111,7 @@ env: - secure: ymdbaVVKJFq193xn+pr7sRyjkcOBtpm6eu+A9QsdtzO6vhaj+MhFfsjWAJbGiaSvX691bLd+9kFqH76HViN1LbmkCujBm2+4k0DBSakb70T/81UNNpYGy4iIYzCKaWPPSwvFGfRjNY14RILEpOI8NCkJuDMuO7CiGkwOtmlOdP/tYdV9T3p36Hgpwa/0U5kIahqwnbBKiwjGGhI8YA4Ik01P4biEv3Fr++jS4dhzMe+hYjWDXW+bksf9OikbtJkPzHlZxCDgFH4yNY1TH6P3X/B8NLTrvpNZOj2GgQoZBDrTEM+RLdaLQ8EYcrJaEaOZs65Jicpw5Ycz8DHUuBXwlSiG1g/VJlzxYchGxnLguVyEELEm7p7vhDFYNOROL3J4PpY8E1+L834xzmhCqbHM2kHB2WeiIob0j1Hq7U1802tFuM+tu8P4gdEyGxstQaIehiTI/VQEJm+sKB1W5xtDQokrnMyiQfJy4K7T4ZrONV/gVhb85ayS6eF/Xu1vr/5s/fWyQOxNKvoeEiO6VVoLTWNPEysTewLFc8o7HcE/Qnv/67IwuK/vx0ZlESbNCRgTfqyWpn5vybyWmgo9aUC51hDiVQtZfVeaoF/Xtg2yxVn/4C1aPybpA2Oacll8LjyYwyoCeH3naD0j9Msy4izny2PF7MTT4iNbtwhRoAAqXic= - secure: loJ+Bfind3tbEVrWqEalZT5bMqGFrMewo3jDwH9iJEw28tl+PasTCvCOJRsOomtdMp2QZh8e5wwnL1m7mkHWZaBDMxAg2mXlEv2W817SyAKkgFVnjXr8FJK4kjGAA5l2WXWKo7HKs2lOygZaDxj67i4htvg6cIxVf3dnI+MHpN5CONBfF6cXkFGMZoW+uc2diApyvIVCzte0JZkp6ZepWiyjelPl38pgWlD9elJEUaut0qKGZHtsRnLgTOzbBl49FV4lzCqt7wBnnwwQpTtvEyRW47O/VMYORAFFXpgUDPejE37+bf1wS6hlr0vSHFSUKILQWUH0l09+BPrxpoRj5SYkFD18xvqlWDNrNoANSMgRm/8cL1ucd7T5N03lKtNpaKT2ejHPj6Hu86mXFvcxcZnIcH7ppmXjZU2xfI2ytmmqxXysYeiCc6RgClmFBf3lnZz7iaHVrL8tU1x+eDzEQKvDbYHQnO9+4xXY37PH4ViJJEDoLq3NGhKxbDJ4oMgtz0mrjdWm8a1nWXIm8QTs2+oIhf+HrCpdqE8FfKnI7OyM8C+cwraApY77cZ9xfBqJGDQIgX3c+syB1ufVxY/DPDOXTysRUUHyWVgJeaL8EJEiMVnZMoGliY7QtnBznOglxynekIIaaZ5FMfh8hwA0pQ5idruqrtzVkBQoq8CdHfk= - DOCKER_REPO: "satosa/satosa" + - DOCKER_TAG_COMMIT: "${DOCKER_REPO}:${TRAVIS_COMMIT}" + - DOCKER_TAG_BRANCH: "${DOCKER_REPO}:${TRAVIS_BRANCH}" + - DOCKER_TAG_GITTAG: "${DOCKER_REPO}:${TRAVIS_TAG:-NO_TAG}" + - DOCKER_TAG_LATEST: "${DOCKER_REPO}:latest" From be62144cc2f9713c98ef3110af7e8e6a426c50c7 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 19 Mar 2020 14:21:17 +0200 Subject: [PATCH 142/401] Tag by pull request number using the PR prefix Signed-off-by: Ivan Kanakarakis --- .travis.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.travis.yml b/.travis.yml index 0a8b8bcc4..898449c55 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,6 +44,7 @@ jobs: DOCKER_REPO: $DOCKER_REPO DOCKER_TAG_COMMIT: $DOCKER_TAG_COMMIT DOCKER_TAG_BRANCH: $DOCKER_TAG_BRANCH + DOCKER_TAG_PR_NUM: $DOCKER_TAG_PR_NUM DOCKER_TAG_GITTAG: $DOCKER_TAG_GITTAG DOCKER_TAG_LATEST: $DOCKER_TAG_LATEST EOF @@ -64,6 +65,15 @@ jobs: - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" - docker push "$DOCKER_REPO" + - stage: Tag docker image with pull request number + if: type = pull_request + script: + - set -e + - docker pull "$DOCKER_TAG_COMMIT" + - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_NUM" + - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - docker push "$DOCKER_REPO" + - stage: Tag docker image with git-tag if: tag IS present script: @@ -113,5 +123,6 @@ env: - DOCKER_REPO: "satosa/satosa" - DOCKER_TAG_COMMIT: "${DOCKER_REPO}:${TRAVIS_COMMIT}" - DOCKER_TAG_BRANCH: "${DOCKER_REPO}:${TRAVIS_BRANCH}" + - DOCKER_TAG_PR_NUM: "${DOCKER_REPO}:PR${TRAVIS_PULL_REQUEST}" - DOCKER_TAG_GITTAG: "${DOCKER_REPO}:${TRAVIS_TAG:-NO_TAG}" - DOCKER_TAG_LATEST: "${DOCKER_REPO}:latest" From 43541de63e2cd96ff0fd01cf9d35f6b762a60270 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 20 Mar 2020 20:21:25 +0200 Subject: [PATCH 143/401] Change docker login command to read password from stdin Signed-off-by: Ivan Kanakarakis --- .travis.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 898449c55..64135a283 100644 --- a/.travis.yml +++ b/.travis.yml @@ -53,7 +53,7 @@ jobs: script: - set -e - docker build -f Dockerfile -t "$DOCKER_TAG_COMMIT" . - - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_REPO" - stage: Tag docker image with branch name @@ -62,7 +62,7 @@ jobs: - set -e - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_BRANCH" - - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_REPO" - stage: Tag docker image with pull request number @@ -71,7 +71,7 @@ jobs: - set -e - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_NUM" - - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_REPO" - stage: Tag docker image with git-tag @@ -80,7 +80,7 @@ jobs: - set -e - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_GITTAG" - - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_REPO" - stage: Tag docker image as latest @@ -89,7 +89,7 @@ jobs: - set -e - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_LATEST" - - docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASS" + - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_REPO" - stage: Deploy new release on GitHub From a860cebd1fcb7c7389f0f808c6a05c96b2ba8c05 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 20 Mar 2020 21:04:12 +0200 Subject: [PATCH 144/401] Remove secure vars Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 64135a283..e45a8b2bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -118,8 +118,6 @@ jobs: env: global: - - secure: ymdbaVVKJFq193xn+pr7sRyjkcOBtpm6eu+A9QsdtzO6vhaj+MhFfsjWAJbGiaSvX691bLd+9kFqH76HViN1LbmkCujBm2+4k0DBSakb70T/81UNNpYGy4iIYzCKaWPPSwvFGfRjNY14RILEpOI8NCkJuDMuO7CiGkwOtmlOdP/tYdV9T3p36Hgpwa/0U5kIahqwnbBKiwjGGhI8YA4Ik01P4biEv3Fr++jS4dhzMe+hYjWDXW+bksf9OikbtJkPzHlZxCDgFH4yNY1TH6P3X/B8NLTrvpNZOj2GgQoZBDrTEM+RLdaLQ8EYcrJaEaOZs65Jicpw5Ycz8DHUuBXwlSiG1g/VJlzxYchGxnLguVyEELEm7p7vhDFYNOROL3J4PpY8E1+L834xzmhCqbHM2kHB2WeiIob0j1Hq7U1802tFuM+tu8P4gdEyGxstQaIehiTI/VQEJm+sKB1W5xtDQokrnMyiQfJy4K7T4ZrONV/gVhb85ayS6eF/Xu1vr/5s/fWyQOxNKvoeEiO6VVoLTWNPEysTewLFc8o7HcE/Qnv/67IwuK/vx0ZlESbNCRgTfqyWpn5vybyWmgo9aUC51hDiVQtZfVeaoF/Xtg2yxVn/4C1aPybpA2Oacll8LjyYwyoCeH3naD0j9Msy4izny2PF7MTT4iNbtwhRoAAqXic= - - secure: loJ+Bfind3tbEVrWqEalZT5bMqGFrMewo3jDwH9iJEw28tl+PasTCvCOJRsOomtdMp2QZh8e5wwnL1m7mkHWZaBDMxAg2mXlEv2W817SyAKkgFVnjXr8FJK4kjGAA5l2WXWKo7HKs2lOygZaDxj67i4htvg6cIxVf3dnI+MHpN5CONBfF6cXkFGMZoW+uc2diApyvIVCzte0JZkp6ZepWiyjelPl38pgWlD9elJEUaut0qKGZHtsRnLgTOzbBl49FV4lzCqt7wBnnwwQpTtvEyRW47O/VMYORAFFXpgUDPejE37+bf1wS6hlr0vSHFSUKILQWUH0l09+BPrxpoRj5SYkFD18xvqlWDNrNoANSMgRm/8cL1ucd7T5N03lKtNpaKT2ejHPj6Hu86mXFvcxcZnIcH7ppmXjZU2xfI2ytmmqxXysYeiCc6RgClmFBf3lnZz7iaHVrL8tU1x+eDzEQKvDbYHQnO9+4xXY37PH4ViJJEDoLq3NGhKxbDJ4oMgtz0mrjdWm8a1nWXIm8QTs2+oIhf+HrCpdqE8FfKnI7OyM8C+cwraApY77cZ9xfBqJGDQIgX3c+syB1ufVxY/DPDOXTysRUUHyWVgJeaL8EJEiMVnZMoGliY7QtnBznOglxynekIIaaZ5FMfh8hwA0pQ5idruqrtzVkBQoq8CdHfk= - DOCKER_REPO: "satosa/satosa" - DOCKER_TAG_COMMIT: "${DOCKER_REPO}:${TRAVIS_COMMIT}" - DOCKER_TAG_BRANCH: "${DOCKER_REPO}:${TRAVIS_BRANCH}" From 89a2e734a50a37bcd6f5e24aaaeb648b3614fb28 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 20 Mar 2020 21:49:04 +0200 Subject: [PATCH 145/401] Set docker repository to idpy/satosa Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e45a8b2bd..ce110b9a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -118,7 +118,7 @@ jobs: env: global: - - DOCKER_REPO: "satosa/satosa" + - DOCKER_REPO: "idpy/satosa" - DOCKER_TAG_COMMIT: "${DOCKER_REPO}:${TRAVIS_COMMIT}" - DOCKER_TAG_BRANCH: "${DOCKER_REPO}:${TRAVIS_BRANCH}" - DOCKER_TAG_PR_NUM: "${DOCKER_REPO}:PR${TRAVIS_PULL_REQUEST}" From 16895f84eb30b815c6d943bb01241d9f8c0cb3cb Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 23 Mar 2020 17:06:23 +0200 Subject: [PATCH 146/401] Revert "Set docker repository to idpy/satosa" Push to satosa/satosa. idpy/satosa may be removed or turned into a collection of other repos like a mirror. This reverts commit 89a2e734a50a37bcd6f5e24aaaeb648b3614fb28. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ce110b9a0..e45a8b2bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -118,7 +118,7 @@ jobs: env: global: - - DOCKER_REPO: "idpy/satosa" + - DOCKER_REPO: "satosa/satosa" - DOCKER_TAG_COMMIT: "${DOCKER_REPO}:${TRAVIS_COMMIT}" - DOCKER_TAG_BRANCH: "${DOCKER_REPO}:${TRAVIS_BRANCH}" - DOCKER_TAG_PR_NUM: "${DOCKER_REPO}:PR${TRAVIS_PULL_REQUEST}" From dbb10c03e642ec5a87a120554530f67bd3ed182d Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 23 Mar 2020 17:07:57 +0200 Subject: [PATCH 147/401] Push docker images specifying their tag Signed-off-by: Ivan Kanakarakis --- .travis.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index e45a8b2bd..25f1ba472 100644 --- a/.travis.yml +++ b/.travis.yml @@ -54,7 +54,7 @@ jobs: - set -e - docker build -f Dockerfile -t "$DOCKER_TAG_COMMIT" . - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - - docker push "$DOCKER_REPO" + - docker push "$DOCKER_TAG_COMMIT" - stage: Tag docker image with branch name if: branch IS present @@ -63,7 +63,7 @@ jobs: - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_BRANCH" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - - docker push "$DOCKER_REPO" + - docker push "$DOCKER_TAG_BRANCH" - stage: Tag docker image with pull request number if: type = pull_request @@ -72,7 +72,7 @@ jobs: - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_NUM" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - - docker push "$DOCKER_REPO" + - docker push "$DOCKER_TAG_PR_NUM" - stage: Tag docker image with git-tag if: tag IS present @@ -81,7 +81,7 @@ jobs: - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_GITTAG" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - - docker push "$DOCKER_REPO" + - docker push "$DOCKER_TAG_GITTAG" - stage: Tag docker image as latest if: branch = master @@ -90,7 +90,7 @@ jobs: - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_LATEST" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - - docker push "$DOCKER_REPO" + - docker push "$DOCKER_TAG_LATEST" - stage: Deploy new release on GitHub if: tag IS present From 05558d496b37e5168fa4c115a7a666f97bec40be Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 23 Mar 2020 17:47:00 +0200 Subject: [PATCH 148/401] Add restrictions by setting conditions on docker builds Signed-off-by: Ivan Kanakarakis --- .travis.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 25f1ba472..304a9ae85 100644 --- a/.travis.yml +++ b/.travis.yml @@ -57,7 +57,7 @@ jobs: - docker push "$DOCKER_TAG_COMMIT" - stage: Tag docker image with branch name - if: branch IS present + if: type = push AND branch IS present script: - set -e - docker pull "$DOCKER_TAG_COMMIT" @@ -75,7 +75,7 @@ jobs: - docker push "$DOCKER_TAG_PR_NUM" - stage: Tag docker image with git-tag - if: tag IS present + if: type = push AND tag IS present script: - set -e - docker pull "$DOCKER_TAG_COMMIT" @@ -84,7 +84,7 @@ jobs: - docker push "$DOCKER_TAG_GITTAG" - stage: Tag docker image as latest - if: branch = master + if: type = push AND branch = master script: - set -e - docker pull "$DOCKER_TAG_COMMIT" @@ -93,7 +93,7 @@ jobs: - docker push "$DOCKER_TAG_LATEST" - stage: Deploy new release on GitHub - if: tag IS present + if: type = push AND branch = master AND tag IS present script: skip deploy: - provider: releases @@ -104,7 +104,7 @@ jobs: tags: true - stage: Deploy new release on PyPI - if: tag IS present + if: type = push AND branch = master AND tag IS present script: skip deploy: - provider: pypi From 6eb73036e9ea354e0b712048eb3273f37ef149d6 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 23 Mar 2020 17:47:20 +0200 Subject: [PATCH 149/401] Tag docker images by pull-request branch name Signed-off-by: Ivan Kanakarakis --- .travis.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.travis.yml b/.travis.yml index 304a9ae85..c2467c038 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,6 +44,7 @@ jobs: DOCKER_REPO: $DOCKER_REPO DOCKER_TAG_COMMIT: $DOCKER_TAG_COMMIT DOCKER_TAG_BRANCH: $DOCKER_TAG_BRANCH + DOCKER_TAG_PR_BRANCH: $DOCKER_TAG_PR_BRANCH DOCKER_TAG_PR_NUM: $DOCKER_TAG_PR_NUM DOCKER_TAG_GITTAG: $DOCKER_TAG_GITTAG DOCKER_TAG_LATEST: $DOCKER_TAG_LATEST @@ -65,6 +66,15 @@ jobs: - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_TAG_BRANCH" + - stage: Tag docker image with pull-request branch name + if: type = pull_request AND head_branch IS present + script: + - set -e + - docker pull "$DOCKER_TAG_COMMIT" + - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_BRANCH" + - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin + - docker push "$DOCKER_TAG_PR_BRANCH" + - stage: Tag docker image with pull request number if: type = pull_request script: @@ -121,6 +131,7 @@ env: - DOCKER_REPO: "satosa/satosa" - DOCKER_TAG_COMMIT: "${DOCKER_REPO}:${TRAVIS_COMMIT}" - DOCKER_TAG_BRANCH: "${DOCKER_REPO}:${TRAVIS_BRANCH}" + - DOCKER_TAG_PR_BRANCH: "${DOCKER_REPO}:PR${TRAVIS_PULL_REQUEST_BRANCH}" - DOCKER_TAG_PR_NUM: "${DOCKER_REPO}:PR${TRAVIS_PULL_REQUEST}" - DOCKER_TAG_GITTAG: "${DOCKER_REPO}:${TRAVIS_TAG:-NO_TAG}" - DOCKER_TAG_LATEST: "${DOCKER_REPO}:latest" From 0cef2eb28e62a26340725524b9698931fdb01cf4 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 23 Mar 2020 17:51:01 +0200 Subject: [PATCH 150/401] Fix stage name Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c2467c038..1a9ddf3f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -75,7 +75,7 @@ jobs: - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_TAG_PR_BRANCH" - - stage: Tag docker image with pull request number + - stage: Tag docker image with pull-request number if: type = pull_request script: - set -e From bd9f71b16dd23082e185b1927e7878eba62b94fc Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 23 Mar 2020 18:14:11 +0200 Subject: [PATCH 151/401] Expose more env-vars Signed-off-by: Ivan Kanakarakis --- .travis.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 1a9ddf3f2..6e8d3cf3e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,15 +27,27 @@ jobs: script: | cat < Date: Mon, 23 Mar 2020 20:09:37 +0200 Subject: [PATCH 152/401] Tag pull-request docker-image only when secure env-vars are present Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 6e8d3cf3e..70c8f4f02 100644 --- a/.travis.yml +++ b/.travis.yml @@ -82,6 +82,7 @@ jobs: if: type = pull_request AND head_branch IS present script: - set -e + - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_BRANCH" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -91,6 +92,7 @@ jobs: if: type = pull_request script: - set -e + - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_NUM" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin From 753c59f113d5f4f45bcbd83f6488149f3f1b64dc Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 24 Mar 2020 18:18:38 +0200 Subject: [PATCH 153/401] Do not run the install step on build and deploy stages Signed-off-by: Ivan Kanakarakis --- .travis.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 70c8f4f02..65a8d75e8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,13 @@ -addons: - apt: - packages: - - xmlsec1 services: - docker - mongodb language: python + +before_install: + - sudo apt-get install -y xmlsec1 + install: - pip install tox - pip install tox-travis @@ -63,6 +63,8 @@ jobs: EOF - stage: Build docker image by commit and deploy on DockerHub + before_install: skip + install: skip script: - set -e - docker build -f Dockerfile -t "$DOCKER_TAG_COMMIT" . @@ -71,6 +73,8 @@ jobs: - stage: Tag docker image with branch name if: type = push AND branch IS present + before_install: skip + install: skip script: - set -e - docker pull "$DOCKER_TAG_COMMIT" @@ -80,6 +84,8 @@ jobs: - stage: Tag docker image with pull-request branch name if: type = pull_request AND head_branch IS present + before_install: skip + install: skip script: - set -e - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 @@ -90,6 +96,8 @@ jobs: - stage: Tag docker image with pull-request number if: type = pull_request + before_install: skip + install: skip script: - set -e - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 @@ -100,6 +108,8 @@ jobs: - stage: Tag docker image with git-tag if: type = push AND tag IS present + before_install: skip + install: skip script: - set -e - docker pull "$DOCKER_TAG_COMMIT" @@ -109,6 +119,8 @@ jobs: - stage: Tag docker image as latest if: type = push AND branch = master + before_install: skip + install: skip script: - set -e - docker pull "$DOCKER_TAG_COMMIT" @@ -118,6 +130,8 @@ jobs: - stage: Deploy new release on GitHub if: type = push AND branch = master AND tag IS present + before_install: skip + install: skip script: skip deploy: - provider: releases @@ -129,6 +143,8 @@ jobs: - stage: Deploy new release on PyPI if: type = push AND branch = master AND tag IS present + before_install: skip + install: skip script: skip deploy: - provider: pypi From 94f5653c491ae4342339878f015eb8fdb869cf74 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 24 Mar 2020 18:51:24 +0200 Subject: [PATCH 154/401] Add assumed and unaliased keys Signed-off-by: Ivan Kanakarakis --- .travis.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 65a8d75e8..185d38196 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,11 @@ +os: linux +dist: xenial +language: python services: - docker - mongodb -language: python - before_install: - sudo apt-get install -y xmlsec1 @@ -135,7 +136,7 @@ jobs: script: skip deploy: - provider: releases - api_key: + token: secure: EOM9qDlyGQrD6NXs8KKMNr2htFXU/H47tO051aA3RKWQrEk7paLXYTDSbQiEq3W9yLg+fifDb0qVqAcFhnV4OWf5ArP++khjaiKQCHYoTaoKIRrTch+12Unq22FEgNj0SYd3HX+CKkG2WpyMoBQAiChgaouDnYIPOvCoqfCxiJzj5e/l5Qomt31smUgZSYhqeDPvX0lN6LP47OLrzsEGDvVxz/fb+EMK3mkCppgPwsB2zy849dER7ofHD6uJiYhY3jP4oCHDBv6GdzqxgMIyDD4zJYh9qCfy1kAwOwc7CYInrELk8GK+YwLFRKMXdTMHu4nYUTgTAJeiXgX6n7oEUfvj4ip+UJ2MfsLdaX7MmgRb2sVStlYjqLWgVR1sZThKmDTH1SzztmZFcNjXBg5Yvs8zPKe+955AoL/EG+pu0ZapFTIrsW7Wq7dCSiXhUkdJ3E/3RZqawqDhTHmrQEiG2j4N2B90SeK7TcXncr7TxaQMwjRpUpkDHmNQPMW3TEHyjEVlTKjzeCmvJEzu/n2oDR12kD6FL5oh4lkMIzIIQqVtp09cB9IJXEO0ww3elIbjZPhMASOocwvoFWM/m9ZTH8i2NjulWuIsnPj9AMmQ8hryR+nqSmkK942D+/9W0/ZHX4rzZ4/6hpEwAi+2+BNnS9yPk1zP4LNMy5FA4NwCV14= on: repo: IdentityPython/SATOSA @@ -149,7 +150,7 @@ jobs: deploy: - provider: pypi distributions: sdist bdist_wheel - user: Lundberg + username: Lundberg password: secure: NwkpOakaeJjErjTF4Y5MWeHzMvkxYZqrBFdRkzfenVfkWsomuyy553A691d3lc1+oREsh1fJJLjpZQYxTLUFIHOUmt/9zr02rFfguzj7hEYfWF8wHBXG6YSWv6T3aCA4RTMXvvzv9cHf1zfxh0fS7kgc+NRMAnd01diVLfYpBciLgmQ31J4mlwShp8yBQUoRBIvzSdzrgjr0TzCQZXB9xM6R2t/oJgXLo6Zz8dTzqq3De9nOU/1P2ZHLxodDikuFdu2/0CjoDgFXB0KnGKGKmJ6G1WMCVvi7abY7smmGA3s4a4NVL7Cirx6VwIj79PsAcgupr2iBAQk/GsPffzdpLtIrBek9u//p84hxrj/IaJWgPOeKeD7+r2Kc2g0r2dQjaM+9MqBx9/lC57xJRX/JHLQWirXfCucB9YyPun5I13Sf3hArkssQy/Jvd2aLFZ885BTfow6TAwl1ud+UPeauvEj6myKO98sko/3Y521EGXRofLGaPokLyPjI/3I4N4jCvw8m86eZAjjIhPFL7JKHf8OVc5gQCYQy3kxiF5wyvbfOeMBp0sk9UvJOrWvBEXFrimAZPu8o8T5WtlQAV02q7rxUwhMd+fpnbGewsl7Ob6eE4rGVrfWQIb86wOHbbJk3lCwPytjEFEI2bdUfRUFcrWxhC040hRP0gzVKLa+nBHM= on: From cb638d8188ab3105be045b9987128ffdfd5bd021 Mon Sep 17 00:00:00 2001 From: sebulibah Date: Thu, 19 Dec 2019 12:05:17 +0000 Subject: [PATCH 155/401] Improve logging - satosa.micro_services.ldap_attribute_store Signed-off-by: Ivan Kanakarakis --- .../micro_services/ldap_attribute_store.py | 98 +++++++++++-------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 624357081..f947ff451 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -15,11 +15,10 @@ from ldap3.core.exceptions import LDAPException from satosa.exception import SATOSAError -from satosa.logging_util import satosa_logging from satosa.micro_services.base import ResponseMicroService from satosa.response import Redirect - +import satosa.logging_util as lu logger = logging.getLogger(__name__) KEY_FOUND_LDAP_RECORD = "ldap_attribute_store_found_record" @@ -66,7 +65,7 @@ def __init__(self, config, *args, **kwargs): if "default" in config and "" in config: msg = """Use either 'default' or "" in config but not both""" - satosa_logging(logger, logging.ERROR, msg, None) + logger.error(msg) raise LdapAttributeStoreError(msg) if "" in config: @@ -74,7 +73,7 @@ def __init__(self, config, *args, **kwargs): if "default" not in config: msg = "No default configuration is present" - satosa_logging(logger, logging.ERROR, msg, None) + logger.error(msg) raise LdapAttributeStoreError(msg) self.config = {} @@ -88,7 +87,7 @@ def __init__(self, config, *args, **kwargs): for sp in sp_list: if not isinstance(config[sp], dict): msg = "Configuration value for {} must be a dictionary" - satosa_logging(logger, logging.ERROR, msg, None) + logger.error(msg) raise LdapAttributeStoreError(msg) # Initialize configuration using module defaults then update @@ -111,14 +110,14 @@ def __init__(self, config, *args, **kwargs): if connection_params in connections: sp_config["connection"] = connections[connection_params] msg = "Reusing LDAP connection for SP {}".format(sp) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) else: try: connection = self._ldap_connection_factory(sp_config) connections[connection_params] = connection sp_config["connection"] = connection msg = "Created new LDAP connection for SP {}".format(sp) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) except LdapAttributeStoreError: # It is acceptable to not have a default LDAP connection # but all SP overrides must have a connection, either @@ -126,13 +125,13 @@ def __init__(self, config, *args, **kwargs): if sp != "default": msg = "No LDAP connection can be initialized for SP {}" msg = msg.format(sp) - satosa_logging(logger, logging.ERROR, msg, None) + logger.error(msg) raise LdapAttributeStoreError(msg) self.config[sp] = sp_config msg = "LDAP Attribute Store microservice initialized" - satosa_logging(logger, logging.INFO, msg, None) + logger.info(msg) def _construct_filter_value( self, candidate, name_id_value, name_id_format, issuer, attributes @@ -176,7 +175,7 @@ def _construct_filter_value( for attr_value in [attributes.get(identifier_name)] ] msg = "Found candidate values {}".format(values) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) # If one of the configured identifier names is name_id then if there is # also a configured name_id_format add the value for the NameID of that @@ -190,7 +189,7 @@ def _construct_filter_value( and candidate_name_id_format == name_id_format ): msg = "IdP asserted NameID {}".format(name_id_value) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) candidate_nameid_value = name_id_value # Only add the NameID value asserted by the IdP if it is not @@ -201,18 +200,18 @@ def _construct_filter_value( if candidate_nameid_value not in values: msg = "Added NameID {} to candidate values" msg = msg.format(candidate_nameid_value) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) values.append(candidate_nameid_value) else: msg = "NameID {} value also asserted as attribute value" msg = msg.format(candidate_nameid_value) - satosa_logging(logger, logging.WARN, msg, None) + logger.warning(msg) # If no value was asserted by the IdP for one of the configured list of # identifier names for this candidate then go onto the next candidate. if None in values: msg = "Candidate is missing value so skipping" - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) return None # All values for the configured list of attribute names are present @@ -225,14 +224,14 @@ def _construct_filter_value( else candidate["add_scope"] ) msg = "Added scope {} to values".format(scope) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) values.append(scope) # Concatenate all values to create the filter value. value = "".join(values) msg = "Constructed filter value {}".format(value) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) return value @@ -283,13 +282,13 @@ def _ldap_connection_factory(self, config): server = ldap3.Server(**args) msg = "Creating a new LDAP connection" - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) msg = "Using LDAP URL {}".format(ldap_url) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) msg = "Using bind DN {}".format(bind_dn) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) auto_bind_string = config["auto_bind"] auto_bind_map = { @@ -309,9 +308,9 @@ def _ldap_connection_factory(self, config): if client_strategy == ldap3.REUSABLE: msg = "Using pool size {}".format(pool_size) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) msg = "Using pool keep alive {}".format(pool_keepalive) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) try: connection = ldap3.Connection( @@ -327,16 +326,16 @@ def _ldap_connection_factory(self, config): pool_keepalive=pool_keepalive, ) msg = "Successfully connected to LDAP server" - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) except LDAPException as e: msg = "Caught exception when connecting to LDAP server: {}" msg = msg.format(e) - satosa_logging(logger, logging.ERROR, msg, None) + logger.error(msg) raise LdapAttributeStoreError(msg) msg = "Successfully connected to LDAP server" - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) return connection @@ -348,7 +347,7 @@ def _populate_attributes(self, config, record): ldap_attributes = record.get("attributes", None) if not ldap_attributes: msg = "No attributes returned with LDAP record" - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) return ldap_to_internal_map = ( @@ -374,7 +373,7 @@ def _populate_attributes(self, config, record): ) msg = "Recording internal attribute {} with values {}" msg = msg.format(internal_attr, attributes[internal_attr]) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) return attributes @@ -408,12 +407,14 @@ def process(self, context, data): "issuer": issuer, "config": self._filter_config(config), } - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Ignore this SP entirely if so configured. if config["ignore"]: msg = "Ignoring SP {}".format(requester) - satosa_logging(logger, logging.INFO, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) return super().process(context, data) # The list of values for the LDAP search filters that will be tried in @@ -437,7 +438,8 @@ def process(self, context, data): if filter_value ] msg = {"message": "Search filters", "filter_values": filter_values} - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Initialize an empty LDAP record. The first LDAP record found using # the ordered # list of search filter values will be the record used. @@ -459,7 +461,8 @@ def process(self, context, data): "message": "LDAP query with constructed search filter", "search filter": search_filter, } - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) attributes = ( config["query_return_attributes"] @@ -480,13 +483,15 @@ def process(self, context, data): exp_msg = "Caught unhandled exception: {}".format(err) if exp_msg: - satosa_logging(logger, logging.ERROR, exp_msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=exp_msg) + logger.error(logline) return super().process(context, data) if not results: msg = "Querying LDAP server: No results for {}." msg = msg.format(filter_val) - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) continue if isinstance(results, bool): @@ -495,9 +500,11 @@ def process(self, context, data): responses = connection.get_response(results)[0] msg = "Done querying LDAP server" - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) msg = "LDAP server returned {} records".format(len(responses)) - satosa_logging(logger, logging.INFO, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) # For now consider only the first record found (if any). if len(responses) > 0: @@ -505,7 +512,8 @@ def process(self, context, data): msg = "LDAP server returned {} records using search filter" msg = msg + " value {}" msg = msg.format(len(responses), filter_val) - satosa_logging(logger, logging.WARN, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warning(logline) record = responses[0] break @@ -514,7 +522,8 @@ def process(self, context, data): if config["clear_input_attributes"]: msg = "Clearing values for these input attributes: {}" msg = msg.format(data.attributes) - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) data.attributes = {} # This adapts records with different search and connection strategy @@ -538,7 +547,8 @@ def process(self, context, data): "DN": record["dn"], "attributes": record["attributes"], } - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) # Populate attributes as configured. new_attrs = self._populate_attributes(config, record) @@ -555,16 +565,18 @@ def process(self, context, data): if user_ids: data.subject_id = "".join(user_ids) msg = "NameID value is {}".format(data.subject_id) - satosa_logging(logger, logging.DEBUG, msg, None) + logger.debug(msg) # Add the record to the context so that later microservices # may use it if required. context.decorate(KEY_FOUND_LDAP_RECORD, record) msg = "Added record {} to context".format(record) - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) else: msg = "No record found in LDAP so no attributes will be added" - satosa_logging(logger, logging.WARN, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warning(msg) on_ldap_search_result_empty = config["on_ldap_search_result_empty"] if on_ldap_search_result_empty: # Redirect to the configured URL with @@ -578,9 +590,11 @@ def process(self, context, data): encoded_idp_entity_id, ) msg = "Redirecting to {}".format(url) - satosa_logging(logger, logging.INFO, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(msg) return Redirect(url) msg = "Returning data.attributes {}".format(data.attributes) - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(msg) return super().process(context, data) From 9f85123654414681d66f050a61f2c81c03008aa8 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 23 Mar 2020 17:20:54 +0200 Subject: [PATCH 156/401] Fixes to the logging params Signed-off-by: Ivan Kanakarakis --- .../micro_services/ldap_attribute_store.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index f947ff451..e401127e3 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -14,11 +14,12 @@ import ldap3 from ldap3.core.exceptions import LDAPException +import satosa.logging_util as lu from satosa.exception import SATOSAError from satosa.micro_services.base import ResponseMicroService from satosa.response import Redirect -import satosa.logging_util as lu + logger = logging.getLogger(__name__) KEY_FOUND_LDAP_RECORD = "ldap_attribute_store_found_record" @@ -372,8 +373,8 @@ def _populate_attributes(self, config, record): else [values] ) msg = "Recording internal attribute {} with values {}" - msg = msg.format(internal_attr, attributes[internal_attr]) - logger.debug(msg) + logline = msg.format(internal_attr, attributes[internal_attr]) + logger.debug(logline) return attributes @@ -452,7 +453,8 @@ def process(self, context, data): "message": "LDAP server host", "server host": connection.server.host, } - satosa_logging(logger, logging.DEBUG, msg, context.state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) for filter_val in filter_values: ldap_ident_attr = config["ldap_identifier_attribute"] @@ -576,7 +578,7 @@ def process(self, context, data): else: msg = "No record found in LDAP so no attributes will be added" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.warning(msg) + logger.warning(logline) on_ldap_search_result_empty = config["on_ldap_search_result_empty"] if on_ldap_search_result_empty: # Redirect to the configured URL with @@ -591,10 +593,10 @@ def process(self, context, data): ) msg = "Redirecting to {}".format(url) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.info(msg) + logger.info(logline) return Redirect(url) msg = "Returning data.attributes {}".format(data.attributes) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(msg) + logger.debug(logline) return super().process(context, data) From 7dff564fd31ba0405dec08294cb971ed7b9c02d0 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 24 Mar 2020 19:30:42 +0200 Subject: [PATCH 157/401] Log if an endpoint cannot be matched to a function Signed-off-by: Ivan Kanakarakis --- src/satosa/proxy_server.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 868ffd5b1..9ac6713b6 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -8,6 +8,7 @@ from cookies_samesite_compat import CookiesSameSiteCompatMiddleware import satosa +import satosa.logging_util as lu from .base import SATOSABase from .context import Context from .response import ServiceError, NotFound @@ -118,17 +119,21 @@ def __call__(self, environ, start_response, debug=False): if isinstance(resp, Exception): raise resp return resp(environ, start_response) - except SATOSANoBoundEndpointError: + except SATOSANoBoundEndpointError as e: + import ipdb; ipdb.set_trace() # noqa XXX + msg = str(e) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) resp = NotFound("The Service or Identity Provider you requested could not be found.") return resp(environ, start_response) - except Exception as err: - if type(err) != UnknownSystemEntity: - logline = "{}".format(err) + except Exception as e: + if type(e) != UnknownSystemEntity: + logline = "{}".format(e) logger.exception(logline) if debug: raise - resp = ServiceError("%s" % err) + resp = ServiceError("%s" % e) return resp(environ, start_response) From 1100dbb1dc4ecdccbc4edda094de540ffd014228 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 00:02:52 +0200 Subject: [PATCH 158/401] Remove debugger call Signed-off-by: Ivan Kanakarakis --- src/satosa/proxy_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 9ac6713b6..a3c336145 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -120,7 +120,6 @@ def __call__(self, environ, start_response, debug=False): raise resp return resp(environ, start_response) except SATOSANoBoundEndpointError as e: - import ipdb; ipdb.set_trace() # noqa XXX msg = str(e) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) From 6a88c1adcbdeea0ce07b45904b75ef301d1d5890 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 11:16:34 +0200 Subject: [PATCH 159/401] Allow py39 builds to fail Signed-off-by: Ivan Kanakarakis --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 185d38196..7ff2a2a70 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,11 +17,12 @@ script: - tox jobs: + allow_failures: + - python: 3.9-dev include: - python: 3.6 - python: 3.7 - python: 3.8 - - python: 3.9-dev - python: pypy3 - stage: Expose env-var information From 4a574b14d8af8e8f0d48b3f7a44034da54a9c33c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 11:17:40 +0200 Subject: [PATCH 160/401] Build and tag docker images only when the secure env-vars are present Signed-off-by: Ivan Kanakarakis --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 7ff2a2a70..11cc6a49f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -69,6 +69,7 @@ jobs: install: skip script: - set -e + - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 - docker build -f Dockerfile -t "$DOCKER_TAG_COMMIT" . - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_TAG_COMMIT" @@ -79,6 +80,7 @@ jobs: install: skip script: - set -e + - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_BRANCH" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -114,6 +116,7 @@ jobs: install: skip script: - set -e + - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_GITTAG" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -125,6 +128,7 @@ jobs: install: skip script: - set -e + - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_LATEST" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin From 81d6d5c06dbb9827c7d108106b8fbdcee27d5f7f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 11:18:05 +0200 Subject: [PATCH 161/401] Use a token to deploy GitHub release Signed-off-by: Ivan Kanakarakis --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 11cc6a49f..4236748d8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -141,8 +141,7 @@ jobs: script: skip deploy: - provider: releases - token: - secure: EOM9qDlyGQrD6NXs8KKMNr2htFXU/H47tO051aA3RKWQrEk7paLXYTDSbQiEq3W9yLg+fifDb0qVqAcFhnV4OWf5ArP++khjaiKQCHYoTaoKIRrTch+12Unq22FEgNj0SYd3HX+CKkG2WpyMoBQAiChgaouDnYIPOvCoqfCxiJzj5e/l5Qomt31smUgZSYhqeDPvX0lN6LP47OLrzsEGDvVxz/fb+EMK3mkCppgPwsB2zy849dER7ofHD6uJiYhY3jP4oCHDBv6GdzqxgMIyDD4zJYh9qCfy1kAwOwc7CYInrELk8GK+YwLFRKMXdTMHu4nYUTgTAJeiXgX6n7oEUfvj4ip+UJ2MfsLdaX7MmgRb2sVStlYjqLWgVR1sZThKmDTH1SzztmZFcNjXBg5Yvs8zPKe+955AoL/EG+pu0ZapFTIrsW7Wq7dCSiXhUkdJ3E/3RZqawqDhTHmrQEiG2j4N2B90SeK7TcXncr7TxaQMwjRpUpkDHmNQPMW3TEHyjEVlTKjzeCmvJEzu/n2oDR12kD6FL5oh4lkMIzIIQqVtp09cB9IJXEO0ww3elIbjZPhMASOocwvoFWM/m9ZTH8i2NjulWuIsnPj9AMmQ8hryR+nqSmkK942D+/9W0/ZHX4rzZ4/6hpEwAi+2+BNnS9yPk1zP4LNMy5FA4NwCV14= + token: "$GITHUB_RELEASE_TOKEN" on: repo: IdentityPython/SATOSA tags: true From 3656940d5d529db49cde28dd61390f294fa2ecbd Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 11:18:18 +0200 Subject: [PATCH 162/401] Use a token to deploy PyPI releases Signed-off-by: Ivan Kanakarakis --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4236748d8..a673733cf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -154,9 +154,8 @@ jobs: deploy: - provider: pypi distributions: sdist bdist_wheel - username: Lundberg - password: - secure: NwkpOakaeJjErjTF4Y5MWeHzMvkxYZqrBFdRkzfenVfkWsomuyy553A691d3lc1+oREsh1fJJLjpZQYxTLUFIHOUmt/9zr02rFfguzj7hEYfWF8wHBXG6YSWv6T3aCA4RTMXvvzv9cHf1zfxh0fS7kgc+NRMAnd01diVLfYpBciLgmQ31J4mlwShp8yBQUoRBIvzSdzrgjr0TzCQZXB9xM6R2t/oJgXLo6Zz8dTzqq3De9nOU/1P2ZHLxodDikuFdu2/0CjoDgFXB0KnGKGKmJ6G1WMCVvi7abY7smmGA3s4a4NVL7Cirx6VwIj79PsAcgupr2iBAQk/GsPffzdpLtIrBek9u//p84hxrj/IaJWgPOeKeD7+r2Kc2g0r2dQjaM+9MqBx9/lC57xJRX/JHLQWirXfCucB9YyPun5I13Sf3hArkssQy/Jvd2aLFZ885BTfow6TAwl1ud+UPeauvEj6myKO98sko/3Y521EGXRofLGaPokLyPjI/3I4N4jCvw8m86eZAjjIhPFL7JKHf8OVc5gQCYQy3kxiF5wyvbfOeMBp0sk9UvJOrWvBEXFrimAZPu8o8T5WtlQAV02q7rxUwhMd+fpnbGewsl7Ob6eE4rGVrfWQIb86wOHbbJk3lCwPytjEFEI2bdUfRUFcrWxhC040hRP0gzVKLa+nBHM= + user: "__token__" + password: "$PYPI_RELEASE_TOKEN" on: repo: IdentityPython/SATOSA tags: true From 15513b6d01ff067218d9e3e46fe167439c3d086f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 13:49:42 +0200 Subject: [PATCH 163/401] Run the install step when building the docker image Signed-off-by: Ivan Kanakarakis --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index a673733cf..9dc1f9556 100644 --- a/.travis.yml +++ b/.travis.yml @@ -66,7 +66,6 @@ jobs: - stage: Build docker image by commit and deploy on DockerHub before_install: skip - install: skip script: - set -e - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 From 193aed21c7f1dafd20824d8096fae84c4c620d90 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 13:50:58 +0200 Subject: [PATCH 164/401] Use the username key instead of user Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 9dc1f9556..31de7b809 100644 --- a/.travis.yml +++ b/.travis.yml @@ -153,7 +153,7 @@ jobs: deploy: - provider: pypi distributions: sdist bdist_wheel - user: "__token__" + username: "__token__" password: "$PYPI_RELEASE_TOKEN" on: repo: IdentityPython/SATOSA From 6f34e290796c38da71e8c9b323cf0e4a5dc1b473 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 26 Mar 2020 14:16:16 +0200 Subject: [PATCH 165/401] Revert "Run the install step when building the docker image" This reverts commit 15513b6d01ff067218d9e3e46fe167439c3d086f. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 31de7b809..85e05bd31 100644 --- a/.travis.yml +++ b/.travis.yml @@ -66,6 +66,7 @@ jobs: - stage: Build docker image by commit and deploy on DockerHub before_install: skip + install: skip script: - set -e - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 From 375e4d9e1a04265acbc08b5282013f0444aad943 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 27 Mar 2020 01:19:20 +0200 Subject: [PATCH 166/401] Do not use exit; instead rely on set -e to abort Signed-off-by: Ivan Kanakarakis --- .travis.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 85e05bd31..99ae0d68d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -69,7 +69,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 + - test "$TRAVIS_SECURE_ENV_VARS" = "true" - docker build -f Dockerfile -t "$DOCKER_TAG_COMMIT" . - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_TAG_COMMIT" @@ -80,7 +80,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 + - test "$TRAVIS_SECURE_ENV_VARS" = "true" - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_BRANCH" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -92,7 +92,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 + - test "$TRAVIS_SECURE_ENV_VARS" = "true" - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_BRANCH" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -104,7 +104,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 + - test "$TRAVIS_SECURE_ENV_VARS" = "true" - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_NUM" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -116,7 +116,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 + - test "$TRAVIS_SECURE_ENV_VARS" = "true" - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_GITTAG" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -128,7 +128,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "false" && exit 0 + - test "$TRAVIS_SECURE_ENV_VARS" = "true" - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_LATEST" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin From 1c026924ea91d45486ea917faa2e739a9875c73c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 27 Mar 2020 17:08:52 +0200 Subject: [PATCH 167/401] Abort docker build gracefully when secure env-vars are not provided Signed-off-by: Ivan Kanakarakis --- .travis.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 99ae0d68d..4e80014d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -69,7 +69,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "true" + - test "$TRAVIS_SECURE_ENV_VARS" = "true" || exit 0 - docker build -f Dockerfile -t "$DOCKER_TAG_COMMIT" . - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin - docker push "$DOCKER_TAG_COMMIT" @@ -80,7 +80,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "true" + - test "$TRAVIS_SECURE_ENV_VARS" = "true" || exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_BRANCH" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -92,7 +92,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "true" + - test "$TRAVIS_SECURE_ENV_VARS" = "true" || exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_BRANCH" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -104,7 +104,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "true" + - test "$TRAVIS_SECURE_ENV_VARS" = "true" || exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_PR_NUM" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -116,7 +116,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "true" + - test "$TRAVIS_SECURE_ENV_VARS" = "true" || exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_GITTAG" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin @@ -128,7 +128,7 @@ jobs: install: skip script: - set -e - - test "$TRAVIS_SECURE_ENV_VARS" = "true" + - test "$TRAVIS_SECURE_ENV_VARS" = "true" || exit 0 - docker pull "$DOCKER_TAG_COMMIT" - docker tag "$DOCKER_TAG_COMMIT" "$DOCKER_TAG_LATEST" - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USERNAME" --password-stdin From d9971306573d710fd1c9bbc34583b4a7bcf3ef64 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 13 Feb 2020 12:16:55 -0600 Subject: [PATCH 168/401] Pull YAML configuration values from environment Add logic so that a YAML tag of the form !ENV indicates that a value of the form ${SOME_ENVIRONMENT_VARIABLE} should be replaced with the value of the process environment variable of the same name. --- doc/README.md | 14 ++++++++++ .../ldap_attribute_store.yaml.example | 3 +- src/satosa/satosa_config.py | 28 +++++++++++++++++++ tests/satosa/test_satosa_config.py | 9 ++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index 11f12b9bd..cd7339b73 100644 --- a/doc/README.md +++ b/doc/README.md @@ -29,9 +29,23 @@ apt-get install libffi-dev libssl-dev xmlsec1 Alternatively the application can be installed directly from PyPI (`pip install satosa`), or the [Docker image](https://hub.docker.com/r/satosa/) can be used. # Configuration +SATOSA is configured using YAML. + All default configuration files, as well as an example WSGI application for the proxy, can be found in the [example directory](../example). +A configuration value that includes the tag !ENV will have a value of the form `${SOME_ENVIRONMENT_VARIABLE}` +replaced with the value from the process environment variable of the same name. For example if the file +`ldap_attribute_store.yaml' includes + +``` +bind_password: !ENV ${LDAP_BIND_PASSWORD} +``` + +and the SATOSA process environment includes the environment variable `LDAP_BIND_PASSWORD` with +value `my_password` then the configuration for `bind_password` will be `my_password`. + + ## SATOSA proxy configuration: `proxy_conf.yaml.example` | Parameter name | Data type | Example value | Description | | -------------- | --------- | ------------- | ----------- | diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 43dd20e1f..62eab2f71 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -8,7 +8,8 @@ config: "": ldap_url: ldaps://ldap.example.org bind_dn: cn=admin,dc=example,dc=org - bind_password: xxxxxxxx + # Obtain bind password from environment variable LDAP_BIND_PASSWORD. + bind_password: !ENV ${LDAP_BIND_PASSWORD} search_base: ou=People,dc=example,dc=org read_only: true auto_bind: true diff --git a/src/satosa/satosa_config.py b/src/satosa/satosa_config.py index d3b414520..8c1c6f9a7 100644 --- a/src/satosa/satosa_config.py +++ b/src/satosa/satosa_config.py @@ -3,6 +3,7 @@ """ import logging import os +import re import yaml @@ -143,6 +144,33 @@ def _load_yaml(self, config_file): :param config_file: config to load. Can be file path or yaml string :return: Loaded config """ + # Tag to indicate environment variable: !ENV + tag = '!ENV' + + # Pattern for environment variable: ${word} + pattern = re.compile('.*?\${(\w+)}.*?') + + yaml.SafeLoader.add_implicit_resolver(tag, pattern, None) + + def constructor_env_variables(loader, node): + """ + Extracts the environment variable from the node's value. + :param yaml.Loader loader: the yaml loader + :param node: the current node in the yaml + :return: value of the environment variable + """ + value = loader.construct_scalar(node) + match = pattern.findall(value) + if match: + new_value = value + for m in match: + new_value = new_value.replace('${' + m + '}', + os.environ.get(m, m)) + return new_value + return value + + yaml.SafeLoader.add_constructor(tag, constructor_env_variables) + try: with open(os.path.abspath(config_file)) as f: return yaml.safe_load(f.read()) diff --git a/tests/satosa/test_satosa_config.py b/tests/satosa/test_satosa_config.py index 030ae6485..73e537045 100644 --- a/tests/satosa/test_satosa_config.py +++ b/tests/satosa/test_satosa_config.py @@ -1,4 +1,5 @@ import json +import os from unittest.mock import mock_open, patch import pytest @@ -7,6 +8,7 @@ from satosa.exception import SATOSAConfigurationError from satosa.satosa_config import SATOSAConfig +TEST_RESOURCE_BASE_PATH = os.path.join(os.path.dirname(__file__), "../test_resources") class TestSATOSAConfig: @pytest.fixture @@ -73,3 +75,10 @@ def test_can_read_endpoint_configs_from_file(self, satosa_config_dict, modules_k with pytest.raises(SATOSAConfigurationError): SATOSAConfig(satosa_config_dict) + + def test_can_substitute_from_environment_variable(self, monkeypatch): + monkeypatch.setenv("SATOSA_COOKIE_STATE_NAME", "oatmeal_raisin") + config = SATOSAConfig(os.path.join(TEST_RESOURCE_BASE_PATH, + "proxy_conf_environment_test.yaml")) + + assert config["COOKIE_STATE_NAME"] == 'oatmeal_raisin' From 9991b1a70c93b3a901bb21a1226358cb24e7ba20 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 13 Feb 2020 13:57:00 -0600 Subject: [PATCH 169/401] Resource file needed for test. Resource file needed for test. --- tests/test_resources/proxy_conf_environment_test.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/test_resources/proxy_conf_environment_test.yaml diff --git a/tests/test_resources/proxy_conf_environment_test.yaml b/tests/test_resources/proxy_conf_environment_test.yaml new file mode 100644 index 000000000..a2c0d7968 --- /dev/null +++ b/tests/test_resources/proxy_conf_environment_test.yaml @@ -0,0 +1,10 @@ +BASE: https://example.com + +STATE_ENCRYPTION_KEY: state_encryption_key + +INTERNAL_ATTRIBUTES: {"attributes": {}} + +COOKIE_STATE_NAME: !ENV ${SATOSA_COOKIE_STATE_NAME} + +BACKEND_MODULES: [] +FRONTEND_MODULES: [] From 022f98935ee7b30629cb5897fb6049a64c1b6238 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Fri, 21 Feb 2020 07:53:07 -0600 Subject: [PATCH 170/401] Pull YAML configuration values from file pointed to by environment Add logic so that a YAML tag of the form !ENVFILE indicates that a value of the form $(SOME_ENVIRONMENT_VARIABLE_FILE) should be replaced with the value obtained by reading the process environment variable of the same name to get a file path and then reading the file contents. --- .../ldap_attribute_store.yaml.example | 3 ++ src/satosa/satosa_config.py | 43 ++++++++++++++++--- tests/satosa/test_satosa_config.py | 9 ++++ tests/test_resources/cookie_state_name | 1 + .../proxy_conf_environment_file_test.yaml | 10 +++++ 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 tests/test_resources/cookie_state_name create mode 100644 tests/test_resources/proxy_conf_environment_file_test.yaml diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 62eab2f71..8c14ba667 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -10,6 +10,9 @@ config: bind_dn: cn=admin,dc=example,dc=org # Obtain bind password from environment variable LDAP_BIND_PASSWORD. bind_password: !ENV ${LDAP_BIND_PASSWORD} + # Obtain bind password from file pointed to by + # environment variable LDAP_BIND_PASSWORD_FILE. + # bind_password: !ENVFILE $(LDAP_BIND_PASSWORD) search_base: ou=People,dc=example,dc=org read_only: true auto_bind: true diff --git a/src/satosa/satosa_config.py b/src/satosa/satosa_config.py index 8c1c6f9a7..dee4a17b2 100644 --- a/src/satosa/satosa_config.py +++ b/src/satosa/satosa_config.py @@ -3,6 +3,7 @@ """ import logging import os +import os.path import re import yaml @@ -145,12 +146,12 @@ def _load_yaml(self, config_file): :return: Loaded config """ # Tag to indicate environment variable: !ENV - tag = '!ENV' + tag_env = '!ENV' # Pattern for environment variable: ${word} - pattern = re.compile('.*?\${(\w+)}.*?') + pattern_env = re.compile('.*?\${(\w+)}.*?') - yaml.SafeLoader.add_implicit_resolver(tag, pattern, None) + yaml.SafeLoader.add_implicit_resolver(tag_env, pattern_env, None) def constructor_env_variables(loader, node): """ @@ -160,7 +161,7 @@ def constructor_env_variables(loader, node): :return: value of the environment variable """ value = loader.construct_scalar(node) - match = pattern.findall(value) + match = pattern_env.findall(value) if match: new_value = value for m in match: @@ -169,7 +170,39 @@ def constructor_env_variables(loader, node): return new_value return value - yaml.SafeLoader.add_constructor(tag, constructor_env_variables) + yaml.SafeLoader.add_constructor(tag_env, constructor_env_variables) + + # Tag to indicate file pointed to by environment variable: !ENVFILE + tag_env_file = '!ENVFILE' + + # Pattern for environment variable: $(word) + pattern_env_file = re.compile('.*?\$\((\w+)\).*?') + + yaml.SafeLoader.add_implicit_resolver(tag_env_file, + pattern_env_file, None) + + def constructor_envfile_variables(loader, node): + """ + Extracts the environment variable from the node's value. + :param yaml.Loader loader: the yaml loader + :param node: the current node in the yaml + :return: value read from file pointed to by environment variable + """ + value = loader.construct_scalar(node) + match = pattern_env_file.findall(value) + if match: + new_value = value + for m in match: + path = os.environ.get(m, '') + if os.path.exists(path): + with open(path, 'r') as f: + new_value = new_value.replace('$(' + m + ')', + f.read().strip()) + return new_value + return value + + yaml.SafeLoader.add_constructor(tag_env_file, + constructor_envfile_variables) try: with open(os.path.abspath(config_file)) as f: diff --git a/tests/satosa/test_satosa_config.py b/tests/satosa/test_satosa_config.py index 73e537045..d5233f9ee 100644 --- a/tests/satosa/test_satosa_config.py +++ b/tests/satosa/test_satosa_config.py @@ -82,3 +82,12 @@ def test_can_substitute_from_environment_variable(self, monkeypatch): "proxy_conf_environment_test.yaml")) assert config["COOKIE_STATE_NAME"] == 'oatmeal_raisin' + + def test_can_substitute_from_environment_variable_file(self, monkeypatch): + cookie_file = os.path.join(TEST_RESOURCE_BASE_PATH, + 'cookie_state_name') + monkeypatch.setenv("SATOSA_COOKIE_STATE_NAME_FILE", cookie_file) + config = SATOSAConfig(os.path.join(TEST_RESOURCE_BASE_PATH, + "proxy_conf_environment_file_test.yaml")) + + assert config["COOKIE_STATE_NAME"] == 'chocolate_chip' diff --git a/tests/test_resources/cookie_state_name b/tests/test_resources/cookie_state_name new file mode 100644 index 000000000..dd5b622b7 --- /dev/null +++ b/tests/test_resources/cookie_state_name @@ -0,0 +1 @@ +chocolate_chip diff --git a/tests/test_resources/proxy_conf_environment_file_test.yaml b/tests/test_resources/proxy_conf_environment_file_test.yaml new file mode 100644 index 000000000..f10a1999a --- /dev/null +++ b/tests/test_resources/proxy_conf_environment_file_test.yaml @@ -0,0 +1,10 @@ +BASE: https://example.com + +STATE_ENCRYPTION_KEY: state_encryption_key + +INTERNAL_ATTRIBUTES: {"attributes": {}} + +COOKIE_STATE_NAME: !ENVFILE $(SATOSA_COOKIE_STATE_NAME_FILE) + +BACKEND_MODULES: [] +FRONTEND_MODULES: [] From e78894cb9e25b245385fde125570a6aa56ff0875 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 29 Feb 2020 03:15:21 +0200 Subject: [PATCH 171/401] Declare yaml as a dependency with extensions At the same time, the syntax for the !ENV and !ENVFILE tags is changed to just the env-var name. No need for ${} or $(). Signed-off-by: Ivan Kanakarakis --- doc/README.md | 6 +- .../ldap_attribute_store.yaml.example | 4 +- src/satosa/satosa_config.py | 68 ++----------------- src/satosa/yaml.py | 58 ++++++++++++++++ tests/satosa/test_satosa_config.py | 15 ++-- tests/test_resources/cookie_state_name | 2 +- .../proxy_conf_environment_file_test.yaml | 2 +- .../proxy_conf_environment_test.yaml | 2 +- 8 files changed, 80 insertions(+), 77 deletions(-) create mode 100644 src/satosa/yaml.py diff --git a/doc/README.md b/doc/README.md index cd7339b73..431954cc0 100644 --- a/doc/README.md +++ b/doc/README.md @@ -34,16 +34,16 @@ SATOSA is configured using YAML. All default configuration files, as well as an example WSGI application for the proxy, can be found in the [example directory](../example). -A configuration value that includes the tag !ENV will have a value of the form `${SOME_ENVIRONMENT_VARIABLE}` +A configuration value that includes the tag !ENV will have a value of the form `SOME_ENVIRONMENT_VARIABLE` replaced with the value from the process environment variable of the same name. For example if the file `ldap_attribute_store.yaml' includes ``` -bind_password: !ENV ${LDAP_BIND_PASSWORD} +bind_password: !ENV LDAP_BIND_PASSWORD ``` and the SATOSA process environment includes the environment variable `LDAP_BIND_PASSWORD` with -value `my_password` then the configuration for `bind_password` will be `my_password`. +value `my_password` then the configuration value for `bind_password` will be `my_password`. ## SATOSA proxy configuration: `proxy_conf.yaml.example` diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 8c14ba667..8f0e74c8f 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -9,10 +9,10 @@ config: ldap_url: ldaps://ldap.example.org bind_dn: cn=admin,dc=example,dc=org # Obtain bind password from environment variable LDAP_BIND_PASSWORD. - bind_password: !ENV ${LDAP_BIND_PASSWORD} + bind_password: !ENV LDAP_BIND_PASSWORD # Obtain bind password from file pointed to by # environment variable LDAP_BIND_PASSWORD_FILE. - # bind_password: !ENVFILE $(LDAP_BIND_PASSWORD) + # bind_password: !ENVFILE LDAP_BIND_PASSWORD search_base: ou=People,dc=example,dc=org read_only: true auto_bind: true diff --git a/src/satosa/satosa_config.py b/src/satosa/satosa_config.py index dee4a17b2..b107e5728 100644 --- a/src/satosa/satosa_config.py +++ b/src/satosa/satosa_config.py @@ -4,11 +4,11 @@ import logging import os import os.path -import re -import yaml +from satosa.exception import SATOSAConfigurationError +from satosa.yaml import load as yaml_load +from satosa.yaml import YAMLError -from .exception import SATOSAConfigurationError logger = logging.getLogger(__name__) @@ -145,69 +145,11 @@ def _load_yaml(self, config_file): :param config_file: config to load. Can be file path or yaml string :return: Loaded config """ - # Tag to indicate environment variable: !ENV - tag_env = '!ENV' - - # Pattern for environment variable: ${word} - pattern_env = re.compile('.*?\${(\w+)}.*?') - - yaml.SafeLoader.add_implicit_resolver(tag_env, pattern_env, None) - - def constructor_env_variables(loader, node): - """ - Extracts the environment variable from the node's value. - :param yaml.Loader loader: the yaml loader - :param node: the current node in the yaml - :return: value of the environment variable - """ - value = loader.construct_scalar(node) - match = pattern_env.findall(value) - if match: - new_value = value - for m in match: - new_value = new_value.replace('${' + m + '}', - os.environ.get(m, m)) - return new_value - return value - - yaml.SafeLoader.add_constructor(tag_env, constructor_env_variables) - - # Tag to indicate file pointed to by environment variable: !ENVFILE - tag_env_file = '!ENVFILE' - - # Pattern for environment variable: $(word) - pattern_env_file = re.compile('.*?\$\((\w+)\).*?') - - yaml.SafeLoader.add_implicit_resolver(tag_env_file, - pattern_env_file, None) - - def constructor_envfile_variables(loader, node): - """ - Extracts the environment variable from the node's value. - :param yaml.Loader loader: the yaml loader - :param node: the current node in the yaml - :return: value read from file pointed to by environment variable - """ - value = loader.construct_scalar(node) - match = pattern_env_file.findall(value) - if match: - new_value = value - for m in match: - path = os.environ.get(m, '') - if os.path.exists(path): - with open(path, 'r') as f: - new_value = new_value.replace('$(' + m + ')', - f.read().strip()) - return new_value - return value - - yaml.SafeLoader.add_constructor(tag_env_file, - constructor_envfile_variables) try: with open(os.path.abspath(config_file)) as f: - return yaml.safe_load(f.read()) - except yaml.YAMLError as exc: + return yaml_load(f.read()) + except YAMLError as exc: logger.error("Could not parse config as YAML: {}".format(exc)) if hasattr(exc, 'problem_mark'): mark = exc.problem_mark diff --git a/src/satosa/yaml.py b/src/satosa/yaml.py new file mode 100644 index 000000000..5d300d30e --- /dev/null +++ b/src/satosa/yaml.py @@ -0,0 +1,58 @@ +import os +import re + +from yaml import SafeLoader as _safe_loader +from yaml import YAMLError +from yaml import safe_load as load + + +def _constructor_env_variables(loader, node): + """ + Extracts the environment variable from the node's value. + :param yaml.Loader loader: the yaml loader + :param node: the current node in the yaml + :return: value of the environment variable + """ + raw_value = loader.construct_scalar(node) + new_value = os.environ.get(raw_value) + if new_value is None: + msg = "Cannot construct value from {node}: {value}".format( + node=node, value=new_value + ) + raise YAMLError(msg) + return new_value + + +def _constructor_envfile_variables(loader, node): + """ + Extracts the environment variable from the node's value. + :param yaml.Loader loader: the yaml loader + :param node: the current node in the yaml + :return: value read from file pointed to by environment variable + """ + raw_value = loader.construct_scalar(node) + filepath = os.environ.get(raw_value) + if filepath is None: + msg = "Cannot construct value from {node}: {path}".format( + node=node, path=filepath + ) + raise YAMLError(msg) + + try: + with open(filepath, "r") as fd: + new_value = fd.read() + except (TypeError, IOError) as e: + msg = "Cannot construct value from {node}: {path}".format( + node=node, path=filepath + ) + raise YAMLError(msg) from e + else: + return new_value + + +TAG_ENV = "!ENV" +TAG_ENV_FILE = "!ENVFILE" + + +_safe_loader.add_constructor(TAG_ENV, _constructor_env_variables) +_safe_loader.add_constructor(TAG_ENV_FILE, _constructor_envfile_variables) diff --git a/tests/satosa/test_satosa_config.py b/tests/satosa/test_satosa_config.py index d5233f9ee..d291d9c87 100644 --- a/tests/satosa/test_satosa_config.py +++ b/tests/satosa/test_satosa_config.py @@ -78,16 +78,19 @@ def test_can_read_endpoint_configs_from_file(self, satosa_config_dict, modules_k def test_can_substitute_from_environment_variable(self, monkeypatch): monkeypatch.setenv("SATOSA_COOKIE_STATE_NAME", "oatmeal_raisin") - config = SATOSAConfig(os.path.join(TEST_RESOURCE_BASE_PATH, - "proxy_conf_environment_test.yaml")) + config = SATOSAConfig( + os.path.join(TEST_RESOURCE_BASE_PATH, "proxy_conf_environment_test.yaml") + ) assert config["COOKIE_STATE_NAME"] == 'oatmeal_raisin' def test_can_substitute_from_environment_variable_file(self, monkeypatch): - cookie_file = os.path.join(TEST_RESOURCE_BASE_PATH, - 'cookie_state_name') + cookie_file = os.path.join(TEST_RESOURCE_BASE_PATH, 'cookie_state_name') monkeypatch.setenv("SATOSA_COOKIE_STATE_NAME_FILE", cookie_file) - config = SATOSAConfig(os.path.join(TEST_RESOURCE_BASE_PATH, - "proxy_conf_environment_file_test.yaml")) + config = SATOSAConfig( + os.path.join( + TEST_RESOURCE_BASE_PATH, "proxy_conf_environment_file_test.yaml" + ) + ) assert config["COOKIE_STATE_NAME"] == 'chocolate_chip' diff --git a/tests/test_resources/cookie_state_name b/tests/test_resources/cookie_state_name index dd5b622b7..84bb814b8 100644 --- a/tests/test_resources/cookie_state_name +++ b/tests/test_resources/cookie_state_name @@ -1 +1 @@ -chocolate_chip +chocolate_chip \ No newline at end of file diff --git a/tests/test_resources/proxy_conf_environment_file_test.yaml b/tests/test_resources/proxy_conf_environment_file_test.yaml index f10a1999a..801c109e8 100644 --- a/tests/test_resources/proxy_conf_environment_file_test.yaml +++ b/tests/test_resources/proxy_conf_environment_file_test.yaml @@ -4,7 +4,7 @@ STATE_ENCRYPTION_KEY: state_encryption_key INTERNAL_ATTRIBUTES: {"attributes": {}} -COOKIE_STATE_NAME: !ENVFILE $(SATOSA_COOKIE_STATE_NAME_FILE) +COOKIE_STATE_NAME: !ENVFILE SATOSA_COOKIE_STATE_NAME_FILE BACKEND_MODULES: [] FRONTEND_MODULES: [] diff --git a/tests/test_resources/proxy_conf_environment_test.yaml b/tests/test_resources/proxy_conf_environment_test.yaml index a2c0d7968..ab8118f31 100644 --- a/tests/test_resources/proxy_conf_environment_test.yaml +++ b/tests/test_resources/proxy_conf_environment_test.yaml @@ -4,7 +4,7 @@ STATE_ENCRYPTION_KEY: state_encryption_key INTERNAL_ATTRIBUTES: {"attributes": {}} -COOKIE_STATE_NAME: !ENV ${SATOSA_COOKIE_STATE_NAME} +COOKIE_STATE_NAME: !ENV SATOSA_COOKIE_STATE_NAME BACKEND_MODULES: [] FRONTEND_MODULES: [] From db888cf08518e73f81df9f8a1ccb1761dd507876 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 29 Feb 2020 03:33:04 +0200 Subject: [PATCH 172/401] Remove unneeded check Signed-off-by: Ivan Kanakarakis --- src/satosa/yaml.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/satosa/yaml.py b/src/satosa/yaml.py index 5d300d30e..0d8747408 100644 --- a/src/satosa/yaml.py +++ b/src/satosa/yaml.py @@ -32,12 +32,6 @@ def _constructor_envfile_variables(loader, node): """ raw_value = loader.construct_scalar(node) filepath = os.environ.get(raw_value) - if filepath is None: - msg = "Cannot construct value from {node}: {path}".format( - node=node, path=filepath - ) - raise YAMLError(msg) - try: with open(filepath, "r") as fd: new_value = fd.read() From 22f5a8dfaf9f9374c79577db7f7727aae37f64d5 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 25 Mar 2020 20:48:03 +0200 Subject: [PATCH 173/401] Rename TAG_ENV_FILE to TAG_ENVFILE Signed-off-by: Ivan Kanakarakis --- src/satosa/yaml.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/yaml.py b/src/satosa/yaml.py index 0d8747408..9efa202c6 100644 --- a/src/satosa/yaml.py +++ b/src/satosa/yaml.py @@ -45,8 +45,8 @@ def _constructor_envfile_variables(loader, node): TAG_ENV = "!ENV" -TAG_ENV_FILE = "!ENVFILE" +TAG_ENVFILE = "!ENVFILE" _safe_loader.add_constructor(TAG_ENV, _constructor_env_variables) -_safe_loader.add_constructor(TAG_ENV_FILE, _constructor_envfile_variables) +_safe_loader.add_constructor(TAG_ENVFILE, _constructor_envfile_variables) From 67b96102d003e545dc64457c854778179644ac89 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 25 Mar 2020 21:01:03 +0200 Subject: [PATCH 174/401] Update the yaml dependency on satosa.plugin_loader module Signed-off-by: Ivan Kanakarakis --- src/satosa/plugin_loader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/satosa/plugin_loader.py b/src/satosa/plugin_loader.py index 65c535de2..b7eb4cf46 100644 --- a/src/satosa/plugin_loader.py +++ b/src/satosa/plugin_loader.py @@ -7,8 +7,8 @@ from contextlib import contextmanager from pydoc import locate -import yaml -from yaml.error import YAMLError +from satosa.yaml import load as yaml_load +from satosa.yaml import YAMLError from .backends.base import BackendModule from .exception import SATOSAConfigurationError @@ -143,7 +143,7 @@ def _response_micro_service_filter(cls): def _load_plugin_config(config): try: - return yaml.safe_load(config) + return yaml_load(config) except YAMLError as exc: if hasattr(exc, 'problem_mark'): mark = exc.problem_mark From f9f1b5c58ce9af7d4adfcdf2e44cdd36d17eec67 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 25 Mar 2020 23:43:03 +0200 Subject: [PATCH 175/401] Update documentation for ENV and ENVFILE yaml tags Signed-off-by: Ivan Kanakarakis --- doc/README.md | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/doc/README.md b/doc/README.md index 431954cc0..fe87e1f97 100644 --- a/doc/README.md +++ b/doc/README.md @@ -34,16 +34,39 @@ SATOSA is configured using YAML. All default configuration files, as well as an example WSGI application for the proxy, can be found in the [example directory](../example). -A configuration value that includes the tag !ENV will have a value of the form `SOME_ENVIRONMENT_VARIABLE` -replaced with the value from the process environment variable of the same name. For example if the file -`ldap_attribute_store.yaml' includes +The default YAML syntax is extended to include the capability to resolve +environment variables. The following tags are used to achieve this: + +* The `!ENV` tag + +The `!ENV` tag is followed by a string that denotes the environment variable +name. It will be replaced by the value of the environment variable with the +same name. + +In the example below `LDAP_BIND_PASSWORD` will, at runtime, be replaced with +the value from the process environment variable of the same name. If the +process environment has been set with `LDAP_BIND_PASSWORD=secret_password` then +the configuration value for `bind_password` will be `secret_password`. ``` bind_password: !ENV LDAP_BIND_PASSWORD ``` -and the SATOSA process environment includes the environment variable `LDAP_BIND_PASSWORD` with -value `my_password` then the configuration value for `bind_password` will be `my_password`. +* The `!ENVFILE` tag + +The `!ENVFILE` tag is followed by a string that denotes the environment +variable name. It will be replaced by the value of the environment variable +with the same name. + +In the example below `LDAP_BIND_PASSWORD_FILE` will, at runtime, be replaced +with the value from the process environment variable of the same name. If the +process environment has been set with +`LDAP_BIND_PASSWORD_FILE=/etc/satosa/secrets/ldap.txt` then the configuration +value for `bind_password` will be `secret_password`. + +``` +bind_password: !ENVFILE LDAP_BIND_PASSWORD_FILE +``` ## SATOSA proxy configuration: `proxy_conf.yaml.example` From 23df299a5e5a17cad95d8802414aae04f08091cd Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Wed, 8 Apr 2020 10:53:43 -0500 Subject: [PATCH 176/401] Flake8 formatting Better line lengths for flake8 formatting. No change in functionality. --- .../micro_services/ldap_attribute_store.py | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index e401127e3..0f373310d 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -399,6 +399,8 @@ def process(self, context, data): Default interface for microservices. Process the input data for the input context. """ + session_id = lu.get_session_id(context.state) + issuer = data.auth_info.issuer requester = data.requester config = self.config.get(requester) or self.config["default"] @@ -408,13 +410,13 @@ def process(self, context, data): "issuer": issuer, "config": self._filter_config(config), } - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) # Ignore this SP entirely if so configured. if config["ignore"]: msg = "Ignoring SP {}".format(requester) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.info(logline) return super().process(context, data) @@ -439,7 +441,7 @@ def process(self, context, data): if filter_value ] msg = {"message": "Search filters", "filter_values": filter_values} - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) # Initialize an empty LDAP record. The first LDAP record found using @@ -453,7 +455,7 @@ def process(self, context, data): "message": "LDAP server host", "server host": connection.server.host, } - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) for filter_val in filter_values: @@ -463,7 +465,7 @@ def process(self, context, data): "message": "LDAP query with constructed search filter", "search filter": search_filter, } - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) attributes = ( @@ -485,14 +487,14 @@ def process(self, context, data): exp_msg = "Caught unhandled exception: {}".format(err) if exp_msg: - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=exp_msg) + logline = lu.LOG_FMT.format(id=session_id, message=exp_msg) logger.error(logline) return super().process(context, data) if not results: msg = "Querying LDAP server: No results for {}." msg = msg.format(filter_val) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) continue @@ -502,10 +504,10 @@ def process(self, context, data): responses = connection.get_response(results)[0] msg = "Done querying LDAP server" - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) msg = "LDAP server returned {} records".format(len(responses)) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.info(logline) # For now consider only the first record found (if any). @@ -514,7 +516,7 @@ def process(self, context, data): msg = "LDAP server returned {} records using search filter" msg = msg + " value {}" msg = msg.format(len(responses), filter_val) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.warning(logline) record = responses[0] break @@ -524,7 +526,7 @@ def process(self, context, data): if config["clear_input_attributes"]: msg = "Clearing values for these input attributes: {}" msg = msg.format(data.attributes) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) data.attributes = {} @@ -549,7 +551,7 @@ def process(self, context, data): "DN": record["dn"], "attributes": record["attributes"], } - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) # Populate attributes as configured. @@ -573,11 +575,11 @@ def process(self, context, data): # may use it if required. context.decorate(KEY_FOUND_LDAP_RECORD, record) msg = "Added record {} to context".format(record) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) else: msg = "No record found in LDAP so no attributes will be added" - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.warning(logline) on_ldap_search_result_empty = config["on_ldap_search_result_empty"] if on_ldap_search_result_empty: @@ -592,11 +594,11 @@ def process(self, context, data): encoded_idp_entity_id, ) msg = "Redirecting to {}".format(url) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.info(logline) return Redirect(url) msg = "Returning data.attributes {}".format(data.attributes) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) return super().process(context, data) From d244ffea8b6c6ac837077c8c22a324195c28d713 Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Wed, 8 Apr 2020 11:45:09 -0500 Subject: [PATCH 177/401] Generalize per-SP override for LDAP attribute authority Generalize the per-SP override for the LDAP attribute authority microservice so that the override can be per-SP, per-IdP, or per- CO virtual IdP. This enhancement does not allow for nested overrides, which may be included in future work. --- .../ldap_attribute_store.yaml.example | 24 ++++++++++++------- .../micro_services/ldap_attribute_store.py | 24 +++++++++++++++---- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 8f0e74c8f..a83873a9b 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -2,10 +2,11 @@ module: LdapAttributeStore name: LdapAttributeStore config: - # The microservice may be configured per SP. - # The configuration key is the entityID of the SP. - # The empty key ("") specifies the default configuration - "": + # The microservice may be configured per entityID. + # The configuration key is the entityID of the requesting SP, + # the authenticating IdP, or the entityID of the CO virtual IdP. + # The key "default" specifies the default configuration + default: ldap_url: ldaps://ldap.example.org bind_dn: cn=admin,dc=example,dc=org # Obtain bind password from environment variable LDAP_BIND_PASSWORD. @@ -96,9 +97,13 @@ config: # from LDAP. The default is not to redirect. on_ldap_search_result_empty: https://my.vo.org/please/go/enroll - # The microservice may be configured per SP. - # The configuration key is the entityID of the SP. - # Αny missing parameters are looked up from the default configuration. + # The microservice may be configured per entityID. + # The configuration key is the entityID of the requesting SP, + # the authenticating IdP, or the entityID of the CO virtual IdP. + # When more than one configured entityID matches during a flow + # the priority ordering is requesting SP, then authenticating IdP, then + # CO virtual IdP. Αny missing parameters are taken from the + # default configuration. https://sp.myserver.edu/shibboleth-sp: search_base: ou=People,o=MyVO,dc=example,dc=org search_return_attributes: @@ -109,6 +114,9 @@ config: user_id_from_attrs: - uid - # The microservice may be configured to ignore a particular SP. + https://federation-proxy.my.edu/satosa/idp/proxy/some_co + search_base: ou=People,o=some_co,dc=example,dc=org + + # The microservice may be configured to ignore a particular entityID. https://another.sp.myserver.edu: ignore: true diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 0f373310d..6d61559b1 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -18,6 +18,8 @@ from satosa.exception import SATOSAError from satosa.micro_services.base import ResponseMicroService from satosa.response import Redirect +from satosa.frontends.saml2 import SAMLVirtualCoFrontend +from satosa.routing import STATE_KEY as ROUTING_STATE_KEY logger = logging.getLogger(__name__) @@ -399,23 +401,35 @@ def process(self, context, data): Default interface for microservices. Process the input data for the input context. """ - session_id = lu.get_session_id(context.state) + state = context.state + session_id = lu.get_session_id(state) - issuer = data.auth_info.issuer requester = data.requester - config = self.config.get(requester) or self.config["default"] + issuer = data.auth_info.issuer + + frontend_name = state.get(ROUTING_STATE_KEY) + co_entity_id_key = SAMLVirtualCoFrontend.KEY_CO_ENTITY_ID + co_entity_id = state.get(frontend_name, {}).get(co_entity_id_key) + + entity_ids = [requester, issuer, co_entity_id, "default"] + + config, entity_id = next((self.config.get(e), e) + for e in entity_ids if self.config.get(e)) + msg = { "message": "entityID for the involved entities", "requester": requester, "issuer": issuer, "config": self._filter_config(config), } + if co_entity_id: + msg["co_entity_id"] = co_entity_id logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.debug(logline) - # Ignore this SP entirely if so configured. + # Ignore this entityID entirely if so configured. if config["ignore"]: - msg = "Ignoring SP {}".format(requester) + msg = "Ignoring entityID {}".format(entity_id) logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.info(logline) return super().process(context, data) From fc6ee2961a7c98116f9ead1b5c11c84ccd30c23c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 17 Apr 2020 18:28:16 +0300 Subject: [PATCH 178/401] Add cdb.json example file Signed-off-by: Ivan Kanakarakis --- .gitignore | 3 --- example/cdb.json.example | 10 ++++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 example/cdb.json.example diff --git a/.gitignore b/.gitignore index ac511933f..6c67df01d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,6 @@ _build *.pyc *.log* -example/* -!example/plugins - *.xml *.db *static/ diff --git a/example/cdb.json.example b/example/cdb.json.example new file mode 100644 index 000000000..a64750b0e --- /dev/null +++ b/example/cdb.json.example @@ -0,0 +1,10 @@ +{ + "test_client": { + "response_types": ["code", "and", "other", "types"], + "client_id": "the_client_id", + "client_secret": "the_client_secret", + "redirect_uris": [ + "http://example.org/rp/the_redirect_uri" + ] + } +} From 1b7bcf0dd0ca53dd6ca4a1d1643934fd3c49a881 Mon Sep 17 00:00:00 2001 From: wert Date: Sun, 19 Apr 2020 13:03:15 +0000 Subject: [PATCH 179/401] Exception handing on context.request["SAMLResponse"] KeyError --- src/satosa/backends/saml2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 13349ee37..2c37e6a2b 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -311,7 +311,7 @@ def authn_response(self, context, binding): :param binding: The saml binding type :return: response """ - if not context.request["SAMLResponse"]: + if not context.request.get("SAMLResponse"): msg = "Missing Response for state" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) From f3b250ef28ceefa3a3cb4e91953ceb9e72557e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Vysko=C4=8Dil?= Date: Wed, 6 May 2020 18:09:27 +0200 Subject: [PATCH 180/401] Fix the cdb.json example file * Key must be the client_id --- example/cdb.json.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/cdb.json.example b/example/cdb.json.example index a64750b0e..611574b5d 100644 --- a/example/cdb.json.example +++ b/example/cdb.json.example @@ -1,5 +1,5 @@ { - "test_client": { + "the_client_id": { "response_types": ["code", "and", "other", "types"], "client_id": "the_client_id", "client_secret": "the_client_secret", From e5e504468ea56862f9644be05ad27cafb26bfba5 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 26 May 2020 01:53:53 +0300 Subject: [PATCH 181/401] Remove mention of SAMLUnsolicitedFrontend frontend form the changelog Signed-off-by: Ivan Kanakarakis --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d22f425d5..8a0324494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,7 +112,6 @@ Trigger new version build to automatically upload to PyPI and docker hub. - Add initial eIDAS support - Support memoization of IdP selection when using MDQ - plugins: Warn when AssertionConsumerService binding is HTTP-Redirect in the saml2 backend -- plugins: Add SAMLUnsolicitedFrontend frontend - plugins: Add SAMLVirtualCoFrontend frontend - plugins: Add extra_scopes configuration to support multiple scopes - plugins: Use the latest pyop version From 047eaebeb7f14c73a1b8e6b4088f4eaa4faae95f Mon Sep 17 00:00:00 2001 From: Scott Koranda Date: Thu, 4 Jun 2020 07:19:33 -0500 Subject: [PATCH 182/401] Add PrimaryIdentifier YAML configuration example Add PrimaryIdentifier YAML configuration example. --- .../primary_identifier.yaml.example | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 example/plugins/microservices/primary_identifier.yaml.example diff --git a/example/plugins/microservices/primary_identifier.yaml.example b/example/plugins/microservices/primary_identifier.yaml.example new file mode 100644 index 000000000..dbc13dbf7 --- /dev/null +++ b/example/plugins/microservices/primary_identifier.yaml.example @@ -0,0 +1,51 @@ +module: PrimaryIdentifier +name: PrimaryIdentifier +config: + # The ordered identifier candidates are searched in order + # to find a candidate primary identifier. The search ends + # when the first candidate is found. The identifier or attribute + # names are the internal SATOSA names for the attributes as + # defined in internal_attributes.yaml. The configuration below + # would search in order for eduPersonUniqueID, eduPersonPrincipalName + # combined with a SAML2 Persistent NameID, eduPersonPrincipalName + # combined with eduPersonTargetedId, eduPersonPrincipalName, + # SAML 2 Persistent NameID, and finally eduPersonTargetedId. + ordered_identifier_candidates: + - attribute_names: [epuid] + # The line below combines, if found, eduPersonPrincipalName and SAML 2 + # persistent NameID to create a primary identifier. + - attribute_names: [eppn, name_id] + name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + # The line below combines, if found, eduPersonPrincipalName and + # eduPersonTargetedId to create a primary identifier. + - attribute_names: [eppn, edupersontargetedid] + - attribute_names: [eppn] + - attribute_names: [name_id] + name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + # The line below addes the IdP entityID to the value for the SAML2 + # Persistent NameID to ensure the value is fully scoped. + add_scope: issuer_entityid + - attribute_names: [edupersontargetedid] + add_scope: issuer_entityid + # The internal SATOSA attribute into which to place the primary + # identifier value once found from the above configured ordered + # candidates. + primary_identifier: uid + # Whether or not to clear the input attributes after setting the + # primary identifier value. + clear_input_attributes: no + # If defined redirect to this page if no primary identifier can + # be found. + on_error: https://my.org/errors/no_primary_identifier + + # The microservice may be configured per entityID. + # The configuration key is the entityID of the requesting SP, + # or the authenticating IdP. An SP configuration overrides an IdP + # configuration when there is a conflict. + "https://my.org/idp/shibboleth": + ordered_identifier_candidates: + - attribute_names: [eppn] + + "https://service.my.org/sp/shibboleth": + ordered_identifier_candidates: + - attribute_names: [mail] From 2801eb1a19feddf5e8571a383368cfc30b683295 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 9 Jun 2020 00:56:49 +0300 Subject: [PATCH 183/401] Make the AuthnContextClassRefs available through the context Signed-off-by: Ivan Kanakarakis --- src/satosa/context.py | 1 + src/satosa/frontends/saml2.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/satosa/context.py b/src/satosa/context.py index 196cb6f4d..a30f67c3d 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -18,6 +18,7 @@ class Context(object): KEY_TARGET_ENTITYID = 'target_entity_id' KEY_FORCE_AUTHN = 'force_authn' KEY_MEMORIZED_IDP = 'memorized_idp' + KEY_AUTHN_CONTEXT_CLASS_REF = 'authn_context_class_ref' def __init__(self): self._path = None diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 168dddc66..545dbea6f 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -247,6 +247,11 @@ def _handle_authn_request(self, context, binding_in, idp): idp, idp_policy, requester, context.state ) + authn_context_class_ref_nodes = getattr( + authn_req.requested_authn_context, 'authn_context_class_ref', [] + ) + authn_context = [ref.text for ref in authn_context_class_ref_nodes] + context.decorate(Context.KEY_AUTHN_CONTEXT_CLASS_REF, authn_context) context.decorate(Context.KEY_METADATA_STORE, self.idp.metadata) return self.auth_req_callback_func(context, internal_req) From ef00df2cdd0882ad7ee51d6b3d3ba925f3fcc9c8 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 9 Jun 2020 01:48:37 +0300 Subject: [PATCH 184/401] Release version 7.0.0 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ setup.py | 3 ++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b29a4f3fa..767a45603 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 6.1.0 +current_version = 7.0.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0324494..d365ba66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## 7.0.0 (2020-06-09) + +- Make the AuthnContextClassRefs available through the context +- Extend YAML parsing to understand the `!ENV` and `!ENVFILE` tags, that read + values or file contents from the environment +- Add `satosa.yaml` module to handle YAML parsing +- BREAKING: Remove previously deprecated configuration options: + - `hash`: use the hasher micro-service instead + - `USER_ID_HASH_SALT`: use the hasher micro-service instead +- BREAKING: Remove previously deprecated classes: + - `SAMLInternalResponse`: use `satosa.internal.InternalData` instead + - `InternalRequest`: use `satosa.internal.InternalData` instead + - `InternalResponse`: use `satosa.internal.InternalData` instead + - `UserIdHashType`: use the hasher micro-service instead + - `UserIdHasher`: use the hasher micro-service instead +- BREAKING: Remove previously deprecated functions: + - `hash_attributes`: use the hasher micro-service instead + - `oidc_subject_type_to_hash_type`: use `satosa.internal.InternalData.subject_type` directly + - `saml_name_id_format_to_hash_type`: use `satosa.internal.InternalData.subject_type` directly + - `hash_type_to_saml_name_id_format`: use `satosa.internal.InternalData.subject_type` directly +- BREAKING: Remove previously deprecated modules: + - `src/satosa/internal_data.py` +- BREAKING: Remove previously deprecated properties of the `saml2.internal.InternalData` class: + - `name_id`: use use `subject_id` instead, + - `user_id`: use `subject_id` instead, + - `user_id_hash_type`: use `subject_type` instead, + - `approved_attributes`: use `attributes` instead, +- The cookie is now a session-cookie; To have the the cookie removed + immediately after use, the CONTEXT_STATE_DELETE configuration option should + be set to `True` +- Create dedicated module to handle the proxy version +- Set the logger to log to stdout on DEBUG level by default +- Cleanup code around the wsgi calls +- micro-services: separate core from micro-services; drop checks for + micro-services order; drop references to the Consent and AccountLinking + micro-services +- micro-services: generate a random name for the pool name when REUSABLE client + strategy is used for the ldap-attribute-store micro-service. +- docs: improve example proxy configuration +- docs: minor fixes/typos/etc +- build: update CI to use Travis-CI stages +- build: run tests for Python3.8 +- build: tag docker image by commit, branch, PR number, version and "latest" + + ## 6.1.0 (2020-02-28) - Set the SameSite cookie attribute to "None" diff --git a/setup.py b/setup.py index 0f9fb46eb..2377459ac 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='6.1.0', + version='7.0.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', @@ -34,6 +34,7 @@ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], entry_points={ "console_scripts": ["satosa-saml-metadata=satosa.scripts.satosa_saml_metadata:construct_saml_metadata"] From 4319bd688cae45118ebb41de7fa7d41855f83f78 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 9 Jun 2020 14:28:23 +0300 Subject: [PATCH 185/401] Fix the CI release process Signed-off-by: Ivan Kanakarakis --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4e80014d7..3fa650542 100644 --- a/.travis.yml +++ b/.travis.yml @@ -135,7 +135,7 @@ jobs: - docker push "$DOCKER_TAG_LATEST" - stage: Deploy new release on GitHub - if: type = push AND branch = master AND tag IS present + if: type = push AND tag IS present before_install: skip install: skip script: skip @@ -147,7 +147,7 @@ jobs: tags: true - stage: Deploy new release on PyPI - if: type = push AND branch = master AND tag IS present + if: type = push AND tag IS present before_install: skip install: skip script: skip From 74fc79a7816836b4af23dcbc01a870177f99738a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 9 Jun 2020 14:28:59 +0300 Subject: [PATCH 186/401] Release version 7.0.1 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 5 +++++ setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 767a45603..489e6c1c5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 7.0.0 +current_version = 7.0.1 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index d365ba66c..c813a6ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 7.0.1 (2020-06-09) + +- build: fix the CI release process + + ## 7.0.0 (2020-06-09) - Make the AuthnContextClassRefs available through the context diff --git a/setup.py b/setup.py index 2377459ac..3bfe6d94d 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='7.0.0', + version='7.0.1', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From a7430c0b5c5e1350f0b20fd5fd258a4f6a02e012 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 10 Jul 2020 12:44:23 +0300 Subject: [PATCH 187/401] Fix SAMLVirtualCoFrontend metadata generation and example config Signed-off-by: Ivan Kanakarakis --- .../frontends/saml2_virtualcofrontend.yaml.example | 6 +++--- src/satosa/frontends/saml2.py | 12 ++++++------ tests/satosa/frontends/test_saml2.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example index 5ab44fee0..111dbf732 100644 --- a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example +++ b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example @@ -2,7 +2,7 @@ module: satosa.frontends.saml2.SAMLVirtualCoFrontend name: Saml2IDP config: collaborative_organizations: - # The encodeable name for the CO will be URL encoded and used + # The encodeable name for the CO will be URL encoded and used # both for the entityID and the SSO endpoints of the virtual IdP. # The entityID has the form # @@ -12,7 +12,7 @@ config: # # {base}/{backend}/{co_name}/{path} # - - encodedable_name: MESS + - encodeable_name: MESS # If organization and contact_person details appear they # will override the same from the base configuration in # the generated metadata for the CO IdP. @@ -23,7 +23,7 @@ config: contact_person: - contact_type: technical email_address: help@messproject.org - given_name MESS Technical Support + given_name: MESS Technical Support # SAML attributes and static values about the CO to be asserted for each user. # The key is the SATOSA internal attribute name. co_static_saml_attributes: diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 545dbea6f..752ff431b 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -919,7 +919,7 @@ def _add_endpoints_to_config(self, config, co_name, backend_name): return config - def _add_entity_id(self, context, config, co_name): + def _add_entity_id(self, config, co_name): """ Use the CO name to construct the entity ID for the virtual IdP for the CO and add it to the config. Also add it to the @@ -943,7 +943,6 @@ def _add_entity_id(self, context, config, co_name): base_entity_id = config['entityid'] co_entity_id = "{}/{}".format(base_entity_id, quote_plus(co_name)) config['entityid'] = co_entity_id - context.decorate(self.KEY_CO_ENTITY_ID, co_entity_id) return config @@ -1026,10 +1025,11 @@ def _create_co_virtual_idp(self, context): # and the entityID for the CO virtual IdP. backend_name = context.target_backend idp_config = copy.deepcopy(self.idp_config) - idp_config = self._add_endpoints_to_config(idp_config, - co_name, - backend_name) - idp_config = self._add_entity_id(context, idp_config, co_name) + idp_config = self._add_endpoints_to_config( + idp_config, co_name, backend_name + ) + idp_config = self._add_entity_id(idp_config, co_name) + context.decorate(self.KEY_CO_ENTITY_ID, idp_config['entityid']) # Use the overwritten IdP config to generate a pysaml2 config object # and from it a server object. diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index 3e89fd2fa..00890a56e 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -541,7 +541,7 @@ def test_co_static_attributes(self, frontend, context, internal_response, backend_name = context.target_backend idp_conf = frontend._add_endpoints_to_config(idp_conf, co_name, backend_name) - idp_conf = frontend._add_entity_id(context, idp_conf, co_name) + idp_conf = frontend._add_entity_id(idp_conf, co_name) # Use a utility function to serialize the idp_conf IdP configuration # fixture to a string and then dynamically update the sp_conf From 106962029c961af1748de384fc2590984b2aa911 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 21 Jul 2020 15:31:53 +0300 Subject: [PATCH 188/401] Add middleware image --- doc/images/middlewares.png | Bin 0 -> 64995 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 doc/images/middlewares.png diff --git a/doc/images/middlewares.png b/doc/images/middlewares.png new file mode 100644 index 0000000000000000000000000000000000000000..2aca071ad661109bc286531a558000196e3a9937 GIT binary patch literal 64995 zcmeFZXH-*L)GiDdRP2I^*uVlvMARr|WsY;VxLXjFg zDhLV00)!4q?@I67wG;H<{q>D|f8Fmro^gh8@4ebwbItn9^+a1!W#_g-+nAV`cB)>z zq{GCtiNM6fdSUBk@Fd7r;V1YDW^-QSJQGuH(DvmUtl*zqwihpI+h0_<=&> zrL~H+leLSjrH;x4CZ==GpX;05I-IR9!`X+fZ>!%_P_kc#f{oSxa;rkF1lzKyrFf#)Uq^MRLHBAgOG(EdS>6@-Ex_{Vc@ zer0&=sE}EzI*XLncJ~64u8aS8AzKg(D)j@T1K`8a=N$I_XcCS<&Z z-ALMC-|L%En2z||+Y>id;~d}JdN}3yK7~q%Q+zOS!*oeVl6T{Z$v+}`;zJ9+QKCDV+yEol{TiJ1jYkNXTfkeU+yA@Z0iJy5Irt~os| z^keipw+}^<*C!|CV3kiwOkVz~c3E=Njh!6m8dX4+cbb(Mhtv602GHauqVybVykGTHz0_ zm=eSK1?0lWvc#;=vRz`gZ?EC6BA4pxl+rx)_oEI*Xm|ZXj+=W9{QN@r_~hf!sXM=V z4R>Gh!PgK5vZu}Djh#hzUCD@GU9?*;;L?14DP#Knw12*EIY0LFN>zCswia85t;fDI zV|gJ*j_IlRE_$0AjjWorn>uv8%jynTk+@skewj~#INN9y~v8xoWJJGNS0 zxYmrrXO1i#h!cxdIJ(P{yFX>gv_jN?`N>i)%e2O-zP{ej?J$9(2F@DiD$a8RoVsWg z5GJKJ_ttRg)roMeJ{{XVx^lW4sksg3cl#5Q-QpctmX?n;raI;641cxk#IhKFlXV)| zS-0OE@ip}3p=>EZ{LA6N$8Qck`lKFsOV_{E@%mxbiO%Vu=_rZP-5e&OD$z?jDl3$J zjV_7)2+U`~AGwFCKIwFjTDzr__1x5h*1WcNM_*j^-rn6XH#$pNl(_4&(lFHi0i7`* za<4#IE{4=K;o79Ayq3;n^W2v?KF(Wzlj-&eAT)R^uj*(pF?sSaG2MH>#Iy(=-5X?L z!iY054O=iV$wo3Uao&zDxh4-@+_g4RMQLa-34-5SnOK?GnKpr6%-}yJ=0i+CW zIQWb0_Elpv6Vt&c=znHa%>6LXgR!lik*kr0x~!$Mqv#DQ=bP4|cO7p-t(fHR%7UMc z)~+{r?m9X+p=IwXoS=Up3w}e7#ZK_hKXJ8JIANrr&2!P&#hOP_^sK1(3B_$ZJUsF) zRw!AWOIOyLgMTTUxaI14TUJcW-Q8W(9WLtZVk34&Mn*VB> z34M~$$?twHS)(moY;U{TIy><|{oc6gjB!;saRM6X&mYD-U2ReSj^u=1Ulv%P81zQ$ zjHtNSpT0p;dFZLEmW!=57#Zqc@r*qE%fFtjk0UPz4ZdMAjFr-#f~6{MlNb9#HpOk~ z|L_|#F)1^tUOKOLmwB{fcZPK%mU|IwAZ-ITqnBTM%8^*#w)vg)w`+KeS9LTU@lh9 z^_)ICGYf3%9v&xIrdlu2I!pMI0z5Vr)jRgM!>yb+bW@QC6(u|$?UOO-0|x~EZJ}gR}4a+^CEr) zS6p0t2A?(9c;fcp=U0ti!=BjrPXs+Vl=bTH$dctPS$=k&9106cB7(~lvyo>bQOt0{ zH~EsqD;C>WmwA1+ZJD_pLHY0;Ws3C;sJc@S{$t6q;Q8a(Jv^VaD%DX+$E(>k9v}+~ z!10xO66_m!R9T7n{C{6=pvZqz`j12Y2&waXa+V>xwdQ_RXt=)HR z1@kSte72RfG}xPF=5B`R!p`aQI?EJA&&SS{rO2*Ke@jmYQY?`jN(`-aGJzDi5g!mY z66ez&0cu|EestLB&3N%Z?4(=g?BYmvwi9V(R`X{h6P%>&j;KEBZdzbX#t$cOuiWDI zE%Kh-8wtZr%8225Znm1%kGa(+Yec1$j+cx)R_b;o+jG;Gbfwaz<;yw2hoJvWY-O{p z_LzGFre8@B=4rc*%If*{VNj+!rsq)Inty?7`s*Od!dfu=qF11jb+9=9W z5mo&dln8vpWjHnE*sJg)-x}Qt(YM`x!wUm(*;Cj)A*9R+(otCQWgbLTcG+aD-O}vX zb~gdvw*p<<)?RZR?uKkr$AoZG zB|I|GxwsFH+orL)e6!qrqI0+sw`o(%Ne-Oq-cJSgq6~rHSJDNuxK0G_xT97U~*N#qC;l}3IvbWyQsc$3s z`kI-uXg;Yt%VyPo24P0rF)P=#pjlu;ja@`3v7O@#jrE?akzIPoY5q3yTQ4s#h;Y0R zdF~D9fko^%`SSyZRs-oA|L3yhNpgCqmSVB&>U>Xn`<~N?O{Gbk=67Ey;(HcqJI+cw zG;7226VkV71Hw#Ab#AC|O`5V*dQ z?(sZwPsRZ-oGZ+wyctbZFciCkT^vdoiJG@6Qh(X%g607HK4NrTRoH#S7YmgF#>uHe?-4LKm1+VC5u*8&2!%$c$iTzpE`6Z#Ey9m zzFOR@)F(qAFc*ZCMd7hfzuBPQvI;eRu(|R$mlbQ{RhoR{z@|=*ff#+1RY*1=rQNKM z&}Gl^O*ojhSG71-e`>d2xzqBuveMDBrGtk7v-6#=lq7_TtF>OeSE;wPcUMmFssxg^ z*}+YCX>vP8$7?p3K(%Kf@@QvC`ph&M4q)a8HnD`c&ck%>!UBW1QJU}irF)fO+pJp5 z@#TDd$qGfCp7vODh|9^i-Ppm4Gr96K=en1YGxlqHBK2{UPX4~EoCWim9_-}S&`JMD zW6T$4(3!d)SPc6W)x&IiA`ju@|FI(DMKHcn&Yh-xTt3CdH5i(i;aFQyc139aSW%av z(K-Mw&q`3Rw?d!2=4Hp1MeP`|O z?;j3L8B#I8SEJY*RTr#F6z&sxYG`9dGR}tzzn!Zf%ZPsKj^J8J?4ODFC{B!=@akF` zw%oG&ScVNeN_wWI*jV6mSzvLr+|D-JgI%@(1l@oxlHleDFH(RnEq6{AC=* zgGD+#yQ}qA>x*C<|77%uzsQjf7U{Tm;;+^gw*YxRyK0`=upA~_;XNMWf5iOFNdFP@ zFW&l}!I_oUF2)T(hd1M1WqW)3wuUq{meAwfOO_Hq2Y==C(RCp-1)gvLQJHLUN;W2TV};ApE8J&Q(w8X$QH>G|R{v zNh}D|vj@-<6aq+dlb*~Xx`MH&VYx3HE)s4eVRFg=;x9R%6-%BygQu$R~38{Hg=r*i6kLMz9@$M_;t;f>s!52c*2- z?(BMclneJ|+mRXfz%7?(o#WQERB!JedjZ{XTEP(}tA$aVjQY(G`*Siqc2dhjyq#1L3 zofgpaD{BEe8#W=w{tN>| z?(S$q>F|jU0nKUlgU(|m6q{S>33+Z~t!KhBi~wN*R4Uc`@0x})*qE`WIKn=_y}daj zXE%@zRt0>LBSGmpe^De9P=s*AEe4W1zdx?L3u~XZVOZsmjG6zw+t&ySEWUL6rQ*1y zVmn~6lJ%yR?XoHshdrw!NXvZ-Tgpz-d)Rn7)t^;v7c0~50QPC4bj7dd9%hRRacc|1 zMZQZ#>6nqGx;CY8=`pu}QpG;{e&QMh!R}_BOfZAC;At_jRxe{lGK&lh%`o~@i7VL39^@5~G{{~j z%|<(!_coVy&SZJ|;=ixouw`?V&5#pUR&NgI#xiM7=U zS~ihnJf|?%uG;~;nijK^u$9V9yIuX-U^&R8R@(5rw=G%rS;fhV&%5TId(4DOK(Yz& zyB+6k_m)P*3$1idKxOMGCb`*W%Ms<Up&O%#UYc2FSHVS~}#!G^!|0zVM9Np{2AkUFN;EptLryxWcda>xst_ zIVxX>88y7n(Y4Ycdsm|cxT7})u3TOlQd&tF#+x_|<)`L)!|Z8UnOgSdR00Ht6Jh&RDERjjQ{IFHB9Ew}Zra z(Mp%Q7q?qA{aRg`$j-h`*)iZdzkBVKt^sNbx+l(QXuxNDpk!=(6d_LFflCjm1{3W0 ziO~hHqL=_Bk2@ej)s$Vk^J#1Klp;J`wHO7rc1NtVHjW)xqKmUiHQ*5$dcqp$e$q4c zPwZcxiB-H+Ou>p+x~)!YtxbbykGcg)V(QHcl$vqhU9|5KX7be4x`3W)rPW`asSe{z zZ9+-0uvcR|@F0O2mzJw7K5x5%+C@ggIm%XMo!8uK z9_4ssq9&!M6sIWgm;nQP>0P(dXLp7vM=e8jd}5!ssE?uBF!(Y8b6vq})#3&1ajPhc zvyN(N+>^4@8Fg`4ftn8MYH1z7z2kX3a03ZumW2H|rf&JMYLGa`S?~E2bDg7?_o~-q z4S%}QYL8OZ>KCO&70hk?$iX(68lMqaqLISq^XnNhBO-To%6FBTPNXV&ne#;4%QjSt z*?p{{9_}6}LNk?`Dx`{FlAoItIc2uZl1am76@*+3?%Kc+o1}3tmj@FC)P68i*BCi4XGD!Wi z;EH^|M{F7X7{9PtP@QkRBNSx#!y9CQ#f0PDzFfz4c=PLnXS#R(qu`mtXADi=G<(KjT1_Xh!5RV;qwCTK0j_%cP#|v7B?zfB_2cz2Ijz0pR|l;16No%ZRwdNMFv#)DfI#Royf=AO0%meqkC z`h1^FNj@uO*v<_C0?M!C*ZO^d>wxa<)yIj{ z8NL7|44i}HMqkwDmSK4#c`}ZD8?J=Z5|6f&S`0yvKhqnROun@x%Wmy2z#|UMRaPucX^= zd)j?)sBk&JmtJfJnNrUWxuc&v#?WZZT6vJRldYa0t*vJDr35rAjS{H_P;v&-WNg!j zly1L$G2F%6YplSE5TO>87yjqWfYeeZI2HM^aFf=+qjG66a_+~ztU&@t#tmdtkK7Sv zwg?>SB9X2WQ5slzTp!5X8Etv&fnEMpZCL6ymX-xFs>Au;H04g*QorW`Y+r+cXtZX5 z$5g*vR|U55uyfh94v=EQ$neK`PwpKU?I!3CcIp1!bW_i_Snt&W-nbaj29%VjGv8T6yiHh{g@LKrrD$BA8rW z$Q#JQaKgpnSx$g^AVNzGXfhLrD=$ZBYKV(;QtVIa9bm1!CAd#&bBe zvFiC@xl$+(?1_gn-y(KeeWDDC6t#b?8DZTZC&*BI#3MST1P^7nV^t}_R#S&Q2|v_W zY#&fGbZ&#k$oRD1sn!SC^z0ztQA_UhBIDUsVSYJ|v(UE1l(iW8H3(;qg|px4A4v?= z91j2Jvq9!Cv|PV1r#^~%t$X)s{QF>@+aUk0@Ap|FN~#ea(`SLk6Gss_1n=T2aD*v* zgm8E@e^Y&k64@&BB{8Zmk`WkWSd2oD&A~O7nm!ZZGZZnbXkFv*74r6yGC0j#udOK3 z%%p5&$-*{X2PSi?kEM^7PE^>9wHD0OdAPifYcJ!MX0Gk9Bx7Pq-6yihbp?@q$>(PR z->>y%<+5o;A;(XdxH4RKx)um91XA0)iy)vtzDArbXdfIHN{P!_GWYw${s2#?&ub`9 z*iJB}3O~aF=1K!RonArEa_XJ}(!I$&2-}U@GVB7s__AT_(FOMoj3HLK751i2XzA1j z>Qn*KYLcCP! z%3zon@B|tWV&x4BcK9L54cplRRDU4NJz0$e($piXe1iz>=3OT^#8EAFgudku{EAFzHAf9}zt-p8jb6KgLR z6|}#mSWEubr5FWVzp+h>{+7qZ9$F}6Dg+}i%AyJjkZsArD3&^K&@<8Z6sf~=&n)y0u?Am|#IZ=w{?{H?GI z8GICrq^{44x}AP`u2R8x{7wKm}My?5|(t))xI^422{`(49c zMB%M3e-Os*<`<`^y~x;RYwj9aNDEx-2ZFBC)I&p;jz|4=c3xZ8cmpY$b4eMV{FLNm zjA(1Cu3c?tbWo{@s6hFaQtK*CIeQ={3#TF`L&R1PN>KyhJxL0E*lb4wuT$3j#Yi<1 z9UJ_q5+g_rT}x)}b>tQ$L>*hsVAXN-p|GgwnDR zhF%_FXhg;2>Q4&oRhVHdWx;XdO1m*LblM02dU1Bk%9)f`H*q1e3;Xo*3ykspqr9QxUc#bc#uLXxy?D z)(nlm=QCH@mAPvRYvcXhibhEPqOjNjr_WjnGkP-s!9=4WQZnSm-zM0+;9mXdKjPNw z)qA_LrP$W6!oBurlG5ahpoXzpOiOW-?3hL=QLP&uw?NrzJ<&o;kv-zt=s|wh{%({ByKrcvf7%c&A!auBwPXFSz8H??6+h%_m*J@(L2^9Kn`l|oO5?9_sqn%B2k5MYtk z^jur&LLx1ZioRTHiS6AY2n^k!iPQw%gKJ>Axk#w8kBs6(+0=0Bt~Cs)WT8JQA{EuR z=b(DLun7h8&%&mwPJS~W%(>HTlBIZZ>#Oz*A%V8{M5FDSvvw;j-;Uvbr_%aDS(y2z zUl8mB?MgqenEl?2B601-tfaT~ zf%G|Z zcqt7=v0D|EzZ{5Yse(OPAkdaQr|P6fGOR~m4)kHhx5nzp4@>*63G`GPabFR|Fw&Ki zz!;05nK*LrX|vQ))O*KsC+=3u4VP-+RnimX7Lw+}4b6ur(dtH=lI~AgJy#~ShBB1m`XPhtlsCBY9!V29n{5`6tp?Xp!GvoC+fLm!uSM7>|jx4BDReOM^NKNI0LQ)`04O+|*nk~%6pS&Ikae0|IuO3TZK^Wq?GN>%~f zq+nTmLP3D6GPNC1gv>Wcymm6DM)>&>^A)?8@)SHZr8%n%S=ivCJ;~;hr$2;&TxzeRl*F7QT23 zUzHav9nEVMVYNO`B#d_2^21uj$=1Z|PMmLT#_d#+;jBxCRaF|{>so%%f`{!f>If{( zR=2cVMy$u&X}&>`O2-XFdIJg38}$9@G!Mxe#b#@)_;O36jj_!7U80r%KTWKPw9G8FxrVkw zhvRW|(((~eY6_*|{I~o56XNYzq_$#Pa>=?XM~0N%{*(QoEJo z9)=Mf$zR|RAZ_ge5fS2NM{G0?Fbo!s)oEOJ;rzDj5~p%MjH}>lxVIL6E*j=D<%!*Drsz&7r##6NU22V?qYks-V8kf(c2oua4%J27Tb%$0A z1YKSE`dqrxDC&a%&I|9?U!UKB+Dn|46tIp6lYMH7a!07`dFjK)^UQZ)lX+kl2;jsN zS~s0V<&q<@Kksvowp*lxH9AKzNzH`K&XBp6^2b1LZ>kg9e+sYEViI|=;PNEO$(;Gewf7#azplc}er zn&6*CV2ypo1Y?~x2P)41`5TglJQ6bb*#BB$fjzD1Bc(Q|5_Ujo@k@{_vzh!5FXrr!*HmWa+DrNKeY{h^GCNCs_x1M{JVw0-38WKaQ z8rZylMaPG$R!n_i#I2asx4vd!!MbqybqLZ9jkQFnAS&@lKeVP#s_kkBUScX@6-M+p zcem4bCLNhMd2jyw@Fy`#fYR0Kn7#(%N>UIICzf>h5Y&j*HYpQKhhCuNDh?s z7A=wA7T=)jbp3_J83D|xpJN6pSuu7g!Wbb;e$dV-MVyPVs9~#=x=j6XaXVa)&ve4f z+~tZb9NK(WRqqj~=}hQ`)*i8mBA=0>;g)5}R@CxjI1?OUxN^sSdDRZ`VR7$3sP4W@ zP2--c%KCm4um{G;;fFPQBWRT1eBGKTWAZY4Shg^+P9233Z98vg{3xol#2nM8kGbOx zyzsJ{6D1>Ai!0fy!`V%3V!jJxOm|ymAS`T5mOVOoXW#e1scqbo8a{WlTuN7uGsj3B zzsHz^A?I#%-gCT`t}_u%lZmS?AlThxykYEm(LJi6+cQT-ZxA0 zU7^X%Sozj#RKT8k#5wxp)~+u96P}dThb;}1UoJN}%6o21@2nqZF|*3z%f3=}T;r)= zWg>QE+RiSnP*{^kX>Jm3$S6fi*9YP6>nbRg)^MaL*(LL=^t+qKe1nyR+$|_#=liZ4 zVVkb)*xY93_5C5IEVFH3ALkoXZO_iU1~K^S;)~V4MKFfv3$_P0gJ`iB=qvW|3%DYbh zDLa4VLb2t|qK521rvWIc|J(1b`~d{#GwG-yc%Gi*dE~K^FKh-SBZ@ckf$Zhx>&Mz- z^zal^;Taq%FhUPc!q1fh1xyRJesmURj}o4$XfR{>G%7X|p0_zOp|@x~0YGh8g^o{f%-^e!V*b05%nOHl~%_|JAUwNZL;;UCTlZKY$!-S0q&|58OSG5GUhV&8z` zT(KZcy?-xvaqJ|BA;h_d##b62ffgMie6`QF)sVUihww zIl#=Kfm@m&k*2g=gE(*12K3Lgx0{-2XKgul`Fg3FqgenqF5xp28P{M|%6@ssoP$UF z8I-pFc#USWAriASKoyufd`2Jt%5EM2`${}FyvWG9FoJZrGa#!|4NHeGL3Bz&TtImZ z0tW|F%;FeH76xO8L%EAci~SoW&V)0D5JNxSjKF_055z@Pr$8KtBje^@t)GFYi185& z_AkUxBoH5#e)_^2TcfUlsXt4|qHdVbo`a!aM(R0FH?Y&aN?{PaRGJ(k1LP8rWyOWmC@KfB*u+n;{qUzX>9fQWCte{F6Q&H<$lno~|7 z&|Gi7A&^ah%6{-yOpw)rIjFjqc2F(F(I{!>EVqeHg{H-Q|DqKwBmr9*_Pn|E?ZdWq zP(Gng_#zKn^)yhv<9c)H_4hx-g*^gz-CYU&M_3eK5>X4FB1ey2Tm|xh!@yz6rdKIJ z&=?yjv-Flt^-jO%i%Z=b2`a)hG@M-@R0{4rce`5|10v@Ja&DuMwAUa3nR=a(c-+A! zE-h_n(_ZAo9%!&yxWEY3R!{*R_b*P_^I2|UsMd+ zik!0x0o2Uw&d2>Ry!qAwV>_-53jU#K09#}Y14PM-cryk_u1alLShsv(JLC}EHDEd5SvpQPG{sDGcMpr^7E(} zex>;y!S$4dFa+&1I*L>} zK~f&HPS%L0eFbUzl+3k7I?5pda7kd*H32%`SiEK}5eXMLnrBsbwV95S-~o-#hxIZ5 zp}L}V}^fy7q+i%0Kk@UBB!CMd_J-?oXw|E7>$_c-L z;Clx70n&iK_Z)pmkuJE2#(bO25~aXJ$P5cP-2fpq1Xgk@4TCR$wJy z3rq8wRsXeF9qAy^B6?hNS??-9Xa9%{n*qujYTwI(^*rP6h#Rb+q;-|6k2_ zKzQ3Lbut@2Srn!iN=y~V*RfVu%M(RA|v9xXl{tXt?=%wNsLA!y1$$$>u_ z9{Qnd2nvo256Ep))5;bH0Z)kA)&C7J&H_x9CG57*nz&af4j$fUi2e=5VJzPaoT&c@ z`kNI1ng4%D(Ay2-{EYLFjIjttf5k0OmpSp3$m}&kglt83&8~Is z*9BrRy5xA{ac~v`47$5Z9^R+;RI9WdN?K0>rkerX)v9t8f}!U8!?In3P@w1baErTHSKm#oSyC-`LE;JO zJn#bGhFh$=j>#L1wHFiV72&`%q9Y<^C%TY^lOPFZXy25<<@iwTu$l$Pj$e!!IK@Kb z`?*j%SDRy5;oMbmLkn{ZA|_Ny`Q?4euXI%lfZl3s$}~xbYO!sIv*R5mnMhK_z@04& zeuZ{Iy#$ENuPWM0hNc#C;~kCqhGzOyBdNpaeu9kw%@nOM^ShXSvWgZ>x&|cwPfw zn&t}^Cj9sVWvCQ}>X~fwXE0VQpqBr7!HxHi>I?9MR}_gB2q|lYIsNaQIhCijy)7L* z75Gvr`;Br|xx?njAovl?5@3-WN@=p|uqB6M zJ`F)C8Q{_Thv9N|qDYNSe8pxTX&1^MLO zJhEt%*;a*r3xUM%f!$*|WsEZ9aS!b%us7TpJYb==J{71dVF-cNT>XWMv$+IgFOp;X zjgR^+`WMx2{Ev^g1G+bfqMF6E@6%Ch@@OB3x}LkwQu43&Z1D?h>&GY=%PX+S$dC{z zr*Jx*oM}u1v6Jb43x_N3flioTSw$(}B9K1CAi}Z_ zTUf4x{~+pGM)OD-^%)z(__P|ewuop6kdSSg>u4D#v7(P`FM4!PLTO2(y%XV&W=8a5 z8D{IB%ezkQy*t}tlMbOpR_PZwfY_0*d9h6F3RHs=!{0`%mrbu-4au8^Tgy*>zBse0 z)A@uy)~LgCB$L;qT!eN4x+g+EFr@+lv6;s@IkW3^+CYgda;}e^IyYC^LQCT0dHE)MSPYh}18n}H*+OOTg-1B&ozdgKspNjQpNB1}(z)1tL;uMDa* zYDjnJZ9&OY_^d2KwN0rI@=o^i(1G?bfIbkN0Fo_zQ{lca3yC49&Z}Hw4+grEgpPFb zZ-YQ*OI|CwC?T|Yt%jV7LJ!btBf8-7l`Qj=CJeteLi?pG+J}WW{T%}HQI{H06!o1E zv0-s#ygb;|g#pg+?3PGaX_+6wbPpkS&HCLSRfl z3DfdoBpr)JHI-5nB7gyMIBK z*~DbRvQy%GJg4%9K_oS1u40AH-A*JmKcVez#J4 zd&d-pG+Fy(%;GfpDyJInW9W_)&f2CKBeos1OcUD7U9^pymPZ_KIpLj1>Zmixc|Zv9 z^c#so^V}<|i4u`2W6d({+EHhgI^iIZW42nT=#EUc<1%zFIX(_(Ri6utvhEPpV|Tl8 zn)e92$^N^Jrjcp`25t&{(r^fAg~#8=hqhF>9KDHN`Y7g;YqK}0UP7)-#4=mHYi)II zWR!R7v9rk3lLO)Q*Tn#8Jg;;F#C8g1R?ja+-jhaP;fSbv0EEz$k(~*O49$dc7|!T4 z2qnxiyF^0z2`?H7Ag$tF?{C5yz)G?S>M!{se7HWOZT6@mqQU{e6Gf}52&dI4;_Yw1 z(+T&8rcp1WxK!zC$ifI{_8gabdVLU%Rq-cocZ0{o+nzw#gNWOqeMDX}kQR!O%f@53 zJCY@Xioa-PNvq$Bs7yzGzV0*{>J~P#k64!=H@P*)#WLOrQ#7I;8y%ID%;tm!NqxS2 zP!T8~4J-Lob$I0U7RJH#WWU6>b+oZbM}-(v(>7|k43An|og?|iE-iJ=Rg@H($_X)I zLpOju@xE4u9-P^HV7|Fp(O^JhPh|D2-j{X4HNw*0YF1I7Te7<;wblmNVG$A>cV|p| zqNXH--U8#Y3Slu0i_I+PhB&iP{dR$`MKJpKsCSfMz$WQlK24oX#WAFkW^a58`r9hz-)3cL6%&gsB964EHFvi5%L~-SI2u_5lCXb&rfsNM4!nH;tI{ij@e_7UstF3gT!n_Qypi?Bky3#WrrxLq9DEn;ZVyh(`^v@ zPLobSNm!ZJ-^h0%3nuL#$Q|%?4>UI4+OK}E^1)UkQMu!rk_i9WF~F{d0X{kv5JE2! zTDP;5EiM3$jYC5F<;Lg?P8EvD{d3xueyJAy`~$Y+zv}dy9s8kFdfAtF3=5~} z?ekEz$MEM@7Zv2rU5$+emBw@v0)jew0$(8wlvxyXmfq>9f+~bpG|hhX+%GJbkGgZ5 zyniBKGc>PCb;!NC8Hou23rg4Rh6E!mhi2}Tx%f#%Q3o<$I#-j*ioZWPoCa=~PO;xh zL;7MdOJhZl>oer(o#bu~40F2hD&R6HbVzloSm@VmlXh%9E7k?v1rx{4GLJ<3l;BTB zRs8fGaCOd>fDVYT;yySNr($dKJ<|o**v#0LBl>l0*EuYXfdnlC$7=YcHn>LX-6CdIa#OPBz;|EB z>$65pHkmkQQpJ|M9F?Fez5K_^=Q=$QmRjKUGP{9#a4XZA;{3ba^>PY0+r<#$4-G)a zccKKx5Zocs2v4He06uI^hE$w=ge;{f6YCiwZ7uoDMsbR;=-3)Nix1!#8iyYv*`!VZ#klHG^1vH!zEH6lE02-= zhNjmBo~wR^OeQn87VDW2*j>nZatvOsKIY}HBHITUH!=4ln0ugGp_UW?$|}Y>7jxWf zRiNwSf_k-Zl_R+U`|Beo%_mh(z|n!$k#x88z;W%W*WOYG?%2^Xe9SRW$P=e83;_81 zcLyc|*=nH!^XW76NpiCDiwt3R;0;#I(M8qf)h$tY3O7~2!q`Sb*pKvM;R@jDu|{Bn z=|CpWE>W~F(R+Im%*6I0ODZgrfN?0y#g2ok;;v6X{{PW~{5cd%6kAg(5yv~P9#yf_ zG0}uoQ-C1z;h=WCzTNdrB1OB32Xjh`bgMb6Zwx^QwD_N7qU@j-d!8 z->P)nhqX*}=lGQtYFtXzZ|wN@b~h&6*0890D>geFzgI9(s0?=n7))_-MdhIOc4Y3^ z0>5;L%;I3eh;HquwcO7UgH8@D`DhopHd9~_mgl+u3n_e_#nH+U4TImhd z8qdeRvXB{YLs;VrjNRE#UL6*1Ok}_%SWxcaArOAshtAQ{2bPhO$xN2d! z5DEzEXvucqn}1@zON)*TS^!IsaX|L8IM}ef&zFHePFl{xLKw%J@NY&swo4|?<(Nxt z9KvFJpM{w*_=6F$7F+se2ceB1zVoX9MJ3q<-)7H$h48epCUS2zI*LObr4!EAhBJ~H8}0?OIE@n?qUbccY$D`Q&f@g_UP&Bo zt|-hiZrK8iw8b&UqK?Aec>XZ~_c#RzacpDXU-AK_%a#U}12tl-QIZJTZ?lP%Li~Vb z{~aN=+OMm5r24bHO18R$8WAFaO%&8Sd{|7r)YlRTZv30#)2{C^d+A8Eyc!~5(q4gZ zw!NM!%xqP4oP38vxM-w4Y{*>F+go5v$y7v z%IQ$nd!n>U{ZjpF~s-gibtd42EdI2JS(Y=}`IK|rKRQ$UIuL8K`tNEsNVNmpv< z#aN;UDs_MX5}LFDsnUmNM9KitM(U`*AZ4V3l!3d?3^B=F_uh5?AMXFdUB53`W_bI1 z-gEYT_VYa3&W>c(@Yqk+D)M1!5B93(z3Z5gHbOW)8|%*TKx)s@o9~_4nn)s46{}TU z>t$UtN0sm)^VE=rF@Xt8?$F4c6?S<yW%B7 zI$@AV_*GcrT3H)VjXu|m0Bb{{bzczql0+0Q@$2O>EwJ` z+&~k~ZmAtNQgGOW+>}|JJ#bV7FAwo;s~pKo1`{}?5yb0)jpjS{d+>eq4{#3$#MaOB zafAWXr8NVf8n#E`Ld-vX>A<-X!l=3nHvg(p34npP-1K9lK^)FM6xZ+2FYn3+DmXC{PA={F!B#Pk-=f<#XuD z%|u@{i^bZI*-`4e{FQNKREmcRx_GqpZ+DPFhD+W=w5@ofj_VUvteS-ZIYTCXmo&?v zeG}J9up7xl7>!qrLf>V7+^y=3(~&ReOr*TUNEt`Z_s;R3lUo5WRX;7x`64lAY+SZA zn)@yAn=0k$(dHxNj8SO~Pq)Fx{xM6_i%8EGDm!mo17K6;_l_@rYb`EF$EhV<6K9tP zGWJS@l^nl+J_+T&iLoje681?6*s!P_Nk@hl1*H(5WT5B*;31&={RyhV7hU<*IxInS z<pxbpI`9l!gxtPU=iYg; z<=Wx?7rNOmLFkkTA#a5jczVj$U)}Swz+24;cUaRqwCWoyF=Xfd__48~93K*?YGrW* zmX0s3nPcKZQS%hMxQ3NfQfK)Om!@QBQJC(fvO@m+`~{z?H7w^!g3-u@#5i8VnC}9o zJSV4ZCEsX(*V};#_!5#%US+u{2CL&?xt^VR=d3tjms6o%0a>P-1F-79HW#ie=BzHN z44DE(MD($Fg%IA&`9$KmN|@paEyARizwg1PKG#*VvaENDIj8?onl+Ac?E&+(Ow#9M zA<gK)Z(msn`vf3u){StKBjafHHO(r4X-%l3s;B-l0 z_L~AM&1_nc48(Mda2RkUrR%mMt&Pk%OcN3{ z&5nu)Q=Gf3jtAOQ`BM6~Rfy%M)rRGP*6!q>kVCzu#q?rr!;L;5KPx`cY>o+++ib9%wQ*(hlojTe!6h!)E6{ocq3xP)8h%V(oG!CUp_n$M6VzA<0C zjA8tpO%QpMR7UcPxBB87TNDcduY^I(Cy_qr)0Hb6t;D7+*rv^`lPpbxBRTl znsvp>RKAQw56BVfwP}TaNFIq-phnaHV?h6DK!QQ$O$q6{>jL+2!VK`^*ztey=>AzX zW#v1l93%WA6i7JTBfb@c5M=h(Qo^bsu5Aj(gaNdr?BcgF6Ur)yFv5oq8C3jLxnk3Hm)%4^}5)e+IIQSo;Z}eNI@N9_D6-ukFd&v=vn+8*jG@|6}hhSr6bu z=igubR+d6h_AoFVwTE7g?EB}5^Z`xy&*S|2+5J}@=P6tHvJ~`&4rt;X9UXInoxhEM z?~Xze$tyr$zrDaM4a{)mhOb9iW9Scvq`Bs9hXdzo-+Lgoh0oc4b*w-RUt!4MYrEM4 zsWhVx-u|-}XFIxSIoiJSoqD}GL>BIt0>m|wn1p=xYS^%)eEE%3J87I5MHtQr+08q# zn(rZ=8)-1_`+2ohBKbDX^bS$teMk-dNbPo^UrxL-+57C7aP*_}9KQQ|4;)DqGuRQj z{-J%RS^cP5}(;GeR zx-BYu&na1tOD)Ca^Ca*y@>fG=%|e@28zhuF7IT1fyls!fvl5Au2JGxEeb3K}P5xC2 z@61-u=Yug>MyGd9LUifMs012_GlKY|`O1PNtS3djvdtpH9@CIK&YT!dFKYHgFD4rB z7rElleDbMJEO(=npws>du7)K zgLa@Y^COSPk(kcaq>dXZQkxxE!=ep%o@pxv>)0RnXHf|Q-i1Z83fnq+CwXhwRiY5) ziS&wDc{Y!NzW=RM6vxq_i>VEbDgB4O%^CB!9QNQS$1t%^TpqhN8V7!d?nL4#?Eh;V<6V|nacPe`5Bg&tE7CdAdk1K1{kzLqD!g1rwVe8lV^jX$Tgn=|ZZrMBQ zKI=8$@quSBHGZn6H#-JwL-&QZhZV)#dsKSOIW?Zl8<-@N|Hy+U95SE4+k4;J9r`|- zg57V7uL(K)2zPf1u2P}}Q|0w}$lZ=swUAU__rib0g*xv3Wqf?LuQ!Bk@7XGZKk5?v zWeLm^V)eA;Qik`hQ~dhj&R`D&X{B?=sBWgQcDnT6?jJE~99+&W?&~UC-W%h^6Caw0L*kcO4uzE3A&E|}o{WnOCM9;tp#@yfadeCqo z{FlZK4VOc4u)YLUQGKkXB?D>Tw$P256!14|K8axsKv zbuwc4mp~+GP10(Pb_M$o84g*g7D9TC3|yD5{e)#ig7t0{Bh;|t7 z44_zIe8gD`@9_L1$REQ~RZ7ofG)S4a-kOqNlc&yjNAYspT_vUXa?X=P_S(Hf(qVlo z?9c5Atg~rB8a_+}*6Et3HV9NqH!AS+$^&(!-Y>q)KGe12a$_TY*qFAc?@}*XEDgxG z(4%RK>I9L#u=4?VUiQAEX>cVJ9klAYIfOZ7C?lKsu1LB64aiUl-4CSr2NyKVhRYfV$IjE@L?DJb< z3wTZ%p(c9y(bKj^Y zyRO;8EO=v^_}K*xzh{zl*Al9-&RA?eE7fACCRg_OY*B1PfRvuqw5H|ZA1Ko?+!W0x zR}HQ?giZ`D=J+#5DN;?RrKWA7!b$g@HB zEJ(HP63^fWd{aG zZR$YT<4X^Zb`ZmhujV|I|!`x2V=AvMbzfhy3TnsYBMPtCDx!59y^Ip>lru zxr15}Y;IQYx1@yZ4D6dIJ^!5}O7JyWlSq8AvnoMwY}T2q^Mp9&;M%Y)81B z^a;L~ZZDEAJLVUCi-2|=xEi=m!6W6eL!A5LA{E?$n+3*l9;3VQR_~Ir^&AojkbW8R zRk@fk#&ox4U3>BSm8jJdKZ=Hsy=7N>SM3+xZS>DQweOhQ*hat6TB(aXN*-fhLXCrc zhg4r~@a(Df%^@KdeQ1N6_M929s z&8LTn-bwu5HVOLyHj(_2WZLk<6Kl2UzUERd$L$jNZ{KC+RN6iqJLInYkLPfOe1V>L zV=6RDC8+kGiV}S96o4h(fqd|=<>dtTr2K=9eRqas_#O6je>(y89^z1kBJe5jvFx;dympzk ze}fYa9D*oNLTT{No9<9)5gXK`DwQ=O>WsPTIQu=7OVSz)}eo*nT^+)`1Ie- zsrHtP8rtEdL!Ww?839oJHcjRQtJLdso}2}X!FQ%YkKVNT$mn1iP`(Zv8jq3RNHiHU zYFPFi;)49Vv7v)I=UZ=u={QRzg!s9@d@PIXWAeeVW$NKGk(sdY;X+Se^)EIUy?=T= zedN&eebSkVhFe#eR5!+6&@U?9ZQE(Nqol0=<357)%#e={W_ZHm(z@Q3&O~&^X!1N%bo4UFk|KsPZlEz%9iwy?hl7s=N!ckH}^yMnQn5T_hZGKU~Tc%e-3ey3BVVFaVA)=fy9@z%8ooEoGdO1PB| z(*hx2;+H+z$Ij?2bzy63{h=dG@y?S~liyce4q0tCV_ubucb)?vLcpO(u-ou)A<;IW zwaItBx7Z|M+NK)32x=xji?}}dTt&v`rYInP%Ki;bKwfQuXOuOyLC)5ocznM zk%-DAqzUG!n^q(C4M+bD z>9G5us$^e+^BPXE%-LknS^EH|I{tn^8EqEM#lwr9CpJ!xei*A-~@;CL=3~ z3%V!p{=zN~uuzNQEY*zAYQB7@sU+r7nqfy`2Jxh&z!r^>lV(ec_EgTd;bB93CVA5ipDoe08++1_{Mzeb5gF1J# z*xoycx2mG~%yu=7nAz6n5AwAbx3j=7dm8;+L_8A=*Lp? z2K3ans7oeT#A z(@BSn)ztOAAWwK_F=bm7d6&c*)ocad97dL*LbrZs=!i$^QESWI8UCa)hKkRX?kwEj zle?-q`GQqV?}drTcjegc)f2!$Lcw{aFPraI&kD8uq3RPI3tdl4dYvj8V|GsnAO?^Hf%BDZwbFBp*V4RXyrKJ0PLpYBT;t4aj(61gK9{8-h>ye+!ji5*dkt>XhahmmV~ zQ&GE}>TH>|u=dgo%et}WeL{?HnBsx+f&0q#|J*6}aC@r&rd}u)W_QW0#HT$FA>5XW z{r!u_M*Xa7mx~}}~KSgNafG z#;i`!i&BS!8u*Njno-1&?wsigY$Hrn&KcbbXT5R#Y&jhJyVBx&J7cVp_=voI^Px*2 z?xg)a{F&$e9&%d>;N^;b7mLjxzfrk9~;GBY5TJPCaLA?(-p@y}hC?zK)=+6xjhfGzk2|l7p&)S4v^&?cQ5|{e?FiSSfYAdE8=jIcSbww5#vh$ zGpYC8jQrgLE%$yMZMXC8P%>E8HT|O?>*+$*5H7%MnK|p$0VHgMZ*P)T@c+c)&M$Gb zNchoO#7aI5ufQ`P4vAuux7jN@zjDMa$F?iC4iL2d`c5UDL(XxDB}SFFj`7c@8@P|Y zn@d=l{R?J6cMkqSouSW8qdTz(6w|ZvAeo-5RqQy)ejj*FxY$PzGLZ@<_>qctoqcMZ ztng~}OKIGi{w|Oc^xS^gU zSL5f4?87J8Uiv+WkkdSCONN@~LjW6955Qa*``^$-Kd zc_1E$dTjb9{O^GiftrNg@g%;EW0W=h-x~yMIeMUe6bBxXq08hw-NvI_(8%J{OV@G1 zs;Q}?9(uI>ytlmjn{<@zfxA5_7|VJFr`9)FX!nS+R8rqYwMat1Q?N!f0Ji`>UgQnT zdGpVkC$A4hsg?N}so~qO@U@<>)oM9{9zi;6%Ri{k{cT!;e+VEjvvb~HoPBrfKjkLF zEBx=?G8Z4n<1@KIOX;t57LUB&d5xz2PMBZ}ljGph}tCCv^2LF1fD)&L*y5Y6hKOe;w5fea&{=2taCX#nn zehn1h`8HGhuWu1XM%-!bf87L`rQ0rGwdVYGBY|=o=VGR_SdR?=>+#j8`Lv?(HOLxn z5mMnl7$$#1RA{X@4DWl#uCjkU>0H!7@&9(qZL1;jEr zBl_aqyKE>mN^>u)AaC(Av7(`aq92rIlalEAop{_Vaj zoE7f^>;hT24sE%}sb`YUgf!@9|MCKeE{7gX2Xh^JUcMMeUt)~9I4I4URN4`R^|NjQ zVBUQi0CtAPMFnTAk$Ml{D@IUW6l5A?4Cy%*wN}P-`D!!eXomh%2$1$GeBowv#I1Gi;I-3T<(8q?4 zB9nw9VFJQor)u~TP&!&~ct6r8u`d+BD8`{TX_3Wk%mZnQD`VR>k2hE!Y$dJBTVALF zRABMT3Zf*EYRB~faOjfD_Xo_hAzZJ%asG$FT%h9dF%Y9>$f}Za2&O%!$wpVfRsnxPj4g4&G0m&o zH>=y7IZf@1H%aj7(k>L|=zHcYM)l|*l1~is`|D;W9loT0ex6S-IkUzvTwvf8D#ghr;T40>o5JNl6a1{wep!evp+@d(*r*F#x9&~ zpqH{cGs)rDRqORpNBuYMxa;z1YkHLvbHxwZ&Y_Q z5}b|aJs!`)Vm@UntYb3T3^3FQwn%-^EW~`H{QOHpQ zqUpss0A(Y`PwNo=ytliJpFfx42icMg1CTJs^!!FgPr7`^3XfxrfC)3}FI?y5El87!eDW21rVIA7;3BdRdI^7eSi{V7Cdd>> z=R4MkCq+RTZ@Z@nwRrwhHt=Sgg4)rt{WD*x;bi`#)ncSNQ_N;uhSuXv!I>J|^h*!LD&?XA54#IE}dX_km- z^d6nW@O5HtKK};jJcT#l#=mhD7X6XcsND>M(7bM5b$R_9K)nI3KqZ z?(Y7DnqJL)KASJ0y`VS8agH(V;?UGybY@A;K(P^KP`nWq5}-(h>;!Z*&BbJ{%89$X zr3IQBrXLeBjPx+X@=g7zv5vMN!L9->T8L1ZWLLBhkzwFyaYfYf=sK-{Q#TQtsh;1! zORxjx2M%nw6EMjT(@Mf8By0Sx~u#)X1S(nS9kL8bd^}AAEr7JM&rk^oJ_54Lf zgfHI!+is06(==vLj@EfJiZy-P#jD+QRUeNYb6G02uTf&y=nNBup4X@8R@_wX&XKlu8gmQ%|e;+0)mj$D^zT_LN(`h&^o_?Lu2<^tkvTEDz=0x)%FW z)hmNtk;VGpd(u8V>#2whzAJC{wkBf>)ZMDa#ulFz@u&E54Cq>qG)-a?hHFfQf%zty zhT4}+_0^S3xyNgC&KlOwNV%oa(v<^e)xiDGS-mSxWd?zbp1a@kuhUA{Em3sm53DPa zZ@kcsAECyFc!P0Iq_Z7y*n?uw-P+k4Rqb_C`!}1@~ zD`IY#an%(Z!_4|!%f5a2=Y3u0c=;?4cKPl!R44c^T`USF^Pj3s(>BbG5?n>}<@l6_ z7r2}QHoNy0PV*nTxT~Y&ce|W^j-K7p?#6%0xV6deiL;c>QR3^2|D!8&%p~DMiI{xb z=)&8@p`3h5?}*Na=nsX8lv7F3t0QL2%&BF|AR6}buui;qww|S)8mVHuaw!G`oLsMF z2BFRsz3)MXyi0kAz!0Ggz)9~&mmFD-Bjaffg^fMkBR$y(wNY2loI&&asvba|f!rf) zJbv_c0%n$nBd9^CdcT};1<%U6qPNWt1#{nGI39MN@yHhQf3xT1>{eI1%0HLCzz7|k znQw=Z0C=C-U{dR~yAHP_m&@}9L zQduefg6ypHsN^;7>S2i81WX|=driA>_@7Zexdr7_jYeAkIvq0H`9E)oe}_d{S%Cy~ zjj*m9^)ek~0>tXhoPC{bmlT{%KDSe`Z9wWKGOmw7+N$BYkz2zPKDKz@$f3k z`bYtWZCZh1f};?%t&;z(X2ue6p|$9gn|zzkWPKrVmO5RDg8vWWkhu7PNdoW{GuI+$ zn*!|abq){{bwc$!7QyvU61hoTC%pb0@Jy|z_{ZV<@3rrFc{}wS^7eHBeM}S+iZOY$ ze!9xk$l$}u^yA||S`c->L5vxu(?zr?Wp1*!nMW%tLcmjWPCTdjE%);{wXBqy#YQkq z!lqpcMHv^oTS%t{V@_}D+EKTs=ZI_bnUwhVIZ#sbS>%|p^Y(!vElh<#p>|H2WyvT7 z&VE3yxGt?)CuKn8~cSDyyI0VN!is5TGsiU-D0Ua!;(oR)O_gd78J~A;c zWYHH2M^4TnLY9`w=?QikZ$fw~>Xv5GlzS1Wfq#C(&eNAhE?$bVDMeKlHKAUQJ>St# zbpXX`i%0ID=93m7XLPL%-3BCwybBq^W(yZ>Y5;>b>)Ll8@rX~;yg)P^?mXQOBn`3d zbbpXY@sDR*&Aiy$$KBItVu3t6^p5A=pqhm%q!o;pYT>m5m(50!m+$mH;0uYC$_aZU zerdc05VL!_osjQelv~+4Mln=7L4%BRzHy-8*;b3aQc3pr4>?~z42Xs{qD?&aU5$*Y zR<{!g+r(Zb8kRU)${9?fULq7^dmCc;TI|ti+o*Fc%}!(eVP*MxjdhVvtCHOhcPdYi zH^f2#z)9cCxtutXz{Gl>NT(q^xP?A`2MOSU@u?x&Q3L9UL|*R%-#MYAFru$kJX)ET zab`qa^`H-6VEn>l=@rk)tv8CRTF)0+ihIJ^j6(;zx~%-o&Y$~H1%D8dsyEte7KI1Y z0_AT+u6s_DyjmcI1A+tp*Ni~jplldZl@=hb8Y|0&S??>bOH-mnKczN?Uv9(t z4jTlKOIkp|Ty2;p^)Ol~Wf`atQSWMk-L1l9SIs3X={~Cb-VwOzqZb-JI~0dc>6vyV z6p_GHqy(10SfGf$XzR`#bF`ELoenT&0GO&LcIcpa>lhyEqLRzs;0GL5N=CMEV%WR4 zjaSV_+uj>;d31J$*_G`z*uj5lR8uaJEShwOak;M1r&EWwQ~Si*!c}I~>b$d9tKl9C z#6YkNFVc?4ZZ+wqb}$aE6Wldkkl_iSc()%|1Pi+jlr$&*Y_)aV$05h^D+0U`cnh%vYMzS^r8EF+kG8>o$8S-;5P>i=ep`f?_E6NJ%rBc$x*Uy=xZz6MZGBk;) zgnO|nC!0*^C9T*v4rx6a5Qr4SixFa&z8_9H%b>DIa&Xe%QvQHC;WN8uEKVep|DHq~ zX=}k+%`&427-O}C-B;QToGd1Z$)(C~3mNjG?$(}i@$V|J=Cg%-+@4xXt(7xro0~+nFgN#!-Ax^%}6nRhzr` zygg+lgp=vlQoRM*ubSo)ZIU>qB%cvqvnvqIQ)<{AQxc z-IM7KnS=D7>qtonj0i6mv09pe|Ih~@?zal#KPC)U%zLpIb>_#OBpz?o+@oSyXQj%a zJe#f)Ar#9XOAYOL(g!Ap6mVpq`9LF4k@c-$p>W02V+zDL(&JE?uYZA!-&Ruey_xEM zg?f#|$f7>5*{J80t}LA=T{a zPv6&$J+gZz)xLE7sLQ+btB8pw88Mf5XUB1dKL8Kb6=lvs3WMXBDQ)^(P#it>fzWWd zT(}DgT!EaszE*Q7HxKY}9&iiPtf5H76H;Sss<~w-7)2H;C=v>(t7lL98jlzP6j;L? z5GF#0Wa{{M_LxkB`-OHPmME9bIiX2Et)jig2}%+LEeMx1q=O%Cp41&h7k(aH_-Ct8 zwh?3WPwVx}1t&=(U=-Ca0)&dV@%SRNR&lbxL-8BK57!Fm0#_kEN$xXb{sq~g_=hZs z?0DAWSisM>aER6T_`i9}1DpSYq{5;M3&H;yNyX>LHOZ^vA8ypoHfl%hdMKbMZ$EVV zHK(4)Z=+#i6DO6Ic_^c1N=4Qu71X5s#{`wzvbtt2G%ahd1-h-GFg!}ftNNOLxHa~| ze?IgJ_;Q$sm}qkz@DJqi9@mlKIJBgtJh`o^&q;p)g=H|hg%IEUqZV~=1-nCedE(h* zh{Bi81^T5m?2!B<_pA2eUu`uJ zb(VG^L}=eNh%8)yDH+j61CJWLjKuENqs=<&I>#RjNtX-@vtd*TR3hkpbtKRb8NP}v zAs}-aqSPjN>-*$njs`B3nBxjI9wuv4-B0itaMfH*-}57CL@4bR(J7xH_XDnVJEn}I z;iP3z!qdjytza0>I(W1^#P4mr&WDd&`2dKaNK`q&Xaj(=zL%QE)OSzbLW1HXYG54%xsr8&E@!!B!A z%G(Tun@+7iPXIn_Xfp?`zQh9Qh-y19D}bTwI9j%cZz6tnb==W+VKRZ~@2)^+-yxEP z0lFUh&>UO3CCjv;v;?Fqbp&K)umD~JH%9Ea1n<8w!;hcy_tY2Ej3EAbeT^#=jS5sd zpR{7TFoq6uft!y+bH4-XE}!V8woQ48oEYJ`mHYXr4e;wPk%npJof1HpRt2QH728%enVDVfRI&%$P-){m=6r;I-Ykds_urVxiR;4zAQ?ryhPGjWhT>xRi&T!HdRL`y z+0%xNeYeigF(2nXk8g1CdN9gzEDU2se zBSbIeOHn1Oa1r>!meccebIR&+Y!#sX)w-Mztid9bS+D&G*Di=cH{RR_7@gIH@ss{v z@6eo9tcJ|QZ-}y1p}^Y^;)RS7TOHZkeTN|&UrMT4d89jC@4ql6S)jr$K#aiGa=Gdp zyTv?K^#ZS+rJdK@~UkM zSV?1klp6*hw@AO8YqO^3#!{__Y%aoo{f}}R=`qKR#icEa)-H0bDi5 zR}21M9)C?qdU#TL-t5_Lf{a}ji-*w!4matwbw=uHk*R&1eZ&N?4g!bKsF-I`S*>Dt zCDK%s;qk8jORN#?2*byf=3l{{(OpL>su`a7w1Nh{DS@b;2)95 z`MIFamW{})o$V*UZvKDUa!lr3MXMb9&heiytIIsD6hG zIvX)0>9W)kX_;1GLIMjCQDBBTO zlLy`g$Q>G^*|MycDX}*&c%9#n0?lTQ=D$h@nT(Jn{Tk+#W(pDx$F6?mSv6m1tTkh{ zn~7Fi7VViB?8176D$ZS7uuz~DZm1lfT>0jLc zGt_ac9&e%lp}Y{LEOf@GNO|&Coq^0`)ZEcWkh~1`MG4`Ig8_~J#VzQp4l76Wd!|Ya zg96>rZ%WWV&mLKX4fX)Lvfht5S-sOQ7n{(jvw{i{T6Gt{*PR2`F_XNaQB6Z02pBF- zL_cjdF=&NYr`2aVmX=kP!sNKnI)~tp;WP*4oJ;C%)@1%~4#xq_4=7^c3nCg=VyBT3 zX=PZMnI@Iyw>s}%Wj_umec8rTxu=}3u?c|x+~zp6N5l_E-ger+RYe$kwQe(s2U#HY ziqMpo@|BG%rI@momZgrmil*-F%(ofE>Z=RNt3!rotW>Xb3sv9LO?@XY+gjweWVm&P zWosLW_yhw3$Jv-JbDJwk%-A53G97KW3$UYJ8KKszLl?ONH3MRVkTl(Q;pk0@8&SBu zgBJvZYD#91@jD-DbqpJMVAI(-%hwbv(EhYGFMm{Rgdog1(OQ)yfcy!9Ag(6;TGmtt zqkCi|uP`y#WaT5z%60Bv8!&Q=cQu+0l(#ohN$w#c^u>c;Ez3yPVy%y(F2qwl7MR9_ z)rAD{R{gLvj-@GiqB#nG z8|qiMLk21A@a6ozuhi8=vnc8li-&f&mx7u3bi?{X^`qw`+hubOsn<BvyZ{Ct`oj&-^GkKZOCBz_E=`o_9F4@pwua=rs~9 zH8MX3DSsx^{F=h~RzX;bQn=|pvC!(ZM9!AsNWr8~&moJ{LaVkDp1WVShhJ_$G>Y<$ zqcxI05_EhWrQC*?5d^VVl@o)OSE<(vm+W_Wng)9H6xpzYx5$&B zp3v;AOJXs(y1QX!)!G=bDPEO1jlX5GiE_?tOY6?Rd+^-RF=?mEvDHb z5(MYsytMD1ESNwWgdl#~GFK&#<~0>8kZ^L9x_S-IA;uE_s-x&7ERQxY&IT7EWURkz?KwVF)->1gyl!sM|k4PnmYid~IvGQ8ker05Av(@4J zzkKpP?*%cQ&<)g;Q156i^X+!udG{yfzFf!GeCi}_!@KykEJYq+Oe-5#tSC7%M9=^2 z7VEM4OwBNx-075O)&{2Eh)4-=UrIj=L0o`XJsryl({s5Ar>FP-xPcvPN$f+b|4WKI z0$}?v{@!m_+m{B)tQTvgoPLmGK>%=lsK?{OZWbZwv*<+exN_=ztw+;Ln04P-i?tV$ zdKR*>eZ?&1n#6r_XtsUPYJ-rXRJcpo)P=vsf*(XC%wx5vl*PdnZ6`lwG9(apex4Od ztP9+Ta_|89ZWt4qF=Uh0>qGJ7h3u#Z z8wsx7C2eebxd#xL@9$>3e;{j?zjkO&0NO*|K5qUFO?|~G#WpW`Lqt6k+Se4x!9{TF zg7MG}RPP?f%A@?W%yq_=J!He6k5OE5S-Lpf#`|-Db2IPf=8o(ib!HEJ{6n}YJ=c7< z2zZmzAbhSVDjq6pWZ}v1Y*hBW&vaZ%H?+L##z+p|-pxPgq#rS1jD zLf`(!2L%Jc@mLy?O$THhONWwK9LKS;*^hs6nc0V&`t-mt?a<%xxCAE}pf4ZGzc_^9 z5UZMs$dQI*eAcm@2kKlDn>1oamQi7b+=qY-F~|;f021kQ?)9VOsjXu@B`%)dC%Qw; zR2%>2!+m?Gf`<|0{TCN5SA(~D5WH_RZW*RkZ=!=_)UgqAUpm;6J3w^FqrWzu;6bG7 z!mx`d#-(u`e=;Q6BtM!Z&}wQ|3SnD}xX;IJ@r7Butk{h8nWq|k7}s(ecZ(;l!yuMd zK1hiO>!!hn-3Bc}^o&f>Zv2i9$JQVuxFU;f2WnZQ=5xE=&b&uHE-a<7jc4B!fG-6B zO}p&(t@R>-FU%7jNNrh~m-J7-fdiegc+OGv7m(pTcJg`kc5v6@eKHFeR5~5Dh`(xD z;WeA*_7zI=Hb@sV)!6qQ) zB_^fbiK&zO-X%dZq|u8E&C1FICFCbPi1*FP)owzqufiF1?7 zne-eC#lJ;eP=-bJS+%I6aoBXD zvS{~n`=C0ohV)Kl5C=Qv7UF%@`K zhyz5&CAIF)J&zWQo;|G$J}7X&8-f0hR|I#}zr_dr32=^8vC#8b|1UuHP^5spPp8GE zGyAC{k&<0MLx)tEij`o2X3bqOHebvZTcWJmBV_5FBPO)Y#$bFSwbIeeu`@5FprlB# z$w?Vn@%6^^&0g!0rn&Zgb?7ZE)a=ix_7D{rYWbqMv9Ux`kJQaLO_J%SUc_?fEXzcS>nluvw& zl>uGm7UvO$#_uA%AUKH}P9b{eH(z zg_bVA5*1o)ru?W3rII@w7BeC_U)3k(e%0~$Jx4`YrY+zz6tTqjbx7{ty+v_(;#Ep8 z_9>CiZ}4@QGBWngi%2h);eVo8N83oK?AE!sD~eZq)%gytP_tYg~GzI)YXSm2sClf_bfv zJE`6(!GcAswF=r8Fj zqj#~J&y~?(;?JD5JjoZRj>xYIl^~X>o99*T`?>$|vx-emD@YWnB=y-Q-=fyWWu4SR z_0zu*7NeiO(M-}IQ$>4WP8z(mT?{#9NnVV3+G)z9Y(y;`IJBjhb5J+4cgDrfYnJWC zrdA&G5xl7xkDcUJuUK7K(jdwtiXr|R&5U3b_wEyxJghPDcFXOMl+fA51$TO2kt2yJ?Rz zT~F|Gt)EAw+uiBdr`(E~JbR9Igs(eUjl)!$4p%`uudAIB60`d!t8mGL0~Wvio*e2s zW-g<#Kyt{bzM)8X*EUBpqb+t6*QFzD7OJ@N)9J${L-Coh?D?I136|k=*RQ-ix}|L> zUD9jfuDH+j-KEJpv5`35GX|=(sYCQRMt7_xRkQAanfEU_N}GuRLP_?sdUvYs23w`Z zXIUv2WF!R`j-^TQzpuZE$26Y4Zq>e#7{J3?vhX`M&UL*S z#cAVz#7bV&Sa3~UUKlH^ zv$yUWqgr!HVF>slYQiug6b?=;E}IwUjl94%pr_6Km~y4>U{AYEB}a#?@`%iW_V-gg z7TdRW59vCQYBPm`#NQQs0G1x+^D0iynY@+tb`eSrxlpwL+q2mH3 z>G+e4E>nlhxBnsE?fL%t{fKjJ*z6EpI-xrFfV-dMU0F}>2&$pqoRphPLb80zHFqQB z-98BkO@)-h zVD4JG*mKi5zVw5RcZNe*aH;RtkN2Y}r_*AFPIM<=D?6iDAskr(@{w0Gq>8#PZW9v;DYmzs;OBZx#TwSiqkC>Hf3XU$CzLI0=Mzy+9H8)$b^F*z5Z{v{p zsN33rJPTL6EyAVu^_Bsir*6OSHi>`*`WQh4lcuTAbki(;ss5}o$IU6ANI)OlY^+u- zwd9&s*+!upu|sV;7P~x49fnGPzNzI)2r}k6oWZzI;dYhkgJ-J*>qE?aTp4GhM?qB%v^)8_t!Fjw|`ll$u7e}O?abK=q9Z+wM#6LF}i4^Da zOcz zXOc<|&4=Tqlo8_M%-I`HC2Mi>Ygc8OAB^&&y9v5@zCMwZL&%F;Hw}za!d2C7^4uRA zmwgLa(hmu46o|bH^?I=xvruEC$|u=%$?T8H&3~4-KE*C4IXp$(OUcc!y^O2w#@)OU zR*+$abZ&3$`M9mku+rX`}TS8LhQJ!l?vC_d%cn@hZzCpxYe1ZOS% z9i~D=4ycpdOM>vX?a@CHk=|u<@T4VUgtOn0GWhGjtUXkA!r6BoxX-G3p$Y){ z6_3OqAFN1!CD5!r0*}CxIwG-St<=aqH2DMkSWPkc7TB_YXGNG_74fKR z%$?YhwaVPuq5szUt(_X}!RnlQ(&bxcBi2D?z?yw7;5Mrf0qe@qum4n&e7o4skb;h_ z?`N4^eY@B1pxS(>r(xf}o=EdKsCaI8bDe$HYZv>U2f8ND`G5RC$)<`S3M}|%&(Yi) ze?H_Gc}xN>KqFD1rf|56^@(iQa-jCmIt~4Qy`N}f;HLB_H3CM5{~a5ARI80~D7Nu{ zI!~-|%5U&`4?G0n+iw!bfBAdt!AJq3#Ekbp$0PJe0`=sM$GT_!H6&m&h5u~!?X%eh z@`C?7kpGO3wE^+p@X6$IVOXELL#Go3Wcu1&zJ?ZAuwt-9GO(R{;p*H zdC;N=VwcQ4kMo)Yo^~C*pl5#Mq$5-7Qsi(Ra``GhW3?EaC_aoZJZ+_g0m&2@|3+o# zjTB|haWHH*_6VSL{0FkD?MO{BX+(D;7eGEvg2d9SL|_3Sd2}e>3a=joPhvxoP9L@a z^@|9TM-jHbkgi&VEYv@lGny*hI)~H6^=_Yp=656N+%`_B8>#89`rUbIX6MD{Z~Txx zN2M8Td*`x!JtmA7@7AgQ^3ft|Np(mbdAz%af7&Ipg{6x#lH{M{J0_3Zb>oVoJPJZt%* zr0*Ygzh>}DFt62d8-_WiLjG>y3U|{M>!3I8^1xKDe_*a?n&T82V^uc-L{6Nl;GQx5 zlrii>hx+h{5BV-95!YU4-UT-i;R}yD;k6~yPAZps!}1GFSGCXkaeWVBj^w8 zBhQsQUtgk9?CDw0y5I|Q7df`NC{^2T{~zsrXH-*J*e+spU>pU((ZL3e0wN$K0s;!? zjH1${D7~lwI z``xw1Uw}F1?Ci7myWjG>&*S3Qx0=BTE;4uVtsuk0QG4rl{p!()v&a064BRunNnuL^JT;veM>9IvM zE%EaXk(vN6K3s8M*3lbjq-D$7{%x?DZyXc?9PzbMjc&Hzy@{{>t>tMY@OD01u~$h$%zJfgGd%ID@AfC9N2OoVu{i|gkx&=My_ue^C+6NmH5 zyhoMH1I^w6(^-~#N<$Ijbhdn~m;H<9H}tgq8_!akMenW?HliE6I-ze-^N=Ek$92cu z4lA(iXDQlwLpKu|bjr35--iR%xT~JK!*IisOYH@P%k{5)-h*WTUiL&*b_uOw^k|6eEovt0GN!9&z4Gmo8H`_JZkR zlM-T4r(cKo%d(*@K|1k!`;Bv=W@Qbb#(sD4BmeUD1_A%Md&M`MEy(-DoBIi|#@6?K zzxi98YRV@7iqx&_9a+a-pr2jd(?}&-w&dh5Ujq$f^#6se`ERJJ?)87O`hT!i#m;9o zad0Szp8fTN!RnI!Z{7Ys-(vWx)2YHcPok**?qL*wp341Y!|JxCTt~IM_X}U3i=UTz zCJHRw4xvWLWJ$zD)9RA+LhDJ;)~U}d&-euBxA1K{`tNMWEdYvs_hUG!-hZ|l@PiNh z5k4uinG@l4Umb&jXX>Mh`EL?Qt>B^F)y}?ojTaFY);%Jwo_6e8SiOP&+#h)N$W@sQ z34n%qE@8n~yLYNKm@khKvpYSU;MbZlj*l0%ci&+H<35ie4>`=7ug&BIICDY&!utBJ zQfsCz1Tm8Q>Jp5TsG=Zm6IF5yoL(oU%o={x#iLlm_|*rhs}T_ah?cdP0nw$oDcQa~ z)omFHm5lL4=;bwSLaMRY;fGQS#&jI$mks$*b^0vCoL>TU1sO0nOdzX^6JtTAk1Jn@ zM{1#*O{jaOI4zY2LHv(;o$6UjONAf-Sd7baq{N8mryY>b^U5SVNT$wx8N6J0BJ#JJ z_FSc`8#3c;07tN-48j2FGDw{CS)a2bp)NEHUWULC6`9d_^Jp5hv1J`u$tE{BTqKz(Hkr3V9s0{e?MH;6c+&?Q!%!=dd}=jp zcSSib`-E;q0}`QO0IW=(Hj6aKswcpwVcy`fplqj+{lilv|y_y(+)DxaNbk~Z~i&gbR^bo^1SVrF(~%ba8A;{jQf zi*e`X2!onC@gD~(VrwxbcsxS433WL(u!4adL`U(0`VQ1zRYWE{C+<7ts+2%z`&U7G6BJK4uXij&^y#csUkX@_rFp?}*CZ6QZPea)zBQsr8h zWQDA33<34DhCiTc`ZkiofL_9Xd@f}G3stDm5nh7QsPao~UJ2!Pk zB+24kNU;$dSpTf{KdzXrpX#<{lfQpHt5=zMDjr!7Ii&YL6!;^%SreEXIcW%%i6Lfu z09QIbqhuoyzREm3zsIE_$I13j$L-4vk56zLR7mcO&~1Ej&61LK{c@0?@xa0Tl(Wzr z`;5bo`i(pTAi;0a=Yk0z{QOCi75z&t8AaOfKz53sW@mb{W#)|S&AKMt`Vgwkh+{&o z9|U^T$0vw>E9lH8fD6D!bD}Rp7_?;0WE`oyXF25}wBeS7h>h-2}-<@cfbT!`mLR zvP4}f5M7HE+T%8V&+5g+%R@`8-)Twg^P^1RB&woTB--nc`?zvv&gPjT-Eo>% zO;k|?RS})v8+x{g?r2;+-I6M&k5R)U-Ffn-28u&N!QnN>g$?kUpp4an^27 zayYE-%zqSjn}K|N%8@z8JmNTNVy^sIcv?6wXA{O|rwd6DYz=KenLk8iqBI2)?H#W% z9~JSVx3X4Uj!yR)iegB6A)tDRoyZogWW&qHMTLJK-Q`#>zm0t8^Y>qvI23O3@8Ljf>T5jCn3!5$*`@88A!?~hIG?Nj%d+jEHLUoF8ynp(zt8(Z;s=RulF=peViN?2o0}IK6Xiw@)@W zMeHrZ1HBj9C@$y<%&=Zw81KC9_^Ly=k7*j+gxl zRsH;C#kx*wWe%f_a9GMF>G(zhXjn}I!7%(l*qYS{Z^v-^XvR7lMVY!V)DP6)`wLRY zjoz+&CtQDRVG;BwSfWNo4oKO4IXnQiRvI*GeYkaVQ~fKDi0nu4RoO^p7WIsUZ(x3` z?kLhPM*<8s`B}Tp&85<|+`e}C`;UEP$15T`n|)8+ic_@j&aYq}9}^vj96S&;e; z#An1GhLgBU9@&YqTi#4pXn{BGs33|xhOV|2%{}o8Skd1Kb<>hxbY5`uCt4^S7IV%W zSAAbPc#bRu0GG*EWgm35uQXK1NG@Uzd#!R>uqRsNf|Y~1EYGnIl*vU)modn#wRH^G zrs%*ZNufHe9?9cM;6;iB7!5&2AfxDLUrdeDQ?P+E zl|Ev7z`c35d6hKYeqkwVN$z1;UjGizFM%jae@+v}nVYR0GvL;ld7%+h$ZLAyd-JcT zhYtwv4?4?e_5c>909H|MgJ%^f^k{K&vj>Su^8gWBqOn@q-rO!+#}WT*|MWV?K!;_& zVYB%cqh^xH@d{lb=cuFS5^ntS2NQbD>bo7gR1iNhMMD4=jFrIc-n>h{xIKJFXNPf_krd$!+%wz(~TT|!KD2?>Lp;_D0Vz&jJT+$Q(OTx z<(iRueBPKw^H8+d(^gyP9HBP`ExorVRe9P-g-fV4Luq3CV3D!uH;XBT(y@U!&G@@= z`RC?K0n4mJso5!Jtm}IpSM1LOs}aOHwBM!ys62Mafy_WcBoOzK4?Bz>pwJ(y3E=4l z&7kR;9a7K-2fA=xmN7f$x0M>#On!p&Borg9j*9JPOHeVi1I5Ai9&M-z%<^RZhl&X^~2@VFcF(WSBono9Sqcu zIQm~ZbJcTYX*S9cSX&Sp1Mp28J8${5g+2i(u@am>#>7aJZ)}8azf!ld-sVnFnEo=+ z{36m90RF!T>lQUI`XpRCrYsVCHaLdyUGzW9cQy1_982BkMEtW`dx`02+1^JQ0$_?@ ztYREx0x?}bH80lp;(V&PPD_nwpF}J-ZHFG#QD$X7q_^xE+DTI1vEeX-axW#6D*~)d zt|=ESb=RreE92Nmp1;~jn3!v98n@4-N~2@=44>O)Z{~&63kx~bQ1{n8EZ{gle2DCE zAlrY_WK4*rqq9^%JFHH{@o^>D`PV#wrT#Zwc#a?f5%bgLJ)$F*6~?cyoNJJBiH5B| zH~nFEvkh(qHNSSfOj+c)iHRZZjBD*%si~^qE=yJ#*r>u|V0bDkV^ieGUv3KSI-&bm z@9Kk7KZ^f+=4rEu*v<4memqew{nUu}(aW8YLED}<8yvj3Nqpq5QyL%FZ+Lny=it`A zOJ(%PA$KdA1N&xsy%J)T8?a@w4e`pe3io>#=<)8%xc;)%MfWE9B0w^-Y=75K-9h?g z=Ne2rX_!+(F5H-8s+q2NMxur)artEZC#}opIGjVTC{`;zE9)DKnLg4V)v^Ft>?2;e z-ii0)Y^oS;jhFVGx^G#=%o`<)s*;Dwo0u<(txLV>kE@!G0>#Qo=E}8wgGd9GH22-j zQ=?%s>>dEN=k}INX$z+JCW*Y3CI<+|$yxGu7u@UTNXJl_LL%wb^5n9d*-{*lxe)78 zp0O-*qNL15(dj)$?1!inGQ(|;4M|a==Tr6-Mqp)E+ww=xs9kfKF~s8*{{AZx*eDzB ztMiLM4?R`Y@VSQ(tv0KD8weuSkb)1N=7`+291?c%q$%gyC%ZU?-WPCt8<=}O*gHJ`_|L~x4C3x~^4dCoCv#ruYy6tm!l|)UmcNiH+m5Kz zJSgugSxxg~=H?*Tn z`V*h+_Wc0k)i+IPw3(awxyaIVEfSnEx3ckQc-va7;MP53Q+w#C3^uZElTPEH+b?m! zBn%Z(MnCkvrzE!=pq=VrTxTl^0*}|pjcV_z4Ro228K$+pgs$f5iuZ2yQ_gQc6K@yw}gaK8&hw9z_f+?@<{aZcg+E$F}z?7P0U%85j z`!SFMm%yiR5k;{F24Cmrgii16y4bfo$MuGhT((@axc@Idlh;5&zL=78_eFl)cGZ0> zqg}r87OVF-$*c`5iNkZ?1HPAd?#2jcbL>2}fy(#hqk1cytktxJDzW0?Jjvuett+Us z$$HL}F=_V`bP0d8V&tu#o;qa;SV)$I%5;s&<=%p$jJ6*@z;B#mAvva_1~Xn zw&GLi=rP=N&UOvPk#_@>)J$t7_8m|NZb&{AS_12Su=6z;#$S7uL5;sN-2T1onogj! z#JYX%MuQm&dobtfH6QfGcR|m?mw`~GV2o40IT)SCOWbZ^X_1FMO38C#v@L|Zx|;^^ zlcP6G_YxznOqvu+7)p5!N-PkTAU-_MBJ~{ zp3_iJ8vx%o+YwGbFNGw}3w%b}c!cAVk+FNlPav>K)78w0J(a+k zLAO5qPr5yejzKlLa*}KH91iD^`8!2+)0@zqGU5xUL7g9}wQC&L37prD>|8&10*fC3 zHOyQ@8pnLyNt3EELjlZm^)b(b3RlEU=f;3CAsvB(3^c{C=eRIG#)O039?xJK0Mc#olA)JpBW5^=!@l~hRtLG>SdqVhuU z(>?YpyZ+-*hy8hfqnhkkYo{t!-7soaGXJm3*N+cISbn!Cq9lmeyhK#F@zP`Um^fY1M7-D^-B7b?AQU@KC8<|44G$ zh-$N9fLzOqnn{(wS{CDvVm(VLVOA_LAkukpch-plmq4l?R2+x$bX94?8M&2V>h5r2 z&yEZl3g#G0G%0E%7VMu+y|nuff~&I{q5k@M|@NtN72mfWcQOulhfdYeGiSznf; zadDgFN|gy=YZuTd)ozf8GG9`~J9}@SO5EPHA~E5m)@&<&UsXTrQK}Ds$-4~Vh?P!= zO)zFDJm3?s;+t223U8-FdiKTjwnaOQMxS|3^qDui$kY6w=b+O;Eo*sswx+8h@Z(*} z4uwgR&9^(D(ZYO%y09p)vNRFcFX2O9cy@=UYuJ4Ar3)7otczWz17oH)|K(O?uhL8~ z{;MO(bEvfNl3Y?Jwh;M0F+S~Kz}4m0Q40@G0jk z-I_Y{ED|SPn7ZcJI=g?k#iL@>%%#v1If&ITtKCn|wO?>3-90S>%$;${M4-`vu zKhPpvXp?bOM(3?K&4Q-LXRQj!yK=jmub|trI`x%Sa$9by@%P)qC1Mdt=!g=Z3gm>| zwLO`a8*hBr_lZ!h-BBm^BYoh`W!>5v8!yb1|B2rG$h5RQw2<#QI1oeM{PzVpFiHen zA*=&8kyu5azRfMqZAl~^Yb9JE3kS=U^yYZB?Qw+N&1}77lX|VvY0DW7&HGwQ0OsWx zExz|Xsk)&kp8IcZd*9jjInPE$+By$4w>ReU5m-F>+)msy;iCi}zRTp4+{V#ev2~H^ zqQcT?Rdb38zfuYAI^BFX$giLS+EK*?FsDnn-==C{iNp-XsK$Yml?Ns46i&UpcuFdC zu&YI$=Dk|AEax&h+;a+Okl_7YJ&EUgf~M?6-SS*>Ajy(nMW-+_$>{x}-{V2i4asN2 zmJcpe1EZLd9qvWZTvl)o8uzs9OH5}kJBrj$o+hfFO--AaCC`-D= zvK4_V%k#rXm74$Vfx-kk4Lj$aa~0Q*d~@chxm$? zD{;jiAP^%-M75ZTLV|KW4tL$JY|N)8#`sxq7m_(iIHB31!AqAId?HD^6$@tEUWoWtep;S~~ ze5{aMNujv^A#(JfzvhSLhfF#JC-A`srxiQxyZTZ^xVebo24n2b!wd}H6SsSy=4^a& z*ym8A+)W1RYVs+TUWdgxGqkr|oPegQ`BK^T=mPIV$#KP2Cx@o+l%ld)nRSwnBs>nh zN#PN*B*i-=?00S)d*egEq)7rOz}I4g@vKrlSF*8r(3HCmC753oV`yf|F^nGi8Ij*b z4vHMWZI>T!ma#XSH%Jhe2N$|Sb$GsMO46U6&)t}j z_u;MkQ+Uc;u!+O@LMi@=^82K^lu5Q4yqZ;@oAo z`Em04x54V1nhL)^cTKJyE-RMOkmdJ_!HWgf%FiH(qJ0Gh=yImQz2rSGr8iy2cbc|H^2y43lPXnS1aF{=a7f;F41bsKZ{tq z{4}8{J`+{^?&#hA)3@pGOwy)WHW0=a4ClqIw=Bix1Y)=fxl2XJTD1XdejMzww-Q3u zo+A2dUKLu{-I!pyyuiYm!wm665;^ETgQ)q%`C_ksUORqX*&ge$Rq_tBXAzvBiSs1R zR(8YcZ=XM(q@`Bx6rTU@3YO9)E@FJcwzaegx>|nSzW)aK-%S3uCRexV|GZ#!4~_BqvkcOQg9GPP&LzqK5ByhuFza`X|TBSPMK)Mx6g(;>Z0_${2c z7G*0%q>R3vS2|oV=tRecZ%xzGDYg1B+8Mj=zBM@;g@mlPSn+z`SMATL>G0pmE4v`e z*Zno=fb7URE-vEwpMEv}*1D)!f?=x;3HD?gs?Ybe;s8s^u~B!&w+5?a2bdwL{g2p= z$mg0O?@vKN_x~H_*O~usHve0js~h(JdJ7GF=WqY}>aN6}dzG%I zmqUb}h4^j`I2e2e2fpN5hb8WMl*ztLbQuLME5~_{K@s2V71RR)qZEQ_6rIo#ngtc%@o3K~h^9K>E1FI;%ZQrXj4ekDMuKJ)0=>-XWQTwRLVNfrdemi|!0=!Ez; zfIggju4)ybczheLtnbl-G3;E`DF4x`18V*QQ24+ey87wE>ybrPb8aAt>#}x}y(apU zTKrj!3dw_fZxCCo(i6~@t491h8wvixV>nL_7PGPQu2i+r zcwbcoRR(c(`I`|I2}4y-<5R1W!saW!2arShGh0uHd}BKiy5>%2a9iOLXq{(;&90^t zjWFwIHajjw6tdq%CR8%gsaipkqd*;>`^@5`$xUl+w@dRwQh~;f!lfwP%+7?2zsyhG z;>>;321x>r1QE}Bzo~z4h<1%E{DXX$c%5jah!r!g@k2C&Mo(q#=FV5N9id)>M2MG| zMGdasa(Oap1d<0{{!ZP|&MV7H(~beOId`+}X@JvTfOLuZG+^;VRA$*Mxm+k;JRR!I zsxQOUXEtVLxSlpgpV~p$e989E74JkD; zWna=IM0n++stBs1kQ(hd*z)|q5dRx0;X@4N2JpFeJ}oL04kj2kGqbNyQEeDi3`edS zk2H)T#nvuJSXt&|haf6F+{J8wb9G>4Q!nf+N1Q0D0l>g|mVrcKlPoJLZx%95 z(8CV(#;$cT``nM!4+Y`uayAt%UZf(}ilfU+I0vIx_!!QyZ&6fL!PcDRPHNxXU)v*R zfm^(xx(Z-cfwhao~#C`OKUBMK-PvC3=Z*xEX3Odia;>10W28u^g<15f}!W9?% zAiuA~Sf@|)CBX*7jg*^sGNiDvjibN1-zWNI#ejpyySkXN!>$Ev^D z|AGsrL4Yt9q3(~xnUb2Ey95DhaJ64TOLS0lS8z#z>UXMu>hmD>E{Z1(8HbdNWA8r8;FI7VDG+{=S}nUtsUfE$Q96254eLmZ{oy#!nmUpe=)=Y{CZx1D z1H&ewyUppM5G}$|Dv3GShhcO?-Zu-?V4Pxf&sQHDpXhFC>vv2Hh0XRi z1Bp&T(EYL-S5lv2!JygxUiS3DoJP-e3Jh)ixs!&*8%?*#-ZCO&68syx_!ec`6$8vh zlpvRP`nA-M?3GX9iHmQduh87On1kot{1}PnO-@?Rq`e11hJu7ni7rE2V6h&qk`!@P zux{4Y@}|vuCsP8aj?(*;7kv}`JLVR=WD8PiPBI(biv1Eq;RO$JU&v#XxWb_O-fh>@ zDwApowbWtH%UEl7xu+}$6dB^~l)JCh0^yHI;Bl&bcd_JK zk5iflN*#Mgn*K#Gh5sI$*gZzCz5(F`QI5e7ywjD}3gNJ1HXZ`M__~z`J4N;7wk%#= z+2Kd;*}PU?eb{vHVO#K>lHa=8^k3BK{~P4$6#aiSlUWs%*U#=$?9ltBCmJsYt83>L zRf$*%=zAX-nS6TdJN1dr9s%354ZMLB6z;D7=T}!A{U8DX6aV=!lf!sP)8dXjKnGG1 zl{M(|4b*HsT((*5!q8=ICpe{CJ|IR!qs?6=YfI^i!*ShV1L{cW6a^N31)cFf;#W&R zDD0weYzU^W+mdsCU z14-NlQ?%__cKGPl8;c+7MxW7fBz2n1=Lex_grruu`Iz}hJROIsQy$I8g&g_t`k6+0 z5^e2ssgOELW9j=?an^wJVJ7&gomGfv*@nD+m*uOk9VRbtz&s4zi@$`&fa6%`0KSUH zUF83b04Uia>4oDeF+Um}ehwbZ5?n9&mPje;vQRdWuK=%Z1favC#+&3mGiJjF)dJxS zEQj7BH0oLbu}&w_MlTWSvPC|cyVE`WZ*Xh7;o+#fsgqAZl}K~I>P#EwLPFl5IpKI( z=?3jJ3OfJ?2GgJK!b+-~;w)2Pfv>lBL*p@376`3)VUzUVOdR?8-S3KltIjO7pBCe* zzoLa28XZu5Pk{aMMzV5I$@0dZCIf3%V76Fn8=@dss+%X((=57C5lKH0jG-pCmcs7mMi6;(Y)+BBqccp4<^2>spVo3gXB zJ8o~;eYB9y|tq+!m)Mn{1@`O z)zAL~!{7hC>z6Z-z38k=;I;mLI>mIw7-asoW#^+F8K|xzP!G1gZqJ1x@rXYT& zt?fQFD<6nT+jnLDD=CIqPNeXhfjPC>ido#Wj7kMxj94aBGu8E9?P^?fO_)l7lt2G9 zZEE#XUs%0A+tJVFb}M`OznDZWV)gp3dmH^AjfLsPMI8FpYvEY8ul);s_~#38BCxzd z^iKWjS|RKCEngM_`=Jy%tYV^W=Bk_g<-dY8+3Mk6euV~ZBCI6rxgSxByt805Y Date: Sat, 25 Jul 2020 00:49:26 +0000 Subject: [PATCH 189/401] Added RegexSubProcessor Attribute Processor --- .../attribute_processor.yaml.example | 7 +++ .../processors/regex_sub_processor.py | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/satosa/micro_services/processors/regex_sub_processor.py diff --git a/example/plugins/microservices/attribute_processor.yaml.example b/example/plugins/microservices/attribute_processor.yaml.example index 8d946f684..a20bb2faa 100644 --- a/example/plugins/microservices/attribute_processor.yaml.example +++ b/example/plugins/microservices/attribute_processor.yaml.example @@ -15,3 +15,10 @@ config: - name: ScopeProcessor module: satosa.micro_services.processors.scope_processor scope: example.com + - attribute: role + processors: + - name: RegexSubProcessor + module: satosa.micro_services.processors.regex_sub_processor + regex_sub_match_pattern: !ENV REGEX_MATCH_PATTERN + regex_sub_replace_pattern: !ENV REGEX_REPLACE_PATTERN + diff --git a/src/satosa/micro_services/processors/regex_sub_processor.py b/src/satosa/micro_services/processors/regex_sub_processor.py new file mode 100644 index 000000000..85b95b50a --- /dev/null +++ b/src/satosa/micro_services/processors/regex_sub_processor.py @@ -0,0 +1,43 @@ +from ..attribute_processor import AttributeProcessorError, AttributeProcessorWarning +from .base_processor import BaseProcessor +import re +import logging + +CONFIG_KEY_MATCH_PATTERN = 'regex_sub_match_pattern' +CONFIG_KEY_REPLACE_PATTERN = 'regex_sub_replace_pattern' +logger = logging.getLogger(__name__) +class RegexSubProcessor(BaseProcessor): + """ + Performs a regex sub against an attribute value. + Example configuration: + module: satosa.micro_services.attribute_processor.AttributeProcessor + name: AttributeProcessor + config: + process: + - attribute: role + processors: + - name: RegexSubProcessor + module: satosa.micro_services.custom.processors.regex_sub_processor + regex_sub_match_pattern: (?<=saml-provider\/)(.*)(?=,) + regex_sub_replace_pattern: \1-Test + + """ + + def process(self, internal_data, attribute, **kwargs): + regex_sub_match_pattern = r'{}'.format(kwargs.get(CONFIG_KEY_MATCH_PATTERN, '')) + if regex_sub_match_pattern == '': + raise AttributeProcessorError("The regex_sub_match_pattern needs to be set") + + regex_sub_replace_pattern = r'{}'.format(kwargs.get(CONFIG_KEY_REPLACE_PATTERN, '')) + if regex_sub_replace_pattern == '': + raise AttributeProcessorError("The regex_sub_replace_pattern needs to be set") + attributes = internal_data.attributes + + values = attributes.get(attribute, []) + new_values = [] + if not values: + raise AttributeProcessorWarning("Cannot apply regex_sub to {}, it has no values".format(attribute)) + for value in values: + new_values.append(re.sub(r'{}'.format(regex_sub_match_pattern), r'{}'.format(regex_sub_replace_pattern), value)) + logger.debug('regex_sub new_values: {}'.format(new_values)) + attributes[attribute] = new_values \ No newline at end of file From 211005af70f4ac9b76460a94760a2d072a49b975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 29 Sep 2020 11:39:30 +0200 Subject: [PATCH 190/401] entityid_endpoint in the example SAML2 frontend configuration To demonstrate that it has to be a top level option, not nested inside `idp_config` --- example/plugins/frontends/saml2_frontend.yaml.example | 1 + 1 file changed, 1 insertion(+) diff --git a/example/plugins/frontends/saml2_frontend.yaml.example b/example/plugins/frontends/saml2_frontend.yaml.example index 87bc4203f..40c9000f2 100644 --- a/example/plugins/frontends/saml2_frontend.yaml.example +++ b/example/plugins/frontends/saml2_frontend.yaml.example @@ -1,6 +1,7 @@ module: satosa.frontends.saml2.SAMLFrontend name: Saml2IDP config: + entityid_endpoint: true idp_config: organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'} contact_person: From 4a442b6f6d0d023ee78e128e96a896e1f89f9cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 29 Sep 2020 11:41:06 +0200 Subject: [PATCH 191/401] entityid_endpoint in the example SAML2 backend configuration To demonstrate that it has to be a top level option, not nested inside `sp_config` --- example/plugins/backends/saml2_backend.yaml.example | 1 + 1 file changed, 1 insertion(+) diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index a71dfd0d4..d5ec7cb56 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -3,6 +3,7 @@ name: Saml2 config: idp_blacklist_file: /path/to/blacklist.json + entityid_endpoint: true mirror_force_authn: no memorize_idp: no use_memorized_idp_when_force_authn: no From 24a7651f0f598df0e58bb30d1c634edd4842f113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 29 Sep 2020 13:45:49 +0200 Subject: [PATCH 192/401] Add sub_hash_salt to README Note that `sub_hash_salt` is regenerated on startup if not specified in config, which results in varying identifiers. https://github.com/IdentityPython/SATOSA/blob/8b641cebbc4910ecc5ac897a67e1e530cf408c24/src/satosa/frontends/openid_connect.py#L99 --- doc/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/README.md b/doc/README.md index fe87e1f97..b41bcf3aa 100644 --- a/doc/README.md +++ b/doc/README.md @@ -433,6 +433,7 @@ The configuration parameters available: * `signing_key_path`: path to a RSA Private Key file (PKCS#1). MUST be configured. * `db_uri`: connection URI to MongoDB instance where the data will be persisted, if it's not specified all data will only be stored in-memory (not suitable for production use). +* `sub_hash_salt`: salt which is hashed into the `sub` claim. If it's not specified, SATOSA will generate a random salt on each startup, which means that users will get new `sub` value after every restart. * `provider`: provider configuration information. MUST be configured, the following configuration are supported: * `response_types_supported` (default: `[id_token]`): list of all supported response types, see [Section 3 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#Authentication). * `subject_types_supported` (default: `[pairwise]`): list of all supported subject identifier types, see [Section 8 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes) From 6214b41b0a8ae4b20792e6c341d0e71f7f2d25cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 29 Sep 2020 13:47:49 +0200 Subject: [PATCH 193/401] add sub_hash_salt to the example OIDC frontend configuration --- example/plugins/frontends/openid_connect_frontend.yaml.example | 1 + 1 file changed, 1 insertion(+) diff --git a/example/plugins/frontends/openid_connect_frontend.yaml.example b/example/plugins/frontends/openid_connect_frontend.yaml.example index 6c94ea758..1006302e2 100644 --- a/example/plugins/frontends/openid_connect_frontend.yaml.example +++ b/example/plugins/frontends/openid_connect_frontend.yaml.example @@ -4,6 +4,7 @@ config: signing_key_path: frontend.key db_uri: mongodb://db.example.com # optional: only support MongoDB, will default to in-memory storage if not specified client_db_path: /path/to/your/cdb.json + sub_hash_salt: randomSALTvalue # if not specified, it is randomly generated on every startup provider: client_registration_supported: Yes response_types_supported: ["code", "id_token token"] From 1c4e316237b6ab6284451e4f95121530244f3982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 6 Oct 2020 11:55:10 +0200 Subject: [PATCH 194/401] Sign in with Apple backend --- .../backends/apple_backend.yaml.example | 29 ++ src/satosa/backends/apple.py | 285 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 example/plugins/backends/apple_backend.yaml.example create mode 100644 src/satosa/backends/apple.py diff --git a/example/plugins/backends/apple_backend.yaml.example b/example/plugins/backends/apple_backend.yaml.example new file mode 100644 index 000000000..4426c8cc4 --- /dev/null +++ b/example/plugins/backends/apple_backend.yaml.example @@ -0,0 +1,29 @@ +module: satosa.backends.apple.AppleBackend +name: apple +config: + provider_metadata: + issuer: https://appleid.apple.com + client: + verify_ssl: yes + auth_req_params: + response_type: code + scope: [openid, email, name] + response_mode: form_post + token_endpoint_auth_method: client_secret_post + client_metadata: + application_name: Sign in with Apple + application_type: web + client_id: 'CLIENT_ID_HERE' + client_secret: 'CLIENT_SECRET_HERE' + redirect_uris: [/] + subject_type: pairwise + entity_info: + organization: + display_name: + - ['Apple', 'en'] + name: + - ['Apple Inc.', 'en'] + ui_info: + display_name: + - lang: en + text: 'Sign in with Apple' diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py new file mode 100644 index 000000000..633e22c19 --- /dev/null +++ b/src/satosa/backends/apple.py @@ -0,0 +1,285 @@ +""" +Apple backend module. +""" +import logging +from datetime import datetime +from urllib.parse import urlparse + +from oic.oauth2.message import Message +from oic import oic +from oic import rndstr +from oic.oic.message import AuthorizationResponse +from oic.oic.message import ProviderConfigurationResponse +from oic.oic.message import RegistrationRequest +from oic.utils.authn.authn_context import UNSPECIFIED +from oic.utils.authn.client import CLIENT_AUTHN_METHOD + +import satosa.logging_util as lu +from satosa.internal import AuthenticationInformation +from satosa.internal import InternalData +from .base import BackendModule +from .oauth import get_metadata_desc_for_oauth_backend +from ..exception import SATOSAAuthenticationError, SATOSAError +from ..response import Redirect + +import base64 +import json +import requests + + +logger = logging.getLogger(__name__) + +NONCE_KEY = "oidc_nonce" +STATE_KEY = "oidc_state" + +# https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple +class AppleBackend(BackendModule): + """Sign in with Apple backend""" + + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): + """ + Sign in with Apple backend module. + :param auth_callback_func: Callback should be called by the module after the authorization + in the backend is done. + :param internal_attributes: Mapping dictionary between SATOSA internal attribute names and + the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and + RP's expects namevice. + :param config: Configuration parameters for the module. + :param base_url: base url of the service + :param name: name of the plugin + + :type auth_callback_func: + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + :type internal_attributes: dict[string, dict[str, str | list[str]]] + :type config: dict[str, dict[str, str] | list[str]] + :type base_url: str + :type name: str + """ + super().__init__(auth_callback_func, internal_attributes, base_url, name) + self.auth_callback_func = auth_callback_func + self.config = config + self.client = _create_client( + config["provider_metadata"], + config["client"]["client_metadata"], + config["client"].get("verify_ssl", True), + ) + if "scope" not in config["client"]["auth_req_params"]: + config["auth_req_params"]["scope"] = "openid" + if "response_type" not in config["client"]["auth_req_params"]: + config["auth_req_params"]["response_type"] = "code" + + def start_auth(self, context, request_info): + """ + See super class method satosa.backends.base#start_auth + :type context: satosa.context.Context + :type request_info: satosa.internal.InternalData + """ + oidc_nonce = rndstr() + oidc_state = rndstr() + state_data = { + NONCE_KEY: oidc_nonce, + STATE_KEY: oidc_state + } + context.state[self.name] = state_data + + args = { + "scope": self.config["client"]["auth_req_params"]["scope"], + "response_type": self.config["client"]["auth_req_params"]["response_type"], + "client_id": self.client.client_id, + "redirect_uri": self.client.registration_response["redirect_uris"][0], + "state": oidc_state, + "nonce": oidc_nonce + } + args.update(self.config["client"]["auth_req_params"]) + auth_req = self.client.construct_AuthorizationRequest(request_args=args) + login_url = auth_req.request(self.client.authorization_endpoint) + return Redirect(login_url) + + def register_endpoints(self): + """ + Creates a list of all the endpoints this backend module needs to listen to. In this case + it's the authentication response from the underlying OP that is redirected from the OP to + the proxy. + :rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]] + :return: A list that can be used to map the request to SATOSA to this endpoint. + """ + url_map = [] + redirect_path = urlparse(self.config["client"]["client_metadata"]["redirect_uris"][0]).path + if not redirect_path: + raise SATOSAError("Missing path in redirect uri") + + url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint)) + return url_map + + def _verify_nonce(self, nonce, context): + """ + Verify the received OIDC 'nonce' from the ID Token. + :param nonce: OIDC nonce + :type nonce: str + :param context: current request context + :type context: satosa.context.Context + :raise SATOSAAuthenticationError: if the nonce is incorrect + """ + backend_state = context.state[self.name] + if nonce != backend_state[NONCE_KEY]: + msg = "Missing or invalid nonce in authn response for state: {}".format(backend_state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + raise SATOSAAuthenticationError(context.state, "Missing or invalid nonce in authn response") + + def _get_tokens(self, authn_response, context): + """ + :param authn_response: authentication response from OP + :type authn_response: oic.oic.message.AuthorizationResponse + :return: access token and ID Token claims + :rtype: Tuple[Optional[str], Optional[Mapping[str, str]]] + """ + if "code" in authn_response: + # make token request + # https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens + args = { + "client_id": self.client.client_id, + "client_secret": self.client.client_secret, + "code": authn_response["code"], + "grant_type": "authorization_code", + "redirect_uri": self.client.registration_response['redirect_uris'][0], + } + + token_resp = requests.post( + "https://appleid.apple.com/auth/token", + data=args, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ).json() + + logger.debug("apple response received") + logger.debug(token_resp) + + self._check_error_response(token_resp, context) + + keyjar = self.client.keyjar + id_token_claims = dict(Message().from_jwt(token_resp["id_token"], keyjar=keyjar)) + + return token_resp["access_token"], id_token_claims + + return authn_response.get("access_token"), authn_response.get("id_token") + + def _check_error_response(self, response, context): + """ + Check if the response is an OAuth error response. + :param response: the OIDC response + :type response: oic.oic.message + :raise SATOSAAuthenticationError: if the response is an OAuth error response + """ + if "error" in response: + msg = "{name} error: {error} {description}".format( + name=type(response).__name__, + error=response["error"], + description=response.get("error_description", ""), + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + raise SATOSAAuthenticationError(context.state, "Access denied") + + def response_endpoint(self, context, *args): + """ + Handles the authentication response from the OP. + :type context: satosa.context.Context + :type args: Any + :rtype: satosa.response.Response + + :param context: SATOSA context + :param args: None + :return: + """ + backend_state = context.state[self.name] + authn_resp = self.client.parse_response(AuthorizationResponse, info=context.request, sformat="dict") + if backend_state[STATE_KEY] != authn_resp["state"]: + msg = "Missing or invalid state in authn response for state: {}".format(backend_state) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + raise SATOSAAuthenticationError(context.state, "Missing or invalid state in authn response") + + self._check_error_response(authn_resp, context) + access_token, id_token_claims = self._get_tokens(authn_resp, context) + if not id_token_claims: + id_token_claims = {} + + # Apple has no userinfo endpoint + userinfo = {} + + if not id_token_claims and not userinfo: + msg = "No id_token or userinfo, nothing to do.." + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise SATOSAAuthenticationError(context.state, "No user info available.") + + all_user_claims = dict(list(userinfo.items()) + list(id_token_claims.items())) + msg = "UserInfo: {}".format(all_user_claims) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + del context.state[self.name] + internal_resp = self._translate_response(all_user_claims, self.client.authorization_endpoint) + return self.auth_callback_func(context, internal_resp) + + def _translate_response(self, response, issuer): + """ + Translates oidc response to SATOSA internal response. + :type response: dict[str, str] + :type issuer: str + :type subject_type: str + :rtype: InternalData + + :param response: Dictioary with attribute name as key. + :param issuer: The oidc op that gave the repsonse. + :param subject_type: public or pairwise according to oidc standard. + :return: A SATOSA internal response. + """ + auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer) + internal_resp = InternalData(auth_info=auth_info) + internal_resp.attributes = self.converter.to_internal("openid", response) + internal_resp.subject_id = response["sub"] + return internal_resp + + def get_metadata_desc(self): + """ + See satosa.backends.oauth.get_metadata_desc + :rtype: satosa.metadata_creation.description.MetadataDescription + """ + return get_metadata_desc_for_oauth_backend(self.config["provider_metadata"]["issuer"], self.config) + + +def _create_client(provider_metadata, client_metadata, verify_ssl=True): + """ + Create a pyoidc client instance. + :param provider_metadata: provider configuration information + :type provider_metadata: Mapping[str, Union[str, Sequence[str]]] + :param client_metadata: client metadata + :type client_metadata: Mapping[str, Union[str, Sequence[str]]] + :return: client instance to use for communicating with the configured provider + :rtype: oic.oic.Client + """ + client = oic.Client( + client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=verify_ssl + ) + + # Provider configuration information + if "authorization_endpoint" in provider_metadata: + # no dynamic discovery necessary + client.handle_provider_config(ProviderConfigurationResponse(**provider_metadata), + provider_metadata["issuer"]) + else: + # do dynamic discovery + client.provider_config(provider_metadata["issuer"]) + + # Client information + if "client_id" in client_metadata: + # static client info provided + client.store_registration_info(RegistrationRequest(**client_metadata)) + else: + # do dynamic registration + client.register(client.provider_info['registration_endpoint'], + **client_metadata) + + client.subject_type = (client.registration_response.get("subject_type") or + client.provider_info["subject_types_supported"][0]) + return client From e98172bba4ec35c638523183a958286b84a3ab2f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 25 Oct 2020 23:21:53 +0200 Subject: [PATCH 195/401] Remove the metadata_construction param Additionally, the saml2.assertion.Policy object can be initialized with a metadata store and thus the .restrict and .filter methods do not need such a param. This will remain as it was, until some time has passed and confidence is built that peolpe are using a recent enough version of pysaml2, before dropping the param from the .restrict method. Up to that point, warnings will be output, but functionality is preserved. Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 4 +--- src/satosa/frontends/saml2.py | 20 +++++++++++-------- src/satosa/metadata_creation/saml_metadata.py | 2 +- tests/flows/test_oidc-saml.py | 2 +- tests/flows/test_saml-oidc.py | 2 +- tests/flows/test_saml-saml.py | 4 ++-- tests/satosa/backends/test_saml2.py | 10 +++++----- tests/satosa/frontends/test_saml2.py | 12 +++++------ .../metadata_creation/test_saml_metadata.py | 4 ++-- tests/util.py | 2 +- 10 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 2c37e6a2b..d855080a2 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -104,9 +104,7 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): 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 - ) + sp_config = SPConfig().load(copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG])) self.sp = Base(sp_config) self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 752ff431b..8c788d749 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -116,7 +116,7 @@ def register_endpoints(self, backend_names): self.idp_config = self._build_idp_config_endpoints( self.config[self.KEY_IDP_CONFIG], backend_names) # Create the idp - idp_config = IdPConfig().load(copy.deepcopy(self.idp_config), metadata_construction=False) + idp_config = IdPConfig().load(copy.deepcopy(self.idp_config)) self.idp = Server(config=idp_config) return self._register_endpoints(backend_names) @@ -290,9 +290,14 @@ def _filter_attributes(self, idp, internal_response, context,): idp_policy = idp.config.getattr("policy", "idp") attributes = {} if idp_policy: - approved_attributes = self._get_approved_attributes(idp, idp_policy, internal_response.requester, - context.state) - attributes = {k: v for k, v in internal_response.attributes.items() if k in approved_attributes} + approved_attributes = self._get_approved_attributes( + idp, idp_policy, internal_response.requester, context.state + ) + attributes = { + k: v + for k, v in internal_response.attributes.items() + if k in approved_attributes + } return attributes @@ -637,7 +642,7 @@ def _load_idp_dynamic_endpoints(self, context): """ target_entity_id = context.target_entity_id_from_path() idp_conf_file = self._load_endpoints_to_config(context.target_backend, target_entity_id) - idp_config = IdPConfig().load(idp_conf_file, metadata_construction=False) + idp_config = IdPConfig().load(idp_conf_file) return Server(config=idp_config) def _load_idp_dynamic_entity_id(self, state): @@ -653,7 +658,7 @@ def _load_idp_dynamic_entity_id(self, state): # Change the idp entity id dynamically idp_config_file = copy.deepcopy(self.idp_config) idp_config_file["entityid"] = "{}/{}".format(self.idp_config["entityid"], state[self.name]["target_entity_id"]) - idp_config = IdPConfig().load(idp_config_file, metadata_construction=False) + idp_config = IdPConfig().load(idp_config_file) return Server(config=idp_config) def handle_authn_request(self, context, binding_in): @@ -1033,8 +1038,7 @@ def _create_co_virtual_idp(self, context): # Use the overwritten IdP config to generate a pysaml2 config object # and from it a server object. - pysaml2_idp_config = IdPConfig().load(idp_config, - metadata_construction=False) + pysaml2_idp_config = IdPConfig().load(idp_config) server = Server(config=pysaml2_idp_config) diff --git a/src/satosa/metadata_creation/saml_metadata.py b/src/satosa/metadata_creation/saml_metadata.py index 1a9e1d730..f1b294759 100644 --- a/src/satosa/metadata_creation/saml_metadata.py +++ b/src/satosa/metadata_creation/saml_metadata.py @@ -17,7 +17,7 @@ def _create_entity_descriptor(entity_config): - cnf = Config().load(copy.deepcopy(entity_config), metadata_construction=True) + cnf = Config().load(copy.deepcopy(entity_config)) return entity_descriptor(cnf) diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index 2d51c9dd6..e5888fb8f 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -78,7 +78,7 @@ def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_ # config test IdP backend_metadata_str = str(backend_metadata[saml_backend_config["name"]][0]) idp_conf["metadata"]["inline"].append(backend_metadata_str) - fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf, metadata_construction=False)) + fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) # create auth resp req_params = dict(parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query)) diff --git a/tests/flows/test_saml-oidc.py b/tests/flows/test_saml-oidc.py index b0068cc50..e242ebb89 100644 --- a/tests/flows/test_saml-oidc.py +++ b/tests/flows/test_saml-oidc.py @@ -32,7 +32,7 @@ def run_test(self, satosa_config_dict, sp_conf, oidc_backend_config, frontend_co # config test SP frontend_metadata_str = str(frontend_metadata[frontend_config["name"]][0]) sp_conf["metadata"]["inline"].append(frontend_metadata_str) - fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False)) + fakesp = FakeSP(SPConfig().load(sp_conf)) # create auth req destination, req_args = fakesp.make_auth_req(frontend_metadata[frontend_config["name"]][0].entity_id) diff --git a/tests/flows/test_saml-saml.py b/tests/flows/test_saml-saml.py index 29f20fc0f..ce6cd6960 100644 --- a/tests/flows/test_saml-saml.py +++ b/tests/flows/test_saml-saml.py @@ -28,7 +28,7 @@ def run_test(self, satosa_config_dict, sp_conf, idp_conf, saml_backend_config, f # config test SP frontend_metadata_str = str(frontend_metadata[frontend_config["name"]][0]) sp_conf["metadata"]["inline"].append(frontend_metadata_str) - fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False)) + fakesp = FakeSP(SPConfig().load(sp_conf)) # create auth req destination, req_args = fakesp.make_auth_req(frontend_metadata[frontend_config["name"]][0].entity_id) @@ -41,7 +41,7 @@ def run_test(self, satosa_config_dict, sp_conf, idp_conf, saml_backend_config, f # config test IdP backend_metadata_str = str(backend_metadata[saml_backend_config["name"]][0]) idp_conf["metadata"]["inline"].append(backend_metadata_str) - fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf, metadata_construction=False)) + fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) # create auth resp req_params = dict(parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query)) diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index e5e2d905c..eed74db6c 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -118,7 +118,7 @@ def test_discovery_server_set_in_context(self, context, sp_conf): def test_full_flow(self, context, idp_conf, sp_conf): test_state_key = "test_state_key_456afgrh" response_binding = BINDING_HTTP_REDIRECT - fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf, metadata_construction=False)) + fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) context.state[test_state_key] = "my_state" @@ -181,8 +181,8 @@ def test_authn_request(self, context, idp_conf): def test_authn_response(self, context, idp_conf, sp_conf): response_binding = BINDING_HTTP_REDIRECT - fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False)) - fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf, metadata_construction=False)) + fakesp = FakeSP(SPConfig().load(sp_conf)) + fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) destination, request_params = fakesp.make_auth_req(idp_conf["entityid"]) url, auth_resp = fakeidp.handle_auth_req(request_params["SAMLRequest"], request_params["RelayState"], BINDING_HTTP_REDIRECT, @@ -202,10 +202,10 @@ def test_authn_response(self, context, idp_conf, sp_conf): def test_authn_response_no_name_id(self, context, idp_conf, sp_conf): response_binding = BINDING_HTTP_REDIRECT - fakesp_conf = SPConfig().load(sp_conf, metadata_construction=False) + fakesp_conf = SPConfig().load(sp_conf) fakesp = FakeSP(fakesp_conf) - fakeidp_conf = IdPConfig().load(idp_conf, metadata_construction=False) + fakeidp_conf = IdPConfig().load(idp_conf) fakeidp = FakeIdP(USERS, config=fakeidp_conf) destination, request_params = fakesp.make_auth_req( diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index 00890a56e..1e26db460 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -75,7 +75,7 @@ def setup_for_authn_req(self, context, idp_conf, sp_conf, nameid_format=None, re idp_metadata_str = create_metadata_from_config_dict(samlfrontend.idp_config) sp_conf["metadata"]["inline"].append(idp_metadata_str) - fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False)) + fakesp = FakeSP(SPConfig().load(sp_conf)) destination, auth_req = fakesp.make_auth_req( samlfrontend.idp_config["entityid"], nameid_format, @@ -94,7 +94,7 @@ def setup_for_authn_req(self, context, idp_conf, sp_conf, nameid_format=None, re return samlfrontend def get_auth_response(self, samlfrontend, context, internal_response, sp_conf, idp_metadata_str): - sp_config = SPConfig().load(sp_conf, metadata_construction=False) + sp_config = SPConfig().load(sp_conf) resp_args = { "name_id_policy": NameIDPolicy(format=NAMEID_FORMAT_TRANSIENT), "in_response_to": None, @@ -150,7 +150,7 @@ def test_handle_authn_request(self, context, idp_conf, sp_conf, internal_respons resp = samlfrontend.handle_authn_response(context, internal_response) resp_dict = parse_qs(urlparse(resp.message).query) - fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False)) + fakesp = FakeSP(SPConfig().load(sp_conf)) resp = fakesp.parse_authn_request_response(resp_dict["SAMLResponse"][0], BINDING_HTTP_REDIRECT) for key in resp.ava: @@ -189,7 +189,7 @@ def test_handle_authn_response_without_relay_state(self, context, idp_conf, sp_c resp = samlfrontend.handle_authn_response(context, internal_response) resp_dict = parse_qs(urlparse(resp.message).query) - fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False)) + fakesp = FakeSP(SPConfig().load(sp_conf)) resp = fakesp.parse_authn_request_response(resp_dict["SAMLResponse"][0], BINDING_HTTP_REDIRECT) @@ -213,7 +213,7 @@ def test_handle_authn_response_without_name_id( resp = samlfrontend.handle_authn_response(context, internal_response) resp_dict = parse_qs(urlparse(resp.message).query) - fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False)) + fakesp = FakeSP(SPConfig().load(sp_conf)) resp = fakesp.parse_authn_request_response( resp_dict["SAMLResponse"][0], BINDING_HTTP_REDIRECT) @@ -548,7 +548,7 @@ def test_co_static_attributes(self, frontend, context, internal_response, # SP configuration fixture with the metadata. idp_metadata_str = create_metadata_from_config_dict(idp_conf) sp_conf["metadata"]["inline"].append(idp_metadata_str) - sp_config = SPConfig().load(sp_conf, metadata_construction=False) + sp_config = SPConfig().load(sp_conf) # Use the updated sp_config fixture to generate a fake SP and then # use the fake SP to generate an authentication request aimed at the diff --git a/tests/satosa/metadata_creation/test_saml_metadata.py b/tests/satosa/metadata_creation/test_saml_metadata.py index 49cff97a4..77e8ac1d7 100644 --- a/tests/satosa/metadata_creation/test_saml_metadata.py +++ b/tests/satosa/metadata_creation/test_saml_metadata.py @@ -236,7 +236,7 @@ def test_create_mirrored_metadata_does_not_contain_target_contact_info(self, sat class TestCreateSignedEntitiesDescriptor: @pytest.fixture def entity_desc(self, sp_conf): - return entity_descriptor(SPConfig().load(sp_conf, metadata_construction=True)) + return entity_descriptor(SPConfig().load(sp_conf)) @pytest.fixture def verification_security_context(self, cert_and_key): @@ -274,7 +274,7 @@ def test_valid_for(self, entity_desc, signature_security_context): class TestCreateSignedEntityDescriptor: @pytest.fixture def entity_desc(self, sp_conf): - return entity_descriptor(SPConfig().load(sp_conf, metadata_construction=True)) + return entity_descriptor(SPConfig().load(sp_conf)) @pytest.fixture def verification_security_context(self, cert_and_key): diff --git a/tests/util.py b/tests/util.py index 0e1f5f9fb..c26c796fe 100644 --- a/tests/util.py +++ b/tests/util.py @@ -231,7 +231,7 @@ def handle_auth_req_no_name_id(self, saml_request, relay_state, binding, def create_metadata_from_config_dict(config): nspair = {"xs": "http://www.w3.org/2001/XMLSchema"} - conf = Config().load(config, metadata_construction=True) + conf = Config().load(config) return entity_descriptor(conf).to_string(nspair).decode("utf-8") From adbf458449168fb5ff114e7f476f637ed052df84 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 30 Oct 2020 20:42:42 +0200 Subject: [PATCH 196/401] Update travis distribution from xenial to bionic Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3fa650542..7e45d5d75 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ os: linux -dist: xenial +dist: bionic language: python services: From 479a56227e49a30898723a55840d0c5f00fbac29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 10 Nov 2020 19:09:41 +0100 Subject: [PATCH 197/401] fix remote metadata config example in README --- doc/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/README.md b/doc/README.md index b41bcf3aa..f6b28920f 100644 --- a/doc/README.md +++ b/doc/README.md @@ -193,11 +193,10 @@ Metadata from local file: Metadata from remote URL: - "metadata": { - "remote": - - url:https://kalmar2.org/simplesaml/module.php/aggregator/?id=kalmarcentral2&set=saml2 - cert:null - } + "metadata": + remote: + - url: "https://kalmar2.org/simplesaml/module.php/aggregator/?id=kalmarcentral2&set=saml2" + cert: null For more detailed information on how you could customize the SAML entities, see the From 326ec9319c8863f6c1b160a1dfb02c3117b955b8 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 14 Dec 2020 14:17:05 +0200 Subject: [PATCH 198/401] Fix YAML formatting Signed-off-by: Ivan Kanakarakis --- doc/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/doc/README.md b/doc/README.md index f6b28920f..3aef52f5e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -306,17 +306,17 @@ basis. This example summarizes the most common settings (hopefully self-explanat ```yaml config: - idp_config: - service: - idp: - policy: - default: - sign_response: True - sign_assertion: False - sign_alg: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" - digest_alg: "http://www.w3.org/2001/04/xmlenc#sha256" - : - ... + idp_config: + service: + idp: + policy: + default: + sign_response: True + sign_assertion: False + sign_alg: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + digest_alg: "http://www.w3.org/2001/04/xmlenc#sha256" + : + ... ``` Overrides per SP entityID is possible by using the entityID as a key instead of the "default" key From 9cbd8d04ff07a261960029cd7ef7422bbb46081f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 14 Dec 2020 14:18:11 +0200 Subject: [PATCH 199/401] Remove reference to sign_alg and digest_alg from documentation Signed-off-by: Ivan Kanakarakis --- doc/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/doc/README.md b/doc/README.md index 3aef52f5e..157b747b0 100644 --- a/doc/README.md +++ b/doc/README.md @@ -313,8 +313,6 @@ config: default: sign_response: True sign_assertion: False - sign_alg: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" - digest_alg: "http://www.w3.org/2001/04/xmlenc#sha256" : ... ``` From 04850eeb395b3f19c70507aa1e03201ff787b6a3 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 14 Dec 2020 19:35:14 +0200 Subject: [PATCH 200/401] Deprecate saml2 frontend sign_alg and digest_alg configuration options sign_alg and digest_alg are deprecated; instead, use signing_algorithm and digest_algorithm configurations under the service/idp configuration path (not under policy/default) Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/saml2.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 8c788d749..1af83e2fb 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -377,18 +377,18 @@ def _handle_authn_response(self, context, internal_response, idp): # Construct arguments for method create_authn_response # on IdP Server instance args = { - 'identity' : ava, - 'name_id' : name_id, - 'authn' : auth_info, - 'sign_response' : sign_response, + # Add the SP details + **resp_args, + # AuthnResponse data + 'identity': ava, + 'name_id': name_id, + 'authn': auth_info, + 'sign_response': sign_response, 'sign_assertion': sign_assertion, 'encrypt_assertion': encrypt_assertion, - 'encrypted_advice_attributes': encrypted_advice_attributes + 'encrypted_advice_attributes': encrypted_advice_attributes, } - # Add the SP details - args.update(**resp_args) - try: args['sign_alg'] = getattr(xmldsig, sign_alg) except AttributeError as e: @@ -413,6 +413,16 @@ def _handle_authn_response(self, context, internal_response, idp): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) + if 'sign_alg' in args or 'digest_alg' in args: + msg = ( + "sign_alg and digest_alg are deprecated; " + "instead, use signing_algorithm and digest_algorithm " + "under the service/idp configuration path " + "(not under policy/default)." + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warning(msg) + resp = idp.create_authn_response(**args) http_args = idp.apply_binding( resp_args["binding"], str(resp), resp_args["destination"], From 580c16671667ff4b4ee6d47e0a6572f000698533 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 14 Dec 2020 22:17:52 +0200 Subject: [PATCH 201/401] Fix the saml2 frontend entity-category tests Signed-off-by: Ivan Kanakarakis --- tests/satosa/frontends/test_saml2.py | 60 +++++++++++++++++++--------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index 1e26db460..8396a5945 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -285,17 +285,28 @@ def test_acr_mapping_per_idp_in_authn_response(self, context, idp_conf, sp_conf, authn_context_class_ref = resp.assertion.authn_statement[0].authn_context.authn_context_class_ref assert authn_context_class_ref.text == expected_loa - @pytest.mark.parametrize("entity_category, entity_category_module, expected_attributes", [ - ([""], "swamid", swamid.RELEASE[""]), - ([COCO], "edugain", edugain.RELEASE[""] + edugain.RELEASE[COCO]), - ([RESEARCH_AND_SCHOLARSHIP], "refeds", refeds.RELEASE[""] + refeds.RELEASE[RESEARCH_AND_SCHOLARSHIP]), - ([RESEARCH_AND_EDUCATION, EU], "swamid", swamid.RELEASE[""] + swamid.RELEASE[(RESEARCH_AND_EDUCATION, EU)]), - ([RESEARCH_AND_EDUCATION, HEI], "swamid", swamid.RELEASE[""] + swamid.RELEASE[(RESEARCH_AND_EDUCATION, HEI)]), - ([RESEARCH_AND_EDUCATION, NREN], "swamid", swamid.RELEASE[""] + swamid.RELEASE[(RESEARCH_AND_EDUCATION, NREN)]), - ([SFS_1993_1153], "swamid", swamid.RELEASE[""] + swamid.RELEASE[SFS_1993_1153]), - ]) - def test_respect_sp_entity_categories(self, context, entity_category, entity_category_module, expected_attributes, - idp_conf, sp_conf, internal_response): + @pytest.mark.parametrize( + "entity_category, entity_category_module, expected_attributes", + [ + ([""], "swamid", swamid.RELEASE[""]), + ([COCO], "edugain", edugain.RELEASE[""] + edugain.RELEASE[COCO]), + ([RESEARCH_AND_SCHOLARSHIP], "refeds", refeds.RELEASE[""] + refeds.RELEASE[RESEARCH_AND_SCHOLARSHIP]), + ([RESEARCH_AND_EDUCATION, EU], "swamid", swamid.RELEASE[""] + swamid.RELEASE[(RESEARCH_AND_EDUCATION, EU)]), + ([RESEARCH_AND_EDUCATION, HEI], "swamid", swamid.RELEASE[""] + swamid.RELEASE[(RESEARCH_AND_EDUCATION, HEI)]), + ([RESEARCH_AND_EDUCATION, NREN], "swamid", swamid.RELEASE[""] + swamid.RELEASE[(RESEARCH_AND_EDUCATION, NREN)]), + ([SFS_1993_1153], "swamid", swamid.RELEASE[""] + swamid.RELEASE[SFS_1993_1153]), + ] + ) + def test_respect_sp_entity_categories( + self, + context, + entity_category, + entity_category_module, + expected_attributes, + idp_conf, + sp_conf, + internal_response + ): idp_metadata_str = create_metadata_from_config_dict(idp_conf) idp_conf["service"]["idp"]["policy"]["default"]["entity_categories"] = [entity_category_module] if all(entity_category): # don't insert empty entity category @@ -303,10 +314,18 @@ def test_respect_sp_entity_categories(self, context, entity_category, entity_cat if entity_category == [COCO]: sp_conf["service"]["sp"]["required_attributes"] = expected_attributes - expected_attributes_in_all_entity_categories = list( - itertools.chain(swamid.RELEASE[""], edugain.RELEASE[COCO], refeds.RELEASE[RESEARCH_AND_SCHOLARSHIP], - swamid.RELEASE[(RESEARCH_AND_EDUCATION, EU)], swamid.RELEASE[(RESEARCH_AND_EDUCATION, HEI)], - swamid.RELEASE[(RESEARCH_AND_EDUCATION, NREN)], swamid.RELEASE[SFS_1993_1153])) + expected_attributes_in_all_entity_categories = set( + itertools.chain( + swamid.RELEASE[""], + edugain.RELEASE[""], + edugain.RELEASE[COCO], + refeds.RELEASE[RESEARCH_AND_SCHOLARSHIP], + swamid.RELEASE[(RESEARCH_AND_EDUCATION, EU)], + swamid.RELEASE[(RESEARCH_AND_EDUCATION, HEI)], + swamid.RELEASE[(RESEARCH_AND_EDUCATION, NREN)], + swamid.RELEASE[SFS_1993_1153], + ) + ) attribute_mapping = {} for expected_attribute in expected_attributes_in_all_entity_categories: attribute_mapping[expected_attribute.lower()] = {"saml": [expected_attribute]} @@ -345,8 +364,9 @@ def test_metadata_endpoint(self, context, idp_conf): assert headers["Content-Type"] == "text/xml" assert idp_conf["entityid"] in resp.message - def test_custom_attribute_release_with_less_attributes_than_entity_category(self, context, idp_conf, sp_conf, - internal_response): + def test_custom_attribute_release_with_less_attributes_than_entity_category( + self, context, idp_conf, sp_conf, internal_response + ): idp_metadata_str = create_metadata_from_config_dict(idp_conf) idp_conf["service"]["idp"]["policy"]["default"]["entity_categories"] = ["swamid"] sp_conf["entity_category"] = [SFS_1993_1153] @@ -364,8 +384,12 @@ def test_custom_attribute_release_with_less_attributes_than_entity_category(self samlfrontend = self.setup_for_authn_req(context, idp_conf, sp_conf, internal_attributes=internal_attributes, extra_config=dict(custom_attribute_release=custom_attributes)) + internal_response.requester = sp_conf["entityid"] resp = self.get_auth_response(samlfrontend, context, internal_response, sp_conf, idp_metadata_str) - assert len(resp.ava.keys()) == 0 + assert len(resp.ava.keys()) == ( + len(expected_attributes) + - len(custom_attributes[internal_response.auth_info.issuer][internal_response.requester]["exclude"]) + ) class TestSAMLMirrorFrontend: From 67da43132e894711a8e910f19e131b63909d4ffe Mon Sep 17 00:00:00 2001 From: ctr49 Date: Fri, 8 Jan 2021 17:01:53 +0100 Subject: [PATCH 202/401] improve debugging for attribute mapping --- src/satosa/attribute_mapping.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/satosa/attribute_mapping.py b/src/satosa/attribute_mapping.py index ebb008bc0..e8729561c 100644 --- a/src/satosa/attribute_mapping.py +++ b/src/satosa/attribute_mapping.py @@ -100,8 +100,8 @@ def to_internal(self, attribute_profile, external_dict): attribute_values = self._collate_attribute_values_by_priority_order(external_attribute_name, external_dict) if attribute_values: # Only insert key if it has some values - logline = "backend attribute {external} mapped to {internal}".format( - external=external_attribute_name, internal=internal_attribute_name + logline = "backend attribute {external} mapped to {internal} ({value})".format( + external=external_attribute_name, internal=internal_attribute_name, value=attribute_values ) logger.debug(logline) internal_dict[internal_attribute_name] = attribute_values @@ -205,8 +205,8 @@ def from_internal(self, attribute_profile, internal_dict): external_attribute_names = self.from_internal_attributes[internal_attribute_name][attribute_profile] # select the first attribute name external_attribute_name = external_attribute_names[0] - logline = "frontend attribute {external} mapped from {internal}".format( - external=external_attribute_name, internal=internal_attribute_name + logline = "frontend attribute {external} mapped from {internal} ({value})".format( + external=external_attribute_name, internal=internal_attribute_name, value=internal_dict[internal_attribute_name] ) logger.debug(logline) From 444d017d2129d90d8266aa4d5a380d53555384ed Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 20 Jan 2021 15:50:30 +0200 Subject: [PATCH 203/401] Prefer signing_algorithm and digest_algorithm over sign_alg and digest_alg Continuing the deprecation of saml2 frontend sign_alg and digest_alg configuration options (see, 04850eeb395b3f19c70507aa1e03201ff787b6a3). The values of the new options should be preferred when set. Otherwise, we fall back to the deprecate options. Notice that the new configuration options expect the algothim identifier, not an internal symbol. Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/saml2.py | 61 +++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 1af83e2fb..72cbb84f2 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -362,18 +362,21 @@ def _handle_authn_response(self, context, internal_response, idp): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) - policies = self.idp_config.get( - 'service', {}).get('idp', {}).get('policy', {}) + idp_conf = self.idp_config.get('service', {}).get('idp', {}) + policies = idp_conf.get('policy', {}) sp_policy = policies.get('default', {}) sp_policy.update(policies.get(sp_entity_id, {})) sign_assertion = sp_policy.get('sign_assertion', False) sign_response = sp_policy.get('sign_response', True) - sign_alg = sp_policy.get('sign_alg', 'SIG_RSA_SHA256') - digest_alg = sp_policy.get('digest_alg', 'DIGEST_SHA256') encrypt_assertion = sp_policy.get('encrypt_assertion', False) encrypted_advice_attributes = sp_policy.get('encrypted_advice_attributes', False) + signing_algorithm = idp_conf.get('signing_algorithm') + digest_algorithm = idp_conf.get('digest_algorithm') + sign_alg_attr = sp_policy.get('sign_alg', 'SIG_RSA_SHA256') + digest_alg_attr = sp_policy.get('digest_alg', 'DIGEST_SHA256') + # Construct arguments for method create_authn_response # on IdP Server instance args = { @@ -389,31 +392,35 @@ def _handle_authn_response(self, context, internal_response, idp): 'encrypted_advice_attributes': encrypted_advice_attributes, } - try: - args['sign_alg'] = getattr(xmldsig, sign_alg) - except AttributeError as e: - msg = "Unsupported sign algorithm {}".format(sign_alg) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline) - raise Exception(msg) from e - else: - msg = "signing with algorithm {}".format(args['sign_alg']) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) + args['sign_alg'] = signing_algorithm + if not args['sign_alg']: + try: + args['sign_alg'] = getattr(xmldsig, sign_alg_attr) + except AttributeError as e: + msg = "Unsupported sign algorithm {}".format(sign_alg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise Exception(msg) from e + + msg = "signing with algorithm {}".format(args['sign_alg']) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) - try: - args['digest_alg'] = getattr(xmldsig, digest_alg) - except AttributeError as e: - msg = "Unsupported digest algorithm {}".format(digest_alg) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline) - raise Exception(msg) from e - else: - msg = "using digest algorithm {}".format(args['digest_alg']) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) + args['digest_alg'] = digest_algorithm + if not args['digest_alg']: + try: + args['digest_alg'] = getattr(xmldsig, digest_alg_attr) + except AttributeError as e: + msg = "Unsupported digest algorithm {}".format(digest_alg) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise Exception(msg) from e + + msg = "using digest algorithm {}".format(args['digest_alg']) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) - if 'sign_alg' in args or 'digest_alg' in args: + if sign_alg_attr or digest_alg_attr: msg = ( "sign_alg and digest_alg are deprecated; " "instead, use signing_algorithm and digest_algorithm " From 21bdada3509c7d53db131a5f8944c563934ba290 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 20 Jan 2021 15:54:10 +0200 Subject: [PATCH 204/401] Release version 7.0.2 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 21 ++++++++++++++++++++- setup.py | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 489e6c1c5..80d0aba0f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 7.0.1 +current_version = 7.0.2 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index c813a6ede..f7aaa3613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 7.0.2 (2021-01-20) - Security release for pySAML2 dependency + +- Add RegexSubProcessor attribute processor +- Fix SAMLVirtualCoFrontend metadata generation +- frontends: Deprecate the sign_alg and digest_alg configuration options on the + saml2 frontend. Instead, use the signing_algorithm and digest_algorithm + configuration options under the service/idp configuration path (not under + service/idp/policy/default) +- backends: New backend to login with Apple ID +- dependencies: Set minimum pysaml2 version to v6.5.0 to make sure we get a + version patched for CVE-2021-21238 and CVE-2021-21239 +- build: Fix the CI base image +- tests: Fix entity-category checks +- docs: Document the sub_hash_salt configuration for the OIDC frontend +- examples: Add entityid_endpoint to the saml backend and frontend + configuration +- examples: Fix the SAMLVirtualCoFrontend example configuration + + ## 7.0.1 (2020-06-09) - build: fix the CI release process @@ -50,7 +69,7 @@ - build: tag docker image by commit, branch, PR number, version and "latest" -## 6.1.0 (2020-02-28) +## 6.1.0 (2020-02-28) - Security release for pySAML2 dependency - Set the SameSite cookie attribute to "None" - Add compatibility support for the SameSite attribute for incompatible diff --git a/setup.py b/setup.py index 3bfe6d94d..27a62a064 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='7.0.1', + version='7.0.2', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', @@ -16,7 +16,7 @@ package_dir={'': 'src'}, install_requires=[ "pyop >= 3.0.1", - "pysaml2 >= 5.0.0", + "pysaml2 >= 6.5.0", "pycryptodomex", "requests", "PyYAML", From 473bf9523606fb75433390a3f0bd8dd04bac0a59 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 21 Jan 2021 01:50:05 +0200 Subject: [PATCH 205/401] Release version 7.0.3 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 6 ++++++ setup.py | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 80d0aba0f..ebebf4aed 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 7.0.2 +current_version = 7.0.3 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index f7aaa3613..812e5a303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 7.0.3 (2021-01-21) + +- dependencies: Set minimum pysaml2 version to v6.5.1 to fix internal XML + parser issues around the xs and xsd namespace prefixes declarations + + ## 7.0.2 (2021-01-20) - Security release for pySAML2 dependency - Add RegexSubProcessor attribute processor diff --git a/setup.py b/setup.py index 27a62a064..ff12945e0 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='7.0.2', + version='7.0.3', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', @@ -16,7 +16,7 @@ package_dir={'': 'src'}, install_requires=[ "pyop >= 3.0.1", - "pysaml2 >= 6.5.0", + "pysaml2 >= 6.5.1", "pycryptodomex", "requests", "PyYAML", From ce3249dea725d40d5e0916b344cdde53ab6d53dc Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Mon, 1 Feb 2021 15:08:29 +0100 Subject: [PATCH 206/401] Make the ScopeExtractorProcessor usable for the Primary Identifier This patch adds support to use the ScopeExtractorProcessor on the Primary Identifiert which is, in contrast to the other values, a string. Closes #348 --- .../micro_services/processors/scope_extractor_processor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/satosa/micro_services/processors/scope_extractor_processor.py b/src/satosa/micro_services/processors/scope_extractor_processor.py index 48e8bda6c..863bc7740 100644 --- a/src/satosa/micro_services/processors/scope_extractor_processor.py +++ b/src/satosa/micro_services/processors/scope_extractor_processor.py @@ -31,6 +31,8 @@ def process(self, internal_data, attribute, **kwargs): values = attributes.get(attribute, []) if not values: raise AttributeProcessorWarning("Cannot apply scope_extractor to {}, it has no values".format(attribute)) + if not isinstance(values, list): + values = [values] if not any('@' in val for val in values): raise AttributeProcessorWarning("Cannot apply scope_extractor to {}, it's values are not scoped".format(attribute)) for value in values: From 04f9f9adae925d050b8ba1fe8f892fce235849ff Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Tue, 2 Feb 2021 10:47:25 +0100 Subject: [PATCH 207/401] Fix Attibure Generation Microservice * Fix broken indentation in the file. Now is is mostly pep8 compatible. * Add missing return in `MustachAttrValue.values` * Remove `None` from mustache rendered strings Co-authored-by: Ivan Kanakarakis --- .../micro_services/attribute_generation.py | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index 485491554..a51c3851d 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -4,51 +4,52 @@ from .base import ResponseMicroService from ..util import get_dict_defaults + class MustachAttrValue(object): def __init__(self, attr_name, values): - self._attr_name = attr_name - self._values = values - if any(['@' in v for v in values]): - local_parts = [] - domain_parts = [] - scopes = dict() - for v in values: - (local_part, sep, domain_part) = v.partition('@') - # probably not needed now... - local_parts.append(local_part) - domain_parts.append(domain_part) - scopes[domain_part] = True - self._scopes = list(scopes.keys()) - else: - self._scopes = None + self._attr_name = attr_name + self._values = values + if any(['@' in v for v in values]): + local_parts = [] + domain_parts = [] + scopes = dict() + for v in values: + (local_part, sep, domain_part) = v.partition('@') + # probably not needed now... + local_parts.append(local_part) + domain_parts.append(domain_part) + scopes[domain_part] = True + self._scopes = list(scopes.keys()) + else: + self._scopes = None def __str__(self): return ";".join(self._values) @property def values(self): - [{self._attr_name: v} for v in self._values] - - @property + return [{self._attr_name: v} for v in self._values] + + @property def value(self): if len(self._values) == 1: - return self._values[0] + return self._values[0] else: - return self._values + return self._values @property def first(self): if len(self._values) > 0: - return self._values[0] + return self._values[0] else: - return "" + return "" @property def scope(self): if self._scopes is not None: - return self._scopes[0] + return self._scopes[0] return "" - + class AddSyntheticAttributes(ResponseMicroService): """ @@ -124,13 +125,18 @@ def __init__(self, config, *args, **kwargs): def _synthesize(self, attributes, requester, provider): syn_attributes = dict() context = dict() - - for attr_name,values in attributes.items(): - context[attr_name] = MustachAttrValue(attr_name, values) + + for attr_name, values in attributes.items(): + context[attr_name] = MustachAttrValue(attr_name, values) recipes = get_dict_defaults(self.synthetic_attributes, requester, provider) for attr_name, fmt in recipes.items(): - syn_attributes[attr_name] = [v.strip().strip(';') for v in re.split("[;\n]+", pystache.render(fmt, context))] + syn_attributes[attr_name] = [ + value + for token in re.split("[;\n]+", pystache.render(fmt, context)) + for value in [token.strip().strip(';')] + if value + ] return syn_attributes def process(self, context, data): From 8a63fdb290d69ab257ec4172b9ea7e142d7f5469 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Tue, 2 Feb 2021 16:02:15 +0100 Subject: [PATCH 208/401] New Microservice Attribute Policy This patch introduces a new micro_service, which is able to force attribute policies for requester by limiting results to a predefined set of allowed attributes. --- doc/README.md | 12 ++++ src/satosa/micro_services/attribute_policy.py | 35 +++++++++++ .../micro_services/test_attribute_policy.py | 58 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/satosa/micro_services/attribute_policy.py create mode 100644 tests/satosa/micro_services/test_attribute_policy.py diff --git a/doc/README.md b/doc/README.md index 157b747b0..5994aacea 100644 --- a/doc/README.md +++ b/doc/README.md @@ -566,6 +566,18 @@ the string `"foo:bar"`: "attr1": "foo:bar" ``` +#### Apply a Attribute Policy + +Attributes delivered from the target provider can be filtered based on a list of allowed attributes per requester +using the `AttributePolicy` class: +```yaml +attribute_policy: + : + allowed: + - attr1 + - attr2 +``` + #### Route to a specific backend based on the requester To choose which backend (essentially choosing target provider) to use based on the requester, use the `DecideBackendByRequester` class which implements that special routing behavior. See the diff --git a/src/satosa/micro_services/attribute_policy.py b/src/satosa/micro_services/attribute_policy.py new file mode 100644 index 000000000..81151d0e4 --- /dev/null +++ b/src/satosa/micro_services/attribute_policy.py @@ -0,0 +1,35 @@ +import logging + +import satosa.logging_util as lu + +from .base import ResponseMicroService + +logger = logging.getLogger(__name__) + + +class AttributePolicy(ResponseMicroService): + """ + Module to filter Attributes by a given Policy. + """ + + def __init__(self, config, *args, **kwargs): + super().__init__(*args, **kwargs) + self.attribute_policy = config["attribute_policy"] + + def process(self, context, data): + state = context.state + session_id = lu.get_session_id(state) + + msg = "Incoming data.attributes {}".format(data.attributes) + logline = lu.LOG_FMT.format(id=session_id, message=msg) + logger.debug(logline) + + policy = self.attribute_policy.get(data.requester, {}) + if "allowed" in policy: + for key in (data.attributes.keys() - set(policy["allowed"])): + del data.attributes[key] + + msg = "Returning data.attributes {}".format(data.attributes) + logline = lu.LOG_FMT.format(id=session_id, message=msg) + logger.debug(logline) + return super().process(context, data) diff --git a/tests/satosa/micro_services/test_attribute_policy.py b/tests/satosa/micro_services/test_attribute_policy.py new file mode 100644 index 000000000..f68483025 --- /dev/null +++ b/tests/satosa/micro_services/test_attribute_policy.py @@ -0,0 +1,58 @@ +from satosa.context import Context +from satosa.internal import AuthenticationInformation, InternalData +from satosa.micro_services.attribute_policy import AttributePolicy + + +class TestAttributePolicy: + def create_attribute_policy_service(self, attribute_policies): + attribute_policy_service = AttributePolicy( + config=attribute_policies, + name="test_attribute_policy", + base_url="https://satosa.example.com" + ) + attribute_policy_service.next = lambda ctx, data: data + return attribute_policy_service + + def test_attribute_policy(self): + requester = "requester" + attribute_policies = { + "attribute_policy": { + "requester_everything_allowed": {}, + "requester_nothing_allowed": { + "allowed": {} + }, + "requester_subset_allowed": { + "allowed": { + "attr1", + "attr2", + }, + }, + }, + } + attributes = { + "attr1": ["foo"], + "attr2": ["foo", "bar"], + "attr3": ["foo"] + } + results = { + "requester_everything_allowed": attributes.keys(), + "requester_nothing_allowed": set(), + "requester_subset_allowed": {"attr1", "attr2"}, + } + for requester, result in results.items(): + attribute_policy_service = self.create_attribute_policy_service( + attribute_policies) + + ctx = Context() + ctx.state = dict() + + resp = InternalData(auth_info=AuthenticationInformation()) + resp.requester = requester + resp.attributes = { + "attr1": ["foo"], + "attr2": ["foo", "bar"], + "attr3": ["foo"] + } + + filtered = attribute_policy_service.process(ctx, resp) + assert(filtered.attributes.keys() == result) From 7661bda09af3e443b0bbab523010acfcaadb6e58 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Tue, 2 Feb 2021 16:38:17 +0100 Subject: [PATCH 209/401] Make the ID token lifetime configurable This patch adds a new configuration option to the pyop Provider making it possible to configure the lifetime of the ID token. --- doc/README.md | 1 + .../plugins/frontends/openid_connect_frontend.yaml.example | 5 +++-- src/satosa/frontends/openid_connect.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/README.md b/doc/README.md index 157b747b0..15864e3d7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -442,6 +442,7 @@ The configuration parameters available: * `access_token_lifetime`: how long access tokens should be valid, see [default](https://github.com/SUNET/pyop#token-lifetimes) * `refresh_token_lifetime`: how long refresh tokens should be valid, if not specified no refresh tokens will be issued (which is [default](https://github.com/SUNET/pyop#token-lifetimes)) * `refresh_token_threshold`: how long before expiration refresh tokens should be refreshed, if not specified refresh tokens will never be refreshed (which is [default](https://github.com/SUNET/pyop#token-lifetimes)) + * `id_token_lifetime`: the lifetime of the ID token in seconds, see [default](https://github.com/SUNET/pyop#token-lifetimes) The other parameters should be left with their default values. diff --git a/example/plugins/frontends/openid_connect_frontend.yaml.example b/example/plugins/frontends/openid_connect_frontend.yaml.example index 1006302e2..1b39cf718 100644 --- a/example/plugins/frontends/openid_connect_frontend.yaml.example +++ b/example/plugins/frontends/openid_connect_frontend.yaml.example @@ -12,5 +12,6 @@ config: scopes_supported: ["openid", "email"] extra_scopes: foo_scope: - - bar_claim - - baz_claim + - bar_claim + - baz_claim + id_token_lifetime: 600 diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 1e0d20793..172822933 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -93,6 +93,7 @@ def _create_provider(self, endpoint_baseurl): cdb, Userinfo(self.user_db), extra_scopes=extra_scopes, + id_token_lifetime=self.config["provider"].get("id_token_lifetime", 600), ) def _init_authorization_state(self): From 84c02347010ebf2b9551a4f0ed0375ec25e81c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 8 Mar 2021 12:03:32 +0100 Subject: [PATCH 210/401] feat: add support for the Scoping element and RequesterID in SAML2 backend --- example/plugins/backends/saml2_backend.yaml.example | 1 + src/satosa/backends/saml2.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index d5ec7cb56..07b81eb14 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -7,6 +7,7 @@ config: mirror_force_authn: no memorize_idp: no use_memorized_idp_when_force_authn: no + send_requester_id: no sp_config: key_file: backend.key diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index d855080a2..dec50af0e 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -9,6 +9,7 @@ from base64 import urlsafe_b64encode from urllib.parse import urlparse +from saml2 import samlp from saml2 import BINDING_HTTP_REDIRECT from saml2.client_base import Base from saml2.config import SPConfig @@ -79,6 +80,7 @@ class SAMLBackend(BackendModule, SAMLBaseModule): KEY_SAML_DISCOVERY_SERVICE_URL = 'saml_discovery_service_url' KEY_SAML_DISCOVERY_SERVICE_POLICY = 'saml_discovery_service_policy' KEY_SP_CONFIG = 'sp_config' + KEY_SEND_REQUESTER_ID = 'send_requester_id' 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' @@ -263,6 +265,10 @@ def authn_request(self, context, entity_id): kwargs["force_authn"] = get_force_authn( context, self.config, self.sp.config ) + if self.config.get(SAMLBackend.KEY_SEND_REQUESTER_ID): + kwargs["scoping"] = samlp.Scoping(requester_id=[samlp.RequesterID()]) + requesterID = context.state.state_dict['SATOSA_BASE']['requester'] + kwargs["scoping"].requester_id[0].text = requesterID try: binding, destination = self.sp.pick_binding( From 9874cb211a1ddf9273baed39c77621499fae75ea Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 8 Mar 2021 13:31:52 +0200 Subject: [PATCH 211/401] Minor refactor Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index dec50af0e..2640fb9db 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -9,18 +9,20 @@ from base64 import urlsafe_b64encode from urllib.parse import urlparse -from saml2 import samlp from saml2 import BINDING_HTTP_REDIRECT from saml2.client_base import Base from saml2.config import SPConfig from saml2.extension.mdui import NAMESPACE as UI_NAMESPACE from saml2.metadata import create_metadata_string from saml2.authn_context import requested_authn_context +from saml2.samlp import RequesterID +from saml2.samlp import Scoping import satosa.logging_util as lu import satosa.util as util from satosa.base import SAMLBaseModule from satosa.base import SAMLEIDASBaseModule +from satosa.base import STATE_KEY as STATE_KEY_BASE from satosa.context import Context from satosa.internal import AuthenticationInformation from satosa.internal import InternalData @@ -266,9 +268,8 @@ def authn_request(self, context, entity_id): context, self.config, self.sp.config ) if self.config.get(SAMLBackend.KEY_SEND_REQUESTER_ID): - kwargs["scoping"] = samlp.Scoping(requester_id=[samlp.RequesterID()]) - requesterID = context.state.state_dict['SATOSA_BASE']['requester'] - kwargs["scoping"].requester_id[0].text = requesterID + requester = context.state.state_dict[STATE_KEY_BASE]['requester'] + kwargs["scoping"] = Scoping(requester_id=[RequesterID(text=requester)]) try: binding, destination = self.sp.pick_binding( From 8cc547e40c5f4edf9622608f83501e9df6c0d001 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Mon, 8 Mar 2021 13:33:28 +0100 Subject: [PATCH 212/401] Apply suggestions from code review Co-authored-by: Ivan Kanakarakis --- doc/README.md | 3 +-- example/plugins/frontends/openid_connect_frontend.yaml.example | 2 +- src/satosa/frontends/openid_connect.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/doc/README.md b/doc/README.md index 15864e3d7..a8ccdeb57 100644 --- a/doc/README.md +++ b/doc/README.md @@ -442,7 +442,7 @@ The configuration parameters available: * `access_token_lifetime`: how long access tokens should be valid, see [default](https://github.com/SUNET/pyop#token-lifetimes) * `refresh_token_lifetime`: how long refresh tokens should be valid, if not specified no refresh tokens will be issued (which is [default](https://github.com/SUNET/pyop#token-lifetimes)) * `refresh_token_threshold`: how long before expiration refresh tokens should be refreshed, if not specified refresh tokens will never be refreshed (which is [default](https://github.com/SUNET/pyop#token-lifetimes)) - * `id_token_lifetime`: the lifetime of the ID token in seconds, see [default](https://github.com/SUNET/pyop#token-lifetimes) + * `id_token_lifetime`: the lifetime of the ID token in seconds - the default is set to 1hr (3600 seconds) (see [default](https://github.com/SUNET/pyop#token-lifetimes)) The other parameters should be left with their default values. @@ -693,4 +693,3 @@ set SATOSA_CONFIG=/home/user/proxy_conf.yaml See the [auxiliary documentation for running using mod\_wsgi](mod_wsgi.md). - diff --git a/example/plugins/frontends/openid_connect_frontend.yaml.example b/example/plugins/frontends/openid_connect_frontend.yaml.example index 1b39cf718..05b47e803 100644 --- a/example/plugins/frontends/openid_connect_frontend.yaml.example +++ b/example/plugins/frontends/openid_connect_frontend.yaml.example @@ -14,4 +14,4 @@ config: foo_scope: - bar_claim - baz_claim - id_token_lifetime: 600 + id_token_lifetime: 3600 diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 172822933..0f96c331e 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -93,7 +93,7 @@ def _create_provider(self, endpoint_baseurl): cdb, Userinfo(self.user_db), extra_scopes=extra_scopes, - id_token_lifetime=self.config["provider"].get("id_token_lifetime", 600), + id_token_lifetime=self.config["provider"].get("id_token_lifetime", 3600), ) def _init_authorization_state(self): From c678a875ab44775f60140da03f444bfe6a0bd80c Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Tue, 2 Feb 2021 17:29:17 +0100 Subject: [PATCH 213/401] New Option to prefer cdb from file over cdb from MongoDB This patch adds the option to set the `client_db_uri` additional to the `db_uri`. Order for the client database is `client_db_uri`, `client_db_path`, in-memory. --- doc/README.md | 2 ++ src/satosa/frontends/openid_connect.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/README.md b/doc/README.md index 157b747b0..b6c68d8c0 100644 --- a/doc/README.md +++ b/doc/README.md @@ -430,6 +430,8 @@ The configuration parameters available: * `signing_key_path`: path to a RSA Private Key file (PKCS#1). MUST be configured. * `db_uri`: connection URI to MongoDB instance where the data will be persisted, if it's not specified all data will only be stored in-memory (not suitable for production use). +* `client_db_uri`: connection URI to MongoDB instance where the client data will be persistent, if it's not specified the clients list will be received from the `client_db_path`. +* `client_db_path`: path to a file containing the client database in json format. It will only be used if `client_db_uri` is not set. If `client_db_uri` and `client_db_path` are not set, clients will only be stored in-memory (not suitable for production use). * `sub_hash_salt`: salt which is hashed into the `sub` claim. If it's not specified, SATOSA will generate a random salt on each startup, which means that users will get new `sub` value after every restart. * `provider`: provider configuration information. MUST be configured, the following configuration are supported: * `response_types_supported` (default: `[id_token]`): list of all supported response types, see [Section 3 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#Authentication). diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 1e0d20793..7172a0abc 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -77,9 +77,10 @@ def _create_provider(self, endpoint_baseurl): authz_state = self._init_authorization_state() db_uri = self.config.get("db_uri") + client_db_uri = self.config.get("client_db_uri") cdb_file = self.config.get("client_db_path") - if db_uri: - cdb = MongoWrapper(db_uri, "satosa", "clients") + if client_db_uri: + cdb = MongoWrapper(client_db_uri, "satosa", "clients") elif cdb_file: with open(cdb_file) as f: cdb = json.loads(f.read()) From 47f3b1a3ce86644886f3b0ded28783341dc1f471 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Thu, 11 Mar 2021 10:39:02 +0100 Subject: [PATCH 214/401] Add the Requester ID to the Consent Call This patch adds the requester to the consent call. This way we are able to show e.g. requester specific AGBs. --- src/satosa/micro_services/consent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/satosa/micro_services/consent.py b/src/satosa/micro_services/consent.py index afad940e2..3823826da 100644 --- a/src/satosa/micro_services/consent.py +++ b/src/satosa/micro_services/consent.py @@ -91,6 +91,7 @@ def _approve_new_consent(self, context, internal_response, id_hash): "attr": internal_response.attributes, "id": id_hash, "redirect_endpoint": "%s/consent%s" % (self.base_url, self.endpoint), + "requester": internal_response.requester, "requester_name": internal_response.requester_name, } if self.locked_attr: From 2d6a8ab645f4808989e6024bf7c22bef74ee8f6e Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Fri, 12 Mar 2021 10:50:19 +0100 Subject: [PATCH 215/401] Option to add Extra Claims to ID Token Some OIDC clients expect extra claims in the ID Token without explicitly asking for them using the `claims` url parameter. This patch adds an option to define in the config per client which extra claims should be added to the ID Token to also work with those clients. Co-authored-by: --- .../openid_connect_frontend.yaml.example | 4 +++ src/satosa/frontends/openid_connect.py | 18 ++++++++-- tests/satosa/frontends/test_openid_connect.py | 36 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/example/plugins/frontends/openid_connect_frontend.yaml.example b/example/plugins/frontends/openid_connect_frontend.yaml.example index 05b47e803..bc941bd1c 100644 --- a/example/plugins/frontends/openid_connect_frontend.yaml.example +++ b/example/plugins/frontends/openid_connect_frontend.yaml.example @@ -15,3 +15,7 @@ config: - bar_claim - baz_claim id_token_lifetime: 3600 + extra_id_token_claims: + foo_client: + - bar_claim + - baz_claim diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 0f96c331e..dac68ed14 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -118,7 +118,15 @@ def _init_authorization_state(self): return AuthorizationState(HashBasedSubjectIdentifierFactory(sub_hash_salt), authz_code_db, access_token_db, refresh_token_db, sub_db, **token_lifetimes) - def handle_authn_response(self, context, internal_resp, extra_id_token_claims=None): + def _get_extra_id_token_claims(self, user_id, client_id): + if "extra_id_token_claims" in self.config["provider"]: + config = self.config["provider"]["extra_id_token_claims"].get(client_id, []) + if type(config) is list and len(config) > 0: + requested_claims = {k: None for k in config} + return self.provider.userinfo.get_claims_for(user_id, requested_claims) + return {} + + def handle_authn_response(self, context, internal_resp): """ See super class method satosa.frontends.base.FrontendModule#handle_authn_response :type context: satosa.context.Context @@ -133,7 +141,8 @@ def handle_authn_response(self, context, internal_resp, extra_id_token_claims=No auth_resp = self.provider.authorize( auth_req, internal_resp.subject_id, - extra_id_token_claims=extra_id_token_claims, + extra_id_token_claims=lambda user_id, client_id: + self._get_extra_id_token_claims(user_id, client_id), ) del context.state[self.name] @@ -360,7 +369,10 @@ def token_endpoint(self, context): """ headers = {"Authorization": context.request_authorization} try: - response = self.provider.handle_token_request(urlencode(context.request), headers) + response = self.provider.handle_token_request( + urlencode(context.request), + headers, + lambda user_id, client_id: self._get_extra_id_token_claims(user_id, client_id)) return Response(response.to_json(), content="application/json") except InvalidClientAuthentication as e: logline = "invalid client authentication at token endpoint" diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index b33a16703..cb322e680 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -71,6 +71,21 @@ def frontend_config_with_extra_scopes(self, signing_key_path): return config + @pytest.fixture + def frontend_config_with_extra_id_token_claims(self, signing_key_path): + config = { + "signing_key_path": signing_key_path, + "provider": { + "response_types_supported": ["code", "id_token", "code id_token token"], + "scopes_supported": ["openid", "email"], + "extra_id_token_claims": { + CLIENT_ID: ["email"], + } + }, + } + + return config + def create_frontend(self, frontend_config): # will use in-memory storage instance = OpenIDConnectFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, @@ -409,6 +424,27 @@ def test_token_endpoint(self, context, frontend_config, authn_req): assert parsed["expires_in"] == token_lifetime assert parsed["id_token"] + def test_token_endpoint_with_extra_claims(self, context, frontend_config_with_extra_id_token_claims, authn_req): + frontend = self.create_frontend(frontend_config_with_extra_id_token_claims) + + user_id = "test_user" + self.insert_client_in_client_db(frontend, authn_req["redirect_uri"]) + self.insert_user_in_user_db(frontend, user_id) + authn_req["response_type"] = "code" + authn_resp = frontend.provider.authorize(authn_req, user_id) + + context.request = AccessTokenRequest(redirect_uri=authn_req["redirect_uri"], code=authn_resp["code"]).to_dict() + credentials = "{}:{}".format(CLIENT_ID, CLIENT_SECRET) + basic_auth = urlsafe_b64encode(credentials.encode("utf-8")).decode("utf-8") + context.request_authorization = "Basic {}".format(basic_auth) + + response = frontend.token_endpoint(context) + parsed = AccessTokenResponse().deserialize(response.message, "json") + assert parsed["access_token"] + + id_token = IdToken().from_jwt(parsed["id_token"], key=[frontend.signing_key]) + assert id_token["email"] == "test@example.com" + def test_token_endpoint_issues_refresh_tokens_if_configured(self, context, frontend_config, authn_req): frontend_config["provider"]["refresh_token_lifetime"] = 60 * 60 * 24 * 365 frontend = OpenIDConnectFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, From 87604bdfe76fbd3485e3718b4229b4582e9dd135 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Fri, 12 Mar 2021 10:55:40 +0100 Subject: [PATCH 216/401] Add flake8 Config in Tox This patch adds a config section for flake8 to tox and also add an ignore for long lines, since it seems like these errors are already actively ignored. Some IDEs (tested only vscode) will pick this up and make working a lot easier and clearer. --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index 4d69d943e..134af7e1f 100644 --- a/tox.ini +++ b/tox.ini @@ -17,3 +17,6 @@ commands = pip --version pip freeze pytest -vvv -ra {posargs:tests/} + +[flake8] +ignore = E501 From 01c51ae99cdf36a6350d98ee8c549e6e4a6bdb7a Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Fri, 12 Mar 2021 11:00:59 +0100 Subject: [PATCH 217/401] Remove some deprecated mongod flags To make the tests work for me with MongoDB v4.4.4, I had to remove some deprecated flags from the mongod. --- tests/conftest.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ef09cd753..bc04eb2b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -396,10 +396,9 @@ def __init__(self): self._process = subprocess.Popen(['mongod', '--bind_ip', 'localhost', '--port', str(self._port), '--dbpath', self._tmpdir, - '--nojournal', '--nohttpinterface', - '--noauth', '--smallfiles', - '--syncdelay', '0', - '--nssize', '1', ], + '--nojournal', + '--noauth', + '--syncdelay', '0'], stdout=open('/tmp/mongo-temp.log', 'wb'), stderr=subprocess.STDOUT) From ae7d3ea9e0d9785fd960749de9b649886f725413 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Mon, 15 Mar 2021 10:03:02 +0100 Subject: [PATCH 218/401] Handly empty Attributes in Attibute Generation `MustachAttrValue` in `attibute_generation.py` can not handly empty attributes (`None`), since it expect a list, and crashed hard. This patch makes sure that `MustachAttrValue` always receives a list as values. Tests included. --- .../micro_services/attribute_generation.py | 5 ++++- .../micro_services/test_attribute_generation.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index a51c3851d..7c99a8fa7 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -127,7 +127,10 @@ def _synthesize(self, attributes, requester, provider): context = dict() for attr_name, values in attributes.items(): - context[attr_name] = MustachAttrValue(attr_name, values) + context[attr_name] = MustachAttrValue( + attr_name, + values if values is not None else [] + ) recipes = get_dict_defaults(self.synthetic_attributes, requester, provider) for attr_name, fmt in recipes.items(): diff --git a/tests/satosa/micro_services/test_attribute_generation.py b/tests/satosa/micro_services/test_attribute_generation.py index be4fd9ab9..e60ab36fc 100644 --- a/tests/satosa/micro_services/test_attribute_generation.py +++ b/tests/satosa/micro_services/test_attribute_generation.py @@ -63,3 +63,20 @@ def test_generate_mustache2(self): assert("kaka1" in resp.attributes['kaka']) assert("a@example.com" in resp.attributes['eppn']) assert("b@example.com" in resp.attributes['eppn']) + + def test_generate_mustache_empty_attribute(self): + synthetic_attributes = { + "": {"default": {"a0": "{{kaka.first}}#{{eppn.scope}}"}} + } + authz_service = self.create_syn_service(synthetic_attributes) + resp = InternalData(auth_info=AuthenticationInformation()) + resp.attributes = { + "kaka": ["kaka1", "kaka2"], + "eppn": None, + } + ctx = Context() + ctx.state = dict() + authz_service.process(ctx, resp) + assert("kaka1#" in resp.attributes['a0']) + assert("kaka1" in resp.attributes['kaka']) + assert("kaka2" in resp.attributes['kaka']) From 6a4b992eed39c80135c2f40937904f61e599a3a4 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Mon, 15 Mar 2021 14:35:21 +0100 Subject: [PATCH 219/401] Set Primary Identifer only if it exists This patch ensures that the primary identifier is only set, if it is present and not `None`. --- src/satosa/micro_services/primary_identifier.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 8b41b65c5..43b25bde6 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -250,13 +250,14 @@ def process(self, context, data): logger.debug(logline) data.attributes = {} - # Set the primary identifier attribute to the value found. - data.attributes[primary_identifier] = primary_identifier_val - msg = "{} Setting attribute {} to value {}".format( - logprefix, primary_identifier, primary_identifier_val - ) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) + if primary_identifier: + # Set the primary identifier attribute to the value found. + data.attributes[primary_identifier] = primary_identifier_val + msg = "{} Setting attribute {} to value {}".format( + logprefix, primary_identifier, primary_identifier_val + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) msg = "{} returning data.attributes {}".format(logprefix, str(data.attributes)) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) From dd9afbc93e90c818480e0c5cd7fbfbb5bc1bc8ac Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Mon, 15 Mar 2021 16:52:47 +0100 Subject: [PATCH 220/401] Filter empty Claims This patch adds a filter to the claims to remove empty lists or `None` values. They are not needed and they break hard during the claim parsing. --- src/satosa/frontends/openid_connect.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 0f96c331e..9ee267581 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -129,6 +129,8 @@ def handle_authn_response(self, context, internal_resp, extra_id_token_claims=No auth_req = self._get_authn_request_from_state(context.state) claims = self.converter.from_internal("openid", internal_resp.attributes) + # Filter unset claims + claims = {k: v for k, v in claims.items() if v} self.user_db[internal_resp.subject_id] = dict(combine_claim_values(claims.items())) auth_resp = self.provider.authorize( auth_req, From 7f43c15492467b5b41063611645dba5b6518708a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 15 Mar 2021 21:01:19 +0200 Subject: [PATCH 221/401] Fix tests by setting client_db_uri Signed-off-by: Ivan Kanakarakis --- tests/flows/test_oidc-saml.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index e5888fb8f..aa61c151f 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -32,6 +32,7 @@ def oidc_frontend_config(signing_key_path, mongodb_instance): "issuer": "https://proxy-op.example.com", "signing_key_path": signing_key_path, "provider": {"response_types_supported": ["id_token"]}, + "client_db_uri": mongodb_instance.get_uri(), # use mongodb for integration testing "db_uri": mongodb_instance.get_uri() # use mongodb for integration testing } } From ba86be233cd5dc8bcf0b8e3db7da99709e955b6d Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 6 Apr 2021 15:06:36 +0300 Subject: [PATCH 222/401] Fix references to undefined variables Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/saml2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 72cbb84f2..c165e1027 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -397,7 +397,7 @@ def _handle_authn_response(self, context, internal_response, idp): try: args['sign_alg'] = getattr(xmldsig, sign_alg_attr) except AttributeError as e: - msg = "Unsupported sign algorithm {}".format(sign_alg) + msg = "Unsupported sign algorithm {}".format(sign_alg_attr) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) raise Exception(msg) from e @@ -411,7 +411,7 @@ def _handle_authn_response(self, context, internal_response, idp): try: args['digest_alg'] = getattr(xmldsig, digest_alg_attr) except AttributeError as e: - msg = "Unsupported digest algorithm {}".format(digest_alg) + msg = "Unsupported digest algorithm {}".format(digest_alg_attr) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) raise Exception(msg) from e From 755c05314b1128071c083aa6c2f5c65847a0e438 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Sun, 11 Apr 2021 18:10:29 +0200 Subject: [PATCH 223/401] feat: added some useful http headers to context for future Micro Services --- src/satosa/proxy_server.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index a3c336145..1b41dabc7 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -65,6 +65,13 @@ def unpack_request(environ, content_length=0): return data +def unpack_http_headers(environ): + headers = ('REQUEST_METHOD', 'PATH_INFO', 'REQUEST_URI', + 'QUERY_STRING', 'SERVER_NAME', 'REMOTE_ADDR', + 'HTTP_HOST', 'HTTP_USER_AGENT', 'HTTP_ACCEPT_LANGUAGE') + return {k:v for k,v in environ.items() if k in headers} + + class ToBytesMiddleware(object): """Converts a message to bytes to be sent by WSGI server.""" @@ -109,6 +116,7 @@ def __call__(self, environ, start_response, debug=False): body = io.BytesIO(environ['wsgi.input'].read(content_length)) environ['wsgi.input'] = body context.request = unpack_request(environ, content_length) + context._http_headers = unpack_http_headers(environ) environ['wsgi.input'].seek(0) context.cookie = environ.get("HTTP_COOKIE", "") From fab68383ead9f3710cd54ce003867075e956e812 Mon Sep 17 00:00:00 2001 From: Thijs Kinkhorst Date: Tue, 4 May 2021 12:06:11 +0200 Subject: [PATCH 224/401] Update some URLs in the README --- doc/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/README.md b/doc/README.md index cb053975e..18aae0516 100644 --- a/doc/README.md +++ b/doc/README.md @@ -17,7 +17,7 @@ apt-get install libffi-dev libssl-dev xmlsec1 ```` ### Instructions -1. Download the SATOSA proxy project as a [compressed archive](https://github.com/SUNET/SATOSA/releases) +1. Download the SATOSA proxy project as a [compressed archive](https://github.com/IdentityPython/SATOSA/releases) and unpack it to ``. 1. Install the application: @@ -440,11 +440,11 @@ The configuration parameters available: * `client_registration_supported` (default: `No`): boolean whether [dynamic client registration is supported](https://openid.net/specs/openid-connect-registration-1_0.html). If dynamic client registration is not supported all clients must exist in the MongoDB instance configured by the `db_uri` in the `"clients"` collection of the `"satosa"` database. The registration info must be stored using the client id as a key, and use the parameter names of a [OIDC Registration Response](https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse). - * `authorization_code_lifetime`: how long authorization codes should be valid, see [default](https://github.com/SUNET/pyop#token-lifetimes) - * `access_token_lifetime`: how long access tokens should be valid, see [default](https://github.com/SUNET/pyop#token-lifetimes) - * `refresh_token_lifetime`: how long refresh tokens should be valid, if not specified no refresh tokens will be issued (which is [default](https://github.com/SUNET/pyop#token-lifetimes)) - * `refresh_token_threshold`: how long before expiration refresh tokens should be refreshed, if not specified refresh tokens will never be refreshed (which is [default](https://github.com/SUNET/pyop#token-lifetimes)) - * `id_token_lifetime`: the lifetime of the ID token in seconds - the default is set to 1hr (3600 seconds) (see [default](https://github.com/SUNET/pyop#token-lifetimes)) + * `authorization_code_lifetime`: how long authorization codes should be valid, see [default](https://github.com/IdentityPython/pyop#token-lifetimes) + * `access_token_lifetime`: how long access tokens should be valid, see [default](https://github.com/IdentityPython/pyop#token-lifetimes) + * `refresh_token_lifetime`: how long refresh tokens should be valid, if not specified no refresh tokens will be issued (which is [default](https://github.com/IdentityPython/pyop#token-lifetimes)) + * `refresh_token_threshold`: how long before expiration refresh tokens should be refreshed, if not specified refresh tokens will never be refreshed (which is [default](https://github.com/IdentityPython/pyop#token-lifetimes)) + * `id_token_lifetime`: the lifetime of the ID token in seconds - the default is set to 1hr (3600 seconds) (see [default](https://github.com/IdentityPython/pyop#token-lifetimes)) The other parameters should be left with their default values. From df4603e1ee3368cc6bdcbd1fc54bd1258bb9cc6a Mon Sep 17 00:00:00 2001 From: Thijs Kinkhorst Date: Fri, 7 May 2021 15:29:11 +0200 Subject: [PATCH 225/401] Fix table of contents from top-level README. Add some anchors to be able to fix links on top level. --- README.md | 9 +++++---- doc/README.md | 16 +++++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index daefcd7e3..b790e0024 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,16 @@ OpenID Connect and OAuth2. - [Plugins](doc/README.md#plugins) - [SAML2 plugins](doc/README.md#saml_plugin) - [Metadata](doc/README.md#metadata) - - [Frontend](doc/README.md#frontend) - - [Backend](doc/README.md#backend) + - [Frontend](doc/README.md#saml_frontend) + - [Backend](doc/README.md#saml_backend) - [Name ID Format](doc/README.md#name_id) - [OpenID Connect plugins](doc/README.md#openid_plugin) - - [Backend](doc/README.md#backend) + - [Backend](doc/README.md#openid_backend) + - [Frontend](doc/README.md#openid_frontend) - [Social login plugins](doc/README.md#social_plugins) - [Google](doc/README.md#google) - [Facebook](doc/README.md#facebook) -- [SAML metadata](doc/README.md#saml_metadata) +- [Generating proxy metadata](doc/README.md#saml_proxy_metadata) - [Running the proxy application](doc/README.md#run) diff --git a/doc/README.md b/doc/README.md index 18aae0516..572d995a7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -181,6 +181,8 @@ Common configuration parameters: | `entityid_endpoint` | bool | `true` | whether `entityid` should be used as a URL that serves the metadata xml document | `acr_mapping` | dict | `None` | custom Authentication Context Class Reference +#### Metadata + The metadata could be loaded in multiple ways in the table above it's loaded from a static file by using the key "local". It's also possible to load read the metadata from a remote URL. @@ -203,7 +205,7 @@ see the [documentation of the underlying library pysaml2](https://github.com/rohe/pysaml2/blob/master/docs/howto/config.rst). -##### Providing `AuthnContextClassRef` +#### Providing `AuthnContextClassRef` SAML2 frontends and backends can provide a custom (configurable) *Authentication Context Class Reference*. For the frontend this is defined in the `AuthnStatement` of the authentication response, while, @@ -231,7 +233,7 @@ provider will be preserved, and when using a OAuth or OpenID Connect backend, th "https://accounts.google.com": LoA1 -#### Frontend +#### Frontend The SAML2 frontend act as a SAML Identity Provider (IdP), accepting authentication requests from SAML Service Providers (SP). The default @@ -299,7 +301,7 @@ config: exclude: ["givenName"] ``` -#### Policy +##### Policy Some settings related to how a SAML response is formed can be overriden on a per-instance or a per-SP basis. This example summarizes the most common settings (hopefully self-explanatory) with their defaults: @@ -322,7 +324,7 @@ in the yaml structure. The most specific key takes presedence. If no policy over the defaults above are used. -#### Backend +#### Backend The SAML2 backend act as a SAML Service Provider (SP), making authentication requests to SAML Identity Providers (IdP). The default configuration file can be found [here](../example/plugins/backends/saml2_backend.yaml.example). @@ -404,7 +406,7 @@ config: ### OpenID Connect plugins -#### Backend +#### Backend The OpenID Connect backend acts as an OpenID Connect Relying Party (RP), making authentication requests to OpenID Connect Provider (OP). The default configuration file can be found [here](../example/plugins/backends/openid_backend.yaml.example). @@ -417,7 +419,7 @@ and make sure to provide the redirect URI, constructed as described in the section about Google configuration below, in the static registration. -#### Frontend +#### Frontend The OpenID Connect frontend acts as and OpenID Connect Provider (OP), accepting requests from OpenID Connect Relying Parties (RPs). The default configuration file can be found [here](../example/plugins/frontends/openid_connect_frontend.yaml.example). @@ -663,7 +665,7 @@ methods: * Request micro services must inherit `satosa.micro_services.base.RequestMicroService`. * Request micro services must inherit `satosa.micro_services.base.ResponseMicroService`. -# Generate proxy metadata +# Generate proxy metadata The proxy metadata is generated based on the front-/backend plugins listed in `proxy_conf.yaml` using the `satosa-saml-metadata` (installed globally by SATOSA installation). From d787736baa37b8e55a1c2d2b3225ea8c286e0c7b Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Tue, 18 May 2021 14:47:11 +1200 Subject: [PATCH 226/401] fix: doc/README.md: fix typo (Request=>Response) --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index cb053975e..8846981f9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -661,7 +661,7 @@ methods: * Frontends must inherit `satosa.frontends.base.FrontendModule`. * Backends must inherit `satosa.backends.base.BackendModule`. * Request micro services must inherit `satosa.micro_services.base.RequestMicroService`. -* Request micro services must inherit `satosa.micro_services.base.ResponseMicroService`. +* Response micro services must inherit `satosa.micro_services.base.ResponseMicroService`. # Generate proxy metadata From 4f26f91d834757c179fc76d5fa12a4b183278d31 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Tue, 18 May 2021 14:47:51 +1200 Subject: [PATCH 227/401] fix: example/ldap_attribute_store: fix YAML syntax Quote where needed, add missing : --- .../plugins/microservices/ldap_attribute_store.yaml.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index a83873a9b..05dfa7355 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -7,7 +7,7 @@ config: # the authenticating IdP, or the entityID of the CO virtual IdP. # The key "default" specifies the default configuration default: - ldap_url: ldaps://ldap.example.org + ldap_url: "ldaps://ldap.example.org" bind_dn: cn=admin,dc=example,dc=org # Obtain bind password from environment variable LDAP_BIND_PASSWORD. bind_password: !ENV LDAP_BIND_PASSWORD @@ -114,7 +114,7 @@ config: user_id_from_attrs: - uid - https://federation-proxy.my.edu/satosa/idp/proxy/some_co + https://federation-proxy.my.edu/satosa/idp/proxy/some_co: search_base: ou=People,o=some_co,dc=example,dc=org # The microservice may be configured to ignore a particular entityID. From 4288ce29c5192898195ed8e86b53390e2cbcd3cd Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Tue, 18 May 2021 15:00:54 +1200 Subject: [PATCH 228/401] fix: example: fix module name in example files --- example/plugins/microservices/ldap_attribute_store.yaml.example | 2 +- example/plugins/microservices/primary_identifier.yaml.example | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 05dfa7355..4efe85072 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -1,4 +1,4 @@ -module: LdapAttributeStore +module: satosa.micro_services.ldap_attribute_store.LdapAttributeStore name: LdapAttributeStore config: diff --git a/example/plugins/microservices/primary_identifier.yaml.example b/example/plugins/microservices/primary_identifier.yaml.example index dbc13dbf7..806b72f87 100644 --- a/example/plugins/microservices/primary_identifier.yaml.example +++ b/example/plugins/microservices/primary_identifier.yaml.example @@ -1,4 +1,4 @@ -module: PrimaryIdentifier +module: satosa.micro_services.primary_identifier.PrimaryIdentifier name: PrimaryIdentifier config: # The ordered identifier candidates are searched in order From cba6cd087178281c6eb42432cea11146f3292662 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Tue, 18 May 2021 15:01:51 +1200 Subject: [PATCH 229/401] fix: PrimaryIdentifier: exclude name_id from attribute value search Attribute 'name_id' gets special handling in other parts of the code - and the way attribute values were fetched, name_id would always throw a None value in, which would later cause this candidate to be rejected with Candidate is missing value so skipping This makes name_id work with PrimaryIdentifier --- src/satosa/micro_services/primary_identifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 43b25bde6..1c41878a8 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -54,7 +54,7 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): # Get the values asserted by the IdP for the configured list of attribute names for this candidate # and substitute None if the IdP did not assert any value for a configured attribute. - values = [ attributes.get(attribute_name, [None])[0] for attribute_name in candidate['attribute_names'] ] + values = [ attributes.get(attribute_name, [None])[0] for attribute_name in candidate['attribute_names'] if attribute_name != 'name_id' ] msg = "{} Found candidate values {}".format(logprefix, values) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) From 4d3020e205862cb0caf517548dd24947ae2610cb Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Tue, 18 May 2021 15:21:57 +1200 Subject: [PATCH 230/401] new: PrimaryIdentifier: add replace_subject_id option Allow replacing subject_id with the constructed primary identifier (off by default) This would normally be accomplished with user_id_from_attrs in internal_attributes.yaml - but as _auth_resp_callback_func in satosa/base.py processes user_id_from_attrs BEFORE calling ResponseMicroServices, the PrimaryIdentifier MicroService comes in too late. This allows letting OpenIDConnect front-end use a stable identifier produced by the PrimaryIdentifier MicroService. --- .../microservices/primary_identifier.yaml.example | 2 ++ src/satosa/micro_services/primary_identifier.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/example/plugins/microservices/primary_identifier.yaml.example b/example/plugins/microservices/primary_identifier.yaml.example index 806b72f87..0406f578e 100644 --- a/example/plugins/microservices/primary_identifier.yaml.example +++ b/example/plugins/microservices/primary_identifier.yaml.example @@ -34,6 +34,8 @@ config: # Whether or not to clear the input attributes after setting the # primary identifier value. clear_input_attributes: no + # Whether to replace subject_id with the constructed primary identifier + replace_subject_id: no # If defined redirect to this page if no primary identifier can # be found. on_error: https://my.org/errors/no_primary_identifier diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 1c41878a8..adf6fe4cf 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -191,6 +191,12 @@ def process(self, context, data): clear_input_attributes = self.config['clear_input_attributes'] else: clear_input_attributes = False + if 'replace_subject_id' in config: + replace_subject_id = config['replace_subject_id'] + elif 'clear_input_attributes' in self.config: + replace_subject_id = self.config['replace_subject_id'] + else: + replace_subject_id = False if 'ignore' in config: ignore = True else: @@ -259,6 +265,15 @@ def process(self, context, data): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) + # Replace subject_id with the constructed primary identifier if so configured. + if replace_subject_id: + msg = "{} Setting subject_id to value {}".format( + logprefix, primary_identifier_val + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + data.subject_id = primary_identifier_val + msg = "{} returning data.attributes {}".format(logprefix, str(data.attributes)) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) From 8ae46960a6c85b313a6d6988b8a81a4a5a00115a Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Tue, 18 May 2021 15:27:07 +1200 Subject: [PATCH 231/401] fix: PrimaryIdentifier: fix clear_input_attributes The clear_input_attributes functionality in the PrimaryIdentifier MicroService wasn't working - the original code: if clear_input_attributes: msg = "{} Clearing values for these input attributes: {}".format( logprefix, data.attribute_names ) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) data.attributes = {} * would break on the use of data.attribute_names (KeyError) * would clear ALL attributes I assume the intended functionality was to clear only the attributes used to construct the values - so `candidate['attribute_names']` for the successful `candidate`. To have access to the candidate object, this code needs to be moved into `constructPrimaryIdentifier` - and in order to have access to the `clear_input_attributes` option value, this is added as an optional parameter to `constructPrimaryIdentifier`. And, as `'name_id'` would not be found among the attributes, exclude it from the list of attributes to clear. --- .../micro_services/primary_identifier.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index adf6fe4cf..db0460510 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -31,7 +31,7 @@ def __init__(self, config, *args, **kwargs): super().__init__(*args, **kwargs) self.config = config - def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): + def constructPrimaryIdentifier(self, data, ordered_identifier_candidates, clear_input_attributes=False): """ Construct and return a primary identifier value from the data asserted by the IdP using the ordered list of candidates @@ -120,6 +120,18 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): # Concatenate all values to create the primary identifier. value = ''.join(values) + + # Clear input attributes if so configured. + if clear_input_attributes: + attributes_to_clear = [attribute_name for attribute_name in candidate['attribute_names'] if attribute_name != 'name_id'] + msg = "{} Clearing values for these input attributes: {}".format( + logprefix, attributes_to_clear + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + for attribute in attributes_to_clear: + del data.attributes[attribute] + break return value @@ -225,7 +237,7 @@ def process(self, context, data): msg = "{} Constructing primary identifier".format(logprefix) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) - primary_identifier_val = self.constructPrimaryIdentifier(data, ordered_identifier_candidates) + primary_identifier_val = self.constructPrimaryIdentifier(data, ordered_identifier_candidates, clear_input_attributes) if not primary_identifier_val: msg = "{} No primary identifier found".format(logprefix) @@ -247,15 +259,6 @@ def process(self, context, data): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.info(logline) - # Clear input attributes if so configured. - if clear_input_attributes: - msg = "{} Clearing values for these input attributes: {}".format( - logprefix, data.attribute_names - ) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) - data.attributes = {} - if primary_identifier: # Set the primary identifier attribute to the value found. data.attributes[primary_identifier] = primary_identifier_val From 7a61afb5a2cca071b29d35341f3ec67fc598949b Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 26 May 2021 13:52:10 +1200 Subject: [PATCH 232/401] Update src/satosa/micro_services/primary_identifier.py Fix copy-editing typo Co-authored-by: Ivan Kanakarakis --- src/satosa/micro_services/primary_identifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index db0460510..262bd0d86 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -205,7 +205,7 @@ def process(self, context, data): clear_input_attributes = False if 'replace_subject_id' in config: replace_subject_id = config['replace_subject_id'] - elif 'clear_input_attributes' in self.config: + elif 'replace_subject_id' in self.config: replace_subject_id = self.config['replace_subject_id'] else: replace_subject_id = False From a0b48595048a7dfdbffc46635e3f509754d07928 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 26 May 2021 14:36:55 +1200 Subject: [PATCH 233/401] Revert "fix: PrimaryIdentifier: fix clear_input_attributes" This reverts commit 8ae46960a6c85b313a6d6988b8a81a4a5a00115a. Original intention was to remove all attribute values, as per discussion in #368 --- .../micro_services/primary_identifier.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 262bd0d86..b22e3bebb 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -31,7 +31,7 @@ def __init__(self, config, *args, **kwargs): super().__init__(*args, **kwargs) self.config = config - def constructPrimaryIdentifier(self, data, ordered_identifier_candidates, clear_input_attributes=False): + def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): """ Construct and return a primary identifier value from the data asserted by the IdP using the ordered list of candidates @@ -120,18 +120,6 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates, clear_ # Concatenate all values to create the primary identifier. value = ''.join(values) - - # Clear input attributes if so configured. - if clear_input_attributes: - attributes_to_clear = [attribute_name for attribute_name in candidate['attribute_names'] if attribute_name != 'name_id'] - msg = "{} Clearing values for these input attributes: {}".format( - logprefix, attributes_to_clear - ) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) - for attribute in attributes_to_clear: - del data.attributes[attribute] - break return value @@ -237,7 +225,7 @@ def process(self, context, data): msg = "{} Constructing primary identifier".format(logprefix) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) - primary_identifier_val = self.constructPrimaryIdentifier(data, ordered_identifier_candidates, clear_input_attributes) + primary_identifier_val = self.constructPrimaryIdentifier(data, ordered_identifier_candidates) if not primary_identifier_val: msg = "{} No primary identifier found".format(logprefix) @@ -259,6 +247,15 @@ def process(self, context, data): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.info(logline) + # Clear input attributes if so configured. + if clear_input_attributes: + msg = "{} Clearing values for these input attributes: {}".format( + logprefix, data.attribute_names + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + data.attributes = {} + if primary_identifier: # Set the primary identifier attribute to the value found. data.attributes[primary_identifier] = primary_identifier_val From bd98c3d870ff2a9e60c451e882f60328c9b97930 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 26 May 2021 14:51:13 +1200 Subject: [PATCH 234/401] fix: PrimaryIdentifier: fix clear_input_attributes The clear_input_attributes functionality in the PrimaryIdentifier MicroService breaks with: AttributeError: '' object has no attribute 'attribute_names' on line logging the attributes being cleared: msg = "{} Clearing values for these input attributes: {}".format( logprefix, data.attribute_names ) As the actual attribute store (dict) being cleared is `data.attributes`, the best fix appears to be to log the keys in the dict - attribute names: logprefix, data.attributes.keys() --- src/satosa/micro_services/primary_identifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index b22e3bebb..9c892570d 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -250,7 +250,7 @@ def process(self, context, data): # Clear input attributes if so configured. if clear_input_attributes: msg = "{} Clearing values for these input attributes: {}".format( - logprefix, data.attribute_names + logprefix, data.attributes.keys() ) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) From dbe9dd6b7d5a39bcd2a90354a79fdd44d358271c Mon Sep 17 00:00:00 2001 From: Thijs Kinkhorst Date: Mon, 28 Jun 2021 22:19:14 +0200 Subject: [PATCH 235/401] Add isMemberOf to basic attribute map --- docker/attributemaps/basic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/attributemaps/basic.py b/docker/attributemaps/basic.py index c05b6e98b..9d84b8236 100644 --- a/docker/attributemaps/basic.py +++ b/docker/attributemaps/basic.py @@ -84,6 +84,7 @@ DEF+'info': 'info', DEF+'initials': 'initials', DEF+'internationaliSDNNumber': 'internationaliSDNNumber', + DEF+'isMemberOf': 'isMemberOf', DEF+'janetMailbox': 'janetMailbox', DEF+'jpegPhoto': 'jpegPhoto', DEF+'knowledgeInformation': 'knowledgeInformation', From 84cf60fd38de06a85d6f9d39ef364a3d12571120 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 29 Jun 2021 15:50:48 +0300 Subject: [PATCH 236/401] Fix tests for new Werkzeug version Signed-off-by: Ivan Kanakarakis --- tests/flows/test_oidc-saml.py | 2 +- tests/flows/test_saml-saml.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index aa61c151f..90bbe1a31 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -92,7 +92,7 @@ def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_ # make auth resp to proxy authn_resp_req = urlparse(url).path + "?" + urlencode(authn_resp) - authn_resp = test_client.get("/" + authn_resp_req) + authn_resp = test_client.get(authn_resp_req) assert authn_resp.status == "303 See Other" # verify auth resp from proxy diff --git a/tests/flows/test_saml-saml.py b/tests/flows/test_saml-saml.py index ce6cd6960..efa1a8729 100644 --- a/tests/flows/test_saml-saml.py +++ b/tests/flows/test_saml-saml.py @@ -54,7 +54,7 @@ def run_test(self, satosa_config_dict, sp_conf, idp_conf, saml_backend_config, f # make auth resp to proxy authn_resp_req = urlparse(url).path + "?" + urlencode(authn_resp) - authn_resp = test_client.get("/" + authn_resp_req) + authn_resp = test_client.get(authn_resp_req) assert authn_resp.status == "303 See Other" # verify auth resp from proxy From ea951ce852f07a82f370e26178c9e613d0a67210 Mon Sep 17 00:00:00 2001 From: Martin van Es Date: Mon, 12 Jul 2021 21:01:33 +0200 Subject: [PATCH 237/401] Add reflector back-end, for easy front-end development. --- .../backends/reflector_backend.yaml.example | 3 + src/satosa/backends/reflector.py | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 example/plugins/backends/reflector_backend.yaml.example create mode 100644 src/satosa/backends/reflector.py diff --git a/example/plugins/backends/reflector_backend.yaml.example b/example/plugins/backends/reflector_backend.yaml.example new file mode 100644 index 000000000..185a08035 --- /dev/null +++ b/example/plugins/backends/reflector_backend.yaml.example @@ -0,0 +1,3 @@ +module: satosa.backends.reflector.ReflectorBackend +name: Reflector +config: diff --git a/src/satosa/backends/reflector.py b/src/satosa/backends/reflector.py new file mode 100644 index 000000000..843cc58eb --- /dev/null +++ b/src/satosa/backends/reflector.py @@ -0,0 +1,80 @@ +""" +A reflector backend module for the satosa proxy +""" +import logging + +from satosa.internal import AuthenticationInformation +from satosa.internal import InternalData +from satosa.metadata_creation.description import MetadataDescription +from satosa.backends.base import BackendModule + +import time + +logger = logging.getLogger(__name__) + + +class ReflectorBackend(BackendModule): + """ + A reflector backend module + """ + + 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) + + 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 + """ + + timestamp = int(time.time()) + auth_info = AuthenticationInformation( + 'reflector', timestamp, 'reflector', + ) + + internal_resp = InternalData( + auth_info=auth_info, + attributes={}, + subject_type=None, + subject_id='reflector', + ) + + return self.auth_callback_func(context, internal_resp) + + 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 = [] + 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 = [] + description = MetadataDescription(urlsafe_b64encode('reflector'.encode("utf-8")).decode("utf-8")) + description.organization = 'reflector' + + entity_descriptions.append(description) + return entity_descriptions From cdef0974e3ec5d015fedb0b36c42a7b739cc48d6 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 01:59:49 +0300 Subject: [PATCH 238/401] Fix timestamp calculation Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/reflector.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/satosa/backends/reflector.py b/src/satosa/backends/reflector.py index 843cc58eb..3d77c556b 100644 --- a/src/satosa/backends/reflector.py +++ b/src/satosa/backends/reflector.py @@ -1,17 +1,13 @@ """ A reflector backend module for the satosa proxy """ -import logging +from datetime import datetime from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.metadata_creation.description import MetadataDescription from satosa.backends.base import BackendModule -import time - -logger = logging.getLogger(__name__) - class ReflectorBackend(BackendModule): """ @@ -45,7 +41,7 @@ def start_auth(self, context, internal_req): :rtype: satosa.response.Response """ - timestamp = int(time.time()) + timestamp = datetime.utcnow().timestamp() auth_info = AuthenticationInformation( 'reflector', timestamp, 'reflector', ) From 3616b4611a100aa50a7f3e66395ed5fa29d6f2ae Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 02:00:13 +0300 Subject: [PATCH 239/401] Format code and use constants Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/reflector.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/satosa/backends/reflector.py b/src/satosa/backends/reflector.py index 3d77c556b..6702dc733 100644 --- a/src/satosa/backends/reflector.py +++ b/src/satosa/backends/reflector.py @@ -14,6 +14,8 @@ class ReflectorBackend(BackendModule): A reflector backend module """ + ENTITY_ID = ORG_NAME = AUTH_CLASS_REF = SUBJECT_ID = "reflector" + def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: @@ -43,14 +45,16 @@ def start_auth(self, context, internal_req): timestamp = datetime.utcnow().timestamp() auth_info = AuthenticationInformation( - 'reflector', timestamp, 'reflector', + auth_class_ref=ReflectorBackend.AUTH_CLASS_REF, + timestamp=timestamp, + issuer=ReflectorBackend.ENTITY_ID, ) internal_resp = InternalData( auth_info=auth_info, attributes={}, subject_type=None, - subject_id='reflector', + subject_id=ReflectorBackend.SUBJECT_ID, ) return self.auth_callback_func(context, internal_resp) @@ -69,8 +73,12 @@ def get_metadata_desc(self): :rtype: satosa.metadata_creation.description.MetadataDescription """ entity_descriptions = [] - description = MetadataDescription(urlsafe_b64encode('reflector'.encode("utf-8")).decode("utf-8")) - description.organization = 'reflector' + description = MetadataDescription( + urlsafe_b64encode(ReflectorBackend.ENTITY_ID.encode("utf-8")).decode( + "utf-8" + ) + ) + description.organization = ReflectorBackend.ORG_NAME entity_descriptions.append(description) return entity_descriptions From 28c509a37bd4095bd0246d44a8e11733346ed523 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 13:20:24 +0300 Subject: [PATCH 240/401] Bump pyop and add appropriate extras Signed-off-by: Ivan Kanakarakis --- setup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ff12945e0..1f6adcaf9 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ packages=find_packages('src/'), package_dir={'': 'src'}, install_requires=[ - "pyop >= 3.0.1", + "pyop >= 3.2.0", "pysaml2 >= 6.5.1", "pycryptodomex", "requests", @@ -27,7 +27,9 @@ "cookies-samesite-compat", ], extras_require={ - "ldap": ["ldap3"] + "ldap": ["ldap3"], + "pyop_mongo": ["pyop[mongo]"], + "pyop_redis": ["pyop[redis]"], }, zip_safe=False, classifiers=[ From 67eb6071abf6c7002023ad44ddec5d4a3996bd7b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 13:42:52 +0300 Subject: [PATCH 241/401] Replace whitelist_externals with allowlist_externals Signed-off-by: Ivan Kanakarakis --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 134af7e1f..7ab4cc495 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ envlist = [testenv] deps = -rtests/test_requirements.txt -whitelist_externals = +allowlist_externals = tox xmlsec1 commands = From 4c49145c18743778df5bf487573b4f4b2cb3d7e3 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 13:27:16 +0300 Subject: [PATCH 242/401] Add extras for tox Signed-off-by: Ivan Kanakarakis --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 7ab4cc495..4e3896034 100644 --- a/tox.ini +++ b/tox.ini @@ -5,11 +5,13 @@ envlist = pypy3 [testenv] +skip_install = true deps = -rtests/test_requirements.txt allowlist_externals = tox xmlsec1 commands = + pip install -U .[pyop_mongo] xmlsec1 --version python --version pytest --version From f1cf72016f21c86d701aeac68897a5dc5129a651 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 13:52:52 +0300 Subject: [PATCH 243/401] Always recreate the tox environment Signed-off-by: Ivan Kanakarakis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7e45d5d75..f63586ac0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ install: - pip install tox-travis script: - - tox + - tox -r jobs: allow_failures: From 2b746b3fb7538f5aa35b8c288ccf82e75ad4b769 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 13:53:03 +0300 Subject: [PATCH 244/401] Test on py38 and py39 Signed-off-by: Ivan Kanakarakis --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 4e3896034..6534e1bdd 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,8 @@ envlist = py36 py37 + py38 + py39 pypy3 [testenv] From 2eabbf1f144ea709846627fb0107cd7008b6e512 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 13:57:11 +0300 Subject: [PATCH 245/401] Ensure pip wheel and setuptools are up to date Signed-off-by: Ivan Kanakarakis --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 6534e1bdd..e26b9ef89 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ allowlist_externals = tox xmlsec1 commands = + pip install -U pip wheel setuptools pip install -U .[pyop_mongo] xmlsec1 --version python --version From 0c006030caf399409a01fa8e39a6a7890cffd88e Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 10 Jul 2021 22:41:53 +0300 Subject: [PATCH 246/401] Update example configs for the saml2 frontend and backend Signed-off-by: Ivan Kanakarakis --- .../backends/saml2_backend.yaml.example | 9 +++-- .../frontends/saml2_frontend.yaml.example | 34 ++++++++++--------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index 07b81eb14..8aca6b4e5 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -3,6 +3,13 @@ name: Saml2 config: idp_blacklist_file: /path/to/blacklist.json + acr_mapping: + "": default-LoA + "https://accounts.google.com": LoA1 + + # disco_srv must be defined if there is more than one IdP in the metadata specified above + disco_srv: http://disco.example.com + entityid_endpoint: true mirror_force_authn: no memorize_idp: no @@ -59,5 +66,3 @@ config: # include a Format attribute in the NameIDPolicy. # name_id_format: 'None' name_id_format_allow_create: true - # disco_srv must be defined if there is more than one IdP in the metadata specified above - disco_srv: http://disco.example.com diff --git a/example/plugins/frontends/saml2_frontend.yaml.example b/example/plugins/frontends/saml2_frontend.yaml.example index 40c9000f2..c0dffe6f6 100644 --- a/example/plugins/frontends/saml2_frontend.yaml.example +++ b/example/plugins/frontends/saml2_frontend.yaml.example @@ -1,7 +1,25 @@ module: satosa.frontends.saml2.SAMLFrontend name: Saml2IDP config: + #acr_mapping: + # "": default-LoA + # "https://accounts.google.com": LoA1 + + endpoints: + single_sign_on_service: + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST': sso/post + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect': sso/redirect + + # If configured and not false or empty the common domain cookie _saml_idp will be set + # with or have appended the IdP used for authentication. The default is not to set the + # cookie. If the value is a dictionary with key 'domain' then the domain for the cookie + # will be set to the value for the 'domain' key. If no 'domain' is set then the domain + # from the BASE defined for the proxy will be used. + #common_domain_cookie: + # domain: .example.com + entityid_endpoint: true + idp_config: organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'} contact_person: @@ -50,19 +68,3 @@ config: name_form: urn:oasis:names:tc:SAML:2.0:attrname-format:uri encrypt_assertion: false encrypted_advice_attributes: false - acr_mapping: - "": default-LoA - "https://accounts.google.com": LoA1 - - endpoints: - single_sign_on_service: - 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST': sso/post - 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect': sso/redirect - - # If configured and not false or empty the common domain cookie _saml_idp will be set - # with or have appended the IdP used for authentication. The default is not to set the - # cookie. If the value is a dictionary with key 'domain' then the domain for the cookie - # will be set to the value for the 'domain' key. If no 'domain' is set then the domain - # from the BASE defined for the proxy will be used. - #common_domain_cookie: - # domain: .example.com From d4d4767812e7b01d263be19b725d0532e063d167 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Sun, 11 Apr 2021 18:17:53 +0200 Subject: [PATCH 247/401] feat: Add IdPHinting micro-service for basic IdP-hinting support Signed-off-by: Ivan Kanakarakis --- .../microservices/idp_hinting.yaml.example | 6 ++ src/satosa/micro_services/idp_hinting.py | 59 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 example/plugins/microservices/idp_hinting.yaml.example create mode 100644 src/satosa/micro_services/idp_hinting.py diff --git a/example/plugins/microservices/idp_hinting.yaml.example b/example/plugins/microservices/idp_hinting.yaml.example new file mode 100644 index 000000000..9238f3c55 --- /dev/null +++ b/example/plugins/microservices/idp_hinting.yaml.example @@ -0,0 +1,6 @@ +module: satosa.micro_services.idp_hinting.IdpHinting +name: IdpHinting +config: + allowed_params: + - idp_hinting + - idp_hint diff --git a/src/satosa/micro_services/idp_hinting.py b/src/satosa/micro_services/idp_hinting.py new file mode 100644 index 000000000..397852d09 --- /dev/null +++ b/src/satosa/micro_services/idp_hinting.py @@ -0,0 +1,59 @@ +import logging +from urllib.parse import parse_qs + +from .base import RequestMicroService +from ..exception import SATOSAConfigurationError +from ..exception import SATOSAError + + +logger = logging.getLogger(__name__) + + +class IdpHintingError(SATOSAError): + """ + SATOSA exception raised by IdpHinting microservice + """ + pass + + +class IdpHinting(RequestMicroService): + """ + Detect if an idp hinting feature have been requested + """ + + def __init__(self, config, *args, **kwargs): + """ + Constructor. + :param config: microservice configuration + :type config: Dict[str, Dict[str, str]] + """ + super().__init__(*args, **kwargs) + try: + self.idp_hint_param_names = config['allowed_params'] + except KeyError: + raise SATOSAConfigurationError( + f"{self.__class__.__name__} can't find allowed_params" + ) + + def process(self, context, data): + """ + This intercepts if idp_hint paramenter is in use + :param context: request context + :param data: the internal request + """ + target_entity_id = context.get_decoration(context.KEY_TARGET_ENTITYID) + qs_raw = context._http_headers['QUERY_STRING'] + if target_entity_id or not qs_raw: + return super().process(context, data) + + qs = parse_qs(qs_raw) + hints = ( + entity_id + for param in self.idp_hint_param_names + for entity_id in qs.get(param, [None]) + if entity_id + ) + hint = next(hints, None) + + context.decorate(context.KEY_TARGET_ENTITYID, hint) + return super().process(context, data) From dc46b6dbd4f9056caed291289a692d1e3ec658db Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 13 Jul 2021 15:41:44 +0300 Subject: [PATCH 248/401] Add section and pointers to external micro-services Signed-off-by: Ivan Kanakarakis --- README.md | 1 + doc/README.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/README.md b/README.md index b790e0024..044091a86 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ OpenID Connect and OAuth2. - [Manual installation](doc/README.md#manual_installation) - [Dependencies](doc/README.md#dependencies) - [Instructions](doc/README.md#install_instructions) + - [External micro-services](doc/README.md#install_external) - [Configuration](doc/README.md#configuration) - [SATOSA proxy configuration: proxy_conf.yaml.example](doc/README.md#proxy_conf) - [Additional services](doc/README.md#additional_service) diff --git a/doc/README.md b/doc/README.md index 62c5f97e4..b9934ce94 100644 --- a/doc/README.md +++ b/doc/README.md @@ -28,6 +28,32 @@ apt-get install libffi-dev libssl-dev xmlsec1 Alternatively the application can be installed directly from PyPI (`pip install satosa`), or the [Docker image](https://hub.docker.com/r/satosa/) can be used. + +### External micro-services + +Micro-services act like plugins and can be developed by anyone. Other people +that have been working with the SaToSa proxy, have built extentions mainly in +the form of additional micro-services that can be shared and used by anyone. + +DAASI International have been a long-time user of this software and have made +their extentions available, licensed under Apache2.0. You can find the +extensions using the following URL: + +- https://gitlab.daasi.de/didmos2/didmos2-auth/-/tree/master/src/didmos_oidc/satosa/micro_services + +The extentions include: + +- SCIM attribute store to fetch attributes via SCIM API (instead of LDAP) +- Authoritzation module for blocking services if necessary group memberships or + attributes are missing in the identity (for service providers that do not + evaluate attributes themselves) +- Backend chooser with Django UI for letting the user choose between any + existing SATOSA backend +- Integration of MFA via PrivacyIDEA + +and more. + + # Configuration SATOSA is configured using YAML. From d1784a7a24a35113a544ff2695a017e0d2f4ebc2 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Wed, 7 Apr 2021 01:11:54 +0200 Subject: [PATCH 249/401] feat: DecideBackedByTarget microservice --- .../target_based_routing.yaml.example | 12 ++ src/satosa/micro_services/custom_routing.py | 109 ++++++++++++++++++ .../micro_services/test_custom_routing.py | 64 +++++++++- 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 example/plugins/microservices/target_based_routing.yaml.example diff --git a/example/plugins/microservices/target_based_routing.yaml.example b/example/plugins/microservices/target_based_routing.yaml.example new file mode 100644 index 000000000..7b017dba8 --- /dev/null +++ b/example/plugins/microservices/target_based_routing.yaml.example @@ -0,0 +1,12 @@ +module: satosa.micro_services.custom_routing.DecideBackendByTargetIdP +name: TargetRouter +config: + default_backend: Saml2 + + # the regex that will intercept http requests to be handled with this microservice + endpoint_paths: + - ".*/disco" + + target_mapping: + "http://idpspid.testunical.it:8088": "spidSaml2" # map SAML entity with entity id 'target_id' to backend name + "http://eidas.testunical.it:8081/saml2/metadata": "eidasSaml2" diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index d903502be..1eaccea5d 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -2,14 +2,123 @@ from base64 import urlsafe_b64encode from satosa.context import Context +from satosa.internal import InternalData + from .base import RequestMicroService from ..exception import SATOSAConfigurationError from ..exception import SATOSAError +from ..exception import SATOSAStateError logger = logging.getLogger(__name__) +class CustomRoutingError(SATOSAError): + """ + SATOSA exception raised by CustomRouting rules + """ + pass + + +class DecideBackendByTargetIdP(RequestMicroService): + """ + Select which backend should be used based on who is the SAML IDP + """ + + def __init__(self, config:dict, *args, **kwargs): + """ + Constructor. + :param config: microservice configuration loaded from yaml file + :type config: Dict[str, Dict[str, str]] + """ + super().__init__(*args, **kwargs) + self.target_mapping = config['target_mapping'] + self.endpoint_paths = config['endpoint_paths'] + self.default_backend = config['default_backend'] + + if not isinstance(self.endpoint_paths, list): + raise SATOSAConfigurationError() + + def register_endpoints(self): + """ + URL mapping of additional endpoints this micro service needs to register for callbacks. + + Example of a mapping from the url path '/callback' to the callback() method of a micro service: + reg_endp = [ + ("^/callback1$", self.callback), + ] + + :rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]] + + :return: A list with functions and args bound to a specific endpoint url, + [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] + """ + + # this intercepts disco response + return [ + (path , self.backend_by_entityid) + for path in self.endpoint_paths + ] + + def _get_request_entity_id(self, context): + return ( + context.get_decoration(Context.KEY_TARGET_ENTITYID) or + context.request.get('entityID') + ) + + def _get_backend(self, context:Context, entity_id:str) -> str: + """ + returns the Target Backend to use + """ + return ( + self.target_mapping.get(entity_id) or + self.default_backend + ) + + def process(self, context:Context, data:dict): + """ + Will modify the context.target_backend attribute based on the target entityid. + :param context: request context + :param data: the internal request + """ + entity_id = self._get_request_entity_id(context) + if entity_id: + self._rewrite_context(entity_id, context) + return super().process(context, data) + + def _rewrite_context(self, entity_id:str, context:Context) -> None: + tr_backend = self._get_backend(context, entity_id) + context.decorate(Context.KEY_TARGET_ENTITYID, entity_id) + context.target_frontend = context.target_frontend or context.state.get('ROUTER') + native_backend = context.target_backend + msg = (f'Found DecideBackendByTarget ({self.name} microservice) ' + f'redirecting {entity_id} from {native_backend} ' + f'backend to {tr_backend}') + logger.info(msg) + context.target_backend = tr_backend + + def backend_by_entityid(self, context:Context): + entity_id = self._get_request_entity_id(context) + + if entity_id: + self._rewrite_context(entity_id, context) + else: + raise CustomRoutingError( + f"{self.__class__.__name__} " + "can't find any valid entity_id in the context." + ) + + if not context.state.get('ROUTER'): + raise SATOSAStateError( + f"{self.__class__.__name__} " + "can't find any valid state in the context." + ) + + data_serialized = context.state.get(self.name, {}).get("internal", {}) + data = InternalData.from_dict(data_serialized) + return super().process(context, data) + + class DecideBackendByRequester(RequestMicroService): """ Select which backend should be used based on who the requester is. diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index 7a5227250..81425872d 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -3,9 +3,11 @@ import pytest from satosa.context import Context -from satosa.exception import SATOSAError, SATOSAConfigurationError +from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed +from satosa.micro_services.custom_routing import DecideBackendByTargetIdP +from satosa.micro_services.custom_routing import CustomRoutingError TARGET_ENTITY = "entity1" @@ -156,3 +158,63 @@ def test_missing_target_entity_id_from_context(self, context): req = InternalData(requester="test_requester") with pytest.raises(SATOSAError): decide_service.process(context, req) + + +class TestDecideBackendByTargetIdP: + rules = { + 'default_backend': 'Saml2', + 'endpoint_paths': ['.*/disco'], + 'target_mapping': {'http://idpspid.testunical.it:8088': 'spidSaml2'} + } + + def create_decide_service(self, rules): + decide_service = DecideBackendByTargetIdP( + config=rules, + name="test_decide_service", + base_url="https://satosa.example.com" + ) + decide_service.next = lambda ctx, data: data + return decide_service + + + def test_missing_state(self, target_context): + decide_service = self.create_decide_service(self.rules) + target_context.request = { + 'entityID': 'http://idpspid.testunical.it:8088', + } + req = InternalData(requester="test_requester") + req.requester = "somebody else" + assert decide_service.process(target_context, req) + + with pytest.raises(SATOSAStateError): + decide_service.backend_by_entityid(target_context) + + + def test_unmatching_target(self, target_context): + """ + It would rely on the default backend + """ + decide_service = self.create_decide_service(self.rules) + target_context.request = { + 'entityID': 'unknow-entity-id', + } + target_context.state['ROUTER'] = 'Saml2' + + req = InternalData(requester="test_requester") + assert decide_service.process(target_context, req) + + res = decide_service.backend_by_entityid(target_context) + assert isinstance(res, InternalData) + + def test_matching_target(self, target_context): + decide_service = self.create_decide_service(self.rules) + target_context.request = { + 'entityID': 'http://idpspid.testunical.it:8088-entity-id' + } + target_context.state['ROUTER'] = 'Saml2' + + req = InternalData(requester="test_requester") + req.requester = "somebody else" + assert decide_service.process(target_context, req) + res = decide_service.backend_by_entityid(target_context) + assert isinstance(res, InternalData) From e7ad982cfa7318f66f9bb45fe48a104ae4589789 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 14 Jul 2021 01:14:47 +0300 Subject: [PATCH 250/401] Fix DecideBackendByTargetIdP and introduce DecideBackendByDiscoIdP Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/custom_routing.py | 108 ++++++-------- .../micro_services/test_custom_routing.py | 133 +++++++++++------- 2 files changed, 130 insertions(+), 111 deletions(-) diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index 1eaccea5d..a276184c5 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -22,22 +22,55 @@ class CustomRoutingError(SATOSAError): class DecideBackendByTargetIdP(RequestMicroService): """ - Select which backend should be used based on who is the SAML IDP + Select target backend based on the target issuer. """ def __init__(self, config:dict, *args, **kwargs): """ Constructor. + :param config: microservice configuration loaded from yaml file :type config: Dict[str, Dict[str, str]] """ super().__init__(*args, **kwargs) + self.target_mapping = config['target_mapping'] - self.endpoint_paths = config['endpoint_paths'] self.default_backend = config['default_backend'] - if not isinstance(self.endpoint_paths, list): - raise SATOSAConfigurationError() + def process(self, context:Context, data:InternalData): + """ + Set context.target_backend based on the target issuer (context.target_entity_id) + + :param context: request context + :param data: the internal request + """ + target_issuer = context.get_decoration(Context.KEY_TARGET_ENTITYID) + if not target_issuer: + return super().process(context, data) + + target_backend = ( + self.target_mapping.get(target_issuer) + or self.default_backend + ) + + report = { + 'msg': 'decided target backend by target issuer', + 'target_issuer': target_issuer, + 'target_backend': target_backend, + } + logger.info(report) + + context.target_backend = target_backend + return super().process(context, data) + + +class DecideBackendByDiscoIdP(DecideBackendByTargetIdP): + def __init__(self, config:dict, *args, **kwargs): + super().__init__(config, *args, **kwargs) + + self.disco_endpoints = config['disco_endpoints'] + if not isinstance(self.disco_endpoints, list): + raise CustomRoutingError('disco_endpoints must be a list of str') def register_endpoints(self): """ @@ -54,69 +87,20 @@ def register_endpoints(self): [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] """ - # this intercepts disco response return [ - (path , self.backend_by_entityid) - for path in self.endpoint_paths + (path , self._handle_disco_response) + for path in self.disco_endpoints ] - def _get_request_entity_id(self, context): - return ( - context.get_decoration(Context.KEY_TARGET_ENTITYID) or - context.request.get('entityID') - ) - - def _get_backend(self, context:Context, entity_id:str) -> str: - """ - returns the Target Backend to use - """ - return ( - self.target_mapping.get(entity_id) or - self.default_backend - ) - - def process(self, context:Context, data:dict): - """ - Will modify the context.target_backend attribute based on the target entityid. - :param context: request context - :param data: the internal request - """ - entity_id = self._get_request_entity_id(context) - if entity_id: - self._rewrite_context(entity_id, context) - return super().process(context, data) - - def _rewrite_context(self, entity_id:str, context:Context) -> None: - tr_backend = self._get_backend(context, entity_id) - context.decorate(Context.KEY_TARGET_ENTITYID, entity_id) - context.target_frontend = context.target_frontend or context.state.get('ROUTER') - native_backend = context.target_backend - msg = (f'Found DecideBackendByTarget ({self.name} microservice) ' - f'redirecting {entity_id} from {native_backend} ' - f'backend to {tr_backend}') - logger.info(msg) - context.target_backend = tr_backend - - def backend_by_entityid(self, context:Context): - entity_id = self._get_request_entity_id(context) - - if entity_id: - self._rewrite_context(entity_id, context) - else: - raise CustomRoutingError( - f"{self.__class__.__name__} " - "can't find any valid entity_id in the context." - ) - - if not context.state.get('ROUTER'): - raise SATOSAStateError( - f"{self.__class__.__name__} " - "can't find any valid state in the context." - ) + def _handle_disco_response(self, context:Context): + target_issuer_from_disco = context.request.get('entityID') + if not target_issuer_from_disco: + raise CustomRoutingError('no valid entity_id in the disco response') - data_serialized = context.state.get(self.name, {}).get("internal", {}) + context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer_from_disco) + data_serialized = context.state.get(self.name, {}).get('internal', {}) data = InternalData.from_dict(data_serialized) - return super().process(context, data) + return self.process(context, data) class DecideBackendByRequester(RequestMicroService): diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index 81425872d..9cbe4eda4 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -1,14 +1,18 @@ from base64 import urlsafe_b64encode +from unittest import TestCase import pytest from satosa.context import Context +from satosa.state import State from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed +from satosa.micro_services.custom_routing import DecideBackendByDiscoIdP from satosa.micro_services.custom_routing import DecideBackendByTargetIdP from satosa.micro_services.custom_routing import CustomRoutingError + TARGET_ENTITY = "entity1" @@ -160,61 +164,92 @@ def test_missing_target_entity_id_from_context(self, context): decide_service.process(context, req) -class TestDecideBackendByTargetIdP: - rules = { - 'default_backend': 'Saml2', - 'endpoint_paths': ['.*/disco'], - 'target_mapping': {'http://idpspid.testunical.it:8088': 'spidSaml2'} - } - - def create_decide_service(self, rules): - decide_service = DecideBackendByTargetIdP( - config=rules, - name="test_decide_service", - base_url="https://satosa.example.com" - ) - decide_service.next = lambda ctx, data: data - return decide_service +class TestDecideBackendByTargetIdP(TestCase): + def setUp(self): + context = Context() + context.state = State() - - def test_missing_state(self, target_context): - decide_service = self.create_decide_service(self.rules) - target_context.request = { - 'entityID': 'http://idpspid.testunical.it:8088', + config = { + 'default_backend': 'default_backend', + 'target_mapping': { + 'mapped_idp.example.org': 'mapped_backend', + }, + 'disco_endpoints': [ + '.*/disco', + ], } - req = InternalData(requester="test_requester") - req.requester = "somebody else" - assert decide_service.process(target_context, req) - - with pytest.raises(SATOSAStateError): - decide_service.backend_by_entityid(target_context) - - def test_unmatching_target(self, target_context): - """ - It would rely on the default backend - """ - decide_service = self.create_decide_service(self.rules) - target_context.request = { - 'entityID': 'unknow-entity-id', + plugin = DecideBackendByTargetIdP( + config=config, + name='test_decide_service', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) + + self.config = config + self.context = context + self.plugin = plugin + + def test_when_target_is_not_set_do_skip(self): + data = InternalData(requester='test_requester') + newctx, newdata = self.plugin.process(self.context, data) + assert not newctx.target_backend + + def test_when_target_is_not_mapped_choose_default_backend(self): + self.context.decorate(Context.KEY_TARGET_ENTITYID, 'idp.example.org') + data = InternalData(requester='test_requester') + newctx, newdata = self.plugin.process(self.context, data) + assert newctx.target_backend == 'default_backend' + + def test_when_target_is_mapped_choose_mapping_backend(self): + self.context.decorate(Context.KEY_TARGET_ENTITYID, 'mapped_idp.example.org') + data = InternalData(requester='test_requester') + data.requester = 'somebody else' + newctx, newdata = self.plugin.process(self.context, data) + assert newctx.target_backend == 'mapped_backend' + + +class TestDecideBackendByDiscoIdP(TestCase): + def setUp(self): + context = Context() + context.state = State() + + config = { + 'default_backend': 'default_backend', + 'target_mapping': { + 'mapped_idp.example.org': 'mapped_backend', + }, + 'disco_endpoints': [ + '.*/disco', + ], } - target_context.state['ROUTER'] = 'Saml2' - req = InternalData(requester="test_requester") - assert decide_service.process(target_context, req) + plugin = DecideBackendByDiscoIdP( + config=config, + name='test_decide_service', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) - res = decide_service.backend_by_entityid(target_context) - assert isinstance(res, InternalData) + self.config = config + self.context = context + self.plugin = plugin - def test_matching_target(self, target_context): - decide_service = self.create_decide_service(self.rules) - target_context.request = { - 'entityID': 'http://idpspid.testunical.it:8088-entity-id' + def test_when_target_is_not_set_raise_error(self): + self.context.request = {} + with pytest.raises(CustomRoutingError): + self.plugin._handle_disco_response(self.context) + + def test_when_target_is_not_mapped_choose_default_backend(self): + self.context.request = { + 'entityID': 'idp.example.org', } - target_context.state['ROUTER'] = 'Saml2' + newctx, newdata = self.plugin._handle_disco_response(self.context) + assert newctx.target_backend == 'default_backend' - req = InternalData(requester="test_requester") - req.requester = "somebody else" - assert decide_service.process(target_context, req) - res = decide_service.backend_by_entityid(target_context) - assert isinstance(res, InternalData) + def test_when_target_is_mapped_choose_mapping_backend(self): + self.context.request = { + 'entityID': 'mapped_idp.example.org', + } + newctx, newdata = self.plugin._handle_disco_response(self.context) + assert newctx.target_backend == 'mapped_backend' From c0265f26b6b1aa8b0bb83d7f01f1becdf162f129 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 14 Jul 2021 02:03:56 +0300 Subject: [PATCH 251/401] Separate disco handling from backend decision Signed-off-by: Ivan Kanakarakis --- .../disco_to_target_issuer.yaml.example | 6 ++ .../target_based_routing.yaml.example | 6 +- src/satosa/micro_services/custom_routing.py | 53 ++---------------- src/satosa/micro_services/disco.py | 48 ++++++++++++++++ .../micro_services/test_custom_routing.py | 56 +------------------ tests/satosa/micro_services/test_disco.py | 44 +++++++++++++++ 6 files changed, 106 insertions(+), 107 deletions(-) create mode 100644 example/plugins/microservices/disco_to_target_issuer.yaml.example create mode 100644 src/satosa/micro_services/disco.py create mode 100644 tests/satosa/micro_services/test_disco.py diff --git a/example/plugins/microservices/disco_to_target_issuer.yaml.example b/example/plugins/microservices/disco_to_target_issuer.yaml.example new file mode 100644 index 000000000..5d5d0100c --- /dev/null +++ b/example/plugins/microservices/disco_to_target_issuer.yaml.example @@ -0,0 +1,6 @@ +module: satosa.micro_services.disco.DiscoToTargetIssuer +name: DiscoToTargetIssuer +config: + # the regex that will intercept http requests to be handled with this microservice + disco_endpoints: + - ".*/disco" diff --git a/example/plugins/microservices/target_based_routing.yaml.example b/example/plugins/microservices/target_based_routing.yaml.example index 7b017dba8..55e699c53 100644 --- a/example/plugins/microservices/target_based_routing.yaml.example +++ b/example/plugins/microservices/target_based_routing.yaml.example @@ -1,12 +1,8 @@ -module: satosa.micro_services.custom_routing.DecideBackendByTargetIdP +module: satosa.micro_services.custom_routing.DecideBackendByTargetIssuer name: TargetRouter config: default_backend: Saml2 - # the regex that will intercept http requests to be handled with this microservice - endpoint_paths: - - ".*/disco" - target_mapping: "http://idpspid.testunical.it:8088": "spidSaml2" # map SAML entity with entity id 'target_id' to backend name "http://eidas.testunical.it:8081/saml2/metadata": "eidasSaml2" diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index a276184c5..541b824f1 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -7,20 +7,17 @@ from .base import RequestMicroService from ..exception import SATOSAConfigurationError from ..exception import SATOSAError -from ..exception import SATOSAStateError logger = logging.getLogger(__name__) class CustomRoutingError(SATOSAError): - """ - SATOSA exception raised by CustomRouting rules - """ + """SATOSA exception raised by CustomRouting rules""" pass -class DecideBackendByTargetIdP(RequestMicroService): +class DecideBackendByTargetIssuer(RequestMicroService): """ Select target backend based on the target issuer. """ @@ -38,14 +35,11 @@ def __init__(self, config:dict, *args, **kwargs): self.default_backend = config['default_backend'] def process(self, context:Context, data:InternalData): - """ - Set context.target_backend based on the target issuer (context.target_entity_id) + """Set context.target_backend based on the target issuer""" - :param context: request context - :param data: the internal request - """ target_issuer = context.get_decoration(Context.KEY_TARGET_ENTITYID) if not target_issuer: + logger.info('skipping backend decision because no target_issuer was found') return super().process(context, data) target_backend = ( @@ -64,45 +58,6 @@ def process(self, context:Context, data:InternalData): return super().process(context, data) -class DecideBackendByDiscoIdP(DecideBackendByTargetIdP): - def __init__(self, config:dict, *args, **kwargs): - super().__init__(config, *args, **kwargs) - - self.disco_endpoints = config['disco_endpoints'] - if not isinstance(self.disco_endpoints, list): - raise CustomRoutingError('disco_endpoints must be a list of str') - - def register_endpoints(self): - """ - URL mapping of additional endpoints this micro service needs to register for callbacks. - - Example of a mapping from the url path '/callback' to the callback() method of a micro service: - reg_endp = [ - ("^/callback1$", self.callback), - ] - - :rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]] - - :return: A list with functions and args bound to a specific endpoint url, - [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] - """ - - return [ - (path , self._handle_disco_response) - for path in self.disco_endpoints - ] - - def _handle_disco_response(self, context:Context): - target_issuer_from_disco = context.request.get('entityID') - if not target_issuer_from_disco: - raise CustomRoutingError('no valid entity_id in the disco response') - - context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer_from_disco) - data_serialized = context.state.get(self.name, {}).get('internal', {}) - data = InternalData.from_dict(data_serialized) - return self.process(context, data) - - class DecideBackendByRequester(RequestMicroService): """ Select which backend should be used based on who the requester is. diff --git a/src/satosa/micro_services/disco.py b/src/satosa/micro_services/disco.py new file mode 100644 index 000000000..7ea5bbe0a --- /dev/null +++ b/src/satosa/micro_services/disco.py @@ -0,0 +1,48 @@ +from satosa.context import Context +from satosa.internal import InternalData + +from .base import RequestMicroService +from ..exception import SATOSAError + + +class DiscoToTargetIssuerError(SATOSAError): + """SATOSA exception raised by CustomRouting rules""" + + +class DiscoToTargetIssuer(RequestMicroService): + def __init__(self, config:dict, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.disco_endpoints = config['disco_endpoints'] + if not isinstance(self.disco_endpoints, list) or not self.disco_endpoints: + raise DiscoToTargetIssuerError('disco_endpoints must be a list of str') + + def register_endpoints(self): + """ + URL mapping of additional endpoints this micro service needs to register for callbacks. + + Example of a mapping from the url path '/callback' to the callback() method of a micro service: + reg_endp = [ + ('^/callback1$', self.callback), + ] + + :rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]] + + :return: A list with functions and args bound to a specific endpoint url, + [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] + """ + + return [ + (path , self._handle_disco_response) + for path in self.disco_endpoints + ] + + def _handle_disco_response(self, context:Context): + target_issuer = context.request.get('entityID') + if not target_issuer: + raise DiscoToTargetIssuerError('no valid entity_id in the disco response') + + data_serialized = context.state.get(self.name, {}).get('internal_data', {}) + data = InternalData.from_dict(data_serialized) + context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer) + return super().process(context, data) diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index 9cbe4eda4..d2022bc3e 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -8,8 +8,7 @@ from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed -from satosa.micro_services.custom_routing import DecideBackendByDiscoIdP -from satosa.micro_services.custom_routing import DecideBackendByTargetIdP +from satosa.micro_services.custom_routing import DecideBackendByTargetIssuer from satosa.micro_services.custom_routing import CustomRoutingError @@ -164,7 +163,7 @@ def test_missing_target_entity_id_from_context(self, context): decide_service.process(context, req) -class TestDecideBackendByTargetIdP(TestCase): +class TestDecideBackendByTargetIssuer(TestCase): def setUp(self): context = Context() context.state = State() @@ -174,12 +173,9 @@ def setUp(self): 'target_mapping': { 'mapped_idp.example.org': 'mapped_backend', }, - 'disco_endpoints': [ - '.*/disco', - ], } - plugin = DecideBackendByTargetIdP( + plugin = DecideBackendByTargetIssuer( config=config, name='test_decide_service', base_url='https://satosa.example.org', @@ -207,49 +203,3 @@ def test_when_target_is_mapped_choose_mapping_backend(self): data.requester = 'somebody else' newctx, newdata = self.plugin.process(self.context, data) assert newctx.target_backend == 'mapped_backend' - - -class TestDecideBackendByDiscoIdP(TestCase): - def setUp(self): - context = Context() - context.state = State() - - config = { - 'default_backend': 'default_backend', - 'target_mapping': { - 'mapped_idp.example.org': 'mapped_backend', - }, - 'disco_endpoints': [ - '.*/disco', - ], - } - - plugin = DecideBackendByDiscoIdP( - config=config, - name='test_decide_service', - base_url='https://satosa.example.org', - ) - plugin.next = lambda ctx, data: (ctx, data) - - self.config = config - self.context = context - self.plugin = plugin - - def test_when_target_is_not_set_raise_error(self): - self.context.request = {} - with pytest.raises(CustomRoutingError): - self.plugin._handle_disco_response(self.context) - - def test_when_target_is_not_mapped_choose_default_backend(self): - self.context.request = { - 'entityID': 'idp.example.org', - } - newctx, newdata = self.plugin._handle_disco_response(self.context) - assert newctx.target_backend == 'default_backend' - - def test_when_target_is_mapped_choose_mapping_backend(self): - self.context.request = { - 'entityID': 'mapped_idp.example.org', - } - newctx, newdata = self.plugin._handle_disco_response(self.context) - assert newctx.target_backend == 'mapped_backend' diff --git a/tests/satosa/micro_services/test_disco.py b/tests/satosa/micro_services/test_disco.py new file mode 100644 index 000000000..ac2c3c5c2 --- /dev/null +++ b/tests/satosa/micro_services/test_disco.py @@ -0,0 +1,44 @@ +from unittest import TestCase + +import pytest + +from satosa.context import Context +from satosa.state import State +from satosa.micro_services.disco import DiscoToTargetIssuer +from satosa.micro_services.disco import DiscoToTargetIssuerError + + +class TestDiscoToTargetIssuer(TestCase): + def setUp(self): + context = Context() + context.state = State() + + config = { + 'disco_endpoints': [ + '.*/disco', + ], + } + + plugin = DiscoToTargetIssuer( + config=config, + name='test_disco_to_target_issuer', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) + + self.config = config + self.context = context + self.plugin = plugin + + def test_when_entity_id_is_not_set_raise_error(self): + self.context.request = {} + with pytest.raises(DiscoToTargetIssuerError): + self.plugin._handle_disco_response(self.context) + + def test_when_entity_id_is_set_target_issuer_is_set(self): + entity_id = 'idp.example.org' + self.context.request = { + 'entityID': entity_id, + } + newctx, newdata = self.plugin._handle_disco_response(self.context) + assert newctx.get_decoration(Context.KEY_TARGET_ENTITYID) == entity_id From d7e45721d94ecc86501ab7eed46a20b41842501b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 16 Jul 2021 22:04:53 +0300 Subject: [PATCH 252/401] Set target_frontend after handling the disco response When the processing of the request micro-services is finished, the context is switched from the frontend to the backend. At that point target_frontend is needed to set the state of the router. The router state will be used when the processing of the response by the response micro-services is finished, to find the appropriate frontend instance. --- The routing state is set at the point when the switch from the frontend (and request micro-service processing) is made towards the backend. If the discovery response was not intercepted by the DiscoToTargetIssuer micro-service, and instead was processed by the backend's disco-response handler, the target_frontend would not be needed, as the routing state would have already been set. When the DiscoToTargetIssuer micro-service intercepts the response, the point when the switch from the frontend to the backend happens will be executed again. Due to leaving the proxy, going to the discovery service and coming back to the proxy, context.target_frontend has been lost. Only the state stored within context.state persists (through the cookie). --- When the request micro-services finish processing the request, backend_routing is called, which sets the router state (context.state['ROUTER']) to target_frontend, and returns the appropriate backend instance based on target_backend. When the time comes to switch from the backend to the frontend, that state is looked up (see below). When the response micro-services finish processing the response, frontend_routing is called, which sets target_frontend from the router state (context.state['ROUTER']) and returns the appropriate frontend instance based on target_frontend. --- Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/disco.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/satosa/micro_services/disco.py b/src/satosa/micro_services/disco.py index 7ea5bbe0a..274f18780 100644 --- a/src/satosa/micro_services/disco.py +++ b/src/satosa/micro_services/disco.py @@ -17,6 +17,13 @@ def __init__(self, config:dict, *args, **kwargs): if not isinstance(self.disco_endpoints, list) or not self.disco_endpoints: raise DiscoToTargetIssuerError('disco_endpoints must be a list of str') + def process(self, context:Context, data:InternalData): + context.state[self.name] = { + 'target_frontend': context.target_frontend, + 'internal_data': data.to_dict(), + } + return super().process(context, data) + def register_endpoints(self): """ URL mapping of additional endpoints this micro service needs to register for callbacks. @@ -42,7 +49,10 @@ def _handle_disco_response(self, context:Context): if not target_issuer: raise DiscoToTargetIssuerError('no valid entity_id in the disco response') + target_frontend = context.state.get(self.name, {}).get('target_frontend') data_serialized = context.state.get(self.name, {}).get('internal_data', {}) data = InternalData.from_dict(data_serialized) + + context.target_frontend = target_frontend context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer) return super().process(context, data) From b78ab3ee828c4774b83a5bf1a521208c4c2af084 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Fri, 11 Jun 2021 11:51:50 +1200 Subject: [PATCH 253/401] new: SAML2 frontend+backend: support reloading metadata Using the reload_metadata method added into pysaml2 in IdentityPython/pysaml2#809, support reloading metadata when triggered via an externally exposed URL (as `//reload-metadata`) This is off by default (URL not exposed) and needs to be explicitly enabled by setting the newly introduced config option `enable_metadata_reload` for the SAML modules to `true` (or `yes`). The loaded config is already preserved in the modules, so can be easily used to provide a reference copy of the metadata configuration to the `reload_metadata` method. This is implemented separately for the SAML2 Backend and SAML2 Frontend (applying to all three SAML2 Frontend classes). This will complete the missing functionality identified in IdentityPython/pysaml2#808 --- .../backends/saml2_backend.yaml.example | 1 + .../frontends/saml2_frontend.yaml.example | 1 + .../saml2_virtualcofrontend.yaml.example | 2 ++ src/satosa/backends/saml2.py | 15 +++++++++++++++ src/satosa/base.py | 10 ++++++++++ src/satosa/frontends/saml2.py | 19 ++++++++++++++++++- 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index 8aca6b4e5..c132e2345 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -15,6 +15,7 @@ config: memorize_idp: no use_memorized_idp_when_force_authn: no send_requester_id: no + enable_metadata_reload: no sp_config: key_file: backend.key diff --git a/example/plugins/frontends/saml2_frontend.yaml.example b/example/plugins/frontends/saml2_frontend.yaml.example index c0dffe6f6..058c7746e 100644 --- a/example/plugins/frontends/saml2_frontend.yaml.example +++ b/example/plugins/frontends/saml2_frontend.yaml.example @@ -19,6 +19,7 @@ config: # domain: .example.com entityid_endpoint: true + enable_metadata_reload: no idp_config: organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'} diff --git a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example index 111dbf732..6d9a7b370 100644 --- a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example +++ b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example @@ -94,6 +94,8 @@ config: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST': sso/post 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect': sso/redirect + enable_metadata_reload: no + # If configured and not false or empty the common domain cookie _saml_idp will be set # with or have appended the IdP used for authentication. The default is not to set the # cookie. If the value is a dictionary with key 'domain' then the domain for the cookie diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 2640fb9db..9ff5555fa 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -82,6 +82,7 @@ class SAMLBackend(BackendModule, SAMLBaseModule): KEY_SAML_DISCOVERY_SERVICE_URL = 'saml_discovery_service_url' KEY_SAML_DISCOVERY_SERVICE_POLICY = 'saml_discovery_service_policy' KEY_SP_CONFIG = 'sp_config' + KEY_METADATA = 'metadata' KEY_SEND_REQUESTER_ID = 'send_requester_id' KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn' KEY_MEMORIZE_IDP = 'memorize_idp' @@ -479,8 +480,22 @@ def register_endpoints(self): url_map.append(("^{0}".format(parsed_entity_id.path[1:]), self._metadata_endpoint)) + if self.enable_metadata_reload(): + url_map.append( + ("^%s/%s$" % (self.name, "reload-metadata"), self._reload_metadata)) + return url_map + def _reload_metadata(self, context): + """ + Reload SAML metadata + """ + logger.debug("Reloading metadata") + res = self.sp.reload_metadata(copy.deepcopy(self.config[SAMLBackend.KEY_SP_CONFIG][SAMLBackend.KEY_METADATA])) + message = "Metadata reload %s" % ("OK" if res else "failed") + status = "200 OK" if res else "500 FAILED" + return Response(message=message, status=status) + def get_metadata_desc(self): """ See super class satosa.backends.backend_base.BackendModule#get_metadata_desc diff --git a/src/satosa/base.py b/src/satosa/base.py index d458293e1..ab872654a 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -261,6 +261,7 @@ def run(self, context): class SAMLBaseModule(object): KEY_ENTITYID_ENDPOINT = 'entityid_endpoint' + KEY_ENABLE_METADATA_RELOAD = 'enable_metadata_reload' KEY_ATTRIBUTE_PROFILE = 'attribute_profile' KEY_ACR_MAPPING = 'acr_mapping' VALUE_ATTRIBUTE_PROFILE_DEFAULT = 'saml' @@ -276,6 +277,15 @@ def expose_entityid_endpoint(self): value = self.config.get(self.KEY_ENTITYID_ENDPOINT, False) return bool(value) + def enable_metadata_reload(self): + """ + Check whether metadata reload has been enabled in config + + return: bool + """ + value = self.config.get(self.KEY_ENABLE_METADATA_RELOAD, False) + return bool(value) + class SAMLEIDASBaseModule(SAMLBaseModule): VALUE_ATTRIBUTE_PROFILE_DEFAULT = 'eidas' diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index c165e1027..4c1e4f313 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -64,6 +64,7 @@ class SAMLFrontend(FrontendModule, SAMLBaseModule): KEY_CUSTOM_ATTR_RELEASE = 'custom_attribute_release' KEY_ENDPOINTS = 'endpoints' KEY_IDP_CONFIG = 'idp_config' + KEY_METADATA = 'metadata' def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): self._validate_config(config) @@ -113,12 +114,18 @@ def register_endpoints(self, backend_names): :type backend_names: list[str] :rtype: list[(str, ((satosa.context.Context, Any) -> satosa.response.Response, Any))] """ + url_map = [] + + if self.enable_metadata_reload(): + url_map.append( + ("^%s/%s$" % (self.name, "reload-metadata"), self._reload_metadata)) + self.idp_config = self._build_idp_config_endpoints( self.config[self.KEY_IDP_CONFIG], backend_names) # Create the idp idp_config = IdPConfig().load(copy.deepcopy(self.idp_config)) self.idp = Server(config=idp_config) - return self._register_endpoints(backend_names) + return self._register_endpoints(backend_names) + url_map def _create_state_data(self, context, resp_args, relay_state): """ @@ -484,6 +491,16 @@ def _metadata_endpoint(self, context): None).decode("utf-8") return Response(metadata_string, content="text/xml") + def _reload_metadata(self, context): + """ + Reload SAML metadata + """ + logger.debug("Reloading metadata") + res = self.idp.reload_metadata(copy.deepcopy(self.config[SAMLFrontend.KEY_IDP_CONFIG][SAMLFrontend.KEY_METADATA])) + message = "Metadata reload %s" % ("OK" if res else "failed") + status = "200 OK" if res else "500 FAILED" + return Response(message=message, status=status) + def _register_endpoints(self, providers): """ Register methods to endpoints From 206d55d9c951e0c3dc55f4517ae1320059fadead Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 26 Jul 2021 23:59:07 +0300 Subject: [PATCH 254/401] Remove the KEY_METADATA key SAMLBackend and SAMLFrontend KEY_* keys are reflecting top-level configuration options. KEY_METADATA is a configuration of pysaml2 objects and not controlled by the SAMLBackend and SAMLFrontend directly. This creates a nasty hardcoded dependency here. We should revamp the API of pysaml2 and cater for this need. Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 5 +++-- src/satosa/frontends/saml2.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 9ff5555fa..db8f12d50 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -82,7 +82,6 @@ class SAMLBackend(BackendModule, SAMLBaseModule): KEY_SAML_DISCOVERY_SERVICE_URL = 'saml_discovery_service_url' KEY_SAML_DISCOVERY_SERVICE_POLICY = 'saml_discovery_service_policy' KEY_SP_CONFIG = 'sp_config' - KEY_METADATA = 'metadata' KEY_SEND_REQUESTER_ID = 'send_requester_id' KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn' KEY_MEMORIZE_IDP = 'memorize_idp' @@ -491,7 +490,9 @@ def _reload_metadata(self, context): Reload SAML metadata """ logger.debug("Reloading metadata") - res = self.sp.reload_metadata(copy.deepcopy(self.config[SAMLBackend.KEY_SP_CONFIG][SAMLBackend.KEY_METADATA])) + res = self.sp.reload_metadata( + copy.deepcopy(self.config[SAMLBackend.KEY_SP_CONFIG]['metadata']) + ) message = "Metadata reload %s" % ("OK" if res else "failed") status = "200 OK" if res else "500 FAILED" return Response(message=message, status=status) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 4c1e4f313..cfd43af6c 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -64,7 +64,6 @@ class SAMLFrontend(FrontendModule, SAMLBaseModule): KEY_CUSTOM_ATTR_RELEASE = 'custom_attribute_release' KEY_ENDPOINTS = 'endpoints' KEY_IDP_CONFIG = 'idp_config' - KEY_METADATA = 'metadata' def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): self._validate_config(config) @@ -496,7 +495,9 @@ def _reload_metadata(self, context): Reload SAML metadata """ logger.debug("Reloading metadata") - res = self.idp.reload_metadata(copy.deepcopy(self.config[SAMLFrontend.KEY_IDP_CONFIG][SAMLFrontend.KEY_METADATA])) + res = self.idp.reload_metadata( + copy.deepcopy(self.config[SAMLFrontend.KEY_IDP_CONFIG]['metadata']) + ) message = "Metadata reload %s" % ("OK" if res else "failed") status = "200 OK" if res else "500 FAILED" return Response(message=message, status=status) From 7e7241f7bbdccd026bab2cfd508935d11d183903 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 27 Jul 2021 00:08:05 +0300 Subject: [PATCH 255/401] Fix warnings from tests Signed-off-by: Ivan Kanakarakis --- tests/conftest.py | 2 +- tests/flows/test_account_linking.py | 4 ++-- tests/flows/test_consent.py | 4 ++-- tests/flows/test_oidc-saml.py | 4 ++-- tests/flows/test_saml-oidc.py | 4 ++-- tests/flows/test_saml-saml.py | 4 ++-- tests/flows/test_wsgi_flow.py | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index bc04eb2b8..9e7a5e18f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -441,7 +441,7 @@ def get_uri(self): return 'mongodb://localhost:{port!s}'.format(port=self.port) -@pytest.yield_fixture +@pytest.fixture def mongodb_instance(): tmp_db = MongoTemporaryInstance() yield tmp_db diff --git a/tests/flows/test_account_linking.py b/tests/flows/test_account_linking.py index 80a87a874..94f53a431 100644 --- a/tests/flows/test_account_linking.py +++ b/tests/flows/test_account_linking.py @@ -1,6 +1,6 @@ import responses from werkzeug.test import Client -from werkzeug.wrappers import BaseResponse +from werkzeug.wrappers import Response from satosa.proxy_server import make_app from satosa.satosa_config import SATOSAConfig @@ -15,7 +15,7 @@ def test_full_flow(self, satosa_config_dict, account_linking_module_config): satosa_config_dict["MICRO_SERVICES"].insert(0, account_linking_module_config) # application - test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), BaseResponse) + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) # incoming auth req http_resp = test_client.get("/{}/{}/request".format(satosa_config_dict["BACKEND_MODULES"][0]["name"], diff --git a/tests/flows/test_consent.py b/tests/flows/test_consent.py index d2da94350..76dff496b 100644 --- a/tests/flows/test_consent.py +++ b/tests/flows/test_consent.py @@ -3,7 +3,7 @@ import responses from werkzeug.test import Client -from werkzeug.wrappers import BaseResponse +from werkzeug.wrappers import Response from satosa.proxy_server import make_app from satosa.satosa_config import SATOSAConfig @@ -18,7 +18,7 @@ def test_full_flow(self, satosa_config_dict, consent_module_config): satosa_config_dict["MICRO_SERVICES"].append(consent_module_config) # application - test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), BaseResponse) + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) # incoming auth req http_resp = test_client.get("/{}/{}/request".format(satosa_config_dict["BACKEND_MODULES"][0]["name"], diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index 90bbe1a31..c70ba5c8b 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -9,7 +9,7 @@ from saml2 import BINDING_HTTP_REDIRECT from saml2.config import IdPConfig from werkzeug.test import Client -from werkzeug.wrappers import BaseResponse +from werkzeug.wrappers import Response from satosa.metadata_creation.saml_metadata import create_entity_descriptors from satosa.proxy_server import make_app @@ -60,7 +60,7 @@ def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_ _, backend_metadata = create_entity_descriptors(SATOSAConfig(satosa_config_dict)) # application - test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), BaseResponse) + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) # get frontend OP config info provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8")) diff --git a/tests/flows/test_saml-oidc.py b/tests/flows/test_saml-oidc.py index e242ebb89..bc41acfe1 100644 --- a/tests/flows/test_saml-oidc.py +++ b/tests/flows/test_saml-oidc.py @@ -5,7 +5,7 @@ from saml2 import BINDING_HTTP_REDIRECT from saml2.config import SPConfig from werkzeug.test import Client -from werkzeug.wrappers import BaseResponse +from werkzeug.wrappers import Response from satosa.metadata_creation.saml_metadata import create_entity_descriptors from satosa.proxy_server import make_app @@ -27,7 +27,7 @@ def run_test(self, satosa_config_dict, sp_conf, oidc_backend_config, frontend_co frontend_metadata, backend_metadata = create_entity_descriptors(SATOSAConfig(satosa_config_dict)) # application - test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), BaseResponse) + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) # config test SP frontend_metadata_str = str(frontend_metadata[frontend_config["name"]][0]) diff --git a/tests/flows/test_saml-saml.py b/tests/flows/test_saml-saml.py index efa1a8729..91c350495 100644 --- a/tests/flows/test_saml-saml.py +++ b/tests/flows/test_saml-saml.py @@ -3,7 +3,7 @@ from saml2 import BINDING_HTTP_REDIRECT from saml2.config import SPConfig, IdPConfig from werkzeug.test import Client -from werkzeug.wrappers import BaseResponse +from werkzeug.wrappers import Response from satosa.metadata_creation.saml_metadata import create_entity_descriptors from satosa.proxy_server import make_app @@ -23,7 +23,7 @@ def run_test(self, satosa_config_dict, sp_conf, idp_conf, saml_backend_config, f frontend_metadata, backend_metadata = create_entity_descriptors(SATOSAConfig(satosa_config_dict)) # application - test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), BaseResponse) + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) # config test SP frontend_metadata_str = str(frontend_metadata[frontend_config["name"]][0]) diff --git a/tests/flows/test_wsgi_flow.py b/tests/flows/test_wsgi_flow.py index 08d4d4a3d..fcae4ce21 100644 --- a/tests/flows/test_wsgi_flow.py +++ b/tests/flows/test_wsgi_flow.py @@ -4,7 +4,7 @@ import json from werkzeug.test import Client -from werkzeug.wrappers import BaseResponse +from werkzeug.wrappers import Response from satosa.proxy_server import make_app from satosa.response import NotFound @@ -21,7 +21,7 @@ def test_flow(self, satosa_config_dict): """ Performs the test. """ - test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), BaseResponse) + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) # Make request to frontend resp = test_client.get('/{}/{}/request'.format("backend", "frontend")) @@ -35,7 +35,7 @@ def test_flow(self, satosa_config_dict): assert resp.data.decode('utf-8') == "Auth response received, passed to test frontend" def test_unknown_request_path(self, satosa_config_dict): - test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), BaseResponse) + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) resp = test_client.get('/unknown') assert resp.status == NotFound._status From 8ecbcefce7dc763783febe2b83f5659627d03c0a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 8 Aug 2021 01:28:12 +0300 Subject: [PATCH 256/401] Attach request_uri, request_method and http_headers on the context - _http_headers is replaced by http_headers - _http_headers used to hold more than headers; this is now fixed - http_headers hold all headers that start with HTTP_ or REMOTE_ or SERVER_ - the query-string of a GET request is already available as context.request - IdpHinting micro-service is now using the new properties Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/idp_hinting.py | 11 ++++++----- src/satosa/proxy_server.py | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/satosa/micro_services/idp_hinting.py b/src/satosa/micro_services/idp_hinting.py index 397852d09..54ff21190 100644 --- a/src/satosa/micro_services/idp_hinting.py +++ b/src/satosa/micro_services/idp_hinting.py @@ -1,5 +1,4 @@ import logging -from urllib.parse import parse_qs from .base import RequestMicroService from ..exception import SATOSAConfigurationError @@ -42,15 +41,17 @@ def process(self, context, data): :param data: the internal request """ target_entity_id = context.get_decoration(context.KEY_TARGET_ENTITYID) - qs_raw = context._http_headers['QUERY_STRING'] - if target_entity_id or not qs_raw: + query_string = context.request + + an_issuer_is_already_selected = bool(target_entity_id) + query_string_is_missing = not query_string + if an_issuer_is_already_selected or query_string_is_missing: return super().process(context, data) - qs = parse_qs(qs_raw) hints = ( entity_id for param in self.idp_hint_param_names - for entity_id in qs.get(param, [None]) + for entity_id in query_string.get(param, []) if entity_id ) hint = next(hints, None) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 1b41dabc7..1c3c2ed1a 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -65,11 +65,17 @@ def unpack_request(environ, content_length=0): return data -def unpack_http_headers(environ): - headers = ('REQUEST_METHOD', 'PATH_INFO', 'REQUEST_URI', - 'QUERY_STRING', 'SERVER_NAME', 'REMOTE_ADDR', - 'HTTP_HOST', 'HTTP_USER_AGENT', 'HTTP_ACCEPT_LANGUAGE') - return {k:v for k,v in environ.items() if k in headers} +def collect_http_headers(environ): + headers = { + header_name: header_value + for header_name, header_value in environ.items() + if ( + header_name.startswith("HTTP_") + or header_name.startswith("REMOTE_") + or header_name.startswith("SERVER_") + ) + } + return headers class ToBytesMiddleware(object): @@ -116,7 +122,9 @@ def __call__(self, environ, start_response, debug=False): body = io.BytesIO(environ['wsgi.input'].read(content_length)) environ['wsgi.input'] = body context.request = unpack_request(environ, content_length) - context._http_headers = unpack_http_headers(environ) + context.request_uri = environ.get("REQUEST_URI") + context.request_method = environ.get("REQUEST_METHOD") + context.http_headers = collect_http_headers(environ) environ['wsgi.input'].seek(0) context.cookie = environ.get("HTTP_COOKIE", "") From e2df9ba50e7731d903c7c740f7430c5579debaba Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 8 Aug 2021 01:31:41 +0300 Subject: [PATCH 257/401] Abstract parsing of the query string as dictionary Signed-off-by: Ivan Kanakarakis --- src/satosa/proxy_server.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 1c3c2ed1a..a902977e4 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -3,7 +3,7 @@ import logging import logging.config import sys -from urllib.parse import parse_qsl +from urllib.parse import parse_qsl as _parse_query_string from cookies_samesite_compat import CookiesSameSiteCompatMiddleware @@ -18,16 +18,19 @@ logger = logging.getLogger(__name__) +def parse_query_string(data): + query_param_pairs = _parse_query_string(data) + query_param_dict = dict(query_param_pairs) + return query_param_dict + + def unpack_get(environ): """ Unpacks a redirect request query string. :param environ: whiskey application environment. :return: A dictionary with parameters. """ - if "QUERY_STRING" in environ: - return dict(parse_qsl(environ["QUERY_STRING"])) - - return None + return parse_query_string(environ.get("QUERY_STRING")) def unpack_post(environ, content_length): @@ -39,7 +42,7 @@ def unpack_post(environ, content_length): post_body = environ['wsgi.input'].read(content_length).decode("utf-8") data = None if "application/x-www-form-urlencoded" in environ["CONTENT_TYPE"]: - data = dict(parse_qsl(post_body)) + data = parse_query_string(post_body) elif "application/json" in environ["CONTENT_TYPE"]: data = json.loads(post_body) From 031cd818329092e34ba5abef4eb97e2526061116 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 9 Aug 2021 09:02:17 +0300 Subject: [PATCH 258/401] Fix documentation heading Signed-off-by: Ivan Kanakarakis --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index b9934ce94..4a39965cc 100644 --- a/doc/README.md +++ b/doc/README.md @@ -29,7 +29,7 @@ apt-get install libffi-dev libssl-dev xmlsec1 Alternatively the application can be installed directly from PyPI (`pip install satosa`), or the [Docker image](https://hub.docker.com/r/satosa/) can be used. -### External micro-services +### External micro-services Micro-services act like plugins and can be developed by anyone. Other people that have been working with the SaToSa proxy, have built extentions mainly in From 7c82d89041cedc4a4676573d9ab8e8ad8ab6c077 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 27 Aug 2021 20:43:08 +0300 Subject: [PATCH 259/401] Pass proper encryption keys when retrieving the subject NameID This requires the latest pysaml2 to work properly, as older versions of get_subject do not accept the optional keys argument. To have this working without this changeset, one should define the pysaml2 configuration option `encryption_keypairs`. Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index db8f12d50..44f94fc8e 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -404,7 +404,7 @@ def _translate_response(self, response, state): ) # The SAML response may not include a NameID. - subject = response.get_subject() + subject = response.get_subject(keys=self.encryption_keys) name_id = subject.text if subject else None name_id_format = subject.format if subject else None From 62ac974be3f1abb3205c64d04a024633af2a3b60 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 27 Aug 2021 00:16:06 +0300 Subject: [PATCH 260/401] Introduce explicit context property to hold the query params A POST request can have a query string. In that case context.request will hold the data from the POST request body, and thus there is no place to hold the query params. With this changeset, a new property is introduced to hold the query string, parsed as query params. The query params is a list of tuples. Each tuple holds two elements, the query param name and the query param value. Params with no value are dropped. ?param_w_value=123¶m_w_no_value Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/idp_hinting.py | 14 +++++++------- src/satosa/proxy_server.py | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/satosa/micro_services/idp_hinting.py b/src/satosa/micro_services/idp_hinting.py index 54ff21190..04f003ef1 100644 --- a/src/satosa/micro_services/idp_hinting.py +++ b/src/satosa/micro_services/idp_hinting.py @@ -41,18 +41,18 @@ def process(self, context, data): :param data: the internal request """ target_entity_id = context.get_decoration(context.KEY_TARGET_ENTITYID) - query_string = context.request + qs_params = context.qs_params - an_issuer_is_already_selected = bool(target_entity_id) - query_string_is_missing = not query_string - if an_issuer_is_already_selected or query_string_is_missing: + issuer_is_already_selected = bool(target_entity_id) + query_string_is_missing = not qs_params + if issuer_is_already_selected or query_string_is_missing: return super().process(context, data) hints = ( entity_id - for param in self.idp_hint_param_names - for entity_id in query_string.get(param, []) - if entity_id + for param_name in self.idp_hint_param_names + for qs_param_name, entity_id in qs_params + if param_name == qs_param_name ) hint = next(hints, None) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index a902977e4..8c2d1795c 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -128,6 +128,7 @@ def __call__(self, environ, start_response, debug=False): context.request_uri = environ.get("REQUEST_URI") context.request_method = environ.get("REQUEST_METHOD") context.http_headers = collect_http_headers(environ) + context.qs_params = parse_query_string(environ.get("QUERY_STRING")) environ['wsgi.input'].seek(0) context.cookie = environ.get("HTTP_COOKIE", "") From 5b110d65e4f20b32046d8ca0dfbcaf805c61d2ad Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 27 Aug 2021 20:42:14 +0300 Subject: [PATCH 261/401] Initialize Context with all its properties Signed-off-by: Ivan Kanakarakis --- src/satosa/context.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/satosa/context.py b/src/satosa/context.py index a30f67c3d..ddfdd468a 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -23,12 +23,17 @@ class Context(object): def __init__(self): self._path = None self.request = None + self.request_uri = None + self.request_method = None + self.qs_params = None + self.http_headers = None + self.cookie = None + self.request_authorization = None self.target_backend = None self.target_frontend = None self.target_micro_service = None # This dict is a data carrier between frontend and backend modules. self.internal_data = {} - self.cookie = None self.state = None @property From b50f70b45589b7f385db68f90364f9382b7ff8b8 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 27 Aug 2021 21:52:50 +0300 Subject: [PATCH 262/401] Separate server headers from http headers Signed-off-by: Ivan Kanakarakis --- src/satosa/context.py | 1 + src/satosa/proxy_server.py | 28 +++++++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/satosa/context.py b/src/satosa/context.py index ddfdd468a..60f35942b 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -26,6 +26,7 @@ def __init__(self): self.request_uri = None self.request_method = None self.qs_params = None + self.server = None self.http_headers = None self.cookie = None self.request_authorization = None diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 8c2d1795c..ce7fd1459 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -1,14 +1,15 @@ -import io import json import logging import logging.config import sys +from io import BytesIO from urllib.parse import parse_qsl as _parse_query_string from cookies_samesite_compat import CookiesSameSiteCompatMiddleware import satosa import satosa.logging_util as lu + from .base import SATOSABase from .context import Context from .response import ServiceError, NotFound @@ -68,6 +69,15 @@ def unpack_request(environ, content_length=0): return data +def collect_server_headers(environ): + headers = { + header_name: header_value + for header_name, header_value in environ.items() + if header_name.startswith("SERVER_") + } + return headers + + def collect_http_headers(environ): headers = { header_name: header_value @@ -75,7 +85,6 @@ def collect_http_headers(environ): if ( header_name.startswith("HTTP_") or header_name.startswith("REMOTE_") - or header_name.startswith("SERVER_") ) } return headers @@ -119,20 +128,21 @@ def __call__(self, environ, start_response, debug=False): context.path = path # copy wsgi.input stream to allow it to be re-read later by satosa plugins - # see: http://stackoverflow.com/ - # questions/1783383/how-do-i-copy-wsgi-input-if-i-want-to-process-post-data-more-than-once + # see: http://stackoverflow.com/questions/1783383/how-do-i-copy-wsgi-input-if-i-want-to-process-post-data-more-than-once content_length = int(environ.get('CONTENT_LENGTH', '0') or '0') - body = io.BytesIO(environ['wsgi.input'].read(content_length)) + body = BytesIO(environ['wsgi.input'].read(content_length)) environ['wsgi.input'] = body + context.request = unpack_request(environ, content_length) context.request_uri = environ.get("REQUEST_URI") context.request_method = environ.get("REQUEST_METHOD") - context.http_headers = collect_http_headers(environ) context.qs_params = parse_query_string(environ.get("QUERY_STRING")) - environ['wsgi.input'].seek(0) + context.server = collect_server_headers(environ) + context.http_headers = collect_http_headers(environ) + context.cookie = context.http_headers.get("HTTP_COOKIE", "") + context.request_authorization = context.http_headers.get("HTTP_AUTHORIZATION", "") - context.cookie = environ.get("HTTP_COOKIE", "") - context.request_authorization = environ.get("HTTP_AUTHORIZATION", "") + environ['wsgi.input'].seek(0) try: resp = self.run(context) From 7ed0774aed259c336596a34a999fa09c469cf2c3 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 27 Aug 2021 00:15:02 +0300 Subject: [PATCH 263/401] Use higher-level function to create a saml request on the saml2 backend Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 44f94fc8e..770211245 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from saml2 import BINDING_HTTP_REDIRECT -from saml2.client_base import Base +from saml2.client import Saml2Client from saml2.config import SPConfig from saml2.extension.mdui import NAMESPACE as UI_NAMESPACE from saml2.metadata import create_metadata_string @@ -109,7 +109,7 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): self.config = self.init_config(config) sp_config = SPConfig().load(copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG])) - self.sp = Base(sp_config) + self.sp = Saml2Client(sp_config) self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV) self.encryption_keys = [] @@ -272,27 +272,19 @@ def authn_request(self, context, entity_id): kwargs["scoping"] = Scoping(requester_id=[RequesterID(text=requester)]) 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: + req_id, binding, http_info = self.sp.prepare_for_negotiated_authenticate( + entityid=entity_id, + response_binding=response_binding, + relay_state=relay_state, + **kwargs, + ) + except Exception as e: 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 + raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from e if self.sp.config.getattr('allow_unsolicited', 'sp') is False: if req_id in self.outstanding_queries: @@ -300,10 +292,10 @@ def authn_request(self, context, entity_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 + self.outstanding_queries[req_id] = req_id context.state[self.name] = {"relay_state": relay_state} - return make_saml_response(binding, ht_args) + return make_saml_response(binding, http_info) def authn_response(self, context, binding): """ From 199025a4e7f2a2c32e6f985281f47a1b58a2acda Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Sat, 14 Aug 2021 00:31:44 +0200 Subject: [PATCH 264/401] Documentation for recently added micro-services --- doc/README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index 4a39965cc..834967e7b 100644 --- a/doc/README.md +++ b/doc/README.md @@ -614,6 +614,14 @@ To choose which backend (essentially choosing target provider) to use based on t `DecideBackendByRequester` class which implements that special routing behavior. See the [example configuration](../example/plugins/microservices/requester_based_routing.yaml.example). +#### Route to a specific backend based on the target entity id +Use the `DecideBackendByTargetIssuer` class which implements that special routing behavior. See the +[example configuration](../example/plugins/microservices/target_based_routing.yaml.example). + +If a Discovery Service have been used and the target entity id is selected by users, + also use `DiscoToTargetIssuer` together with `DecideBackendByTargetIssuer` + to get the expected result. See the [example configuration](../example/plugins/microservices/disco_to_target_issuer.yaml.example). + #### Filter authentication requests to target SAML entities If using the `SAMLMirrorFrontend` module and some of the target providers should support some additional SP's, the `DecideIfRequesterIsAllowed` micro service can be used. It provides a rules mechanism to describe which SP's are @@ -679,6 +687,15 @@ persistent NameID may also be obtained from attributes returned from the LDAP di LDAP microservice install the extra necessary dependencies with `pip install satosa[ldap]` and then see the [example config](../example/plugins/microservices/ldap_attribute_store.yaml.example). +#### Minimal support for IdP Hinting + +It's possible to hint an IdP to SaToSa using `IdpHinting` microservice. See the + [example configuration](../example/plugins/microservices/idp_hinting.yaml.example). + +With this feature an SP can send an hint about the IdP to avoid the discovery + service page to the users, using a url parameter. Example + `https://[...]?[...]&idphint=https://that.idp.entity.id"` + ### Custom plugins It's possible to write custom plugins which can be loaded by SATOSA. They have to be contained in a Python module, @@ -734,4 +751,3 @@ set SATOSA_CONFIG=/home/user/proxy_conf.yaml ## Using Apache HTTP Server and mod\_wsgi See the [auxiliary documentation for running using mod\_wsgi](mod_wsgi.md). - From fe2100c972d93af168cc3cdabbf7659f9a214abb Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 28 Aug 2021 00:13:42 +0300 Subject: [PATCH 265/401] Normalize document formatting Signed-off-by: Ivan Kanakarakis --- doc/README.md | 74 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/doc/README.md b/doc/README.md index 834967e7b..6f22af7d5 100644 --- a/doc/README.md +++ b/doc/README.md @@ -5,22 +5,26 @@ This document describes how to install and configure the SATOSA proxy. # Installation ## Docker + A pre-built Docker image is accessible at the [Docker Hub](https://hub.docker.com/r/satosa/), and is the recommended ways of running the proxy. ## Manual installation ### Dependencies + SATOSA requires Python 3.4 (or above), and the following packages on Ubuntu: -``` + +```bash apt-get install libffi-dev libssl-dev xmlsec1 ```` ### Instructions + 1. Download the SATOSA proxy project as a [compressed archive](https://github.com/IdentityPython/SATOSA/releases) and unpack it to ``. -1. Install the application: +2. Install the application: ```bash pip install @@ -28,7 +32,6 @@ apt-get install libffi-dev libssl-dev xmlsec1 Alternatively the application can be installed directly from PyPI (`pip install satosa`), or the [Docker image](https://hub.docker.com/r/satosa/) can be used. - ### External micro-services Micro-services act like plugins and can be developed by anyone. Other people @@ -53,8 +56,8 @@ The extentions include: and more. - # Configuration + SATOSA is configured using YAML. All default configuration files, as well as an example WSGI application for the proxy, can be found @@ -74,7 +77,7 @@ the value from the process environment variable of the same name. If the process environment has been set with `LDAP_BIND_PASSWORD=secret_password` then the configuration value for `bind_password` will be `secret_password`. -``` +```yaml bind_password: !ENV LDAP_BIND_PASSWORD ``` @@ -90,12 +93,12 @@ process environment has been set with `LDAP_BIND_PASSWORD_FILE=/etc/satosa/secrets/ldap.txt` then the configuration value for `bind_password` will be `secret_password`. -``` +```yaml bind_password: !ENVFILE LDAP_BIND_PASSWORD_FILE ``` - ## SATOSA proxy configuration: `proxy_conf.yaml.example` + | Parameter name | Data type | Example value | Description | | -------------- | --------- | ------------- | ----------- | | `BASE` | string | `https://proxy.example.com` | base url of the proxy | @@ -109,10 +112,10 @@ bind_password: !ENVFILE LDAP_BIND_PASSWORD_FILE | `MICRO_SERVICES` | string[] | `[statistics_service.yaml]` | list of plugin configuration file paths, describing enabled microservices | | `LOGGING` | dict | see [Python logging.conf](https://docs.python.org/3/library/logging.config.html) | optional configuration of application logging | - ## Attribute mapping configuration: `internal_attributes.yaml` ### attributes + The values directly under the `attributes` key are the internal attribute names. Every internal attribute has a map of profiles, which in turn has a list of external attributes names which should be mapped to the internal attributes. @@ -124,6 +127,7 @@ internal attribute. Sometimes the external attributes are nested/complex structures. One example is the [address claim in OpenID connect](http://openid.net/specs/openid-connect-core-1_0.html#AddressClaim) which consists of multiple sub-fields, e.g.: + ```json "address": { "formatted": "100 Universal City Plaza, Hollywood CA 91608, USA", @@ -131,7 +135,7 @@ which consists of multiple sub-fields, e.g.: "locality": "Hollywood", "region": "CA", "postal_code": "91608", - "country": "USA", + "country": "USA" } ``` @@ -166,28 +170,29 @@ attributes (in the proxy backend) <-> internal <-> returned attributes (from the * Any plugin using the `saml` profile will use the attribute value from `postaladdress` delivered from the target provider as the value for `address`. - ### user_id_from_attrs + The subject identifier generated by the backend module can be overridden by specifying a list of internal attribute names under the `user_id_from_attrs` key. The attribute values of the attributes specified in this list will be concatenated and used as the subject identifier. - ### user_id_to_attr + To store the subject identifier in a specific internal attribute, the internal attribute name can be specified in `user_id_to_attr`. When the [ALService](https://github.com/its-dirg/ALservice) is used for account linking, the `user_id_to_attr` configuration parameter should be set, since that service will overwrite the subject identifier generated by the proxy. - ## Plugins + The authentication protocol specific communication is handled by different plugins, divided into frontends (receiving requests from clients) and backends (sending requests to target providers). ### Common plugin configuration parameters + Both `name` and `module` must be specified in all plugin configurations (frontends, backends, and micro services). The `name` must be unique to ensure correct functionality, and the `module` must be the fully qualified name of an importable Python module. @@ -230,7 +235,6 @@ For more detailed information on how you could customize the SAML entities, see the [documentation of the underlying library pysaml2](https://github.com/rohe/pysaml2/blob/master/docs/howto/config.rst). - #### Providing `AuthnContextClassRef` SAML2 frontends and backends can provide a custom (configurable) *Authentication Context Class Reference*. @@ -252,12 +256,13 @@ provider will be preserved, and when using a OAuth or OpenID Connect backend, th **Example** - config: - [...] - acr_mapping: - "": default-LoA - "https://accounts.google.com": LoA1 - +```yaml +config: + [...] + acr_mapping: + "": default-LoA + "https://accounts.google.com": LoA1 +``` #### Frontend @@ -296,8 +301,8 @@ An example configuration can be found [here](../example/plugins/frontends/saml2_ `SP -> Virtual CO SAMLFrontend -> SAMLBackend -> optional discovery service -> target IdP` - ##### Custom attribute release + In addition to respecting for example entity categories from the SAML metadata, the SAML frontend can also further restrict the attribute release with the `custom_attribute_release` configuration parameter based on the SP entity id. @@ -349,13 +354,14 @@ Overrides per SP entityID is possible by using the entityID as a key instead of in the yaml structure. The most specific key takes presedence. If no policy overrides are provided the defaults above are used. - #### Backend + The SAML2 backend act as a SAML Service Provider (SP), making authentication requests to SAML Identity Providers (IdP). The default configuration file can be found [here](../example/plugins/backends/saml2_backend.yaml.example). ##### Name ID Format + The SAML backend can indicate which *Name ID* format it wants by specifying the key `name_id_format` in the SP entity configuration in the backend plugin configuration: @@ -368,6 +374,7 @@ The SAML backend can indicate which *Name ID* format it wants by specifying the ``` ##### Use a discovery service + To allow the user to choose which target provider they want to authenticate with, the configuration parameter `disco_srv`, must be specified if the metadata given to the backend module contains more than one IdP: @@ -433,6 +440,7 @@ config: ### OpenID Connect plugins #### Backend + The OpenID Connect backend acts as an OpenID Connect Relying Party (RP), making authentication requests to OpenID Connect Provider (OP). The default configuration file can be found [here](../example/plugins/backends/openid_backend.yaml.example). @@ -444,8 +452,8 @@ When using an OP that only supports statically registered clients, see the and make sure to provide the redirect URI, constructed as described in the section about Google configuration below, in the static registration. - #### Frontend + The OpenID Connect frontend acts as and OpenID Connect Provider (OP), accepting requests from OpenID Connect Relying Parties (RPs). The default configuration file can be found [here](../example/plugins/frontends/openid_connect_frontend.yaml.example). @@ -477,10 +485,12 @@ The configuration parameters available: The other parameters should be left with their default values. ### Social login plugins + The social login plugins can be used as backends for the proxy, allowing the proxy to act as a client to the social login services. #### Google + The default configuration file can be found [here](../example/plugins/backends/google_backend.yaml.example). @@ -495,7 +505,7 @@ It should use the available variables, `` and ``, where: 1. `` is the base url of the proxy as specified in the `BASE` configuration parameter in `proxy_conf.yaml`, e.g. "https://proxy.example.com". -1. `` is the plugin name specified in the `name` configuration parameter defined in the plugin configuration file. +2. `` is the plugin name specified in the `name` configuration parameter defined in the plugin configuration file. The example config in `google_backend.yaml.example`: @@ -507,14 +517,15 @@ config: redirect_uris: [/] [...] ``` + together with `BASE: "https://proxy.example.com"` in `proxy_conf.yaml` would yield the redirect URI `https://proxy.example.com/google` to register with Google. A list of all claims possibly released by Google can be found [here](https://developers.google.com/identity/protocols/OpenIDConnect#obtainuserinfo), which should be used when configuring the attribute mapping (see above). - #### Facebook + The default configuration file can be found [here](../example/plugins/backends/facebook_backend.yaml.example). @@ -549,7 +560,7 @@ pre-configured (static) attributes, see the The static attributes are described as key-value pairs in the YAML file, e.g: -``` +```yaml organisation: Example Org. country: Sweden ``` @@ -574,8 +585,10 @@ The filters are applied such that all attribute values matched by the regular ex non-matching attribute values will be discarded. ##### Examples + Filter attributes from the target provider `https://provider.example.com`, to only preserve values starting with the string `"foo:bar"`: + ```yaml "https://provider.example.com": "": @@ -583,6 +596,7 @@ string `"foo:bar"`: ``` Filter the attribute `attr1` to only preserve values ending with the string `"foo:bar"`: + ```yaml "": "": @@ -591,6 +605,7 @@ Filter the attribute `attr1` to only preserve values ending with the string `"fo Filter the attribute `attr1` to the requester `https://provider.example.com`, to only preserver values containing the string `"foo:bar"`: + ```yaml "": "https://client.example.com": @@ -601,6 +616,7 @@ the string `"foo:bar"`: Attributes delivered from the target provider can be filtered based on a list of allowed attributes per requester using the `AttributePolicy` class: + ```yaml attribute_policy: : @@ -610,11 +626,13 @@ attribute_policy: ``` #### Route to a specific backend based on the requester + To choose which backend (essentially choosing target provider) to use based on the requester, use the `DecideBackendByRequester` class which implements that special routing behavior. See the [example configuration](../example/plugins/microservices/requester_based_routing.yaml.example). #### Route to a specific backend based on the target entity id + Use the `DecideBackendByTargetIssuer` class which implements that special routing behavior. See the [example configuration](../example/plugins/microservices/target_based_routing.yaml.example). @@ -623,6 +641,7 @@ If a Discovery Service have been used and the target entity id is selected by us to get the expected result. See the [example configuration](../example/plugins/microservices/disco_to_target_issuer.yaml.example). #### Filter authentication requests to target SAML entities + If using the `SAMLMirrorFrontend` module and some of the target providers should support some additional SP's, the `DecideIfRequesterIsAllowed` micro service can be used. It provides a rules mechanism to describe which SP's are allowed to send requests to which IdP's. See the [example configuration](../example/plugins/microservices/allowed_requesters.yaml.example). @@ -633,6 +652,7 @@ Metadata containing all SP's (any SP that might be allowed by a target IdP) must The rules are described using `allow` and `deny` directives under the `rules` configuration parameter. In the following example, the target IdP `target_entity_id1` only allows requests from `requester1` and `requester2`. + ```yaml rules: target_entity_id1: @@ -643,6 +663,7 @@ SP's are by default denied if the IdP has any rules associated with it (i.e, the However, if the IdP does not have any rules associated with its entity id, all SP's are by default allowed. Deny all but one SP: + ```yaml rules: target_entity_id1: @@ -651,6 +672,7 @@ rules: ``` Allow all but one SP: + ```yaml rules: target_entity_id1: @@ -733,9 +755,11 @@ full featured general purpose web server (in a reverse proxy architecture) such Apache HTTP Server to help buffer slow clients and enable more sophisticated error page rendering. Start the proxy server with the following command: + ```bash gunicorn -b satosa.wsgi:app --keyfile= --certfile= ``` + where * `socket address` is the socket address that `gunicorn` should bind to for incoming requests, e.g. `0.0.0.0:8080` * `https key` is the path to the private key to use for HTTPS, e.g. `pki/key.pem` From 104aa87cb535533cfa3252fccdfb272f6b2bb5fb Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 28 Aug 2021 00:25:47 +0300 Subject: [PATCH 266/401] Amend docs Signed-off-by: Ivan Kanakarakis --- doc/README.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/doc/README.md b/doc/README.md index 6f22af7d5..d058d6ad7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -636,9 +636,12 @@ To choose which backend (essentially choosing target provider) to use based on t Use the `DecideBackendByTargetIssuer` class which implements that special routing behavior. See the [example configuration](../example/plugins/microservices/target_based_routing.yaml.example). -If a Discovery Service have been used and the target entity id is selected by users, - also use `DiscoToTargetIssuer` together with `DecideBackendByTargetIssuer` - to get the expected result. See the [example configuration](../example/plugins/microservices/disco_to_target_issuer.yaml.example). +#### Route to a specific backend based on the discovery service response + +If a Discovery Service is in use and a target entity id is selected by users, you may want to use the +`DiscoToTargetIssuer` class together with `DecideBackendByTargetIssuer` to be able to select a +backend (essentially choosing target provider) based on the response from the discovery service. +See the [example configuration](../example/plugins/microservices/disco_to_target_issuer.yaml.example). #### Filter authentication requests to target SAML entities @@ -709,14 +712,15 @@ persistent NameID may also be obtained from attributes returned from the LDAP di LDAP microservice install the extra necessary dependencies with `pip install satosa[ldap]` and then see the [example config](../example/plugins/microservices/ldap_attribute_store.yaml.example). -#### Minimal support for IdP Hinting +#### Support for IdP Hinting -It's possible to hint an IdP to SaToSa using `IdpHinting` microservice. See the - [example configuration](../example/plugins/microservices/idp_hinting.yaml.example). +It's possible to hint an IdP to SaToSa using the `IdpHinting` micro-service. -With this feature an SP can send an hint about the IdP to avoid the discovery - service page to the users, using a url parameter. Example - `https://[...]?[...]&idphint=https://that.idp.entity.id"` +With this feature an SP can send a hint about the IdP that should be used, in order to skip the discovery service. +The hint as a parameter in the query string of the request. +The hint query parameter value must be the entityID of the IdP. +The hint query parameter name is specified in the micro-service configuation. +See the [example configuration](../example/plugins/microservices/idp_hinting.yaml.example). ### Custom plugins From 1de6a360d52369e01a40d7d0400af20735d7e899 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 28 Aug 2021 22:02:34 +0300 Subject: [PATCH 267/401] Add authenticating authority as part of the internal AuthenticationInformation object Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 15 +++++++++------ src/satosa/internal.py | 9 ++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 770211245..7118ea007 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -384,15 +384,18 @@ def _translate_response(self, response, state): # 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) + response.parse_assertion(keys=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 - + authn_context_ref, authenticating_authorities, authn_instant = next( + iter(response.authn_info()), [None, None, None] + ) + authenticating_authority = next(iter(authenticating_authorities), None) auth_info = AuthenticationInformation( - auth_class_ref, timestamp, issuer, + auth_class_ref=authn_context_ref, + timestamp=authn_instant, + authority=authenticating_authority, + issuer=issuer, ) # The SAML response may not include a NameID. diff --git a/src/satosa/internal.py b/src/satosa/internal.py index 38b82acfb..24de31890 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -85,7 +85,13 @@ class AuthenticationInformation(_Datafy): """ def __init__( - self, auth_class_ref=None, timestamp=None, issuer=None, *args, **kwargs + self, + auth_class_ref=None, + timestamp=None, + issuer=None, + authority=None, + *args, + **kwargs, ): """ Initiate the data carrier @@ -102,6 +108,7 @@ def __init__( self.auth_class_ref = auth_class_ref self.timestamp = timestamp self.issuer = issuer + self.authority = authority class InternalData(_Datafy): From 5d0601453e20e8145902116862780327d6950c2e Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 28 Aug 2021 22:45:02 +0300 Subject: [PATCH 268/401] Revert "Pass proper encryption keys when retrieving the subject NameID" This reverts commit 7c82d89041cedc4a4676573d9ab8e8ad8ab6c077. --- src/satosa/backends/saml2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 7118ea007..bd4733275 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -399,7 +399,7 @@ def _translate_response(self, response, state): ) # The SAML response may not include a NameID. - subject = response.get_subject(keys=self.encryption_keys) + subject = response.get_subject() name_id = subject.text if subject else None name_id_format = subject.format if subject else None From b1ea01d8f6e18eb53fb66438e6058fa2bf3d9ced Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sat, 28 Aug 2021 22:44:19 +0300 Subject: [PATCH 269/401] Reflect the encryption_keypairs in the saml client configuration See also commit 7c82d89041cedc4a4676573d9ab8e8ad8ab6c077 which was reverted for backwards compatibility reasons by commit 5d0601453e20e8145902116862780327d6950c2e The original goal was: > Pass proper encryption keys when retrieving the subject NameID > > This requires the latest pysaml2 to work properly, as older versions of > get_subject do not accept the optional keys argument. > > To have this working without this changeset, one should define the > pysaml2 configuration option `encryption_keypairs`. We are now opting the solution without the above changeset (it was reverted) to keep backwards compatibility. Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index bd4733275..8b726edf9 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -108,27 +108,38 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): 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])) - self.sp = Saml2Client(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 = [] + sp_config = SPConfig().load(copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG])) + + # if encryption_keypairs is defined, use those keys for decryption + # else, if key_file and cert_file are defined, use them for decryption + # otherwise, do not use any decryption key. + # ensure the choice is reflected back in the configuration. + sp_conf_encryption_keypairs = sp_config.getattr('encryption_keypairs', '') + sp_conf_key_file = sp_config.getattr('key_file', '') + sp_conf_cert_file = sp_config.getattr('cert_file', '') + sp_keypairs = ( + sp_conf_encryption_keypairs + if sp_conf_encryption_keypairs + else [{'key_file': sp_conf_key_file, 'cert_file': sp_conf_cert_file}] + if sp_conf_key_file and sp_conf_cert_file + else [] + ) + sp_config.setattr('', 'encryption_keypairs', sp_keypairs) + # load the encryption keys + key_file_paths = [pair['key_file'] for pair in sp_keypairs] for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read()) + # finally, initialize the client object + self.sp = Saml2Client(sp_config) + def get_idp_entity_id(self, context): """ :type context: satosa.context.Context From d8cc20817c971fa0d25ddbde99d833948fdd02ff Mon Sep 17 00:00:00 2001 From: Ali Haider Date: Mon, 30 Aug 2021 10:16:50 +0200 Subject: [PATCH 270/401] OpenIDConnectFrontend jwks endpoint should also expose "kid" if configured via the new "signing_key_id" configuration parameter in the openid_connect_frontend.yaml. --- .../plugins/frontends/openid_connect_frontend.yaml.example | 1 + src/satosa/frontends/openid_connect.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/example/plugins/frontends/openid_connect_frontend.yaml.example b/example/plugins/frontends/openid_connect_frontend.yaml.example index bc941bd1c..6c74b2d4c 100644 --- a/example/plugins/frontends/openid_connect_frontend.yaml.example +++ b/example/plugins/frontends/openid_connect_frontend.yaml.example @@ -2,6 +2,7 @@ module: satosa.frontends.openid_connect.OpenIDConnectFrontend name: OIDC config: signing_key_path: frontend.key + signing_key_id: frontend.key1 db_uri: mongodb://db.example.com # optional: only support MongoDB, will default to in-memory storage if not specified client_db_path: /path/to/your/cdb.json sub_hash_salt: randomSALTvalue # if not specified, it is randomly generated on every startup diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 1acc80583..8bd1319f7 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -44,7 +44,8 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, super().__init__(auth_req_callback_func, internal_attributes, base_url, name) self.config = conf - self.signing_key = RSAKey(key=rsa_load(conf["signing_key_path"]), use="sig", alg="RS256") + self.signing_key = RSAKey(key=rsa_load(conf["signing_key_path"]), use="sig", alg="RS256", + kid=conf.get("signing_key_id", "")) def _create_provider(self, endpoint_baseurl): response_types_supported = self.config["provider"].get("response_types_supported", ["id_token"]) @@ -240,6 +241,10 @@ def _validate_config(self, config): if k not in config: raise ValueError("Missing configuration parameter '{}' for OpenID Connect frontend.".format(k)) + if "signing_key_id" in config and type(config["signing_key_id"]) is not str: + raise ValueError( + "The configuration parameter 'signing_key_id' is not defined as a string for OpenID Connect frontend.") + def _get_authn_request_from_state(self, state): """ Extract the clietns request stoed in the SATOSA state. From 89741a8b349df69f6974c64d1a8ebb0561fde12c Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Tue, 31 Aug 2021 14:10:31 +0200 Subject: [PATCH 271/401] IdP hinting improvements - fix: exception accessing to qs_params - chore: improved exacmple configuration - feat: added unit tests --- .../microservices/idp_hinting.yaml.example | 1 + src/satosa/micro_services/idp_hinting.py | 3 +- .../satosa/micro_services/test_idp_hinting.py | 40 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/satosa/micro_services/test_idp_hinting.py diff --git a/example/plugins/microservices/idp_hinting.yaml.example b/example/plugins/microservices/idp_hinting.yaml.example index 9238f3c55..8dbc26932 100644 --- a/example/plugins/microservices/idp_hinting.yaml.example +++ b/example/plugins/microservices/idp_hinting.yaml.example @@ -4,3 +4,4 @@ config: allowed_params: - idp_hinting - idp_hint + - idphint diff --git a/src/satosa/micro_services/idp_hinting.py b/src/satosa/micro_services/idp_hinting.py index 04f003ef1..3d3694cfc 100644 --- a/src/satosa/micro_services/idp_hinting.py +++ b/src/satosa/micro_services/idp_hinting.py @@ -51,10 +51,9 @@ def process(self, context, data): hints = ( entity_id for param_name in self.idp_hint_param_names - for qs_param_name, entity_id in qs_params + for qs_param_name, entity_id in qs_params.items() if param_name == qs_param_name ) hint = next(hints, None) - context.decorate(context.KEY_TARGET_ENTITYID, hint) return super().process(context, data) diff --git a/tests/satosa/micro_services/test_idp_hinting.py b/tests/satosa/micro_services/test_idp_hinting.py new file mode 100644 index 000000000..06b96bd69 --- /dev/null +++ b/tests/satosa/micro_services/test_idp_hinting.py @@ -0,0 +1,40 @@ +from unittest import TestCase + +import pytest + +from satosa.context import Context +from satosa.state import State +from satosa.micro_services.idp_hinting import IdpHinting + + +class TestIdpHinting(TestCase): + def setUp(self): + context = Context() + context.state = State() + + config = { + 'allowed_params': ["idp_hinting", "idp_hint", "idphint"] + } + + plugin = IdpHinting( + config=config, + name='test_idphinting', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) + + self.config = config + self.context = context + self.plugin = plugin + + def test_idp_hinting(self): + self.context.request = {} + _target = 'https://localhost:8080' + self.context.qs_params = {'idphint': _target} + res = self.plugin.process(self.context, data={}) + assert res[0].internal_data.get('target_entity_id') == _target + + def test_no_idp_hinting(self): + self.context.request = {} + res = self.plugin.process(self.context, data={}) + assert not res[0].internal_data.get('target_entity_id') From fdc5bfd29b31c4ee3b97bb3c55c2ad606085df76 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 31 Aug 2021 16:33:10 +0300 Subject: [PATCH 272/401] Add more tests for the idp hinting micro-service Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/idp_hinting.py | 1 + .../satosa/micro_services/test_idp_hinting.py | 33 ++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/satosa/micro_services/idp_hinting.py b/src/satosa/micro_services/idp_hinting.py index 3d3694cfc..90569d706 100644 --- a/src/satosa/micro_services/idp_hinting.py +++ b/src/satosa/micro_services/idp_hinting.py @@ -55,5 +55,6 @@ def process(self, context, data): if param_name == qs_param_name ) hint = next(hints, None) + context.decorate(context.KEY_TARGET_ENTITYID, hint) return super().process(context, data) diff --git a/tests/satosa/micro_services/test_idp_hinting.py b/tests/satosa/micro_services/test_idp_hinting.py index 06b96bd69..a13d3d7a3 100644 --- a/tests/satosa/micro_services/test_idp_hinting.py +++ b/tests/satosa/micro_services/test_idp_hinting.py @@ -3,6 +3,7 @@ import pytest from satosa.context import Context +from satosa.internal import InternalData from satosa.state import State from satosa.micro_services.idp_hinting import IdpHinting @@ -11,6 +12,7 @@ class TestIdpHinting(TestCase): def setUp(self): context = Context() context.state = State() + internal_data = InternalData() config = { 'allowed_params': ["idp_hinting", "idp_hint", "idphint"] @@ -25,16 +27,31 @@ def setUp(self): self.config = config self.context = context + self.data = internal_data self.plugin = plugin - def test_idp_hinting(self): - self.context.request = {} + def test_no_query_params(self): + self.context.qs_params = {} + new_context, new_data = self.plugin.process(self.context, self.data) + assert not new_context.get_decoration(Context.KEY_TARGET_ENTITYID) + + def test_hint_in_params(self): _target = 'https://localhost:8080' self.context.qs_params = {'idphint': _target} - res = self.plugin.process(self.context, data={}) - assert res[0].internal_data.get('target_entity_id') == _target + new_context, new_data = self.plugin.process(self.context, self.data) + assert new_context.get_decoration(Context.KEY_TARGET_ENTITYID) == _target + + def test_no_hint_in_params(self): + _target = 'https://localhost:8080' + self.context.qs_params = {'param_not_in_allowed_params': _target} + new_context, new_data = self.plugin.process(self.context, self.data) + assert not new_context.get_decoration(Context.KEY_TARGET_ENTITYID) - def test_no_idp_hinting(self): - self.context.request = {} - res = self.plugin.process(self.context, data={}) - assert not res[0].internal_data.get('target_entity_id') + def test_issuer_already_set(self): + _pre_selected_target = 'https://local.localhost:8080' + self.context.decorate(Context.KEY_TARGET_ENTITYID, _pre_selected_target) + _target = 'https://localhost:8080' + self.context.qs_params = {'idphint': _target} + new_context, new_data = self.plugin.process(self.context, self.data) + assert new_context.get_decoration(Context.KEY_TARGET_ENTITYID) == _pre_selected_target + assert new_context.get_decoration(Context.KEY_TARGET_ENTITYID) != _target From 027a4219d7ba8143b9de62fe1efc736b845622e6 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 6 Sep 2021 15:19:00 +0300 Subject: [PATCH 273/401] Switch from pystache to chevron as the dep to render mustache templates Signed-off-by: Ivan Kanakarakis --- setup.py | 2 +- src/satosa/micro_services/attribute_generation.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 1f6adcaf9..7b2be2673 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ "gunicorn", "Werkzeug", "click", - "pystache", + "chevron", "cookies-samesite-compat", ], extras_require={ diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index 7c99a8fa7..d96d8e1e1 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -1,5 +1,5 @@ import re -import pystache +from chevron import render as render_mustache from .base import ResponseMicroService from ..util import get_dict_defaults @@ -136,7 +136,7 @@ def _synthesize(self, attributes, requester, provider): for attr_name, fmt in recipes.items(): syn_attributes[attr_name] = [ value - for token in re.split("[;\n]+", pystache.render(fmt, context)) + for token in re.split("[;\n]+", render_mustache(fmt, context)) for value in [token.strip().strip(';')] if value ] From 003881baffcd26ae2e4e3d89ba74184a7175fa4b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 7 Sep 2021 14:58:36 +0300 Subject: [PATCH 274/401] Release version 8.0.0 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 2 +- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ebebf4aed..5b192d1fa 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 7.0.3 +current_version = 8.0.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 812e5a303..47a12d345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,100 @@ # Changelog +## 8.0.0 (2021-08-08) + +This is a breaking release, if you were using the openid_connect frontend. To +keep compatibility: + +1. Install the proxy with `pip install satosa[pyop_mongo]` in order to fetch + the right dependencies. +2. If you were not using the `client_db_path` option then set the new option + `client_db_uri` to the value of `db_uri`. + +- The internal data now hold the authenticating authority as part of the + AuthenticationInformation object + (`satosa.internal::AuthenticationInformation::authority`). +- The Context object now holds a dictionary of query string params + (`context.qs_params`). +- The Context object now holds a dictionary of http headers + (`context.http_headers`). +- The Context object now holds a dictionary of server headers + (`context.server_headers`). +- The Context object now holds the request method (`context.request_method`). +- The Context object now holds the request uri (`context.request_uri`). +- The Context object now holds a dictionary of http headers. +- frontends: the openid_connect frontend has a new configuration option + `signing_key_id` to set the `kid` field on the jwks endpoint. +- frontends: the openid_connect frontend dependency `pyop` has been updated + to work with both Redis and MongoDB. This changed how its dependencies are + set. This is reflected in this package's new extras that can be set to + `pyop_mongo` (to preserve the previous behaviour), or `pyop_redis`. +- frontends: the openid_connect frontend filters out unset claims. +- frontends: the openid_connect frontend has a new option + `extra_id_token_claims` to define in the config per client which extra claims + should be added to the ID Token to also work with those clients. +- frontends: the openid_connect frontend has a new option `client_db_uri` to + specify a database connection string for the client database. If unset, + `client_db_path` will be used to load the clients from a file. + Previously, the option `db_uri` was used to set the client database string. + If you were relying on this behaviour, add the `client_db_uri` option with + the same value as `db_uri`. +- frontends: document the `client_db_path` option for openid_connect +- frontends: the openid_connect frontend has a new configuration option + `id_token_lifetime` to set the lifetime of the ID token in seconds. +- frontends: the saml2 frontend has a new option `enable_metadata_reload` to + expose an endpoint (`//reload-metadata`) that allows external + triggers to reload the frontend's metadata. This setting is disabled by + default. It is up to the user to protect the endpoint if enabled. This + feature requires pysaml2 > 7.0.1 +- backends: the saml2 backend derives the encryption keys based on the + `encryption_keypairs` configuration option, otherwise falling back to + the `key_file` and `cert_file` pair. This is now reflected in the internal + pysaml2 configuration. +- backends: the saml2 backend `sp` property is now of type + `saml2.client::Saml2Client` instead of `saml2.client_base::Base`. This allows + us to call the higer level method + `saml2.client::Saml2Client::prepare_for_negotiated_authenticate` instead of + `saml2.client_base::Base::create_authn_request` to properly behave when + needing to sign the AuthnRequest using the Redirect binding. +- backends: the saml2 backend has a new option `enable_metadata_reload` to + expose an endpoint (`//reload-metadata`) that allows external + triggers to reload the backend's metadata. This setting is disabled by + default. It is up to the user to protect the endpoint if enabled. This + feature requires pysaml2 > 7.0.1 +- backends: new ReflectorBackend to help with frontend debugging easier and + developing quicker. +- backends: the saml2 backend has a new configuration option + `send_requester_id` to specify whether Scoping/RequesterID element should be + part of the AuthnRequest. +- micro-services: new DecideBackendByTargetIssuer micro-service, to select + a target backend based on the target issuer. +- micro-services: new DiscoToTargetIssuer micro-service, to set the discovery + protocol response to be the target issuer. +- micro-services: new IdpHinting micro-service, to detect if an idp-hinting + feature has been requested and set the target entityID. Enabling this + micro-service will result in skipping the discovery service and using the + specified entityID as the IdP to be used. The IdP entityID is expected to be + specified as a query-param value on the authentication request. +- micro-services: new AttributePolicy micro-service, which is able to force + attribute policies for requester by limiting results to a predefined set of + allowed attributes. +- micro-services: the PrimaryIdentifier micro-service has a new option + `replace_subject_id` to specify whether to replace the `subject_id` with the + constructed primary identifier. +- micro-services: PrimaryIdentifier is set only if there is a value. +- micro-services: AddSyntheticAttributes has various small fixes. +- micro-services: ScopeExtractorProcessor can handle string values. +- dependencies: the `pystache` package has been replaced by `chevron`, as + `pystache` seems to be abandoned and will not work with python v3.10 and + `setuptools` v58 or newer. This package is a dependency of the + `satosa.micro_services.attribute_generation.AddSyntheticAttributes` + micro-service. +- tests: MongoDB flags have been updated to cater for deprecated flags. +- docs: updated with information about the newly added micro-services. +- docs: various typo fixes. +- docs: various example configuration fixes. + + ## 7.0.3 (2021-01-21) - dependencies: Set minimum pysaml2 version to v6.5.1 to fix internal XML diff --git a/setup.py b/setup.py index 7b2be2673..44ab5ee74 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='7.0.3', + version='8.0.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 3ef09280ce2ff2efa3b3b13dc538b3b1b4a1917c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 7 Sep 2021 15:11:21 +0300 Subject: [PATCH 275/401] Fix changlog entry date Signed-off-by: Ivan Kanakarakis --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a12d345..acea1c0dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 8.0.0 (2021-08-08) +## 8.0.0 (2021-09-07) This is a breaking release, if you were using the openid_connect frontend. To keep compatibility: From e5bd7b6b98316ea896515735a9ce49da8beddaf8 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 8 Sep 2021 12:10:28 +1200 Subject: [PATCH 276/401] new: examples: add attribute_policy.yaml.example --- .../microservices/attribute_policy.yaml.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 example/plugins/microservices/attribute_policy.yaml.example diff --git a/example/plugins/microservices/attribute_policy.yaml.example b/example/plugins/microservices/attribute_policy.yaml.example new file mode 100644 index 000000000..3a32c78df --- /dev/null +++ b/example/plugins/microservices/attribute_policy.yaml.example @@ -0,0 +1,12 @@ +module: satosa.micro_services.attribute_policy.AttributePolicy +name: AttributePolicy +config: + attribute_policy: + : + allowed: + - mail + - name + - givenname + - surname + + From bf39e0688fa26bd3f6fb52a72ff49311d8c6407f Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Fri, 10 Sep 2021 14:06:50 +1200 Subject: [PATCH 277/401] fix: do not pass extra arg to logging.error SATOSA formats all log messages explicitly before passing them to the logger. Python logging formats messages if it receives extra args in the call, otherwise pass them straight through. This call to logger.error in _run_bound_endpoint was (accidentally) passing an extra argument error.state, causing logging to do another round of formatting on an already formatted message. This is dangerous, as the text of the (already formatted) message may contain externally supplied data - such as the redirect URI with URI-encoded data like %3A#2F (which in best part just throw another exception - "Unknown formatting character A") State is already included in the explicit message formatting, so the extra argument here should be safe to remove. --- src/satosa/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index ab872654a..7468a4ca0 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -185,7 +185,7 @@ def _run_bound_endpoint(self, context, spec): err_id=error.error_id, state=state ) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline, error.state, exc_info=True) + logger.error(logline, exc_info=True) return self._handle_satosa_authentication_error(error) def _load_state(self, context): From e7f281c2418902f3a00bed88b311a670cd938136 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 14 Sep 2021 18:14:58 +0300 Subject: [PATCH 278/401] Allow request micro-services to affect the authn-context-class-ref that the backend will generate Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 17 ++++++++++------- src/satosa/context.py | 1 + 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 8b726edf9..a3aa210d3 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -225,11 +225,11 @@ def disco_query(self, context): ) 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) + def construct_requested_authn_context(self, entity_id, *, target_accr=None): + acr_entry = ( + target_accr + or util.get_dict_defaults(self.acr_mapping or {}, entity_id) + ) if not acr_entry: return None @@ -241,7 +241,9 @@ def construct_requested_authn_context(self, entity_id): authn_context = requested_authn_context( acr_entry['class_ref'], comparison=acr_entry.get( - 'comparison', self.VALUE_ACR_COMPARISON_DEFAULT)) + 'comparison', self.VALUE_ACR_COMPARISON_DEFAULT + ) + ) return authn_context @@ -271,7 +273,8 @@ def authn_request(self, context, entity_id): raise SATOSAAuthenticationError(context.state, "Selected IdP is blacklisted for this backend") kwargs = {} - authn_context = self.construct_requested_authn_context(entity_id) + target_accr = context.state.get(Context.KEY_TARGET_AUTHN_CONTEXT_CLASS_REF) + authn_context = self.construct_requested_authn_context(entity_id, target_accr=target_accr) if authn_context: kwargs["requested_authn_context"] = authn_context if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN): diff --git a/src/satosa/context.py b/src/satosa/context.py index 60f35942b..1cf140586 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -19,6 +19,7 @@ class Context(object): KEY_FORCE_AUTHN = 'force_authn' KEY_MEMORIZED_IDP = 'memorized_idp' KEY_AUTHN_CONTEXT_CLASS_REF = 'authn_context_class_ref' + KEY_TARGET_AUTHN_CONTEXT_CLASS_REF = 'target_authn_context_class_ref' def __init__(self): self._path = None From d409448433fcdf74f6ac0326f67b4484e0382cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Fri, 1 Oct 2021 10:36:35 +0200 Subject: [PATCH 279/401] docs: fix display_name in Apple backend example --- example/plugins/backends/apple_backend.yaml.example | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/example/plugins/backends/apple_backend.yaml.example b/example/plugins/backends/apple_backend.yaml.example index 4426c8cc4..bae8e5673 100644 --- a/example/plugins/backends/apple_backend.yaml.example +++ b/example/plugins/backends/apple_backend.yaml.example @@ -25,5 +25,4 @@ config: - ['Apple Inc.', 'en'] ui_info: display_name: - - lang: en - text: 'Sign in with Apple' + - ['Sign in with Apple', 'en'] From 5a9447e1c4f7bafd0473b029c9860ab1fcaae83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Wed, 6 Oct 2021 11:53:59 +0200 Subject: [PATCH 280/401] fix: correct return variable in github backend convert GitHub id into string to avoid TypeError --- src/satosa/backends/github.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/backends/github.py b/src/satosa/backends/github.py index 1da9dadbe..b04906f56 100644 --- a/src/satosa/backends/github.py +++ b/src/satosa/backends/github.py @@ -108,4 +108,4 @@ def user_information(self, access_token): r = requests.get(url, headers=headers) ret = r.json() ret['id'] = str(ret['id']) - return r.json() + return ret From 731dd496f4de7ce8f8a0f2647c8f74be883cbab5 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 18 Oct 2021 15:17:39 +0300 Subject: [PATCH 281/401] Keep the last authority from the authenticating authority list Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index a3aa210d3..f138648a9 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -404,7 +404,11 @@ def _translate_response(self, response, state): authn_context_ref, authenticating_authorities, authn_instant = next( iter(response.authn_info()), [None, None, None] ) - authenticating_authority = next(iter(authenticating_authorities), None) + authenticating_authority = ( + authenticating_authorities[-1] + if authenticating_authorities + else None + ) auth_info = AuthenticationInformation( auth_class_ref=authn_context_ref, timestamp=authn_instant, From db4c551c6376a64a9d8b6c0ad6032726e1040163 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 1 Nov 2021 23:10:21 +0200 Subject: [PATCH 282/401] Bump minimum pyop version to handle invalid redirect-uris correctly Signed-off-by: Ivan Kanakarakis --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 44ab5ee74..12ffacf72 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ packages=find_packages('src/'), package_dir={'': 'src'}, install_requires=[ - "pyop >= 3.2.0", + "pyop >= 3.3.1", "pysaml2 >= 6.5.1", "pycryptodomex", "requests", From 8a096d52fc146a2cd0d8d2ef70c46d999389ce81 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 2 Nov 2021 00:31:05 +0200 Subject: [PATCH 283/401] Reinitialize state if error occurs while loading state Signed-off-by: Ivan Kanakarakis --- src/satosa/state.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/satosa/state.py b/src/satosa/state.py index 6aaa5154b..7feba1a9e 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -13,6 +13,7 @@ from lzma import LZMACompressor from lzma import LZMADecompressor +from lzma import LZMAError from Cryptodome import Random from Cryptodome.Cipher import AES @@ -186,15 +187,27 @@ def __init__(self, urlstate_data=None, encryption_key=None): raise ValueError("If an 'urlstate_data' is supplied 'encrypt_key' must be specified.") if urlstate_data: - urlstate_data = urlstate_data.encode("utf-8") - urlstate_data = base64.urlsafe_b64decode(urlstate_data) - lzma = LZMADecompressor() - urlstate_data = lzma.decompress(urlstate_data) - urlstate_data = _AESCipher(encryption_key).decrypt(urlstate_data) - lzma = LZMADecompressor() - urlstate_data = lzma.decompress(urlstate_data) - urlstate_data = urlstate_data.decode("UTF-8") - urlstate_data = json.loads(urlstate_data) + try: + urlstate_data_bytes = urlstate_data.encode("utf-8") + urlstate_data_b64decoded = base64.urlsafe_b64decode(urlstate_data_bytes) + lzma = LZMADecompressor() + urlstate_data_decompressed = lzma.decompress(urlstate_data_b64decoded) + urlstate_data_decrypted = _AESCipher(encryption_key).decrypt( + urlstate_data_decompressed + ) + lzma = LZMADecompressor() + urlstate_data_decrypted_decompressed = lzma.decompress(urlstate_data_decrypted) + urlstate_data_obj = json.loads(urlstate_data_decrypted_decompressed) + except Exception as e: + error_context = { + "message": "Failed to load state data. Reinitializing empty state.", + "reason": str(e), + "urlstate_data": urlstate_data, + } + logger.warning(error_context) + urlstate_data = {} + else: + urlstate_data = urlstate_data_obj session_id = ( urlstate_data[_SESSION_ID_KEY] From 87f40822707dd0e7f4fc695b6291ea15ee995ee9 Mon Sep 17 00:00:00 2001 From: Vishal Kadam Date: Fri, 16 Oct 2020 17:53:27 -0400 Subject: [PATCH 284/401] Support for exposing co entity's metadata endpoint --- .gitignore | 1 + example/internal_attributes.yaml.example | 2 +- src/satosa/backends/saml2.py | 3 +- src/satosa/frontends/saml2.py | 52 ++++++++++++++++--- src/satosa/metadata_creation/saml_metadata.py | 2 +- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 6c67df01d..9d8244255 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ **/.DS_Store _build .idea +*.iml *.pyc *.log* diff --git a/example/internal_attributes.yaml.example b/example/internal_attributes.yaml.example index dc8b5fe1f..02e1a131e 100644 --- a/example/internal_attributes.yaml.example +++ b/example/internal_attributes.yaml.example @@ -27,7 +27,7 @@ attributes: orcid: [emails.str] github: [email] openid: [email] - saml: [email, emailAdress, mail] + saml: [email, emailAddress, mail] name: facebook: [name] orcid: [name.credit-name] diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index f138648a9..d50a93fb7 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -448,7 +448,7 @@ def _metadata_endpoint(self, context): :param context: The current context :return: response with metadata """ - msg = "Sending metadata response" + msg = "Sending metadata response for entityId = {}".format(self.sp.config.entityid) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) @@ -488,6 +488,7 @@ def register_endpoints(self): ("^%s$" % parsed_endp.path[1:], self.disco_response)) if self.expose_entityid_endpoint(): + logger.debug("Exposing backend entity endpoint = {}".format(self.sp.config.entityid)) parsed_entity_id = urlparse(self.sp.config.entityid) url_map.append(("^{0}".format(parsed_entity_id.path[1:]), self._metadata_endpoint)) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index cfd43af6c..ee9083b05 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -483,7 +483,7 @@ def _metadata_endpoint(self, context): :param context: The current context :return: response with metadata """ - msg = "Sending metadata response" + msg = "Sending metadata response for entityId = {}".format(self.idp.config.entityid) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) metadata_string = create_metadata_string(None, self.idp.config, 4, None, None, None, None, @@ -523,6 +523,7 @@ def _register_endpoints(self, providers): functools.partial(self.handle_authn_request, binding_in=binding))) if self.expose_entityid_endpoint(): + logger.debug("Exposing frontend entity endpoint = {}".format(self.idp.config.entityid)) parsed_entity_id = urlparse(self.idp.config.entityid) url_map.append(("^{0}".format(parsed_entity_id.path[1:]), self._metadata_endpoint)) @@ -959,7 +960,7 @@ def _add_endpoints_to_config(self, config, co_name, backend_name): return config - def _add_entity_id(self, config, co_name): + def _add_entity_id(self, config, co_name, backend_name): """ Use the CO name to construct the entity ID for the virtual IdP for the CO and add it to the config. Also add it to the @@ -967,22 +968,31 @@ def _add_entity_id(self, config, co_name): The entity ID has the form - {base_entity_id}/{co_name} + {base_entity_id}/{backend_name}/{co_name} :type context: The current context :type config: satosa.satosa_config.SATOSAConfig :type co_name: str + :type backend_name: str :rtype: satosa.satosa_config.SATOSAConfig :param context: :param config: satosa proxy config :param co_name: CO name + :param backend_name: Backend name :return: config with updated entity ID """ base_entity_id = config['entityid'] - co_entity_id = "{}/{}".format(base_entity_id, quote_plus(co_name)) - config['entityid'] = co_entity_id + + replace = [ + ("", quote_plus(backend_name)), + ("", quote_plus(co_name)) + ] + for _replace in replace: + base_entity_id = base_entity_id.replace(_replace[0], _replace[1]) + + config['entityid'] = base_entity_id return config @@ -1035,7 +1045,7 @@ def _co_names_from_config(self): return co_names - def _create_co_virtual_idp(self, context): + def _create_co_virtual_idp(self, context, co_name=None): """ Create a virtual IdP to represent the CO. @@ -1045,7 +1055,7 @@ def _create_co_virtual_idp(self, context): :param context: :return: An idp server """ - co_name = self._get_co_name(context) + co_name = co_name or self._get_co_name(context) context.decorate(self.KEY_CO_NAME, co_name) # Verify that we are configured for this CO. If the CO was not @@ -1068,7 +1078,7 @@ def _create_co_virtual_idp(self, context): idp_config = self._add_endpoints_to_config( idp_config, co_name, backend_name ) - idp_config = self._add_entity_id(idp_config, co_name) + idp_config = self._add_entity_id(idp_config, co_name, backend_name) context.decorate(self.KEY_CO_ENTITY_ID, idp_config['entityid']) # Use the overwritten IdP config to generate a pysaml2 config object @@ -1155,4 +1165,30 @@ def _register_endpoints(self, backend_names): logline = "Adding mapping {}".format(mapping) logger.debug(logline) + if self.expose_entityid_endpoint(): + for backend_name in backend_names: + for co_name in co_names: + idp_config = self._add_entity_id(copy.deepcopy(self.idp_config), co_name, backend_name) + entity_id = idp_config['entityid'] + logger.debug("Exposing frontend entity endpoint = {}".format(entity_id)) + parsed_entity_id = urlparse(entity_id) + metadata_endpoint = "^{0}".format(parsed_entity_id.path[1:]) + the_callable = functools.partial(self._metadata_endpoint, co_name=co_name) + url_to_callable_mappings.append((metadata_endpoint, the_callable)) + return url_to_callable_mappings + + def _metadata_endpoint(self, context, co_name): + """ + Endpoint for retrieving the virtual frontend metadata + :type context: satosa.context.Context + :rtype: satosa.response.Response + + :param context: The current context + :return: response with metadata + """ + # Using the context of the current request and saved state from the + # authentication request dynamically create an IdP instance. + self.idp = self._create_co_virtual_idp(context, co_name=co_name) + return super()._metadata_endpoint(context=context); + diff --git a/src/satosa/metadata_creation/saml_metadata.py b/src/satosa/metadata_creation/saml_metadata.py index f1b294759..895de4b98 100644 --- a/src/satosa/metadata_creation/saml_metadata.py +++ b/src/satosa/metadata_creation/saml_metadata.py @@ -80,7 +80,7 @@ def _create_frontend_metadata(frontend_modules, backend_modules): logger.info(logline) idp_config = copy.deepcopy(frontend.config["idp_config"]) idp_config = frontend._add_endpoints_to_config(idp_config, co_name, backend.name) - idp_config = frontend._add_entity_id(idp_config, co_name) + idp_config = frontend._add_entity_id(idp_config, co_name, backend.name) idp_config = frontend._overlay_for_saml_metadata(idp_config, co_name) entity_desc = _create_entity_descriptor(idp_config) frontend_metadata[frontend.name].append(entity_desc) From 0fc3ef328a600ccf6d6c35bbfb9d2ae947eaea51 Mon Sep 17 00:00:00 2001 From: Vishal Kadam Date: Thu, 3 Jun 2021 12:53:09 -0400 Subject: [PATCH 285/401] Added test cases for expose co-frontend entity endpoints and duplicate entity id fix --- .gitignore | 1 + .../saml2_virtualcofrontend.yaml.example | 7 +- src/satosa/frontends/saml2.py | 21 +++- tests/conftest.py | 2 +- tests/satosa/frontends/test_saml2.py | 116 ++++++++++++------ 5 files changed, 104 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 9d8244255..bb142cef6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ _build build/ dist/ .coverage +venv/ \ No newline at end of file diff --git a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example index 6d9a7b370..e7415c55e 100644 --- a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example +++ b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example @@ -49,7 +49,12 @@ config: metadata: local: [sp.xml] - entityid: //proxy.xml + # Available placeholders to use while constructing entityid, + # : Backend name + # : collaborative_organizations encodeable_name + # : Base url of installation + # : Name of this virtual co-frontend + entityid: //idp/ accepted_time_diff: 60 service: idp: diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index ee9083b05..c744610ed 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -788,6 +788,10 @@ class SAMLVirtualCoFrontend(SAMLFrontend): KEY_ORGANIZATION = 'organization' KEY_ORGANIZATION_KEYS = ['display_name', 'name', 'url'] + def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): + self.has_multiple_backends = False + super().__init__(auth_req_callback_func, internal_attributes, config, base_url, name) + def handle_authn_request(self, context, binding_in): """ See super class @@ -984,6 +988,9 @@ def _add_entity_id(self, config, co_name, backend_name): :return: config with updated entity ID """ base_entity_id = config['entityid'] + # If not using template for entityId and does not has multiple backends, then for backward compatibility append co_name at end + if "" not in base_entity_id and not self.has_multiple_backends: + base_entity_id = "{}/{}".format(base_entity_id, "") replace = [ ("", quote_plus(backend_name)), @@ -1110,10 +1117,22 @@ def _register_endpoints(self, backend_names): :param backend_names: A list of backend names :return: A list of url and endpoint function pairs """ + + # Throw exception if there is possibility of duplicate entity ids when using co_names with multiple backends + self.has_multiple_backends = len(backend_names) > 1 + co_names = self._co_names_from_config() + all_entity_ids = [] + for backend_name in backend_names: + for co_name in co_names: + all_entity_ids.append(self._add_entity_id(copy.deepcopy(self.idp_config), co_name, backend_name)['entityid']) + + if len(all_entity_ids) != len(set(all_entity_ids)): + raise ValueError("Duplicate entities ids would be created for co-frontends, please make sure to make entity ids unique. " + "You can use and to achieve it. See example yaml file.") + # Create a regex pattern that will match any of the CO names. We # escape special characters like '+' and '.' that are valid # characters in an URL encoded string. - co_names = self._co_names_from_config() url_encoded_co_names = [re.escape(quote_plus(name)) for name in co_names] co_name_pattern = "|".join(url_encoded_co_names) diff --git a/tests/conftest.py b/tests/conftest.py index 9e7a5e18f..65b3602b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,7 +64,7 @@ def sp_conf(cert_and_key): @pytest.fixture def idp_conf(cert_and_key): - idp_base = "http://idp.example.com" + idp_base = BASE_URL idpconfig = { "entityid": "{}/{}/proxy.xml".format(idp_base, "Saml2IDP"), diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index 8396a5945..d5eb2af98 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -1,6 +1,7 @@ """ Tests for the SAML frontend module src/frontends/saml2.py. """ +import copy import itertools import re from collections import Counter @@ -28,7 +29,6 @@ from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.state import State -from satosa.context import Context from tests.users import USERS from tests.util import FakeSP, create_metadata_from_config_dict @@ -298,14 +298,14 @@ def test_acr_mapping_per_idp_in_authn_response(self, context, idp_conf, sp_conf, ] ) def test_respect_sp_entity_categories( - self, - context, - entity_category, - entity_category_module, - expected_attributes, - idp_conf, - sp_conf, - internal_response + self, + context, + entity_category, + entity_category_module, + expected_attributes, + idp_conf, + sp_conf, + internal_response ): idp_metadata_str = create_metadata_from_config_dict(idp_conf) idp_conf["service"]["idp"]["policy"]["default"]["entity_categories"] = [entity_category_module] @@ -365,7 +365,7 @@ def test_metadata_endpoint(self, context, idp_conf): assert idp_conf["entityid"] in resp.message def test_custom_attribute_release_with_less_attributes_than_entity_category( - self, context, idp_conf, sp_conf, internal_response + self, context, idp_conf, sp_conf, internal_response ): idp_metadata_str = create_metadata_from_config_dict(idp_conf) idp_conf["service"]["idp"]["policy"]["default"]["entity_categories"] = ["swamid"] @@ -387,8 +387,8 @@ def test_custom_attribute_release_with_less_attributes_than_entity_category( internal_response.requester = sp_conf["entityid"] resp = self.get_auth_response(samlfrontend, context, internal_response, sp_conf, idp_metadata_str) assert len(resp.ava.keys()) == ( - len(expected_attributes) - - len(custom_attributes[internal_response.auth_info.issuer][internal_response.requester]["exclude"]) + len(expected_attributes) + - len(custom_attributes[internal_response.auth_info.issuer][internal_response.requester]["exclude"]) ) @@ -431,6 +431,7 @@ def test_load_idp_dynamic_entity_id(self, idp_conf): class TestSAMLVirtualCoFrontend(TestSAMLFrontend): BACKEND = "test_backend" + BACKEND_1 = "test_backend_1" CO = "MESS" CO_O = "organization" CO_C = "countryname" @@ -442,7 +443,7 @@ class TestSAMLVirtualCoFrontend(TestSAMLFrontend): CO_C: ["US"], CO_CO: ["United States"], CO_NOREDUORGACRONYM: ["MESS"] - } + } KEY_SSO = "single_sign_on_service" @pytest.fixture @@ -471,10 +472,10 @@ def frontend(self, idp_conf, sp_conf): # endpoints, and the collaborative organization configuration to # create the configuration for the frontend. conf = { - "idp_config": idp_conf, - "endpoints": ENDPOINTS, - "collaborative_organizations": [collab_org] - } + "idp_config": idp_conf, + "endpoints": ENDPOINTS, + "collaborative_organizations": [collab_org] + } # Use a richer set of internal attributes than what is provided # for the parent class so that we can test for the static SAML @@ -504,10 +505,13 @@ def context(self, context): that would be available during a SAML flow and that would include a path and target_backend that indicates the CO. """ - context.path = "{}/{}/sso/redirect".format(self.BACKEND, self.CO) - context.target_backend = self.BACKEND + return self._make_context(context, self.BACKEND, self.CO) - return context + def _make_context(self, context, backend, co_name): + _context = copy.deepcopy(context) + _context.path = "{}/{}/sso/redirect".format(backend, co_name) + _context.target_backend = backend + return _context def test_create_state_data(self, frontend, context, idp_conf): frontend._create_co_virtual_idp(context) @@ -542,6 +546,17 @@ def test_create_co_virtual_idp(self, frontend, context, idp_conf): assert idp_server.config.entityid == expected_entityid assert all(sso in sso_endpoints for sso in expected_endpoints) + def test_create_co_virtual_idp_with_entity_id_templates(self, frontend, context): + frontend.idp_config['entityid'] = "{}/Saml2IDP/proxy.xml".format(BASE_URL) + expected_entity_id = "{}/Saml2IDP/proxy.xml/{}".format(BASE_URL, self.CO) + idp_server = frontend._create_co_virtual_idp(context) + assert idp_server.config.entityid == expected_entity_id + + frontend.idp_config['entityid'] = "{}//idp/".format(BASE_URL) + expected_entity_id = "{}/{}/idp/{}".format(BASE_URL, context.target_backend, self.CO) + idp_server = frontend._create_co_virtual_idp(context) + assert idp_server.config.entityid == expected_entity_id + def test_register_endpoints(self, frontend, context): idp_server = frontend._create_co_virtual_idp(context) url_map = frontend.register_endpoints([self.BACKEND]) @@ -553,6 +568,28 @@ def test_register_endpoints(self, frontend, context): for endpoint in all_idp_endpoints: assert any(pat.match(endpoint) for pat in compiled_regex) + def test_register_endpoints_throws_error_in_case_duplicate_entity_ids(self, frontend): + with pytest.raises(ValueError): + frontend.register_endpoints([self.BACKEND, self.BACKEND_1]) + + def test_register_endpoints_with_metadata_endpoints(self, frontend, context): + frontend.idp_config['entityid'] = "{}//idp/".format(BASE_URL) + frontend.config['entityid_endpoint'] = True + idp_server_1 = frontend._create_co_virtual_idp(context) + context_2 = self._make_context(context, self.BACKEND_1, self.CO) + idp_server_2 = frontend._create_co_virtual_idp(context_2) + + url_map = frontend.register_endpoints([self.BACKEND, self.BACKEND_1]) + expected_idp_endpoints = [urlparse(endpoint[0]).path[1:] for server in [idp_server_1, idp_server_2] + for endpoint in server.config._idp_endpoints[self.KEY_SSO]] + for server in [idp_server_1, idp_server_2]: + expected_idp_endpoints.append(urlparse(server.config.entityid).path[1:]) + + compiled_regex = [re.compile(regex) for regex, _ in url_map] + + for endpoint in expected_idp_endpoints: + assert any(pat.match(endpoint) for pat in compiled_regex) + def test_co_static_attributes(self, frontend, context, internal_response, idp_conf, sp_conf): # Use the frontend and context fixtures to dynamically create the @@ -563,9 +600,8 @@ def test_co_static_attributes(self, frontend, context, internal_response, # and then use those to dynamically update the ipd_conf fixture. co_name = frontend._get_co_name(context) backend_name = context.target_backend - idp_conf = frontend._add_endpoints_to_config(idp_conf, co_name, - backend_name) - idp_conf = frontend._add_entity_id(idp_conf, co_name) + idp_conf = frontend._add_endpoints_to_config(idp_conf, co_name, backend_name) + idp_conf = frontend._add_entity_id(idp_conf, co_name, backend_name) # Use a utility function to serialize the idp_conf IdP configuration # fixture to a string and then dynamically update the sp_conf @@ -597,9 +633,9 @@ def test_co_static_attributes(self, frontend, context, internal_response, "name_id_policy": NameIDPolicy(format=NAMEID_FORMAT_TRANSIENT), "in_response_to": None, "destination": sp_config.endpoint( - "assertion_consumer_service", - binding=BINDING_HTTP_REDIRECT - )[0], + "assertion_consumer_service", + binding=BINDING_HTTP_REDIRECT + )[0], "sp_entity_id": sp_conf["entityid"], "binding": BINDING_HTTP_REDIRECT } @@ -616,42 +652,42 @@ def test_co_static_attributes(self, frontend, context, internal_response, class TestSubjectTypeToSamlNameIdFormat: def test_should_default_to_persistent(self): assert ( - subject_type_to_saml_nameid_format("unmatched") - == NAMEID_FORMAT_PERSISTENT + subject_type_to_saml_nameid_format("unmatched") + == NAMEID_FORMAT_PERSISTENT ) def test_should_map_persistent(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_PERSISTENT) - == NAMEID_FORMAT_PERSISTENT + subject_type_to_saml_nameid_format(NAMEID_FORMAT_PERSISTENT) + == NAMEID_FORMAT_PERSISTENT ) def test_should_map_transient(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_TRANSIENT) - == NAMEID_FORMAT_TRANSIENT + subject_type_to_saml_nameid_format(NAMEID_FORMAT_TRANSIENT) + == NAMEID_FORMAT_TRANSIENT ) def test_should_map_emailaddress(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_EMAILADDRESS) - == NAMEID_FORMAT_EMAILADDRESS + subject_type_to_saml_nameid_format(NAMEID_FORMAT_EMAILADDRESS) + == NAMEID_FORMAT_EMAILADDRESS ) def test_should_map_unspecified(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_UNSPECIFIED) - == NAMEID_FORMAT_UNSPECIFIED + subject_type_to_saml_nameid_format(NAMEID_FORMAT_UNSPECIFIED) + == NAMEID_FORMAT_UNSPECIFIED ) def test_should_map_public(self): assert ( - subject_type_to_saml_nameid_format("public") - == NAMEID_FORMAT_PERSISTENT + subject_type_to_saml_nameid_format("public") + == NAMEID_FORMAT_PERSISTENT ) def test_should_map_pairwise(self): assert ( - subject_type_to_saml_nameid_format("pairwise") - == NAMEID_FORMAT_TRANSIENT + subject_type_to_saml_nameid_format("pairwise") + == NAMEID_FORMAT_TRANSIENT ) From c35a20bc21e58c2dfe1bcbab41c9af8dcb39017a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 2 Nov 2021 01:25:13 +0200 Subject: [PATCH 286/401] Fix formatting Signed-off-by: Ivan Kanakarakis --- .gitignore | 2 +- src/satosa/frontends/saml2.py | 1 - tests/conftest.py | 2 +- tests/satosa/frontends/test_saml2.py | 52 +++++++++++++--------------- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index bb142cef6..17b270187 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ _build build/ dist/ .coverage -venv/ \ No newline at end of file +venv/ diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index c744610ed..b481b5d25 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -1210,4 +1210,3 @@ def _metadata_endpoint(self, context, co_name): # authentication request dynamically create an IdP instance. self.idp = self._create_co_virtual_idp(context, co_name=co_name) return super()._metadata_endpoint(context=context); - diff --git a/tests/conftest.py b/tests/conftest.py index 65b3602b1..9e7a5e18f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,7 +64,7 @@ def sp_conf(cert_and_key): @pytest.fixture def idp_conf(cert_and_key): - idp_base = BASE_URL + idp_base = "http://idp.example.com" idpconfig = { "entityid": "{}/{}/proxy.xml".format(idp_base, "Saml2IDP"), diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index d5eb2af98..978489429 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -298,14 +298,14 @@ def test_acr_mapping_per_idp_in_authn_response(self, context, idp_conf, sp_conf, ] ) def test_respect_sp_entity_categories( - self, - context, - entity_category, - entity_category_module, - expected_attributes, - idp_conf, - sp_conf, - internal_response + self, + context, + entity_category, + entity_category_module, + expected_attributes, + idp_conf, + sp_conf, + internal_response ): idp_metadata_str = create_metadata_from_config_dict(idp_conf) idp_conf["service"]["idp"]["policy"]["default"]["entity_categories"] = [entity_category_module] @@ -365,7 +365,7 @@ def test_metadata_endpoint(self, context, idp_conf): assert idp_conf["entityid"] in resp.message def test_custom_attribute_release_with_less_attributes_than_entity_category( - self, context, idp_conf, sp_conf, internal_response + self, context, idp_conf, sp_conf, internal_response ): idp_metadata_str = create_metadata_from_config_dict(idp_conf) idp_conf["service"]["idp"]["policy"]["default"]["entity_categories"] = ["swamid"] @@ -387,8 +387,8 @@ def test_custom_attribute_release_with_less_attributes_than_entity_category( internal_response.requester = sp_conf["entityid"] resp = self.get_auth_response(samlfrontend, context, internal_response, sp_conf, idp_metadata_str) assert len(resp.ava.keys()) == ( - len(expected_attributes) - - len(custom_attributes[internal_response.auth_info.issuer][internal_response.requester]["exclude"]) + len(expected_attributes) + - len(custom_attributes[internal_response.auth_info.issuer][internal_response.requester]["exclude"]) ) @@ -442,7 +442,7 @@ class TestSAMLVirtualCoFrontend(TestSAMLFrontend): CO_O: ["Medium Energy Synchrotron Source"], CO_C: ["US"], CO_CO: ["United States"], - CO_NOREDUORGACRONYM: ["MESS"] + CO_NOREDUORGACRONYM: ["MESS"], } KEY_SSO = "single_sign_on_service" @@ -474,7 +474,7 @@ def frontend(self, idp_conf, sp_conf): conf = { "idp_config": idp_conf, "endpoints": ENDPOINTS, - "collaborative_organizations": [collab_org] + "collaborative_organizations": [collab_org], } # Use a richer set of internal attributes than what is provided @@ -652,42 +652,40 @@ def test_co_static_attributes(self, frontend, context, internal_response, class TestSubjectTypeToSamlNameIdFormat: def test_should_default_to_persistent(self): assert ( - subject_type_to_saml_nameid_format("unmatched") - == NAMEID_FORMAT_PERSISTENT + subject_type_to_saml_nameid_format("unmatched") + == NAMEID_FORMAT_PERSISTENT ) def test_should_map_persistent(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_PERSISTENT) - == NAMEID_FORMAT_PERSISTENT + subject_type_to_saml_nameid_format(NAMEID_FORMAT_PERSISTENT) + == NAMEID_FORMAT_PERSISTENT ) def test_should_map_transient(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_TRANSIENT) - == NAMEID_FORMAT_TRANSIENT + subject_type_to_saml_nameid_format(NAMEID_FORMAT_TRANSIENT) + == NAMEID_FORMAT_TRANSIENT ) def test_should_map_emailaddress(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_EMAILADDRESS) - == NAMEID_FORMAT_EMAILADDRESS + subject_type_to_saml_nameid_format(NAMEID_FORMAT_EMAILADDRESS) + == NAMEID_FORMAT_EMAILADDRESS ) def test_should_map_unspecified(self): assert ( - subject_type_to_saml_nameid_format(NAMEID_FORMAT_UNSPECIFIED) - == NAMEID_FORMAT_UNSPECIFIED + subject_type_to_saml_nameid_format(NAMEID_FORMAT_UNSPECIFIED) + == NAMEID_FORMAT_UNSPECIFIED ) def test_should_map_public(self): assert ( - subject_type_to_saml_nameid_format("public") - == NAMEID_FORMAT_PERSISTENT + subject_type_to_saml_nameid_format("public") == NAMEID_FORMAT_PERSISTENT ) def test_should_map_pairwise(self): assert ( - subject_type_to_saml_nameid_format("pairwise") - == NAMEID_FORMAT_TRANSIENT + subject_type_to_saml_nameid_format("pairwise") == NAMEID_FORMAT_TRANSIENT ) From 065e5da1c669d5d2a3e2be3d4806947ab1b32a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 16 Nov 2021 14:37:20 +0100 Subject: [PATCH 287/401] docs: add name and description to example SAML2 backend these attributes are required to generate valid metadata in combination with required_attributes and/or optional_attributes --- example/plugins/backends/saml2_backend.yaml.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index c132e2345..335da8117 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -18,6 +18,8 @@ config: enable_metadata_reload: no sp_config: + name: "SP Name" + description: "SP Description" key_file: backend.key cert_file: backend.crt organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'} From b361005302c2fd5b9cd1c88d0b2fd447df401dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Fri, 19 Nov 2021 12:21:29 +0100 Subject: [PATCH 288/401] docs: fix disco_srv nesting in README disco_srv needs to be in the top-level config, not under sp_config --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index d058d6ad7..047cda51c 100644 --- a/doc/README.md +++ b/doc/README.md @@ -380,8 +380,8 @@ parameter `disco_srv`, must be specified if the metadata given to the backend mo ```yaml config: + disco_srv: http://disco.example.com sp_config: [...] - disco_srv: http://disco.example.com ``` ##### Mirror the SAML ForceAuthn option From 2fb3d5ad7200a1094998c60e5c3e108a2a25361d Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Sat, 11 Dec 2021 15:56:53 +0100 Subject: [PATCH 289/401] Add option search_filter to ldap This patch adds the option to override the search_filter in ldap with an own complex search_filter, because sometimes a single simple argument is not sufficient. --- .../microservices/ldap_attribute_store.yaml.example | 5 +++++ src/satosa/micro_services/ldap_attribute_store.py | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 4efe85072..77be74e44 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -84,6 +84,11 @@ config: ldap_identifier_attribute: uid + # Override the contructed search_filter with ldap_identifier_attribute + # with an own filter. This allows more komplex queries. + # {0} will be injected with the ordered_identifier_candidates. + search_filter: None + # Whether to clear values for attributes incoming # to this microservice. Default is no or false. clear_input_attributes: no diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 6d61559b1..d5c1f05eb 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -46,6 +46,7 @@ class LdapAttributeStore(ResponseMicroService): "clear_input_attributes": False, "ignore": False, "ldap_identifier_attribute": None, + "search_filter": None, "ldap_url": None, "ldap_to_internal_map": None, "on_ldap_search_result_empty": None, @@ -473,8 +474,11 @@ def process(self, context, data): logger.debug(logline) for filter_val in filter_values: - ldap_ident_attr = config["ldap_identifier_attribute"] - search_filter = "({0}={1})".format(ldap_ident_attr, filter_val) + if config["search_filter"]: + search_filter = config["search_filter"].format(filter_val) + else: + ldap_ident_attr = config["ldap_identifier_attribute"] + search_filter = "({0}={1})".format(ldap_ident_attr, filter_val) msg = { "message": "LDAP query with constructed search filter", "search filter": search_filter, From f0c57fd81f8e2fcfa5ff386fa139460273214264 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Sat, 11 Dec 2021 16:26:25 +0100 Subject: [PATCH 290/401] Select LDAP config by extracted attribute This patch introduces a new global config variable `provider_attribute` to make it possible to select the config not only by entity but also select the config variable by a previous set attribute. This way it is possible to use a single point of authentication, but enrich the information from different ldap server based on e.g. the domain attribute extracted in previous steps. --- .../ldap_attribute_store.yaml.example | 19 +++++++++++++++---- .../micro_services/ldap_attribute_store.py | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 4efe85072..e1085f7bc 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -97,13 +97,23 @@ config: # from LDAP. The default is not to redirect. on_ldap_search_result_empty: https://my.vo.org/please/go/enroll - # The microservice may be configured per entityID. + # The microservice may be configured per entityID or per extracted attribute. # The configuration key is the entityID of the requesting SP, - # the authenticating IdP, or the entityID of the CO virtual IdP. - # When more than one configured entityID matches during a flow - # the priority ordering is requesting SP, then authenticating IdP, then + # the authenticating IdP, the entityID of the CO virtual IdP, or the + # extracted attribute defined by `global.provider_attribute`. + # When more than one configured key matches during a flow + # the priority ordering is provider attribute, requesting SP, then authenticating IdP, then # CO virtual IdP. Αny missing parameters are taken from the # default configuration. + global: + provider_attribute: domain + + # domain attribute is extracted in a previous microserver and used as a key + # here. + company.com: + ldap_url: ldaps://ldap.company.com + search_base: ou=group,dc=identity,dc=company,dc=com + https://sp.myserver.edu/shibboleth-sp: search_base: ou=People,o=MyVO,dc=example,dc=org search_return_attributes: @@ -120,3 +130,4 @@ config: # The microservice may be configured to ignore a particular entityID. https://another.sp.myserver.edu: ignore: true + diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 6d61559b1..89145955a 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -81,9 +81,15 @@ def __init__(self, config, *args, **kwargs): self.config = {} + # Get provider attribute + self.provider_attribute = None + if "global" in config: + if "provider_attribute" in config["global"]: + self.provider_attribute = config["global"]["provider_attribute"] + # Process the default configuration first then any per-SP overrides. sp_list = ["default"] - sp_list.extend([key for key in config.keys() if key != "default"]) + sp_list.extend([key for key in config.keys() if key != "default" and key != "global"]) connections = {} @@ -412,6 +418,14 @@ def process(self, context, data): co_entity_id = state.get(frontend_name, {}).get(co_entity_id_key) entity_ids = [requester, issuer, co_entity_id, "default"] + if self.provider_attribute: + try: + entity_ids.insert( + 0, + data.attributes[self.provider_attribute][0] + ) + except (KeyError, IndexError): + pass config, entity_id = next((self.config.get(e), e) for e in entity_ids if self.config.get(e)) From af4820ce6e81cb5a47cef244c4f3000a1db35555 Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Sat, 11 Dec 2021 16:49:45 +0100 Subject: [PATCH 291/401] Add option to process all ldap results This patch adds an option to not only process the first ldap result, but all of them. This can be useful while trying to enrich the data e.g. with multiple group information. --- .../ldap_attribute_store.yaml.example | 4 + .../micro_services/ldap_attribute_store.py | 135 +++++++++--------- 2 files changed, 73 insertions(+), 66 deletions(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 4efe85072..d5d4d7885 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -93,6 +93,10 @@ config: user_id_from_attrs: - employeeNumber + # If true, do not only process the first ldap result, but iterate over + # the result and process all of them. + use_all_results: false + # Where to redirect the browser if no record is returned # from LDAP. The default is not to redirect. on_ldap_search_result_empty: https://my.vo.org/please/go/enroll diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 6d61559b1..2a4284746 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -526,13 +526,13 @@ def process(self, context, data): # For now consider only the first record found (if any). if len(responses) > 0: - if len(responses) > 1: + if len(responses) > 1 and not config.get("use_all_results", False): msg = "LDAP server returned {} records using search filter" msg = msg + " value {}" msg = msg.format(len(responses), filter_val) logline = lu.LOG_FMT.format(id=session_id, message=msg) logger.warning(logline) - record = responses[0] + responses = responses[0:1] break # Before using a found record, if any, to populate attributes @@ -544,73 +544,76 @@ def process(self, context, data): logger.debug(logline) data.attributes = {} - # This adapts records with different search and connection strategy - # (sync without pool), it should be tested with anonimous bind with - # message_id. - if isinstance(results, bool) and record: - record = { - "dn": record.entry_dn if hasattr(record, "entry_dn") else "", - "attributes": ( - record.entry_attributes_as_dict - if hasattr(record, "entry_attributes_as_dict") - else {} - ), - } - - # Use a found record, if any, to populate attributes and input for - # NameID - if record: - msg = { - "message": "Using record with DN and attributes", - "DN": record["dn"], - "attributes": record["attributes"], - } - logline = lu.LOG_FMT.format(id=session_id, message=msg) - logger.debug(logline) + for record in responses: + # This adapts records with different search and connection strategy + # (sync without pool), it should be tested with anonimous bind with + # message_id. + if isinstance(results, bool) and record: + record = { + "dn": record.entry_dn if hasattr(record, "entry_dn") else "", + "attributes": ( + record.entry_attributes_as_dict + if hasattr(record, "entry_attributes_as_dict") + else {} + ), + } + + # Use a found record, if any, to populate attributes and input for + # NameID + if record: + msg = { + "message": "Using record with DN and attributes", + "DN": record["dn"], + "attributes": record["attributes"], + } + logline = lu.LOG_FMT.format(id=session_id, message=msg) + logger.debug(logline) - # Populate attributes as configured. - new_attrs = self._populate_attributes(config, record) - - overwrite = config["overwrite_existing_attributes"] - for attr, values in new_attrs.items(): - if not overwrite: - values = list(set(data.attributes.get(attr, []) + values)) - data.attributes[attr] = values - - # Populate input for NameID if configured. SATOSA core does the - # hashing of input to create a persistent NameID. - user_ids = self._populate_input_for_name_id(config, record, data) - if user_ids: - data.subject_id = "".join(user_ids) - msg = "NameID value is {}".format(data.subject_id) - logger.debug(msg) + # Populate attributes as configured. + new_attrs = self._populate_attributes(config, record) + + overwrite = config["overwrite_existing_attributes"] + for attr, values in new_attrs.items(): + if not overwrite: + values = list(map(str, set(data.attributes.get(attr, []) + values))) + else: + values = list(map(str, set(values))) + data.attributes[attr] = values + + # Populate input for NameID if configured. SATOSA core does the + # hashing of input to create a persistent NameID. + user_ids = self._populate_input_for_name_id(config, record, data) + if user_ids: + data.subject_id = "".join(user_ids) + msg = "NameID value is {}".format(data.subject_id) + logger.debug(msg) - # Add the record to the context so that later microservices - # may use it if required. - context.decorate(KEY_FOUND_LDAP_RECORD, record) - msg = "Added record {} to context".format(record) - logline = lu.LOG_FMT.format(id=session_id, message=msg) - logger.debug(logline) - else: - msg = "No record found in LDAP so no attributes will be added" - logline = lu.LOG_FMT.format(id=session_id, message=msg) - logger.warning(logline) - on_ldap_search_result_empty = config["on_ldap_search_result_empty"] - if on_ldap_search_result_empty: - # Redirect to the configured URL with - # the entityIDs for the target SP and IdP used by the user - # as query string parameters (URL encoded). - encoded_sp_entity_id = urllib.parse.quote_plus(requester) - encoded_idp_entity_id = urllib.parse.quote_plus(issuer) - url = "{}?sp={}&idp={}".format( - on_ldap_search_result_empty, - encoded_sp_entity_id, - encoded_idp_entity_id, - ) - msg = "Redirecting to {}".format(url) + # Add the record to the context so that later microservices + # may use it if required. + context.decorate(KEY_FOUND_LDAP_RECORD, record) + msg = "Added record {} to context".format(record) logline = lu.LOG_FMT.format(id=session_id, message=msg) - logger.info(logline) - return Redirect(url) + logger.debug(logline) + else: + msg = "No record found in LDAP so no attributes will be added" + logline = lu.LOG_FMT.format(id=session_id, message=msg) + logger.warning(logline) + on_ldap_search_result_empty = config["on_ldap_search_result_empty"] + if on_ldap_search_result_empty: + # Redirect to the configured URL with + # the entityIDs for the target SP and IdP used by the user + # as query string parameters (URL encoded). + encoded_sp_entity_id = urllib.parse.quote_plus(requester) + encoded_idp_entity_id = urllib.parse.quote_plus(issuer) + url = "{}?sp={}&idp={}".format( + on_ldap_search_result_empty, + encoded_sp_entity_id, + encoded_idp_entity_id, + ) + msg = "Redirecting to {}".format(url) + logline = lu.LOG_FMT.format(id=session_id, message=msg) + logger.info(logline) + return Redirect(url) msg = "Returning data.attributes {}".format(data.attributes) logline = lu.LOG_FMT.format(id=session_id, message=msg) From 1a408439a6b8855346e5ca2c645dee6ab1ce8c0a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 22 Feb 2022 15:40:33 +0200 Subject: [PATCH 292/401] Release version 8.0.1 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 12 ++++++++++++ setup.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5b192d1fa..621e3e9a0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.0.0 +current_version = 8.0.1 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index acea1c0dd..bbdade9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 8.0.1 (2022-02-22) + +- Reinitialize state if error occurs while loading state +- VirtualCoFrontend: Expose metadata endpoint and fix duplicate entity ids with multiple backends +- saml-backend: Allow request micro-services to affect the authn-context-class-ref +- saml-backend: Keep the last authority from the authenticating authority list +- minor fixes to the Apple and GitHub backends +- micro_services: example config for attribute_policy +- deps: bump minimum pyop version to 3.3.1 +- docs: fixes for example files and config options + + ## 8.0.0 (2021-09-07) This is a breaking release, if you were using the openid_connect frontend. To diff --git a/setup.py b/setup.py index 12ffacf72..175b97b29 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.0.0', + version='8.0.1', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 52dce96454fd83f359ad8524c524e5d261e44dca Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Tue, 22 Feb 2022 16:14:14 +0100 Subject: [PATCH 293/401] Update example like suggested in the Pull Request --- .../plugins/microservices/ldap_attribute_store.yaml.example | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 77be74e44..033737924 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -85,8 +85,10 @@ config: ldap_identifier_attribute: uid # Override the contructed search_filter with ldap_identifier_attribute - # with an own filter. This allows more komplex queries. + # with an own filter. This allows more complex queries. # {0} will be injected with the ordered_identifier_candidates. + # For example: + # search_filter: "(&(uid={0})(isMemberOf=authorized))" search_filter: None # Whether to clear values for attributes incoming From c96990027c12268cb5e2b82e8c4204f65c0ccc0f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 12 Apr 2022 21:46:35 +0300 Subject: [PATCH 294/401] Orcid family-name is optional Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/orcid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/backends/orcid.py b/src/satosa/backends/orcid.py index aaa18b7e5..6ad69fe96 100644 --- a/src/satosa/backends/orcid.py +++ b/src/satosa/backends/orcid.py @@ -99,6 +99,6 @@ def user_information(self, access_token, orcid, name): mail=' '.join([e['email'] for e in emails]), name=name, givenname=r['name']['given-names']['value'], - surname=r['name']['family-name']['value'], + surname=(r['name']['family-name'] or {}).get('value'), ) return ret From 250a6e72be2ee6aa593f29f2794f313b1ea2346d Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 13 May 2021 14:00:57 +0200 Subject: [PATCH 295/401] OIDC frontend: support Redis and session expiration Support all storage backends from recent pyop. Add automatic expiration TTL for the different collections so that the session databases does not grow without bounds. The default TTL values were copied from pyop's current defaults. TODO: add pyop version requirement once there is an official release. Signed-off-by: Ivan Kanakarakis --- doc/README.md | 8 ++--- src/satosa/frontends/openid_connect.py | 41 +++++++++++++++++++++----- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/doc/README.md b/doc/README.md index 047cda51c..f4b907ec7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -459,14 +459,14 @@ Connect Relying Parties (RPs). The default configuration file can be found [here](../example/plugins/frontends/openid_connect_frontend.yaml.example). As opposed to the other plugins, this plugin is NOT stateless (due to the nature of OpenID Connect using any other -flow than "Implicit Flow"). However, the frontend supports using a MongoDB instance as its backend storage, so as long +flow than "Implicit Flow"). However, the frontend supports using a MongoDB or Redis instance as its backend storage, so as long that's reachable from all machines it should not be a problem. The configuration parameters available: * `signing_key_path`: path to a RSA Private Key file (PKCS#1). MUST be configured. -* `db_uri`: connection URI to MongoDB instance where the data will be persisted, if it's not specified all data will only +* `db_uri`: connection URI to MongoDB or Redis instance where the data will be persisted, if it's not specified all data will only be stored in-memory (not suitable for production use). -* `client_db_uri`: connection URI to MongoDB instance where the client data will be persistent, if it's not specified the clients list will be received from the `client_db_path`. +* `client_db_uri`: connection URI to MongoDB or Redis instance where the client data will be persistent, if it's not specified the clients list will be received from the `client_db_path`. * `client_db_path`: path to a file containing the client database in json format. It will only be used if `client_db_uri` is not set. If `client_db_uri` and `client_db_path` are not set, clients will only be stored in-memory (not suitable for production use). * `sub_hash_salt`: salt which is hashed into the `sub` claim. If it's not specified, SATOSA will generate a random salt on each startup, which means that users will get new `sub` value after every restart. * `provider`: provider configuration information. MUST be configured, the following configuration are supported: @@ -474,7 +474,7 @@ The configuration parameters available: * `subject_types_supported` (default: `[pairwise]`): list of all supported subject identifier types, see [Section 8 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes) * `scopes_supported` (default: `[openid]`): list of all supported scopes, see [Section 5.4 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) * `client_registration_supported` (default: `No`): boolean whether [dynamic client registration is supported](https://openid.net/specs/openid-connect-registration-1_0.html). - If dynamic client registration is not supported all clients must exist in the MongoDB instance configured by the `db_uri` in the `"clients"` collection of the `"satosa"` database. + If dynamic client registration is not supported all clients must exist in the MongoDB or Redis instance configured by the `db_uri` in the `"clients"` collection of the `"satosa"` database. The registration info must be stored using the client id as a key, and use the parameter names of a [OIDC Registration Response](https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse). * `authorization_code_lifetime`: how long authorization codes should be valid, see [default](https://github.com/IdentityPython/pyop#token-lifetimes) * `access_token_lifetime`: how long access tokens should be valid, see [default](https://github.com/IdentityPython/pyop#token-lifetimes) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 8bd1319f7..af96a8215 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -16,7 +16,7 @@ from pyop.exceptions import (InvalidAuthenticationRequest, InvalidClientRegistrationRequest, InvalidClientAuthentication, OAuthError, BearerTokenError, InvalidAccessToken) from pyop.provider import Provider -from pyop.storage import MongoWrapper +from pyop.storage import StorageBase from pyop.subject_identifier import HashBasedSubjectIdentifierFactory from pyop.userinfo import Userinfo from pyop.util import should_fragment_encode @@ -81,13 +81,22 @@ def _create_provider(self, endpoint_baseurl): client_db_uri = self.config.get("client_db_uri") cdb_file = self.config.get("client_db_path") if client_db_uri: - cdb = MongoWrapper(client_db_uri, "satosa", "clients") + cdb = StorageBase.from_uri( + client_db_uri, db_name="satosa", collection="clients" + ) elif cdb_file: with open(cdb_file) as f: cdb = json.loads(f.read()) else: cdb = {} - self.user_db = MongoWrapper(db_uri, "satosa", "authz_codes") if db_uri else {} + + #XXX What is the correct ttl for user_db? Is it the same as authz_code_db? + self.user_db = ( + StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes") + if db_uri + else {} + ) + self.provider = Provider( self.signing_key, capabilities, @@ -102,10 +111,28 @@ def _init_authorization_state(self): sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16)) db_uri = self.config.get("db_uri") if db_uri: - authz_code_db = MongoWrapper(db_uri, "satosa", "authz_codes") - access_token_db = MongoWrapper(db_uri, "satosa", "access_tokens") - refresh_token_db = MongoWrapper(db_uri, "satosa", "refresh_tokens") - sub_db = MongoWrapper(db_uri, "satosa", "subject_identifiers") + authz_code_db = StorageBase.from_uri( + db_uri, + db_name="satosa", + collection="authz_codes", + ttl=self.config["provider"].get("authorization_code_lifetime", 600), + ) + access_token_db = StorageBase.from_uri( + db_uri, + db_name="satosa", + collection="access_tokens", + ttl=self.config["provider"].get("access_token_lifetime", 3600), + ) + refresh_token_db = StorageBase.from_uri( + db_uri, + db_name="satosa", + collection="refresh_tokens", + ttl=self.config["provider"].get("refresh_token_lifetime", None), + ) + #XXX what is the correct TTL for sub_db? + sub_db = StorageBase.from_uri( + db_uri, db_name="satosa", collection="subject_identifiers" + ) else: authz_code_db = None access_token_db = None From 3c8d95f9c21949008a9a4e93fa1cb324f813a3a6 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 18 Apr 2022 16:31:48 +0300 Subject: [PATCH 296/401] Restructure initialization Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/openid_connect.py | 281 +++++++++++++++---------- 1 file changed, 167 insertions(+), 114 deletions(-) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index af96a8215..f144b43e2 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -40,49 +40,35 @@ class OpenIDConnectFrontend(FrontendModule): """ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, name): - self._validate_config(conf) + _validate_config(conf) super().__init__(auth_req_callback_func, internal_attributes, base_url, name) self.config = conf - self.signing_key = RSAKey(key=rsa_load(conf["signing_key_path"]), use="sig", alg="RS256", - kid=conf.get("signing_key_id", "")) - - def _create_provider(self, endpoint_baseurl): - response_types_supported = self.config["provider"].get("response_types_supported", ["id_token"]) - subject_types_supported = self.config["provider"].get("subject_types_supported", ["pairwise"]) - scopes_supported = self.config["provider"].get("scopes_supported", ["openid"]) - extra_scopes = self.config["provider"].get("extra_scopes") - capabilities = { - "issuer": self.base_url, - "authorization_endpoint": "{}/{}".format(endpoint_baseurl, AuthorizationEndpoint.url), - "jwks_uri": "{}/jwks".format(endpoint_baseurl), - "response_types_supported": response_types_supported, - "id_token_signing_alg_values_supported": [self.signing_key.alg], - "response_modes_supported": ["fragment", "query"], - "subject_types_supported": subject_types_supported, - "claim_types_supported": ["normal"], - "claims_parameter_supported": True, - "claims_supported": [attribute_map["openid"][0] - for attribute_map in self.internal_attributes["attributes"].values() - if "openid" in attribute_map], - "request_parameter_supported": False, - "request_uri_parameter_supported": False, - "scopes_supported": scopes_supported - } - - if 'code' in response_types_supported: - capabilities["token_endpoint"] = "{}/{}".format(endpoint_baseurl, TokenEndpoint.url) - - if self.config["provider"].get("client_registration_supported", False): - capabilities["registration_endpoint"] = "{}/{}".format(endpoint_baseurl, RegistrationEndpoint.url) - - authz_state = self._init_authorization_state() + provider_config = self.config["provider"] + provider_config["issuer"] = base_url + + self.signing_key = RSAKey( + key=rsa_load(self.config["signing_key_path"]), + use="sig", + alg="RS256", + kid=self.config.get("signing_key_id", ""), + ) + db_uri = self.config.get("db_uri") + self.user_db = ( + StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes") + if db_uri + else {} + ) + + sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16)) + authz_state = _init_authorization_state(provider_config, db_uri, sub_hash_salt) + client_db_uri = self.config.get("client_db_uri") cdb_file = self.config.get("client_db_path") if client_db_uri: cdb = StorageBase.from_uri( - client_db_uri, db_name="satosa", collection="clients" + client_db_uri, db_name="satosa", collection="clients", ttl=None ) elif cdb_file: with open(cdb_file) as f: @@ -90,63 +76,17 @@ def _create_provider(self, endpoint_baseurl): else: cdb = {} - #XXX What is the correct ttl for user_db? Is it the same as authz_code_db? - self.user_db = ( - StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes") - if db_uri - else {} - ) - - self.provider = Provider( + self.endpoint_baseurl = "{}/{}".format(self.base_url, self.name) + self.provider = _create_provider( + provider_config, + self.endpoint_baseurl, + self.internal_attributes, self.signing_key, - capabilities, authz_state, + self.user_db, cdb, - Userinfo(self.user_db), - extra_scopes=extra_scopes, - id_token_lifetime=self.config["provider"].get("id_token_lifetime", 3600), ) - def _init_authorization_state(self): - sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16)) - db_uri = self.config.get("db_uri") - if db_uri: - authz_code_db = StorageBase.from_uri( - db_uri, - db_name="satosa", - collection="authz_codes", - ttl=self.config["provider"].get("authorization_code_lifetime", 600), - ) - access_token_db = StorageBase.from_uri( - db_uri, - db_name="satosa", - collection="access_tokens", - ttl=self.config["provider"].get("access_token_lifetime", 3600), - ) - refresh_token_db = StorageBase.from_uri( - db_uri, - db_name="satosa", - collection="refresh_tokens", - ttl=self.config["provider"].get("refresh_token_lifetime", None), - ) - #XXX what is the correct TTL for sub_db? - sub_db = StorageBase.from_uri( - db_uri, db_name="satosa", collection="subject_identifiers" - ) - else: - authz_code_db = None - access_token_db = None - refresh_token_db = None - sub_db = None - - token_lifetimes = {k: self.config["provider"][k] for k in ["authorization_code_lifetime", - "access_token_lifetime", - "refresh_token_lifetime", - "refresh_token_threshold"] - if k in self.config["provider"]} - return AuthorizationState(HashBasedSubjectIdentifierFactory(sub_hash_salt), authz_code_db, access_token_db, - refresh_token_db, sub_db, **token_lifetimes) - def _get_extra_id_token_claims(self, user_id, client_id): if "extra_id_token_claims" in self.config["provider"]: config = self.config["provider"]["extra_id_token_claims"].get(client_id, []) @@ -223,9 +163,6 @@ def register_endpoints(self, backend_names): else: backend_name = backend_names[0] - endpoint_baseurl = "{}/{}".format(self.base_url, self.name) - self._create_provider(endpoint_baseurl) - provider_config = ("^.well-known/openid-configuration$", self.provider_config) jwks_uri = ("^{}/jwks$".format(self.name), self.jwks) @@ -236,42 +173,36 @@ def register_endpoints(self, backend_names): auth_path = urlparse(auth_endpoint).path.lstrip("/") else: auth_path = "{}/{}".format(self.name, AuthorizationEndpoint.url) + authentication = ("^{}$".format(auth_path), self.handle_authn_request) url_map = [provider_config, jwks_uri, authentication] if any("code" in v for v in self.provider.configuration_information["response_types_supported"]): - self.provider.configuration_information["token_endpoint"] = "{}/{}".format(endpoint_baseurl, - TokenEndpoint.url) - token_endpoint = ("^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint) + self.provider.configuration_information["token_endpoint"] = "{}/{}".format( + self.endpoint_baseurl, TokenEndpoint.url + ) + token_endpoint = ( + "^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint + ) url_map.append(token_endpoint) - self.provider.configuration_information["userinfo_endpoint"] = "{}/{}".format(endpoint_baseurl, - UserinfoEndpoint.url) - userinfo_endpoint = ("^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint) + self.provider.configuration_information["userinfo_endpoint"] = ( + "{}/{}".format(self.endpoint_baseurl, UserinfoEndpoint.url) + ) + userinfo_endpoint = ( + "^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint + ) url_map.append(userinfo_endpoint) + if "registration_endpoint" in self.provider.configuration_information: - client_registration = ("^{}/{}".format(self.name, RegistrationEndpoint.url), self.client_registration) + client_registration = ( + "^{}/{}".format(self.name, RegistrationEndpoint.url), + self.client_registration, + ) url_map.append(client_registration) return url_map - def _validate_config(self, config): - """ - Validates that all necessary config parameters are specified. - :type config: dict[str, dict[str, Any] | str] - :param config: the module config - """ - if config is None: - raise ValueError("OIDCFrontend conf can't be 'None'.") - - for k in {"signing_key_path", "provider"}: - if k not in config: - raise ValueError("Missing configuration parameter '{}' for OpenID Connect frontend.".format(k)) - - if "signing_key_id" in config and type(config["signing_key_id"]) is not str: - raise ValueError( - "The configuration parameter 'signing_key_id' is not defined as a string for OpenID Connect frontend.") - def _get_authn_request_from_state(self, state): """ Extract the clietns request stoed in the SATOSA state. @@ -438,6 +369,128 @@ def userinfo_endpoint(self, context): return response +def _validate_config(config): + """ + Validates that all necessary config parameters are specified. + :type config: dict[str, dict[str, Any] | str] + :param config: the module config + """ + if config is None: + raise ValueError("OIDCFrontend configuration can't be 'None'.") + + for k in {"signing_key_path", "provider"}: + if k not in config: + raise ValueError("Missing configuration parameter '{}' for OpenID Connect frontend.".format(k)) + + if "signing_key_id" in config and type(config["signing_key_id"]) is not str: + raise ValueError( + "The configuration parameter 'signing_key_id' is not defined as a string for OpenID Connect frontend.") + + +def _create_provider( + provider_config, + endpoint_baseurl, + internal_attributes, + signing_key, + authz_state, + user_db, + cdb, +): + response_types_supported = provider_config.get("response_types_supported", ["id_token"]) + subject_types_supported = provider_config.get("subject_types_supported", ["pairwise"]) + scopes_supported = provider_config.get("scopes_supported", ["openid"]) + extra_scopes = provider_config.get("extra_scopes") + capabilities = { + "issuer": provider_config["issuer"], + "authorization_endpoint": "{}/{}".format(endpoint_baseurl, AuthorizationEndpoint.url), + "jwks_uri": "{}/jwks".format(endpoint_baseurl), + "response_types_supported": response_types_supported, + "id_token_signing_alg_values_supported": [signing_key.alg], + "response_modes_supported": ["fragment", "query"], + "subject_types_supported": subject_types_supported, + "claim_types_supported": ["normal"], + "claims_parameter_supported": True, + "claims_supported": [ + attribute_map["openid"][0] + for attribute_map in internal_attributes["attributes"].values() + if "openid" in attribute_map + ], + "request_parameter_supported": False, + "request_uri_parameter_supported": False, + "scopes_supported": scopes_supported + } + + if 'code' in response_types_supported: + capabilities["token_endpoint"] = "{}/{}".format( + endpoint_baseurl, TokenEndpoint.url + ) + + if provider_config.get("client_registration_supported", False): + capabilities["registration_endpoint"] = "{}/{}".format( + endpoint_baseurl, RegistrationEndpoint.url + ) + + provider = Provider( + signing_key, + capabilities, + authz_state, + cdb, + Userinfo(user_db), + extra_scopes=extra_scopes, + id_token_lifetime=provider_config.get("id_token_lifetime", 3600), + ) + return provider + + +def _init_authorization_state(provider_config, db_uri, sub_hash_salt): + if db_uri: + authz_code_db = StorageBase.from_uri( + db_uri, + db_name="satosa", + collection="authz_codes", + ttl=provider_config.get("authorization_code_lifetime", 600), + ) + access_token_db = StorageBase.from_uri( + db_uri, + db_name="satosa", + collection="access_tokens", + ttl=provider_config.get("access_token_lifetime", 3600), + ) + refresh_token_db = StorageBase.from_uri( + db_uri, + db_name="satosa", + collection="refresh_tokens", + ttl=provider_config.get("refresh_token_lifetime", None), + ) + sub_db = StorageBase.from_uri( + db_uri, db_name="satosa", collection="subject_identifiers", ttl=None + ) + else: + authz_code_db = None + access_token_db = None + refresh_token_db = None + sub_db = None + + token_lifetimes = { + k: provider_config[k] + for k in [ + "authorization_code_lifetime", + "access_token_lifetime", + "refresh_token_lifetime", + "refresh_token_threshold", + ] + if k in provider_config + } + return AuthorizationState( + HashBasedSubjectIdentifierFactory(sub_hash_salt), + authz_code_db, + access_token_db, + refresh_token_db, + sub_db, + **token_lifetimes, + ) + + def combine_return_input(values): return values From bece3e259eaee34b9cd926bfd73f0f62d1718753 Mon Sep 17 00:00:00 2001 From: Ali Haider Date: Wed, 22 Dec 2021 07:45:21 +0100 Subject: [PATCH 297/401] Support stateless code flow Signed-off-by: Ivan Kanakarakis --- .../openid_connect_frontend.yaml.example | 30 ++- src/satosa/frontends/openid_connect.py | 28 ++- tests/flows/test_oidc-saml.py | 171 ++++++++++++++++++ 3 files changed, 220 insertions(+), 9 deletions(-) diff --git a/example/plugins/frontends/openid_connect_frontend.yaml.example b/example/plugins/frontends/openid_connect_frontend.yaml.example index 6c74b2d4c..d7a5584d8 100644 --- a/example/plugins/frontends/openid_connect_frontend.yaml.example +++ b/example/plugins/frontends/openid_connect_frontend.yaml.example @@ -3,9 +3,35 @@ name: OIDC config: signing_key_path: frontend.key signing_key_id: frontend.key1 - db_uri: mongodb://db.example.com # optional: only support MongoDB, will default to in-memory storage if not specified + + # Defines the database connection URI for the databases: + # - authz_code_db + # - access_token_db + # - refresh_token_db + # - sub_db + # - user_db + # + # supported storage backends: + # - In-memory dictionary + # - MongoDB (e.g. mongodb://db.example.com) + # - Redis (e.g. redis://example/0) + # - Stateless (eg. stateless://user:encryptionkey?alg=aes256) + # + # This configuration is optional. + # By default, the in-memory storage is used. + db_uri: mongodb://db.example.com + + # Where to store clients. + # + # If client_db_uri is set, the database connection is used. + # Otherwise, if client_db_path is set, the JSON file is used. + # By default, an in-memory dictionary is used. + client_db_uri: mongodb://db.example.com client_db_path: /path/to/your/cdb.json - sub_hash_salt: randomSALTvalue # if not specified, it is randomly generated on every startup + + # if not specified, it is randomly generated on every startup + sub_hash_salt: randomSALTvalue + provider: client_registration_supported: Yes response_types_supported: ["code", "id_token token"] diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index f144b43e2..d4069aec0 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -1,20 +1,32 @@ """ A OpenID Connect frontend module for the satosa proxy """ + import json import logging from collections import defaultdict from urllib.parse import urlencode, urlparse from jwkest.jwk import rsa_load, RSAKey + from oic.oic import scope2claims -from oic.oic.message import (AuthorizationRequest, AuthorizationErrorResponse, TokenErrorResponse, - UserInfoErrorResponse) -from oic.oic.provider import RegistrationEndpoint, AuthorizationEndpoint, TokenEndpoint, UserinfoEndpoint +from oic.oic.message import AuthorizationRequest +from oic.oic.message import AuthorizationErrorResponse +from oic.oic.message import TokenErrorResponse +from oic.oic.message import UserInfoErrorResponse +from oic.oic.provider import RegistrationEndpoint +from oic.oic.provider import AuthorizationEndpoint +from oic.oic.provider import TokenEndpoint +from oic.oic.provider import UserinfoEndpoint + from pyop.access_token import AccessToken from pyop.authz_state import AuthorizationState -from pyop.exceptions import (InvalidAuthenticationRequest, InvalidClientRegistrationRequest, - InvalidClientAuthentication, OAuthError, BearerTokenError, InvalidAccessToken) +from pyop.exceptions import InvalidAuthenticationRequest +from pyop.exceptions import InvalidClientRegistrationRequest +from pyop.exceptions import InvalidClientAuthentication +from pyop.exceptions import OAuthError +from pyop.exceptions import BearerTokenError +from pyop.exceptions import InvalidAccessToken from pyop.provider import Provider from pyop.storage import StorageBase from pyop.subject_identifier import HashBasedSubjectIdentifierFactory @@ -57,7 +69,7 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, db_uri = self.config.get("db_uri") self.user_db = ( StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes") - if db_uri + if db_uri and not StorageBase.type(db_uri) == "stateless" else {} ) @@ -108,7 +120,9 @@ def handle_authn_response(self, context, internal_resp): claims = self.converter.from_internal("openid", internal_resp.attributes) # Filter unset claims claims = {k: v for k, v in claims.items() if v} - self.user_db[internal_resp.subject_id] = dict(combine_claim_values(claims.items())) + self.user_db[internal_resp.subject_id] = dict( + combine_claim_values(claims.items()) + ) auth_resp = self.provider.authorize( auth_req, internal_resp.subject_id, diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index c70ba5c8b..257a8f7c9 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -1,4 +1,6 @@ +import os import json +import base64 from urllib.parse import urlparse, urlencode, parse_qsl import pytest @@ -20,8 +22,27 @@ CLIENT_ID = "client1" +CLIENT_SECRET = "secret" +CLIENT_REDIRECT_URI = "https://client.example.com/cb" REDIRECT_URI = "https://client.example.com/cb" +@pytest.fixture(scope="session") +def client_db_path(tmpdir_factory): + tmpdir = str(tmpdir_factory.getbasetemp()) + path = os.path.join(tmpdir, "cdb.json") + cdb_json = { + CLIENT_ID: { + "response_types": ["id_token", "code"], + "redirect_uris": [ + CLIENT_REDIRECT_URI + ], + "client_secret": CLIENT_SECRET + } + } + with open(path, "w") as f: + f.write(json.dumps(cdb_json)) + + return path @pytest.fixture def oidc_frontend_config(signing_key_path, mongodb_instance): @@ -47,6 +68,25 @@ def oidc_frontend_config(signing_key_path, mongodb_instance): return data +@pytest.fixture +def oidc_stateless_frontend_config(signing_key_path, client_db_path): + data = { + "module": "satosa.frontends.openid_connect.OpenIDConnectFrontend", + "name": "OIDCFrontend", + "config": { + "issuer": "https://proxy-op.example.com", + "signing_key_path": signing_key_path, + "client_db_path": client_db_path, + "db_uri": "stateless://user:abc123@localhost", + "provider": { + "response_types_supported": ["id_token", "code"] + } + } + } + + return data + + class TestOIDCToSAML: def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_config, idp_conf): subject_id = "testuser1" @@ -105,3 +145,134 @@ def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_ (name, values) in id_token_claims.items() for name, values in OIDC_USERS[subject_id].items() ) + + def test_full_stateless_id_token_flow(self, satosa_config_dict, oidc_stateless_frontend_config, saml_backend_config, idp_conf): + subject_id = "testuser1" + + # proxy config + satosa_config_dict["FRONTEND_MODULES"] = [oidc_stateless_frontend_config] + satosa_config_dict["BACKEND_MODULES"] = [saml_backend_config] + satosa_config_dict["INTERNAL_ATTRIBUTES"]["attributes"] = {attr_name: {"openid": [attr_name], + "saml": [attr_name]} + for attr_name in USERS[subject_id]} + _, backend_metadata = create_entity_descriptors(SATOSAConfig(satosa_config_dict)) + + # application + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) + + # get frontend OP config info + provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8")) + + # create auth req + claims_request = ClaimsRequest(id_token=Claims(**{k: None for k in USERS[subject_id]})) + req_args = {"scope": "openid", "response_type": "id_token", "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, "nonce": "nonce", + "claims": claims_request.to_json()} + auth_req = urlparse(provider_config["authorization_endpoint"]).path + "?" + urlencode(req_args) + + # make auth req to proxy + proxied_auth_req = test_client.get(auth_req) + assert proxied_auth_req.status == "303 See Other" + + # config test IdP + backend_metadata_str = str(backend_metadata[saml_backend_config["name"]][0]) + idp_conf["metadata"]["inline"].append(backend_metadata_str) + fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) + + # create auth resp + req_params = dict(parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query)) + url, authn_resp = fakeidp.handle_auth_req( + req_params["SAMLRequest"], + req_params["RelayState"], + BINDING_HTTP_REDIRECT, + subject_id, + response_binding=BINDING_HTTP_REDIRECT) + + # make auth resp to proxy + authn_resp_req = urlparse(url).path + "?" + urlencode(authn_resp) + authn_resp = test_client.get(authn_resp_req) + assert authn_resp.status == "303 See Other" + + # verify auth resp from proxy + resp_dict = dict(parse_qsl(urlparse(authn_resp.data.decode("utf-8")).fragment)) + signing_key = RSAKey(key=rsa_load(oidc_stateless_frontend_config["config"]["signing_key_path"]), + use="sig", alg="RS256") + id_token_claims = JWS().verify_compact(resp_dict["id_token"], keys=[signing_key]) + + assert all( + (name, values) in id_token_claims.items() + for name, values in OIDC_USERS[subject_id].items() + ) + + def test_full_stateless_code_flow(self, satosa_config_dict, oidc_stateless_frontend_config, saml_backend_config, idp_conf): + subject_id = "testuser1" + + # proxy config + satosa_config_dict["FRONTEND_MODULES"] = [oidc_stateless_frontend_config] + satosa_config_dict["BACKEND_MODULES"] = [saml_backend_config] + satosa_config_dict["INTERNAL_ATTRIBUTES"]["attributes"] = {attr_name: {"openid": [attr_name], + "saml": [attr_name]} + for attr_name in USERS[subject_id]} + _, backend_metadata = create_entity_descriptors(SATOSAConfig(satosa_config_dict)) + + # application + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response) + + # get frontend OP config info + provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8")) + + # create auth req + claims_request = ClaimsRequest(id_token=Claims(**{k: None for k in USERS[subject_id]})) + req_args = {"scope": "openid", "response_type": "code", "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, "nonce": "nonce", + "claims": claims_request.to_json()} + auth_req = urlparse(provider_config["authorization_endpoint"]).path + "?" + urlencode(req_args) + + # make auth req to proxy + proxied_auth_req = test_client.get(auth_req) + assert proxied_auth_req.status == "303 See Other" + + # config test IdP + backend_metadata_str = str(backend_metadata[saml_backend_config["name"]][0]) + idp_conf["metadata"]["inline"].append(backend_metadata_str) + fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) + + # create auth resp + req_params = dict(parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query)) + url, authn_resp = fakeidp.handle_auth_req( + req_params["SAMLRequest"], + req_params["RelayState"], + BINDING_HTTP_REDIRECT, + subject_id, + response_binding=BINDING_HTTP_REDIRECT) + + # make auth resp to proxy + authn_resp_req = urlparse(url).path + "?" + urlencode(authn_resp) + authn_resp = test_client.get(authn_resp_req) + assert authn_resp.status == "303 See Other" + + resp_dict = dict(parse_qsl(urlparse(authn_resp.data.decode("utf-8")).query)) + code = resp_dict["code"] + client_id_secret_str = CLIENT_ID + ":" + CLIENT_SECRET + auth_header = "Basic %s" % base64.b64encode(client_id_secret_str.encode()).decode() + + authn_resp = test_client.post(provider_config["token_endpoint"], + data={ + "code": code, + "grant_type": "authorization_code", + "redirect_uri": CLIENT_REDIRECT_URI + }, + headers={'Authorization': auth_header}) + + assert authn_resp.status == "200 OK" + + # verify auth resp from proxy + resp_dict = json.loads(authn_resp.data.decode("utf-8")) + signing_key = RSAKey(key=rsa_load(oidc_stateless_frontend_config["config"]["signing_key_path"]), + use="sig", alg="RS256") + id_token_claims = JWS().verify_compact(resp_dict["id_token"], keys=[signing_key]) + + assert all( + (name, values) in id_token_claims.items() + for name, values in OIDC_USERS[subject_id].items() + ) From b903d97fe16d8cf5787b35c42349b0fc9111d2df Mon Sep 17 00:00:00 2001 From: Ali Haider Date: Wed, 20 Apr 2022 13:16:43 +0500 Subject: [PATCH 298/401] Remove user entry from the user_db in the case of stateless flow --- src/satosa/frontends/openid_connect.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index d4069aec0..c2787ea5c 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -67,9 +67,10 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, ) db_uri = self.config.get("db_uri") + self.stateless = StorageBase.type(db_uri) == "stateless" self.user_db = ( StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes") - if db_uri and not StorageBase.type(db_uri) == "stateless" + if db_uri and not self.stateless else {} ) @@ -130,6 +131,9 @@ def handle_authn_response(self, context, internal_resp): self._get_extra_id_token_claims(user_id, client_id), ) + if self.stateless: + del self.user_db[internal_resp.subject_id] + del context.state[self.name] http_response = auth_resp.request(auth_req["redirect_uri"], should_fragment_encode(auth_req)) return SeeOther(http_response) From eb0ba8ddc4da9480396be72bf387927d74843ae0 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 6 May 2022 18:46:01 +0300 Subject: [PATCH 299/401] Update documentation Signed-off-by: Ivan Kanakarakis --- README.md | 103 ++++++++++++++++----------- doc/README.md | 193 +++++++++++++++++++++++++++++--------------------- 2 files changed, 175 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 044091a86..a1f251318 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,84 @@ # SATOSA -[![Build Status](https://travis-ci.org/IdentityPython/SATOSA.svg?branch=travis)](https://travis-ci.org/IdentityPython/SATOSA) + [![PyPI](https://img.shields.io/pypi/v/SATOSA.svg)](https://pypi.python.org/pypi/SATOSA) -A configurable proxy for translating between different authentication protocols such as SAML2, -OpenID Connect and OAuth2. +A configurable proxy for translating between different authentication protocols +such as SAML2, OpenID Connect and OAuth2. + # Table of Contents - [Installation](doc/README.md#installation) - - [Docker](doc/README.md#docker) - - [Manual installation](doc/README.md#manual_installation) - - [Dependencies](doc/README.md#dependencies) - - [Instructions](doc/README.md#install_instructions) - - [External micro-services](doc/README.md#install_external) + - [Docker](doc/README.md#docker) + - [Manual installation](doc/README.md#manual-installation) + - [Dependencies](doc/README.md#dependencies) + - [Instructions](doc/README.md#instructions) - [Configuration](doc/README.md#configuration) - - [SATOSA proxy configuration: proxy_conf.yaml.example](doc/README.md#proxy_conf) - - [Additional services](doc/README.md#additional_service) - - [Attribute mapping configuration: internal_attributes.yaml](doc/README.md#attr_map) - - [attributes](doc/README.md#attributes) - - [user_id_from_attrs](doc/README.md#user_id_from_attrs) - - [user_id_to_attr](doc/README.md#user_id_to_attr) + - [SATOSA proxy configuration: proxy_conf.yaml.example](doc/README.md#satosa-proxy-configuration-proxy_confyamlexample) + - [Attribute mapping configuration: internal_attributes.yaml](doc/README.md#attribute-mapping-configuration-internal_attributesyaml) + - [attributes](doc/README.md#attributes) + - [user_id_from_attrs](doc/README.md#user_id_from_attrs) + - [user_id_to_attr](doc/README.md#user_id_to_attr) - [Plugins](doc/README.md#plugins) - - [SAML2 plugins](doc/README.md#saml_plugin) - - [Metadata](doc/README.md#metadata) - - [Frontend](doc/README.md#saml_frontend) - - [Backend](doc/README.md#saml_backend) - - [Name ID Format](doc/README.md#name_id) - - [OpenID Connect plugins](doc/README.md#openid_plugin) - - [Backend](doc/README.md#openid_backend) - - [Frontend](doc/README.md#openid_frontend) - - [Social login plugins](doc/README.md#social_plugins) - - [Google](doc/README.md#google) - - [Facebook](doc/README.md#facebook) -- [Generating proxy metadata](doc/README.md#saml_proxy_metadata) -- [Running the proxy application](doc/README.md#run) + - [SAML2 plugins](doc/README.md#saml2-plugins) + - [Metadata](doc/README.md#metadata) + - [AuthnContextClassRef](doc/README.md#providing-authncontextclassref) + - [Frontend](doc/README.md#saml2-frontend) + - [Custom attribute release](doc/README.md#custom-attribute-release) + - [Policy](doc/README.md#policy) + - [Backend](doc/README.md#saml2-backend) + - [Name ID Format](doc/README.md#name-id-format) + - [Discovery service](doc/README.md#use-a-discovery-service) + - [ForceAuthn option](doc/README.md#mirror-the-saml-forceauthn-option) + - [Memorize IdP](doc/README.md#memorize-the-idp-selected-through-the-discovery-service) + - [OpenID Connect plugins](doc/README.md#openid-connect-plugins) + - [Frontend](doc/README.md#oidc-frontend) + - [Backend](doc/README.md#oidc-backend) + - [Social login plugins](doc/README.md#social-login-plugins) + - [Google](doc/README.md#google) + - [Facebook](doc/README.md#facebook) + - [Dummy adapters](doc/README.md#dummy-adapters) + - [Micro-services](doc/README.md#micro-services) +- [Generating proxy metadata](doc/README.md#generate-proxy-metadata) +- [Running the proxy application](doc/README.md#running-the-proxy-application) +- [External contributions](doc/README.md#external-contributions) # Use cases + In this section a set of use cases for the proxy is presented. + ## SAML2<->SAML2 -There are SAML2 service providers for example Box which is not able to handle multiple identity -providers. For more information about how to set up, configure and run such a proxy instance -please visit [Single Service Provider<->Multiple Identity providers](doc/one-to-many.md) -If an identity provider can not communicate with service providers in for example a federation the -can convert request and make the communication possible. +There are SAML2 service providers for example Box which is not able to handle +multiple identity providers. For more information about how to set up, +configure and run such a proxy instance please visit [Single Service +Provider<->Multiple Identity providers](doc/one-to-many.md) + +If an identity provider can not communicate with service providers in for +example a federation the can convert request and make the communication +possible. + ## SAML2<->Social logins -This setup makes it possible to connect a SAML2 service provider to multiple social media identity -providers such as Google and Facebook. The proxy makes it possible to mirror a identity provider by -generating SAML2 metadata corresponding that provider and create dynamic endpoint which -are connected to a single identity provider. -For more information about how to set up, configure and run such a proxy instance please visit -[SAML2<->Social logins](doc/SAML2-to-Social_logins.md) + +This setup makes it possible to connect a SAML2 service provider to multiple +social media identity providers such as Google and Facebook. The proxy makes it +possible to mirror a identity provider by generating SAML2 metadata +corresponding that provider and create dynamic endpoint which are connected to +a single identity provider. + +For more information about how to set up, configure and run such a proxy +instance please read [SAML2<->Social logins](doc/SAML2-to-Social_logins.md) + ## SAML2<->OIDC -The proxy is able to act as a proxy between a SAML2 service provider and a OpenID connect provider -[SAML2<->OIDC](doc/saml2-to-oidc.md) + +The proxy is able to act as a proxy between a SAML2 service provider and a +OpenID connect provider [SAML2<->OIDC](doc/saml2-to-oidc.md) # Contact -If you have any questions regarding operations/deployment of SATOSA please use the satosa-users [mailing list](https://lists.sunet.se/listinfo/satosa-users). + +If you have any questions regarding operations/deployment of SATOSA please use +the satosa-users [mailing list](https://lists.sunet.se/listinfo/satosa-users). diff --git a/doc/README.md b/doc/README.md index f4b907ec7..bf6e56099 100644 --- a/doc/README.md +++ b/doc/README.md @@ -4,14 +4,14 @@ This document describes how to install and configure the SATOSA proxy. # Installation -## Docker +## Docker A pre-built Docker image is accessible at the [Docker Hub](https://hub.docker.com/r/satosa/), and is the recommended ways of running the proxy. -## Manual installation +## Manual installation -### Dependencies +### Dependencies SATOSA requires Python 3.4 (or above), and the following packages on Ubuntu: @@ -19,7 +19,7 @@ SATOSA requires Python 3.4 (or above), and the following packages on Ubuntu: apt-get install libffi-dev libssl-dev xmlsec1 ```` -### Instructions +### Instructions 1. Download the SATOSA proxy project as a [compressed archive](https://github.com/IdentityPython/SATOSA/releases) and unpack it to ``. @@ -32,29 +32,6 @@ apt-get install libffi-dev libssl-dev xmlsec1 Alternatively the application can be installed directly from PyPI (`pip install satosa`), or the [Docker image](https://hub.docker.com/r/satosa/) can be used. -### External micro-services - -Micro-services act like plugins and can be developed by anyone. Other people -that have been working with the SaToSa proxy, have built extentions mainly in -the form of additional micro-services that can be shared and used by anyone. - -DAASI International have been a long-time user of this software and have made -their extentions available, licensed under Apache2.0. You can find the -extensions using the following URL: - -- https://gitlab.daasi.de/didmos2/didmos2-auth/-/tree/master/src/didmos_oidc/satosa/micro_services - -The extentions include: - -- SCIM attribute store to fetch attributes via SCIM API (instead of LDAP) -- Authoritzation module for blocking services if necessary group memberships or - attributes are missing in the identity (for service providers that do not - evaluate attributes themselves) -- Backend chooser with Django UI for letting the user choose between any - existing SATOSA backend -- Integration of MFA via PrivacyIDEA - -and more. # Configuration @@ -97,7 +74,7 @@ value for `bind_password` will be `secret_password`. bind_password: !ENVFILE LDAP_BIND_PASSWORD_FILE ``` -## SATOSA proxy configuration: `proxy_conf.yaml.example` +## SATOSA proxy configuration: `proxy_conf.yaml.example` | Parameter name | Data type | Example value | Description | | -------------- | --------- | ------------- | ----------- | @@ -112,7 +89,7 @@ bind_password: !ENVFILE LDAP_BIND_PASSWORD_FILE | `MICRO_SERVICES` | string[] | `[statistics_service.yaml]` | list of plugin configuration file paths, describing enabled microservices | | `LOGGING` | dict | see [Python logging.conf](https://docs.python.org/3/library/logging.config.html) | optional configuration of application logging | -## Attribute mapping configuration: `internal_attributes.yaml` +## Attribute mapping configuration: `internal_attributes.yaml` ### attributes @@ -185,19 +162,19 @@ When the [ALService](https://github.com/its-dirg/ALservice) is used for account linking, the `user_id_to_attr` configuration parameter should be set, since that service will overwrite the subject identifier generated by the proxy. -## Plugins +# Plugins The authentication protocol specific communication is handled by different plugins, divided into frontends (receiving requests from clients) and backends (sending requests to target providers). -### Common plugin configuration parameters +## Common plugin configuration parameters Both `name` and `module` must be specified in all plugin configurations (frontends, backends, and micro services). The `name` must be unique to ensure correct functionality, and the `module` must be the fully qualified name of an importable Python module. -### SAML2 plugins +## SAML2 plugins Common configuration parameters: @@ -212,7 +189,7 @@ Common configuration parameters: | `entityid_endpoint` | bool | `true` | whether `entityid` should be used as a URL that serves the metadata xml document | `acr_mapping` | dict | `None` | custom Authentication Context Class Reference -#### Metadata +### Metadata The metadata could be loaded in multiple ways in the table above it's loaded from a static file by using the key "local". It's also possible to load read the metadata from a remote URL. @@ -235,7 +212,7 @@ For more detailed information on how you could customize the SAML entities, see the [documentation of the underlying library pysaml2](https://github.com/rohe/pysaml2/blob/master/docs/howto/config.rst). -#### Providing `AuthnContextClassRef` +### Providing `AuthnContextClassRef` SAML2 frontends and backends can provide a custom (configurable) *Authentication Context Class Reference*. For the frontend this is defined in the `AuthnStatement` of the authentication response, while, @@ -264,7 +241,7 @@ config: "https://accounts.google.com": LoA1 ``` -#### Frontend +### SAML2 Frontend The SAML2 frontend act as a SAML Identity Provider (IdP), accepting authentication requests from SAML Service Providers (SP). The default @@ -301,7 +278,7 @@ An example configuration can be found [here](../example/plugins/frontends/saml2_ `SP -> Virtual CO SAMLFrontend -> SAMLBackend -> optional discovery service -> target IdP` -##### Custom attribute release +#### Custom attribute release In addition to respecting for example entity categories from the SAML metadata, the SAML frontend can also further restrict the attribute release with the `custom_attribute_release` configuration parameter based on the SP entity id. @@ -332,7 +309,7 @@ config: exclude: ["givenName"] ``` -##### Policy +#### Policy Some settings related to how a SAML response is formed can be overriden on a per-instance or a per-SP basis. This example summarizes the most common settings (hopefully self-explanatory) with their defaults: @@ -354,13 +331,13 @@ Overrides per SP entityID is possible by using the entityID as a key instead of in the yaml structure. The most specific key takes presedence. If no policy overrides are provided the defaults above are used. -#### Backend +### SAML2 Backend The SAML2 backend act as a SAML Service Provider (SP), making authentication requests to SAML Identity Providers (IdP). The default configuration file can be found [here](../example/plugins/backends/saml2_backend.yaml.example). -##### Name ID Format +#### Name ID Format The SAML backend can indicate which *Name ID* format it wants by specifying the key `name_id_format` in the SP entity configuration in the backend plugin configuration: @@ -373,7 +350,7 @@ The SAML backend can indicate which *Name ID* format it wants by specifying the name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:transient ``` -##### Use a discovery service +#### Use a discovery service To allow the user to choose which target provider they want to authenticate with, the configuration parameter `disco_srv`, must be specified if the metadata given to the backend module contains more than one IdP: @@ -384,7 +361,7 @@ config: sp_config: [...] ``` -##### Mirror the SAML ForceAuthn option +#### Mirror the SAML ForceAuthn option By default when the SAML frontend receives a SAML authentication request with `ForceAuthn` set to `True`, this information is not mirrored in the SAML @@ -402,7 +379,7 @@ config: [...] ``` -##### Memorize the IdP selected through the discovery service +#### Memorize the IdP selected through the discovery service In the classic flow, the user is asked to select their home organization to authenticate to. The `memorize_idp` configuration option controls whether @@ -437,22 +414,9 @@ config: [...] ``` -### OpenID Connect plugins - -#### Backend - -The OpenID Connect backend acts as an OpenID Connect Relying Party (RP), making -authentication requests to OpenID Connect Provider (OP). The default -configuration file can be found [here](../example/plugins/backends/openid_backend.yaml.example). - -The example configuration assumes the OP supports [discovery](http://openid.net/specs/openid-connect-discovery-1_0.html) -and [dynamic client registration](https://openid.net/specs/openid-connect-registration-1_0.html). -When using an OP that only supports statically registered clients, see the -[default configuration for using Google as the OP](../example/plugins/backends/google_backend.yaml.example) -and make sure to provide the redirect URI, constructed as described in the -section about Google configuration below, in the static registration. +## OpenID Connect plugins -#### Frontend +### OIDC Frontend The OpenID Connect frontend acts as and OpenID Connect Provider (OP), accepting requests from OpenID Connect Relying Parties (RPs). The default configuration file can be found @@ -484,7 +448,20 @@ The configuration parameters available: The other parameters should be left with their default values. -### Social login plugins +### OIDC Backend + +The OpenID Connect backend acts as an OpenID Connect Relying Party (RP), making +authentication requests to OpenID Connect Provider (OP). The default +configuration file can be found [here](../example/plugins/backends/openid_backend.yaml.example). + +The example configuration assumes the OP supports [discovery](http://openid.net/specs/openid-connect-discovery-1_0.html) +and [dynamic client registration](https://openid.net/specs/openid-connect-registration-1_0.html). +When using an OP that only supports statically registered clients, see the +[default configuration for using Google as the OP](../example/plugins/backends/google_backend.yaml.example) +and make sure to provide the redirect URI, constructed as described in the +section about Google configuration below, in the static registration. + +### Social login plugins The social login plugins can be used as backends for the proxy, allowing the proxy to act as a client to the social login services. @@ -537,6 +514,9 @@ for information on how to obtain them. A list of all user attributes released by Facebook can be found [here](https://developers.facebook.com/docs/graph-api/reference/v2.5/user), which should be used when configuring the attribute mapping (see above). + +## Dummy adapters + ### Ping frontend for simple heartbeat monitoring The ping frontend responds to a query with a simple @@ -544,15 +524,17 @@ The ping frontend responds to a query with a simple for example by a load balancer. The default configuration file can be found [here](../example/plugins/frontends/ping_frontend.yaml.example). -### Micro services -Additional behaviour can be configured in the proxy through so called *micro services*. There are two different types -of micro services: *request micro services* which are applied to the incoming request, and *response micro services* -which are applied to the incoming response from the target provider. +## Micro-services + +Additional behaviour can be configured in the proxy through so called *micro +services*. There are two different types of micro services: *request micro +services* which are applied to the incoming request, and *response micro +services* which are applied to the incoming response from the target provider. The following micro services are bundled with SATOSA. -#### Adding static attributes to all responses +### Adding static attributes to all responses To add a set of static attributes, use the `AddStaticAttributes` class which will add pre-configured (static) attributes, see the @@ -567,7 +549,7 @@ country: Sweden where the keys are the internal attribute names defined in `internal_attributes.yaml`. -#### Filtering attribute values +### Filtering attribute values Attribute values delivered from the target provider can be filtered based on a per target provider per requester basis using the `FilterAttributeValues` class. See the [example configuration](../example/plugins/microservices/filter_attributes.yaml.example). @@ -584,7 +566,7 @@ where the empty string (`""`) can be used as a key on any level to describe a de The filters are applied such that all attribute values matched by the regular expression are preserved, while any non-matching attribute values will be discarded. -##### Examples +#### Examples Filter attributes from the target provider `https://provider.example.com`, to only preserve values starting with the string `"foo:bar"`: @@ -612,7 +594,7 @@ the string `"foo:bar"`: "attr1": "foo:bar" ``` -#### Apply a Attribute Policy +### Apply an Attribute Policy Attributes delivered from the target provider can be filtered based on a list of allowed attributes per requester using the `AttributePolicy` class: @@ -625,25 +607,25 @@ attribute_policy: - attr2 ``` -#### Route to a specific backend based on the requester +### Route to a specific backend based on the requester To choose which backend (essentially choosing target provider) to use based on the requester, use the `DecideBackendByRequester` class which implements that special routing behavior. See the [example configuration](../example/plugins/microservices/requester_based_routing.yaml.example). -#### Route to a specific backend based on the target entity id +### Route to a specific backend based on the target entity id Use the `DecideBackendByTargetIssuer` class which implements that special routing behavior. See the [example configuration](../example/plugins/microservices/target_based_routing.yaml.example). -#### Route to a specific backend based on the discovery service response +### Route to a specific backend based on the discovery service response If a Discovery Service is in use and a target entity id is selected by users, you may want to use the `DiscoToTargetIssuer` class together with `DecideBackendByTargetIssuer` to be able to select a backend (essentially choosing target provider) based on the response from the discovery service. See the [example configuration](../example/plugins/microservices/disco_to_target_issuer.yaml.example). -#### Filter authentication requests to target SAML entities +### Filter authentication requests to target SAML entities If using the `SAMLMirrorFrontend` module and some of the target providers should support some additional SP's, the `DecideIfRequesterIsAllowed` micro service can be used. It provides a rules mechanism to describe which SP's are @@ -683,7 +665,7 @@ rules: deny: ["requester1"] ``` -#### Account linking +### Account linking To allow account linking (multiple accounts at possibly different target providers are linked together as belonging to the same user), an external service can be used. See the [example config](../example/plugins/microservices/account_linking.yaml.example) @@ -693,16 +675,17 @@ the same REST API). This micro service must be the first in the list of configured micro services in the `proxy_conf.yaml` to ensure correct functionality. -#### User consent management +### User consent management -To handle user consent of released information, an external service can be used. See the [example config](../example/plugins/microservices/consent.yaml.example) -which is intended to work with the [CMService](https://github.com/its-dirg/CMservice) (or any other service providing -the same REST API). +To handle user consent of released information, an external service can be +used. See the [example config](../example/plugins/microservices/consent.yaml.example) +which is intended to work with the [CMService](https://github.com/its-dirg/CMservice) +(or any other service providing the same RESTish API). -This micro service must be the last in the list of configured micro services in the `proxy_conf.yaml` to ensure -correct functionality. +This micro service must be the last in the list of configured micro services in +the `proxy_conf.yaml` to ensure correct functionality. -#### LDAP attribute store +### LDAP attribute store An identifier such as eduPersonPrincipalName asserted by an IdP can be used to look up a person record in an LDAP directory to find attributes to assert about the authenticated user to the SP. The identifier @@ -712,7 +695,7 @@ persistent NameID may also be obtained from attributes returned from the LDAP di LDAP microservice install the extra necessary dependencies with `pip install satosa[ldap]` and then see the [example config](../example/plugins/microservices/ldap_attribute_store.yaml.example). -#### Support for IdP Hinting +### Support for IdP Hinting It's possible to hint an IdP to SaToSa using the `IdpHinting` micro-service. @@ -734,7 +717,8 @@ methods: * Request micro services must inherit `satosa.micro_services.base.RequestMicroService`. * Response micro services must inherit `satosa.micro_services.base.ResponseMicroService`. -# Generate proxy metadata + +# Generate proxy metadata The proxy metadata is generated based on the front-/backend plugins listed in `proxy_conf.yaml` using the `satosa-saml-metadata` (installed globally by SATOSA installation). @@ -747,6 +731,7 @@ satosa-saml-metadata Date: Fri, 6 May 2022 19:13:41 +0300 Subject: [PATCH 300/401] Release version 8.1.0 ## 8.1.0 (2022-05-06) - OIDC frontend: support stateless code flow - OIDC frontend: support Redis and session expiration - orcid backend: allow family-name to be optional - docs: add references to external contributions - docs: update structure Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 9 +++++++++ setup.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 621e3e9a0..98fcf1b31 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.0.1 +current_version = 8.1.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index bbdade9d7..6eef4a532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 8.1.0 (2022-05-06) + +- OIDC frontend: support stateless code flow +- OIDC frontend: support Redis and session expiration +- orcid backend: allow family-name to be optional +- docs: add references to external contributions +- docs: update structure + + ## 8.0.1 (2022-02-22) - Reinitialize state if error occurs while loading state diff --git a/setup.py b/setup.py index 175b97b29..c1718cba9 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.0.1', + version='8.1.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 02367a3a87eb01ae58463ef98ce5e84ce03b38fe Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 31 May 2022 14:29:46 +0300 Subject: [PATCH 301/401] Set minimum pyop version to v3.4.0 to ensure the needed methods are available Signed-off-by: Ivan Kanakarakis --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c1718cba9..ca750f0d3 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ packages=find_packages('src/'), package_dir={'': 'src'}, install_requires=[ - "pyop >= 3.3.1", + "pyop >= v3.4.0", "pysaml2 >= 6.5.1", "pycryptodomex", "requests", From 043300c554de4d5a4f7cff8b9dc5010c71707616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 20 Jun 2022 12:41:48 +0200 Subject: [PATCH 302/401] docs: fix internal_attributes.xml example ORCID attributes have different names (coming from OrcidBackend) --- example/internal_attributes.yaml.example | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/example/internal_attributes.yaml.example b/example/internal_attributes.yaml.example index 02e1a131e..a1c5dff9a 100644 --- a/example/internal_attributes.yaml.example +++ b/example/internal_attributes.yaml.example @@ -1,43 +1,43 @@ attributes: address: openid: [address.street_address] - orcid: [addresses.str] + orcid: [address] saml: [postaladdress] displayname: openid: [nickname] - orcid: [name.credit-name] + orcid: [displayname] github: [login] saml: [displayName] edupersontargetedid: facebook: [id] linkedin: [id] - orcid: [orcid] + orcid: [edupersontargetedid] github: [id] openid: [sub] saml: [eduPersonTargetedID] givenname: facebook: [first_name] linkedin: [email-address] - orcid: [name.given-names.value] + orcid: [givenname] openid: [given_name] saml: [givenName] mail: facebook: [email] linkedin: [email-address] - orcid: [emails.str] + orcid: [mail] github: [email] openid: [email] saml: [email, emailAddress, mail] name: facebook: [name] - orcid: [name.credit-name] + orcid: [name] github: [name] openid: [name] saml: [cn] surname: facebook: [last_name] linkedin: [lastName] - orcid: [name.family-name.value] + orcid: [surname] openid: [family_name] saml: [sn, surname] user_id_from_attrs: [edupersontargetedid] From 1dc9565d68d2d909c58921225c8a2adce069120d Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 23 Jun 2022 01:41:35 +0300 Subject: [PATCH 303/401] Release version 8.1.1 ## 8.1.1 (2022-06-23) - OIDC frontend: Set minimum pyop version to v3.4.0 to ensure the needed methods are available - docs: Fix orcid mapping in example internal_attributes Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 6 ++++++ setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 98fcf1b31..bb573d655 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.1.0 +current_version = 8.1.1 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eef4a532..264005e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 8.1.1 (2022-06-23) + +- OIDC frontend: Set minimum pyop version to v3.4.0 to ensure the needed methods are available +- docs: Fix orcid mapping in example internal_attributes + + ## 8.1.0 (2022-05-06) - OIDC frontend: support stateless code flow diff --git a/setup.py b/setup.py index ca750f0d3..4e4f9f0d1 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.1.0', + version='8.1.1', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 6a4a83bfef8a92f7e1a14dc717af5e427cfd410a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 1 Jul 2022 16:41:27 +0300 Subject: [PATCH 304/401] Fix mailing list link Prefer idpy-discuss as the central point to have discussions. Signed-off-by: Ivan Kanakarakis --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a1f251318..112d9459b 100644 --- a/README.md +++ b/README.md @@ -81,4 +81,4 @@ OpenID connect provider [SAML2<->OIDC](doc/saml2-to-oidc.md) # Contact If you have any questions regarding operations/deployment of SATOSA please use -the satosa-users [mailing list](https://lists.sunet.se/listinfo/satosa-users). +the satosa-users [mailing list](https://lists.sunet.se/postorius/lists/idpy-discuss.lists.sunet.se/). From 385cc0987e8b0bd9c9bde57a05c38e2efc71a62c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 12 Aug 2022 01:03:14 +0300 Subject: [PATCH 305/401] chore: Remove optional args to create_metadata_string Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 5 +++-- src/satosa/frontends/saml2.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index d50a93fb7..b8310aea7 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -452,8 +452,9 @@ def _metadata_endpoint(self, context): 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") + metadata_string = create_metadata_string( + configfile=None, config=self.sp.config, valid=4 + ).decode("utf-8") return Response(metadata_string, content="text/xml") def register_endpoints(self): diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index b481b5d25..4dcc40833 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -486,8 +486,9 @@ def _metadata_endpoint(self, context): msg = "Sending metadata response for entityId = {}".format(self.idp.config.entityid) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) - metadata_string = create_metadata_string(None, self.idp.config, 4, None, None, None, None, - None).decode("utf-8") + metadata_string = create_metadata_string( + configfile=None, config=self.idp.config, valid=4 + ).decode("utf-8") return Response(metadata_string, content="text/xml") def _reload_metadata(self, context): From 6219d21b850988d06d7fa3eea13cc1a2de9f7b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Fri, 19 Aug 2022 11:42:37 +0200 Subject: [PATCH 306/401] fix: name is optional in ORCID backend previously backend threw KeyError for users with empty name --- src/satosa/backends/orcid.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/satosa/backends/orcid.py b/src/satosa/backends/orcid.py index 6ad69fe96..d0ceee9b9 100644 --- a/src/satosa/backends/orcid.py +++ b/src/satosa/backends/orcid.py @@ -73,7 +73,7 @@ def _authn_response(self, context): request_args=rargs, state=aresp['state']) user_info = self.user_information( - atresp['access_token'], atresp['orcid'], atresp['name']) + atresp['access_token'], atresp['orcid'], atresp.get('name')) internal_response = InternalData( auth_info=self.auth_info(context.request)) internal_response.attributes = self.converter.to_internal( @@ -82,7 +82,7 @@ def _authn_response(self, context): del context.state[self.name] return self.auth_callback_func(context, internal_response) - def user_information(self, access_token, orcid, name): + def user_information(self, access_token, orcid, name=None): base_url = self.config['server_info']['user_info'] url = urljoin(base_url, '{}/person'.format(orcid)) headers = { @@ -92,13 +92,15 @@ def user_information(self, access_token, orcid, name): r = requests.get(url, headers=headers) r = r.json() emails, addresses = r['emails']['email'], r['addresses']['address'] + rname = r.get('name') or {} ret = dict( address=', '.join([e['country']['value'] for e in addresses]), displayname=name, edupersontargetedid=orcid, orcid=orcid, mail=' '.join([e['email'] for e in emails]), name=name, - givenname=r['name']['given-names']['value'], - surname=(r['name']['family-name'] or {}).get('value'), + givenname=(rname.get('given-names') or {}).get('value'), + surname=(rname.get('family-name') or {}).get('value'), ) + return ret From 2e3dcf8a8a7ba11fa8db293ff97bc207990bbc7f Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 25 Aug 2022 15:50:03 +0200 Subject: [PATCH 307/401] oidc_frontend: mirror public subject Add `sub_mirror_subject` configuration parameter. If this is set to true, the subject received from the backend will be mirrored to the client, if public sub is used. To maintain backwards compatibility, the default value is false. MirrorPublicSubjectIdentifierFactory would normally belong to pyop, but in order to keep the code and the configuration in the same place, this code overloads pyop's HashBasedSubjectIdentifierFactory. --- doc/README.md | 1 + src/satosa/frontends/openid_connect.py | 22 ++++++++++++++++--- tests/satosa/frontends/test_openid_connect.py | 20 +++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/doc/README.md b/doc/README.md index bf6e56099..cc23d27cf 100644 --- a/doc/README.md +++ b/doc/README.md @@ -433,6 +433,7 @@ The configuration parameters available: * `client_db_uri`: connection URI to MongoDB or Redis instance where the client data will be persistent, if it's not specified the clients list will be received from the `client_db_path`. * `client_db_path`: path to a file containing the client database in json format. It will only be used if `client_db_uri` is not set. If `client_db_uri` and `client_db_path` are not set, clients will only be stored in-memory (not suitable for production use). * `sub_hash_salt`: salt which is hashed into the `sub` claim. If it's not specified, SATOSA will generate a random salt on each startup, which means that users will get new `sub` value after every restart. +* `sub_mirror_subject` (default: `No`): if this is set to `Yes` and SATOSA releases a public `sub` claim to the client, then the subject identifier received from the backend will be mirrored to the client. The default is to hash the public subject identifier with `sub_hash_salt`. Pairwise `sub` claims are always hashed. * `provider`: provider configuration information. MUST be configured, the following configuration are supported: * `response_types_supported` (default: `[id_token]`): list of all supported response types, see [Section 3 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#Authentication). * `subject_types_supported` (default: `[pairwise]`): list of all supported subject identifier types, see [Section 8 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index c2787ea5c..40b6730d1 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -46,6 +46,11 @@ logger = logging.getLogger(__name__) +class MirrorPublicSubjectIdentifierFactory(HashBasedSubjectIdentifierFactory): + def create_public_identifier(self, user_id): + return user_id + + class OpenIDConnectFrontend(FrontendModule): """ A OpenID Connect frontend module @@ -75,7 +80,10 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, ) sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16)) - authz_state = _init_authorization_state(provider_config, db_uri, sub_hash_salt) + mirror_public = self.config.get("sub_mirror_public", False) + authz_state = _init_authorization_state( + provider_config, db_uri, sub_hash_salt, mirror_public + ) client_db_uri = self.config.get("client_db_uri") cdb_file = self.config.get("client_db_path") @@ -460,7 +468,9 @@ def _create_provider( return provider -def _init_authorization_state(provider_config, db_uri, sub_hash_salt): +def _init_authorization_state( + provider_config, db_uri, sub_hash_salt, mirror_public=False +): if db_uri: authz_code_db = StorageBase.from_uri( db_uri, @@ -499,8 +509,14 @@ def _init_authorization_state(provider_config, db_uri, sub_hash_salt): ] if k in provider_config } + + subject_id_factory = ( + MirrorPublicSubjectIdentifierFactory(sub_hash_salt) + if mirror_public + else HashBasedSubjectIdentifierFactory(sub_hash_salt) + ) return AuthorizationState( - HashBasedSubjectIdentifierFactory(sub_hash_salt), + subject_id_factory, authz_code_db, access_token_db, refresh_token_db, diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index cb322e680..3fad27e82 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -402,6 +402,26 @@ def test_register_endpoints_dynamic_client_registration_is_configurable( provider_info = ProviderConfigurationResponse().deserialize(frontend.provider_config(None).message, "json") assert ("registration_endpoint" in provider_info) == client_registration_enabled + @pytest.mark.parametrize("sub_mirror_public", [ + True, + False + ]) + def test_mirrored_subject(self, context, frontend_config, authn_req, sub_mirror_public): + frontend_config["sub_mirror_public"] = sub_mirror_public + frontend_config["provider"]["subject_types_supported"] = ["public"] + frontend = self.create_frontend(frontend_config) + + self.insert_client_in_client_db(frontend, authn_req["redirect_uri"]) + internal_response = self.setup_for_authn_response(context, frontend, authn_req) + http_resp = frontend.handle_authn_response(context, internal_response) + + resp = AuthorizationResponse().deserialize(urlparse(http_resp.message).fragment) + id_token = IdToken().from_jwt(resp["id_token"], key=[frontend.signing_key]) + if sub_mirror_public: + assert id_token["sub"] == OIDC_USERS["testuser1"]["eduPersonTargetedID"][0] + else: + assert id_token["sub"] != OIDC_USERS["testuser1"]["eduPersonTargetedID"][0] + def test_token_endpoint(self, context, frontend_config, authn_req): token_lifetime = 60 * 60 * 24 frontend_config["provider"]["access_token_lifetime"] = token_lifetime From 630ebfa429cd8e1398b8b07ddf4a47df9c0c6880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 5 Sep 2022 12:25:42 +0200 Subject: [PATCH 308/401] feat: get name of user in Apple backend Apple sends the name only via POST in the first authentication (ever) --- src/satosa/backends/apple.py | 107 ++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 33 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index 633e22c19..fd6d90534 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -76,10 +76,7 @@ def start_auth(self, context, request_info): """ oidc_nonce = rndstr() oidc_state = rndstr() - state_data = { - NONCE_KEY: oidc_nonce, - STATE_KEY: oidc_state - } + state_data = {NONCE_KEY: oidc_nonce, STATE_KEY: oidc_state} context.state[self.name] = state_data args = { @@ -88,7 +85,7 @@ def start_auth(self, context, request_info): "client_id": self.client.client_id, "redirect_uri": self.client.registration_response["redirect_uris"][0], "state": oidc_state, - "nonce": oidc_nonce + "nonce": oidc_nonce, } args.update(self.config["client"]["auth_req_params"]) auth_req = self.client.construct_AuthorizationRequest(request_args=args) @@ -104,7 +101,9 @@ def register_endpoints(self): :return: A list that can be used to map the request to SATOSA to this endpoint. """ url_map = [] - redirect_path = urlparse(self.config["client"]["client_metadata"]["redirect_uris"][0]).path + redirect_path = urlparse( + self.config["client"]["client_metadata"]["redirect_uris"][0] + ).path if not redirect_path: raise SATOSAError("Missing path in redirect uri") @@ -122,10 +121,16 @@ def _verify_nonce(self, nonce, context): """ backend_state = context.state[self.name] if nonce != backend_state[NONCE_KEY]: - msg = "Missing or invalid nonce in authn response for state: {}".format(backend_state) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + msg = "Missing or invalid nonce in authn response for state: {}".format( + backend_state + ) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) logger.debug(logline) - raise SATOSAAuthenticationError(context.state, "Missing or invalid nonce in authn response") + raise SATOSAAuthenticationError( + context.state, "Missing or invalid nonce in authn response" + ) def _get_tokens(self, authn_response, context): """ @@ -142,14 +147,14 @@ def _get_tokens(self, authn_response, context): "client_secret": self.client.client_secret, "code": authn_response["code"], "grant_type": "authorization_code", - "redirect_uri": self.client.registration_response['redirect_uris'][0], + "redirect_uri": self.client.registration_response["redirect_uris"][0], } token_resp = requests.post( "https://appleid.apple.com/auth/token", data=args, - headers={"Content-Type": "application/x-www-form-urlencoded"} - ).json() + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ).json() logger.debug("apple response received") logger.debug(token_resp) @@ -157,7 +162,9 @@ def _get_tokens(self, authn_response, context): self._check_error_response(token_resp, context) keyjar = self.client.keyjar - id_token_claims = dict(Message().from_jwt(token_resp["id_token"], keyjar=keyjar)) + id_token_claims = dict( + Message().from_jwt(token_resp["id_token"], keyjar=keyjar) + ) return token_resp["access_token"], id_token_claims @@ -176,7 +183,9 @@ def _check_error_response(self, response, context): error=response["error"], description=response.get("error_description", ""), ) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) logger.debug(logline) raise SATOSAAuthenticationError(context.state, "Access denied") @@ -192,24 +201,49 @@ def response_endpoint(self, context, *args): :return: """ backend_state = context.state[self.name] - authn_resp = self.client.parse_response(AuthorizationResponse, info=context.request, sformat="dict") + + # Apple sends some user information only via POST in the first request + if "user" in context.request: + userinfo = json.load(context.request["user"]) + userinfo["name"] = " ".join( + filter( + None, + [ + userinfo.get("firstName", ""), + userinfo.get("middleName", ""), + userinfo.get("lastName", ""), + ], + ) + ) + else: + # Apple has no userinfo endpoint + userinfo = {} + + authn_resp = self.client.parse_response( + AuthorizationResponse, info=context.request, sformat="dict" + ) if backend_state[STATE_KEY] != authn_resp["state"]: - msg = "Missing or invalid state in authn response for state: {}".format(backend_state) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + msg = "Missing or invalid state in authn response for state: {}".format( + backend_state + ) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) logger.debug(logline) - raise SATOSAAuthenticationError(context.state, "Missing or invalid state in authn response") + raise SATOSAAuthenticationError( + context.state, "Missing or invalid state in authn response" + ) self._check_error_response(authn_resp, context) access_token, id_token_claims = self._get_tokens(authn_resp, context) if not id_token_claims: id_token_claims = {} - # Apple has no userinfo endpoint - userinfo = {} - if not id_token_claims and not userinfo: msg = "No id_token or userinfo, nothing to do.." - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) logger.error(logline) raise SATOSAAuthenticationError(context.state, "No user info available.") @@ -218,7 +252,9 @@ def response_endpoint(self, context, *args): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) del context.state[self.name] - internal_resp = self._translate_response(all_user_claims, self.client.authorization_endpoint) + internal_resp = self._translate_response( + all_user_claims, self.client.authorization_endpoint + ) return self.auth_callback_func(context, internal_resp) def _translate_response(self, response, issuer): @@ -245,7 +281,9 @@ def get_metadata_desc(self): See satosa.backends.oauth.get_metadata_desc :rtype: satosa.metadata_creation.description.MetadataDescription """ - return get_metadata_desc_for_oauth_backend(self.config["provider_metadata"]["issuer"], self.config) + return get_metadata_desc_for_oauth_backend( + self.config["provider_metadata"]["issuer"], self.config + ) def _create_client(provider_metadata, client_metadata, verify_ssl=True): @@ -258,15 +296,15 @@ def _create_client(provider_metadata, client_metadata, verify_ssl=True): :return: client instance to use for communicating with the configured provider :rtype: oic.oic.Client """ - client = oic.Client( - client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=verify_ssl - ) + client = oic.Client(client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=verify_ssl) # Provider configuration information if "authorization_endpoint" in provider_metadata: # no dynamic discovery necessary - client.handle_provider_config(ProviderConfigurationResponse(**provider_metadata), - provider_metadata["issuer"]) + client.handle_provider_config( + ProviderConfigurationResponse(**provider_metadata), + provider_metadata["issuer"], + ) else: # do dynamic discovery client.provider_config(provider_metadata["issuer"]) @@ -277,9 +315,12 @@ def _create_client(provider_metadata, client_metadata, verify_ssl=True): client.store_registration_info(RegistrationRequest(**client_metadata)) else: # do dynamic registration - client.register(client.provider_info['registration_endpoint'], - **client_metadata) + client.register( + client.provider_info["registration_endpoint"], **client_metadata + ) - client.subject_type = (client.registration_response.get("subject_type") or - client.provider_info["subject_types_supported"][0]) + client.subject_type = ( + client.registration_response.get("subject_type") + or client.provider_info["subject_types_supported"][0] + ) return client From 253a15f35fbd0c3e84f116fee84bceb16d4e1599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 5 Sep 2022 17:07:08 +0200 Subject: [PATCH 309/401] Update apple.py --- src/satosa/backends/apple.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index fd6d90534..61b0351e4 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -203,19 +203,11 @@ def response_endpoint(self, context, *args): backend_state = context.state[self.name] # Apple sends some user information only via POST in the first request - if "user" in context.request: - userinfo = json.load(context.request["user"]) - userinfo["name"] = " ".join( - filter( - None, - [ - userinfo.get("firstName", ""), - userinfo.get("middleName", ""), - userinfo.get("lastName", ""), - ], - ) - ) - else: + # https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple + try: + userdata = context.request.get("user", "{}") + userinfo = json.load(userdata) + except Exception as e: # Apple has no userinfo endpoint userinfo = {} From 58421c86e5a92aee6f0b8ed5273e70bac9c01a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 5 Sep 2022 22:21:09 +0200 Subject: [PATCH 310/401] Update src/satosa/backends/apple.py Co-authored-by: Ivan Kanakarakis --- src/satosa/backends/apple.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index 61b0351e4..d7b21da46 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -202,13 +202,16 @@ def response_endpoint(self, context, *args): """ backend_state = context.state[self.name] - # Apple sends some user information only via POST in the first request - # https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple + # Apple has no userinfo endpoint + # but may send some user information via POST in the first request. + # + # References: + # - https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple + # - https://developer.apple.com/documentation/sign_in_with_apple/namei try: userdata = context.request.get("user", "{}") userinfo = json.load(userdata) except Exception as e: - # Apple has no userinfo endpoint userinfo = {} authn_resp = self.client.parse_response( From 128b8e2355d2c1ed446a11b0642c4f63c39b78ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Wed, 5 Oct 2022 11:07:24 +0200 Subject: [PATCH 311/401] feat: is_passive option for SAML backend allows sending IsPassive to SAML IdP --- src/satosa/backends/saml2.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index b8310aea7..b6d0d8910 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -84,6 +84,7 @@ class SAMLBackend(BackendModule, SAMLBaseModule): KEY_SP_CONFIG = 'sp_config' KEY_SEND_REQUESTER_ID = 'send_requester_id' KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn' + KEY_IS_PASSIVE = 'is_passive' KEY_MEMORIZE_IDP = 'memorize_idp' KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN = 'use_memorized_idp_when_force_authn' @@ -284,6 +285,8 @@ def authn_request(self, context, entity_id): if self.config.get(SAMLBackend.KEY_SEND_REQUESTER_ID): requester = context.state.state_dict[STATE_KEY_BASE]['requester'] kwargs["scoping"] = Scoping(requester_id=[RequesterID(text=requester)]) + if self.config.get(SAMLBackend.KEY_IS_PASSIVE): + kwargs["is_passive"] = "true" try: acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0] From 1576878773e3279d990956d3ef75064ff346ba55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Thu, 13 Oct 2022 13:28:10 +0200 Subject: [PATCH 312/401] docs: correct attribute_generation.yaml.example --- example/plugins/microservices/attribute_generation.yaml.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/plugins/microservices/attribute_generation.yaml.example b/example/plugins/microservices/attribute_generation.yaml.example index a1c65c91b..45f5b269f 100644 --- a/example/plugins/microservices/attribute_generation.yaml.example +++ b/example/plugins/microservices/attribute_generation.yaml.example @@ -7,5 +7,5 @@ config: eduPersonAffiliation: member;employee default: default: - schacHomeOrganization: {{eduPersonPrincipalName.scope}} + schacHomeOrganization: "{{eduPersonPrincipalName.scope}}" schacHomeOrganizationType: tomfoolery provider From 1220c310e38ccc86309dc4f39bdc49a8acaa48ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Sun, 16 Oct 2022 10:29:59 +0200 Subject: [PATCH 313/401] fix(attribute_generation): run mustach only on strings --- src/satosa/micro_services/attribute_generation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index d96d8e1e1..907a8462d 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -129,7 +129,11 @@ def _synthesize(self, attributes, requester, provider): for attr_name, values in attributes.items(): context[attr_name] = MustachAttrValue( attr_name, - values if values is not None else [] + values + if values + and isinstance(values, list) + and all(isinstance(value, str) for value in values) + else [], ) recipes = get_dict_defaults(self.synthetic_attributes, requester, provider) From 7d1f76d2b2651670f58edce2888d2df4ea9f72d2 Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Mon, 24 Oct 2022 12:08:55 +0200 Subject: [PATCH 314/401] doc: fix name_id_format vs name_id_policy_format ambiguity After `0c1873da1` in pysaml2, the ambiguity between the format in the of the and the in the metadata has been resolved. This change provides a followup in the SATOSA documentation and example. --- doc/README.md | 16 +++++++++++++--- .../plugins/backends/saml2_backend.yaml.example | 10 ++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/doc/README.md b/doc/README.md index cc23d27cf..5f0a6d610 100644 --- a/doc/README.md +++ b/doc/README.md @@ -339,15 +339,25 @@ found [here](../example/plugins/backends/saml2_backend.yaml.example). #### Name ID Format -The SAML backend can indicate which *Name ID* format it wants by specifying the key -`name_id_format` in the SP entity configuration in the backend plugin configuration: +The SAML backend has two ways to indicate which *Name ID* format it wants: +* `name_id_format`: is a list of strings to set the `` element in + SP metadata +* `name_id_policy_format`: is a string to set the `Format` attribute in the + `` element in the authentication request. + +The default is to not set any of the above. Note that if the IdP can not +provide the NameID in a format, which is requested in the ``, it +must return an error. ```yaml config: sp_config: service: sp: - name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:transient + name_id_format: + - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + - urn:oasis:names:tc:SAML:2.0:nameid-format:transient + name_id_policy_format: urn:oasis:names:tc:SAML:2.0:nameid-format:transient ``` #### Use a discovery service diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index 335da8117..2dbe97092 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -64,8 +64,10 @@ config: - [//acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] discovery_response: - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] - name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' - # A name_id_format of 'None' will cause the authentication request to not - # include a Format attribute in the NameIDPolicy. - # name_id_format: 'None' + + # name_id_format: a list of strings to set the element in SP metadata + # name_id_policy_format: a string to set the Format attribute in the NameIDPolicy element + # of the authentication request + # name_id_format_allow_create: sets the AllowCreate attribute in the NameIDPolicy element + # of the authentication request name_id_format_allow_create: true From 3af4dddae4380b930c5fe415f0c855997e5a30d8 Mon Sep 17 00:00:00 2001 From: Nyiro Gergo Date: Thu, 27 Oct 2022 17:11:05 +0200 Subject: [PATCH 315/401] satosa.base: log state on debug level State of satosa can contain some encoded data (cookies_samesite_compat) which are to verbose on info level. Therefore The "Loaded state ..." log message is emitted on debug level. --- src/satosa/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index 7468a4ca0..7288aca08 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -207,7 +207,7 @@ def _load_state(self, context): context.state = state msg = "Loaded state {state} from cookie {cookie}".format(state=state, cookie=context.cookie) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.info(logline) + logger.debug(logline) def _save_state(self, resp, context): """ From 6c988f672ca990a1ac8f3101f20e3a9a739d698c Mon Sep 17 00:00:00 2001 From: claycooper Date: Fri, 28 Oct 2022 14:56:34 -0400 Subject: [PATCH 316/401] Updated links to Docker Hub --- doc/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/README.md b/doc/README.md index 5f0a6d610..c5b8317ef 100644 --- a/doc/README.md +++ b/doc/README.md @@ -6,7 +6,7 @@ This document describes how to install and configure the SATOSA proxy. ## Docker -A pre-built Docker image is accessible at the [Docker Hub](https://hub.docker.com/r/satosa/), and is the +A pre-built Docker image is accessible at the [Docker Hub](https://hub.docker.com/_/satosa), and is the recommended ways of running the proxy. ## Manual installation @@ -30,7 +30,7 @@ apt-get install libffi-dev libssl-dev xmlsec1 pip install ``` -Alternatively the application can be installed directly from PyPI (`pip install satosa`), or the [Docker image](https://hub.docker.com/r/satosa/) can be used. +Alternatively the application can be installed directly from PyPI (`pip install satosa`), or the [Docker image](https://hub.docker.com/_/satosa) can be used. # Configuration From c629dd5dfce44675bc263241c880a6481ccb33d6 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 9 Nov 2022 10:25:05 +0100 Subject: [PATCH 317/401] fix: OpenIDConnectFrontend: check for empty db_uri (#420) With IdentityPython/pyop#44 merged, OpenIDConnectFrontend init fails when `db_uri` is not set, as `StorageBase.type` now throws a `ValueError` for db_uri values that do not match one of the recognised storage types (including when `db_uri` is `None`). Fix this by guarding the `StorageBase.type` with a pythonic test whether `db_uri` was provided. Same test already guards `StorageBase.from_uri`, add it also to the `StorageBase.type` call made to determine `self.stateless`. --- src/satosa/frontends/openid_connect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 40b6730d1..88041b373 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -72,7 +72,7 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, ) db_uri = self.config.get("db_uri") - self.stateless = StorageBase.type(db_uri) == "stateless" + self.stateless = db_uri and StorageBase.type(db_uri) == "stateless" self.user_db = ( StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes") if db_uri and not self.stateless From e0a4fb3b142b6931bc7b864af377b929fe70c3c7 Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 10 Nov 2022 15:19:25 +0100 Subject: [PATCH 318/401] tests: remove real MongoDB dependency OIDC integration tests now use mongomock instead of launching a full mongodb server, which may not be available in a development environment. --- tests/conftest.py | 85 ------------------- tests/flows/test_oidc-saml.py | 29 ++++--- .../scripts/test_satosa_saml_metadata.py | 4 +- tests/test_requirements.txt | 1 + 4 files changed, 22 insertions(+), 97 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9e7a5e18f..e6c11fa36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -361,88 +361,3 @@ def consent_module_config(signing_key_path): } } return consent_config - - -import atexit -import random -import shutil -import subprocess -import tempfile -import time - -import pymongo -import pytest - - -class MongoTemporaryInstance(object): - """Singleton to manage a temporary MongoDB instance - - Use this for testing purpose only. The instance is automatically destroyed - at the end of the program. - - """ - _instance = None - - @classmethod - def get_instance(cls): - if cls._instance is None: - cls._instance = cls() - atexit.register(cls._instance.shutdown) - return cls._instance - - def __init__(self): - self._tmpdir = tempfile.mkdtemp() - self._port = 27017 - self._process = subprocess.Popen(['mongod', '--bind_ip', 'localhost', - '--port', str(self._port), - '--dbpath', self._tmpdir, - '--nojournal', - '--noauth', - '--syncdelay', '0'], - stdout=open('/tmp/mongo-temp.log', 'wb'), - stderr=subprocess.STDOUT) - - # XXX: wait for the instance to be ready - # Mongo is ready in a glance, we just wait to be able to open a - # Connection. - for i in range(10): - time.sleep(0.2) - try: - self._conn = pymongo.MongoClient('localhost', self._port) - except pymongo.errors.ConnectionFailure: - continue - else: - break - else: - self.shutdown() - assert False, 'Cannot connect to the mongodb test instance' - - @property - def conn(self): - return self._conn - - @property - def port(self): - return self._port - - def shutdown(self): - if self._process: - self._process.terminate() - self._process.wait() - self._process = None - shutil.rmtree(self._tmpdir, ignore_errors=True) - - def get_uri(self): - """ - Convenience function to get a mongodb URI to the temporary database. - - :return: URI - """ - return 'mongodb://localhost:{port!s}'.format(port=self.port) - - -@pytest.fixture -def mongodb_instance(): - tmp_db = MongoTemporaryInstance() - yield tmp_db - tmp_db.shutdown() diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index 257a8f7c9..2a299bfef 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -3,11 +3,12 @@ import base64 from urllib.parse import urlparse, urlencode, parse_qsl +import mongomock import pytest from jwkest.jwk import rsa_load, RSAKey from jwkest.jws import JWS from oic.oic.message import ClaimsRequest, Claims -from pyop.storage import MongoWrapper +from pyop.storage import StorageBase from saml2 import BINDING_HTTP_REDIRECT from saml2.config import IdPConfig from werkzeug.test import Client @@ -25,6 +26,7 @@ CLIENT_SECRET = "secret" CLIENT_REDIRECT_URI = "https://client.example.com/cb" REDIRECT_URI = "https://client.example.com/cb" +DB_URI = "mongodb://localhost/satosa" @pytest.fixture(scope="session") def client_db_path(tmpdir_factory): @@ -45,7 +47,7 @@ def client_db_path(tmpdir_factory): return path @pytest.fixture -def oidc_frontend_config(signing_key_path, mongodb_instance): +def oidc_frontend_config(signing_key_path): data = { "module": "satosa.frontends.openid_connect.OpenIDConnectFrontend", "name": "OIDCFrontend", @@ -53,18 +55,11 @@ def oidc_frontend_config(signing_key_path, mongodb_instance): "issuer": "https://proxy-op.example.com", "signing_key_path": signing_key_path, "provider": {"response_types_supported": ["id_token"]}, - "client_db_uri": mongodb_instance.get_uri(), # use mongodb for integration testing - "db_uri": mongodb_instance.get_uri() # use mongodb for integration testing + "client_db_uri": DB_URI, # use mongodb for integration testing + "db_uri": DB_URI # use mongodb for integration testing } } - # insert client in mongodb - cdb = MongoWrapper(mongodb_instance.get_uri(), "satosa", "clients") - cdb[CLIENT_ID] = { - "redirect_uris": [REDIRECT_URI], - "response_types": ["id_token"] - } - return data @@ -87,8 +82,20 @@ def oidc_stateless_frontend_config(signing_key_path, client_db_path): return data +@mongomock.patch(servers=(('localhost', 27017),)) class TestOIDCToSAML: + def _client_setup(self): + """Insert client in mongodb.""" + self._cdb = StorageBase.from_uri( + DB_URI, db_name="satosa", collection="clients", ttl=None + ) + self._cdb[CLIENT_ID] = { + "redirect_uris": [REDIRECT_URI], + "response_types": ["id_token"] + } + def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_config, idp_conf): + self._client_setup() subject_id = "testuser1" # proxy config diff --git a/tests/satosa/scripts/test_satosa_saml_metadata.py b/tests/satosa/scripts/test_satosa_saml_metadata.py index 26809dc2a..f76f5d990 100644 --- a/tests/satosa/scripts/test_satosa_saml_metadata.py +++ b/tests/satosa/scripts/test_satosa_saml_metadata.py @@ -1,6 +1,7 @@ import glob import os +import mongomock import pytest from saml2.config import Config from saml2.mdstore import MetaDataFile @@ -10,7 +11,7 @@ @pytest.fixture -def oidc_frontend_config(signing_key_path, mongodb_instance): +def oidc_frontend_config(signing_key_path): data = { "module": "satosa.frontends.openid_connect.OpenIDConnectFrontend", "name": "OIDCFrontend", @@ -23,6 +24,7 @@ def oidc_frontend_config(signing_key_path, mongodb_instance): return data +@mongomock.patch(servers=(('localhost', 27017),)) class TestConstructSAMLMetadata: def test_saml_saml(self, tmpdir, cert_and_key, satosa_config_dict, saml_frontend_config, saml_backend_config): diff --git a/tests/test_requirements.txt b/tests/test_requirements.txt index bf7f30deb..1991e4cac 100644 --- a/tests/test_requirements.txt +++ b/tests/test_requirements.txt @@ -2,3 +2,4 @@ pytest responses beautifulsoup4 ldap3 +mongomock From f701c73caf6360b58c314dddb2b0ff08a138b734 Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 10 Nov 2022 22:21:05 +0100 Subject: [PATCH 319/401] tox.ini: ignore all current flake8 errors ...so that flake8 tests can pass without an error. Fixing flake8 tests should happen in a TDD fashion with removing the fixed test from the ignore list. --- tox.ini | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index e26b9ef89..36515d12c 100644 --- a/tox.ini +++ b/tox.ini @@ -24,4 +24,36 @@ commands = pytest -vvv -ra {posargs:tests/} [flake8] -ignore = E501 +ignore = + F401 + E402 + E501 + E111 + E117 + E121 + E123 + E125 + E126 + E201 + E202 + E203 + E221 + E226 + E231 + E261 + E262 + E265 + E275 + E302 + E303 + E703 + F601 + F811 + F821 + F841 + W291 + W292 + W293 + W503 + W504 + W605 From c5bbb9b4f95d31212a440867e5a2028cb4794f78 Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 10 Nov 2022 22:21:05 +0100 Subject: [PATCH 320/401] flake8: fix unused imports (F401) --- src/satosa/__init__.py | 2 +- src/satosa/backends/apple.py | 1 - src/satosa/base.py | 1 - src/satosa/frontends/ping.py | 1 - src/satosa/micro_services/attribute_processor.py | 1 - src/satosa/micro_services/primary_identifier.py | 1 - .../micro_services/processors/scope_remover_processor.py | 2 +- src/satosa/proxy_server.py | 1 - src/satosa/state.py | 4 +--- src/satosa/yaml.py | 3 +-- tests/flows/test_wsgi_flow.py | 2 -- tests/satosa/backends/test_openid_connect.py | 2 +- tests/satosa/metadata_creation/test_description.py | 2 -- tests/satosa/micro_services/test_attribute_generation.py | 1 - tests/satosa/micro_services/test_consent.py | 2 +- tests/satosa/micro_services/test_custom_routing.py | 3 +-- tests/satosa/micro_services/test_idp_hinting.py | 2 -- tests/satosa/test_satosa_config.py | 1 - tox.ini | 1 - 19 files changed, 7 insertions(+), 26 deletions(-) diff --git a/src/satosa/__init__.py b/src/satosa/__init__.py index 895e0166f..eeadbe8f8 100644 --- a/src/satosa/__init__.py +++ b/src/satosa/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- """SATOSA: An any to any Single Sign On (SSO) proxy.""" -from .version import version as __version__ +from .version import version as __version__ # noqa: F401 diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index d7b21da46..f197b0f38 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -22,7 +22,6 @@ from ..exception import SATOSAAuthenticationError, SATOSAError from ..response import Redirect -import base64 import json import requests diff --git a/src/satosa/base.py b/src/satosa/base.py index 7288aca08..0db349451 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -9,7 +9,6 @@ from satosa import util from .context import Context -from .exception import SATOSAConfigurationError from .exception import SATOSAError, SATOSAAuthenticationError, SATOSAUnknownError from .plugin_loader import load_backends, load_frontends from .plugin_loader import load_request_microservices, load_response_microservices diff --git a/src/satosa/frontends/ping.py b/src/satosa/frontends/ping.py index 8eda3948c..4444cd83d 100644 --- a/src/satosa/frontends/ping.py +++ b/src/satosa/frontends/ping.py @@ -2,7 +2,6 @@ import satosa.logging_util as lu import satosa.micro_services.base -from satosa.logging_util import satosa_logging from satosa.response import Response diff --git a/src/satosa/micro_services/attribute_processor.py b/src/satosa/micro_services/attribute_processor.py index 1973402b2..7232e484e 100644 --- a/src/satosa/micro_services/attribute_processor.py +++ b/src/satosa/micro_services/attribute_processor.py @@ -1,5 +1,4 @@ import importlib -import json import logging from satosa.exception import SATOSAError diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 9c892570d..9275779f9 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -5,7 +5,6 @@ the value for a configured attribute, for example uid. """ -import copy import logging import urllib.parse diff --git a/src/satosa/micro_services/processors/scope_remover_processor.py b/src/satosa/micro_services/processors/scope_remover_processor.py index b6e61b7ed..82073b5b8 100644 --- a/src/satosa/micro_services/processors/scope_remover_processor.py +++ b/src/satosa/micro_services/processors/scope_remover_processor.py @@ -1,4 +1,4 @@ -from ..attribute_processor import AttributeProcessorError, AttributeProcessorWarning +from ..attribute_processor import AttributeProcessorWarning from .base_processor import BaseProcessor class ScopeRemoverProcessor(BaseProcessor): diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index ce7fd1459..03305d4ce 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -1,7 +1,6 @@ import json import logging import logging.config -import sys from io import BytesIO from urllib.parse import parse_qsl as _parse_query_string diff --git a/src/satosa/state.py b/src/satosa/state.py index 7feba1a9e..05e343529 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -11,9 +11,7 @@ from satosa.cookies import SimpleCookie from uuid import uuid4 -from lzma import LZMACompressor -from lzma import LZMADecompressor -from lzma import LZMAError +from lzma import LZMACompressor, LZMADecompressor from Cryptodome import Random from Cryptodome.Cipher import AES diff --git a/src/satosa/yaml.py b/src/satosa/yaml.py index 9efa202c6..2f8d51f1b 100644 --- a/src/satosa/yaml.py +++ b/src/satosa/yaml.py @@ -1,9 +1,8 @@ import os -import re from yaml import SafeLoader as _safe_loader from yaml import YAMLError -from yaml import safe_load as load +from yaml import safe_load as load # noqa: F401 def _constructor_env_variables(loader, node): diff --git a/tests/flows/test_wsgi_flow.py b/tests/flows/test_wsgi_flow.py index fcae4ce21..ab9d636f5 100644 --- a/tests/flows/test_wsgi_flow.py +++ b/tests/flows/test_wsgi_flow.py @@ -1,8 +1,6 @@ """ Complete test for a SAML to SAML proxy. """ -import json - from werkzeug.test import Client from werkzeug.wrappers import Response diff --git a/tests/satosa/backends/test_openid_connect.py b/tests/satosa/backends/test_openid_connect.py index b282e7725..b898e157c 100644 --- a/tests/satosa/backends/test_openid_connect.py +++ b/tests/satosa/backends/test_openid_connect.py @@ -9,7 +9,7 @@ import responses from Cryptodome.PublicKey import RSA from jwkest.jwk import RSAKey -from oic.oic.message import RegistrationRequest, IdToken +from oic.oic.message import IdToken from oic.utils.authn.client import CLIENT_AUTHN_METHOD from satosa.backends.openid_connect import OpenIDConnectBackend, _create_client, STATE_KEY, NONCE_KEY diff --git a/tests/satosa/metadata_creation/test_description.py b/tests/satosa/metadata_creation/test_description.py index 8b73ec923..ae8caf166 100644 --- a/tests/satosa/metadata_creation/test_description.py +++ b/tests/satosa/metadata_creation/test_description.py @@ -1,5 +1,3 @@ -from unittest.mock import mock_open, patch - import pytest from satosa.metadata_creation.description import ContactPersonDesc, UIInfoDesc, OrganizationDesc, MetadataDescription diff --git a/tests/satosa/micro_services/test_attribute_generation.py b/tests/satosa/micro_services/test_attribute_generation.py index e60ab36fc..67f669417 100644 --- a/tests/satosa/micro_services/test_attribute_generation.py +++ b/tests/satosa/micro_services/test_attribute_generation.py @@ -1,7 +1,6 @@ from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.micro_services.attribute_generation import AddSyntheticAttributes -from satosa.exception import SATOSAAuthenticationError from satosa.context import Context class TestAddSyntheticAttributes: diff --git a/tests/satosa/micro_services/test_consent.py b/tests/satosa/micro_services/test_consent.py index 514367300..a8eaed965 100644 --- a/tests/satosa/micro_services/test_consent.py +++ b/tests/satosa/micro_services/test_consent.py @@ -1,7 +1,7 @@ import json import re from collections import Counter -from urllib.parse import urlparse, parse_qs +from urllib.parse import urlparse import pytest import requests diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index d2022bc3e..ed834ef4b 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -5,11 +5,10 @@ from satosa.context import Context from satosa.state import State -from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError +from satosa.exception import SATOSAError, SATOSAConfigurationError from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed from satosa.micro_services.custom_routing import DecideBackendByTargetIssuer -from satosa.micro_services.custom_routing import CustomRoutingError TARGET_ENTITY = "entity1" diff --git a/tests/satosa/micro_services/test_idp_hinting.py b/tests/satosa/micro_services/test_idp_hinting.py index a13d3d7a3..2fa454253 100644 --- a/tests/satosa/micro_services/test_idp_hinting.py +++ b/tests/satosa/micro_services/test_idp_hinting.py @@ -1,7 +1,5 @@ from unittest import TestCase -import pytest - from satosa.context import Context from satosa.internal import InternalData from satosa.state import State diff --git a/tests/satosa/test_satosa_config.py b/tests/satosa/test_satosa_config.py index d291d9c87..bdc504384 100644 --- a/tests/satosa/test_satosa_config.py +++ b/tests/satosa/test_satosa_config.py @@ -3,7 +3,6 @@ from unittest.mock import mock_open, patch import pytest -from satosa.exception import SATOSAConfigurationError from satosa.exception import SATOSAConfigurationError from satosa.satosa_config import SATOSAConfig diff --git a/tox.ini b/tox.ini index 36515d12c..d255d02aa 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,6 @@ commands = [flake8] ignore = - F401 E402 E501 E111 From 64f61b2bed8db433238468e03fde3ef21aecc23d Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 10 Nov 2022 22:21:05 +0100 Subject: [PATCH 321/401] flake8: fix accidental redefinition of test method (F811) --- tests/satosa/test_satosa_config.py | 14 +++----------- tox.ini | 1 - 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/satosa/test_satosa_config.py b/tests/satosa/test_satosa_config.py index bdc504384..fd5045a93 100644 --- a/tests/satosa/test_satosa_config.py +++ b/tests/satosa/test_satosa_config.py @@ -59,22 +59,14 @@ def test_can_read_endpoint_configs_from_file(self, satosa_config_dict, modules_k satosa_config_dict[modules_key] = ["/fake_file_path"] expected_config = {"foo": "bar"} + with pytest.raises(SATOSAConfigurationError): + SATOSAConfig(satosa_config_dict) + with patch("builtins.open", mock_open(read_data=json.dumps(expected_config))): config = SATOSAConfig(satosa_config_dict) assert config[modules_key] == [expected_config] - @pytest.mark.parametrize("modules_key", [ - "BACKEND_MODULES", - "FRONTEND_MODULES", - "MICRO_SERVICES" - ]) - def test_can_read_endpoint_configs_from_file(self, satosa_config_dict, modules_key): - satosa_config_dict[modules_key] = ["/fake_file_path"] - - with pytest.raises(SATOSAConfigurationError): - SATOSAConfig(satosa_config_dict) - def test_can_substitute_from_environment_variable(self, monkeypatch): monkeypatch.setenv("SATOSA_COOKIE_STATE_NAME", "oatmeal_raisin") config = SATOSAConfig( diff --git a/tox.ini b/tox.ini index d255d02aa..066ad38ea 100644 --- a/tox.ini +++ b/tox.ini @@ -47,7 +47,6 @@ ignore = E303 E703 F601 - F811 F821 F841 W291 From 68c9fa3b7e30b3da10da9a437c308e21b919b19d Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 10 Nov 2022 22:21:05 +0100 Subject: [PATCH 322/401] flake8: fix missing import (F821) --- src/satosa/backends/reflector.py | 3 ++- tox.ini | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/backends/reflector.py b/src/satosa/backends/reflector.py index 6702dc733..6a9055485 100644 --- a/src/satosa/backends/reflector.py +++ b/src/satosa/backends/reflector.py @@ -1,6 +1,7 @@ """ A reflector backend module for the satosa proxy """ +import base64 from datetime import datetime from satosa.internal import AuthenticationInformation @@ -74,7 +75,7 @@ def get_metadata_desc(self): """ entity_descriptions = [] description = MetadataDescription( - urlsafe_b64encode(ReflectorBackend.ENTITY_ID.encode("utf-8")).decode( + base64.urlsafe_b64encode(ReflectorBackend.ENTITY_ID.encode("utf-8")).decode( "utf-8" ) ) diff --git a/tox.ini b/tox.ini index 066ad38ea..d5c3b6905 100644 --- a/tox.ini +++ b/tox.ini @@ -47,7 +47,6 @@ ignore = E303 E703 F601 - F821 F841 W291 W292 From d5de97696fb96e7ba477749d32631db0b16cb14a Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Thu, 10 Nov 2022 22:26:36 +0100 Subject: [PATCH 323/401] flake8: fix accidental dictionary key redefinition (F601) --- tests/conftest.py | 8 -------- tox.ini | 1 - 2 files changed, 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e6c11fa36..f0602a028 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -130,8 +130,6 @@ def satosa_config_dict(backend_plugin_config, frontend_plugin_config, request_mi config = { "BASE": BASE_URL, "COOKIE_STATE_NAME": "TEST_STATE", - "BACKEND_MODULES": ["foo"], - "FRONTEND_MODULES": ["bar"], "INTERNAL_ATTRIBUTES": {"attributes": {}}, "STATE_ENCRYPTION_KEY": "state_encryption_key", "CUSTOM_PLUGIN_MODULE_PATHS": [os.path.dirname(__file__)], @@ -190,12 +188,6 @@ def saml_frontend_config(cert_and_key, sp_conf): "config": { "idp_config": { "entityid": "frontend-entity_id", - "organization": {"display_name": "Test Identities", "name": "Test Identities Org.", - "url": "http://www.example.com"}, - "contact_person": [{"contact_type": "technical", "email_address": "technical@example.com", - "given_name": "Technical"}, - {"contact_type": "support", "email_address": "support@example.com", - "given_name": "Support"}], "service": { "idp": { "endpoints": { diff --git a/tox.ini b/tox.ini index d5c3b6905..1ed5cf92e 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,6 @@ ignore = E302 E303 E703 - F601 F841 W291 W292 From 502e8757a1f3fe103bb69f70f185a003207a5b4a Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Fri, 11 Nov 2022 08:46:59 +0100 Subject: [PATCH 324/401] flake8: fix unused local variables (F841) --- src/satosa/backends/apple.py | 2 +- src/satosa/backends/oauth.py | 2 +- src/satosa/base.py | 2 +- src/satosa/frontends/saml2.py | 8 +++----- src/satosa/micro_services/consent.py | 4 ++-- src/satosa/micro_services/custom_logging.py | 4 +--- src/satosa/micro_services/primary_identifier.py | 4 ++-- tests/satosa/frontends/test_openid_connect.py | 2 +- .../test_attribute_authorization.py | 17 ++++++----------- tox.ini | 1 - 10 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index f197b0f38..edace8641 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -210,7 +210,7 @@ def response_endpoint(self, context, *args): try: userdata = context.request.get("user", "{}") userinfo = json.load(userdata) - except Exception as e: + except Exception: userinfo = {} authn_resp = self.client.parse_response( diff --git a/src/satosa/backends/oauth.py b/src/satosa/backends/oauth.py index 2308f1eee..1e584f617 100644 --- a/src/satosa/backends/oauth.py +++ b/src/satosa/backends/oauth.py @@ -259,7 +259,7 @@ def user_information(self, access_token): try: picture_url = data["picture"]["data"]["url"] data["picture"] = picture_url - except KeyError as e: + except KeyError: pass return data diff --git a/src/satosa/base.py b/src/satosa/base.py index 0db349451..404104920 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -200,7 +200,7 @@ def _load_state(self, context): self.config["COOKIE_STATE_NAME"], self.config["STATE_ENCRYPTION_KEY"], ) - except SATOSAStateError as e: + except SATOSAStateError: state = State() finally: context.state = state diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 4dcc40833..655e6da68 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -173,10 +173,8 @@ def _validate_config(self, config): raise ValueError("No configuration given") for key in required_keys: - try: - _val = config[key] - except KeyError as e: - raise ValueError("Missing configuration key: %s" % key) from e + if key not in config: + raise ValueError("Missing configuration key: %s" % key) def _handle_authn_request(self, context, binding_in, idp): """ @@ -630,7 +628,7 @@ def _get_sp_display_name(self, idp, entity_id): try: return extensions[0]["display_name"] - except (IndexError, KeyError) as e: + except (IndexError, KeyError): pass return None diff --git a/src/satosa/micro_services/consent.py b/src/satosa/micro_services/consent.py index 3823826da..a469e2189 100644 --- a/src/satosa/micro_services/consent.py +++ b/src/satosa/micro_services/consent.py @@ -66,7 +66,7 @@ def _handle_consent_response(self, context): except ConnectionError as e: msg = "Consent service is not reachable, no consent given." logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline) + logger.error(logline, exc_info=e) # Send an internal_response without any attributes consent_attributes = None @@ -136,7 +136,7 @@ def process(self, context, internal_response): except requests.exceptions.ConnectionError as e: msg = "Consent service is not reachable, no consent is given." logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline) + logger.error(logline, exc_info=e) # Send an internal_response without any attributes internal_response.attributes = {} return self._end_consent(context, internal_response) diff --git a/src/satosa/micro_services/custom_logging.py b/src/satosa/micro_services/custom_logging.py index c82d03449..14d435d8f 100644 --- a/src/satosa/micro_services/custom_logging.py +++ b/src/satosa/micro_services/custom_logging.py @@ -39,7 +39,7 @@ def process(self, context, data): try: spEntityID = context.state.state_dict['SATOSA_BASE']['requester'] idpEntityID = data.auth_info.issuer - except KeyError as err: + except KeyError: msg = "{} Unable to determine the entityID's for the IdP or SP".format(logprefix) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) @@ -71,8 +71,6 @@ def process(self, context, data): logger.error(logline) return super().process(context, data) - record = None - try: msg = "{} Using context {}".format(logprefix, context) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 9275779f9..2a140a9e4 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -138,7 +138,7 @@ def process(self, context, data): # Find the entityID for the SP that initiated the flow try: spEntityID = context.state.state_dict['SATOSA_BASE']['requester'] - except KeyError as err: + except KeyError: msg = "{} Unable to determine the entityID for the SP requester".format(logprefix) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) @@ -151,7 +151,7 @@ def process(self, context, data): # Find the entityID for the IdP that issued the assertion try: idpEntityID = data.auth_info.issuer - except KeyError as err: + except KeyError: msg = "{} Unable to determine the entityID for the IdP issuer".format(logprefix) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index 3fad27e82..f769b2c66 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -557,7 +557,7 @@ def test_full_flow(self, context, frontend_with_extra_scopes): frontend_with_extra_scopes.auth_req_callback_func = mock_callback # discovery http_response = frontend_with_extra_scopes.provider_config(context) - provider_config = ProviderConfigurationResponse().deserialize(http_response.message, "json") + _ = ProviderConfigurationResponse().deserialize(http_response.message, "json") # client registration registration_request = RegistrationRequest(redirect_uris=[redirect_uri], response_types=[response_type]) diff --git a/tests/satosa/micro_services/test_attribute_authorization.py b/tests/satosa/micro_services/test_attribute_authorization.py index 4bd0cfc54..15b1458ff 100644 --- a/tests/satosa/micro_services/test_attribute_authorization.py +++ b/tests/satosa/micro_services/test_attribute_authorization.py @@ -1,3 +1,4 @@ +import pytest from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.micro_services.attribute_authorization import AttributeAuthorization @@ -25,7 +26,7 @@ def test_authz_allow_success(self): ctx = Context() ctx.state = dict() authz_service.process(ctx, resp) - except SATOSAAuthenticationError as ex: + except SATOSAAuthenticationError: assert False def test_authz_allow_fail(self): @@ -38,13 +39,10 @@ def test_authz_allow_fail(self): resp.attributes = { "a0": ["bar"], } - try: + with pytest.raises(SATOSAAuthenticationError): ctx = Context() ctx.state = dict() authz_service.process(ctx, resp) - assert False - except SATOSAAuthenticationError as ex: - assert True def test_authz_allow_second(self): attribute_allow = { @@ -60,7 +58,7 @@ def test_authz_allow_second(self): ctx = Context() ctx.state = dict() authz_service.process(ctx, resp) - except SATOSAAuthenticationError as ex: + except SATOSAAuthenticationError: assert False def test_authz_deny_success(self): @@ -73,13 +71,10 @@ def test_authz_deny_success(self): resp.attributes = { "a0": ["foo2"], } - try: + with pytest.raises(SATOSAAuthenticationError): ctx = Context() ctx.state = dict() authz_service.process(ctx, resp) - assert False - except SATOSAAuthenticationError as ex: - assert True def test_authz_deny_fail(self): attribute_deny = { @@ -95,5 +90,5 @@ def test_authz_deny_fail(self): ctx = Context() ctx.state = dict() authz_service.process(ctx, resp) - except SATOSAAuthenticationError as ex: + except SATOSAAuthenticationError: assert False diff --git a/tox.ini b/tox.ini index 1ed5cf92e..95cbdc864 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,6 @@ ignore = E302 E303 E703 - F841 W291 W292 W293 From 28b3c363194b00e0db37092419f356617922b203 Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Tue, 15 Nov 2022 12:33:11 +0100 Subject: [PATCH 325/401] tests: remove real MongoDB dependency (#422) OIDC integration tests now use mongomock instead of launching a full mongodb server, which may not be available in a development environment. --- tests/conftest.py | 85 ------------------- tests/flows/test_oidc-saml.py | 29 ++++--- .../scripts/test_satosa_saml_metadata.py | 4 +- tests/test_requirements.txt | 1 + 4 files changed, 22 insertions(+), 97 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9e7a5e18f..e6c11fa36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -361,88 +361,3 @@ def consent_module_config(signing_key_path): } } return consent_config - - -import atexit -import random -import shutil -import subprocess -import tempfile -import time - -import pymongo -import pytest - - -class MongoTemporaryInstance(object): - """Singleton to manage a temporary MongoDB instance - - Use this for testing purpose only. The instance is automatically destroyed - at the end of the program. - - """ - _instance = None - - @classmethod - def get_instance(cls): - if cls._instance is None: - cls._instance = cls() - atexit.register(cls._instance.shutdown) - return cls._instance - - def __init__(self): - self._tmpdir = tempfile.mkdtemp() - self._port = 27017 - self._process = subprocess.Popen(['mongod', '--bind_ip', 'localhost', - '--port', str(self._port), - '--dbpath', self._tmpdir, - '--nojournal', - '--noauth', - '--syncdelay', '0'], - stdout=open('/tmp/mongo-temp.log', 'wb'), - stderr=subprocess.STDOUT) - - # XXX: wait for the instance to be ready - # Mongo is ready in a glance, we just wait to be able to open a - # Connection. - for i in range(10): - time.sleep(0.2) - try: - self._conn = pymongo.MongoClient('localhost', self._port) - except pymongo.errors.ConnectionFailure: - continue - else: - break - else: - self.shutdown() - assert False, 'Cannot connect to the mongodb test instance' - - @property - def conn(self): - return self._conn - - @property - def port(self): - return self._port - - def shutdown(self): - if self._process: - self._process.terminate() - self._process.wait() - self._process = None - shutil.rmtree(self._tmpdir, ignore_errors=True) - - def get_uri(self): - """ - Convenience function to get a mongodb URI to the temporary database. - - :return: URI - """ - return 'mongodb://localhost:{port!s}'.format(port=self.port) - - -@pytest.fixture -def mongodb_instance(): - tmp_db = MongoTemporaryInstance() - yield tmp_db - tmp_db.shutdown() diff --git a/tests/flows/test_oidc-saml.py b/tests/flows/test_oidc-saml.py index 257a8f7c9..2a299bfef 100644 --- a/tests/flows/test_oidc-saml.py +++ b/tests/flows/test_oidc-saml.py @@ -3,11 +3,12 @@ import base64 from urllib.parse import urlparse, urlencode, parse_qsl +import mongomock import pytest from jwkest.jwk import rsa_load, RSAKey from jwkest.jws import JWS from oic.oic.message import ClaimsRequest, Claims -from pyop.storage import MongoWrapper +from pyop.storage import StorageBase from saml2 import BINDING_HTTP_REDIRECT from saml2.config import IdPConfig from werkzeug.test import Client @@ -25,6 +26,7 @@ CLIENT_SECRET = "secret" CLIENT_REDIRECT_URI = "https://client.example.com/cb" REDIRECT_URI = "https://client.example.com/cb" +DB_URI = "mongodb://localhost/satosa" @pytest.fixture(scope="session") def client_db_path(tmpdir_factory): @@ -45,7 +47,7 @@ def client_db_path(tmpdir_factory): return path @pytest.fixture -def oidc_frontend_config(signing_key_path, mongodb_instance): +def oidc_frontend_config(signing_key_path): data = { "module": "satosa.frontends.openid_connect.OpenIDConnectFrontend", "name": "OIDCFrontend", @@ -53,18 +55,11 @@ def oidc_frontend_config(signing_key_path, mongodb_instance): "issuer": "https://proxy-op.example.com", "signing_key_path": signing_key_path, "provider": {"response_types_supported": ["id_token"]}, - "client_db_uri": mongodb_instance.get_uri(), # use mongodb for integration testing - "db_uri": mongodb_instance.get_uri() # use mongodb for integration testing + "client_db_uri": DB_URI, # use mongodb for integration testing + "db_uri": DB_URI # use mongodb for integration testing } } - # insert client in mongodb - cdb = MongoWrapper(mongodb_instance.get_uri(), "satosa", "clients") - cdb[CLIENT_ID] = { - "redirect_uris": [REDIRECT_URI], - "response_types": ["id_token"] - } - return data @@ -87,8 +82,20 @@ def oidc_stateless_frontend_config(signing_key_path, client_db_path): return data +@mongomock.patch(servers=(('localhost', 27017),)) class TestOIDCToSAML: + def _client_setup(self): + """Insert client in mongodb.""" + self._cdb = StorageBase.from_uri( + DB_URI, db_name="satosa", collection="clients", ttl=None + ) + self._cdb[CLIENT_ID] = { + "redirect_uris": [REDIRECT_URI], + "response_types": ["id_token"] + } + def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_config, idp_conf): + self._client_setup() subject_id = "testuser1" # proxy config diff --git a/tests/satosa/scripts/test_satosa_saml_metadata.py b/tests/satosa/scripts/test_satosa_saml_metadata.py index 26809dc2a..f76f5d990 100644 --- a/tests/satosa/scripts/test_satosa_saml_metadata.py +++ b/tests/satosa/scripts/test_satosa_saml_metadata.py @@ -1,6 +1,7 @@ import glob import os +import mongomock import pytest from saml2.config import Config from saml2.mdstore import MetaDataFile @@ -10,7 +11,7 @@ @pytest.fixture -def oidc_frontend_config(signing_key_path, mongodb_instance): +def oidc_frontend_config(signing_key_path): data = { "module": "satosa.frontends.openid_connect.OpenIDConnectFrontend", "name": "OIDCFrontend", @@ -23,6 +24,7 @@ def oidc_frontend_config(signing_key_path, mongodb_instance): return data +@mongomock.patch(servers=(('localhost', 27017),)) class TestConstructSAMLMetadata: def test_saml_saml(self, tmpdir, cert_and_key, satosa_config_dict, saml_frontend_config, saml_backend_config): diff --git a/tests/test_requirements.txt b/tests/test_requirements.txt index bf7f30deb..1991e4cac 100644 --- a/tests/test_requirements.txt +++ b/tests/test_requirements.txt @@ -2,3 +2,4 @@ pytest responses beautifulsoup4 ldap3 +mongomock From 0a57dab317e1d61dbff26d124649b2e4333fee80 Mon Sep 17 00:00:00 2001 From: Dick Visser Date: Tue, 15 Nov 2022 12:41:53 +0100 Subject: [PATCH 326/401] docs: fix typos and grammar --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 112d9459b..4a8d757eb 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ configure and run such a proxy instance please visit [Single Service Provider<->Multiple Identity providers](doc/one-to-many.md) If an identity provider can not communicate with service providers in for -example a federation the can convert request and make the communication +example a federation, they can convert requests and make the communication possible. @@ -65,8 +65,8 @@ possible. This setup makes it possible to connect a SAML2 service provider to multiple social media identity providers such as Google and Facebook. The proxy makes it -possible to mirror a identity provider by generating SAML2 metadata -corresponding that provider and create dynamic endpoint which are connected to +possible to mirror an identity provider by generating SAML2 metadata +corresponding to that provider and create dynamic endpoints which are connected to a single identity provider. For more information about how to set up, configure and run such a proxy From 802ec54a3521b65d22a0008c0e25914939326dad Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Tue, 15 Nov 2022 13:05:41 +0100 Subject: [PATCH 327/401] saml2 backend: support using multiple ACS URLs (#409) * saml2 backend: support using multiple ACS URLs When Satosa sends out a SAML2 AuthnRequest, it specifies the AssertionConsumerServiceUrl parameter as well, unless the `hide_assertion_consumer_service` configuration parameter is set. However, Satosa might be deployed in an environment where not all interfaces and host names are accessible for all users. After this change, Satosa tries to select the ACS URL based on the current request, and falls back to the first ACS if there is no match. * squash! saml2 backend: support using multiple ACS URLs Make ACS selection configurable with the `acs_selection_strategy` parameter, keeping the default backwards-compatible (`use_first_acs`). Added the relevant example and documentation. Additionally, log an error (instead of debug) message if the authentication request can not be constructed, since most of the time this is a configuration or environment error. --- doc/README.md | 20 ++++++ .../backends/saml2_backend.yaml.example | 1 + src/satosa/backends/saml2.py | 57 ++++++++++++++++- tests/satosa/backends/test_saml2.py | 62 ++++++++++++++++++- 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/doc/README.md b/doc/README.md index c5b8317ef..8d001847e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -424,6 +424,26 @@ config: [...] ``` +#### Assertion Consumer Service selection + +When SATOSA sends the SAML2 authentication request to the IDP, it always +specifies the AssertionConsumerServiceURL and binding. When +`acs_selection_strategy` configuration option is set to `use_first_acs` (the +default), then the first element of the `assertion_consumer_service` list will +be selected. If `acs_selection_strategy` is `prefer_matching_host`, then SATOSA +will try to select the `assertion_consumer_service`, which matches the host in +the HTTP request (in simple words, it tries to select an ACS that matches the +URL in the user's browser). If there is no match, it will fall back to using the +first assertion consumer service. + +Default value: `use_first_acs`. + +```yaml +config: + acs_selection_strategy: prefer_matching_host + [...] +``` + ## OpenID Connect plugins ### OIDC Frontend diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index 2dbe97092..3fb30fb2a 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -16,6 +16,7 @@ config: use_memorized_idp_when_force_authn: no send_requester_id: no enable_metadata_reload: no + acs_selection_strategy: prefer_matching_host sp_config: name: "SP Name" diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index b6d0d8910..be7a095fb 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -289,10 +289,11 @@ def authn_request(self, context, entity_id): kwargs["is_passive"] = "true" try: - acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0] + acs_endp, response_binding = self._get_acs(context) relay_state = util.rndstr() req_id, binding, http_info = self.sp.prepare_for_negotiated_authenticate( entityid=entity_id, + assertion_consumer_service_url=acs_endp, response_binding=response_binding, relay_state=relay_state, **kwargs, @@ -300,7 +301,7 @@ def authn_request(self, context, entity_id): except Exception as e: 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) + logger.error(logline, exc_info=True) raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from e if self.sp.config.getattr('allow_unsolicited', 'sp') is False: @@ -314,6 +315,58 @@ def authn_request(self, context, entity_id): context.state[self.name] = {"relay_state": relay_state} return make_saml_response(binding, http_info) + def _get_acs(self, context): + """ + Select the AssertionConsumerServiceURL and binding. + + :param context: The current context + :type context: satosa.context.Context + :return: Selected ACS URL and binding + :rtype: tuple(str, str) + """ + acs_strategy = self.config.get("acs_selection_strategy", "use_first_acs") + if acs_strategy == "use_first_acs": + acs_strategy_fn = self._use_first_acs + elif acs_strategy == "prefer_matching_host": + acs_strategy_fn = self._prefer_matching_host + else: + msg = "Invalid value for '{}' ({}). Using the first ACS instead".format( + "acs_selection_strategy", acs_strategy + ) + logger.error(msg) + acs_strategy_fn = self._use_first_acs + return acs_strategy_fn(context) + + def _use_first_acs(self, context): + return self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][ + 0 + ] + + def _prefer_matching_host(self, context): + acs_config = self.sp.config.getattr("endpoints", "sp")[ + "assertion_consumer_service" + ] + try: + hostname = context.http_headers["HTTP_HOST"] + for acs, binding in acs_config: + parsed_acs = urlparse(acs) + if hostname == parsed_acs.netloc: + msg = "Selected ACS '{}' based on the request".format(acs) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) + logger.debug(logline) + return acs, binding + except (TypeError, KeyError): + pass + + msg = "Can't find an ACS URL to this hostname ({}), selecting the first one".format( + context.http_headers.get("HTTP_HOST", "") if context.http_headers else "" + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + return self._use_first_acs(context) + def authn_response(self, context, binding): """ Endpoint for the idp response diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index eed74db6c..dcfdb0fa9 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -12,9 +12,11 @@ import pytest import saml2 -from saml2 import BINDING_HTTP_REDIRECT +from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST from saml2.authn_context import PASSWORD from saml2.config import IdPConfig, SPConfig +from saml2.entity import Entity +from saml2.samlp import authn_request_from_string from saml2.s_utils import deflate_and_base64_encode from satosa.backends.saml2 import SAMLBackend @@ -179,6 +181,64 @@ def test_authn_request(self, context, idp_conf): req_params = dict(parse_qsl(urlparse(resp.message).query)) assert context.state[self.samlbackend.name]["relay_state"] == req_params["RelayState"] + @pytest.mark.parametrize("hostname", ["example.com:8443", "example.net"]) + @pytest.mark.parametrize( + "strat", + ["", "use_first_acs", "prefer_matching_host", "invalid"], + ) + def test_acs_selection_strategy(self, context, sp_conf, idp_conf, hostname, strat): + acs_endpoints = [ + ("https://example.com/saml2/acs/post", BINDING_HTTP_POST), + ("https://example.net/saml2/acs/post", BINDING_HTTP_POST), + ("https://example.com:8443/saml2/acs/post", BINDING_HTTP_POST), + ] + config = {"sp_config": sp_conf} + config["sp_config"]["service"]["sp"]["endpoints"][ + "assertion_consumer_service" + ] = acs_endpoints + if strat: + config["acs_selection_strategy"] = strat + + req = self._make_authn_request(hostname, context, config, idp_conf["entityid"]) + + if strat == "prefer_matching_host": + expected_acs = hostname + else: + expected_acs = urlparse(acs_endpoints[0][0]).netloc + assert urlparse(req.assertion_consumer_service_url).netloc == expected_acs + + def _make_authn_request(self, http_host, context, config, entity_id): + context.http_headers = {"HTTP_HOST": http_host} if http_host else {} + self.samlbackend = SAMLBackend( + Mock(), + INTERNAL_ATTRIBUTES, + config, + "base_url", + "samlbackend", + ) + resp = self.samlbackend.authn_request(context, entity_id) + req_params = dict(parse_qsl(urlparse(resp.message).query)) + req_xml = Entity.unravel(req_params["SAMLRequest"], BINDING_HTTP_REDIRECT) + return authn_request_from_string(req_xml) + + @pytest.mark.parametrize("hostname", ["unknown-hostname", None]) + def test_unknown_or_no_hostname_selects_first_acs( + self, context, sp_conf, idp_conf, hostname + ): + config = {"sp_config": sp_conf} + config["sp_config"]["service"]["sp"]["endpoints"][ + "assertion_consumer_service" + ] = ( + ("https://first-hostname/saml2/acs/post", BINDING_HTTP_POST), + ("https://other-hostname/saml2/acs/post", BINDING_HTTP_POST), + ) + config["acs_selection_strategy"] = "prefer_matching_host" + req = self._make_authn_request(hostname, context, config, idp_conf["entityid"]) + assert ( + req.assertion_consumer_service_url + == "https://first-hostname/saml2/acs/post" + ) + def test_authn_response(self, context, idp_conf, sp_conf): response_binding = BINDING_HTTP_REDIRECT fakesp = FakeSP(SPConfig().load(sp_conf)) From d7de930be9483d9e70567e564bcdb6bca71d7416 Mon Sep 17 00:00:00 2001 From: Johan Wassberg Date: Wed, 16 Nov 2022 09:21:45 +0100 Subject: [PATCH 328/401] Deny auth if requested attribute is missing If a requested attribute is missing the authorization should fail --- .../micro_services/attribute_authorization.py | 3 +++ .../micro_services/test_attribute_authorization.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/satosa/micro_services/attribute_authorization.py b/src/satosa/micro_services/attribute_authorization.py index 1bcaf8cda..9aca606a7 100644 --- a/src/satosa/micro_services/attribute_authorization.py +++ b/src/satosa/micro_services/attribute_authorization.py @@ -53,6 +53,9 @@ def _check_authz(self, context, attributes, requester, provider): if attribute_name in attributes: if not any([any(filter(re.compile(af).search, attributes[attribute_name])) for af in attribute_filters]): raise SATOSAAuthenticationError(context.state, "Permission denied") + else: + raise SATOSAAuthenticationError(context.state, "Permission denied") + for attribute_name, attribute_filters in get_dict_defaults(self.attribute_deny, requester, provider).items(): if attribute_name in attributes: diff --git a/tests/satosa/micro_services/test_attribute_authorization.py b/tests/satosa/micro_services/test_attribute_authorization.py index 15b1458ff..10de7d0f7 100644 --- a/tests/satosa/micro_services/test_attribute_authorization.py +++ b/tests/satosa/micro_services/test_attribute_authorization.py @@ -44,6 +44,20 @@ def test_authz_allow_fail(self): ctx.state = dict() authz_service.process(ctx, resp) + def test_authz_allow_missing(self): + attribute_allow = { + "": { "default": {"a0": ['foo1','foo2']} } + } + attribute_deny = {} + authz_service = self.create_authz_service(attribute_allow, attribute_deny) + resp = InternalData(auth_info=AuthenticationInformation()) + resp.attributes = { + } + with pytest.raises(SATOSAAuthenticationError): + ctx = Context() + ctx.state = dict() + authz_service.process(ctx, resp) + def test_authz_allow_second(self): attribute_allow = { "": { "default": {"a0": ['foo1','foo2']} } From 9d6c1bec573edad613f403f2e84632ba4b4c0d58 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 16 Nov 2022 16:31:47 +0200 Subject: [PATCH 329/401] Make attribute presence enforcement configurable Signed-off-by: Ivan Kanakarakis --- .../micro_services/attribute_authorization.py | 94 ++++++++++++------- .../test_attribute_authorization.py | 22 ++++- 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/src/satosa/micro_services/attribute_authorization.py b/src/satosa/micro_services/attribute_authorization.py index 9aca606a7..60f4afe4b 100644 --- a/src/satosa/micro_services/attribute_authorization.py +++ b/src/satosa/micro_services/attribute_authorization.py @@ -5,62 +5,86 @@ from ..util import get_dict_defaults class AttributeAuthorization(ResponseMicroService): - """ -A microservice that performs simple regexp-based authorization based on response -attributes. The configuration assumes a dict with two keys: attributes_allow -and attributes_deny. An examples speaks volumes: + A microservice that performs simple regexp-based authorization based on response + attributes. There are two configuration options to match attribute values in order + to allow or deny authorization. + + The configuration is wrapped in two nested dicts that specialize the options per + requester (SP/RP) and issuer (IdP/OP). + + There are also two options to enforce presence of the attributes that are going to + be checked. + + Example configuration: -```yaml -config: - attribute_allow: - target_provider1: + ```yaml + config: + force_attributes_presence_on_allow: true + attribute_allow: + target_provider1: requester1: - attr1: - - "^foo:bar$" - - "^kaka$" + attr1: + - "^foo:bar$" + - "^kaka$" default: - attr1: - - "plupp@.+$" - "": + attr1: + - "plupp@.+$" + "": "": - attr2: - - "^knytte:.*$" - attribute_deny: - default: - default: - eppn: - - "^[^@]+$" + attr2: + - "^knytte:.*$" -``` + force_attributes_presence_on_deny: false + attribute_deny: + default: + default: + eppn: + - "^[^@]+$" + ``` -The use of "" and 'default' is synonymous. Attribute rules are not overloaded -or inherited. For instance a response from "provider2" would only be allowed -through if the eppn attribute had all values containing an '@' (something -perhaps best implemented via an allow rule in practice). Responses from -target_provider1 bound for requester1 would be allowed through only if attr1 -contained foo:bar or kaka. Note that attribute filters (the leaves of the -structure above) are ORed together - i.e any attribute match is sufficient. + The use of "" and "default" is synonymous. Attribute rules are not overloaded + or inherited. For instance a response from "provider2" would only be allowed + through if the eppn attribute had all values containing an '@' (something + perhaps best implemented via an allow rule in practice). Responses from + target_provider1 bound for requester1 would be allowed through only if attr1 + contained foo:bar or kaka. Note that attribute filters (the leaves of the + structure above) are ORed together - i.e any attribute match is sufficient. """ def __init__(self, config, *args, **kwargs): super().__init__(*args, **kwargs) self.attribute_allow = config.get("attribute_allow", {}) self.attribute_deny = config.get("attribute_deny", {}) + self.force_attributes_presence_on_allow = config.get("force_attributes_presence_on_allow", False) + self.force_attributes_presence_on_deny = config.get("force_attributes_presence_on_deny", False) def _check_authz(self, context, attributes, requester, provider): for attribute_name, attribute_filters in get_dict_defaults(self.attribute_allow, requester, provider).items(): - if attribute_name in attributes: - if not any([any(filter(re.compile(af).search, attributes[attribute_name])) for af in attribute_filters]): + attr_values = attributes.get(attribute_name) + if attr_values is not None: + if not any( + [ + any(filter(lambda x: re.search(af, x), attr_values)) + for af in attribute_filters + ] + ): raise SATOSAAuthenticationError(context.state, "Permission denied") - else: + elif self.force_attributes_presence_on_allow: raise SATOSAAuthenticationError(context.state, "Permission denied") - for attribute_name, attribute_filters in get_dict_defaults(self.attribute_deny, requester, provider).items(): - if attribute_name in attributes: - if any([any(filter(re.compile(af).search, attributes[attribute_name])) for af in attribute_filters]): + attr_values = attributes.get(attribute_name) + if attr_values is not None: + if any( + [ + any(filter(lambda x: re.search(af, x), attributes[attribute_name])) + for af in attribute_filters + ] + ): raise SATOSAAuthenticationError(context.state, "Permission denied") + elif self.force_attributes_presence_on_deny: + raise SATOSAAuthenticationError(context.state, "Permission denied") def process(self, context, data): self._check_authz(context, data.attributes, data.requester, data.auth_info.issuer) diff --git a/tests/satosa/micro_services/test_attribute_authorization.py b/tests/satosa/micro_services/test_attribute_authorization.py index 10de7d0f7..6fb277d15 100644 --- a/tests/satosa/micro_services/test_attribute_authorization.py +++ b/tests/satosa/micro_services/test_attribute_authorization.py @@ -6,9 +6,23 @@ from satosa.context import Context class TestAttributeAuthorization: - def create_authz_service(self, attribute_allow, attribute_deny): - authz_service = AttributeAuthorization(config=dict(attribute_allow=attribute_allow,attribute_deny=attribute_deny), name="test_authz", - base_url="https://satosa.example.com") + def create_authz_service( + self, + attribute_allow, + attribute_deny, + force_attributes_presence_on_allow=False, + force_attributes_presence_on_deny=False, + ): + authz_service = AttributeAuthorization( + config=dict( + force_attributes_presence_on_allow=force_attributes_presence_on_allow, + force_attributes_presence_on_deny=force_attributes_presence_on_deny, + attribute_allow=attribute_allow, + attribute_deny=attribute_deny, + ), + name="test_authz", + base_url="https://satosa.example.com", + ) authz_service.next = lambda ctx, data: data return authz_service @@ -49,7 +63,7 @@ def test_authz_allow_missing(self): "": { "default": {"a0": ['foo1','foo2']} } } attribute_deny = {} - authz_service = self.create_authz_service(attribute_allow, attribute_deny) + authz_service = self.create_authz_service(attribute_allow, attribute_deny, force_attributes_presence_on_allow=True) resp = InternalData(auth_info=AuthenticationInformation()) resp.attributes = { } From cfd24b42092d4e2dcce576627385563444c87f52 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 17 Nov 2022 19:55:14 +0200 Subject: [PATCH 330/401] docker: remove local Dockerfile; see satosa-docker instead Repo: https://github.com/IdentityPython/satosa-docker Images: https://hub.docker.com/_/satosa/ Signed-off-by: Ivan Kanakarakis --- docker/attributemaps/__init__.py | 2 - docker/attributemaps/adfs_v1x.py | 18 -- docker/attributemaps/adfs_v20.py | 49 ---- docker/attributemaps/basic.py | 341 ------------------------- docker/attributemaps/saml_uri.py | 307 ---------------------- docker/attributemaps/shibboleth_uri.py | 197 -------------- docker/setup.sh | 10 - docker/start.sh | 66 ----- 8 files changed, 990 deletions(-) delete mode 100644 docker/attributemaps/__init__.py delete mode 100644 docker/attributemaps/adfs_v1x.py delete mode 100644 docker/attributemaps/adfs_v20.py delete mode 100644 docker/attributemaps/basic.py delete mode 100644 docker/attributemaps/saml_uri.py delete mode 100644 docker/attributemaps/shibboleth_uri.py delete mode 100755 docker/setup.sh delete mode 100755 docker/start.sh diff --git a/docker/attributemaps/__init__.py b/docker/attributemaps/__init__.py deleted file mode 100644 index d041d3f13..000000000 --- a/docker/attributemaps/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__author__ = 'rohe0002' -__all__ = ["adfs_v1x", "adfs_v20", "basic", "saml_uri", "shibboleth_uri"] diff --git a/docker/attributemaps/adfs_v1x.py b/docker/attributemaps/adfs_v1x.py deleted file mode 100644 index 0f8d01a5d..000000000 --- a/docker/attributemaps/adfs_v1x.py +++ /dev/null @@ -1,18 +0,0 @@ -CLAIMS = 'http://schemas.xmlsoap.org/claims/' - - -MAP = { - "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified", - 'fro': { - CLAIMS+'commonname': 'commonName', - CLAIMS+'emailaddress': 'emailAddress', - CLAIMS+'group': 'group', - CLAIMS+'upn': 'upn', - }, - 'to': { - 'commonName': CLAIMS+'commonname', - 'emailAddress': CLAIMS+'emailaddress', - 'group': CLAIMS+'group', - 'upn': CLAIMS+'upn', - } -} diff --git a/docker/attributemaps/adfs_v20.py b/docker/attributemaps/adfs_v20.py deleted file mode 100644 index 94150d077..000000000 --- a/docker/attributemaps/adfs_v20.py +++ /dev/null @@ -1,49 +0,0 @@ -CLAIMS = 'http://schemas.xmlsoap.org/claims/' -COM_WS_CLAIMS = 'http://schemas.xmlsoap.com/ws/2005/05/identity/claims/' -MS_CLAIMS = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/' -ORG_WS_CLAIMS = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/' - - -MAP = { - "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified", - 'fro': { - CLAIMS+'commonname': 'commonName', - CLAIMS+'group': 'group', - COM_WS_CLAIMS+'denyonlysid': 'denyOnlySid', - MS_CLAIMS+'authenticationmethod': 'authenticationMethod', - MS_CLAIMS+'denyonlyprimarygroupsid': 'denyOnlyPrimaryGroupSid', - MS_CLAIMS+'denyonlyprimarysid': 'denyOnlyPrimarySid', - MS_CLAIMS+'groupsid': 'groupSid', - MS_CLAIMS+'primarygroupsid': 'primaryGroupSid', - MS_CLAIMS+'primarysid': 'primarySid', - MS_CLAIMS+'role': 'role', - MS_CLAIMS+'windowsaccountname': 'windowsAccountName', - ORG_WS_CLAIMS+'emailaddress': 'emailAddress', - ORG_WS_CLAIMS+'givenname': 'givenName', - ORG_WS_CLAIMS+'name': 'name', - ORG_WS_CLAIMS+'nameidentifier': 'nameId', - ORG_WS_CLAIMS+'privatepersonalidentifier': 'privatePersonalId', - ORG_WS_CLAIMS+'surname': 'surname', - ORG_WS_CLAIMS+'upn': 'upn', - }, - 'to': { - 'authenticationMethod': MS_CLAIMS+'authenticationmethod', - 'commonName': CLAIMS+'commonname', - 'denyOnlyPrimaryGroupSid': MS_CLAIMS+'denyonlyprimarygroupsid', - 'denyOnlyPrimarySid': MS_CLAIMS+'denyonlyprimarysid', - 'denyOnlySid': COM_WS_CLAIMS+'denyonlysid', - 'emailAddress': ORG_WS_CLAIMS+'emailaddress', - 'givenName': ORG_WS_CLAIMS+'givenname', - 'group': CLAIMS+'group', - 'groupSid': MS_CLAIMS+'groupsid', - 'name': ORG_WS_CLAIMS+'name', - 'nameId': ORG_WS_CLAIMS+'nameidentifier', - 'primaryGroupSid': MS_CLAIMS+'primarygroupsid', - 'primarySid': MS_CLAIMS+'primarysid', - 'privatePersonalId': ORG_WS_CLAIMS+'privatepersonalidentifier', - 'role': MS_CLAIMS+'role', - 'surname': ORG_WS_CLAIMS+'surname', - 'upn': ORG_WS_CLAIMS+'upn', - 'windowsAccountName': MS_CLAIMS+'windowsaccountname', - } -} diff --git a/docker/attributemaps/basic.py b/docker/attributemaps/basic.py deleted file mode 100644 index 9d84b8236..000000000 --- a/docker/attributemaps/basic.py +++ /dev/null @@ -1,341 +0,0 @@ -DEF = 'urn:mace:dir:attribute-def:' - - -MAP = { - "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - 'fro': { - DEF+'aRecord': 'aRecord', - DEF+'aliasedEntryName': 'aliasedEntryName', - DEF+'aliasedObjectName': 'aliasedObjectName', - DEF+'associatedDomain': 'associatedDomain', - DEF+'associatedName': 'associatedName', - DEF+'audio': 'audio', - DEF+'authorityRevocationList': 'authorityRevocationList', - DEF+'buildingName': 'buildingName', - DEF+'businessCategory': 'businessCategory', - DEF+'c': 'c', - DEF+'cACertificate': 'cACertificate', - DEF+'cNAMERecord': 'cNAMERecord', - DEF+'carLicense': 'carLicense', - DEF+'certificateRevocationList': 'certificateRevocationList', - DEF+'cn': 'cn', - DEF+'co': 'co', - DEF+'commonName': 'commonName', - DEF+'countryName': 'countryName', - DEF+'crossCertificatePair': 'crossCertificatePair', - DEF+'dITRedirect': 'dITRedirect', - DEF+'dSAQuality': 'dSAQuality', - DEF+'dc': 'dc', - DEF+'deltaRevocationList': 'deltaRevocationList', - DEF+'departmentNumber': 'departmentNumber', - DEF+'description': 'description', - DEF+'destinationIndicator': 'destinationIndicator', - DEF+'displayName': 'displayName', - DEF+'distinguishedName': 'distinguishedName', - DEF+'dmdName': 'dmdName', - DEF+'dnQualifier': 'dnQualifier', - DEF+'documentAuthor': 'documentAuthor', - DEF+'documentIdentifier': 'documentIdentifier', - DEF+'documentLocation': 'documentLocation', - DEF+'documentPublisher': 'documentPublisher', - DEF+'documentTitle': 'documentTitle', - DEF+'documentVersion': 'documentVersion', - DEF+'domainComponent': 'domainComponent', - DEF+'drink': 'drink', - DEF+'eduOrgHomePageURI': 'eduOrgHomePageURI', - DEF+'eduOrgIdentityAuthNPolicyURI': 'eduOrgIdentityAuthNPolicyURI', - DEF+'eduOrgLegalName': 'eduOrgLegalName', - DEF+'eduOrgSuperiorURI': 'eduOrgSuperiorURI', - DEF+'eduOrgWhitePagesURI': 'eduOrgWhitePagesURI', - DEF+'eduCourseOffering': 'eduCourseOffering', - DEF+'eduCourseMember': 'eduCourseMember', - DEF+'eduPersonAffiliation': 'eduPersonAffiliation', - DEF+'eduPersonEntitlement': 'eduPersonEntitlement', - DEF+'eduPersonNickname': 'eduPersonNickname', - DEF+'eduPersonOrgDN': 'eduPersonOrgDN', - DEF+'eduPersonOrgUnitDN': 'eduPersonOrgUnitDN', - DEF+'eduPersonPrimaryAffiliation': 'eduPersonPrimaryAffiliation', - DEF+'eduPersonPrimaryOrgUnitDN': 'eduPersonPrimaryOrgUnitDN', - DEF+'eduPersonPrincipalName': 'eduPersonPrincipalName', - DEF+'eduPersonPrincipalNamePrior': 'eduPersonPrincipalNamePrior', - DEF+'eduPersonScopedAffiliation': 'eduPersonScopedAffiliation', - DEF+'eduPersonTargetedID': 'eduPersonTargetedID', - DEF+'eduPersonAssurance': 'eduPersonAssurance', - DEF+'eduPersonUniqueId': 'eduPersonUniqueId', - DEF+'eduPersonOrcid': 'eduPersonOrcid', - DEF+'email': 'email', - DEF+'emailAddress': 'emailAddress', - DEF+'employeeNumber': 'employeeNumber', - DEF+'employeeType': 'employeeType', - DEF+'enhancedSearchGuide': 'enhancedSearchGuide', - DEF+'facsimileTelephoneNumber': 'facsimileTelephoneNumber', - DEF+'favouriteDrink': 'favouriteDrink', - DEF+'fax': 'fax', - DEF+'federationFeideSchemaVersion': 'federationFeideSchemaVersion', - DEF+'friendlyCountryName': 'friendlyCountryName', - DEF+'generationQualifier': 'generationQualifier', - DEF+'givenName': 'givenName', - DEF+'gn': 'gn', - DEF+'homePhone': 'homePhone', - DEF+'homePostalAddress': 'homePostalAddress', - DEF+'homeTelephoneNumber': 'homeTelephoneNumber', - DEF+'host': 'host', - DEF+'houseIdentifier': 'houseIdentifier', - DEF+'info': 'info', - DEF+'initials': 'initials', - DEF+'internationaliSDNNumber': 'internationaliSDNNumber', - DEF+'isMemberOf': 'isMemberOf', - DEF+'janetMailbox': 'janetMailbox', - DEF+'jpegPhoto': 'jpegPhoto', - DEF+'knowledgeInformation': 'knowledgeInformation', - DEF+'l': 'l', - DEF+'labeledURI': 'labeledURI', - DEF+'localityName': 'localityName', - DEF+'mDRecord': 'mDRecord', - DEF+'mXRecord': 'mXRecord', - DEF+'mail': 'mail', - DEF+'mailPreferenceOption': 'mailPreferenceOption', - DEF+'manager': 'manager', - DEF+'member': 'member', - DEF+'mobile': 'mobile', - DEF+'mobileTelephoneNumber': 'mobileTelephoneNumber', - DEF+'nSRecord': 'nSRecord', - DEF+'name': 'name', - DEF+'norEduOrgAcronym': 'norEduOrgAcronym', - DEF+'norEduOrgNIN': 'norEduOrgNIN', - DEF+'norEduOrgSchemaVersion': 'norEduOrgSchemaVersion', - DEF+'norEduOrgUniqueIdentifier': 'norEduOrgUniqueIdentifier', - DEF+'norEduOrgUniqueNumber': 'norEduOrgUniqueNumber', - DEF+'norEduOrgUnitUniqueIdentifier': 'norEduOrgUnitUniqueIdentifier', - DEF+'norEduOrgUnitUniqueNumber': 'norEduOrgUnitUniqueNumber', - DEF+'norEduPersonBirthDate': 'norEduPersonBirthDate', - DEF+'norEduPersonLIN': 'norEduPersonLIN', - DEF+'norEduPersonNIN': 'norEduPersonNIN', - DEF+'o': 'o', - DEF+'objectClass': 'objectClass', - DEF+'organizationName': 'organizationName', - DEF+'organizationalStatus': 'organizationalStatus', - DEF+'organizationalUnitName': 'organizationalUnitName', - DEF+'otherMailbox': 'otherMailbox', - DEF+'ou': 'ou', - DEF+'owner': 'owner', - DEF+'pager': 'pager', - DEF+'pagerTelephoneNumber': 'pagerTelephoneNumber', - DEF+'personalSignature': 'personalSignature', - DEF+'personalTitle': 'personalTitle', - DEF+'photo': 'photo', - DEF+'physicalDeliveryOfficeName': 'physicalDeliveryOfficeName', - DEF+'pkcs9email': 'pkcs9email', - DEF+'postOfficeBox': 'postOfficeBox', - DEF+'postalAddress': 'postalAddress', - DEF+'postalCode': 'postalCode', - DEF+'preferredDeliveryMethod': 'preferredDeliveryMethod', - DEF+'preferredLanguage': 'preferredLanguage', - DEF+'presentationAddress': 'presentationAddress', - DEF+'protocolInformation': 'protocolInformation', - DEF+'pseudonym': 'pseudonym', - DEF+'registeredAddress': 'registeredAddress', - DEF+'rfc822Mailbox': 'rfc822Mailbox', - DEF+'roleOccupant': 'roleOccupant', - DEF+'roomNumber': 'roomNumber', - DEF+'sOARecord': 'sOARecord', - DEF+'searchGuide': 'searchGuide', - DEF+'secretary': 'secretary', - DEF+'seeAlso': 'seeAlso', - DEF+'serialNumber': 'serialNumber', - DEF+'singleLevelQuality': 'singleLevelQuality', - DEF+'sn': 'sn', - DEF+'st': 'st', - DEF+'stateOrProvinceName': 'stateOrProvinceName', - DEF+'street': 'street', - DEF+'streetAddress': 'streetAddress', - DEF+'subtreeMaximumQuality': 'subtreeMaximumQuality', - DEF+'subtreeMinimumQuality': 'subtreeMinimumQuality', - DEF+'supportedAlgorithms': 'supportedAlgorithms', - DEF+'supportedApplicationContext': 'supportedApplicationContext', - DEF+'surname': 'surname', - DEF+'telephoneNumber': 'telephoneNumber', - DEF+'teletexTerminalIdentifier': 'teletexTerminalIdentifier', - DEF+'telexNumber': 'telexNumber', - DEF+'textEncodedORAddress': 'textEncodedORAddress', - DEF+'title': 'title', - DEF+'uid': 'uid', - DEF+'uniqueIdentifier': 'uniqueIdentifier', - DEF+'uniqueMember': 'uniqueMember', - DEF+'userCertificate': 'userCertificate', - DEF+'userClass': 'userClass', - DEF+'userPKCS12': 'userPKCS12', - DEF+'userPassword': 'userPassword', - DEF+'userSMIMECertificate': 'userSMIMECertificate', - DEF+'userid': 'userid', - DEF+'x121Address': 'x121Address', - DEF+'x500UniqueIdentifier': 'x500UniqueIdentifier', - }, - 'to': { - 'aRecord': DEF+'aRecord', - 'aliasedEntryName': DEF+'aliasedEntryName', - 'aliasedObjectName': DEF+'aliasedObjectName', - 'associatedDomain': DEF+'associatedDomain', - 'associatedName': DEF+'associatedName', - 'audio': DEF+'audio', - 'authorityRevocationList': DEF+'authorityRevocationList', - 'buildingName': DEF+'buildingName', - 'businessCategory': DEF+'businessCategory', - 'c': DEF+'c', - 'cACertificate': DEF+'cACertificate', - 'cNAMERecord': DEF+'cNAMERecord', - 'carLicense': DEF+'carLicense', - 'certificateRevocationList': DEF+'certificateRevocationList', - 'cn': DEF+'cn', - 'co': DEF+'co', - 'commonName': DEF+'commonName', - 'countryName': DEF+'countryName', - 'crossCertificatePair': DEF+'crossCertificatePair', - 'dITRedirect': DEF+'dITRedirect', - 'dSAQuality': DEF+'dSAQuality', - 'dc': DEF+'dc', - 'deltaRevocationList': DEF+'deltaRevocationList', - 'departmentNumber': DEF+'departmentNumber', - 'description': DEF+'description', - 'destinationIndicator': DEF+'destinationIndicator', - 'displayName': DEF+'displayName', - 'distinguishedName': DEF+'distinguishedName', - 'dmdName': DEF+'dmdName', - 'dnQualifier': DEF+'dnQualifier', - 'documentAuthor': DEF+'documentAuthor', - 'documentIdentifier': DEF+'documentIdentifier', - 'documentLocation': DEF+'documentLocation', - 'documentPublisher': DEF+'documentPublisher', - 'documentTitle': DEF+'documentTitle', - 'documentVersion': DEF+'documentVersion', - 'domainComponent': DEF+'domainComponent', - 'drink': DEF+'drink', - 'eduOrgHomePageURI': DEF+'eduOrgHomePageURI', - 'eduOrgIdentityAuthNPolicyURI': DEF+'eduOrgIdentityAuthNPolicyURI', - 'eduOrgLegalName': DEF+'eduOrgLegalName', - 'eduOrgSuperiorURI': DEF+'eduOrgSuperiorURI', - 'eduOrgWhitePagesURI': DEF+'eduOrgWhitePagesURI', - 'eduCourseMember': DEF+'eduCourseMember', - 'eduCourseOffering': DEF+'eduCourseOffering', - 'eduPersonAffiliation': DEF+'eduPersonAffiliation', - 'eduPersonEntitlement': DEF+'eduPersonEntitlement', - 'eduPersonNickname': DEF+'eduPersonNickname', - 'eduPersonOrgDN': DEF+'eduPersonOrgDN', - 'eduPersonOrgUnitDN': DEF+'eduPersonOrgUnitDN', - 'eduPersonPrimaryAffiliation': DEF+'eduPersonPrimaryAffiliation', - 'eduPersonPrimaryOrgUnitDN': DEF+'eduPersonPrimaryOrgUnitDN', - 'eduPersonPrincipalName': DEF+'eduPersonPrincipalName', - 'eduPersonPrincipalNamePrior': DEF+'eduPersonPrincipalNamePrior', - 'eduPersonScopedAffiliation': DEF+'eduPersonScopedAffiliation', - 'eduPersonTargetedID': DEF+'eduPersonTargetedID', - 'eduPersonAssurance': DEF+'eduPersonAssurance', - 'eduPersonUniqueId': DEF+'eduPersonUniqueId', - 'eduPersonOrcid': DEF+'eduPersonOrcid', - 'email': DEF+'email', - 'emailAddress': DEF+'emailAddress', - 'employeeNumber': DEF+'employeeNumber', - 'employeeType': DEF+'employeeType', - 'enhancedSearchGuide': DEF+'enhancedSearchGuide', - 'facsimileTelephoneNumber': DEF+'facsimileTelephoneNumber', - 'favouriteDrink': DEF+'favouriteDrink', - 'fax': DEF+'fax', - 'federationFeideSchemaVersion': DEF+'federationFeideSchemaVersion', - 'friendlyCountryName': DEF+'friendlyCountryName', - 'generationQualifier': DEF+'generationQualifier', - 'givenName': DEF+'givenName', - 'gn': DEF+'gn', - 'homePhone': DEF+'homePhone', - 'homePostalAddress': DEF+'homePostalAddress', - 'homeTelephoneNumber': DEF+'homeTelephoneNumber', - 'host': DEF+'host', - 'houseIdentifier': DEF+'houseIdentifier', - 'info': DEF+'info', - 'initials': DEF+'initials', - 'internationaliSDNNumber': DEF+'internationaliSDNNumber', - 'janetMailbox': DEF+'janetMailbox', - 'jpegPhoto': DEF+'jpegPhoto', - 'knowledgeInformation': DEF+'knowledgeInformation', - 'l': DEF+'l', - 'labeledURI': DEF+'labeledURI', - 'localityName': DEF+'localityName', - 'mDRecord': DEF+'mDRecord', - 'mXRecord': DEF+'mXRecord', - 'mail': DEF+'mail', - 'mailPreferenceOption': DEF+'mailPreferenceOption', - 'manager': DEF+'manager', - 'member': DEF+'member', - 'mobile': DEF+'mobile', - 'mobileTelephoneNumber': DEF+'mobileTelephoneNumber', - 'nSRecord': DEF+'nSRecord', - 'name': DEF+'name', - 'norEduOrgAcronym': DEF+'norEduOrgAcronym', - 'norEduOrgNIN': DEF+'norEduOrgNIN', - 'norEduOrgSchemaVersion': DEF+'norEduOrgSchemaVersion', - 'norEduOrgUniqueIdentifier': DEF+'norEduOrgUniqueIdentifier', - 'norEduOrgUniqueNumber': DEF+'norEduOrgUniqueNumber', - 'norEduOrgUnitUniqueIdentifier': DEF+'norEduOrgUnitUniqueIdentifier', - 'norEduOrgUnitUniqueNumber': DEF+'norEduOrgUnitUniqueNumber', - 'norEduPersonBirthDate': DEF+'norEduPersonBirthDate', - 'norEduPersonLIN': DEF+'norEduPersonLIN', - 'norEduPersonNIN': DEF+'norEduPersonNIN', - 'o': DEF+'o', - 'objectClass': DEF+'objectClass', - 'organizationName': DEF+'organizationName', - 'organizationalStatus': DEF+'organizationalStatus', - 'organizationalUnitName': DEF+'organizationalUnitName', - 'otherMailbox': DEF+'otherMailbox', - 'ou': DEF+'ou', - 'owner': DEF+'owner', - 'pager': DEF+'pager', - 'pagerTelephoneNumber': DEF+'pagerTelephoneNumber', - 'personalSignature': DEF+'personalSignature', - 'personalTitle': DEF+'personalTitle', - 'photo': DEF+'photo', - 'physicalDeliveryOfficeName': DEF+'physicalDeliveryOfficeName', - 'pkcs9email': DEF+'pkcs9email', - 'postOfficeBox': DEF+'postOfficeBox', - 'postalAddress': DEF+'postalAddress', - 'postalCode': DEF+'postalCode', - 'preferredDeliveryMethod': DEF+'preferredDeliveryMethod', - 'preferredLanguage': DEF+'preferredLanguage', - 'presentationAddress': DEF+'presentationAddress', - 'protocolInformation': DEF+'protocolInformation', - 'pseudonym': DEF+'pseudonym', - 'registeredAddress': DEF+'registeredAddress', - 'rfc822Mailbox': DEF+'rfc822Mailbox', - 'roleOccupant': DEF+'roleOccupant', - 'roomNumber': DEF+'roomNumber', - 'sOARecord': DEF+'sOARecord', - 'searchGuide': DEF+'searchGuide', - 'secretary': DEF+'secretary', - 'seeAlso': DEF+'seeAlso', - 'serialNumber': DEF+'serialNumber', - 'singleLevelQuality': DEF+'singleLevelQuality', - 'sn': DEF+'sn', - 'st': DEF+'st', - 'stateOrProvinceName': DEF+'stateOrProvinceName', - 'street': DEF+'street', - 'streetAddress': DEF+'streetAddress', - 'subtreeMaximumQuality': DEF+'subtreeMaximumQuality', - 'subtreeMinimumQuality': DEF+'subtreeMinimumQuality', - 'supportedAlgorithms': DEF+'supportedAlgorithms', - 'supportedApplicationContext': DEF+'supportedApplicationContext', - 'surname': DEF+'surname', - 'telephoneNumber': DEF+'telephoneNumber', - 'teletexTerminalIdentifier': DEF+'teletexTerminalIdentifier', - 'telexNumber': DEF+'telexNumber', - 'textEncodedORAddress': DEF+'textEncodedORAddress', - 'title': DEF+'title', - 'uid': DEF+'uid', - 'uniqueIdentifier': DEF+'uniqueIdentifier', - 'uniqueMember': DEF+'uniqueMember', - 'userCertificate': DEF+'userCertificate', - 'userClass': DEF+'userClass', - 'userPKCS12': DEF+'userPKCS12', - 'userPassword': DEF+'userPassword', - 'userSMIMECertificate': DEF+'userSMIMECertificate', - 'userid': DEF+'userid', - 'x121Address': DEF+'x121Address', - 'x500UniqueIdentifier': DEF+'x500UniqueIdentifier', - } -} diff --git a/docker/attributemaps/saml_uri.py b/docker/attributemaps/saml_uri.py deleted file mode 100644 index ca6dfd840..000000000 --- a/docker/attributemaps/saml_uri.py +++ /dev/null @@ -1,307 +0,0 @@ -EDUCOURSE_OID = 'urn:oid:1.3.6.1.4.1.5923.1.6.1.' -EDUPERSON_OID = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.' -EDUMEMBER1_OID = 'urn:oid:1.3.6.1.4.1.5923.1.5.1.' -LDAPGVAT_OID = 'urn:oid:1.2.40.0.10.2.1.1.' # ldap.gv.at definitions as specified in http://www.ref.gv.at/AG-IZ-PVP2-Version-2-1-0-2.2754.0.html -UCL_DIR_PILOT = 'urn:oid:0.9.2342.19200300.100.1.' -X500ATTR_OID = 'urn:oid:2.5.4.' -LDAPGVAT_UCL_DIR_PILOT = UCL_DIR_PILOT -LDAPGVAT_X500ATTR_OID = X500ATTR_OID -NETSCAPE_LDAP = 'urn:oid:2.16.840.1.113730.3.1.' -NOREDUPERSON_OID = 'urn:oid:1.3.6.1.4.1.2428.90.1.' -PKCS_9 = 'urn:oid:1.2.840.113549.1.9.1.' -SCHAC = 'urn:oid:1.3.6.1.4.1.25178.1.2.' -SIS = 'urn:oid:1.2.752.194.10.2.' -UMICH = 'urn:oid:1.3.6.1.4.1.250.1.57.' -OPENOSI_OID = 'urn:oid:1.3.6.1.4.1.27630.2.1.1.' #openosi-0.82.schema http://www.openosi.org/osi/display/ldap/Home - -MAP = { - 'identifier': 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', - 'fro': { - EDUCOURSE_OID+'1': 'eduCourseOffering', - EDUCOURSE_OID+'2': 'eduCourseMember', - EDUMEMBER1_OID+'1': 'isMemberOf', - EDUPERSON_OID+'1': 'eduPersonAffiliation', - EDUPERSON_OID+'2': 'eduPersonNickname', - EDUPERSON_OID+'3': 'eduPersonOrgDN', - EDUPERSON_OID+'4': 'eduPersonOrgUnitDN', - EDUPERSON_OID+'5': 'eduPersonPrimaryAffiliation', - EDUPERSON_OID+'6': 'eduPersonPrincipalName', - EDUPERSON_OID+'7': 'eduPersonEntitlement', - EDUPERSON_OID+'8': 'eduPersonPrimaryOrgUnitDN', - EDUPERSON_OID+'9': 'eduPersonScopedAffiliation', - EDUPERSON_OID+'10': 'eduPersonTargetedID', - EDUPERSON_OID+'11': 'eduPersonAssurance', - EDUPERSON_OID+'12': 'eduPersonPrincipalNamePrior', - EDUPERSON_OID+'13': 'eduPersonUniqueId', - EDUPERSON_OID+'16': 'eduPersonOrcid', - LDAPGVAT_OID+'1': 'PVP-GID', - LDAPGVAT_OID+'149': 'PVP-BPK', - LDAPGVAT_OID+'153': 'PVP-OU-OKZ', - LDAPGVAT_OID+'261.10': 'PVP-VERSION', - LDAPGVAT_OID+'261.20': 'PVP-PRINCIPAL-NAME', - LDAPGVAT_OID+'261.24': 'PVP-PARTICIPANT-OKZ', - LDAPGVAT_OID+'261.30': 'PVP-ROLES', - LDAPGVAT_OID+'261.40': 'PVP-INVOICE-RECPT-ID', - LDAPGVAT_OID+'261.50': 'PVP-COST-CENTER-ID', - LDAPGVAT_OID+'261.60': 'PVP-CHARGE-CODE', - LDAPGVAT_OID+'3': 'PVP-OU-GV-OU-ID', - LDAPGVAT_OID+'33': 'PVP-FUNCTION', - LDAPGVAT_OID+'55': 'PVP-BIRTHDATE', - LDAPGVAT_OID+'71': 'PVP-PARTICIPANT-ID', - LDAPGVAT_UCL_DIR_PILOT+'1': 'PVP-USERID', - LDAPGVAT_UCL_DIR_PILOT+'3': 'PVP-MAIL', - LDAPGVAT_X500ATTR_OID+'11': 'PVP-OU', - LDAPGVAT_X500ATTR_OID+'20': 'PVP-TEL', - LDAPGVAT_X500ATTR_OID+'42': 'PVP-GIVENNAME', - NETSCAPE_LDAP+'1': 'carLicense', - NETSCAPE_LDAP+'2': 'departmentNumber', - NETSCAPE_LDAP+'3': 'employeeNumber', - NETSCAPE_LDAP+'4': 'employeeType', - NETSCAPE_LDAP+'39': 'preferredLanguage', - NETSCAPE_LDAP+'40': 'userSMIMECertificate', - NETSCAPE_LDAP+'216': 'userPKCS12', - NETSCAPE_LDAP+'241': 'displayName', - NOREDUPERSON_OID+'1': 'norEduOrgUniqueNumber', - NOREDUPERSON_OID+'2': 'norEduOrgUnitUniqueNumber', - NOREDUPERSON_OID+'3': 'norEduPersonBirthDate', - NOREDUPERSON_OID+'4': 'norEduPersonLIN', - NOREDUPERSON_OID+'5': 'norEduPersonNIN', - NOREDUPERSON_OID+'6': 'norEduOrgAcronym', - NOREDUPERSON_OID+'7': 'norEduOrgUniqueIdentifier', - NOREDUPERSON_OID+'8': 'norEduOrgUnitUniqueIdentifier', - NOREDUPERSON_OID+'9': 'federationFeideSchemaVersion', - NOREDUPERSON_OID+'10': 'norEduPersonLegalName', - NOREDUPERSON_OID+'11': 'norEduOrgSchemaVersion', - NOREDUPERSON_OID+'12': 'norEduOrgNIN', - OPENOSI_OID+'17': 'osiHomeUrl', - OPENOSI_OID+'19': 'osiPreferredTZ', - OPENOSI_OID+'72': 'osiICardTimeLastUpdated', - OPENOSI_OID+'104': 'osiMiddleName', - OPENOSI_OID+'107': 'osiOtherEmail', - OPENOSI_OID+'109': 'osiOtherHomePhone', - OPENOSI_OID+'120': 'osiWorkURL', - PKCS_9+'1': 'email', - SCHAC+'1': 'schacMotherTongue', - SCHAC+'2': 'schacGender', - SCHAC+'3': 'schacDateOfBirth', - SCHAC+'4': 'schacPlaceOfBirth', - SCHAC+'5': 'schacCountryOfCitizenship', - SCHAC+'6': 'schacSn1', - SCHAC+'7': 'schacSn2', - SCHAC+'8': 'schacPersonalTitle', - SCHAC+'9': 'schacHomeOrganization', - SCHAC+'10': 'schacHomeOrganizationType', - SCHAC+'11': 'schacCountryOfResidence', - SCHAC+'12': 'schacUserPresenceID', - SCHAC+'13': 'schacPersonalPosition', - SCHAC+'14': 'schacPersonalUniqueCode', - SCHAC+'15': 'schacPersonalUniqueID', - SCHAC+'17': 'schacExpiryDate', - SCHAC+'18': 'schacUserPrivateAttribute', - SCHAC+'19': 'schacUserStatus', - SCHAC+'20': 'schacProjectMembership', - SCHAC+'21': 'schacProjectSpecificRole', - SIS+'1': 'sisLegalGuardianFor', - SIS+'2': 'sisSchoolGrade', - UCL_DIR_PILOT+'1': 'uid', - UCL_DIR_PILOT+'3': 'mail', - UCL_DIR_PILOT+'25': 'dc', - UCL_DIR_PILOT+'37': 'associatedDomain', - UCL_DIR_PILOT+'43': 'co', - UCL_DIR_PILOT+'60': 'jpegPhoto', - UMICH+'57': 'labeledURI', - X500ATTR_OID+'2': 'knowledgeInformation', - X500ATTR_OID+'3': 'cn', - X500ATTR_OID+'4': 'sn', - X500ATTR_OID+'5': 'serialNumber', - X500ATTR_OID+'6': 'c', - X500ATTR_OID+'7': 'l', - X500ATTR_OID+'8': 'st', - X500ATTR_OID+'9': 'street', - X500ATTR_OID+'10': 'o', - X500ATTR_OID+'11': 'ou', - X500ATTR_OID+'12': 'title', - X500ATTR_OID+'14': 'searchGuide', - X500ATTR_OID+'15': 'businessCategory', - X500ATTR_OID+'16': 'postalAddress', - X500ATTR_OID+'17': 'postalCode', - X500ATTR_OID+'18': 'postOfficeBox', - X500ATTR_OID+'19': 'physicalDeliveryOfficeName', - X500ATTR_OID+'20': 'telephoneNumber', - X500ATTR_OID+'21': 'telexNumber', - X500ATTR_OID+'22': 'teletexTerminalIdentifier', - X500ATTR_OID+'23': 'facsimileTelephoneNumber', - X500ATTR_OID+'24': 'x121Address', - X500ATTR_OID+'25': 'internationaliSDNNumber', - X500ATTR_OID+'26': 'registeredAddress', - X500ATTR_OID+'27': 'destinationIndicator', - X500ATTR_OID+'28': 'preferredDeliveryMethod', - X500ATTR_OID+'29': 'presentationAddress', - X500ATTR_OID+'30': 'supportedApplicationContext', - X500ATTR_OID+'31': 'member', - X500ATTR_OID+'32': 'owner', - X500ATTR_OID+'33': 'roleOccupant', - X500ATTR_OID+'36': 'userCertificate', - X500ATTR_OID+'37': 'cACertificate', - X500ATTR_OID+'38': 'authorityRevocationList', - X500ATTR_OID+'39': 'certificateRevocationList', - X500ATTR_OID+'40': 'crossCertificatePair', - X500ATTR_OID+'42': 'givenName', - X500ATTR_OID+'43': 'initials', - X500ATTR_OID+'44': 'generationQualifier', - X500ATTR_OID+'45': 'x500UniqueIdentifier', - X500ATTR_OID+'46': 'dnQualifier', - X500ATTR_OID+'47': 'enhancedSearchGuide', - X500ATTR_OID+'48': 'protocolInformation', - X500ATTR_OID+'50': 'uniqueMember', - X500ATTR_OID+'51': 'houseIdentifier', - X500ATTR_OID+'52': 'supportedAlgorithms', - X500ATTR_OID+'53': 'deltaRevocationList', - X500ATTR_OID+'54': 'dmdName', - X500ATTR_OID+'65': 'pseudonym', - }, - 'to': { - 'associatedDomain': UCL_DIR_PILOT+'37', - 'authorityRevocationList': X500ATTR_OID+'38', - 'businessCategory': X500ATTR_OID+'15', - 'c': X500ATTR_OID+'6', - 'cACertificate': X500ATTR_OID+'37', - 'carLicense': NETSCAPE_LDAP+'1', - 'certificateRevocationList': X500ATTR_OID+'39', - 'cn': X500ATTR_OID+'3', - 'co': UCL_DIR_PILOT+'43', - 'crossCertificatePair': X500ATTR_OID+'40', - 'dc': UCL_DIR_PILOT+'25', - 'deltaRevocationList': X500ATTR_OID+'53', - 'departmentNumber': NETSCAPE_LDAP+'2', - 'destinationIndicator': X500ATTR_OID+'27', - 'displayName': NETSCAPE_LDAP+'241', - 'dmdName': X500ATTR_OID+'54', - 'dnQualifier': X500ATTR_OID+'46', - 'eduCourseMember': EDUCOURSE_OID+'2', - 'eduCourseOffering': EDUCOURSE_OID+'1', - 'eduPersonAffiliation': EDUPERSON_OID+'1', - 'eduPersonEntitlement': EDUPERSON_OID+'7', - 'eduPersonNickname': EDUPERSON_OID+'2', - 'eduPersonOrgDN': EDUPERSON_OID+'3', - 'eduPersonOrgUnitDN': EDUPERSON_OID+'4', - 'eduPersonPrimaryAffiliation': EDUPERSON_OID+'5', - 'eduPersonPrimaryOrgUnitDN': EDUPERSON_OID+'8', - 'eduPersonPrincipalName': EDUPERSON_OID+'6', - 'eduPersonPrincipalNamePrior': EDUPERSON_OID+'12', - 'eduPersonScopedAffiliation': EDUPERSON_OID+'9', - 'eduPersonTargetedID': EDUPERSON_OID+'10', - 'eduPersonAssurance': EDUPERSON_OID+'11', - 'eduPersonUniqueId': EDUPERSON_OID+'13', - 'eduPersonOrcid': EDUPERSON_OID+'16', - 'email': PKCS_9+'1', - 'employeeNumber': NETSCAPE_LDAP+'3', - 'employeeType': NETSCAPE_LDAP+'4', - 'enhancedSearchGuide': X500ATTR_OID+'47', - 'facsimileTelephoneNumber': X500ATTR_OID+'23', - 'federationFeideSchemaVersion': NOREDUPERSON_OID+'9', - 'generationQualifier': X500ATTR_OID+'44', - 'givenName': X500ATTR_OID+'42', - 'houseIdentifier': X500ATTR_OID+'51', - 'initials': X500ATTR_OID+'43', - 'internationaliSDNNumber': X500ATTR_OID+'25', - 'isMemberOf': EDUMEMBER1_OID+'1', - 'jpegPhoto': UCL_DIR_PILOT+'60', - 'knowledgeInformation': X500ATTR_OID+'2', - 'l': X500ATTR_OID+'7', - 'labeledURI': UMICH+'57', - 'mail': UCL_DIR_PILOT+'3', - 'member': X500ATTR_OID+'31', - 'norEduOrgAcronym': NOREDUPERSON_OID+'6', - 'norEduOrgNIN': NOREDUPERSON_OID+'12', - 'norEduOrgSchemaVersion': NOREDUPERSON_OID+'11', - 'norEduOrgUniqueIdentifier': NOREDUPERSON_OID+'7', - 'norEduOrgUniqueNumber': NOREDUPERSON_OID+'1', - 'norEduOrgUnitUniqueIdentifier': NOREDUPERSON_OID+'8', - 'norEduOrgUnitUniqueNumber': NOREDUPERSON_OID+'2', - 'norEduPersonBirthDate': NOREDUPERSON_OID+'3', - 'norEduPersonLIN': NOREDUPERSON_OID+'4', - 'norEduPersonLegalName': NOREDUPERSON_OID+'10', - 'norEduPersonNIN': NOREDUPERSON_OID+'5', - 'o': X500ATTR_OID+'10', - 'osiHomeUrl': OPENOSI_OID+'17', - 'osiPreferredTZ': OPENOSI_OID+'19', - 'osiICardTimeLastUpdated': OPENOSI_OID+'72', - 'osiMiddleName': OPENOSI_OID+'104', - 'osiOtherEmail': OPENOSI_OID+'107', - 'osiOtherHomePhone': OPENOSI_OID+'109', - 'osiWorkURL': OPENOSI_OID+'120', - 'ou': X500ATTR_OID+'11', - 'owner': X500ATTR_OID+'32', - 'physicalDeliveryOfficeName': X500ATTR_OID+'19', - 'postOfficeBox': X500ATTR_OID+'18', - 'postalAddress': X500ATTR_OID+'16', - 'postalCode': X500ATTR_OID+'17', - 'preferredDeliveryMethod': X500ATTR_OID+'28', - 'preferredLanguage': NETSCAPE_LDAP+'39', - 'presentationAddress': X500ATTR_OID+'29', - 'protocolInformation': X500ATTR_OID+'48', - 'pseudonym': X500ATTR_OID+'65', - 'PVP-USERID': LDAPGVAT_UCL_DIR_PILOT+'1', - 'PVP-MAIL': LDAPGVAT_UCL_DIR_PILOT+'3', - 'PVP-GID': LDAPGVAT_OID+'1', - 'PVP-BPK': LDAPGVAT_OID+'149', - 'PVP-OU-OKZ': LDAPGVAT_OID+'153', - 'PVP-VERSION': LDAPGVAT_OID+'261.10', - 'PVP-PRINCIPAL-NAME': LDAPGVAT_OID+'261.20', - 'PVP-PARTICIPANT-OKZ': LDAPGVAT_OID+'261.24', - 'PVP-ROLES': LDAPGVAT_OID+'261.30', - 'PVP-INVOICE-RECPT-ID': LDAPGVAT_OID+'261.40', - 'PVP-COST-CENTER-ID': LDAPGVAT_OID+'261.50', - 'PVP-CHARGE-CODE': LDAPGVAT_OID+'261.60', - 'PVP-OU-GV-OU-ID': LDAPGVAT_OID+'3', - 'PVP-FUNCTION': LDAPGVAT_OID+'33', - 'PVP-BIRTHDATE': LDAPGVAT_OID+'55', - 'PVP-PARTICIPANT-ID': LDAPGVAT_OID+'71', - 'PVP-OU': LDAPGVAT_X500ATTR_OID+'11', - 'PVP-TEL': LDAPGVAT_X500ATTR_OID+'20', - 'PVP-GIVENNAME': LDAPGVAT_X500ATTR_OID+'42', - 'registeredAddress': X500ATTR_OID+'26', - 'roleOccupant': X500ATTR_OID+'33', - 'schacCountryOfCitizenship': SCHAC+'5', - 'schacCountryOfResidence': SCHAC+'11', - 'schacDateOfBirth': SCHAC+'3', - 'schacExpiryDate': SCHAC+'17', - 'schacGender': SCHAC+'2', - 'schacHomeOrganization': SCHAC+'9', - 'schacHomeOrganizationType': SCHAC+'10', - 'schacMotherTongue': SCHAC+'1', - 'schacPersonalPosition': SCHAC+'13', - 'schacPersonalTitle': SCHAC+'8', - 'schacPersonalUniqueCode': SCHAC+'14', - 'schacPersonalUniqueID': SCHAC+'15', - 'schacPlaceOfBirth': SCHAC+'4', - 'schacProjectMembership': SCHAC+'20', - 'schacProjectSpecificRole': SCHAC+'21', - 'schacSn1': SCHAC+'6', - 'schacSn2': SCHAC+'7', - 'schacUserPresenceID': SCHAC+'12', - 'schacUserPrivateAttribute': SCHAC+'18', - 'schacUserStatus': SCHAC+'19', - 'searchGuide': X500ATTR_OID+'14', - 'serialNumber': X500ATTR_OID+'5', - 'sisLegalGuardianFor': SIS+'1', - 'sisSchoolGrade': SIS+'2', - 'sn': X500ATTR_OID+'4', - 'st': X500ATTR_OID+'8', - 'street': X500ATTR_OID+'9', - 'supportedAlgorithms': X500ATTR_OID+'52', - 'supportedApplicationContext': X500ATTR_OID+'30', - 'telephoneNumber': X500ATTR_OID+'20', - 'teletexTerminalIdentifier': X500ATTR_OID+'22', - 'telexNumber': X500ATTR_OID+'21', - 'title': X500ATTR_OID+'12', - 'uid': UCL_DIR_PILOT+'1', - 'uniqueMember': X500ATTR_OID+'50', - 'userCertificate': X500ATTR_OID+'36', - 'userPKCS12': NETSCAPE_LDAP+'216', - 'userSMIMECertificate': NETSCAPE_LDAP+'40', - 'x121Address': X500ATTR_OID+'24', - 'x500UniqueIdentifier': X500ATTR_OID+'45', - } -} diff --git a/docker/attributemaps/shibboleth_uri.py b/docker/attributemaps/shibboleth_uri.py deleted file mode 100644 index 54de47353..000000000 --- a/docker/attributemaps/shibboleth_uri.py +++ /dev/null @@ -1,197 +0,0 @@ -EDUPERSON_OID = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.' -NETSCAPE_LDAP = 'urn:oid:2.16.840.1.113730.3.1.' -NOREDUPERSON_OID = 'urn:oid:1.3.6.1.4.1.2428.90.1.' -PKCS_9 = 'urn:oid:1.2.840.113549.1.9.' -UCL_DIR_PILOT = 'urn:oid:0.9.2342.19200300.100.1.' -UMICH = 'urn:oid:1.3.6.1.4.1.250.1.57.' -X500ATTR = 'urn:oid:2.5.4.' - - -MAP = { - "identifier": "urn:mace:shibboleth:1.0:attributeNamespace:uri", - 'fro': { - EDUPERSON_OID+'1': 'eduPersonAffiliation', - EDUPERSON_OID+'2': 'eduPersonNickname', - EDUPERSON_OID+'3': 'eduPersonOrgDN', - EDUPERSON_OID+'4': 'eduPersonOrgUnitDN', - EDUPERSON_OID+'5': 'eduPersonPrimaryAffiliation', - EDUPERSON_OID+'6': 'eduPersonPrincipalName', - EDUPERSON_OID+'7': 'eduPersonEntitlement', - EDUPERSON_OID+'8': 'eduPersonPrimaryOrgUnitDN', - EDUPERSON_OID+'9': 'eduPersonScopedAffiliation', - EDUPERSON_OID+'10': 'eduPersonTargetedID', - EDUPERSON_OID+'11': 'eduPersonAssurance', - EDUPERSON_OID+'12': 'eduPersonPrincipalNamePrior', - EDUPERSON_OID+'13': 'eduPersonUniqueId', - EDUPERSON_OID+'16': 'eduPersonOrcid', - NETSCAPE_LDAP+'1': 'carLicense', - NETSCAPE_LDAP+'2': 'departmentNumber', - NETSCAPE_LDAP+'3': 'employeeNumber', - NETSCAPE_LDAP+'4': 'employeeType', - NETSCAPE_LDAP+'39': 'preferredLanguage', - NETSCAPE_LDAP+'40': 'userSMIMECertificate', - NETSCAPE_LDAP+'216': 'userPKCS12', - NETSCAPE_LDAP+'241': 'displayName', - NOREDUPERSON_OID+'1': 'norEduOrgUniqueNumber', - NOREDUPERSON_OID+'2': 'norEduOrgUnitUniqueNumber', - NOREDUPERSON_OID+'3': 'norEduPersonBirthDate', - NOREDUPERSON_OID+'4': 'norEduPersonLIN', - NOREDUPERSON_OID+'5': 'norEduPersonNIN', - NOREDUPERSON_OID+'6': 'norEduOrgAcronym', - NOREDUPERSON_OID+'7': 'norEduOrgUniqueIdentifier', - NOREDUPERSON_OID+'8': 'norEduOrgUnitUniqueIdentifier', - NOREDUPERSON_OID+'9': 'federationFeideSchemaVersion', - PKCS_9+'1': 'email', - UCL_DIR_PILOT+'3': 'mail', - UCL_DIR_PILOT+'25': 'dc', - UCL_DIR_PILOT+'37': 'associatedDomain', - UCL_DIR_PILOT+'60': 'jpegPhoto', - X500ATTR+'2': 'knowledgeInformation', - X500ATTR+'4': 'sn', - X500ATTR+'5': 'serialNumber', - X500ATTR+'6': 'c', - X500ATTR+'7': 'l', - X500ATTR+'8': 'st', - X500ATTR+'9': 'street', - X500ATTR+'10': 'o', - X500ATTR+'11': 'ou', - X500ATTR+'12': 'title', - X500ATTR+'14': 'searchGuide', - X500ATTR+'15': 'businessCategory', - X500ATTR+'16': 'postalAddress', - X500ATTR+'17': 'postalCode', - X500ATTR+'18': 'postOfficeBox', - X500ATTR+'19': 'physicalDeliveryOfficeName', - X500ATTR+'20': 'telephoneNumber', - X500ATTR+'21': 'telexNumber', - X500ATTR+'22': 'teletexTerminalIdentifier', - X500ATTR+'23': 'facsimileTelephoneNumber', - X500ATTR+'24': 'x121Address', - X500ATTR+'25': 'internationaliSDNNumber', - X500ATTR+'26': 'registeredAddress', - X500ATTR+'27': 'destinationIndicator', - X500ATTR+'28': 'preferredDeliveryMethod', - X500ATTR+'29': 'presentationAddress', - X500ATTR+'30': 'supportedApplicationContext', - X500ATTR+'31': 'member', - X500ATTR+'32': 'owner', - X500ATTR+'33': 'roleOccupant', - X500ATTR+'36': 'userCertificate', - X500ATTR+'37': 'cACertificate', - X500ATTR+'38': 'authorityRevocationList', - X500ATTR+'39': 'certificateRevocationList', - X500ATTR+'40': 'crossCertificatePair', - X500ATTR+'42': 'givenName', - X500ATTR+'43': 'initials', - X500ATTR+'44': 'generationQualifier', - X500ATTR+'45': 'x500UniqueIdentifier', - X500ATTR+'46': 'dnQualifier', - X500ATTR+'47': 'enhancedSearchGuide', - X500ATTR+'48': 'protocolInformation', - X500ATTR+'50': 'uniqueMember', - X500ATTR+'51': 'houseIdentifier', - X500ATTR+'52': 'supportedAlgorithms', - X500ATTR+'53': 'deltaRevocationList', - X500ATTR+'54': 'dmdName', - X500ATTR+'65': 'pseudonym', - }, - 'to': { - 'associatedDomain': UCL_DIR_PILOT+'37', - 'authorityRevocationList': X500ATTR+'38', - 'businessCategory': X500ATTR+'15', - 'c': X500ATTR+'6', - 'cACertificate': X500ATTR+'37', - 'carLicense': NETSCAPE_LDAP+'1', - 'certificateRevocationList': X500ATTR+'39', - 'countryName': X500ATTR+'6', - 'crossCertificatePair': X500ATTR+'40', - 'dc': UCL_DIR_PILOT+'25', - 'deltaRevocationList': X500ATTR+'53', - 'departmentNumber': NETSCAPE_LDAP+'2', - 'destinationIndicator': X500ATTR+'27', - 'displayName': NETSCAPE_LDAP+'241', - 'dmdName': X500ATTR+'54', - 'dnQualifier': X500ATTR+'46', - 'domainComponent': UCL_DIR_PILOT+'25', - 'eduPersonAffiliation': EDUPERSON_OID+'1', - 'eduPersonEntitlement': EDUPERSON_OID+'7', - 'eduPersonNickname': EDUPERSON_OID+'2', - 'eduPersonOrgDN': EDUPERSON_OID+'3', - 'eduPersonOrgUnitDN': EDUPERSON_OID+'4', - 'eduPersonPrimaryAffiliation': EDUPERSON_OID+'5', - 'eduPersonPrimaryOrgUnitDN': EDUPERSON_OID+'8', - 'eduPersonPrincipalName': EDUPERSON_OID+'6', - 'eduPersonPrincipalNamePrior': EDUPERSON_OID+'12', - 'eduPersonScopedAffiliation': EDUPERSON_OID+'9', - 'eduPersonTargetedID': EDUPERSON_OID+'10', - 'eduPersonAssurance': EDUPERSON_OID+'11', - 'eduPersonUniqueId': EDUPERSON_OID+'13', - 'eduPersonOrcid': EDUPERSON_OID+'16', - 'email': PKCS_9+'1', - 'emailAddress': PKCS_9+'1', - 'employeeNumber': NETSCAPE_LDAP+'3', - 'employeeType': NETSCAPE_LDAP+'4', - 'enhancedSearchGuide': X500ATTR+'47', - 'facsimileTelephoneNumber': X500ATTR+'23', - 'fax': X500ATTR+'23', - 'federationFeideSchemaVersion': NOREDUPERSON_OID+'9', - 'generationQualifier': X500ATTR+'44', - 'givenName': X500ATTR+'42', - 'gn': X500ATTR+'42', - 'houseIdentifier': X500ATTR+'51', - 'initials': X500ATTR+'43', - 'internationaliSDNNumber': X500ATTR+'25', - 'jpegPhoto': UCL_DIR_PILOT+'60', - 'knowledgeInformation': X500ATTR+'2', - 'l': X500ATTR+'7', - 'localityName': X500ATTR+'7', - 'mail': UCL_DIR_PILOT+'3', - 'member': X500ATTR+'31', - 'norEduOrgAcronym': NOREDUPERSON_OID+'6', - 'norEduOrgUniqueIdentifier': NOREDUPERSON_OID+'7', - 'norEduOrgUniqueNumber': NOREDUPERSON_OID+'1', - 'norEduOrgUnitUniqueIdentifier': NOREDUPERSON_OID+'8', - 'norEduOrgUnitUniqueNumber': NOREDUPERSON_OID+'2', - 'norEduPersonBirthDate': NOREDUPERSON_OID+'3', - 'norEduPersonLIN': NOREDUPERSON_OID+'4', - 'norEduPersonNIN': NOREDUPERSON_OID+'5', - 'o': X500ATTR+'10', - 'organizationName': X500ATTR+'10', - 'organizationalUnitName': X500ATTR+'11', - 'ou': X500ATTR+'11', - 'owner': X500ATTR+'32', - 'physicalDeliveryOfficeName': X500ATTR+'19', - 'pkcs9email': PKCS_9+'1', - 'postOfficeBox': X500ATTR+'18', - 'postalAddress': X500ATTR+'16', - 'postalCode': X500ATTR+'17', - 'preferredDeliveryMethod': X500ATTR+'28', - 'preferredLanguage': NETSCAPE_LDAP+'39', - 'presentationAddress': X500ATTR+'29', - 'protocolInformation': X500ATTR+'48', - 'pseudonym': X500ATTR+'65', - 'registeredAddress': X500ATTR+'26', - 'rfc822Mailbox': UCL_DIR_PILOT+'3', - 'roleOccupant': X500ATTR+'33', - 'searchGuide': X500ATTR+'14', - 'serialNumber': X500ATTR+'5', - 'sn': X500ATTR+'4', - 'st': X500ATTR+'8', - 'stateOrProvinceName': X500ATTR+'8', - 'street': X500ATTR+'9', - 'streetAddress': X500ATTR+'9', - 'supportedAlgorithms': X500ATTR+'52', - 'supportedApplicationContext': X500ATTR+'30', - 'surname': X500ATTR+'4', - 'telephoneNumber': X500ATTR+'20', - 'teletexTerminalIdentifier': X500ATTR+'22', - 'telexNumber': X500ATTR+'21', - 'title': X500ATTR+'12', - 'uniqueMember': X500ATTR+'50', - 'userCertificate': X500ATTR+'36', - 'userPKCS12': NETSCAPE_LDAP+'216', - 'userSMIMECertificate': NETSCAPE_LDAP+'40', - 'x121Address': X500ATTR+'24', - 'x500UniqueIdentifier': X500ATTR+'45', - } -} diff --git a/docker/setup.sh b/docker/setup.sh deleted file mode 100755 index 3545c5156..000000000 --- a/docker/setup.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -set -e - -VENV_DIR=/opt/satosa - -python3 -m venv "$VENV_DIR" - -"${VENV_DIR}/bin/pip" install --upgrade pip -"${VENV_DIR}/bin/pip" install -e /src/satosa/ diff --git a/docker/start.sh b/docker/start.sh deleted file mode 100755 index dd57b2ee2..000000000 --- a/docker/start.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env sh - -set -e - -# for Click library to work in satosa-saml-metadata -export LC_ALL="C.UTF-8" -export LANG="C.UTF-8" - -if [ -z "${DATA_DIR}" ] -then DATA_DIR=/opt/satosa/etc -fi - -if [ ! -d "${DATA_DIR}" ] -then mkdir -p "${DATA_DIR}" -fi - -if [ -z "${PROXY_PORT}" ] -then PROXY_PORT="8000" -fi - -if [ -z "${METADATA_DIR}" ] -then METADATA_DIR="${DATA_DIR}" -fi - -if [ ! -d "${DATA_DIR}/attributemaps" ] -then cp -pr /opt/satosa/attributemaps "${DATA_DIR}/attributemaps" -fi - -# activate virtualenv -. /opt/satosa/bin/activate - -# generate metadata for frontend(IdP interface) and backend(SP interface) -# write the result to mounted volume -mkdir -p "${METADATA_DIR}" -satosa-saml-metadata \ - "${DATA_DIR}/proxy_conf.yaml" \ - "${DATA_DIR}/metadata.key" \ - "${DATA_DIR}/metadata.crt" \ - --dir "${METADATA_DIR}" - -# if the user provided a gunicorn configuration, use it -if [ -f "$GUNICORN_CONF" ] -then conf_opt="--config ${GUNICORN_CONF}" -else conf_opt="--chdir ${DATA_DIR}" -fi - -# if HTTPS cert is available, use it -https_key="${DATA_DIR}/https.key" -https_crt="${DATA_DIR}/https.crt" -if [ -f "$https_key" -a -f "$https_crt" ] -then https_opts="--keyfile ${https_key} --certfile ${https_crt}" -fi - -# if a chain is available, use it -chain_pem="${DATA_DIR}/chain.pem" -if [ -f "$chain_pem" ] -then chain_opts="--ca-certs chain.pem" -fi - -# start the proxy -exec gunicorn $conf_opt \ - -b 0.0.0.0:"${PROXY_PORT}" \ - satosa.wsgi:app \ - $https_opts \ - $chain_opts \ - ; From 103f477caf46d25fa5831fecc51db81f3f6b4d18 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 17 Nov 2022 19:55:49 +0200 Subject: [PATCH 331/401] Release v.8.2.0 Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 20 ++++++++++++++++++++ setup.py | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index bb573d655..e1fc6a06a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.1.1 +current_version = 8.2.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 264005e65..620912c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 8.2.0 (2022-11-17) + +- attribute_authorization: new configuration options `force_attributes_presence_on_allow` and `force_attributes_presence_on_deny` to enforce attribute presence enforcement +- saml2 backend: new configuration option `acs_selection_strategy` to support different ways of selecting an ACS URL +- saml2 backend: new configuration option `is_passive` to set whether the discovery service is allowed to visibly interact with the user agent. +- orcid backend: make the name claim optional +- apple backend: retrieve the name of user when available. +- openid_connect frontend: new configuration option `sub_mirror_subject` the set sub to mirror the subject identifier as received in the backend. +- openid_connect frontend: check for empty `db_uri` before using it with a storage backend +- attribute_generation: try to render mustach tempate only on string values +- logging: move cookie state log to the debug level +- chore: fix non-formatting flake8 changes +- tests: remove dependency on actual MongoDB instance +- build: update links for the Docker image on Docker Hub +- docs: properly document the `name_id_format` and `name_id_policy_format` options +- docs attribute_generation: correct example configuration +- docs: fix mailing list link. +- docs: fix typos and grammar + + ## 8.1.1 (2022-06-23) - OIDC frontend: Set minimum pyop version to v3.4.0 to ensure the needed methods are available diff --git a/setup.py b/setup.py index 4e4f9f0d1..727e469ec 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.1.1', + version='8.2.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 1ee1696498b3a0aceda2db51cbd6a08cbef9870b Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 23 Nov 2022 00:36:55 +1300 Subject: [PATCH 332/401] new: add example for DecideBackendByRequester --- .../custom_routing_decide_by_requester.yaml.example | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 example/plugins/microservices/custom_routing_decide_by_requester.yaml.example diff --git a/example/plugins/microservices/custom_routing_decide_by_requester.yaml.example b/example/plugins/microservices/custom_routing_decide_by_requester.yaml.example new file mode 100644 index 000000000..a4ec441e3 --- /dev/null +++ b/example/plugins/microservices/custom_routing_decide_by_requester.yaml.example @@ -0,0 +1,7 @@ +module: satosa.micro_services.custom_routing.DecideBackendByRequester +name: DecideBackendByRequester +config: + requester_mapping: + 'requestor-id': 'backend_custom' + + From 11ceb2ea4e5615cdb8082c0230724c77b1c3d8be Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 23 Nov 2022 01:08:23 +1300 Subject: [PATCH 333/401] fix: DecideBackendByRequester: lookup via get This will make it easier to check for and deal with lookup failures. The microservice will return a None backend instead of raising an exception, but that's behaviour that's easier to deal with. And if nothing else provides a default backend, SATOSA will raise KeyError: None --- src/satosa/micro_services/custom_routing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index 541b824f1..1dbca48c5 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -79,7 +79,7 @@ def process(self, context, data): :param context: request context :param data: the internal request """ - context.target_backend = self.requester_mapping[data.requester] + context.target_backend = self.requester_mapping.get(data.requester) return super().process(context, data) From da2c135ed3411656f59392ff757062348f0c60e8 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 23 Nov 2022 01:10:48 +1300 Subject: [PATCH 334/401] new: add missing tests for DecideBackendByRequester --- .../micro_services/test_custom_routing.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index ed834ef4b..61a12a341 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -9,6 +9,7 @@ from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed from satosa.micro_services.custom_routing import DecideBackendByTargetIssuer +from satosa.micro_services.custom_routing import DecideBackendByRequester TARGET_ENTITY = "entity1" @@ -202,3 +203,37 @@ def test_when_target_is_mapped_choose_mapping_backend(self): data.requester = 'somebody else' newctx, newdata = self.plugin.process(self.context, data) assert newctx.target_backend == 'mapped_backend' + + +class TestDecideBackendByRequester(TestCase): + def setUp(self): + context = Context() + context.state = State() + + config = { + 'requester_mapping': { + 'test_requester': 'mapped_backend', + }, + } + + plugin = DecideBackendByRequester( + config=config, + name='test_decide_service', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) + + self.config = config + self.context = context + self.plugin = plugin + + def test_when_requester_is_not_mapped_skip(self): + data = InternalData(requester='other_test_requester') + newctx, newdata = self.plugin.process(self.context, data) + assert not newctx.target_backend + + def test_when_requester_is_mapped_choose_mapping_backend(self): + data = InternalData(requester='test_requester') + data.requester = 'test_requester' + newctx, newdata = self.plugin.process(self.context, data) + assert newctx.target_backend == 'mapped_backend' From ec0f620f9d1997a77a4d3f8bc03f59f0f04ab36a Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 23 Nov 2022 02:32:15 +1300 Subject: [PATCH 335/401] new: DecideBackendByRequester: add default_backend setting To avoid having to spell out all requesters: with default backend, only exceptions/overrides need to be listed. --- ...stom_routing_decide_by_requester.yaml.example | 1 + src/satosa/micro_services/custom_routing.py | 6 ++++-- .../satosa/micro_services/test_custom_routing.py | 16 +++++++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/example/plugins/microservices/custom_routing_decide_by_requester.yaml.example b/example/plugins/microservices/custom_routing_decide_by_requester.yaml.example index a4ec441e3..90aed60eb 100644 --- a/example/plugins/microservices/custom_routing_decide_by_requester.yaml.example +++ b/example/plugins/microservices/custom_routing_decide_by_requester.yaml.example @@ -1,6 +1,7 @@ module: satosa.micro_services.custom_routing.DecideBackendByRequester name: DecideBackendByRequester config: + default_backend: Saml2 requester_mapping: 'requestor-id': 'backend_custom' diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index 1dbca48c5..5706ce9aa 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -67,11 +67,13 @@ def __init__(self, config, *args, **kwargs): """ Constructor. :param config: mapping from requester identifier to - backend module name under the key 'requester_mapping' + backend module name under the key 'requester_mapping'. + May also include default backend under key 'default_backend'. :type config: Dict[str, Dict[str, str]] """ super().__init__(*args, **kwargs) self.requester_mapping = config['requester_mapping'] + self.default_backend = config.get('default_backend') def process(self, context, data): """ @@ -79,7 +81,7 @@ def process(self, context, data): :param context: request context :param data: the internal request """ - context.target_backend = self.requester_mapping.get(data.requester) + context.target_backend = self.requester_mapping.get(data.requester) or self.default_backend return super().process(context, data) diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index 61a12a341..1be124877 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -227,11 +227,25 @@ def setUp(self): self.context = context self.plugin = plugin - def test_when_requester_is_not_mapped_skip(self): + def test_when_requester_is_not_mapped_and_no_default_backend_skip(self): data = InternalData(requester='other_test_requester') newctx, newdata = self.plugin.process(self.context, data) assert not newctx.target_backend + def test_when_requester_is_not_mapped_choose_default_backend(self): + # override config to set default backend + self.config['default_backend'] = 'default_backend' + self.plugin = DecideBackendByRequester( + config=self.config, + name='test_decide_service', + base_url='https://satosa.example.org', + ) + self.plugin.next = lambda ctx, data: (ctx, data) + + data = InternalData(requester='other_test_requester') + newctx, newdata = self.plugin.process(self.context, data) + assert newctx.target_backend == 'default_backend' + def test_when_requester_is_mapped_choose_mapping_backend(self): data = InternalData(requester='test_requester') data.requester = 'test_requester' From 5d543e0907008763f99d0eea9ed05e2db98faaf7 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 29 Nov 2022 15:49:04 +0200 Subject: [PATCH 336/401] frontends: ping: minor adjustments and fixes for interface compliance Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/ping.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/satosa/frontends/ping.py b/src/satosa/frontends/ping.py index 4444cd83d..27fec279c 100644 --- a/src/satosa/frontends/ping.py +++ b/src/satosa/frontends/ping.py @@ -1,14 +1,14 @@ import logging import satosa.logging_util as lu -import satosa.micro_services.base +from satosa.frontends.base import FrontendModule from satosa.response import Response logger = logging.getLogger(__name__) -class PingFrontend(satosa.frontends.base.FrontendModule): +class PingFrontend(FrontendModule): """ SATOSA frontend that responds to a query with a simple 200 OK, intended to be used as a simple heartbeat monitor. @@ -19,12 +19,12 @@ def __init__(self, auth_req_callback_func, internal_attributes, config, base_url self.config = config - def handle_authn_response(self, context, internal_resp, extra_id_token_claims=None): + def handle_authn_response(self, context, internal_resp): """ See super class method satosa.frontends.base.FrontendModule#handle_authn_response :type context: satosa.context.Context :type internal_response: satosa.internal.InternalData - :rtype oic.utils.http_util.Response + :rtype: satosa.response.Response """ raise NotImplementedError() @@ -32,7 +32,7 @@ def handle_backend_error(self, exception): """ See super class satosa.frontends.base.FrontendModule :type exception: satosa.exception.SATOSAError - :rtype: oic.utils.http_util.Response + :rtype: satosa.response.Response """ raise NotImplementedError() @@ -49,6 +49,8 @@ def register_endpoints(self, backend_names): def ping_endpoint(self, context): """ + :type context: satosa.context.Context + :rtype: satosa.response.Response """ msg = "Ping returning 200 OK" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) From 4e8d27c0697f5fc302f4387a7a4ca170a6a1e393 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Tue, 31 Jan 2023 23:36:25 +1300 Subject: [PATCH 337/401] Examples: minor fixes and enhancements for ContactPerson examples for SAML backend and frontend (#430) * fix: example: prefix ContactPerson emailAddress with "mailto:" As per SAML 2.0 spec, this should be URIs - so should start with "mailto:" * new: example/saml: add example for REFEDS security contact As per https://refeds.org/metadata/contactType/security --- example/plugins/backends/saml2_backend.yaml.example | 5 +++-- example/plugins/frontends/saml2_frontend.yaml.example | 5 +++-- .../plugins/frontends/saml2_virtualcofrontend.yaml.example | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index 3fb30fb2a..3d3f25c0d 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -25,8 +25,9 @@ config: cert_file: backend.crt organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'} contact_person: - - {contact_type: technical, email_address: technical@example.com, given_name: Technical} - - {contact_type: support, email_address: support@example.com, given_name: Support} + - {contact_type: technical, email_address: 'mailto:technical@example.com', given_name: Technical} + - {contact_type: support, email_address: 'mailto:support@example.com', given_name: Support} + - {contact_type: other, email_address: 'mailto:security@example.com', given_name: Security, extension_attributes: {'xmlns:remd': 'http://refeds.org/metadata', 'remd:contactType': 'http://refeds.org/metadata/contactType/security'}} metadata: local: [idp.xml] diff --git a/example/plugins/frontends/saml2_frontend.yaml.example b/example/plugins/frontends/saml2_frontend.yaml.example index 058c7746e..a527ab652 100644 --- a/example/plugins/frontends/saml2_frontend.yaml.example +++ b/example/plugins/frontends/saml2_frontend.yaml.example @@ -24,8 +24,9 @@ config: idp_config: organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'} contact_person: - - {contact_type: technical, email_address: technical@example.com, given_name: Technical} - - {contact_type: support, email_address: support@example.com, given_name: Support} + - {contact_type: technical, email_address: 'mailto:technical@example.com', given_name: Technical} + - {contact_type: support, email_address: 'mailto:support@example.com', given_name: Support} + - {contact_type: other, email_address: 'mailto:security@example.com', given_name: Security, extension_attributes: {'xmlns:remd': 'http://refeds.org/metadata', 'remd:contactType': 'http://refeds.org/metadata/contactType/security'}} key_file: frontend.key cert_file: frontend.crt metadata: diff --git a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example index e7415c55e..a1ed8ad8f 100644 --- a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example +++ b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example @@ -22,7 +22,7 @@ config: url: https://messproject.org contact_person: - contact_type: technical - email_address: help@messproject.org + email_address: 'mailto:help@messproject.org' given_name: MESS Technical Support # SAML attributes and static values about the CO to be asserted for each user. # The key is the SATOSA internal attribute name. From ad6154e92ca8a6a4b694fea9c94a629d4f2e3cb6 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 15 Mar 2023 13:27:35 +1300 Subject: [PATCH 338/401] new: examples/filter_attributes: enforce controlled vocabulary for eduPerson*Affiliation attributes --- .../plugins/microservices/filter_attributes.yaml.example | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/example/plugins/microservices/filter_attributes.yaml.example b/example/plugins/microservices/filter_attributes.yaml.example index f368493b5..f8ae2fb0a 100644 --- a/example/plugins/microservices/filter_attributes.yaml.example +++ b/example/plugins/microservices/filter_attributes.yaml.example @@ -2,6 +2,15 @@ module: satosa.micro_services.attribute_modifications.FilterAttributeValues name: AttributeFilter config: attribute_filters: + # default rules for any IdentityProvider + "": + # default rules for any requester + "": + # enforce controlled vocabulary + eduPersonAffiliation: "^(faculty|student|staff|alum|member|affiliate|employee|library-walk-in)$" + eduPersonPrimaryAffiliation: "^(faculty|student|staff|alum|member|affiliate|employee|library-walk-in)$" + eduPersonScopedAffiliation: "^(faculty|student|staff|alum|member|affiliate|employee|library-walk-in)@" + target_provider1: requester1: attr1: "^foo:bar$" From 1b58acd6b94179c81ad023edff2877b5426e20a3 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 15 Mar 2023 13:43:42 +1300 Subject: [PATCH 339/401] new: FilterAttributeValues: allow more filter types besides regexp Introduce an extended filter notation where for each attribute, instead of a single value with a regexp, the filter can be a dict indexed by filter type, with (optional) filter value. Define `regexp` filter matching existing behaviour. Support existing syntax (regexp as direct filter value) by mapping it to a dict explictily pointing to regexp filter. --- .../micro_services/attribute_modifications.py | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/satosa/micro_services/attribute_modifications.py b/src/satosa/micro_services/attribute_modifications.py index 67633af27..447d42e8e 100644 --- a/src/satosa/micro_services/attribute_modifications.py +++ b/src/satosa/micro_services/attribute_modifications.py @@ -1,6 +1,7 @@ import re from .base import ResponseMicroService +from ..exception import SATOSAError class AddStaticAttributes(ResponseMicroService): @@ -40,17 +41,27 @@ def process(self, context, data): def _apply_requester_filters(self, attributes, provider_filters, requester): # apply default requester filters default_requester_filters = provider_filters.get("", {}) - self._apply_filter(attributes, default_requester_filters) + self._apply_filters(attributes, default_requester_filters) # apply requester specific filters requester_filters = provider_filters.get(requester, {}) - self._apply_filter(attributes, requester_filters) - - def _apply_filter(self, attributes, attribute_filters): - for attribute_name, attribute_filter in attribute_filters.items(): - regex = re.compile(attribute_filter) - if attribute_name == "": # default filter for all attributes - for attribute, values in attributes.items(): - attributes[attribute] = list(filter(regex.search, attributes[attribute])) - elif attribute_name in attributes: - attributes[attribute_name] = list(filter(regex.search, attributes[attribute_name])) + self._apply_filters(attributes, requester_filters) + + def _apply_filters(self, attributes, attribute_filters): + for attribute_name, attribute_filters in attribute_filters.items(): + if type(attribute_filters) == str: + # convert simple notation to filter list + attribute_filters = {'regexp': attribute_filters} + + for filter_type, filter_value in attribute_filters.items(): + + if filter_type == "regexp": + filter_func = re.compile(filter_value).search + else: + raise SATOSAError("Unknown filter type") + + if attribute_name == "": # default filter for all attributes + for attribute, values in attributes.items(): + attributes[attribute] = list(filter(filter_func, attributes[attribute])) + elif attribute_name in attributes: + attributes[attribute_name] = list(filter(filter_func, attributes[attribute_name])) From df563efbd7534e95f007efffac338d286f5c2b31 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 15 Mar 2023 13:48:45 +1300 Subject: [PATCH 340/401] new: FilterAttributeValues: pass context and target_provider through _apply_requester_filters and _apply_filters ... for use by specific filters --- .../micro_services/attribute_modifications.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/satosa/micro_services/attribute_modifications.py b/src/satosa/micro_services/attribute_modifications.py index 447d42e8e..3ed063161 100644 --- a/src/satosa/micro_services/attribute_modifications.py +++ b/src/satosa/micro_services/attribute_modifications.py @@ -30,24 +30,24 @@ def __init__(self, config, *args, **kwargs): def process(self, context, data): # apply default filters provider_filters = self.attribute_filters.get("", {}) - self._apply_requester_filters(data.attributes, provider_filters, data.requester) + target_provider = data.auth_info.issuer + self._apply_requester_filters(data.attributes, provider_filters, data.requester, context, target_provider) # apply target provider specific filters - target_provider = data.auth_info.issuer provider_filters = self.attribute_filters.get(target_provider, {}) - self._apply_requester_filters(data.attributes, provider_filters, data.requester) + self._apply_requester_filters(data.attributes, provider_filters, data.requester, context, target_provider) return super().process(context, data) - def _apply_requester_filters(self, attributes, provider_filters, requester): + def _apply_requester_filters(self, attributes, provider_filters, requester, context, target_provider): # apply default requester filters default_requester_filters = provider_filters.get("", {}) - self._apply_filters(attributes, default_requester_filters) + self._apply_filters(attributes, default_requester_filters, context, target_provider) # apply requester specific filters requester_filters = provider_filters.get(requester, {}) - self._apply_filters(attributes, requester_filters) + self._apply_filters(attributes, requester_filters, context, target_provider) - def _apply_filters(self, attributes, attribute_filters): + def _apply_filters(self, attributes, attribute_filters, context, target_provider): for attribute_name, attribute_filters in attribute_filters.items(): if type(attribute_filters) == str: # convert simple notation to filter list From c14f0a0b60ad524947b6f74a9a00d76f161725c8 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 15 Mar 2023 13:50:58 +1300 Subject: [PATCH 341/401] new: FilterAttributeValues: add new filter types shibmdscope_match_scope and shibmdscope_match_value Equivalent to ScopeMatchesShibMDScope and ValueMatchesShibMDScope from the Shibboleth project. --- .../micro_services/attribute_modifications.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/satosa/micro_services/attribute_modifications.py b/src/satosa/micro_services/attribute_modifications.py index 3ed063161..29ca7298c 100644 --- a/src/satosa/micro_services/attribute_modifications.py +++ b/src/satosa/micro_services/attribute_modifications.py @@ -1,8 +1,11 @@ import re +import logging from .base import ResponseMicroService +from ..context import Context from ..exception import SATOSAError +logger = logging.getLogger(__name__) class AddStaticAttributes(ResponseMicroService): """ @@ -57,6 +60,14 @@ def _apply_filters(self, attributes, attribute_filters, context, target_provider if filter_type == "regexp": filter_func = re.compile(filter_value).search + elif filter_type == "shibmdscope_match_scope": + mdstore = context.get_decoration(Context.KEY_METADATA_STORE) + md_scopes = list(mdstore.shibmd_scopes(target_provider,"idpsso_descriptor")) + filter_func = lambda v: self._shibmdscope_match_scope(v, md_scopes) + elif filter_type == "shibmdscope_match_value": + mdstore = context.get_decoration(Context.KEY_METADATA_STORE) + md_scopes = list(mdstore.shibmd_scopes(target_provider,"idpsso_descriptor")) + filter_func = lambda v: self._shibmdscope_match_value(v, md_scopes) else: raise SATOSAError("Unknown filter type") @@ -65,3 +76,19 @@ def _apply_filters(self, attributes, attribute_filters, context, target_provider attributes[attribute] = list(filter(filter_func, attributes[attribute])) elif attribute_name in attributes: attributes[attribute_name] = list(filter(filter_func, attributes[attribute_name])) + + def _shibmdscope_match_value(self, value, md_scopes): + for md_scope in md_scopes: + if not md_scope['regexp'] and md_scope['text'] == value: + return True + elif md_scope['regexp'] and re.compile(md_scope['text']).match(value): + return True + return False + + def _shibmdscope_match_scope(self, value, md_scopes): + split_value = value.split('@') + if len(split_value) != 2: + logger.info(f"Discarding invalid scoped value {value}") + return False + value_scope = split_value[1] + return self._shibmdscope_match_value(value_scope, md_scopes) From f7fcadff16ae9fcdf6a3aaead3182720a54fb2a6 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Wed, 15 Mar 2023 13:53:28 +1300 Subject: [PATCH 342/401] new: examples/filter_attributes: enforce scope on scoped attributes (and also enforce scoping rules on schacHomeOrganization value) --- .../microservices/filter_attributes.yaml.example | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/example/plugins/microservices/filter_attributes.yaml.example b/example/plugins/microservices/filter_attributes.yaml.example index f8ae2fb0a..9d445765c 100644 --- a/example/plugins/microservices/filter_attributes.yaml.example +++ b/example/plugins/microservices/filter_attributes.yaml.example @@ -6,10 +6,20 @@ config: "": # default rules for any requester "": - # enforce controlled vocabulary + # enforce controlled vocabulary (via simple notation) eduPersonAffiliation: "^(faculty|student|staff|alum|member|affiliate|employee|library-walk-in)$" eduPersonPrimaryAffiliation: "^(faculty|student|staff|alum|member|affiliate|employee|library-walk-in)$" - eduPersonScopedAffiliation: "^(faculty|student|staff|alum|member|affiliate|employee|library-walk-in)@" + eduPersonScopedAffiliation: + # enforce controlled vocabulary (via extended notation) + regexp: "^(faculty|student|staff|alum|member|affiliate|employee|library-walk-in)@" + # enforce correct scope + shibmdscope_match_scope: + eduPersonPrincipalName: + # enforce correct scope + shibmdscope_match_scope: + schacHomeOrganization: + # enforce scoping rule on attribute value + shibmdscope_match_value: target_provider1: requester1: From fcbd4ddf42f4bf15d38ed3333b090b13b71144a8 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Fri, 17 Mar 2023 15:59:29 +1300 Subject: [PATCH 343/401] fix: FilterAttributeValues: use re.fullmatch, remove unnecessary compile ... as per review in #432 --- src/satosa/micro_services/attribute_modifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/attribute_modifications.py b/src/satosa/micro_services/attribute_modifications.py index 29ca7298c..6fe6dfa4a 100644 --- a/src/satosa/micro_services/attribute_modifications.py +++ b/src/satosa/micro_services/attribute_modifications.py @@ -81,7 +81,7 @@ def _shibmdscope_match_value(self, value, md_scopes): for md_scope in md_scopes: if not md_scope['regexp'] and md_scope['text'] == value: return True - elif md_scope['regexp'] and re.compile(md_scope['text']).match(value): + elif md_scope['regexp'] and re.fullmatch(md_scope['text'], value): return True return False From a7491502d88d7f2196ac3ef211e902276e03a19e Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Fri, 17 Mar 2023 16:00:58 +1300 Subject: [PATCH 344/401] fix: FilterAttributeValues: call mdstore only if available --- src/satosa/micro_services/attribute_modifications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/micro_services/attribute_modifications.py b/src/satosa/micro_services/attribute_modifications.py index 6fe6dfa4a..bb00761b4 100644 --- a/src/satosa/micro_services/attribute_modifications.py +++ b/src/satosa/micro_services/attribute_modifications.py @@ -62,11 +62,11 @@ def _apply_filters(self, attributes, attribute_filters, context, target_provider filter_func = re.compile(filter_value).search elif filter_type == "shibmdscope_match_scope": mdstore = context.get_decoration(Context.KEY_METADATA_STORE) - md_scopes = list(mdstore.shibmd_scopes(target_provider,"idpsso_descriptor")) + md_scopes = list(mdstore.shibmd_scopes(target_provider,"idpsso_descriptor")) if mdstore else [] filter_func = lambda v: self._shibmdscope_match_scope(v, md_scopes) elif filter_type == "shibmdscope_match_value": mdstore = context.get_decoration(Context.KEY_METADATA_STORE) - md_scopes = list(mdstore.shibmd_scopes(target_provider,"idpsso_descriptor")) + md_scopes = list(mdstore.shibmd_scopes(target_provider,"idpsso_descriptor")) if mdstore else [] filter_func = lambda v: self._shibmdscope_match_value(v, md_scopes) else: raise SATOSAError("Unknown filter type") From e5a67cdad638621d5c552087b14fee53bba776df Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Fri, 17 Mar 2023 16:04:49 +1300 Subject: [PATCH 345/401] new: FilterAttributeValues: add tests for new filter notation Test regexp filter via new notation, test invalid filter type. --- .../test_attribute_modifications.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/satosa/micro_services/test_attribute_modifications.py b/tests/satosa/micro_services/test_attribute_modifications.py index 0efaec43e..3e3b1d815 100644 --- a/tests/satosa/micro_services/test_attribute_modifications.py +++ b/tests/satosa/micro_services/test_attribute_modifications.py @@ -1,3 +1,5 @@ +import pytest +from satosa.exception import SATOSAError from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.micro_services.attribute_modifications import FilterAttributeValues @@ -116,3 +118,43 @@ def test_filter_one_attribute_for_one_target_provider_for_one_requester(self): } filtered = filter_service.process(None, resp) assert filtered.attributes == {"a1": ["1:foo:bar:2"]} + + def test_filter_one_attribute_from_all_target_providers_for_all_requesters_in_extended_notation(self): + attribute_filters = { + "": { + "": { + "a2": { + "regexp": "^foo:bar$" + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo:bar", "1:foo:bar:2"], + } + filtered = filter_service.process(None, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": ["foo:bar"]} + + def test_invalid_filter_type(self): + attribute_filters = { + "": { + "": { + "a2": { + "invalid_filter": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo:bar", "1:foo:bar:2"], + } + with pytest.raises(SATOSAError): + filtered = filter_service.process(None, resp) From 92b9dc7576070887cf9f1540beb69fb53e582c39 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Fri, 17 Mar 2023 16:05:47 +1300 Subject: [PATCH 346/401] new: FilterAttributeValues: add tests for shibmdscope_match_scope and shibmdscope_match_value filters --- .../test_attribute_modifications.py | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/tests/satosa/micro_services/test_attribute_modifications.py b/tests/satosa/micro_services/test_attribute_modifications.py index 3e3b1d815..2bd1db0fc 100644 --- a/tests/satosa/micro_services/test_attribute_modifications.py +++ b/tests/satosa/micro_services/test_attribute_modifications.py @@ -1,4 +1,8 @@ import pytest +from tests.util import FakeIdP, create_metadata_from_config_dict, FakeSP +from saml2.mdstore import MetadataStore +from saml2.config import Config +from satosa.context import Context from satosa.exception import SATOSAError from satosa.internal import AuthenticationInformation from satosa.internal import InternalData @@ -12,6 +16,22 @@ def create_filter_service(self, attribute_filters): filter_service.next = lambda ctx, data: data return filter_service + def create_idp_metadata_conf_with_shibmd_scopes(self, idp_entityid, shibmd_scopes): + idp_conf = { + "entityid": idp_entityid, + "service": { + "idp":{} + } + } + + if shibmd_scopes is not None: + idp_conf["service"]["idp"]["scope"] = shibmd_scopes + + metadata_conf = { + "inline": [create_metadata_from_config_dict(idp_conf)] + } + return metadata_conf + def test_filter_all_attributes_from_all_target_providers_for_all_requesters(self): attribute_filters = { "": { # all providers @@ -158,3 +178,223 @@ def test_invalid_filter_type(self): } with pytest.raises(SATOSAError): filtered = filter_service.process(None, resp) + + def test_shibmdscope_match_value_filter_with_no_md_store_in_context(self): + attribute_filters = { + "": { + "": { + "a2": { + "shibmdscope_match_value": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo:bar", "1:foo:bar:2"], + } + ctx = Context() + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": []} + + def test_shibmdscope_match_value_filter_with_empty_md_store_in_context(self): + attribute_filters = { + "": { + "": { + "a2": { + "shibmdscope_match_value": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo:bar", "1:foo:bar:2"], + } + ctx = Context() + mdstore = MetadataStore(None, None) + ctx.decorate(Context.KEY_METADATA_STORE, mdstore) + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": []} + + def test_shibmdscope_match_value_filter_with_idp_md_with_no_scope(self): + attribute_filters = { + "": { + "": { + "a2": { + "shibmdscope_match_value": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo.bar", "1.foo.bar.2"], + } + + idp_entityid = 'https://idp.example.org/' + resp.auth_info.issuer = idp_entityid + + mdstore = MetadataStore(None, Config()) + mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, None)) + ctx = Context() + ctx.decorate(Context.KEY_METADATA_STORE, mdstore) + + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": []} + + def test_shibmdscope_match_value_filter_with_idp_md_with_single_scope(self): + attribute_filters = { + "": { + "": { + "a2": { + "shibmdscope_match_value": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo.bar", "1.foo.bar.2"], + } + + idp_entityid = 'https://idp.example.org/' + resp.auth_info.issuer = idp_entityid + + mdstore = MetadataStore(None, Config()) + mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, ["foo.bar"])) + ctx = Context() + ctx.decorate(Context.KEY_METADATA_STORE, mdstore) + + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": ["foo.bar"]} + + def test_shibmdscope_match_value_filter_with_idp_md_with_single_regexp_scope(self): + attribute_filters = { + "": { + "": { + "a2": { + "shibmdscope_match_value": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["test.foo.bar", "1.foo.bar.2"], + } + + idp_entityid = 'https://idp.example.org/' + resp.auth_info.issuer = idp_entityid + + mdstore = MetadataStore(None, Config()) + mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, ["[^.]*\.foo\.bar$"])) + mdstore[idp_entityid]['idpsso_descriptor'][0]['extensions']['extension_elements'][0]['regexp'] = 'true' + ctx = Context() + ctx.decorate(Context.KEY_METADATA_STORE, mdstore) + + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": ["test.foo.bar"]} + + def test_shibmdscope_match_value_filter_with_idp_md_with_multiple_scopes(self): + attribute_filters = { + "": { + "": { + "a2": { + "shibmdscope_match_value": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo.bar", "1.foo.bar.2", "foo.baz", "foo.baz.com"], + } + + idp_entityid = 'https://idp.example.org/' + resp.auth_info.issuer = idp_entityid + + mdstore = MetadataStore(None, Config()) + mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, ["foo.bar", "foo.baz"])) + ctx = Context() + ctx.decorate(Context.KEY_METADATA_STORE, mdstore) + + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": ["foo.bar", "foo.baz"]} + + def test_shibmdscope_match_scope_filter_with_single_scope(self): + attribute_filters = { + "": { + "": { + "a2": { + "shibmdscope_match_scope": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo.bar", "value@foo.bar", "1.foo.bar.2", "value@foo.bar.2", "value@extra@foo.bar"], + } + + idp_entityid = 'https://idp.example.org/' + resp.auth_info.issuer = idp_entityid + + mdstore = MetadataStore(None, Config()) + mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, ["foo.bar"])) + ctx = Context() + ctx.decorate(Context.KEY_METADATA_STORE, mdstore) + + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": ["value@foo.bar"]} + + def test_multiple_filters_for_single_attribute(self): + attribute_filters = { + "": { + "": { + "a2": { + "regexp": "^value1@", + "shibmdscope_match_scope": None + } + } + } + } + filter_service = self.create_filter_service(attribute_filters) + + resp = InternalData(AuthenticationInformation()) + resp.attributes = { + "a1": ["abc:xyz"], + "a2": ["foo.bar", "value1@foo.bar", "value2@foo.bar", "1.foo.bar.2", "value@foo.bar.2", "value@extra@foo.bar"], + } + + idp_entityid = 'https://idp.example.org/' + resp.auth_info.issuer = idp_entityid + + mdstore = MetadataStore(None, Config()) + mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, ["foo.bar"])) + ctx = Context() + ctx.decorate(Context.KEY_METADATA_STORE, mdstore) + + filtered = filter_service.process(ctx, resp) + assert filtered.attributes == {"a1": ["abc:xyz"], "a2": ["value1@foo.bar"]} From cfda9cedaa137ae9eb8d0759089cd2f166a8ed87 Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Mon, 20 Mar 2023 10:29:55 +1300 Subject: [PATCH 347/401] new: examples/filter_attributes: add sample rules for saml-subject-id and saml-pairwise-id --- .../microservices/filter_attributes.yaml.example | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/example/plugins/microservices/filter_attributes.yaml.example b/example/plugins/microservices/filter_attributes.yaml.example index 9d445765c..185f2dec0 100644 --- a/example/plugins/microservices/filter_attributes.yaml.example +++ b/example/plugins/microservices/filter_attributes.yaml.example @@ -17,6 +17,16 @@ config: eduPersonPrincipalName: # enforce correct scope shibmdscope_match_scope: + subject-id: + # enforce attribute syntax + regexp: "^[0-9A-Za-z][-=0-9A-Za-z]{0,126}@[0-9A-Za-z][-.0-9A-Za-z]{0,126}\\Z" + # enforce correct scope + shibmdscope_match_scope: + pairwise-id: + # enforce attribute syntax + regexp: "^[0-9A-Za-z][-=0-9A-Za-z]{0,126}@[0-9A-Za-z][-.0-9A-Za-z]{0,126}\\Z" + # enforce correct scope + shibmdscope_match_scope: schacHomeOrganization: # enforce scoping rule on attribute value shibmdscope_match_value: From f8529f158620e49eb9ebc05db8d1205dfc286b2d Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Mon, 20 Mar 2023 10:32:35 +1300 Subject: [PATCH 348/401] nfc: FilterAttributeValues: add clarifying comment to shibmdscope_match_scope test --- tests/satosa/micro_services/test_attribute_modifications.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/satosa/micro_services/test_attribute_modifications.py b/tests/satosa/micro_services/test_attribute_modifications.py index 2bd1db0fc..aa1fcb8d5 100644 --- a/tests/satosa/micro_services/test_attribute_modifications.py +++ b/tests/satosa/micro_services/test_attribute_modifications.py @@ -304,6 +304,7 @@ def test_shibmdscope_match_value_filter_with_idp_md_with_single_regexp_scope(sel mdstore = MetadataStore(None, Config()) mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, ["[^.]*\.foo\.bar$"])) + # mark scope as regexp (cannot be done via pysaml2 YAML config) mdstore[idp_entityid]['idpsso_descriptor'][0]['extensions']['extension_elements'][0]['regexp'] = 'true' ctx = Context() ctx.decorate(Context.KEY_METADATA_STORE, mdstore) From 754dcc2099adba680ba6d70690524ed367efbaa5 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 16 May 2023 18:10:33 +0300 Subject: [PATCH 349/401] Improve configuration readability of the primary-identifier plugin Signed-off-by: Ivan Kanakarakis --- .../plugins/microservices/primary_identifier.yaml.example | 6 +++++- src/satosa/micro_services/primary_identifier.py | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/example/plugins/microservices/primary_identifier.yaml.example b/example/plugins/microservices/primary_identifier.yaml.example index 0406f578e..0b14d7127 100644 --- a/example/plugins/microservices/primary_identifier.yaml.example +++ b/example/plugins/microservices/primary_identifier.yaml.example @@ -22,20 +22,24 @@ config: - attribute_names: [eppn] - attribute_names: [name_id] name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - # The line below addes the IdP entityID to the value for the SAML2 + # The line below adds the IdP entityID to the value for the SAML2 # Persistent NameID to ensure the value is fully scoped. add_scope: issuer_entityid - attribute_names: [edupersontargetedid] add_scope: issuer_entityid + # The internal SATOSA attribute into which to place the primary # identifier value once found from the above configured ordered # candidates. primary_identifier: uid + # Whether or not to clear the input attributes after setting the # primary identifier value. clear_input_attributes: no + # Whether to replace subject_id with the constructed primary identifier replace_subject_id: no + # If defined redirect to this page if no primary identifier can # be found. on_error: https://my.org/errors/no_primary_identifier diff --git a/src/satosa/micro_services/primary_identifier.py b/src/satosa/micro_services/primary_identifier.py index 2a140a9e4..1df2479eb 100644 --- a/src/satosa/micro_services/primary_identifier.py +++ b/src/satosa/micro_services/primary_identifier.py @@ -62,7 +62,6 @@ def constructPrimaryIdentifier(self, data, ordered_identifier_candidates): # name_id_format add the value for the NameID of that format if it was asserted by the IdP # or else add the value None. if 'name_id' in candidate['attribute_names']: - candidate_nameid_value = None candidate_nameid_value = None candidate_name_id_format = candidate.get('name_id_format') name_id_value = data.subject_id From f4f55b0d5664953cae38d39700aaab9997cca4f5 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 16 May 2023 18:53:26 +0300 Subject: [PATCH 350/401] tests: use matchers to mock responses Signed-off-by: Ivan Kanakarakis --- .../micro_services/test_account_linking.py | 19 +++++++++++++------ tests/test_requirements.txt | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/satosa/micro_services/test_account_linking.py b/tests/satosa/micro_services/test_account_linking.py index 1c6dad5e4..859f3517d 100644 --- a/tests/satosa/micro_services/test_account_linking.py +++ b/tests/satosa/micro_services/test_account_linking.py @@ -3,7 +3,10 @@ import pytest import requests + import responses +from responses import matchers + from jwkest.jwk import rsa_load, RSAKey from jwkest.jws import JWS @@ -46,13 +49,15 @@ def test_existing_account_linking_with_known_known_uuid(self, account_linking_co } key = RSAKey(key=rsa_load(account_linking_config["sign_key"]), use="sig", alg="RS256") jws = JWS(json.dumps(data), alg=key.alg).sign_compact([key]) + url = "%s/get_id" % account_linking_config["api_url"] + params = {"jwt": jws} responses.add( responses.GET, - "%s/get_id?jwt=%s" % (account_linking_config["api_url"], jws), - status=200, + url=url, body=uuid, + match=[matchers.query_param_matcher(params)], content_type="text/html", - match_querystring=True + status=200, ) self.account_linking.process(context, internal_response) @@ -82,13 +87,15 @@ def test_full_flow(self, account_linking_config, internal_response, context): uuid = "uuid" with responses.RequestsMock() as rsps: # account is linked, 200 OK + url = "%s/get_id" % account_linking_config["api_url"] + params = {"jwt": jws} rsps.add( responses.GET, - "%s/get_id?jwt=%s" % (account_linking_config["api_url"], jws), - status=200, + url=url, body=uuid, + match=[matchers.query_param_matcher(params)], content_type="text/html", - match_querystring=True + status=200, ) internal_response = self.account_linking._handle_al_response(context) assert internal_response.subject_id == uuid diff --git a/tests/test_requirements.txt b/tests/test_requirements.txt index 1991e4cac..fa872ab2a 100644 --- a/tests/test_requirements.txt +++ b/tests/test_requirements.txt @@ -1,5 +1,5 @@ pytest -responses +responses >= 0.14 beautifulsoup4 ldap3 mongomock From 501a63af262220a51c390fbd5bbeb906e3100476 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 8 Jun 2023 19:45:58 +0300 Subject: [PATCH 351/401] opend_connect backend: use PyoidcSettings class to configure pyoidc/oic based clients Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/openid_connect.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/satosa/backends/openid_connect.py b/src/satosa/backends/openid_connect.py index 87772f565..cb97154f6 100644 --- a/src/satosa/backends/openid_connect.py +++ b/src/satosa/backends/openid_connect.py @@ -12,6 +12,7 @@ from oic.oic.message import RegistrationRequest from oic.utils.authn.authn_context import UNSPECIFIED from oic.utils.authn.client import CLIENT_AUTHN_METHOD +from oic.utils.settings import PyoidcSettings import satosa.logging_util as lu from satosa.internal import AuthenticationInformation @@ -55,10 +56,12 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na super().__init__(auth_callback_func, internal_attributes, base_url, name) self.auth_callback_func = auth_callback_func self.config = config + cfg_verify_ssl = config["client"].get("verify_ssl", True) + oidc_settings = PyoidcSettings(verify_ssl=cfg_verify_ssl) self.client = _create_client( - config["provider_metadata"], - config["client"]["client_metadata"], - config["client"].get("verify_ssl", True), + provider_metadata=config["provider_metadata"], + client_metadata=config["client"]["client_metadata"], + settings=oidc_settings, ) if "scope" not in config["client"]["auth_req_params"]: config["auth_req_params"]["scope"] = "openid" @@ -243,7 +246,7 @@ def get_metadata_desc(self): return get_metadata_desc_for_oauth_backend(self.config["provider_metadata"]["issuer"], self.config) -def _create_client(provider_metadata, client_metadata, verify_ssl=True): +def _create_client(provider_metadata, client_metadata, settings=None): """ Create a pyoidc client instance. :param provider_metadata: provider configuration information @@ -254,7 +257,7 @@ def _create_client(provider_metadata, client_metadata, verify_ssl=True): :rtype: oic.oic.Client """ client = oic.Client( - client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=verify_ssl + client_authn_method=CLIENT_AUTHN_METHOD, settings=settings ) # Provider configuration information From 770ad420c26ac604ef9bce6d585c2ef6ca773a3b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 8 Jun 2023 19:54:13 +0300 Subject: [PATCH 352/401] saml frontend: remove metadata param when applying the set policy This param was deprecated by pysaml2 v6.3.0 Signed-off-by: Ivan Kanakarakis --- src/satosa/frontends/saml2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 655e6da68..379635fc2 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -282,7 +282,7 @@ def _get_approved_attributes(self, idp, idp_policy, sp_entity_id, state): for aconv in attrconvs: if aconv.name_format == name_format: all_attributes = {v: None for v in aconv._fro.values()} - attribute_filter = list(idp_policy.restrict(all_attributes, sp_entity_id, idp.metadata).keys()) + attribute_filter = list(idp_policy.restrict(all_attributes, sp_entity_id).keys()) break attribute_filter = self.converter.to_internal_filter(self.attribute_profile, attribute_filter) msg = "Filter: {}".format(attribute_filter) From 45651e871eb0445e374e96ae51673ba662aa7cba Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Thu, 8 Jun 2023 18:19:05 +0300 Subject: [PATCH 353/401] Release v8.3.0 - FilterAttributeValues plugin: add new filter types shibmdscope_match_scope and shibmdscope_match_value; add tests - FilterAttributeValues plugin: add example rules for saml-subject-id and saml-pairwise-id - FilterAttributeValues plugin: add example rules enforcing controlled vocabulary for eduPersonAffiliation and eduPersonScopedAffiliation attributes - DecideBackendByRequester plugin: add default_backend setting; add tests; minor fixes - opend_connect backend: use PyoidcSettings class to configure pyoidc/oic based clients - ping frontend: minor adjustments and fixes for interface compliance - tests: update code to use matchers API to mock responses - examples: improve configuration readability of the primary-identifier plugin - examples: minor fixes and enhancements for ContactPerson examples for SAML backend and frontend Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 13 +++++++++++++ setup.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e1fc6a06a..c1fc9a358 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.2.0 +current_version = 8.3.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 620912c35..2824cc0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 8.3.0 (2023-06-08) + +- FilterAttributeValues plugin: add new filter types shibmdscope_match_scope and shibmdscope_match_value; add tests +- FilterAttributeValues plugin: add example rules for saml-subject-id and saml-pairwise-id +- FilterAttributeValues plugin: add example rules enforcing controlled vocabulary for eduPersonAffiliation and eduPersonScopedAffiliation attributes +- DecideBackendByRequester plugin: add default_backend setting; add tests; minor fixes +- opend_connect backend: use PyoidcSettings class to configure pyoidc/oic based clients +- ping frontend: minor adjustments and fixes for interface compliance +- tests: update code to use matchers API to mock responses +- examples: improve configuration readability of the primary-identifier plugin +- examples: minor fixes and enhancements for ContactPerson examples for SAML backend and frontend + + ## 8.2.0 (2022-11-17) - attribute_authorization: new configuration options `force_attributes_presence_on_allow` and `force_attributes_presence_on_deny` to enforce attribute presence enforcement diff --git a/setup.py b/setup.py index 727e469ec..b01ef2dc1 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.2.0', + version='8.3.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 00e7ada6f5104f7e67a294de6fb5470c93729650 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 13:58:28 +0300 Subject: [PATCH 354/401] Move away from pkg_resources when deriving the package version at runtime Signed-off-by: Ivan Kanakarakis --- setup.py | 1 + src/satosa/version.py | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index b01ef2dc1..644ed1d19 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ "click", "chevron", "cookies-samesite-compat", + "importlib-metadata >= 1.7.0; python_version <= '3.8'", ], extras_require={ "ldap": ["ldap3"], diff --git a/src/satosa/version.py b/src/satosa/version.py index 8025c9e3c..cac85faf0 100644 --- a/src/satosa/version.py +++ b/src/satosa/version.py @@ -1,11 +1,12 @@ -import pkg_resources as _pkg_resources +try: + from importlib.metadata import version as _resolve_package_version +except ImportError: + from importlib_metadata import version as _resolve_package_version # type: ignore[no-redef] def _parse_version(): - data = _pkg_resources.get_distribution('satosa') - value = _pkg_resources.parse_version(data.version) + value = _resolve_package_version("satosa") return value -version_info = _parse_version() -version = str(version_info) +version = _parse_version() From c9c5ba05902d5c43012ce9d891505444ec952430 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 13:58:47 +0300 Subject: [PATCH 355/401] Update markers of supported Python versions Signed-off-by: Ivan Kanakarakis --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index 644ed1d19..557691df7 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,9 @@ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], entry_points={ "console_scripts": ["satosa-saml-metadata=satosa.scripts.satosa_saml_metadata:construct_saml_metadata"] From 58f9381df0b913253ddee94ee616198fc738d40f Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 13:59:03 +0300 Subject: [PATCH 356/401] Use raw strings for regex Signed-off-by: Ivan Kanakarakis --- tests/satosa/micro_services/test_attribute_modifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/satosa/micro_services/test_attribute_modifications.py b/tests/satosa/micro_services/test_attribute_modifications.py index aa1fcb8d5..41ce8a7c0 100644 --- a/tests/satosa/micro_services/test_attribute_modifications.py +++ b/tests/satosa/micro_services/test_attribute_modifications.py @@ -303,7 +303,7 @@ def test_shibmdscope_match_value_filter_with_idp_md_with_single_regexp_scope(sel resp.auth_info.issuer = idp_entityid mdstore = MetadataStore(None, Config()) - mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, ["[^.]*\.foo\.bar$"])) + mdstore.imp(self.create_idp_metadata_conf_with_shibmd_scopes(idp_entityid, [r"[^.]*\.foo\.bar$"])) # mark scope as regexp (cannot be done via pysaml2 YAML config) mdstore[idp_entityid]['idpsso_descriptor'][0]['extensions']['extension_elements'][0]['regexp'] = 'true' ctx = Context() From c86c9c29d2c7d77fbbe06aff9624e2e59e3f362c Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Sat, 14 Jan 2023 22:56:07 +1300 Subject: [PATCH 357/401] fix: metadata_creation: for SAML backend, use sp.config to render metadata ... because SAMLBackend modifies the config (adding encryption_keypairs to config) and this modified config is stored under sp.config. Otherwise, metadata created via the metadata-creation scripts (satosa-saml-metadata) would be missing encryption keys (KeyDescriptor use="encryption"). --- src/satosa/metadata_creation/saml_metadata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/metadata_creation/saml_metadata.py b/src/satosa/metadata_creation/saml_metadata.py index 895de4b98..b1c5087c1 100644 --- a/src/satosa/metadata_creation/saml_metadata.py +++ b/src/satosa/metadata_creation/saml_metadata.py @@ -17,7 +17,7 @@ def _create_entity_descriptor(entity_config): - cnf = Config().load(copy.deepcopy(entity_config)) + cnf = entity_config if isinstance(entity_config, Config) else Config().load(copy.deepcopy(entity_config)) return entity_descriptor(cnf) @@ -28,7 +28,7 @@ def _create_backend_metadata(backend_modules): if isinstance(plugin_module, SAMLBackend): logline = "Generating SAML backend '{}' metadata".format(plugin_module.name) logger.info(logline) - backend_metadata[plugin_module.name] = [_create_entity_descriptor(plugin_module.config["sp_config"])] + backend_metadata[plugin_module.name] = [_create_entity_descriptor(plugin_module.sp.config)] return backend_metadata From 69b532a31c88de19a3ab4b3a1ec1401cd20c99cb Mon Sep 17 00:00:00 2001 From: Vlad Mencl Date: Sat, 14 Jan 2023 23:04:44 +1300 Subject: [PATCH 358/401] new: satosa-saml-metadata: make signing optional Allow skipping signing with --no-sign - and in that case, do not require key+cert. Default to signing enabled (keep existing behaviour). Mark key and cert args as optional in Click and instead check them explicitly when signing is enabled. Add new method create_entity_descriptor_metadata as counterpart to create_signed_entity_descriptor to also apply `valid` option to EntityDescriptor but avoid signing. --- src/satosa/metadata_creation/saml_metadata.py | 15 +++++++ src/satosa/scripts/satosa_saml_metadata.py | 45 +++++++++++++------ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/satosa/metadata_creation/saml_metadata.py b/src/satosa/metadata_creation/saml_metadata.py index b1c5087c1..f88bbaaec 100644 --- a/src/satosa/metadata_creation/saml_metadata.py +++ b/src/satosa/metadata_creation/saml_metadata.py @@ -154,3 +154,18 @@ def create_signed_entity_descriptor(entity_descriptor, security_context, valid_f raise ValueError("Could not construct valid EntityDescriptor tag") return xmldoc + + +def create_entity_descriptor_metadata(entity_descriptor, valid_for=None): + """ + :param entity_descriptor: the entity descriptor to create metadata for + :param valid_for: number of hours the metadata should be valid + :return: the EntityDescriptor metadata + + :type entity_descriptor: saml2.md.EntityDescriptor] + :type valid_for: Optional[int] + """ + if valid_for: + entity_descriptor.valid_until = in_a_while(hours=valid_for) + + return str(entity_descriptor) diff --git a/src/satosa/scripts/satosa_saml_metadata.py b/src/satosa/scripts/satosa_saml_metadata.py index 20e4ae4f9..c0638d8b7 100644 --- a/src/satosa/scripts/satosa_saml_metadata.py +++ b/src/satosa/scripts/satosa_saml_metadata.py @@ -5,6 +5,7 @@ from saml2.sigver import security_context from ..metadata_creation.saml_metadata import create_entity_descriptors +from ..metadata_creation.saml_metadata import create_entity_descriptor_metadata from ..metadata_creation.saml_metadata import create_signed_entity_descriptor from ..satosa_config import SATOSAConfig @@ -16,44 +17,58 @@ def _get_security_context(key, cert): return security_context(conf) -def _create_split_entity_descriptors(entities, secc, valid): +def _create_split_entity_descriptors(entities, secc, valid, sign=True): output = [] for module_name, eds in entities.items(): for i, ed in enumerate(eds): - output.append((create_signed_entity_descriptor(ed, secc, valid), "{}_{}.xml".format(module_name, i))) + ed_str = ( + create_signed_entity_descriptor(ed, secc, valid) + if sign + else create_entity_descriptor_metadata(ed, valid) + ) + output.append((ed_str, "{}_{}.xml".format(module_name, i))) return output -def _create_merged_entities_descriptors(entities, secc, valid, name): +def _create_merged_entities_descriptors(entities, secc, valid, name, sign=True): output = [] frontend_entity_descriptors = [e for sublist in entities.values() for e in sublist] for frontend in frontend_entity_descriptors: - output.append((create_signed_entity_descriptor(frontend, secc, valid), name)) + ed_str = ( + create_signed_entity_descriptor(frontend, secc, valid) + if sign + else create_entity_descriptor_metadata(frontend, valid) + ) + output.append((ed_str, name)) return output def create_and_write_saml_metadata(proxy_conf, key, cert, dir, valid, split_frontend_metadata=False, - split_backend_metadata=False): + split_backend_metadata=False, sign=True): """ Generates SAML metadata for the given PROXY_CONF, signed with the given KEY and associated CERT. """ satosa_config = SATOSAConfig(proxy_conf) - secc = _get_security_context(key, cert) + + if sign and (not key or not cert): + raise ValueError("Key and cert are required when signing") + secc = _get_security_context(key, cert) if sign else None + frontend_entities, backend_entities = create_entity_descriptors(satosa_config) output = [] if frontend_entities: if split_frontend_metadata: - output.extend(_create_split_entity_descriptors(frontend_entities, secc, valid)) + output.extend(_create_split_entity_descriptors(frontend_entities, secc, valid, sign)) else: - output.extend(_create_merged_entities_descriptors(frontend_entities, secc, valid, "frontend.xml")) + output.extend(_create_merged_entities_descriptors(frontend_entities, secc, valid, "frontend.xml", sign)) if backend_entities: if split_backend_metadata: - output.extend(_create_split_entity_descriptors(backend_entities, secc, valid)) + output.extend(_create_split_entity_descriptors(backend_entities, secc, valid, sign)) else: - output.extend(_create_merged_entities_descriptors(backend_entities, secc, valid, "backend.xml")) + output.extend(_create_merged_entities_descriptors(backend_entities, secc, valid, "backend.xml", sign)) for metadata, filename in output: path = os.path.join(dir, filename) @@ -64,8 +79,8 @@ def create_and_write_saml_metadata(proxy_conf, key, cert, dir, valid, split_fron @click.command() @click.argument("proxy_conf") -@click.argument("key") -@click.argument("cert") +@click.argument("key", required=False) +@click.argument("cert", required=False) @click.option("--dir", type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True, readable=False, resolve_path=False), @@ -75,5 +90,7 @@ def create_and_write_saml_metadata(proxy_conf, key, cert, dir, valid, split_fron help="Create one entity descriptor per file for the frontend metadata") @click.option("--split-backend", is_flag=True, type=click.BOOL, default=False, help="Create one entity descriptor per file for the backend metadata") -def construct_saml_metadata(proxy_conf, key, cert, dir, valid, split_frontend, split_backend): - create_and_write_saml_metadata(proxy_conf, key, cert, dir, valid, split_frontend, split_backend) +@click.option("--sign/--no-sign", is_flag=True, type=click.BOOL, default=True, + help="Sign the generated metadata") +def construct_saml_metadata(proxy_conf, key, cert, dir, valid, split_frontend, split_backend, sign): + create_and_write_saml_metadata(proxy_conf, key, cert, dir, valid, split_frontend, split_backend, sign) From c0a7f2293cecdfda1b8bdc612949e0cd3803340a Mon Sep 17 00:00:00 2001 From: Christos Kanellopoulos Date: Wed, 2 Mar 2022 21:09:08 +0000 Subject: [PATCH 359/401] Completes the support for the mdui:UIInfo element Adds: - keywords - information_url - privacy_statement_url --- src/satosa/backends/oauth.py | 8 +++ src/satosa/backends/saml2.py | 8 +++ src/satosa/metadata_creation/description.py | 59 ++++++++++++++++++- .../metadata_creation/test_description.py | 6 ++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/satosa/backends/oauth.py b/src/satosa/backends/oauth.py index 1e584f617..0cfa3a6ff 100644 --- a/src/satosa/backends/oauth.py +++ b/src/satosa/backends/oauth.py @@ -319,6 +319,14 @@ def get_metadata_desc_for_oauth_backend(entity_id, config): ui_description.add_display_name(name[0], name[1]) for logo in ui_info.get("logo", []): ui_description.add_logo(logo["image"], logo["width"], logo["height"], logo["lang"]) + for keywords in ui_info.get("keywords", []): + ui_description.add_keywords(keywords.get("text", []), keywords.get("lang")) + for information_url in ui_info.get("information_url", []): + ui_description.add_information_url(information_url.get("text"), information_url.get("lang")) + for privacy_statement_url in ui_info.get("privacy_statement_url", []): + ui_description.add_information_url( + privacy_statement_url.get("text"), privacy_statement_url.get("lang") + ) description.ui_info = ui_description diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index be7a095fb..12a641732 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -623,6 +623,14 @@ def get_metadata_desc(self): 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")) + for keywords in ui_info.get("keywords", []): + ui_info_desc.add_keywords(keywords.get("text", []), keywords.get("lang")) + for information_url in ui_info.get("information_url", []): + ui_info_desc.add_information_url(information_url.get("text"), information_url.get("lang")) + for privacy_statement_url in ui_info.get("privacy_statement_url", []): + ui_info_desc.add_privacy_statement_url( + privacy_statement_url.get("text"), privacy_statement_url.get("lang") + ) description.ui_info = ui_info_desc entity_descriptions.append(description) diff --git a/src/satosa/metadata_creation/description.py b/src/satosa/metadata_creation/description.py index 26abdd555..4aa82fa31 100644 --- a/src/satosa/metadata_creation/description.py +++ b/src/satosa/metadata_creation/description.py @@ -52,6 +52,9 @@ def __init__(self): self._description = [] self._display_name = [] self._logos = [] + self._keywords = [] + self._information_url = [] + self._privacy_statement_url = [] def add_description(self, text, lang): """ @@ -96,6 +99,52 @@ def add_logo(self, text, width, height, lang=None): logo_entry["lang"] = lang self._logos.append(logo_entry) + def add_keywords(self, text, lang): + """ + Binds keywords to the given language + :type text: List + :type lang: str + + :param text: List of keywords + :param lang: language + """ + + if text: + self._keywords.append( + { + "text": [_keyword.replace(" ", "+") for _keyword in text], + "lang": lang if lang else "en", + } + ) + + def add_information_url(self, text, lang): + """ + Binds information_url to the given language + :type text: str + :type lang: str + + :param text: Information URL + :param lang: language + """ + + if text: + self._information_url.append({"text": text, "lang": lang if lang else "en"}) + + def add_privacy_statement_url(self, text, lang): + """ + Binds privacy_statement_url to the given language + :type text: str + :type lang: str + + :param text: Privacy statement URL + :param lang: language + """ + + if text: + self._privacy_statement_url.append( + {"text": text, "lang": lang if lang else "en"} + ) + def to_dict(self): """ Returns a dictionary representation of the UIInfoDesc object. @@ -110,6 +159,12 @@ def to_dict(self): ui_info["display_name"] = self._display_name if self._logos: ui_info["logo"] = self._logos + if self._keywords: + ui_info["keywords"] = self._keywords + if self._information_url: + ui_info["information_url"] = self._information_url + if self._privacy_statement_url: + ui_info["privacy_statement_url"] = self._privacy_statement_url return {"service": {"idp": {"ui_info": ui_info}}} if ui_info else {} @@ -227,9 +282,9 @@ def to_dict(self): if self._organization: description.update(self._organization.to_dict()) if self._contact_person: - description['contact_person'] = [] + description["contact_person"] = [] for person in self._contact_person: - description['contact_person'].append(person.to_dict()) + description["contact_person"].append(person.to_dict()) if self._ui_info: description.update(self._ui_info.to_dict()) return description diff --git a/tests/satosa/metadata_creation/test_description.py b/tests/satosa/metadata_creation/test_description.py index ae8caf166..818d01a03 100644 --- a/tests/satosa/metadata_creation/test_description.py +++ b/tests/satosa/metadata_creation/test_description.py @@ -24,12 +24,18 @@ def test_to_dict(self): desc.add_description("test", "en") desc.add_display_name("my company", "en") desc.add_logo("logo.jpg", 80, 80, "en") + desc.add_keywords(["kw1", "kw2"], "en") + desc.add_information_url("https://test", "en") + desc.add_privacy_statement_url("https://test", "en") serialized = desc.to_dict() ui_info = serialized["service"]["idp"]["ui_info"] assert ui_info["description"] == [{"text": "test", "lang": "en"}] assert ui_info["display_name"] == [{"text": "my company", "lang": "en"}] assert ui_info["logo"] == [{"text": "logo.jpg", "width": 80, "height": 80, "lang": "en"}] + assert ui_info["keywords"] == [{"text": ["kw1", "kw2"], "lang": "en"}] + assert ui_info["information_url"] == [{"text": "https://test", "lang": "en"}] + assert ui_info["privacy_statement_url"] == [{"text": "https://test", "lang": "en"}] def test_to_dict_for_logo_without_lang(self): desc = UIInfoDesc() From fd64ece504ef89ce25e69f39d817477288997ad8 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 18:51:57 +0300 Subject: [PATCH 360/401] Avoid setting duplicate set-cookie headers Especially helpful for healthcheck requests that are continuously and with short interval checking an endpoint while never completing a flow thus not having the state cleared. Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index 404104920..2a862a969 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -219,8 +219,19 @@ def _save_state(self, resp, context): :param context: Session context """ - cookie = state_to_cookie(context.state, self.config["COOKIE_STATE_NAME"], "/", - self.config["STATE_ENCRYPTION_KEY"]) + cookie_name = self.config["COOKIE_STATE_NAME"] + cookie = state_to_cookie( + context.state, + name=cookie_name, + path="/", + encryption_key=self.config["STATE_ENCRYPTION_KEY"], + ) + resp.headers = [ + (name, value) + for (name, value) in resp.headers + if name != "Set-Cookie" + or not value.startswith(f"{cookie_name}=") + ] resp.headers.append(tuple(cookie.output().split(": ", 1))) def run(self, context): From 4041df2e79129f8e70342909fbfdccf6bb6f722c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 19:58:53 +0300 Subject: [PATCH 361/401] Rearrange class order Signed-off-by: Ivan Kanakarakis --- src/satosa/state.py | 206 ++++++++++++++++++++++---------------------- 1 file changed, 103 insertions(+), 103 deletions(-) diff --git a/src/satosa/state.py b/src/satosa/state.py index 05e343529..37609f4e9 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -25,6 +25,109 @@ _SESSION_ID_KEY = "SESSION_ID" +class State(UserDict): + """ + This class holds a state attribute object. A state object must be able to be converted to + a json string, otherwise will an exception be raised. + """ + + def __init__(self, urlstate_data=None, encryption_key=None): + """ + If urlstate is empty a new empty state instance will be returned. + + If urlstate is not empty the constructor will rebuild the state attribute objects + from the urlstate string. + :type urlstate_data: str + :type encryption_key: str + :rtype: State + + :param encryption_key: The key to be used for encryption. + :param urlstate_data: A string created by the method urlstate in this class. + :return: An instance of this class. + """ + self.delete = False + + urlstate_data = {} if urlstate_data is None else urlstate_data + if urlstate_data and not encryption_key: + raise ValueError("If an 'urlstate_data' is supplied 'encrypt_key' must be specified.") + + if urlstate_data: + try: + urlstate_data_bytes = urlstate_data.encode("utf-8") + urlstate_data_b64decoded = base64.urlsafe_b64decode(urlstate_data_bytes) + lzma = LZMADecompressor() + urlstate_data_decompressed = lzma.decompress(urlstate_data_b64decoded) + urlstate_data_decrypted = _AESCipher(encryption_key).decrypt( + urlstate_data_decompressed + ) + lzma = LZMADecompressor() + urlstate_data_decrypted_decompressed = lzma.decompress(urlstate_data_decrypted) + urlstate_data_obj = json.loads(urlstate_data_decrypted_decompressed) + except Exception as e: + error_context = { + "message": "Failed to load state data. Reinitializing empty state.", + "reason": str(e), + "urlstate_data": urlstate_data, + } + logger.warning(error_context) + urlstate_data = {} + else: + urlstate_data = urlstate_data_obj + + session_id = ( + urlstate_data[_SESSION_ID_KEY] + if urlstate_data and _SESSION_ID_KEY in urlstate_data + else uuid4().urn + ) + urlstate_data[_SESSION_ID_KEY] = session_id + + super().__init__(urlstate_data) + + @property + def session_id(self): + return self.data.get(_SESSION_ID_KEY) + + def urlstate(self, encryption_key): + """ + Will return a url safe representation of the state. + + :type encryption_key: Key used for encryption. + :rtype: str + + :return: Url representation av of the state. + """ + lzma = LZMACompressor() + urlstate_data = json.dumps(self.data) + urlstate_data = lzma.compress(urlstate_data.encode("UTF-8")) + urlstate_data += lzma.flush() + urlstate_data = _AESCipher(encryption_key).encrypt(urlstate_data) + lzma = LZMACompressor() + urlstate_data = lzma.compress(urlstate_data) + urlstate_data += lzma.flush() + urlstate_data = base64.urlsafe_b64encode(urlstate_data) + return urlstate_data.decode("utf-8") + + def copy(self): + """ + Returns a deepcopy of the state + + :rtype: satosa.state.State + + :return: A copy of the state + """ + state_copy = State() + state_copy.data = copy.deepcopy(self.data) + return state_copy + + @property + def state_dict(self): + """ + :rtype: dict[str, any] + :return: A copy of the state as dictionary. + """ + return copy.deepcopy(self.data) + + def state_to_cookie(state, name, path, encryption_key): """ Saves a state to a cookie @@ -156,106 +259,3 @@ def _unpad(b): :rtype: bytes """ return b[:-ord(b[len(b) - 1:])] - - -class State(UserDict): - """ - This class holds a state attribute object. A state object must be able to be converted to - a json string, otherwise will an exception be raised. - """ - - def __init__(self, urlstate_data=None, encryption_key=None): - """ - If urlstate is empty a new empty state instance will be returned. - - If urlstate is not empty the constructor will rebuild the state attribute objects - from the urlstate string. - :type urlstate_data: str - :type encryption_key: str - :rtype: State - - :param encryption_key: The key to be used for encryption. - :param urlstate_data: A string created by the method urlstate in this class. - :return: An instance of this class. - """ - self.delete = False - - urlstate_data = {} if urlstate_data is None else urlstate_data - if urlstate_data and not encryption_key: - raise ValueError("If an 'urlstate_data' is supplied 'encrypt_key' must be specified.") - - if urlstate_data: - try: - urlstate_data_bytes = urlstate_data.encode("utf-8") - urlstate_data_b64decoded = base64.urlsafe_b64decode(urlstate_data_bytes) - lzma = LZMADecompressor() - urlstate_data_decompressed = lzma.decompress(urlstate_data_b64decoded) - urlstate_data_decrypted = _AESCipher(encryption_key).decrypt( - urlstate_data_decompressed - ) - lzma = LZMADecompressor() - urlstate_data_decrypted_decompressed = lzma.decompress(urlstate_data_decrypted) - urlstate_data_obj = json.loads(urlstate_data_decrypted_decompressed) - except Exception as e: - error_context = { - "message": "Failed to load state data. Reinitializing empty state.", - "reason": str(e), - "urlstate_data": urlstate_data, - } - logger.warning(error_context) - urlstate_data = {} - else: - urlstate_data = urlstate_data_obj - - session_id = ( - urlstate_data[_SESSION_ID_KEY] - if urlstate_data and _SESSION_ID_KEY in urlstate_data - else uuid4().urn - ) - urlstate_data[_SESSION_ID_KEY] = session_id - - super().__init__(urlstate_data) - - @property - def session_id(self): - return self.data.get(_SESSION_ID_KEY) - - def urlstate(self, encryption_key): - """ - Will return a url safe representation of the state. - - :type encryption_key: Key used for encryption. - :rtype: str - - :return: Url representation av of the state. - """ - lzma = LZMACompressor() - urlstate_data = json.dumps(self.data) - urlstate_data = lzma.compress(urlstate_data.encode("UTF-8")) - urlstate_data += lzma.flush() - urlstate_data = _AESCipher(encryption_key).encrypt(urlstate_data) - lzma = LZMACompressor() - urlstate_data = lzma.compress(urlstate_data) - urlstate_data += lzma.flush() - urlstate_data = base64.urlsafe_b64encode(urlstate_data) - return urlstate_data.decode("utf-8") - - def copy(self): - """ - Returns a deepcopy of the state - - :rtype: satosa.state.State - - :return: A copy of the state - """ - state_copy = State() - state_copy.data = copy.deepcopy(self.data) - return state_copy - - @property - def state_dict(self): - """ - :rtype: dict[str, any] - :return: A copy of the state as dictionary. - """ - return copy.deepcopy(self.data) From 1206ea58aff60dedd0f5e1f89488237fa5f947dd Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 20:02:20 +0300 Subject: [PATCH 362/401] feat: make cookie parameters configurable Signed-off-by: Ivan Kanakarakis --- doc/README.md | 4 +++ src/satosa/base.py | 6 +++- src/satosa/satosa_config.py | 8 ++--- src/satosa/state.py | 58 +++++++++++++++++++++++-------------- tests/satosa/test_state.py | 4 +-- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/doc/README.md b/doc/README.md index 8d001847e..fd266723b 100644 --- a/doc/README.md +++ b/doc/README.md @@ -80,6 +80,10 @@ bind_password: !ENVFILE LDAP_BIND_PASSWORD_FILE | -------------- | --------- | ------------- | ----------- | | `BASE` | string | `https://proxy.example.com` | base url of the proxy | | `COOKIE_STATE_NAME` | string | `satosa_state` | name of the cookie SATOSA uses for preserving state between requests | +| `COOKIE_SECURE` | bool | `True` | whether to include the cookie only when the request is transmitted over a secure channel | +| `COOKIE_HTTPONLY` | bool | `True` | whether the cookie should only be accessed only by the server | +| `COOKIE_SAMESITE` | string | `"None"` | whether the cookie should only be sent with requests initiated from the same registrable domain | +| `COOKIE_MAX_AGE` | string | `"1200"` | indicates the maximum lifetime of the cookie represented as the number of seconds until the cookie expires | | `CONTEXT_STATE_DELETE` | bool | `True` | controls whether SATOSA will delete the state cookie after receiving the authentication response from the upstream IdP| | `STATE_ENCRYPTION_KEY` | string | `52fddd3528a44157` | key used for encrypting the state cookie, will be overridden by the environment variable `SATOSA_STATE_ENCRYPTION_KEY` if it is set | | `INTERNAL_ATTRIBUTES` | string | `example/internal_attributes.yaml` | path to attribute mapping diff --git a/src/satosa/base.py b/src/satosa/base.py index 2a862a969..b53b4d8ab 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -204,7 +204,7 @@ def _load_state(self, context): state = State() finally: context.state = state - msg = "Loaded state {state} from cookie {cookie}".format(state=state, cookie=context.cookie) + msg = f"Loaded state {state} from cookie {context.cookie}" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) @@ -225,6 +225,10 @@ def _save_state(self, resp, context): name=cookie_name, path="/", encryption_key=self.config["STATE_ENCRYPTION_KEY"], + secure=self.config.get("COOKIE_SECURE"), + httponly=self.config.get("COOKIE_HTTPONLY"), + samesite=self.config.get("COOKIE_SAMESITE"), + max_age=self.config.get("COOKIE_MAX_AGE"), ) resp.headers = [ (name, value) diff --git a/src/satosa/satosa_config.py b/src/satosa/satosa_config.py index b107e5728..d45280c41 100644 --- a/src/satosa/satosa_config.py +++ b/src/satosa/satosa_config.py @@ -40,7 +40,7 @@ def __init__(self, config): # Load sensitive config from environment variables for key in SATOSAConfig.sensitive_dict_keys: - val = os.environ.get("SATOSA_{key}".format(key=key)) + val = os.environ.get(f"SATOSA_{key}") if val: self._config[key] = val @@ -56,7 +56,7 @@ def __init__(self, config): plugin_configs.append(plugin_config) break else: - raise SATOSAConfigurationError('Failed to load plugin config \'{}\''.format(config)) + raise SATOSAConfigurationError(f"Failed to load plugin config '{config}'") self._config[key] = plugin_configs for parser in parsers: @@ -86,8 +86,8 @@ def _verify_dict(self, conf): raise SATOSAConfigurationError("Missing key '%s' in config" % key) for key in SATOSAConfig.sensitive_dict_keys: - if key not in conf and "SATOSA_{key}".format(key=key) not in os.environ: - raise SATOSAConfigurationError("Missing key '%s' from config and ENVIRONMENT" % key) + if key not in conf and f"SATOSA_{key}" not in os.environ: + raise SATOSAConfigurationError(f"Missing key '{key}' from config and ENVIRONMENT") def __getitem__(self, item): """ diff --git a/src/satosa/state.py b/src/satosa/state.py index 37609f4e9..1fc768425 100644 --- a/src/satosa/state.py +++ b/src/satosa/state.py @@ -128,31 +128,46 @@ def state_dict(self): return copy.deepcopy(self.data) -def state_to_cookie(state, name, path, encryption_key): +def state_to_cookie( + state: State, + *, + name: str, + path: str, + encryption_key: str, + secure: bool = None, + httponly: bool = None, + samesite: str = None, + max_age: str = None, +) -> SimpleCookie: """ Saves a state to a cookie - :type state: satosa.state.State - :type name: str - :type path: str - :type encryption_key: str - :rtype: satosa.cookies.SimpleCookie - - :param state: The state to save - :param name: Name identifier of the cookie - :param path: Endpoint path the cookie will be associated to - :param encryption_key: Key to encrypt the state information - :return: A cookie + :param state: the data to save + :param name: identifier of the cookie + :param path: path the cookie will be associated to + :param encryption_key: the key to use to encrypt the state information + :param secure: whether to include the cookie only when the request is transmitted + over a secure channel + :param httponly: whether the cookie should only be accessed only by the server + :param samesite: whether the cookie should only be sent with requests + initiated from the same registrable domain + :param max_age: indicates the maximum lifetime of the cookie, + represented as the number of seconds until the cookie expires + :return: A cookie object """ - - cookie_data = "" if state.delete else state.urlstate(encryption_key) - cookie = SimpleCookie() - cookie[name] = cookie_data - cookie[name]["samesite"] = "None" - cookie[name]["secure"] = True + cookie[name] = "" if state.delete else state.urlstate(encryption_key) cookie[name]["path"] = path - cookie[name]["max-age"] = 0 if state.delete else "" + cookie[name]["secure"] = secure if secure is not None else True + cookie[name]["httponly"] = httponly if httponly is not None else "" + cookie[name]["samesite"] = samesite if samesite is not None else "None" + cookie[name]["max-age"] = ( + 0 + if state.delete + else max_age + if max_age is not None + else "" + ) msg = "Saved state in cookie {name} with properties {props}".format( name=name, props=list(cookie[name].items()) @@ -163,7 +178,7 @@ def state_to_cookie(state, name, path, encryption_key): return cookie -def cookie_to_state(cookie_str, name, encryption_key): +def cookie_to_state(cookie_str: str, name: str, encryption_key: str) -> State: """ Loads a state from a cookie @@ -181,8 +196,7 @@ def cookie_to_state(cookie_str, name, encryption_key): cookie = SimpleCookie(cookie_str) state = State(cookie[name].value, encryption_key) except KeyError as e: - msg_tmpl = 'No cookie named {name} in {data}' - msg = msg_tmpl.format(name=name, data=cookie_str) + msg = f'No cookie named {name} in {cookie_str}' raise SATOSAStateError(msg) from e except ValueError as e: msg_tmpl = 'Failed to process {name} from {data}' diff --git a/tests/satosa/test_state.py b/tests/satosa/test_state.py index 76b33d60c..eadee2182 100644 --- a/tests/satosa/test_state.py +++ b/tests/satosa/test_state.py @@ -100,7 +100,7 @@ def test_encode_decode_of_state(self): path = "/" encrypt_key = "2781y4hef90" - cookie = state_to_cookie(state, cookie_name, path, encrypt_key) + cookie = state_to_cookie(state, name=cookie_name, path=path, encryption_key=encrypt_key) cookie_str = cookie[cookie_name].OutputString() loaded_state = cookie_to_state(cookie_str, cookie_name, encrypt_key) @@ -117,7 +117,7 @@ def test_state_to_cookie_produces_cookie_without_max_age_for_state_that_should_b path = "/" encrypt_key = "2781y4hef90" - cookie = state_to_cookie(state, cookie_name, path, encrypt_key) + cookie = state_to_cookie(state, name=cookie_name, path=path, encryption_key=encrypt_key) cookie_str = cookie[cookie_name].OutputString() parsed_cookie = SimpleCookie(cookie_str) From 44aa4ac541ec109417a2cd7c76327e254877581a Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 20:17:28 +0300 Subject: [PATCH 363/401] Release v8.4.0 - Make cookie parameters configurable - Avoid setting duplicate set-cookie headers - Complete the support for the mdui:UIInfo element - satosa-saml-metadata: make signing optional - metadata_creation: for SAML backend, use sp.config to render metadata - tests: update markers of supported Python versions - deps: move away from pkg_resources when deriving the package version at runtime Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 11 +++++++++++ setup.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c1fc9a358..35f7a82c6 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.3.0 +current_version = 8.4.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 2824cc0a1..ee782f08f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 8.4.0 (2023-06-11) + +- Make cookie parameters configurable +- Avoid setting duplicate set-cookie headers +- Complete the support for the mdui:UIInfo element +- satosa-saml-metadata: make signing optional +- metadata_creation: for SAML backend, use sp.config to render metadata +- tests: update markers of supported Python versions +- deps: move away from pkg_resources when deriving the package version at runtime + + ## 8.3.0 (2023-06-08) - FilterAttributeValues plugin: add new filter types shibmdscope_match_scope and shibmdscope_match_value; add tests diff --git a/setup.py b/setup.py index 557691df7..59065f6ac 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.3.0', + version='8.4.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 4ba0b2f4fe3d779921ee9c31841f75ebffdcbb18 Mon Sep 17 00:00:00 2001 From: Christos Kanellopoulos Date: Mon, 13 Apr 2020 14:49:01 +0200 Subject: [PATCH 364/401] Handle missing state from cookie and redirect to generic error page --- src/satosa/backends/apple.py | 1 - src/satosa/backends/github.py | 1 - src/satosa/backends/linkedin.py | 1 - src/satosa/backends/oauth.py | 1 - src/satosa/backends/openid_connect.py | 44 +++++++-- src/satosa/backends/orcid.py | 1 - src/satosa/backends/saml2.py | 130 ++++++++++++++++++++------ src/satosa/base.py | 47 +++++++++- src/satosa/context.py | 1 + src/satosa/exception.py | 35 +++++++ src/satosa/frontends/saml2.py | 46 ++++++++- 11 files changed, 261 insertions(+), 47 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index edace8641..37f756a68 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -245,7 +245,6 @@ def response_endpoint(self, context, *args): msg = "UserInfo: {}".format(all_user_claims) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) - del context.state[self.name] internal_resp = self._translate_response( all_user_claims, self.client.authorization_endpoint ) diff --git a/src/satosa/backends/github.py b/src/satosa/backends/github.py index b04906f56..70944e371 100644 --- a/src/satosa/backends/github.py +++ b/src/satosa/backends/github.py @@ -99,7 +99,6 @@ def _authn_response(self, context): internal_response.attributes = self.converter.to_internal( self.external_type, user_info) internal_response.subject_id = str(user_info[self.user_id_attr]) - del context.state[self.name] return self.auth_callback_func(context, internal_response) def user_information(self, access_token): diff --git a/src/satosa/backends/linkedin.py b/src/satosa/backends/linkedin.py index 06a5cbac8..8d3a85b4c 100644 --- a/src/satosa/backends/linkedin.py +++ b/src/satosa/backends/linkedin.py @@ -110,7 +110,6 @@ def _authn_response(self, context): self.external_type, user_info) internal_response.subject_id = user_info[self.user_id_attr] - del context.state[self.name] return self.auth_callback_func(context, internal_response) def user_information(self, access_token, api): diff --git a/src/satosa/backends/oauth.py b/src/satosa/backends/oauth.py index 0cfa3a6ff..3e2bd041b 100644 --- a/src/satosa/backends/oauth.py +++ b/src/satosa/backends/oauth.py @@ -145,7 +145,6 @@ def _authn_response(self, context): internal_response = InternalData(auth_info=self.auth_info(context.request)) internal_response.attributes = self.converter.to_internal(self.external_type, user_info) internal_response.subject_id = user_info[self.user_id_attr] - del context.state[self.name] return self.auth_callback_func(context, internal_response) def auth_info(self, request): diff --git a/src/satosa/backends/openid_connect.py b/src/satosa/backends/openid_connect.py index cb97154f6..58d47af9b 100644 --- a/src/satosa/backends/openid_connect.py +++ b/src/satosa/backends/openid_connect.py @@ -19,7 +19,9 @@ from satosa.internal import InternalData from .base import BackendModule from .oauth import get_metadata_desc_for_oauth_backend -from ..exception import SATOSAAuthenticationError, SATOSAError +from ..exception import SATOSAAuthenticationError +from ..exception import SATOSAError +from ..exception import SATOSAMissingStateError from ..response import Redirect @@ -58,11 +60,24 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na self.config = config cfg_verify_ssl = config["client"].get("verify_ssl", True) oidc_settings = PyoidcSettings(verify_ssl=cfg_verify_ssl) - self.client = _create_client( - provider_metadata=config["provider_metadata"], - client_metadata=config["client"]["client_metadata"], - settings=oidc_settings, - ) + + try: + self.client = _create_client( + provider_metadata=config["provider_metadata"], + client_metadata=config["client"]["client_metadata"], + settings=oidc_settings, + ) + except Exception as exc: + msg = { + "message": f"Failed to initialize client", + "error": str(exc), + "client_metadata": self.config['client']['client_metadata'], + "provider_metadata": self.config['provider_metadata'], + } + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise SATOSAAuthenticationError(context.state, msg) from exc + if "scope" not in config["client"]["auth_req_params"]: config["auth_req_params"]["scope"] = "openid" if "response_type" not in config["client"]["auth_req_params"]: @@ -185,6 +200,22 @@ def response_endpoint(self, context, *args): :param args: None :return: """ + + if self.name not in context.state: + """ + If we end up here, it means that the user returns to the proxy + without the SATOSA session cookie. This can happen at least in the + following cases: + - the user deleted the cookie from the browser + - the browser of the user blocked the cookie + - the user has completed an authentication flow, the cookie has + been removed by SATOSA and then the user used the back button + of their browser and resend the authentication response, but + without the SATOSA session cookie + """ + error = "Received AuthN response without a SATOSA session cookie" + raise SATOSAMissingStateError(error) + backend_state = context.state[self.name] authn_resp = self.client.parse_response(AuthorizationResponse, info=context.request, sformat="dict") if backend_state[STATE_KEY] != authn_resp["state"]: @@ -215,7 +246,6 @@ def response_endpoint(self, context, *args): msg = "UserInfo: {}".format(all_user_claims) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) - del context.state[self.name] internal_resp = self._translate_response(all_user_claims, self.client.authorization_endpoint) return self.auth_callback_func(context, internal_resp) diff --git a/src/satosa/backends/orcid.py b/src/satosa/backends/orcid.py index d0ceee9b9..649e72451 100644 --- a/src/satosa/backends/orcid.py +++ b/src/satosa/backends/orcid.py @@ -79,7 +79,6 @@ def _authn_response(self, context): internal_response.attributes = self.converter.to_internal( self.external_type, user_info) internal_response.subject_id = user_info[self.user_id_attr] - del context.state[self.name] return self.auth_callback_func(context, internal_response) def user_information(self, access_token, orcid, name=None): diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 12a641732..3376ab300 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -27,6 +27,8 @@ from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.exception import SATOSAAuthenticationError +from satosa.exception import SATOSAMissingStateError +from satosa.exception import SATOSAAuthenticationFlowError from satosa.response import SeeOther, Response from satosa.saml_util import make_saml_response from satosa.metadata_creation.description import ( @@ -224,6 +226,14 @@ def disco_query(self, context): loc = self.sp.create_discovery_service_request( disco_url, self.sp.config.entityid, **args ) + + msg = { + "message": "Sending user to the discovery service", + "disco_url": loc + } + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + return SeeOther(loc) def construct_requested_authn_context(self, entity_id, *, target_accr=None): @@ -268,10 +278,13 @@ def authn_request(self, context, entity_id): 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) + msg = { + "message": "AuthnRequest Failed", + "error": f"Selected IdP with EntityID {entity_id} is blacklisted for this backend", + } 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") + logger.info(logline) + raise SATOSAAuthenticationError(context.state, msg) kwargs = {} target_accr = context.state.get(Context.KEY_TARGET_AUTHN_CONTEXT_CLASS_REF) @@ -299,16 +312,22 @@ def authn_request(self, context, entity_id): **kwargs, ) except Exception as e: - msg = "Failed to construct the AuthnRequest for state" + msg = { + "message": "AuthnRequest Failed", + "error": f"Failed to construct the AuthnRequest for state: {e}", + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline, exc_info=True) - raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from e + logger.info(logline) + raise SATOSAAuthenticationError(context.state, msg) from e 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) + msg = { + "message": "AuthnRequest Failed", + "error": f"Request with duplicate id {req_id}", + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) + logger.info(logline) raise SATOSAAuthenticationError(context.state, msg) self.outstanding_queries[req_id] = req_id @@ -378,43 +397,78 @@ def authn_response(self, context, binding): :param binding: The saml binding type :return: response """ + + if self.name not in context.state: + """ + If we end up here, it means that the user returns to the proxy + without the SATOSA session cookie. This can happen at least in the + following cases: + - the user deleted the cookie from the browser + - the browser of the user blocked the cookie + - the user has completed an authentication flow, the cookie has + been removed by SATOSA and then the user used the back button + of their browser and resend the authentication response, but + without the SATOSA session cookie + """ + msg = { + "message": "Authentication failed", + "error": "Received AuthN response without a SATOSA session cookie", + } + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + raise SATOSAMissingStateError(msg) + if not context.request.get("SAMLResponse"): - msg = "Missing Response for state" + msg = { + "message": "Authentication failed", + "error": "SAML Response not found in context.request", + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) - raise SATOSAAuthenticationError(context.state, "Missing Response") + logger.info(logline) + raise SATOSAAuthenticationError(context.state, msg) 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" + binding, + outstanding=self.outstanding_queries, + ) + except Exception as e: + msg = { + "message": "Authentication failed", + "error": f"Failed to parse Authn response: {err}", + } 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 + logger.info(logline) + raise SATOSAAuthenticationError(context.state, msg) from e 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), + msg = { + "message": "Authentication failed", + "error": f"No corresponding request with id: {req_id}", + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) + logger.info(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" + msg = { + "message": "Authentication failed", + "error": "Response state query param did not match relay state for request", + } 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") + logger.info(logline) + raise SATOSAAuthenticationError(context.state, msg) 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)) @@ -431,13 +485,18 @@ def disco_response(self, context): 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 + if 'SATOSA_BASE' not in state: + raise SATOSAAuthenticationFlowError("Discovery response without AuthN request") + + entity_id = info.get("entityID") + msg = { + "message": "Received response from the discovery service", + "entity_id": entity_id, + } + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + if not entity_id: + raise SATOSAAuthenticationError(state, msg) from err return self.authn_request(context, entity_id) @@ -488,11 +547,20 @@ def _translate_response(self, response, state): subject_id=name_id, ) - msg = "backend received attributes:\n{}".format( - json.dumps(response.ava, indent=4) - ) + msg = "backend received attributes: {}".format(response.ava) logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) logger.debug(logline) + + msg = { + "message": "Attributes received by the backend", + "issuer": issuer, + "attributes": " ".join(list(response.ava.keys())) + } + if name_id_format: + msg['name_id'] = name_id_format + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.info(logline) + return internal_resp def _metadata_endpoint(self, context): diff --git a/src/satosa/base.py b/src/satosa/base.py index b53b4d8ab..388a4c900 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -8,8 +8,15 @@ from saml2.s_utils import UnknownSystemEntity from satosa import util +from satosa.response import Redirect +from satosa.response import BadRequest from .context import Context -from .exception import SATOSAError, SATOSAAuthenticationError, SATOSAUnknownError +from .exception import SATOSAError +from .exception import SATOSAAuthenticationError +from .exception import SATOSAUnknownError +from .exception import SATOSAMissingStateError +from .exception import SATOSAAuthenticationFlowError +from .exception import SATOSABadRequestError from .plugin_loader import load_backends, load_frontends from .plugin_loader import load_request_microservices, load_response_microservices from .routing import ModuleRouter, SATOSANoBoundEndpointError @@ -253,6 +260,39 @@ def run(self, context): spec = self.module_router.endpoint_routing(context) resp = self._run_bound_endpoint(context, spec) self._save_state(resp, context) + except SATOSABadRequestError as e: + msg = { + "message": "Bad Request", + "error": e.error, + "error_id": uuid.uuid4().urn + } + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + return BadRequest(e.error) + except SATOSAMissingStateError as e: + msg = { + "message": "Missing SATOSA State", + "error": e.error, + "error_id": uuid.uuid4().urn + } + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + if self.config.get("ERROR_URL"): + return Redirect(self.config.get("ERROR_URL")) + else: + raise + except SATOSAAuthenticationFlowError as e: + msg = { + "message": "SATOSA Authentication Flow Error", + "error": e.error, + "error_id": uuid.uuid4().urn + } + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + if self.config.get("ERROR_URL"): + return Redirect(self.config.get("ERROR_URL")) + else: + raise except SATOSANoBoundEndpointError: raise except SATOSAError: @@ -269,7 +309,10 @@ def run(self, context): msg = "Uncaught exception" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline, exc_info=True) - raise SATOSAUnknownError("Unknown error") from err + if self.config.get("ERROR_URL"): + return Redirect(self.config.get("ERROR_URL")) + else: + raise SATOSAUnknownError("Unknown error") from err return resp diff --git a/src/satosa/context.py b/src/satosa/context.py index 1cf140586..33c365a51 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -18,6 +18,7 @@ class Context(object): KEY_TARGET_ENTITYID = 'target_entity_id' KEY_FORCE_AUTHN = 'force_authn' KEY_MEMORIZED_IDP = 'memorized_idp' + KEY_REQUESTER_METADATA = 'requester_metadata' KEY_AUTHN_CONTEXT_CLASS_REF = 'authn_context_class_ref' KEY_TARGET_AUTHN_CONTEXT_CLASS_REF = 'target_authn_context_class_ref' diff --git a/src/satosa/exception.py b/src/satosa/exception.py index 02f3c0554..f4fc4bc0c 100644 --- a/src/satosa/exception.py +++ b/src/satosa/exception.py @@ -67,3 +67,38 @@ def message(self): :return: Exception message """ return self._message.format(error_id=self.error_id) + +class SATOSABasicError(SATOSAError): + """ + eduTEAMS error + """ + def __init__(self, error): + self.error = error + +class SATOSAMissingStateError(SATOSABasicError): + """ + SATOSA Missing State error. + + This exception should be raised when SATOSA receives a request as part of + an authentication flow and while the session state cookie is expected for + that step, it is not included in the request + """ + pass + +class SATOSAAuthenticationFlowError(SATOSABasicError): + """ + SATOSA Flow error. + + This exception should be raised when SATOSA receives a request that cannot + be serviced because previous steps in the authentication flow for that session + cannot be found + """ + pass + +class SATOSABadRequestError(SATOSABasicError): + """ + SATOSA Bad Request error. + + This exception should be raised when we want to return an HTTP 400 Bad Request + """ + pass diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index 379635fc2..cecd533db 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -34,6 +34,8 @@ from ..response import ServiceError from ..saml_util import make_saml_response from satosa.exception import SATOSAError +from satosa.exception import SATOSABadRequestError +from satosa.exception import SATOSAMissingStateError import satosa.util as util import satosa.logging_util as lu @@ -152,7 +154,23 @@ def load_state(self, state): :param state: The current state :return: The dictionary given by the save_state function """ - state_data = state[self.name] + try: + state_data = state[self.name] + except KeyError: + """ + If we end up here, it means that the user returns to the proxy + without the SATOSA session cookie. This can happen at least in the + following cases: + - the user deleted the cookie from the browser + - the browser of the user blocked the cookie + - the user has completed an authentication flow, the cookie has + been removed by SATOSA and then the user used the back button + of their browser and resend the authentication response, but + without the SATOSA session cookie + """ + error = "Received AuthN response without a SATOSA session cookie" + raise SATOSAMissingStateError(error) + if isinstance(state_data["resp_args"]["name_id_policy"], str): state_data["resp_args"]["name_id_policy"] = name_id_policy_from_string( state_data["resp_args"]["name_id_policy"]) @@ -190,7 +208,16 @@ def _handle_authn_request(self, context, binding_in, idp): :param idp: The saml frontend idp server :return: response """ - req_info = idp.parse_authn_request(context.request["SAMLRequest"], binding_in) + + try: + req_info = idp.parse_authn_request(context.request["SAMLRequest"], binding_in) + except KeyError: + """ + HTTP clients that call the SSO endpoint without sending SAML AuthN + request will receive a "400 Bad Request" response + """ + raise SATOSABadRequestError("HTTP request does not include a SAML AuthN request") + authn_req = req_info.message msg = "{}".format(authn_req) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) @@ -444,6 +471,21 @@ def _handle_authn_response(self, context, internal_response, idp): self._set_common_domain_cookie(internal_response, http_args, context) del context.state[self.name] + + msg = { + "message": "Sending SAML AuthN Response", + "issuer": internal_response.auth_info.issuer, + "requester": sp_entity_id, + "signed response": sign_response, + "signed assertion": sign_assertion, + "encrypted": encrypt_assertion, + "attributes": " ".join(list(ava.keys())) + } + if nameid_format: + msg['name_id'] = nameid_format + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + return make_saml_response(resp_args["binding"], http_args) def _handle_backend_error(self, exception, idp): From d7adb92fd259c11b054421e10b18535f2d43b8fa Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 18:20:24 +0300 Subject: [PATCH 365/401] Add debug info for request data Signed-off-by: Ivan Kanakarakis --- src/satosa/proxy_server.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 03305d4ce..b06534b11 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -143,6 +143,18 @@ def __call__(self, environ, start_response, debug=False): environ['wsgi.input'].seek(0) + logline = { + "message": "Proxy server received request", + "request_method": context.request_method, + "request_uri": context.request_uri, + "content_length": content_length, + "request_data": context.request, + "query_params": context.qs_params, + "http_headers": context.http_headers, + "server_headers": context.server, + } + logger.debug(logline) + try: resp = self.run(context) if isinstance(resp, Exception): From e66bfcb25e774b53a8d43256c397d81593191bdd Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 18:21:03 +0300 Subject: [PATCH 366/401] Handle more generic error cases Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 55 +++++++++++++++++++++++++------------- src/satosa/proxy_server.py | 17 +++--------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index 388a4c900..9c562b457 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -10,6 +10,7 @@ from satosa import util from satosa.response import Redirect from satosa.response import BadRequest +from satosa.response import NotFound from .context import Context from .exception import SATOSAError from .exception import SATOSAAuthenticationError @@ -268,7 +269,11 @@ def run(self, context): } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) - return BadRequest(e.error) + generic_error_url = self.config.get("ERROR_URL") + if generic_error_url: + return Redirect(generic_error_url) + else: + return BadRequest(e.error) except SATOSAMissingStateError as e: msg = { "message": "Missing SATOSA State", @@ -277,8 +282,9 @@ def run(self, context): } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) - if self.config.get("ERROR_URL"): - return Redirect(self.config.get("ERROR_URL")) + generic_error_url = self.config.get("ERROR_URL") + if generic_error_url: + return Redirect(generic_error_url) else: raise except SATOSAAuthenticationFlowError as e: @@ -289,30 +295,43 @@ def run(self, context): } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) - if self.config.get("ERROR_URL"): - return Redirect(self.config.get("ERROR_URL")) + generic_error_url = self.config.get("ERROR_URL") + if generic_error_url: + return Redirect(generic_error_url) else: raise - except SATOSANoBoundEndpointError: - raise + except SATOSANoBoundEndpointError as e: + msg = str(e) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + return NotFound("The Service or Identity Provider you requested could not be found.") except SATOSAError: msg = "Uncaught SATOSA error" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline, exc_info=True) - raise - except UnknownSystemEntity as err: - msg = "configuration error: unknown system entity " + str(err) + logger.error(logline) + generic_error_url = self.config.get("ERROR_URL") + if generic_error_url: + return Redirect(generic_error_url) + else: + raise + except UnknownSystemEntity as e: + msg = f"Configuration error: unknown system entity: {e}" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline, exc_info=False) - raise - except Exception as err: + logger.error(logline) + generic_error_url = self.config.get("ERROR_URL") + if generic_error_url: + return Redirect(generic_error_url) + else: + raise + except Exception as e: msg = "Uncaught exception" logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.error(logline, exc_info=True) - if self.config.get("ERROR_URL"): - return Redirect(self.config.get("ERROR_URL")) + logger.error(logline) + generic_error_url = self.config.get("ERROR_URL") + if generic_error_url: + return Redirect(generic_error_url) else: - raise SATOSAUnknownError("Unknown error") from err + raise SATOSAUnknownError("Unknown error") from e return resp diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index b06534b11..7968167ea 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -7,13 +7,12 @@ from cookies_samesite_compat import CookiesSameSiteCompatMiddleware import satosa -import satosa.logging_util as lu from .base import SATOSABase from .context import Context -from .response import ServiceError, NotFound -from .routing import SATOSANoBoundEndpointError -from saml2.s_utils import UnknownSystemEntity +from .response import ServiceError +from .response import NotFound + logger = logging.getLogger(__name__) @@ -160,16 +159,8 @@ def __call__(self, environ, start_response, debug=False): if isinstance(resp, Exception): raise resp return resp(environ, start_response) - except SATOSANoBoundEndpointError as e: - msg = str(e) - logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) - logger.debug(logline) - resp = NotFound("The Service or Identity Provider you requested could not be found.") - return resp(environ, start_response) except Exception as e: - if type(e) != UnknownSystemEntity: - logline = "{}".format(e) - logger.exception(logline) + logger.exception(str(e)) if debug: raise From 189871d711128f7096b8ff6654d8a8fbb7c99c31 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Sun, 11 Jun 2023 18:35:36 +0300 Subject: [PATCH 367/401] Fix tests after changes in state presence Signed-off-by: Ivan Kanakarakis --- tests/satosa/backends/test_bitbucket.py | 2 -- tests/satosa/backends/test_oauth.py | 2 -- tests/satosa/backends/test_openid_connect.py | 2 -- tests/satosa/backends/test_saml2.py | 6 ++---- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/satosa/backends/test_bitbucket.py b/tests/satosa/backends/test_bitbucket.py index 192c55a84..d6cf25bac 100644 --- a/tests/satosa/backends/test_bitbucket.py +++ b/tests/satosa/backends/test_bitbucket.py @@ -159,7 +159,6 @@ def test_authn_response(self, incoming_authn_response): mock_do_access_token_request self.bb_backend._authn_response(incoming_authn_response) - assert self.bb_backend.name not in incoming_authn_response.state self.assert_expected_attributes() self.assert_token_request(**mock_do_access_token_request.call_args[1]) @@ -190,5 +189,4 @@ def test_entire_flow(self, context): "state": mock_get_state.return_value } self.bb_backend._authn_response(context) - assert self.bb_backend.name not in context.state self.assert_expected_attributes() diff --git a/tests/satosa/backends/test_oauth.py b/tests/satosa/backends/test_oauth.py index 0100cfaa9..22afc8ee7 100644 --- a/tests/satosa/backends/test_oauth.py +++ b/tests/satosa/backends/test_oauth.py @@ -136,7 +136,6 @@ def test_authn_response(self, incoming_authn_response): self.fb_backend.consumer.do_access_token_request = mock_do_access_token_request self.fb_backend._authn_response(incoming_authn_response) - assert self.fb_backend.name not in incoming_authn_response.state self.assert_expected_attributes() self.assert_token_request(**mock_do_access_token_request.call_args[1]) @@ -164,5 +163,4 @@ def test_entire_flow(self, context): "state": mock_get_state.return_value } self.fb_backend._authn_response(context) - assert self.fb_backend.name not in context.state self.assert_expected_attributes() diff --git a/tests/satosa/backends/test_openid_connect.py b/tests/satosa/backends/test_openid_connect.py index b898e157c..34bac79fe 100644 --- a/tests/satosa/backends/test_openid_connect.py +++ b/tests/satosa/backends/test_openid_connect.py @@ -163,7 +163,6 @@ def test_response_endpoint(self, backend_config, internal_attributes, userinfo, self.setup_userinfo_endpoint(backend_config["provider_metadata"]["userinfo_endpoint"], userinfo) self.oidc_backend.response_endpoint(incoming_authn_response) - assert self.oidc_backend.name not in incoming_authn_response.state args = self.oidc_backend.auth_callback_func.call_args[0] assert isinstance(args[0], Context) @@ -198,7 +197,6 @@ def test_entire_flow(self, context, backend_config, internal_attributes, userinf "token_type": "Bearer", } self.oidc_backend.response_endpoint(context) - assert self.oidc_backend.name not in context.state args = self.oidc_backend.auth_callback_func.call_args[0] self.assert_expected_attributes(internal_attributes, userinfo, args[1].attributes) diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index dcfdb0fa9..de349d9ad 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -132,11 +132,12 @@ def test_full_flow(self, context, idp_conf, sp_conf): disco_resp = parse_qs(urlparse(resp.message).query) info = parse_qs(urlparse(disco_resp["return"][0]).query) info["entityID"] = idp_conf["entityid"] - request_context = Context() + request_context = context request_context.request = info request_context.state = context.state # pass discovery response to backend and check that it redirects to the selected IdP + context.state["SATOSA_BASE"] = {"requester": "the-service-identifier"} resp = self.samlbackend.disco_response(request_context) assert_redirect_to_idp(resp, idp_conf) @@ -155,7 +156,6 @@ def test_full_flow(self, context, idp_conf, sp_conf): # pass auth response to backend and verify behavior self.samlbackend.authn_response(response_context, response_binding) context, internal_resp = self.samlbackend.auth_callback_func.call_args[0] - assert self.samlbackend.name not in context.state assert context.state[test_state_key] == "my_state" assert_authn_response(internal_resp) @@ -254,7 +254,6 @@ def test_authn_response(self, context, idp_conf, sp_conf): context, internal_resp = self.samlbackend.auth_callback_func.call_args[0] assert_authn_response(internal_resp) - assert self.samlbackend.name not in context.state @pytest.mark.skipif( saml2.__version__ < '4.6.1', @@ -290,7 +289,6 @@ def test_authn_response_no_name_id(self, context, idp_conf, sp_conf): context, internal_resp = backend.auth_callback_func.call_args[0] assert_authn_response(internal_resp) - assert backend.name not in context.state def test_authn_response_with_encrypted_assertion(self, sp_conf, context): with open(os.path.join( From 62f8775421734af08a337be18ff208d00a78bc71 Mon Sep 17 00:00:00 2001 From: Kristof Bajnok Date: Tue, 21 Mar 2023 08:42:05 +0100 Subject: [PATCH 368/401] Test for missing state and missing relay state Signed-off-by: Ivan Kanakarakis --- src/satosa/backends/saml2.py | 9 ++-- tests/satosa/backends/test_saml2.py | 83 ++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 3376ab300..ec99cad06 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -418,7 +418,8 @@ def authn_response(self, context, binding): logger.info(logline) raise SATOSAMissingStateError(msg) - if not context.request.get("SAMLResponse"): + samlresponse = context.request.get("SAMLResponse") + if not samlresponse: msg = { "message": "Authentication failed", "error": "SAML Response not found in context.request", @@ -429,9 +430,7 @@ def authn_response(self, context, binding): try: authn_response = self.sp.parse_authn_request_response( - context.request["SAMLResponse"], - binding, - outstanding=self.outstanding_queries, + samlresponse, binding, outstanding=self.outstanding_queries ) except Exception as e: msg = { @@ -456,7 +455,7 @@ def authn_response(self, context, binding): 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"]: + if context.state[self.name].get("relay_state") != context.request["RelayState"]: msg = { "message": "Authentication failed", "error": "Response state query param did not match relay state for request", diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index de349d9ad..e1cc96466 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -21,6 +21,8 @@ from satosa.backends.saml2 import SAMLBackend from satosa.context import Context +from satosa.exception import SATOSAAuthenticationError +from satosa.exception import SATOSAMissingStateError from satosa.internal import InternalData from tests.users import USERS from tests.util import FakeIdP, create_metadata_from_config_dict, FakeSP @@ -132,7 +134,7 @@ def test_full_flow(self, context, idp_conf, sp_conf): disco_resp = parse_qs(urlparse(resp.message).query) info = parse_qs(urlparse(disco_resp["return"][0]).query) info["entityID"] = idp_conf["entityid"] - request_context = context + request_context = Context() request_context.request = info request_context.state = context.state @@ -241,13 +243,9 @@ def test_unknown_or_no_hostname_selects_first_acs( def test_authn_response(self, context, idp_conf, sp_conf): response_binding = BINDING_HTTP_REDIRECT - fakesp = FakeSP(SPConfig().load(sp_conf)) - fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) - destination, request_params = fakesp.make_auth_req(idp_conf["entityid"]) - url, auth_resp = fakeidp.handle_auth_req(request_params["SAMLRequest"], request_params["RelayState"], - BINDING_HTTP_REDIRECT, - "testuser1", response_binding=response_binding) - + request_params, auth_resp = self._perform_request_response( + idp_conf, sp_conf, response_binding + ) context.request = auth_resp context.state[self.samlbackend.name] = {"relay_state": request_params["RelayState"]} self.samlbackend.authn_response(context, response_binding) @@ -255,29 +253,62 @@ def test_authn_response(self, context, idp_conf, sp_conf): context, internal_resp = self.samlbackend.auth_callback_func.call_args[0] assert_authn_response(internal_resp) - @pytest.mark.skipif( - saml2.__version__ < '4.6.1', - reason="Optional NameID needs pysaml2 v4.6.1 or higher") - def test_authn_response_no_name_id(self, context, idp_conf, sp_conf): + def _perform_request_response( + self, idp_conf, sp_conf, response_binding, receive_nameid=True + ): + fakesp = FakeSP(SPConfig().load(sp_conf)) + fakeidp = FakeIdP(USERS, config=IdPConfig().load(idp_conf)) + destination, request_params = fakesp.make_auth_req(idp_conf["entityid"]) + auth_resp_func = ( + fakeidp.handle_auth_req + if receive_nameid + else fakeidp.handle_auth_req_no_name_id + ) + url, auth_resp = auth_resp_func( + request_params["SAMLRequest"], + request_params["RelayState"], + BINDING_HTTP_REDIRECT, + "testuser1", + response_binding=response_binding, + ) + + return request_params, auth_resp + + def test_no_state_raises_error(self, context, idp_conf, sp_conf): response_binding = BINDING_HTTP_REDIRECT + request_params, auth_resp = self._perform_request_response( + idp_conf, sp_conf, response_binding + ) + context.request = auth_resp + # not setting context.state[self.samlbackend.name] + # to simulate a request with lost state - fakesp_conf = SPConfig().load(sp_conf) - fakesp = FakeSP(fakesp_conf) + with pytest.raises(SATOSAMissingStateError): + self.samlbackend.authn_response(context, response_binding) - fakeidp_conf = IdPConfig().load(idp_conf) - fakeidp = FakeIdP(USERS, config=fakeidp_conf) + def test_no_relay_state_raises_error(self, context, idp_conf, sp_conf): + response_binding = BINDING_HTTP_REDIRECT + request_params, auth_resp = self._perform_request_response( + idp_conf, sp_conf, response_binding + ) + context.request = auth_resp + # not setting context.state[self.samlbackend.name]["relay_state"] + # to simulate a request without a relay state + context.state[self.samlbackend.name] = {} - destination, request_params = fakesp.make_auth_req( - idp_conf["entityid"]) + with pytest.raises(SATOSAAuthenticationError): + self.samlbackend.authn_response(context, response_binding) - # Use the fake IdP to mock up an authentication request that has no - # element. - url, auth_resp = fakeidp.handle_auth_req_no_name_id( - request_params["SAMLRequest"], - request_params["RelayState"], - BINDING_HTTP_REDIRECT, - "testuser1", - response_binding=response_binding) + @pytest.mark.skipif( + saml2.__version__ < '4.6.1', + reason="Optional NameID needs pysaml2 v4.6.1 or higher" + ) + def test_authn_response_no_name_id(self, context, idp_conf, sp_conf): + response_binding = BINDING_HTTP_REDIRECT + + request_params, auth_resp = self._perform_request_response( + idp_conf, sp_conf, response_binding, receive_nameid=False + ) backend = self.samlbackend From 014e12166d0097d251e0e38bd291f730be8969de Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 12 Jun 2023 21:15:08 +0300 Subject: [PATCH 369/401] Restructure fatal error messages Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 97 +++++++++++++++++++++++++++-------------- src/satosa/context.py | 9 +--- src/satosa/exception.py | 18 ++++++++ src/satosa/routing.py | 18 +------- 4 files changed, 86 insertions(+), 56 deletions(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index 9c562b457..1e17c8cbe 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -8,20 +8,26 @@ from saml2.s_utils import UnknownSystemEntity from satosa import util -from satosa.response import Redirect from satosa.response import BadRequest from satosa.response import NotFound +from satosa.response import Redirect from .context import Context -from .exception import SATOSAError from .exception import SATOSAAuthenticationError -from .exception import SATOSAUnknownError -from .exception import SATOSAMissingStateError from .exception import SATOSAAuthenticationFlowError from .exception import SATOSABadRequestError -from .plugin_loader import load_backends, load_frontends -from .plugin_loader import load_request_microservices, load_response_microservices -from .routing import ModuleRouter, SATOSANoBoundEndpointError -from .state import cookie_to_state, SATOSAStateError, State, state_to_cookie +from .exception import SATOSAError +from .exception import SATOSAMissingStateError +from .exception import SATOSANoBoundEndpointError +from .exception import SATOSAUnknownError +from .exception import SATOSAStateError +from .plugin_loader import load_backends +from .plugin_loader import load_frontends +from .plugin_loader import load_request_microservices +from .plugin_loader import load_response_microservices +from .routing import ModuleRouter +from .state import State +from .state import cookie_to_state +from .state import state_to_cookie import satosa.logging_util as lu @@ -262,77 +268,104 @@ def run(self, context): resp = self._run_bound_endpoint(context, spec) self._save_state(resp, context) except SATOSABadRequestError as e: + error_id = uuid.uuid4().urn msg = { "message": "Bad Request", - "error": e.error, - "error_id": uuid.uuid4().urn + "error": str(e), + "error_id": error_id, } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) generic_error_url = self.config.get("ERROR_URL") if generic_error_url: + redirect_url = f"{generic_error_url}?errorid={error_id}" return Redirect(generic_error_url) - else: - return BadRequest(e.error) + return BadRequest(error) except SATOSAMissingStateError as e: + error_id = uuid.uuid4().urn msg = { "message": "Missing SATOSA State", - "error": e.error, - "error_id": uuid.uuid4().urn + "error": str(e), + "error_id": error_id, } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) generic_error_url = self.config.get("ERROR_URL") if generic_error_url: + redirect_url = f"{generic_error_url}?errorid={error_id}" return Redirect(generic_error_url) - else: - raise + raise except SATOSAAuthenticationFlowError as e: + error_id = uuid.uuid4().urn msg = { "message": "SATOSA Authentication Flow Error", - "error": e.error, - "error_id": uuid.uuid4().urn + "error": str(e), + "error_id": error_id, } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) generic_error_url = self.config.get("ERROR_URL") if generic_error_url: + redirect_url = f"{generic_error_url}?errorid={error_id}" return Redirect(generic_error_url) - else: - raise + raise except SATOSANoBoundEndpointError as e: - msg = str(e) + error_id = uuid.uuid4().urn + msg = { + "message": "URL-path is not bound to any endpoint function", + "error": str(e), + "error_id": error_id, + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) + generic_error_url = self.config.get("ERROR_URL") + if generic_error_url: + redirect_url = f"{generic_error_url}?errorid={error_id}" + return Redirect(generic_error_url) return NotFound("The Service or Identity Provider you requested could not be found.") - except SATOSAError: - msg = "Uncaught SATOSA error" + except SATOSAError as e: + error_id = uuid.uuid4().urn + msg = { + "message": "Uncaught SATOSA error", + "error": str(e), + "error_id": error_id, + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) generic_error_url = self.config.get("ERROR_URL") if generic_error_url: + redirect_url = f"{generic_error_url}?errorid={error_id}" return Redirect(generic_error_url) - else: - raise + raise except UnknownSystemEntity as e: - msg = f"Configuration error: unknown system entity: {e}" + error_id = uuid.uuid4().urn + msg = { + "message": "Configuration error: unknown system entity", + "error": str(e), + "error_id": error_id, + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) generic_error_url = self.config.get("ERROR_URL") if generic_error_url: + redirect_url = f"{generic_error_url}?errorid={error_id}" return Redirect(generic_error_url) - else: - raise + raise except Exception as e: - msg = "Uncaught exception" + error_id = uuid.uuid4().urn + msg = { + "message": "Uncaught exception", + "error": str(e), + "error_id": error_id, + } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.error(logline) generic_error_url = self.config.get("ERROR_URL") if generic_error_url: return Redirect(generic_error_url) - else: - raise SATOSAUnknownError("Unknown error") from e - return resp + raise SATOSAUnknownError("Unknown error") from e + else: + return resp class SAMLBaseModule(object): diff --git a/src/satosa/context.py b/src/satosa/context.py index 33c365a51..2cd8243ac 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -1,13 +1,6 @@ from warnings import warn as _warn -from satosa.exception import SATOSAError - - -class SATOSABadContextError(SATOSAError): - """ - Raise this exception if validating the Context and failing. - """ - pass +from satosa.exception import SATOSABadContextError class Context(object): diff --git a/src/satosa/exception.py b/src/satosa/exception.py index f4fc4bc0c..770d26283 100644 --- a/src/satosa/exception.py +++ b/src/satosa/exception.py @@ -68,6 +68,7 @@ def message(self): """ return self._message.format(error_id=self.error_id) + class SATOSABasicError(SATOSAError): """ eduTEAMS error @@ -75,6 +76,7 @@ class SATOSABasicError(SATOSAError): def __init__(self, error): self.error = error + class SATOSAMissingStateError(SATOSABasicError): """ SATOSA Missing State error. @@ -85,6 +87,7 @@ class SATOSAMissingStateError(SATOSABasicError): """ pass + class SATOSAAuthenticationFlowError(SATOSABasicError): """ SATOSA Flow error. @@ -95,6 +98,7 @@ class SATOSAAuthenticationFlowError(SATOSABasicError): """ pass + class SATOSABadRequestError(SATOSABasicError): """ SATOSA Bad Request error. @@ -102,3 +106,17 @@ class SATOSABadRequestError(SATOSABasicError): This exception should be raised when we want to return an HTTP 400 Bad Request """ pass + + +class SATOSABadContextError(SATOSAError): + """ + Raise this exception if validating the Context and failing. + """ + pass + + +class SATOSANoBoundEndpointError(SATOSAError): + """ + Raised when a given url path is not bound to any endpoint function + """ + pass diff --git a/src/satosa/routing.py b/src/satosa/routing.py index 317b047f9..015cffb23 100644 --- a/src/satosa/routing.py +++ b/src/satosa/routing.py @@ -4,8 +4,8 @@ import logging import re -from satosa.context import SATOSABadContextError -from satosa.exception import SATOSAError +from satosa.exception import SATOSABadContextError +from satosa.exception import SATOSANoBoundEndpointError import satosa.logging_util as lu @@ -15,20 +15,6 @@ STATE_KEY = "ROUTER" -class SATOSANoBoundEndpointError(SATOSAError): - """ - Raised when a given url path is not bound to any endpoint function - """ - pass - - -class SATOSAUnknownTargetBackend(SATOSAError): - """ - Raised when targeting an unknown backend - """ - pass - - class ModuleRouter(object): class UnknownEndpoint(ValueError): pass From ee913b21327806ca175902ad49df95a1b90d0efe Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Sat, 11 Dec 2021 15:41:03 +0100 Subject: [PATCH 370/401] Add option pool_lifetime option to ldap This patch adds another option to the ldap connection. Next to the other pool connections, it is now possible to set the `pool_lifetime`. --- .../plugins/microservices/ldap_attribute_store.yaml.example | 3 +++ src/satosa/micro_services/ldap_attribute_store.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/example/plugins/microservices/ldap_attribute_store.yaml.example b/example/plugins/microservices/ldap_attribute_store.yaml.example index 4efe85072..35e1bf264 100644 --- a/example/plugins/microservices/ldap_attribute_store.yaml.example +++ b/example/plugins/microservices/ldap_attribute_store.yaml.example @@ -27,6 +27,9 @@ config: # pool_keepalive: seconds to wait between calls to server to keep the # connection alive; default: 10 pool_keepalive: 10 + # pool_lifetime: number of seconds before recreating a new connection + # in a pooled connection strategy. + pool_lifetime: None # Attributes to return from LDAP query. query_return_attributes: diff --git a/src/satosa/micro_services/ldap_attribute_store.py b/src/satosa/micro_services/ldap_attribute_store.py index 6d61559b1..fa0cb422f 100644 --- a/src/satosa/micro_services/ldap_attribute_store.py +++ b/src/satosa/micro_services/ldap_attribute_store.py @@ -61,6 +61,7 @@ class LdapAttributeStore(ResponseMicroService): "client_strategy": "REUSABLE", "pool_size": 10, "pool_keepalive": 10, + "pool_lifetime": None, } def __init__(self, config, *args, **kwargs): @@ -307,6 +308,7 @@ def _ldap_connection_factory(self, config): pool_size = config["pool_size"] pool_keepalive = config["pool_keepalive"] + pool_lifetime = config["pool_lifetime"] pool_name = ''.join(random.sample(string.ascii_lowercase, 6)) if client_strategy == ldap3.REUSABLE: @@ -314,6 +316,9 @@ def _ldap_connection_factory(self, config): logger.debug(msg) msg = "Using pool keep alive {}".format(pool_keepalive) logger.debug(msg) + if pool_lifetime: + msg = "Using pool lifetime {}".format(pool_lifetime) + logger.debug(msg) try: connection = ldap3.Connection( @@ -327,6 +332,7 @@ def _ldap_connection_factory(self, config): pool_name=pool_name, pool_size=pool_size, pool_keepalive=pool_keepalive, + pool_lifetime=pool_lifetime, ) msg = "Successfully connected to LDAP server" logger.debug(msg) From 97cbdf814dd7405ddc3a5ad372b3c9e81f1f12dd Mon Sep 17 00:00:00 2001 From: Sven Haardiek Date: Fri, 16 Jun 2023 15:56:37 +0200 Subject: [PATCH 371/401] =?UTF-8?q?Add=20tests=20f=C3=BCr=20ldap=20connect?= =?UTF-8?q?ion=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch adds tests to check the configuration of the ldap connection. Signed-off-by: Sven Haardiek --- .../test_ldap_attribute_store.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/satosa/micro_services/test_ldap_attribute_store.py b/tests/satosa/micro_services/test_ldap_attribute_store.py index e3af1a7f5..26dc3b9fb 100644 --- a/tests/satosa/micro_services/test_ldap_attribute_store.py +++ b/tests/satosa/micro_services/test_ldap_attribute_store.py @@ -2,6 +2,8 @@ from copy import deepcopy +from ldap3 import AUTO_BIND_NO_TLS, MOCK_SYNC + from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from satosa.micro_services.ldap_attribute_store import LdapAttributeStore @@ -107,3 +109,60 @@ def test_attributes_general(self, ldap_attribute_store): internal_attr = ldap_to_internal_map[ldap_attr] response_attr = response.attributes[internal_attr] assert(ldap_value in response_attr) + + @pytest.mark.parametrize( + 'config,connection_attributes', + [ + ( + { + 'auto_bind': 'AUTO_BIND_NO_TLS', + 'client_strategy': 'MOCK_SYNC', + 'ldap_url': 'ldap://satosa.example.com', + 'bind_dn': 'uid=readonly_user,ou=system,dc=example,dc=com', + 'bind_password': 'password', + }, + { + 'user': 'uid=readonly_user,ou=system,dc=example,dc=com', + 'password': 'password', + 'auto_bind': AUTO_BIND_NO_TLS, + 'strategy_type': MOCK_SYNC, + 'read_only': True, + 'version': 3, + 'pool_size': 10, + 'pool_keepalive': 10, + 'pool_lifetime': None, + }, + ), + ( + { + 'auto_bind': 'AUTO_BIND_NO_TLS', + 'client_strategy': 'MOCK_SYNC', + 'ldap_url': 'ldap://satosa.example.com', + 'bind_dn': 'uid=readonly_user,ou=system,dc=example,dc=com', + 'bind_password': 'password', + 'pool_size': 40, + 'pool_keepalive': 41, + 'pool_lifetime': 42, + }, + { + 'user': 'uid=readonly_user,ou=system,dc=example,dc=com', + 'password': 'password', + 'auto_bind': AUTO_BIND_NO_TLS, + 'strategy_type': MOCK_SYNC, + 'read_only': True, + 'version': 3, + 'pool_size': 40, + 'pool_keepalive': 41, + 'pool_lifetime': 42, + }, + ), + ] + ) + def test_connection_config(self, config, connection_attributes): + ldapAttributeStore = LdapAttributeStore({'default': config}, + name="test_ldap_attribute_store", + base_url="https://satosa.example.com") + connection = ldapAttributeStore.config['default']['connection'] + + for k, v in connection_attributes.items(): + assert getattr(connection, k) == v From 47638a79834a37ef6e2062d370f97efc1137aba3 Mon Sep 17 00:00:00 2001 From: roland Date: Thu, 8 Jun 2023 09:40:43 +0200 Subject: [PATCH 372/401] New idpyoidc based OAuth2/OIDC backend --- src/satosa/backends/idpy_oidc.py | 124 +++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/satosa/backends/idpy_oidc.py diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py new file mode 100644 index 000000000..a0aa20f72 --- /dev/null +++ b/src/satosa/backends/idpy_oidc.py @@ -0,0 +1,124 @@ +""" +OIDC backend module. +""" +import logging +from datetime import datetime + +from idpyoidc.server.user_authn.authn_context import UNSPECIFIED + +from satosa.backends.base import BackendModule +from satosa.internal import AuthenticationInformation +from satosa.internal import InternalData + +logger = logging.getLogger(__name__) + +""" +OIDC/OAuth2 backend module. +""" +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient + + +class IdpyOIDCBackend(BackendModule): + """ + Backend module for OIDC and OAuth 2.0, can be directly used. + """ + + def __init__(self, + outgoing, + internal_attributes, + config, + base_url, + name, + external_type, + user_id_attr + ): + """ + :param outgoing: Callback should be called by the module after the authorization in the + backend is done. + :param internal_attributes: Mapping dictionary between SATOSA internal attribute names and + the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and + RP's expects namevice. + :param config: Configuration parameters for the module. + :param base_url: base url of the service + :param name: name of the plugin + :param external_type: The name for this module in the internal attributes. + + :type outgoing: + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + :type internal_attributes: dict[string, dict[str, str | list[str]]] + :type config: dict[str, dict[str, str] | list[str]] + :type base_url: str + :type name: str + :type external_type: str + """ + super().__init__(outgoing, internal_attributes, base_url, name) + self.name = name + self.external_type = external_type + self.user_id_attr = user_id_attr + + self.client = StandAloneClient(config=config["client_config"], + client_type=config["client_config"]['client_type']) + # Deal with provider discovery and client registration + self.client.do_provider_info() + self.client.do_client_registration() + + def start_auth(self, context, internal_request): + """ + See super class method satosa.backends.base#start_auth + + :type context: satosa.context.Context + :type internal_request: satosa.internal.InternalData + :rtype satosa.response.Redirect + """ + return self.client.init_authorization() + + def register_endpoints(self): + """ + Creates a list of all the endpoints this backend module needs to listen to. In this case + it's the authentication response from the underlying OP that is redirected from the OP to + the proxy. + :rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]] + :return: A list that can be used to map the request to SATOSA to this endpoint. + """ + + return self.client.context.claims.get_usage('authorization_endpoint') + + def _authn_response(self, context): + """ + Handles the authentication response from the AS. + + :type context: satosa.context.Context + :rtype: satosa.response.Response + :param context: The context in SATOSA + :return: A SATOSA response. This method is only responsible to call the callback function + which generates the Response object. + """ + + _info = self.client.finalize(context.request) + + try: + auth_info = self.auth_info(context.request) + except NotImplementedError: + auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), _info["issuer"]) + + internal_response = InternalData(auth_info=auth_info) + internal_response.attributes = self.converter.to_internal(self.external_type, + _info['userinfo']) + internal_response.subject_id = _info['userinfo'][self.user_id_attr] + del context.state[self.name] + # return self.auth_callback_func(context, internal_response) + if 'error' in _info: + return _info + else: + return _info['userinfo'] + + def auth_info(self, request): + """ + Creates the SATOSA authentication information object. + :type request: dict[str, str] + :rtype: AuthenticationInformation + + :param request: The request parameters in the authentication response sent by the AS. + :return: How, who and when the authentication took place. + """ + raise NotImplementedError("Method 'auth_info' must be implemented in the subclass!") From dba92f80141cfeb6112d05983697be55b5ae5cf5 Mon Sep 17 00:00:00 2001 From: roland Date: Thu, 8 Jun 2023 12:17:09 +0200 Subject: [PATCH 373/401] Added error message handling. --- src/satosa/backends/idpy_oidc.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index a0aa20f72..f9c18826f 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -6,7 +6,9 @@ from idpyoidc.server.user_authn.authn_context import UNSPECIFIED +import satosa.logging_util as lu from satosa.backends.base import BackendModule +from satosa.exception import SATOSAAuthenticationError from satosa.internal import AuthenticationInformation from satosa.internal import InternalData @@ -83,6 +85,23 @@ def register_endpoints(self): return self.client.context.claims.get_usage('authorization_endpoint') + def _check_error_response(self, response, context): + """ + Check if the response is an error response. + :param response: the response from finalize() + :type response: oic.oic.message + :raise SATOSAAuthenticationError: if the response is an OAuth error response + """ + if "error" in response: + msg = "{name} error: {error} {description}".format( + name=type(response).__name__, + error=response["error"], + description=response.get("error_description", ""), + ) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + raise SATOSAAuthenticationError(context.state, "Access denied") + def _authn_response(self, context): """ Handles the authentication response from the AS. @@ -95,6 +114,7 @@ def _authn_response(self, context): """ _info = self.client.finalize(context.request) + self._check_error_response(_info, context) try: auth_info = self.auth_info(context.request) From 7ca9a801337299301965e46f3d090cb660ef4a02 Mon Sep 17 00:00:00 2001 From: roland Date: Thu, 8 Jun 2023 21:34:35 +0200 Subject: [PATCH 374/401] Updated init attributes. --- src/satosa/backends/idpy_oidc.py | 61 +++++++++++++------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index f9c18826f..cec28fe80 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -25,38 +25,23 @@ class IdpyOIDCBackend(BackendModule): Backend module for OIDC and OAuth 2.0, can be directly used. """ - def __init__(self, - outgoing, - internal_attributes, - config, - base_url, - name, - external_type, - user_id_attr - ): + def __init__(self, outgoing, internal_attributes, config, base_url, name): """ - :param outgoing: Callback should be called by the module after the authorization in the - backend is done. - :param internal_attributes: Mapping dictionary between SATOSA internal attribute names and - the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and - RP's expects namevice. - :param config: Configuration parameters for the module. - :param base_url: base url of the service - :param name: name of the plugin - :param external_type: The name for this module in the internal attributes. - :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response - :type internal_attributes: dict[string, dict[str, str | list[str]]] - :type config: dict[str, dict[str, str] | list[str]] + :type internal_attributes: dict[str, dict[str, list[str] | str]] + :type config: dict[str, Any] :type base_url: str :type name: str - :type external_type: 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.name = name - self.external_type = external_type - self.user_id_attr = user_id_attr self.client = StandAloneClient(config=config["client_config"], client_type=config["client_config"]['client_type']) @@ -119,18 +104,20 @@ def _authn_response(self, context): try: auth_info = self.auth_info(context.request) except NotImplementedError: - auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), _info["issuer"]) - - internal_response = InternalData(auth_info=auth_info) - internal_response.attributes = self.converter.to_internal(self.external_type, - _info['userinfo']) - internal_response.subject_id = _info['userinfo'][self.user_id_attr] - del context.state[self.name] - # return self.auth_callback_func(context, internal_response) - if 'error' in _info: - return _info - else: - return _info['userinfo'] + auth_info = AuthenticationInformation(auth_class_ref=UNSPECIFIED, + timestamp=str(datetime.now()), + issuer=_info["issuer"]) + + attributes = self.converter.to_internal( + self.client.client_type, _info['userinfo'], + ) + + internal_response = InternalData( + auth_info=auth_info, + attributes=attributes, + subject_id=_info['userinfo']['sub'] + ) + return internal_response def auth_info(self, request): """ From f0f38af3fd1bee7d24f055a798b6c5065bb25373 Mon Sep 17 00:00:00 2001 From: roland Date: Fri, 9 Jun 2023 16:33:12 +0200 Subject: [PATCH 375/401] Changes as a result of Ali's testing. --- src/satosa/backends/idpy_oidc.py | 141 ++++++++++++++++--------------- 1 file changed, 73 insertions(+), 68 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index cec28fe80..825ba9f72 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -1,51 +1,50 @@ """ -OIDC backend module. +OIDC/OAuth2 backend module. """ import logging from datetime import datetime +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient from idpyoidc.server.user_authn.authn_context import UNSPECIFIED import satosa.logging_util as lu from satosa.backends.base import BackendModule -from satosa.exception import SATOSAAuthenticationError from satosa.internal import AuthenticationInformation from satosa.internal import InternalData +from ..exception import SATOSAAuthenticationError +from ..response import Redirect logger = logging.getLogger(__name__) -""" -OIDC/OAuth2 backend module. -""" -from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient - class IdpyOIDCBackend(BackendModule): """ Backend module for OIDC and OAuth 2.0, can be directly used. """ - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): """ - :type outgoing: + OIDC backend module. + :param auth_callback_func: Callback should be called by the module after the authorization + in the backend is done. + :param internal_attributes: Mapping dictionary between SATOSA internal attribute names and + the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and + RP's expects namevice. + :param config: Configuration parameters for the module. + :param base_url: base url of the service + :param name: name of the plugin + + :type auth_callback_func: (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 internal_attributes: dict[string, dict[str, str | list[str]]] + :type config: dict[str, dict[str, str] | list[str]] :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.client = StandAloneClient(config=config["client_config"], - client_type=config["client_config"]['client_type']) - # Deal with provider discovery and client registration + super().__init__(auth_callback_func, internal_attributes, base_url, name) + # self.auth_callback_func = auth_callback_func + # self.config = config + self.client = StandAloneClient(config=config["client"], client_type="oidc") self.client.do_provider_info() self.client.do_client_registration() @@ -57,7 +56,8 @@ def start_auth(self, context, internal_request): :type internal_request: satosa.internal.InternalData :rtype satosa.response.Redirect """ - return self.client.init_authorization() + login_url = self.client.init_authorization() + return Redirect(login_url) def register_endpoints(self): """ @@ -67,8 +67,56 @@ def register_endpoints(self): :rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]] :return: A list that can be used to map the request to SATOSA to this endpoint. """ + return self.client.context.claims.get_usage('redirect_uris') + + def response_endpoint(self, context, *args): + """ + Handles the authentication response from the OP. + :type context: satosa.context.Context + :type args: Any + :rtype: satosa.response.Response - return self.client.context.claims.get_usage('authorization_endpoint') + :param context: SATOSA context + :param args: None + :return: + """ + + _info = self.client.finalize(context.request) + self._check_error_response(_info, context) + userinfo = _info.get('userinfo') + id_token = _info.get('id_token') + + if not id_token and not userinfo: + msg = "No id_token or userinfo, nothing to do.." + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise SATOSAAuthenticationError(context.state, "No user info available.") + + all_user_claims = dict(list(userinfo.items()) + list(id_token.items())) + msg = "UserInfo: {}".format(all_user_claims) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + internal_resp = self._translate_response(all_user_claims, _info["issuer"]) + return self.auth_callback_func(context, internal_resp) + + def _translate_response(self, response, issuer): + """ + Translates oidc response to SATOSA internal response. + :type response: dict[str, str] + :type issuer: str + :type subject_type: str + :rtype: InternalData + + :param response: Dictioary with attribute name as key. + :param issuer: The oidc op that gave the repsonse. + :param subject_type: public or pairwise according to oidc standard. + :return: A SATOSA internal response. + """ + auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer) + internal_resp = InternalData(auth_info=auth_info) + internal_resp.attributes = self.converter.to_internal("openid", response) + internal_resp.subject_id = response["sub"] + return internal_resp def _check_error_response(self, response, context): """ @@ -86,46 +134,3 @@ def _check_error_response(self, response, context): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) raise SATOSAAuthenticationError(context.state, "Access denied") - - def _authn_response(self, context): - """ - Handles the authentication response from the AS. - - :type context: satosa.context.Context - :rtype: satosa.response.Response - :param context: The context in SATOSA - :return: A SATOSA response. This method is only responsible to call the callback function - which generates the Response object. - """ - - _info = self.client.finalize(context.request) - self._check_error_response(_info, context) - - try: - auth_info = self.auth_info(context.request) - except NotImplementedError: - auth_info = AuthenticationInformation(auth_class_ref=UNSPECIFIED, - timestamp=str(datetime.now()), - issuer=_info["issuer"]) - - attributes = self.converter.to_internal( - self.client.client_type, _info['userinfo'], - ) - - internal_response = InternalData( - auth_info=auth_info, - attributes=attributes, - subject_id=_info['userinfo']['sub'] - ) - return internal_response - - def auth_info(self, request): - """ - Creates the SATOSA authentication information object. - :type request: dict[str, str] - :rtype: AuthenticationInformation - - :param request: The request parameters in the authentication response sent by the AS. - :return: How, who and when the authentication took place. - """ - raise NotImplementedError("Method 'auth_info' must be implemented in the subclass!") From b175d0ee156d2314e68aefcd4fa229973558f8b6 Mon Sep 17 00:00:00 2001 From: roland Date: Wed, 14 Jun 2023 09:10:11 +0200 Subject: [PATCH 376/401] More changes as a result of Ali Haider's testing. --- src/satosa/backends/idpy_oidc.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index 825ba9f72..06eb3c8c4 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -3,6 +3,7 @@ """ import logging from datetime import datetime +from urllib.parse import urlparse from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient from idpyoidc.server.user_authn.authn_context import UNSPECIFIED @@ -12,6 +13,7 @@ from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from ..exception import SATOSAAuthenticationError +from ..exception import SATOSAError from ..response import Redirect logger = logging.getLogger(__name__) @@ -67,7 +69,13 @@ def register_endpoints(self): :rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]] :return: A list that can be used to map the request to SATOSA to this endpoint. """ - return self.client.context.claims.get_usage('redirect_uris') + url_map = [] + redirect_path = self.client.context.claims.get_usage('redirect_uris') + if not redirect_path: + raise SATOSAError("Missing path in redirect uri") + redirect_path = urlparse(redirect_path[0]).path + url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint)) + return url_map def response_endpoint(self, context, *args): """ From a56db954385ca683164f99b946ba25472c7c5a96 Mon Sep 17 00:00:00 2001 From: roland Date: Tue, 20 Jun 2023 13:19:11 +0200 Subject: [PATCH 377/401] Example backend used by Ali Haider. --- .../plugins/backends/idpyoidc_backend.yaml.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 example/plugins/backends/idpyoidc_backend.yaml.example diff --git a/example/plugins/backends/idpyoidc_backend.yaml.example b/example/plugins/backends/idpyoidc_backend.yaml.example new file mode 100644 index 000000000..45d011b21 --- /dev/null +++ b/example/plugins/backends/idpyoidc_backend.yaml.example @@ -0,0 +1,12 @@ +module: satosa.backends.idpy_oidc.IdpyOIDCBackend +name: oidc +config: + client_type: oidc + redirect_uris: [/] + client_id: !ENV SATOSA_OIDC_BACKEND_CLIENTID + client_secret: !ENV SATOSA_OIDC_BACKEND_CLIENTSECRET + response_types_supported: ["code"] + scopes_supported: ["openid", "profile", "email"] + subject_type_supported: ["public"] + provider_info: + issuer: !ENV SATOSA_OIDC_BACKEND_ISSUER \ No newline at end of file From b3860b83a26690cd39285dc3c497a1ad91bf5d38 Mon Sep 17 00:00:00 2001 From: Roland Hedberg Date: Mon, 26 Jun 2023 09:39:39 +0200 Subject: [PATCH 378/401] Added tests --- src/satosa/backends/idpy_oidc.py | 6 +- tests/satosa/backends/test_idpy_oidc.py | 207 ++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 tests/satosa/backends/test_idpy_oidc.py diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index 06eb3c8c4..0f259ea1f 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -1,17 +1,17 @@ """ OIDC/OAuth2 backend module. """ -import logging from datetime import datetime +import logging from urllib.parse import urlparse from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient from idpyoidc.server.user_authn.authn_context import UNSPECIFIED -import satosa.logging_util as lu from satosa.backends.base import BackendModule from satosa.internal import AuthenticationInformation from satosa.internal import InternalData +import satosa.logging_util as lu from ..exception import SATOSAAuthenticationError from ..exception import SATOSAError from ..response import Redirect @@ -74,7 +74,7 @@ def register_endpoints(self): if not redirect_path: raise SATOSAError("Missing path in redirect uri") redirect_path = urlparse(redirect_path[0]).path - url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint)) + url_map.append((f"^{redirect_path.lstrip('/')}$", self.response_endpoint)) return url_map def response_endpoint(self, context, *args): diff --git a/tests/satosa/backends/test_idpy_oidc.py b/tests/satosa/backends/test_idpy_oidc.py new file mode 100644 index 000000000..067118c5d --- /dev/null +++ b/tests/satosa/backends/test_idpy_oidc.py @@ -0,0 +1,207 @@ +import json +import re +import time +from unittest.mock import Mock +from urllib.parse import parse_qsl +from urllib.parse import urlparse + +from cryptojwt.key_jar import build_keyjar +from idpyoidc.client.defaults import DEFAULT_KEY_DEFS +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient +from idpyoidc.message.oidc import AuthorizationResponse +from idpyoidc.message.oidc import IdToken +from oic.oic import AuthorizationRequest +import pytest +import responses + +from satosa.backends.idpy_oidc import IdpyOIDCBackend +from satosa.context import Context +from satosa.internal import InternalData +from satosa.response import Response + +ISSUER = "https://provider.example.com" +CLIENT_ID = "test_client" +CLIENT_BASE_URL = "https://client.test.com" +NONCE = "the nonce" + + +class TestIdpyOIDCBackend(object): + @pytest.fixture + def backend_config(self): + return { + "client": { + "base_url": CLIENT_BASE_URL, + "client_id": CLIENT_ID, + "client_type": "oidc", + "client_secret": "ZJYCqe3GGRvdrudKyZS0XhGv_Z45DuKhCUk0gBR1vZk", + "application_type": "web", + "application_name": "SATOSA Test", + "contacts": ["ops@example.com"], + "response_types_supported": ["code"], + "response_type": "code id_token token", + "scope": "openid foo", + "key_conf": {"key_defs": DEFAULT_KEY_DEFS}, + "jwks_uri": f"{CLIENT_BASE_URL}/jwks.json", + "provider_info": { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authn", + "token_endpoint": f"{ISSUER}/token", + "userinfo_endpoint": f"{ISSUER}/user", + "jwks_uri": f"{ISSUER}/static/jwks" + } + } + } + + @pytest.fixture + def internal_attributes(self): + return { + "attributes": { + "givenname": {"openid": ["given_name"]}, + "mail": {"openid": ["email"]}, + "edupersontargetedid": {"openid": ["sub"]}, + "surname": {"openid": ["family_name"]} + } + } + + @pytest.fixture(autouse=True) + @responses.activate + def create_backend(self, internal_attributes, backend_config): + base_url = backend_config['client']['base_url'] + self.issuer_keys = build_keyjar(DEFAULT_KEY_DEFS) + with responses.RequestsMock() as rsps: + rsps.add( + responses.GET, + backend_config['client']['provider_info']['jwks_uri'], + body=self.issuer_keys.export_jwks_as_json(), + status=200, + content_type="application/json") + + self.oidc_backend = IdpyOIDCBackend(Mock(), internal_attributes, backend_config, + base_url, "oidc") + + @pytest.fixture + def userinfo(self): + return { + "given_name": "Test", + "family_name": "Devsson", + "email": "test_dev@example.com", + "sub": "username" + } + + def test_client(self, backend_config): + assert isinstance(self.oidc_backend.client, StandAloneClient) + # 3 signing keys. One RSA, one EC and one symmetric + assert len(self.oidc_backend.client.context.keyjar.get_signing_key()) == 3 + assert self.oidc_backend.client.context.jwks_uri == backend_config['client']['jwks_uri'] + + def assert_expected_attributes(self, attr_map, user_claims, actual_attributes): + expected_attributes = {} + for out_attr, in_mapping in attr_map["attributes"].items(): + expected_attributes[out_attr] = [user_claims[in_mapping["openid"][0]]] + + assert actual_attributes == expected_attributes + + def setup_token_endpoint(self, userinfo): + _client = self.oidc_backend.client + signing_key = self.issuer_keys.get_signing_key(key_type='RSA')[0] + signing_key.alg = "RS256" + id_token_claims = { + "iss": ISSUER, + "sub": userinfo["sub"], + "aud": CLIENT_ID, + "nonce": NONCE, + "exp": time.time() + 3600, + "iat": time.time() + } + id_token = IdToken(**id_token_claims).to_jwt([signing_key], algorithm=signing_key.alg) + token_response = { + "access_token": "SlAV32hkKG", + "token_type": "Bearer", + "refresh_token": "8xLOxBtZp8", + "expires_in": 3600, + "id_token": id_token + } + responses.add(responses.POST, + _client.context.provider_info['token_endpoint'], + body=json.dumps(token_response), + status=200, + content_type="application/json") + + def setup_userinfo_endpoint(self, userinfo): + responses.add(responses.GET, + self.oidc_backend.client.context.provider_info['userinfo_endpoint'], + body=json.dumps(userinfo), + status=200, + content_type="application/json") + + @pytest.fixture + def incoming_authn_response(self): + _context = self.oidc_backend.client.context + oidc_state = "my state" + _uri = _context.claims.get_usage("redirect_uris")[0] + _request = AuthorizationRequest( + redirect_uri=_uri, + response_type="code", + client_id=_context.get_client_id(), + scope=_context.claims.get_usage("scope"), + nonce=NONCE + ) + _context.cstate.set(oidc_state, {"iss": _context.issuer}) + _context.cstate.bind_key(NONCE, oidc_state) + _context.cstate.update(oidc_state, _request) + + response = AuthorizationResponse( + code="F+R4uWbN46U+Bq9moQPC4lEvRd2De4o=", + state=oidc_state, + iss=_context.issuer, + nonce=NONCE + ) + return response.to_dict() + + def test_register_endpoints(self): + _uri = self.oidc_backend.client.context.claims.get_usage("redirect_uris")[0] + redirect_uri_path = urlparse(_uri).path.lstrip('/') + url_map = self.oidc_backend.register_endpoints() + regex, callback = url_map[0] + assert re.search(regex, redirect_uri_path) + assert callback == self.oidc_backend.response_endpoint + + def test_translate_response_to_internal_response(self, userinfo): + internal_response = self.oidc_backend._translate_response(userinfo, ISSUER) + assert internal_response.subject_id == userinfo["sub"] + self.assert_expected_attributes(self.oidc_backend.internal_attributes, userinfo, + internal_response.attributes) + + @responses.activate + def test_response_endpoint(self, context, userinfo, incoming_authn_response): + self.setup_token_endpoint(userinfo) + self.setup_userinfo_endpoint(userinfo) + + response_context = Context() + response_context.request = incoming_authn_response + response_context.state = context.state + + self.oidc_backend.response_endpoint(response_context) + + args = self.oidc_backend.auth_callback_func.call_args[0] + assert isinstance(args[0], Context) + assert isinstance(args[1], InternalData) + self.assert_expected_attributes(self.oidc_backend.internal_attributes, userinfo, + args[1].attributes) + + def test_start_auth_redirects_to_provider_authorization_endpoint(self, context): + _client = self.oidc_backend.client + auth_response = self.oidc_backend.start_auth(context, None) + assert isinstance(auth_response, Response) + + login_url = auth_response.message + parsed = urlparse(login_url) + assert login_url.startswith(_client.context.provider_info["authorization_endpoint"]) + auth_params = dict(parse_qsl(parsed.query)) + assert auth_params["scope"] == " ".join(_client.context.claims.get_usage("scope")) + assert auth_params["response_type"] == _client.context.claims.get_usage("response_types")[0] + assert auth_params["client_id"] == _client.client_id + assert auth_params["redirect_uri"] == _client.context.claims.get_usage("redirect_uris")[0] + assert "state" in auth_params + assert "nonce" in auth_params + From 34d85971a79880a9a74fe594d1f9fd6588ff796c Mon Sep 17 00:00:00 2001 From: Roland Hedberg Date: Thu, 6 Jul 2023 09:49:58 +0200 Subject: [PATCH 379/401] Changes after comments from Ivan. --- src/satosa/backends/idpy_oidc.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index 0f259ea1f..fbab4c272 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -50,6 +50,11 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na self.client.do_provider_info() self.client.do_client_registration() + _redirect_uris = self.client.context.claims.get_usage('redirect_uris') + if not _redirect_uris: + raise SATOSAError("Missing path in redirect uri") + self.redirect_path = urlparse(_redirect_uris[0]).path + def start_auth(self, context, internal_request): """ See super class method satosa.backends.base#start_auth @@ -70,11 +75,7 @@ def register_endpoints(self): :return: A list that can be used to map the request to SATOSA to this endpoint. """ url_map = [] - redirect_path = self.client.context.claims.get_usage('redirect_uris') - if not redirect_path: - raise SATOSAError("Missing path in redirect uri") - redirect_path = urlparse(redirect_path[0]).path - url_map.append((f"^{redirect_path.lstrip('/')}$", self.response_endpoint)) + url_map.append((f"^{self.redirect_path.lstrip('/')}$", self.response_endpoint)) return url_map def response_endpoint(self, context, *args): @@ -120,7 +121,10 @@ def _translate_response(self, response, issuer): :param subject_type: public or pairwise according to oidc standard. :return: A SATOSA internal response. """ - auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer) + timestamp = response["auth_time"] + auth_class_ref = response.get("amr", response.get("acr", UNSPECIFIED)) + auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) + internal_resp = InternalData(auth_info=auth_info) internal_resp.attributes = self.converter.to_internal("openid", response) internal_resp.subject_id = response["sub"] From a8a446ad12dec0ea96c096ff7a196daa14e42de6 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 10 Jul 2023 21:59:53 +0300 Subject: [PATCH 380/401] Prepare the right datetime format --- src/satosa/backends/idpy_oidc.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index fbab4c272..f3ea43f61 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -1,7 +1,7 @@ """ OIDC/OAuth2 backend module. """ -from datetime import datetime +import datetime import logging from urllib.parse import urlparse @@ -16,6 +16,8 @@ from ..exception import SATOSAError from ..response import Redirect + +UTC = datetime.timezone.utc logger = logging.getLogger(__name__) @@ -121,9 +123,15 @@ def _translate_response(self, response, issuer): :param subject_type: public or pairwise according to oidc standard. :return: A SATOSA internal response. """ - timestamp = response["auth_time"] - auth_class_ref = response.get("amr", response.get("acr", UNSPECIFIED)) - auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) + timestamp_epoch = ( + response.get("auth_time") + or response.get("iat") + or int(datetime.datetime.now(UTC).timestamp()) + ) + timestamp_dt = datetime.datetime.fromtimestamp(timestamp_epoch, UTC) + timestamp_iso = timestamp_dt.isoformat().replace("+00:00", "Z") + auth_class_ref = response.get("acr") or response.get("amr") or UNSPECIFIED + auth_info = AuthenticationInformation(auth_class_ref, timestamp_iso, issuer) internal_resp = InternalData(auth_info=auth_info) internal_resp.attributes = self.converter.to_internal("openid", response) From aeaea946c1679387c8223f2f0d94649433afbc8c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 10 Jul 2023 22:46:11 +0300 Subject: [PATCH 381/401] Fix tests Signed-off-by: Ivan Kanakarakis --- tests/satosa/backends/test_idpy_oidc.py | 56 ++++++++++++++++++------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/tests/satosa/backends/test_idpy_oidc.py b/tests/satosa/backends/test_idpy_oidc.py index 067118c5d..95e8b427c 100644 --- a/tests/satosa/backends/test_idpy_oidc.py +++ b/tests/satosa/backends/test_idpy_oidc.py @@ -1,6 +1,7 @@ import json import re import time +from datetime import datetime from unittest.mock import Mock from urllib.parse import parse_qsl from urllib.parse import urlparse @@ -88,6 +89,29 @@ def userinfo(self): "sub": "username" } + @pytest.fixture + def id_token(self, userinfo): + issuer_keys = build_keyjar(DEFAULT_KEY_DEFS) + signing_key = issuer_keys.get_signing_key(key_type='RSA')[0] + signing_key.alg = "RS256" + auth_time = int(datetime.utcnow().timestamp()) + id_token_claims = { + "auth_time": auth_time, + "iss": ISSUER, + "sub": userinfo["sub"], + "aud": CLIENT_ID, + "nonce": NONCE, + "exp": auth_time + 3600, + "iat": auth_time, + } + id_token = IdToken(**id_token_claims) + return id_token + + @pytest.fixture + def all_user_claims(self, userinfo, id_token): + all_user_claims = {**userinfo, **id_token} + return all_user_claims + def test_client(self, backend_config): assert isinstance(self.oidc_backend.client, StandAloneClient) # 3 signing keys. One RSA, one EC and one symmetric @@ -95,10 +119,10 @@ def test_client(self, backend_config): assert self.oidc_backend.client.context.jwks_uri == backend_config['client']['jwks_uri'] def assert_expected_attributes(self, attr_map, user_claims, actual_attributes): - expected_attributes = {} - for out_attr, in_mapping in attr_map["attributes"].items(): - expected_attributes[out_attr] = [user_claims[in_mapping["openid"][0]]] - + expected_attributes = { + out_attr: [user_claims[in_mapping["openid"][0]]] + for out_attr, in_mapping in attr_map["attributes"].items() + } assert actual_attributes == expected_attributes def setup_token_endpoint(self, userinfo): @@ -166,16 +190,19 @@ def test_register_endpoints(self): assert re.search(regex, redirect_uri_path) assert callback == self.oidc_backend.response_endpoint - def test_translate_response_to_internal_response(self, userinfo): - internal_response = self.oidc_backend._translate_response(userinfo, ISSUER) - assert internal_response.subject_id == userinfo["sub"] - self.assert_expected_attributes(self.oidc_backend.internal_attributes, userinfo, - internal_response.attributes) + def test_translate_response_to_internal_response(self, all_user_claims): + internal_response = self.oidc_backend._translate_response(all_user_claims, ISSUER) + assert internal_response.subject_id == all_user_claims["sub"] + self.assert_expected_attributes( + self.oidc_backend.internal_attributes, + all_user_claims, + internal_response.attributes, + ) @responses.activate - def test_response_endpoint(self, context, userinfo, incoming_authn_response): - self.setup_token_endpoint(userinfo) - self.setup_userinfo_endpoint(userinfo) + def test_response_endpoint(self, context, all_user_claims, incoming_authn_response): + self.setup_token_endpoint(all_user_claims) + self.setup_userinfo_endpoint(all_user_claims) response_context = Context() response_context.request = incoming_authn_response @@ -186,8 +213,9 @@ def test_response_endpoint(self, context, userinfo, incoming_authn_response): args = self.oidc_backend.auth_callback_func.call_args[0] assert isinstance(args[0], Context) assert isinstance(args[1], InternalData) - self.assert_expected_attributes(self.oidc_backend.internal_attributes, userinfo, - args[1].attributes) + self.assert_expected_attributes( + self.oidc_backend.internal_attributes, all_user_claims, args[1].attributes + ) def test_start_auth_redirects_to_provider_authorization_endpoint(self, context): _client = self.oidc_backend.client From 628ee94f507d9923b1ed6b20dd831c84860d753c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 10 Jul 2023 22:46:36 +0300 Subject: [PATCH 382/401] Add extra requirement for the new idpy-oidc based backend Signed-off-by: Ivan Kanakarakis --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 59065f6ac..51bb389ea 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "ldap": ["ldap3"], "pyop_mongo": ["pyop[mongo]"], "pyop_redis": ["pyop[redis]"], + "idpy_oidc_backend": ["idpyoidc >= 2.1.0"], }, zip_safe=False, classifiers=[ From 468cc87e9eca5c4d4c3422df4014119a4ffb5474 Mon Sep 17 00:00:00 2001 From: Rastislav Krutak <492918@mail.muni.cz> Date: Mon, 17 Jul 2023 11:19:08 +0200 Subject: [PATCH 383/401] feat: treat resource param as list --- src/satosa/proxy_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/satosa/proxy_server.py b/src/satosa/proxy_server.py index 7968167ea..e23be1418 100644 --- a/src/satosa/proxy_server.py +++ b/src/satosa/proxy_server.py @@ -20,6 +20,8 @@ def parse_query_string(data): query_param_pairs = _parse_query_string(data) query_param_dict = dict(query_param_pairs) + if "resource" in query_param_dict: + query_param_dict["resource"] = [t[1] for t in query_param_pairs if t[0] == "resource"] return query_param_dict From 6ea06d662f33b739c7239c5c4272f19d8c264e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 31 Jul 2023 08:59:33 +0200 Subject: [PATCH 384/401] feat: allow loading of tuples from YAML configs --- src/satosa/yaml.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/satosa/yaml.py b/src/satosa/yaml.py index 2f8d51f1b..ad74e2e9e 100644 --- a/src/satosa/yaml.py +++ b/src/satosa/yaml.py @@ -43,9 +43,21 @@ def _constructor_envfile_variables(loader, node): return new_value +def _constructor_tuple_variables(loader, node): + """ + Extracts the tuple variable from the node's value. + :param yaml.Loader loader: the yaml loader + :param node: the current node in the yaml + :return: value of the tuple + """ + return tuple(loader.construct_sequence(node)) + + TAG_ENV = "!ENV" TAG_ENVFILE = "!ENVFILE" +TAG_TUPLE = u'tag:yaml.org,2002:python/tuple' _safe_loader.add_constructor(TAG_ENV, _constructor_env_variables) _safe_loader.add_constructor(TAG_ENVFILE, _constructor_envfile_variables) +_safe_loader.add_constructor(TAG_TUPLE, _constructor_tuple_variables) From 5debe486fe6d832fdbe880aba378c8cc0afcb133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Wed, 23 Aug 2023 11:25:21 +0200 Subject: [PATCH 385/401] chore: quotes Co-authored-by: Ivan Kanakarakis --- src/satosa/yaml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/yaml.py b/src/satosa/yaml.py index ad74e2e9e..d45b12116 100644 --- a/src/satosa/yaml.py +++ b/src/satosa/yaml.py @@ -55,7 +55,7 @@ def _constructor_tuple_variables(loader, node): TAG_ENV = "!ENV" TAG_ENVFILE = "!ENVFILE" -TAG_TUPLE = u'tag:yaml.org,2002:python/tuple' +TAG_TUPLE = "tag:yaml.org,2002:python/tuple" _safe_loader.add_constructor(TAG_ENV, _constructor_env_variables) From 80210b364cf8f3a127a8d8eb7046c360704ad8d9 Mon Sep 17 00:00:00 2001 From: war Date: Wed, 8 Nov 2023 14:05:10 +0100 Subject: [PATCH 386/401] fix: prevent endless loop from accessing self --- src/satosa/internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index 24de31890..a96b19b1f 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -35,7 +35,7 @@ def __setattr__(self, key, value): def __getattr__(self, key): if key == "data": - return self.data + return super().data try: value = self.__getitem__(key) From 48bd453426e946f3201e3c7270a5e089e8f8bfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 28 Nov 2022 23:43:29 +0100 Subject: [PATCH 387/401] refactor: base AppleBackend on OpenIDConnectBackend common parts are not duplicated --- src/satosa/backends/apple.py | 207 +---------------------------------- 1 file changed, 3 insertions(+), 204 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index 37f756a68..870f5d157 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -2,135 +2,21 @@ Apple backend module. """ import logging -from datetime import datetime -from urllib.parse import urlparse - +from .openid_connect import OpenIDConnectBackend, STATE_KEY from oic.oauth2.message import Message -from oic import oic -from oic import rndstr from oic.oic.message import AuthorizationResponse -from oic.oic.message import ProviderConfigurationResponse -from oic.oic.message import RegistrationRequest -from oic.utils.authn.authn_context import UNSPECIFIED -from oic.utils.authn.client import CLIENT_AUTHN_METHOD - import satosa.logging_util as lu -from satosa.internal import AuthenticationInformation -from satosa.internal import InternalData -from .base import BackendModule -from .oauth import get_metadata_desc_for_oauth_backend -from ..exception import SATOSAAuthenticationError, SATOSAError -from ..response import Redirect - +from ..exception import SATOSAAuthenticationError import json import requests logger = logging.getLogger(__name__) -NONCE_KEY = "oidc_nonce" -STATE_KEY = "oidc_state" - # https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple -class AppleBackend(BackendModule): +class AppleBackend(OpenIDConnectBackend): """Sign in with Apple backend""" - def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): - """ - Sign in with Apple backend module. - :param auth_callback_func: Callback should be called by the module after the authorization - in the backend is done. - :param internal_attributes: Mapping dictionary between SATOSA internal attribute names and - the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and - RP's expects namevice. - :param config: Configuration parameters for the module. - :param base_url: base url of the service - :param name: name of the plugin - - :type auth_callback_func: - (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response - :type internal_attributes: dict[string, dict[str, str | list[str]]] - :type config: dict[str, dict[str, str] | list[str]] - :type base_url: str - :type name: str - """ - super().__init__(auth_callback_func, internal_attributes, base_url, name) - self.auth_callback_func = auth_callback_func - self.config = config - self.client = _create_client( - config["provider_metadata"], - config["client"]["client_metadata"], - config["client"].get("verify_ssl", True), - ) - if "scope" not in config["client"]["auth_req_params"]: - config["auth_req_params"]["scope"] = "openid" - if "response_type" not in config["client"]["auth_req_params"]: - config["auth_req_params"]["response_type"] = "code" - - def start_auth(self, context, request_info): - """ - See super class method satosa.backends.base#start_auth - :type context: satosa.context.Context - :type request_info: satosa.internal.InternalData - """ - oidc_nonce = rndstr() - oidc_state = rndstr() - state_data = {NONCE_KEY: oidc_nonce, STATE_KEY: oidc_state} - context.state[self.name] = state_data - - args = { - "scope": self.config["client"]["auth_req_params"]["scope"], - "response_type": self.config["client"]["auth_req_params"]["response_type"], - "client_id": self.client.client_id, - "redirect_uri": self.client.registration_response["redirect_uris"][0], - "state": oidc_state, - "nonce": oidc_nonce, - } - args.update(self.config["client"]["auth_req_params"]) - auth_req = self.client.construct_AuthorizationRequest(request_args=args) - login_url = auth_req.request(self.client.authorization_endpoint) - return Redirect(login_url) - - def register_endpoints(self): - """ - Creates a list of all the endpoints this backend module needs to listen to. In this case - it's the authentication response from the underlying OP that is redirected from the OP to - the proxy. - :rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]] - :return: A list that can be used to map the request to SATOSA to this endpoint. - """ - url_map = [] - redirect_path = urlparse( - self.config["client"]["client_metadata"]["redirect_uris"][0] - ).path - if not redirect_path: - raise SATOSAError("Missing path in redirect uri") - - url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint)) - return url_map - - def _verify_nonce(self, nonce, context): - """ - Verify the received OIDC 'nonce' from the ID Token. - :param nonce: OIDC nonce - :type nonce: str - :param context: current request context - :type context: satosa.context.Context - :raise SATOSAAuthenticationError: if the nonce is incorrect - """ - backend_state = context.state[self.name] - if nonce != backend_state[NONCE_KEY]: - msg = "Missing or invalid nonce in authn response for state: {}".format( - backend_state - ) - logline = lu.LOG_FMT.format( - id=lu.get_session_id(context.state), message=msg - ) - logger.debug(logline) - raise SATOSAAuthenticationError( - context.state, "Missing or invalid nonce in authn response" - ) - def _get_tokens(self, authn_response, context): """ :param authn_response: authentication response from OP @@ -169,25 +55,6 @@ def _get_tokens(self, authn_response, context): return authn_response.get("access_token"), authn_response.get("id_token") - def _check_error_response(self, response, context): - """ - Check if the response is an OAuth error response. - :param response: the OIDC response - :type response: oic.oic.message - :raise SATOSAAuthenticationError: if the response is an OAuth error response - """ - if "error" in response: - msg = "{name} error: {error} {description}".format( - name=type(response).__name__, - error=response["error"], - description=response.get("error_description", ""), - ) - logline = lu.LOG_FMT.format( - id=lu.get_session_id(context.state), message=msg - ) - logger.debug(logline) - raise SATOSAAuthenticationError(context.state, "Access denied") - def response_endpoint(self, context, *args): """ Handles the authentication response from the OP. @@ -249,71 +116,3 @@ def response_endpoint(self, context, *args): all_user_claims, self.client.authorization_endpoint ) return self.auth_callback_func(context, internal_resp) - - def _translate_response(self, response, issuer): - """ - Translates oidc response to SATOSA internal response. - :type response: dict[str, str] - :type issuer: str - :type subject_type: str - :rtype: InternalData - - :param response: Dictioary with attribute name as key. - :param issuer: The oidc op that gave the repsonse. - :param subject_type: public or pairwise according to oidc standard. - :return: A SATOSA internal response. - """ - auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer) - internal_resp = InternalData(auth_info=auth_info) - internal_resp.attributes = self.converter.to_internal("openid", response) - internal_resp.subject_id = response["sub"] - return internal_resp - - def get_metadata_desc(self): - """ - See satosa.backends.oauth.get_metadata_desc - :rtype: satosa.metadata_creation.description.MetadataDescription - """ - return get_metadata_desc_for_oauth_backend( - self.config["provider_metadata"]["issuer"], self.config - ) - - -def _create_client(provider_metadata, client_metadata, verify_ssl=True): - """ - Create a pyoidc client instance. - :param provider_metadata: provider configuration information - :type provider_metadata: Mapping[str, Union[str, Sequence[str]]] - :param client_metadata: client metadata - :type client_metadata: Mapping[str, Union[str, Sequence[str]]] - :return: client instance to use for communicating with the configured provider - :rtype: oic.oic.Client - """ - client = oic.Client(client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=verify_ssl) - - # Provider configuration information - if "authorization_endpoint" in provider_metadata: - # no dynamic discovery necessary - client.handle_provider_config( - ProviderConfigurationResponse(**provider_metadata), - provider_metadata["issuer"], - ) - else: - # do dynamic discovery - client.provider_config(provider_metadata["issuer"]) - - # Client information - if "client_id" in client_metadata: - # static client info provided - client.store_registration_info(RegistrationRequest(**client_metadata)) - else: - # do dynamic registration - client.register( - client.provider_info["registration_endpoint"], **client_metadata - ) - - client.subject_type = ( - client.registration_response.get("subject_type") - or client.provider_info["subject_types_supported"][0] - ) - return client From f4464c4019c9e0f29f59957e0c18c933605448ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 29 Nov 2022 12:06:06 +0100 Subject: [PATCH 388/401] fix: correct user info loading in apple backend incorrect function was used for parsing json (load is for files, loads for strings) and the error was masked because of too broad except clause --- src/satosa/backends/apple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index 870f5d157..0415f9b14 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -76,8 +76,8 @@ def response_endpoint(self, context, *args): # - https://developer.apple.com/documentation/sign_in_with_apple/namei try: userdata = context.request.get("user", "{}") - userinfo = json.load(userdata) - except Exception: + userinfo = json.loads(userdata) + except json.JSONDecodeError: userinfo = {} authn_resp = self.client.parse_response( From 2e3a78238d7da18b92a9ac18d4bfe76a8fe7455f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 29 Nov 2022 12:49:37 +0100 Subject: [PATCH 389/401] fix: prevent exception in attribute mapping when internal_attributes contain nested attribute but the actual value is not nested --- src/satosa/attribute_mapping.py | 8 +++-- tests/satosa/test_attribute_mapping.py | 50 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/satosa/attribute_mapping.py b/src/satosa/attribute_mapping.py index e8729561c..d5745864c 100644 --- a/src/satosa/attribute_mapping.py +++ b/src/satosa/attribute_mapping.py @@ -1,6 +1,7 @@ import logging from collections import defaultdict from itertools import chain +from typing import Mapping from mako.template import Template @@ -97,8 +98,9 @@ def to_internal(self, attribute_profile, external_dict): continue external_attribute_name = mapping[attribute_profile] - attribute_values = self._collate_attribute_values_by_priority_order(external_attribute_name, - external_dict) + attribute_values = self._collate_attribute_values_by_priority_order( + external_attribute_name, external_dict + ) if attribute_values: # Only insert key if it has some values logline = "backend attribute {external} mapped to {internal} ({value})".format( external=external_attribute_name, internal=internal_attribute_name, value=attribute_values @@ -157,6 +159,8 @@ def _get_nested_attribute_value(self, nested_key, data): d = data for key in keys: + if not isinstance(d, Mapping): + return None d = d.get(key) if d is None: return None diff --git a/tests/satosa/test_attribute_mapping.py b/tests/satosa/test_attribute_mapping.py index c109ab717..93a3dff78 100644 --- a/tests/satosa/test_attribute_mapping.py +++ b/tests/satosa/test_attribute_mapping.py @@ -5,6 +5,56 @@ from satosa.attribute_mapping import AttributeMapper +class TestAttributeMapperNestedDataDifferentAttrProfile: + def test_nested_mapping_nested_data_to_internal(self): + mapping = { + "attributes": { + "name": { + "openid": ["name"] + }, + "givenname": { + "openid": ["given_name", "name.firstName"] + }, + }, + } + + data = { + "name": { + "firstName": "value-first", + "lastName": "value-last", + }, + "email": "someuser@apple.com", + } + + converter = AttributeMapper(mapping) + internal_repr = converter.to_internal("openid", data) + assert internal_repr["name"] == [data["name"]] + assert internal_repr["givenname"] == [data["name"]["firstName"]] + + + def test_nested_mapping_simple_data_to_internal(self): + mapping = { + "attributes": { + "name": { + "openid": ["name"] + }, + "givenname": { + "openid": ["given_name", "name.firstName"] + }, + }, + } + + data = { + "name": "value-first", + "email": "someuser@google.com", + } + + converter = AttributeMapper(mapping) + internal_repr = converter.to_internal("openid", data) + assert internal_repr["name"] == [data["name"]] + assert internal_repr.get("givenname") is None + + class TestAttributeMapper: def test_nested_attribute_to_internal(self): mapping = { From 45c4aa15a5f8b47f46e4be3d5829b7d8a905993c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Mon, 13 Nov 2023 13:38:16 +0100 Subject: [PATCH 390/401] fix: convert strings to booleans in Apple backend --- src/satosa/backends/apple.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index 0415f9b14..3a7c4290d 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -13,6 +13,7 @@ logger = logging.getLogger(__name__) + # https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple class AppleBackend(OpenIDConnectBackend): """Sign in with Apple backend""" @@ -109,6 +110,12 @@ def response_endpoint(self, context, *args): raise SATOSAAuthenticationError(context.state, "No user info available.") all_user_claims = dict(list(userinfo.items()) + list(id_token_claims.items())) + + # convert "string or Boolean" claims to actual booleans + for bool_claim_name in ["email_verified", "is_private_email"]: + if type(userinfo.get(bool_claim_name)) == str: + userinfo[bool_claim_name] = userinfo[bool_claim_name] == "true" + msg = "UserInfo: {}".format(all_user_claims) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) From 0d6615aa3357b1752855c2f3667ab498b3fe154e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 14 Nov 2023 00:23:59 +0100 Subject: [PATCH 391/401] refactor: easier to read boolean expression Co-authored-by: Ivan Kanakarakis --- src/satosa/backends/apple.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index 3a7c4290d..b17308c48 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -113,8 +113,11 @@ def response_endpoint(self, context, *args): # convert "string or Boolean" claims to actual booleans for bool_claim_name in ["email_verified", "is_private_email"]: - if type(userinfo.get(bool_claim_name)) == str: - userinfo[bool_claim_name] = userinfo[bool_claim_name] == "true" + userinfo[bool_claim_name] = ( + True + if userinfo[bool_claim_name] == "true" + else False + ) msg = "UserInfo: {}".format(all_user_claims) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) From ef4c10d0edf5f3188ae887044bd96eb81b67e73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Tue, 14 Nov 2023 00:48:36 +0100 Subject: [PATCH 392/401] fix: only modify existing string booleans in Apple backend --- src/satosa/backends/apple.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index b17308c48..f7c1189ea 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -113,11 +113,10 @@ def response_endpoint(self, context, *args): # convert "string or Boolean" claims to actual booleans for bool_claim_name in ["email_verified", "is_private_email"]: - userinfo[bool_claim_name] = ( - True - if userinfo[bool_claim_name] == "true" - else False - ) + if type(all_user_claims.get(bool_claim_name)) == str: + all_user_claims[bool_claim_name] = ( + True if all_user_claims[bool_claim_name] == "true" else False + ) msg = "UserInfo: {}".format(all_user_claims) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) From 355eb05f0d8754978dd121abcdf9247c861b5c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?= Date: Wed, 10 Jan 2024 11:32:17 +0100 Subject: [PATCH 393/401] fix: correct typo in saml2 exception is thrown during error handling --- src/satosa/backends/saml2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index ec99cad06..8be4572d4 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -435,7 +435,7 @@ def authn_response(self, context, binding): except Exception as e: msg = { "message": "Authentication failed", - "error": f"Failed to parse Authn response: {err}", + "error": f"Failed to parse Authn response: {e}", } logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline, exc_info=True) From 1b7236822017ab5cc699e845e633e69c7fbdfe00 Mon Sep 17 00:00:00 2001 From: Jonathan Perret Date: Thu, 25 Apr 2024 14:00:42 +0200 Subject: [PATCH 394/401] BaseProcessor: add missing 'self' --- src/satosa/micro_services/processors/base_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/processors/base_processor.py b/src/satosa/micro_services/processors/base_processor.py index ad5eb10b5..b29b7f294 100644 --- a/src/satosa/micro_services/processors/base_processor.py +++ b/src/satosa/micro_services/processors/base_processor.py @@ -2,5 +2,5 @@ class BaseProcessor(object): def __init__(self): pass - def process(internal_data, attribute, **kwargs): + def process(self, internal_data, attribute, **kwargs): pass From af6ff771ad2a1a16e00d355ad5b7249cc64319fb Mon Sep 17 00:00:00 2001 From: Dave Lafferty Date: Thu, 23 May 2024 09:52:32 -0400 Subject: [PATCH 395/401] ACR documentation changes. --- doc/README.md | 4 ++-- example/plugins/backends/saml2_backend.yaml.example | 4 ++-- example/plugins/frontends/saml2_frontend.yaml.example | 4 ++-- .../plugins/frontends/saml2_virtualcofrontend.yaml.example | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/README.md b/doc/README.md index fd266723b..8b6416eda 100644 --- a/doc/README.md +++ b/doc/README.md @@ -241,8 +241,8 @@ provider will be preserved, and when using a OAuth or OpenID Connect backend, th config: [...] acr_mapping: - "": default-LoA - "https://accounts.google.com": LoA1 + "": "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified" + "https://accounts.google.com": "http://eidas.europa.eu/LoA/low" ``` ### SAML2 Frontend diff --git a/example/plugins/backends/saml2_backend.yaml.example b/example/plugins/backends/saml2_backend.yaml.example index 3d3f25c0d..76f9406ee 100644 --- a/example/plugins/backends/saml2_backend.yaml.example +++ b/example/plugins/backends/saml2_backend.yaml.example @@ -4,8 +4,8 @@ config: idp_blacklist_file: /path/to/blacklist.json acr_mapping: - "": default-LoA - "https://accounts.google.com": LoA1 + "": "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified" + "https://accounts.google.com": "http://eidas.europa.eu/LoA/low" # disco_srv must be defined if there is more than one IdP in the metadata specified above disco_srv: http://disco.example.com diff --git a/example/plugins/frontends/saml2_frontend.yaml.example b/example/plugins/frontends/saml2_frontend.yaml.example index a527ab652..342ae03f5 100644 --- a/example/plugins/frontends/saml2_frontend.yaml.example +++ b/example/plugins/frontends/saml2_frontend.yaml.example @@ -2,8 +2,8 @@ module: satosa.frontends.saml2.SAMLFrontend name: Saml2IDP config: #acr_mapping: - # "": default-LoA - # "https://accounts.google.com": LoA1 + # "": "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified" + # "https://accounts.google.com": "http://eidas.europa.eu/LoA/low" endpoints: single_sign_on_service: diff --git a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example index a1ed8ad8f..f5a87e9f2 100644 --- a/example/plugins/frontends/saml2_virtualcofrontend.yaml.example +++ b/example/plugins/frontends/saml2_virtualcofrontend.yaml.example @@ -91,8 +91,8 @@ config: lifetime: {minutes: 15} name_form: urn:oasis:names:tc:SAML:2.0:attrname-format:uri acr_mapping: - "": default-LoA - "https://accounts.google.com": LoA1 + "": "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified" + "https://accounts.google.com": "http://eidas.europa.eu/LoA/low" endpoints: single_sign_on_service: From f6155f4825a40397417839dba5ff2e70de17b69c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gyula=20Szab=C3=B3?= Date: Tue, 4 Jun 2024 09:40:55 +0200 Subject: [PATCH 396/401] typo in README.md emailAdress -> emailAddress --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index fd266723b..15ce71115 100644 --- a/doc/README.md +++ b/doc/README.md @@ -129,7 +129,7 @@ attribute to use, e.g. `address.formatted` will access the attribute value attributes: mail: openid: [email] - saml: [mail, emailAdress, email] + saml: [mail, emailAddress, email] address: openid: [address.formatted] saml: [postaladdress] From 14c64d570076316ac396a935f29120f705e74cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gyula=20Szab=C3=B3?= Date: Wed, 5 Jun 2024 08:21:11 +0200 Subject: [PATCH 397/401] Update one-to-many.md --- doc/one-to-many.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/one-to-many.md b/doc/one-to-many.md index c9b08851f..c370db9d8 100644 --- a/doc/one-to-many.md +++ b/doc/one-to-many.md @@ -58,7 +58,7 @@ be configured with a SAML2 frontend and an SAML2 backend. mv internal_attributes.yaml.example internal_attributes.yaml ``` - 1. Map the necessary attributes, see the [Attribute mapping configuration](README.md#attr_map) + 1. Map the necessary attributes, see the [Attribute mapping configuration](README.md#attribute-mapping-configuration-internal_attributesyaml) section of the proxy configuration instructions for more information. From 75c325b3d0c4a73e18994189cacda364804a709b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 6 Nov 2024 14:06:45 +0200 Subject: [PATCH 398/401] Fix typo in error handler for BadRequest Signed-off-by: Ivan Kanakarakis --- src/satosa/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/base.py b/src/satosa/base.py index 1e17c8cbe..40af19979 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -280,7 +280,7 @@ def run(self, context): if generic_error_url: redirect_url = f"{generic_error_url}?errorid={error_id}" return Redirect(generic_error_url) - return BadRequest(error) + return BadRequest(e.error) except SATOSAMissingStateError as e: error_id = uuid.uuid4().urn msg = { From 2644c73cc41774f6dbae16055c2ab0aaa8ff5e66 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 8 Jan 2025 13:50:41 +0200 Subject: [PATCH 399/401] Release v8.5.0 - openid connect backend: Add OAuth2/OIDC backend based on idpy-oidc (new extra requirement `idpy_oidc_backend` to pull the library dependecy) - apple backend: Rework the Apple backend to be based on the generic OpenIDConnectBackend and fix the userinfo loading - Restructure fatal error messages to redirect to generic error page when an errors occur - Allow multiple values for the "resource" query param - Fix checks for missing state from cookie and missing relay state - Allow loading of tuples from YAML configs - docs: minor fixes Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 11 +++++++++++ setup.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 35f7a82c6..1a8457bbb 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.4.0 +current_version = 8.5.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index ee782f08f..380f8bda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 8.5.0 (2025-01-08) + +- openid connect backend: Add OAuth2/OIDC backend based on idpy-oidc (new extra requirement `idpy_oidc_backend` to pull the library dependecy) +- apple backend: Rework the Apple backend to be based on the generic OpenIDConnectBackend and fix the userinfo loading +- Restructure fatal error messages to redirect to generic error page when an errors occur +- Allow multiple values for the "resource" query param +- Fix checks for missing state from cookie and missing relay state +- Allow loading of tuples from YAML configs +- docs: minor fixes + + ## 8.4.0 (2023-06-11) - Make cookie parameters configurable diff --git a/setup.py b/setup.py index 51bb389ea..5e802545c 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.4.0', + version='8.5.0', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se', From 382094bb6b8869fa5631dd0b13d907a6df018da6 Mon Sep 17 00:00:00 2001 From: Vasco Fernandes Date: Tue, 14 Jan 2025 01:15:41 +0000 Subject: [PATCH 400/401] Update saml2.py avoid repeated work --- src/satosa/frontends/saml2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index cecd533db..22f43376d 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -235,7 +235,7 @@ def _handle_authn_request(self, context, binding_in, idp): return ServiceError("Incorrect request from requester: %s" % e) requester = resp_args["sp_entity_id"] - context.state[self.name] = self._create_state_data(context, idp.response_args(authn_req), + context.state[self.name] = self._create_state_data(context, resp_args, context.request.get("RelayState")) subject = authn_req.subject From 2cdf670ce12f3ae44e9735d1eff7cf82a10ddafe Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 10 Feb 2025 17:55:07 +0200 Subject: [PATCH 401/401] Release version 8.5.1 ## 8.5.1 (2025-02-10) - ldap_attribute_store plugin: Add configuration option `use_all_results` to specify whether all LDAP results should be processed. - ldap_attribute_store plugin: Add configuration option `provider_attribute` to define the extracted attribute (ie, domain) that will be used to select the LDAP configuration. - ldap_attribute_store plugin: Add configuration option search_filter to define complex LDAP queries, when the default search based on an identifier is not good enough. - ldap_attribute_store plugin: Add configuration option pool_lifetime. The LDAP Server may abandon connections after some time without notifying the client. The new option allows to set the maximum pool lifetime, so that connections close on the client side. Signed-off-by: Ivan Kanakarakis --- .bumpversion.cfg | 2 +- CHANGELOG.md | 16 ++++++++++++++++ setup.py | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1a8457bbb..f9133d54b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 8.5.0 +current_version = 8.5.1 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 380f8bda0..8287887df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 8.5.1 (2025-02-10) + +- ldap_attribute_store plugin: Add configuration option `use_all_results` to + specify whether all LDAP results should be processed. +- ldap_attribute_store plugin: Add configuration option `provider_attribute` to + define the extracted attribute (ie, domain) that will be used to select the LDAP + configuration. +- ldap_attribute_store plugin: Add configuration option search_filter to define + complex LDAP queries, when the default search based on an identifier is not + good enough. +- ldap_attribute_store plugin: Add configuration option pool_lifetime. The LDAP + Server may abandon connections after some time without notifying the client. + The new option allows to set the maximum pool lifetime, so that connections + close on the client side. + + ## 8.5.0 (2025-01-08) - openid connect backend: Add OAuth2/OIDC backend based on idpy-oidc (new extra requirement `idpy_oidc_backend` to pull the library dependecy) diff --git a/setup.py b/setup.py index 5e802545c..70d1e51ab 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='SATOSA', - version='8.5.0', + version='8.5.1', description='Protocol proxy (SAML/OIDC).', author='DIRG', author_email='satosa-dev@lists.sunet.se',