Example #1
0
def test_1():
    c = SPConfig().load(sp1)
    c.context = "sp"
    print(c)
    assert c._sp_endpoints
    assert c._sp_name
    assert c._sp_idp
    md = c.metadata
    assert isinstance(md, MetadataStore)

    assert len(c._sp_idp) == 1
    assert list(c._sp_idp.keys()) == ["urn:mace:example.com:saml:roland:idp"]
    assert list(c._sp_idp.values()) == [{
        'single_sign_on_service': {
            'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect':
            ('http://localhost:8088/sso/')
        }
    }]

    assert c.only_use_keys_in_metadata
    assert type(c.getattr("requested_authn_context")) is dict
    assert c.getattr("requested_authn_context").get(
        "authn_context_class_ref") == [
            AUTHN_PASSWORD_PROTECTED,
            AUTHN_TIME_SYNC_TOKEN,
        ]
    assert c.getattr("requested_authn_context").get("comparison") == "exact"
Example #2
0
    def __init__(self, outgoing, internal_attributes, config, base_url, name):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]
        :type base_url: str
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)
        self.config = self.init_config(config)

        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_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)
Example #3
0
    def __init__(self, outgoing, internal_attributes, config, base_url, name):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]
        :type base_url: str
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)
        self.config = self.init_config(config)

        sp_config = SPConfig().load(
            copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG]), False)
        self.sp = Base(sp_config)

        self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV)
        self.encryption_keys = []
        self.outstanding_queries = {}
        self.idp_blacklist_file = config.get('idp_blacklist_file', None)
        self.requested_attributes = self.config.get(
            SAMLBackend.KEY_DYNAMIC_REQUESTED_ATTRIBUTES)

        sp_keypairs = sp_config.getattr('encryption_keypairs', '')
        sp_key_file = sp_config.getattr('key_file', '')
        if sp_keypairs:
            key_file_paths = [pair['key_file'] for pair in sp_keypairs]
        elif sp_key_file:
            key_file_paths = [sp_key_file]
        else:
            key_file_paths = []

        for p in key_file_paths:
            with open(p) as key_file:
                self.encryption_keys.append(key_file.read())
Example #4
0
    def __init__(self, outgoing, internal_attributes, config, base_url, name):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]
        :type base_url: str
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)
        self.config = self.init_config(config)

        sp_config = SPConfig().load(copy.deepcopy(config[self.KEY_SP_CONFIG]), False)
        self.sp = Base(sp_config)

        self.discosrv = config.get(self.KEY_DISCO_SRV)
        self.encryption_keys = []
        self.outstanding_queries = {}
        self.idp_blacklist_file = config.get('idp_blacklist_file', None)

        sp_keypairs = sp_config.getattr('encryption_keypairs', '')
        sp_key_file = sp_config.getattr('key_file', '')
        if sp_keypairs:
            key_file_paths = [pair['key_file'] for pair in sp_keypairs]
        elif sp_key_file:
            key_file_paths = [sp_key_file]
        else:
            key_file_paths = []

        for p in key_file_paths:
            with open(p) as key_file:
                self.encryption_keys.append(key_file.read())
Example #5
0
def test_2():
    c = SPConfig().load(sp2)
    c.context = "sp"

    print c
    assert c._sp_endpoints
    assert c.getattr("endpoints", "sp")
    assert c._sp_idp
    assert c._sp_optional_attributes
    assert c.name
    assert c._sp_required_attributes

    assert len(c._sp_idp) == 1
    assert c._sp_idp.keys() == [""]
    assert c._sp_idp.values() == ["https://example.com/saml2/idp/SSOService.php"]
    assert c.only_use_keys_in_metadata is True
Example #6
0
def test_2():
    c = SPConfig().load(sp2)
    c.context = "sp"

    print(c)
    assert c._sp_endpoints
    assert c.getattr("endpoints", "sp")
    assert c._sp_idp
    assert c._sp_optional_attributes
    assert c.name
    assert c._sp_required_attributes

    assert len(c._sp_idp) == 1
    assert list(c._sp_idp.keys()) == [""]
    assert list(c._sp_idp.values()) == [
        "https://example.com/saml2/idp/SSOService.php"]
    assert c.only_use_keys_in_metadata is True
Example #7
0
def test_set_force_authn():
    cnf = SPConfig().load(sp2)
    assert bool(cnf.getattr('force_authn', 'sp')) == True
Example #8
0
def test_unset_force_authn():
    cnf = SPConfig().load(sp1)
    assert bool(cnf.getattr('force_authn', 'sp')) == False
Example #9
0
class Saml(object):

    """
    SAML Wrapper around pysaml2.

    Implements SAML2 Service Provider functionality for Flask.
    """

    def __init__(self, config):
        """Initialize SAML Service Provider.

        Args:
            config (dict): Service Provider config info in dict form
        """
        if config.get('metadata') is not None:
            config['metadata'] = _parse_metadata_dict_to_inline(
                config['metadata'])
        self._config = SPConfig().load(config)
        self._config.setattr('', 'allow_unknown_attributes', True)
        # Set discovery end point, if configured for.
        if config['service']['sp'].get('ds'):
            self.discovery_service_end_point = \
                config['service']['sp'].get('ds')[0]

    def authenticate(self, next_url="/", binding=BINDING_HTTP_REDIRECT,
                     selected_idp=None):
        """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.
                Defaults to BINDING_HTTP_REDIRECT (don't change til HTTP_POST
                support is complete in pysaml2).
            selected_idp (string): A specfic IdP that should be used to
                authenticate. Defaults to `None`.

        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.
        """
        # Fail if signing requested but no private key configured.
        if self._config.getattr('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")

        # Find configured for IdPs for requested binding method.
        bindable_idps = []
        all_idps = self._config.metadata.identity_providers()
        # Filter IdPs to allowed IdPs, if we have some.
        if self._config.getattr('idp') is not None:
            all_idps = list(set(all_idps) & set(self._config.getattr('idp')))
        # Filter IdPs to selected IdP, if we have one.
        if selected_idp is not None:
            all_idps = list(set(all_idps) & set([selected_idp]))
        # From all IdPs allowed/selected, get the ones we can bind to.
        for idp in all_idps:
            if self._config.metadata.single_sign_on_service(idp, binding) != []:
                bindable_idps.append(idp)
        if not len(bindable_idps):
            raise AuthException("Unable to locate valid IdP for this request")

        # Retrieve cache.
        outstanding_queries_cache = \
            AuthDictCache(session, '_saml_outstanding_queries')
        LOGGER.debug("Outstanding queries cache %s", outstanding_queries_cache)

        if len(bindable_idps) > 1:
            # Redirect to discovery service
            (session_id, response) = self._handle_discovery_request()
        else:
            idp_entityid = bindable_idps[0]
            LOGGER.debug("Connecting to Identity Provider %s", idp_entityid)

            # Make pysaml2 call to authenticate.
            client = Saml2Client(self._config)
            (session_id, result) = client.prepare_for_authenticate(
                entityid=idp_entityid,
                relay_state=next_url,
                sign=self._config.getattr('authn_requests_signed'),
                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['headers']))
            elif binding == BINDING_HTTP_POST:
                LOGGER.debug("Post to Identity Provider %s ( %s )",
                             idp_entityid, result)
                response = 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_discovery_request(self):
        """Handle SAML Discovery Service request. This method is called
        internally by the `authenticate` method when multiple acceptable IdPs
        are detected.

        Returns:
            Tuple containing session Id and Flask Response object to return to
                user containing either HTTP_REDIRECT to configured Discovery
                Service end point.

        Raises:
            AuthException: when unable to find discovery response end point.
        """
        session_id = sid()
        try:
            return_url = self._config.getattr(
                'endpoints', 'sp')['discovery_response'][0][0]
        except KeyError:
            raise AuthException(
                "Multiple IdPs configured with no configured Discovery" + \
                " response end point.")
        return_url += "?session_id=%s" % session_id
        disco_url = Saml2Client.create_discovery_service_request(
            self.discovery_service_end_point,
            self._config.entityid, **{'return': return_url})
        LOGGER.debug("Redirect to Discovery Service %s", disco_url)
        return (session_id, make_response('', 302, {'Location': disco_url}))

    def handle_discovery_response(self, request):
        """Handle SAML Discovery Service response. This method is basically
        a wrapper around `authenticate` with a little extra logic for getting
        the `entityID` out of the request and the next_url and binding that was
        previously submitted to `authenticate` from the user's session.

        Args:
            request (Request): Flask request object for this HTTP transaction.

        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.
        """
        session_id = request.args.get('session_id')
        next_url = "/"

        # Retrieve cache. Get `next_url` from cache.
        outstanding_queries_cache = \
            AuthDictCache(session, '_saml_outstanding_queries')
        if session_id in outstanding_queries_cache.keys():
            next_url = outstanding_queries_cache[session_id]
            del outstanding_queries_cache[session_id]
        outstanding_queries_cache.sync()
        # Get the selected IdP from the Discovery Service response.
        selected_idp = Saml2Client.parse_discovery_service_response(
            query=request.query_string)
        return self.authenticate(next_url=next_url, selected_idp=selected_idp)

    def handle_assertion(self, request):
        """Handle SAML Authentication login assertion (POST).

        Args:
            request (Request): Flask request object for this HTTP transaction.

        Returns:
            (tuple) SAML assertion response information (dict) containing the
                IdP entity id, the subject's name id, and any additional
                attributes which may have been returned in the assertion, and
                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)
        saml_response = client.parse_authn_request_response(
            request.form['SAMLResponse'],
            BINDING_HTTP_POST,
            outstanding=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()

        saml_subject_id = saml_response.name_id
        # Assemble SAML assertion info for returning to the method caller.
        saml_assertion_info = saml_response.get_identity()
        # Note: SAML assertion attributes can have multiple values so the
        # values returned for these attributes are lists even if there is only
        # one entry. For consistency the `name_id` returned with the SAML
        # assertion information has been included as a single item list.
        saml_assertion_info['name_id'] = [saml_response.get_subject().text]
        # The IdP entity id is obviously not an attribute, so no list required.
        saml_assertion_info['idp_entity_id'] = saml_response.issuer()
        LOGGER.debug("SAML Session Info ( %s )", saml_assertion_info)

        # set subject Id in cache to retrieved name_id
        session['_saml_subject_id'] = saml_subject_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 (saml_assertion_info, redirect(relay_state))

    def logout(self, next_url='/', expire=None):
        """Start SAML Authentication logout process.

        Args:
            next_url (string): HTTP URL to return user to when logout is
                complete.
            expire (struct_time): The latest the log out should happen.

        Returns:
            Flask Response object to return to user containing either
                HTTP_REDIRECT or HTTP_POST SAML message.

        Raises:
            AuthException: Can not resolve IdP 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.getattr('logout_requests_signed') == True:
            LOGGER.debug("key_file %s", self._config.key_file)
            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)
        saml_response = client.global_logout(subject_id, expire=expire)

        # 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.get('1', None) == "": # used SOAP BINDING successfully
            return redirect(next_url)

        LOGGER.debug("Returning Response from SAML for continuation of the" + \
            " logout process")
        for _, item in saml_response.items():
            if isinstance(item, tuple):
                http_type, htargs = item
                break

        if http_type == BINDING_HTTP_POST:
            return htargs, 200
        else:
            return make_response("", 302, htargs['headers'])

    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)
        # 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 = _handle_logout_request(
                client, request, subject_id, binding)
        elif "SAMLResponse" in request.values:
            response = _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 or
        # this subject was already logged out.
        success = not subject_id or \
            identity_cache.get_identity(subject_id) == ({}, [])
        if success:
            session.pop('_saml_subject_id', None)
        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)
        if self._config.key_file:
            _, edesc = sign_entity_descriptor(
                edesc, None, security_context(self._config))
        response = make_response(str(edesc))
        response.headers['Content-type'] = "text/xml; charset=utf-8"
        return response
Example #10
0
class Saml(object):
    """
    SAML Wrapper around pysaml2.

    Implements SAML2 Service Provider functionality for Flask.
    """
    def __init__(self, config):
        """Initialize SAML Service Provider.

        Args:
            config (dict): Service Provider config info in dict form
        """
        if config.get('metadata') is not None:
            config['metadata'] = _parse_metadata_dict_to_inline(
                config['metadata'])
        self._config = SPConfig().load(config)
        self._config.setattr('', 'allow_unknown_attributes', True)
        # Set discovery end point, if configured for.
        if config['service']['sp'].get('ds'):
            self.discovery_service_end_point = \
                config['service']['sp'].get('ds')[0]

    def authenticate(self,
                     next_url="/",
                     binding=BINDING_HTTP_REDIRECT,
                     selected_idp=None):
        """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.
                Defaults to BINDING_HTTP_REDIRECT (don't change til HTTP_POST
                support is complete in pysaml2).
            selected_idp (string): A specfic IdP that should be used to
                authenticate. Defaults to `None`.

        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.
        """
        # Fail if signing requested but no private key configured.
        if self._config.getattr('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")

        # Find configured for IdPs for requested binding method.
        bindable_idps = []
        all_idps = self._config.metadata.identity_providers()
        # Filter IdPs to allowed IdPs, if we have some.
        if self._config.getattr('idp') is not None:
            all_idps = list(set(all_idps) & set(self._config.getattr('idp')))
        # Filter IdPs to selected IdP, if we have one.
        if selected_idp is not None:
            all_idps = list(set(all_idps) & set([selected_idp]))
        # From all IdPs allowed/selected, get the ones we can bind to.
        for idp in all_idps:
            if self._config.metadata.single_sign_on_service(idp,
                                                            binding) != []:
                bindable_idps.append(idp)
        if not len(bindable_idps):
            raise AuthException("Unable to locate valid IdP for this request")

        # Retrieve cache.
        outstanding_queries_cache = \
            AuthDictCache(session, '_saml_outstanding_queries')
        LOGGER.debug("Outstanding queries cache %s", outstanding_queries_cache)

        if len(bindable_idps) > 1:
            # Redirect to discovery service
            (session_id, response) = self._handle_discovery_request()
        else:
            idp_entityid = bindable_idps[0]
            LOGGER.debug("Connecting to Identity Provider %s", idp_entityid)

            # Make pysaml2 call to authenticate.
            client = Saml2Client(self._config)
            (session_id, result) = client.prepare_for_authenticate(
                entityid=idp_entityid,
                relay_state=next_url,
                sign=self._config.getattr('authn_requests_signed'),
                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['headers']))
            elif binding == BINDING_HTTP_POST:
                LOGGER.debug("Post to Identity Provider %s ( %s )",
                             idp_entityid, result)
                response = 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_discovery_request(self):
        """Handle SAML Discovery Service request. This method is called
        internally by the `authenticate` method when multiple acceptable IdPs
        are detected.

        Returns:
            Tuple containing session Id and Flask Response object to return to
                user containing either HTTP_REDIRECT to configured Discovery
                Service end point.

        Raises:
            AuthException: when unable to find discovery response end point.
        """
        session_id = sid()
        try:
            return_url = self._config.getattr('endpoints',
                                              'sp')['discovery_response'][0][0]
        except KeyError:
            raise AuthException(
                "Multiple IdPs configured with no configured Discovery" + \
                " response end point.")
        return_url += "?session_id=%s" % session_id
        disco_url = Saml2Client.create_discovery_service_request(
            self.discovery_service_end_point, self._config.entityid,
            **{'return': return_url})
        LOGGER.debug("Redirect to Discovery Service %s", disco_url)
        return (session_id, make_response('', 302, {'Location': disco_url}))

    def handle_discovery_response(self, request):
        """Handle SAML Discovery Service response. This method is basically
        a wrapper around `authenticate` with a little extra logic for getting
        the `entityID` out of the request and the next_url and binding that was
        previously submitted to `authenticate` from the user's session.

        Args:
            request (Request): Flask request object for this HTTP transaction.

        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.
        """
        session_id = request.args.get('session_id')
        next_url = "/"

        # Retrieve cache. Get `next_url` from cache.
        outstanding_queries_cache = \
            AuthDictCache(session, '_saml_outstanding_queries')
        if session_id in outstanding_queries_cache.keys():
            next_url = outstanding_queries_cache[session_id]
            del outstanding_queries_cache[session_id]
        outstanding_queries_cache.sync()
        # Get the selected IdP from the Discovery Service response.
        selected_idp = Saml2Client.parse_discovery_service_response(
            query=request.query_string)
        return self.authenticate(next_url=next_url, selected_idp=selected_idp)

    def handle_assertion(self, request):
        """Handle SAML Authentication login assertion (POST).

        Args:
            request (Request): Flask request object for this HTTP transaction.

        Returns:
            (tuple) SAML assertion response information (dict) containing the
                IdP entity id, the subject's name id, and any additional
                attributes which may have been returned in the assertion, and
                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)
        saml_response = client.parse_authn_request_response(
            request.form['SAMLResponse'],
            BINDING_HTTP_POST,
            outstanding=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()

        saml_subject_id = saml_response.name_id
        # Assemble SAML assertion info for returning to the method caller.
        saml_assertion_info = saml_response.get_identity()
        # Note: SAML assertion attributes can have multiple values so the
        # values returned for these attributes are lists even if there is only
        # one entry. For consistency the `name_id` returned with the SAML
        # assertion information has been included as a single item list.
        saml_assertion_info['name_id'] = [saml_response.get_subject().text]
        # The IdP entity id is obviously not an attribute, so no list required.
        saml_assertion_info['idp_entity_id'] = saml_response.issuer()
        LOGGER.debug("SAML Session Info ( %s )", saml_assertion_info)

        # set subject Id in cache to retrieved name_id
        session['_saml_subject_id'] = saml_subject_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 (saml_assertion_info, redirect(relay_state))

    def logout(self, next_url='/', expire=None):
        """Start SAML Authentication logout process.

        Args:
            next_url (string): HTTP URL to return user to when logout is
                complete.
            expire (struct_time): The latest the log out should happen.

        Returns:
            Flask Response object to return to user containing either
                HTTP_REDIRECT or HTTP_POST SAML message.

        Raises:
            AuthException: Can not resolve IdP 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.getattr('logout_requests_signed') == True:
            LOGGER.debug("key_file %s", self._config.key_file)
            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)
        saml_response = client.global_logout(subject_id, expire=expire)

        # 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.get('1',
                             None) == "":  # used SOAP BINDING successfully
            return redirect(next_url)

        LOGGER.debug("Returning Response from SAML for continuation of the" + \
            " logout process")
        for _, item in saml_response.items():
            if isinstance(item, tuple):
                http_type, htargs = item
                break

        if http_type == BINDING_HTTP_POST:
            return htargs, 200
        else:
            return make_response("", 302, htargs['headers'])

    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)
        # 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 = _handle_logout_request(client, request, subject_id,
                                              binding)
        elif "SAMLResponse" in request.values:
            response = _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 or
        # this subject was already logged out.
        success = not subject_id or \
            identity_cache.get_identity(subject_id) == ({}, [])
        if success:
            session.pop('_saml_subject_id', None)
        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)
        if self._config.key_file:
            _, edesc = sign_entity_descriptor(edesc, None,
                                              security_context(self._config))
        response = make_response(str(edesc))
        response.headers['Content-type'] = "text/xml; charset=utf-8"
        return response
Example #11
0
def test_set_force_authn():
    cnf = SPConfig().load(sp2)
    assert bool(cnf.getattr('force_authn', 'sp')) == True
Example #12
0
def test_unset_force_authn():
    cnf = SPConfig().load(sp1)
    assert bool(cnf.getattr('force_authn', 'sp')) == False