def test_sp(): cnf = SPConfig() cnf.load_file("sp_1_conf") assert cnf.single_logout_services("urn:mace:example.com:saml:roland:idp", BINDING_HTTP_POST) == ["http://localhost:8088/slo"] assert cnf.endpoint("assertion_consumer_service") == \ ["http://lingon.catalogix.se:8087/"] assert len(cnf.idps()) == 1
def test_wayf(): c = SPConfig().load_file("server_conf") c.context = "sp" idps = c.idps() assert idps == {'urn:mace:example.com:saml:roland:idp': 'Example Co.'} idps = c.idps(["se","en"]) assert idps == {'urn:mace:example.com:saml:roland:idp': 'Exempel AB'} c.setup_logger() assert root_logger.level != logging.NOTSET assert root_logger.level == logging.INFO assert len(root_logger.handlers) == 1 assert isinstance(root_logger.handlers[0], logging.handlers.RotatingFileHandler) handler = root_logger.handlers[0] assert handler.backupCount == 5 assert handler.maxBytes == 100000 assert handler.mode == "a" assert root_logger.name == "saml2" assert root_logger.level == 20
class Saml(object): """ SAML Wrapper around pysaml2. Implements SAML2 Service Provider functionality for Flask. """ def __init__(self, config, attribute_map=None): """Initialize SAML Service Provider. Args: config (dict): Service Provider config info in dict form attribute_map (dict): Mapping of attribute keys to user data """ self._config = SPConfig() self._config.load(config) if config['metadata'].get('config'): # Hacked in a way to get the IdP metadata from a python dict # rather than having to resort to loading XML from file or http. idp_config = IdPConfig() idp_config.load(config['metadata']['config'][0]) idp_entityid = config['metadata']['config'][0]['entityid'] idp_metadata_str = str(entity_descriptor(idp_config, 24)) LOGGER.debug('IdP XML Metadata for %s: %s' % ( idp_entityid, idp_metadata_str)) self._config.metadata.import_metadata( idp_metadata_str, idp_entityid) self.attribute_map = {} if attribute_map is not None: self.attribute_map = attribute_map def authenticate(self, next_url='/', binding=BINDING_HTTP_REDIRECT): """Start SAML Authentication login process. Args: next_url (string): HTTP URL to return user to when authentication is complete. binding (binding): Saml2 binding method to use for request, default BINDING_HTTP_REDIRECT (don't change til HTTP_POST support is complete in pysaml2. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: when unable to locate valid IdP. BadRequest: when invalid result returned from SAML client. """ # find configured for IdP for requested binding method idp_entityid = '' idps = self._config.idps().keys() for idp in idps: if self._config.single_sign_on_services(idp, binding) != []: idp_entityid = idp break if idp_entityid == '': raise AuthException('Unable to locate valid IdP for this request') # fail if signing requested but no private key configured if self._config.authn_requests_signed == 'true': if not self._config.key_file \ or not os.path.exists(self._config.key_file): raise AuthException( 'Signature requested for this Saml authentication request,' ' but no private key file configured') LOGGER.debug('Connecting to Identity Provider %s' % idp_entityid) # retrieve cache outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') LOGGER.debug('Outstanding queries cache %s' % ( outstanding_queries_cache)) # make pysaml2 call to authenticate client = Saml2Client(self._config, logger=LOGGER) (session_id, result) = client.authenticate( entityid=idp_entityid, relay_state=next_url, binding=binding) # The psaml2 source for this method indicates that BINDING_HTTP_POST # should not be used right now to authenticate. Regardless, we'll # check for it and act accordingly. if binding == BINDING_HTTP_REDIRECT: LOGGER.debug('Redirect to Identity Provider %s ( %s )' % ( idp_entityid, result)) response = make_response('', 302, dict([result])) elif binding == BINDING_HTTP_POST: LOGGER.warn('POST binding used to authenticate is not currently' ' supported by pysaml2 release version. Fix in place in repo.') LOGGER.debug('Post to Identity Provider %s ( %s )' % ( idp_entityid, result)) response = make_response('\n'.join(result), 200) else: raise BadRequest('Invalid result returned from SAML client') LOGGER.debug( 'Saving session_id ( %s ) in outstanding queries' % session_id) # cache the outstanding query outstanding_queries_cache.update({session_id: next_url}) outstanding_queries_cache.sync() LOGGER.debug('Outstanding queries cache %s' % ( session['_saml_outstanding_queries'])) return response def handle_assertion(self, request): """Handle SAML Authentication login assertion (POST). Args: request (Request): Flask request object for this HTTP transaction. Returns: User Id (string), User attributes (dict), Redirect Flask response object to return user to now that authentication is complete. Raises: BadRequest: when error with SAML response from Identity Provider. AuthException: when unable to locate uid attribute in response. """ if not request.form.get('SAMLResponse'): raise BadRequest('SAMLResponse missing from POST') # retrieve cache outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') identity_cache = IdentityCache(session, '_saml_identity') LOGGER.debug('Outstanding queries cache %s' % ( outstanding_queries_cache)) LOGGER.debug('Identity cache %s' % identity_cache) # use pysaml2 to process the SAML authentication response client = Saml2Client(self._config, identity_cache=identity_cache, logger=LOGGER) saml_response = client.response( dict(SAMLResponse=request.form['SAMLResponse']), outstanding_queries_cache) if saml_response is None: raise BadRequest('SAML response is invalid') # make sure outstanding query cache is cleared for this session_id session_id = saml_response.session_id() if session_id in outstanding_queries_cache.keys(): del outstanding_queries_cache[session_id] outstanding_queries_cache.sync() # retrieve session_info saml_session_info = saml_response.session_info() LOGGER.debug('SAML Session Info ( %s )' % saml_session_info) # retrieve user data via API try: if self.attribute_map.get('uid', 'name_id') == 'name_id': user_id = saml_session_info.get('name_id') else: user_id = saml_session_info['ava'] \ .get(self.attribute_map.get('uid'))[0] except: raise AuthException('Unable to find "%s" attribute in response' % ( self.attribute_map.get('uid', 'name_id'))) # Future: map attributes to user info user_attributes = dict() # set subject Id in cache to retrieved name_id session['_saml_subject_id'] = saml_session_info.get('name_id') LOGGER.debug('Outstanding queries cache %s' % ( session['_saml_outstanding_queries'])) LOGGER.debug('Identity cache %s' % session['_saml_identity']) LOGGER.debug('Subject Id %s' % session['_saml_subject_id']) relay_state = request.form.get('RelayState', '/') LOGGER.debug('Returning redirect to %s' % relay_state) return user_id, user_attributes, redirect(relay_state) def logout(self, next_url='/'): """Start SAML Authentication logout process. Args: next_url (string): HTTP URL to return user to when logout is complete. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: when unable to resolve Identity Provider single logout end-point. """ # retrieve cache state_cache = AuthDictCache(session, '_saml_state') identity_cache = IdentityCache(session, '_saml_identity') subject_id = session.get('_saml_subject_id') # don't logout if not logged in if subject_id is None: raise AuthException('Unable to retrieve subject id for logout') # fail if signing requested but no private key configured if self._config.logout_requests_signed == 'true': if not self._config.key_file \ or not os.path.exists(self._config.key_file): raise AuthException( 'Signature requested for this Saml logout request,' ' but no private key file configured') LOGGER.debug('State cache %s' % state_cache) LOGGER.debug('Identity cache %s' % identity_cache) LOGGER.debug('Subject Id %s' % subject_id) # use pysaml2 to initiate the SAML logout request client = Saml2Client(self._config, state_cache=state_cache, identity_cache=identity_cache, logger=LOGGER) saml_response = client.global_logout(subject_id, return_to=next_url) # sync the state to cache state_cache.sync() LOGGER.debug('State cache %s' % session['_saml_state']) LOGGER.debug('Identity cache %s' % session['_saml_identity']) if saml_response[1] == "": # used SOAP BINDING successfully return redirect(next_url) LOGGER.debug('Returning Response from SAML for continuation of the' ' logout process') return make_response('\n'.join(saml_response[3]), saml_response[1], saml_response[2]) # body, status, headers def _handle_logout_request(self, client, request, subject_id, binding): """Handle SAML Authentication logout request (GET). Args: client (Saml2Client): instance of SAML client class. request (Request): Flask request object for this HTTP transaction. subject_id (string): Id of the subject we are processing the logout for. binding (string): the SAML binding method being used for this request. Returns: Flask Response object to return to user containing HTTP_REDIRECT SAML message. Raises: BadRequest: when SAML request data is missing. AuthException: when SAML request indicates logout failed. """ LOGGER.debug('Received a logout request from Identity Provider') # pysaml2 logout_request currently only returns for # BINDING_HTTP_REDIRECT. We will have it fail for anything # other than the header 'Location' try: headers, _success = client.logout_request( request.values, subject_id, binding=binding) except TypeError: raise BadRequest('SAML request is invalid') try: assert headers is not None assert headers[0][0] == 'Location' return redirect(headers[0][1]) except: raise AuthException('An error occurred during logout') def _handle_logout_response(self, client, request, binding, next_url): """Handle SAML Authentication logout response (GET or POST). Args: client (Saml2Client): instance of SAML client class. request (Request): Flask request object for this HTTP transaction. binding (string): the SAML binding method being used for this request. next_url (string): URL to get redirected to if all is successful. Returns: Flask Response object to return to user containing HTTP_REDIRECT SAML message. Raises: BadRequest: when SAML response data is missing. AuthException: when SAML response indicates logout failed. """ LOGGER.debug('Received a logout response from Identity Provider') try: saml_response = client.logout_response( request.values['SAMLResponse'], binding=binding) except TypeError: raise BadRequest('SAML response is invalid') LOGGER.debug(saml_response) if saml_response: if saml_response[1] == '': # used SOAP BINDING successfully response = redirect(next_url) else: # body, status, headers response = make_response('\n'.join(saml_response[3]), saml_response[1], saml_response[2]) # pysaml2 returns an empty 200 in some cases, # we'll redirect instead if response.status_code == 200 and not response.data: response = redirect(next_url) else: raise AuthException('An error occurred during logout') return response def handle_logout(self, request, next_url='/'): """Handle SAML Authentication logout request/response. Args: request (Request): Flask request object for this HTTP transaction. next_url (string): URL to get redirected to if all is successful. Returns: (boolean) Success, Flask Response object to return to user containing HTTP_REDIRECT SAML message. Raises: BadRequest: when SAML request/response data is missing. """ # retrieve cache state_cache = AuthDictCache(session, '_saml_state') identity_cache = IdentityCache(session, '_saml_identity') subject_id = session.get('_saml_subject_id') LOGGER.debug('State cache %s' % state_cache) LOGGER.debug('Identity cache %s' % identity_cache) LOGGER.debug('Subject Id %s' % subject_id) # use pysaml2 to complete the SAML logout request client = Saml2Client(self._config, state_cache=state_cache, identity_cache=identity_cache, logger=LOGGER) # let's try to figure out what binding is being used and what type of # logout call we are handling if request.args: binding = BINDING_HTTP_REDIRECT elif request.form: binding = BINDING_HTTP_POST else: # The SOAP binding is only valid on logout requests which currently # pysaml2 doesn't support. raise BadRequest('Unable to find supported binding') if 'SAMLRequest' in request.values: response = self._handle_logout_request( client, request, subject_id, binding) elif 'SAMLResponse' in request.values: response = self._handle_logout_response( client, request, binding, next_url) else: raise BadRequest('Unable to find SAMLRequest or SAMLResponse') # cache the state and remove subject if logout was successful success = identity_cache.get_identity(subject_id) == ({}, []) if success: session.pop('_saml_subject_id') state_cache.sync() LOGGER.debug('State cache %s' % session['_saml_state']) LOGGER.debug('Identity cache %s' % session['_saml_identity']) LOGGER.debug( 'Returning redirect to complete/continue the logout process') return success, response def get_metadata(self): """Returns SAML Service Provider Metadata""" edesc = entity_descriptor(self._config, 24) if self._config.key_file: edesc = sign_entity_descriptor(edesc, 24, None, security_context(self._config)) response = make_response(str(edesc)) response.headers['Content-type'] = 'text/xml; charset=utf-8' return response