def test__remote_patron_lookup_block_rules(self):
        """This patron has a value of "m" in MBLOCK[56], which generally
        means they are blocked.
        """
        # Default behavior -- anything other than '-' means blocked.
        self.api.enqueue("dump.blocked.html")
        patrondata = PatronData(authorization_identifier="good barcode")
        patrondata = self.api._remote_patron_lookup(patrondata)
        assert PatronData.UNKNOWN_BLOCK == patrondata.block_reason

        # If we set custom block types that say 'm' doesn't really
        # mean the patron is blocked, they're not blocked.
        api = self.mock_api(block_types="abcde")
        api.enqueue("dump.blocked.html")
        patrondata = PatronData(authorization_identifier="good barcode")
        patrondata = api._remote_patron_lookup(patrondata)
        assert PatronData.NO_VALUE == patrondata.block_reason

        # If we set custom block types that include 'm', the patron
        # is blocked.
        api = self.mock_api(block_types="lmn")
        api.enqueue("dump.blocked.html")
        patrondata = PatronData(authorization_identifier="good barcode")
        patrondata = api._remote_patron_lookup(patrondata)
        assert PatronData.UNKNOWN_BLOCK == patrondata.block_reason
Example #2
0
    def remote_patron_lookup(self, subject):
        """Creates a PatronData object based on Subject object containing SAML Subject and AttributeStatement

        :param subject: Subject object containing SAML Subject and AttributeStatement
        :type subject: api.saml.metadata.Subject

        :return: PatronData object containing information about the authenticated SAML subject or
            ProblemDetail object in the case of any errors
        :rtype: Union[PatronData, ProblemDetail]
        """
        if not subject:
            return SAML_INVALID_SUBJECT.detailed('Subject is empty')

        if isinstance(subject, PatronData):
            return subject

        if not isinstance(subject, Subject):
            return SAML_INVALID_SUBJECT.detailed('Incorrect subject type')

        extractor = SubjectUIDExtractor()
        uid = extractor.extract(subject)

        if uid is None:
            return SAML_INVALID_SUBJECT.detailed(
                'Subject does not have a unique ID')

        patron_data = PatronData(permanent_id=uid,
                                 authorization_identifier=uid,
                                 external_type='A',
                                 complete=True)

        return patron_data
Example #3
0
 def test__remote_patron_lookup_barcode_spaces(self):
     self.api.enqueue("dump.success_barcode_spaces.html")
     patrondata = PatronData(authorization_identifier="44444444444447")
     patrondata = self.api._remote_patron_lookup(patrondata)
     eq_("44444444444447", patrondata.authorization_identifier)
     eq_(["44444444444447", "4 444 4444 44444 7"],
         patrondata.authorization_identifiers)
    def test__remote_patron_lookup(self):
        p = SimpleAuthenticationProvider
        integration = self._external_integration(self._str)
        integration.setting(p.TEST_IDENTIFIER).value = "barcode"
        integration.setting(p.TEST_PASSWORD).value = "pass"
        integration.setting(p.PASSWORD_KEYBOARD).value = p.NULL_KEYBOARD
        provider = p(self._default_library, integration)
        patron_data = PatronData(authorization_identifier="barcode")
        patron = self._patron()
        patron.authorization_identifier = "barcode"

        # Returns None if nothing is passed in
        assert provider._remote_patron_lookup(None) == None

        # Returns a patron if a patron is passed in and something is found
        result = provider._remote_patron_lookup(patron)
        assert result.permanent_id == "barcode_id"

        # Returns None if no patron is found
        patron.authorization_identifier = "wrong barcode"
        result = provider._remote_patron_lookup(patron)
        assert result == None

        # Returns a patron if a PatronData object is passed in and something is found
        result = provider._remote_patron_lookup(patron_data)
        assert result.permanent_id == "barcode_id"
 def test_parse_poorly_behaved_dump(self):
     """The HTML parser is able to handle HTML embedded in
     field values.
     """
     self.api.enqueue("dump.embedded_html.html")
     patrondata = PatronData(authorization_identifier="good barcode")
     patrondata = self.api._remote_patron_lookup(patrondata)
     assert "abcd" == patrondata.authorization_identifier
 def test_remote_patron_lookup_blocked(self):
     """This patron has a block on their record, which shows up in 
     PatronData.
     """
     self.api.enqueue("dump.blocked.html")
     patrondata = PatronData(authorization_identifier="good barcode")
     patrondata = self.api.remote_patron_lookup(patrondata)
     eq_(PatronData.UNKNOWN_BLOCK, patrondata.block_reason)
Example #7
0
    def remote_patron_lookup(self, token):
        """Use a bearer token to look up detailed patron information.

        :return: A ProblemDetail if there's a problem. Otherwise, a
        PatronData.
        """
        bearer_headers = {'Authorization': 'Bearer %s' % token}
        result = self._get(self.CLEVER_API_BASE_URL + '/me', bearer_headers)
        data = result.get('data', {})

        identifier = data.get('id', None)

        if not identifier:
            return INVALID_CREDENTIALS.detailed(
                _("A valid Clever login is required."))

        if result.get('type') not in self.SUPPORTED_USER_TYPES:
            return UNSUPPORTED_CLEVER_USER_TYPE

        links = result['links']

        user_link = [l for l in links if l['rel'] == 'canonical'][0]['uri']
        user = self._get(self.CLEVER_API_BASE_URL + user_link, bearer_headers)

        user_data = user['data']
        school_id = user_data['school']
        school = self._get(
            self.CLEVER_API_BASE_URL + '/v1.1/schools/%s' % school_id,
            bearer_headers)

        school_nces_id = school['data'].get('nces_id')

        # TODO: check student free and reduced lunch status as well

        if school_nces_id not in TITLE_I_NCES_IDS:
            self.log.info("%s didn't match a Title I NCES ID" % school_nces_id)
            return CLEVER_NOT_ELIGIBLE

        if result['type'] == 'student':
            grade = user_data.get('grade')
            external_type = None
            if grade in ["Kindergarten", "1", "2", "3"]:
                external_type = "E"
            elif grade in ["4", "5", "6", "7", "8"]:
                external_type = "M"
            elif grade in ["9", "10", "11", "12"]:
                external_type = "H"
        else:
            external_type = "A"

        patrondata = PatronData(permanent_id=identifier,
                                authorization_identifier=identifier,
                                external_type=external_type,
                                personal_name=user_data.get('name'),
                                complete=True)
        return patrondata
Example #8
0
    def info_to_patrondata(cls, info):
        """Convert the SIP-specific dictionary obtained from
        SIPClient.patron_information() to an abstract,
        authenticator-independent PatronData object.
        """
        if info.get('valid_patron_password') == 'N':
            # The patron did not authenticate correctly. Don't
            # return any data.
            return None

            # TODO: I'm not 100% convinced that a missing CQ field
            # always means "we don't have passwords so you're
            # authenticated," rather than "you didn't provide a
            # password so we didn't check."
        patrondata = PatronData()
        if 'sipserver_internal_id' in info:
            patrondata.permanent_id = info['sipserver_internal_id']
        if 'patron_identifier' in info:
            patrondata.authorization_identifier = info['patron_identifier']
        if 'email_address' in info:
            patrondata.email_address = info['email_address']
        if 'personal_name' in info:
            patrondata.personal_name = info['personal_name']
        if 'fee_amount' in info:
            fines = info['fee_amount']
        else:
            fines = '0'
        patrondata.fines = MoneyUtility.parse(fines)
        if 'sipserver_patron_class' in info:
            patrondata.external_type = info['sipserver_patron_class']
        for expire_field in [
                'sipserver_patron_expiration', 'polaris_patron_expiration'
        ]:
            if expire_field in info:
                value = info.get(expire_field)
                value = cls.parse_date(value)
                if value:
                    patrondata.authorization_expires = value
                    break
        return patrondata
    def test__remote_patron_lookup_success(self):
        self.api.enqueue("dump.success.html")
        patrondata = PatronData(authorization_identifier="good barcode")
        patrondata = self.api._remote_patron_lookup(patrondata)

        # Although "good barcode" was successful in lookup this patron
        # up, it didn't show up in their patron dump as a barcode, so
        # the authorization_identifier from the patron dump took
        # precedence.
        assert "6666666" == patrondata.permanent_id
        assert "44444444444447" == patrondata.authorization_identifier
        assert "alice" == patrondata.username
        assert Decimal(0) == patrondata.fines
        assert date(2059, 4, 1) == patrondata.authorization_expires
        assert "SHELDON, ALICE" == patrondata.personal_name
        assert "*****@*****.**" == patrondata.email_address
        assert PatronData.NO_VALUE == patrondata.block_reason
 def test__remote_patron_lookup_no_such_patron(self):
     self.api.enqueue("dump.no such barcode.html")
     patrondata = PatronData(authorization_identifier="bad barcode")
     assert None == self.api._remote_patron_lookup(patrondata)
Example #11
0
    def info_to_patrondata(cls, info):
        """Convert the SIP-specific dictionary obtained from
        SIPClient.patron_information() to an abstract,
        authenticator-independent PatronData object.
        """
        if info.get('valid_patron_password') == 'N':
            # The patron did not authenticate correctly. Don't
            # return any data.
            return None

            # TODO: I'm not 100% convinced that a missing CQ field
            # always means "we don't have passwords so you're
            # authenticated," rather than "you didn't provide a
            # password so we didn't check."
        patrondata = PatronData()
        if 'sipserver_internal_id' in info:
            patrondata.permanent_id = info['sipserver_internal_id']
        if 'patron_identifier' in info:
            patrondata.authorization_identifier = info['patron_identifier']
        if 'email_address' in info:
            patrondata.email_address = info['email_address']
        if 'personal_name' in info:
            patrondata.personal_name = info['personal_name']
        if 'fee_amount' in info:
            fines = info['fee_amount']
        else:
            fines = '0'
        patrondata.fines = MoneyUtility.parse(fines)
        if 'sipserver_patron_class' in info:
            patrondata.external_type = info['sipserver_patron_class']
        for expire_field in [
                'sipserver_patron_expiration', 'polaris_patron_expiration'
        ]:
            if expire_field in info:
                value = info.get(expire_field)
                value = cls.parse_date(value)
                if value:
                    patrondata.authorization_expires = value
                    break

        # A True value in most (but not all) subfields of the
        # patron_status field will prohibit the patron from borrowing
        # books.
        status = info['patron_status_parsed']
        block_reason = PatronData.NO_VALUE
        for field in SIPClient.PATRON_STATUS_FIELDS_THAT_DENY_BORROWING_PRIVILEGES:
            if status.get(field) is True:
                block_reason = cls.SPECIFIC_BLOCK_REASONS.get(
                    field, PatronData.UNKNOWN_BLOCK)
                if block_reason not in (PatronData.NO_VALUE,
                                        PatronData.UNKNOWN_BLOCK):
                    # Even if there are multiple problems with this
                    # patron's account, we can now present a specific
                    # error message. There's no need to look through
                    # more fields.
                    break
        patrondata.block_reason = block_reason

        # If we can tell by looking at the SIP2 message that the
        # patron has excessive fines, we can use that as the reason
        # they're blocked.
        if 'fee_limit' in info:
            fee_limit = MoneyUtility.parse(info['fee_limit']).amount
            if fee_limit and patrondata.fines > fee_limit:
                patrondata.block_reason = PatronData.EXCESSIVE_FINES

        return patrondata
Example #12
0
    def remote_patron_lookup(self, token):
        """Use a bearer token for a patron to look up that patron's Clever
        record through the Clever API.

        This is the only method that has access to a patron's personal
        information as provided by Clever. Here's an inventory of the
        information we process and what happens to it:

        * The Clever 'id' associated with this patron is passed out of
          this method through the PatronData object, and persisted to
          two database fields: 'patrons.external_identifier' and
          'patrons.authorization_identifier'.

          As far as we know, the Clever ID is an opaque reference
          which uniquely identifies a given patron but contains no
          personal information about them.

        * If the patron is a student, their grade level
          ("Kindergarten" through "12") is converted into an Open
          eBooks patron type ("E" for "Early Grades", "M" for "Middle
          Grades", or "H" for "High School"). This is stored in the
          PatronData object returned from this method, and persisted
          to the database field 'patrons.external_type'. If the patron
          is not a student, their Open eBooks patron type is set to
          "A" for "All Access").

          This system does not track a patron's grade level or store
          it in the database. Only the coarser-grained Open eBooks
          patron type is tracked. This is used to show age-appropriate
          books to the patron.

        * The internal Clever ID of the patron's school is used to
          make a _second_ Clever API request to get information about
          the school. From that, we get the school's NCES ID, which we
          cross-check against data we've gathered separately to
          validate the school's Title I status. The school ID and NCES
          ID are not stored in the PatronData object or persisted to
          the database. Any patron who ends up in the database is
          presumed to have passed this check.

        To summarize, an opaque ID associated with the patron is
        persisted to the database, as is a coarse-grained indicator of
        the patron's age. No other information about the patron makes
        it out of this method.

        :return: A ProblemDetail if there's a problem. Otherwise, a PatronData
            with the data listed above.

        """
        bearer_headers = {'Authorization': 'Bearer %s' % token}
        result = self._get(self.CLEVER_API_BASE_URL + '/me', bearer_headers)
        data = result.get('data', {}) or {}

        identifier = data.get('id', None)

        if not identifier:
            return INVALID_CREDENTIALS.detailed(
                _("A valid Clever login is required."))

        if result.get('type') not in self.SUPPORTED_USER_TYPES:
            return UNSUPPORTED_CLEVER_USER_TYPE

        links = result['links']

        user_link = [l for l in links if l['rel'] == 'canonical'][0]['uri']
        user = self._get(self.CLEVER_API_BASE_URL + user_link, bearer_headers)

        user_data = user['data']
        school_id = user_data['school']
        school = self._get(
            self.CLEVER_API_BASE_URL + '/v1.1/schools/%s' % school_id,
            bearer_headers)

        school_nces_id = school['data'].get('nces_id')

        # TODO: check student free and reduced lunch status as well

        if school_nces_id is None:
            self.log.error("No NCES ID found in Clever school data: %s",
                           repr(school))
            return CLEVER_UNKNOWN_SCHOOL

        if school_nces_id not in TITLE_I_NCES_IDS:
            self.log.info("%s didn't match a Title I NCES ID", school_nces_id)
            return CLEVER_NOT_ELIGIBLE

        if result['type'] == 'student':
            grade = user_data.get('grade')
            external_type = None
            if grade in ["Kindergarten", "1", "2", "3"]:
                external_type = "E"
            elif grade in ["4", "5", "6", "7", "8"]:
                external_type = "M"
            elif grade in ["9", "10", "11", "12"]:
                external_type = "H"
        else:
            external_type = "A"

        patrondata = PatronData(permanent_id=identifier,
                                authorization_identifier=identifier,
                                external_type=external_type,
                                complete=True)
        return patrondata
Example #13
0
    def info_to_patrondata(self, info, validate_password=True):

        """Convert the SIP-specific dictionary obtained from
        SIPClient.patron_information() to an abstract,
        authenticator-independent PatronData object.
        """
        if info.get("valid_patron", "N") == "N":
            # The patron could not be identified as a patron of this
            # library. Don't return any data.
            return None

        if info.get("valid_patron_password") == "N" and validate_password:
            # The patron did not authenticate correctly. Don't
            # return any data.
            return None

            # TODO: I'm not 100% convinced that a missing CQ field
            # always means "we don't have passwords so you're
            # authenticated," rather than "you didn't provide a
            # password so we didn't check."
        patrondata = PatronData()
        if "sipserver_internal_id" in info:
            patrondata.permanent_id = info["sipserver_internal_id"]
        if "patron_identifier" in info:
            patrondata.authorization_identifier = info["patron_identifier"]
        if "email_address" in info:
            patrondata.email_address = info["email_address"]
        if "personal_name" in info:
            patrondata.personal_name = info["personal_name"]
        if "fee_amount" in info:
            fines = info["fee_amount"]
        else:
            fines = "0"
        patrondata.fines = MoneyUtility.parse(fines)
        if "sipserver_patron_class" in info:
            patrondata.external_type = info["sipserver_patron_class"]
        for expire_field in [
            "sipserver_patron_expiration",
            "polaris_patron_expiration",
        ]:
            if expire_field in info:
                value = info.get(expire_field)
                value = self.parse_date(value)
                if value:
                    patrondata.authorization_expires = value
                    break

        # A True value in most (but not all) subfields of the
        # patron_status field will prohibit the patron from borrowing
        # books.
        status = info["patron_status_parsed"]
        block_reason = PatronData.NO_VALUE
        for field in self.fields_that_deny_borrowing:
            if status.get(field) is True:
                block_reason = self.SPECIFIC_BLOCK_REASONS.get(
                    field, PatronData.UNKNOWN_BLOCK
                )
                if block_reason not in (PatronData.NO_VALUE, PatronData.UNKNOWN_BLOCK):
                    # Even if there are multiple problems with this
                    # patron's account, we can now present a specific
                    # error message. There's no need to look through
                    # more fields.
                    break
        patrondata.block_reason = block_reason

        # If we can tell by looking at the SIP2 message that the
        # patron has excessive fines, we can use that as the reason
        # they're blocked.
        if "fee_limit" in info:
            fee_limit = MoneyUtility.parse(info["fee_limit"]).amount
            if fee_limit and patrondata.fines > fee_limit:
                patrondata.block_reason = PatronData.EXCESSIVE_FINES

        return patrondata
Example #14
0
    def remote_patron_lookup(self, token):
        """Use a bearer token for a patron to look up that patron's Clever
        record through the Clever API.

        This is the only method that has access to a patron's personal
        information as provided by Clever. Here's an inventory of the
        information we process and what happens to it:

        * The Clever 'id' associated with this patron is passed out of
          this method through the PatronData object, and persisted to
          two database fields: 'patrons.external_identifier' and
          'patrons.authorization_identifier'.

          As far as we know, the Clever ID is an opaque reference
          which uniquely identifies a given patron but contains no
          personal information about them.

        * If the patron is a student, their grade level
          ("Kindergarten" through "12") is converted into an Open
          eBooks patron type ("E" for "Early Grades", "M" for "Middle
          Grades", or "H" for "High School"). This is stored in the
          PatronData object returned from this method, and persisted
          to the database field 'patrons.external_type'. If the patron
          is not a student, their Open eBooks patron type is set to
          "A" for "All Access").

          This system does not track a patron's grade level or store
          it in the database. Only the coarser-grained Open eBooks
          patron type is tracked. This is used to show age-appropriate
          books to the patron.

        * The internal Clever ID of the patron's school is used to
          make a _second_ Clever API request to get information about
          the school. From that, we get the school's NCES ID, which we
          cross-check against data we've gathered separately to
          validate the school's Title I status. The school ID and NCES
          ID are not stored in the PatronData object or persisted to
          the database. Any patron who ends up in the database is
          presumed to have passed this check.

        To summarize, an opaque ID associated with the patron is
        persisted to the database, as is a coarse-grained indicator of
        the patron's age. No other information about the patron makes
        it out of this method.

        :return: A ProblemDetail if there's a problem. Otherwise, a PatronData
            with the data listed above.

        """
        bearer_headers = {"Authorization": "Bearer %s" % token}
        result = self._get(self.CLEVER_API_BASE_URL + "/me", bearer_headers)
        data = result.get("data", {}) or {}

        identifier = data.get("id", None)

        if not identifier:
            return INVALID_CREDENTIALS.detailed(
                lgt("A valid Clever login is required."))

        if result.get("type") not in self.SUPPORTED_USER_TYPES:
            return UNSUPPORTED_CLEVER_USER_TYPE

        links = result["links"]

        user_link = [link for link in links
                     if link["rel"] == "canonical"][0]["uri"]
        # The canonical link includes the API version, so we use the base URL.
        user = self._get(self.CLEVER_API_BASE_URL + user_link, bearer_headers)

        user_data = user["data"]
        school_id = user_data["school"]
        school = self._get(
            f"{self.CLEVER_API_BASE_URL}/v1.1/schools/{school_id}",
            bearer_headers)
        school_nces_id = school["data"].get("nces_id")

        # TODO: check student free and reduced lunch status as well

        if school_nces_id is None:
            self.log.error("No NCES ID found in Clever school data: %s",
                           repr(school))
            return CLEVER_UNKNOWN_SCHOOL

        if school_nces_id not in TITLE_I_NCES_IDS:
            self.log.info("%s didn't match a Title I NCES ID", school_nces_id)
            return CLEVER_NOT_ELIGIBLE

        external_type = None

        if result["type"] == "student":
            # We need to be able to assign an external_type to students, so that they
            # get the correct content level. To do so we rely on the grade field in the
            # user data we get back from Clever. Their API doesn't guarantee that the
            # grade field is present, so we supply a default.
            student_grade = user_data.get("grade", None)

            if not student_grade:  # If no grade was supplied, log the school/student
                msg = (
                    f"CLEVER_UNKNOWN_PATRON_GRADE: School with NCES ID {school_nces_id} "
                    f"did not supply grade for student {user_data.get('id')}")
                self.log.info(msg)

            # If we can't determine a type from the grade level, set to "A"
            external_type = external_type_from_clever_grade(student_grade)
        else:
            external_type = "A"  # Non-students get content level "A"

        patrondata = PatronData(
            permanent_id=identifier,
            authorization_identifier=identifier,
            external_type=external_type,
            complete=True,
        )
        return patrondata
class TestSAMLWebSSOAuthenticationProvider(ControllerTest):
    def setup_method(self, _db=None, set_up_circulation_manager=True):
        super(TestSAMLWebSSOAuthenticationProvider, self).setup_method()

        metadata_parser = SAMLMetadataParser()

        self._external_integration_association = create_autospec(
            spec=HasExternalIntegration)
        self._external_integration_association.external_integration = MagicMock(
            return_value=self._integration)

        self._configuration_storage = ConfigurationStorage(
            self._external_integration_association)
        self._configuration_factory = SAMLConfigurationFactory(metadata_parser)

    @parameterized.expand([
        (
            "identity_provider_with_display_name",
            [IDENTITY_PROVIDER_WITH_DISPLAY_NAME],
            {
                "type":
                SAMLWebSSOAuthenticationProvider.FLOW_TYPE,
                "description":
                SAMLWebSSOAuthenticationProvider.NAME,
                "links": [{
                    "rel":
                    "authenticate",
                    "href":
                    "http://localhost/default/saml_authenticate?provider=SAML+2.0+Web+SSO&idp_entity_id=http%3A%2F%2Fidp2.hilbertteam.net%2Fidp%2Fshibboleth",
                    "display_names": [
                        {
                            "value": fixtures.IDP_1_UI_INFO_EN_DISPLAY_NAME,
                            "language": "en",
                        },
                        {
                            "value": fixtures.IDP_1_UI_INFO_ES_DISPLAY_NAME,
                            "language": "es",
                        },
                    ],
                    "descriptions": [
                        {
                            "value": fixtures.IDP_1_UI_INFO_DESCRIPTION,
                            "language": "en",
                        },
                        {
                            "value": fixtures.IDP_1_UI_INFO_DESCRIPTION,
                            "language": "es",
                        },
                    ],
                    "information_urls": [
                        {
                            "value": fixtures.IDP_1_UI_INFO_INFORMATION_URL,
                            "language": "en",
                        },
                        {
                            "value": fixtures.IDP_1_UI_INFO_INFORMATION_URL,
                            "language": "es",
                        },
                    ],
                    "privacy_statement_urls": [
                        {
                            "value":
                            fixtures.IDP_1_UI_INFO_PRIVACY_STATEMENT_URL,
                            "language": "en",
                        },
                        {
                            "value":
                            fixtures.IDP_1_UI_INFO_PRIVACY_STATEMENT_URL,
                            "language": "es",
                        },
                    ],
                    "logo_urls": [
                        {
                            "value": fixtures.IDP_1_UI_INFO_LOGO_URL,
                            "language": "en",
                        },
                        {
                            "value": fixtures.IDP_1_UI_INFO_LOGO_URL,
                            "language": "es",
                        },
                    ],
                }],
            },
        ),
        (
            "identity_provider_with_organization_display_name",
            [IDENTITY_PROVIDER_WITH_ORGANIZATION_DISPLAY_NAME],
            {
                "type":
                SAMLWebSSOAuthenticationProvider.FLOW_TYPE,
                "description":
                SAMLWebSSOAuthenticationProvider.NAME,
                "links": [{
                    "rel":
                    "authenticate",
                    "href":
                    "http://localhost/default/saml_authenticate?provider=SAML+2.0+Web+SSO&idp_entity_id=http%3A%2F%2Fidp2.hilbertteam.net%2Fidp%2Fshibboleth",
                    "display_names": [
                        {
                            "value": fixtures.
                            IDP_1_ORGANIZATION_EN_ORGANIZATION_DISPLAY_NAME,
                            "language": "en",
                        },
                        {
                            "value": fixtures.
                            IDP_1_ORGANIZATION_ES_ORGANIZATION_DISPLAY_NAME,
                            "language": "es",
                        },
                    ],
                    "descriptions": [],
                    "information_urls": [],
                    "privacy_statement_urls": [],
                    "logo_urls": [],
                }],
            },
        ),
        (
            "identity_provider_without_display_names_and_default_template",
            [
                IDENTITY_PROVIDER_WITHOUT_DISPLAY_NAMES,
                IDENTITY_PROVIDER_WITHOUT_DISPLAY_NAMES,
            ],
            {
                "type":
                SAMLWebSSOAuthenticationProvider.FLOW_TYPE,
                "description":
                SAMLWebSSOAuthenticationProvider.NAME,
                "links": [
                    {
                        "rel":
                        "authenticate",
                        "href":
                        "http://localhost/default/saml_authenticate?provider=SAML+2.0+Web+SSO&idp_entity_id=http%3A%2F%2Fidp1.hilbertteam.net%2Fidp%2Fshibboleth",
                        "display_names": [{
                            "value":
                            SAMLConfiguration.
                            IDP_DISPLAY_NAME_DEFAULT_TEMPLATE.format(1),
                            "language":
                            "en",
                        }],
                        "descriptions": [],
                        "information_urls": [],
                        "privacy_statement_urls": [],
                        "logo_urls": [],
                    },
                    {
                        "rel":
                        "authenticate",
                        "href":
                        "http://localhost/default/saml_authenticate?provider=SAML+2.0+Web+SSO&idp_entity_id=http%3A%2F%2Fidp1.hilbertteam.net%2Fidp%2Fshibboleth",
                        "display_names": [{
                            "value":
                            SAMLConfiguration.
                            IDP_DISPLAY_NAME_DEFAULT_TEMPLATE.format(2),
                            "language":
                            "en",
                        }],
                        "descriptions": [],
                        "information_urls": [],
                        "privacy_statement_urls": [],
                        "logo_urls": [],
                    },
                ],
            },
        ),
    ])
    def test_authentication_document(self, _, identity_providers,
                                     expected_result):
        # Arrange
        configuration = create_autospec(spec=SAMLConfiguration)
        configuration.get_service_provider = MagicMock(
            return_value=SERVICE_PROVIDER)
        configuration.get_identity_providers = MagicMock(
            return_value=identity_providers)
        configuration.patron_id_use_name_id = "true"
        configuration.patron_id_attributes = []
        configuration.patron_id_regular_expression = None

        configuration_factory_create_context_manager = MagicMock()
        configuration_factory_create_context_manager.__enter__ = MagicMock(
            return_value=configuration)

        configuration_factory = create_autospec(spec=SAMLConfigurationFactory)
        configuration_factory.create = MagicMock(
            return_value=configuration_factory_create_context_manager)

        onelogin_configuration = SAMLOneLoginConfiguration(configuration)
        subject_parser = SAMLSubjectParser()
        parser = DSLParser()
        visitor = DSLEvaluationVisitor()
        evaluator = DSLEvaluator(parser, visitor)
        subject_filter = SAMLSubjectFilter(evaluator)
        authentication_manager = SAMLAuthenticationManager(
            onelogin_configuration, subject_parser, subject_filter)

        authentication_manager_factory = create_autospec(
            spec=SAMLAuthenticationManagerFactory)
        authentication_manager_factory.create = MagicMock(
            return_value=authentication_manager)

        with patch("api.saml.provider.SAMLAuthenticationManagerFactory"
                   ) as authentication_manager_factory_constructor_mock, patch(
                       "api.saml.provider.SAMLConfigurationFactory"
                   ) as configuration_factory_constructor_mock:
            authentication_manager_factory_constructor_mock.return_value = (
                authentication_manager_factory)
            configuration_factory_constructor_mock.return_value = configuration_factory

            # Act
            provider = SAMLWebSSOAuthenticationProvider(
                self._default_library, self._integration)

            self.app.config["SERVER_NAME"] = "localhost"

            with self.app.test_request_context("/"):
                result = provider.authentication_flow_document(self._db)

            # Assert
            assert expected_result == result

    @parameterized.expand([
        ("empty_subject", None,
         SAML_INVALID_SUBJECT.detailed("Subject is empty")),
        (
            "subject_is_patron_data",
            PatronData(permanent_id=12345),
            PatronData(permanent_id=12345),
        ),
        (
            "subject_does_not_have_unique_id",
            SAMLSubject(None, None),
            SAML_INVALID_SUBJECT.detailed("Subject does not have a unique ID"),
        ),
        (
            "subject_has_unique_id",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
        ),
        (
            "subject_has_unique_name_id_but_use_of_name_id_is_switched_off_using_integer_literal",
            SAMLSubject(
                SAMLNameID(SAMLNameIDFormat.UNSPECIFIED, "", "", "12345"),
                SAMLAttributeStatement([]),
            ),
            SAML_INVALID_SUBJECT.detailed("Subject does not have a unique ID"),
            0,
        ),
        (
            "subject_has_unique_name_id_but_use_of_name_id_is_switched_off_using_string_literal",
            SAMLSubject(
                SAMLNameID(SAMLNameIDFormat.UNSPECIFIED, "", "", "12345"),
                SAMLAttributeStatement([]),
            ),
            SAML_INVALID_SUBJECT.detailed("Subject does not have a unique ID"),
            "false",
        ),
        (
            "subject_has_unique_name_id_and_use_of_name_id_is_switched_on_using_string_literal_true",
            SAMLSubject(
                SAMLNameID(SAMLNameIDFormat.UNSPECIFIED, "", "", "12345"),
                SAMLAttributeStatement([]),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
            "true",
        ),
        (
            "subject_has_unique_id_matching_the_regular_expression",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonPrincipalName.name,
                        values=["*****@*****.**"],
                    )
                ]),
            ),
            PatronData(
                permanent_id="firstname.lastname",
                authorization_identifier="firstname.lastname",
                external_type="A",
                complete=True,
            ),
            False,
            [SAMLAttributeType.eduPersonPrincipalName.name],
            fixtures.PATRON_ID_REGULAR_EXPRESSION_ORG,
        ),
        (
            "subject_has_unique_id_not_matching_the_regular_expression",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonPrincipalName.name,
                        values=["*****@*****.**"],
                    )
                ]),
            ),
            SAML_INVALID_SUBJECT.detailed("Subject does not have a unique ID"),
            False,
            [SAMLAttributeType.eduPersonPrincipalName.name],
            fixtures.PATRON_ID_REGULAR_EXPRESSION_ORG,
        ),
    ])
    def test_remote_patron_lookup(
        self,
        _,
        subject,
        expected_result,
        patron_id_use_name_id=None,
        patron_id_attributes=None,
        patron_id_regular_expression=None,
    ):
        # Arrange
        with self._configuration_factory.create(
                self._configuration_storage, self._db,
                SAMLConfiguration) as configuration:
            if patron_id_use_name_id is not None:
                configuration.patron_id_use_name_id = patron_id_use_name_id
            if patron_id_attributes is not None:
                configuration.patron_id_attributes = json.dumps(
                    patron_id_attributes)
            if patron_id_regular_expression is not None:
                configuration.patron_id_regular_expression = (
                    patron_id_regular_expression)

        provider = SAMLWebSSOAuthenticationProvider(self._default_library,
                                                    self._integration)

        # Act
        result = provider.remote_patron_lookup(subject)

        # Assert
        if isinstance(result, ProblemDetail):
            assert result.response == expected_result.response
        else:
            assert result == expected_result

    @parameterized.expand([
        ("empty_subject", None,
         SAML_INVALID_SUBJECT.detailed("Subject is empty")),
        (
            "subject_does_not_have_unique_id",
            SAMLSubject(None, None),
            SAML_INVALID_SUBJECT.detailed("Subject does not have a unique ID"),
        ),
        (
            "subject_has_unique_id",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
        ),
        (
            "subject_has_unique_id_and_persistent_name_id",
            SAMLSubject(
                SAMLNameID(
                    SAMLNameIDFormat.PERSISTENT.value,
                    "name-qualifier",
                    "sp-name-qualifier",
                    "12345",
                ),
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
            None,
        ),
        (
            "subject_has_unique_id_and_transient_name_id",
            SAMLSubject(
                SAMLNameID(
                    SAMLNameIDFormat.TRANSIENT.value,
                    "name-qualifier",
                    "sp-name-qualifier",
                    "12345",
                ),
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
            '{"attributes": {"eduPersonUniqueId": ["12345"]}}',
        ),
        (
            "subject_has_unique_id_and_custom_session_lifetime",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
            None,
            datetime_utc(2020, 1, 1) + datetime.timedelta(days=42),
            42,
        ),
        (
            "subject_has_unique_id_and_empty_session_lifetime",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
            None,
            None,
            "",
        ),
        (
            "subject_has_unique_id_and_non_default_expiration_timeout",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
                valid_till=datetime.timedelta(days=1),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
        ),
        (
            "subject_has_unique_id_non_default_expiration_timeout_and_custom_session_lifetime",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
                valid_till=datetime.timedelta(days=1),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
            None,
            datetime_utc(2020, 1, 1) + datetime.timedelta(days=42),
            42,
        ),
        (
            "subject_has_unique_id_non_default_expiration_timeout_and_empty_session_lifetime",
            SAMLSubject(
                None,
                SAMLAttributeStatement([
                    SAMLAttribute(
                        name=SAMLAttributeType.eduPersonUniqueId.name,
                        values=["12345"],
                    )
                ]),
                valid_till=datetime.timedelta(days=1),
            ),
            PatronData(
                permanent_id="12345",
                authorization_identifier="12345",
                external_type="A",
                complete=True,
            ),
            None,
            None,
            "",
        ),
    ])
    @freeze_time("2020-01-01 00:00:00")
    def test_saml_callback(
        self,
        _,
        subject,
        expected_patron_data,
        expected_credential=None,
        expected_expiration_time=None,
        cm_session_lifetime=None,
    ):
        # This test makes sure that SAMLWebSSOAuthenticationProvider.saml_callback
        # correctly processes a SAML subject and returns right PatronData.

        # Arrange
        provider = SAMLWebSSOAuthenticationProvider(self._default_library,
                                                    self._integration)

        if expected_credential is None:
            expected_credential = json.dumps(subject,
                                             cls=SAMLSubjectJSONEncoder)

        if expected_expiration_time is None and subject is not None:
            expected_expiration_time = utc_now() + subject.valid_till

        if cm_session_lifetime is not None:
            with self._configuration_factory.create(
                    self._configuration_storage, self._db,
                    SAMLConfiguration) as configuration:
                configuration.session_lifetime = cm_session_lifetime

        # Act
        result = provider.saml_callback(self._db, subject)

        # Assert
        if isinstance(result, ProblemDetail):
            assert result.response == expected_patron_data.response
        else:
            credential, patron, patron_data = result

            assert expected_credential == credential.credential
            assert expected_patron_data.permanent_id == patron.external_identifier
            assert expected_patron_data == patron_data
            assert expected_expiration_time == credential.expires
Example #16
0
class TestSAMLWebSSOAuthenticationProvider(ControllerTest):
    @parameterized.expand([
        ('identity_provider_with_display_name', [
            IDENTITY_PROVIDER_WITH_DISPLAY_NAME
        ], {
            'type':
            SAMLWebSSOAuthenticationProvider.FLOW_TYPE,
            'description':
            SAMLWebSSOAuthenticationProvider.NAME,
            'links': [{
                'rel':
                'authenticate',
                'href':
                'http://localhost/default/saml_authenticate?idp_entity_id=http%3A%2F%2Fidp2.hilbertteam.net%2Fidp%2Fshibboleth&provider=SAML+2.0+Web+SSO',
                'display_names': [
                    {
                        'value': fixtures.IDP_1_UI_INFO_EN_DISPLAY_NAME,
                        'language': 'en'
                    }, {
                        'value': fixtures.IDP_1_UI_INFO_ES_DISPLAY_NAME,
                        'language': 'es'
                    }
                ],
                'descriptions': [
                    {
                        'value': fixtures.IDP_1_UI_INFO_DESCRIPTION,
                        'language': 'en'
                    }, {
                        'value':
                        fixtures.
                        IDP_1_UI_INFO_DESCRIPTION,
                        'language': 'es'
                    }
                ],
                'information_urls': [
                    {
                        'value': fixtures.IDP_1_UI_INFO_INFORMATION_URL,
                        'language': 'en'
                    }, {
                        'value': fixtures.IDP_1_UI_INFO_INFORMATION_URL,
                        'language': 'es'
                    }
                ],
                'privacy_statement_urls': [
                    {
                        'value': fixtures.IDP_1_UI_INFO_PRIVACY_STATEMENT_URL,
                        'language': 'en'
                    }, {
                        'value': fixtures.IDP_1_UI_INFO_PRIVACY_STATEMENT_URL,
                        'language': 'es'
                    }
                ],
                'logo_urls': [
                    {
                        'value': fixtures.IDP_1_UI_INFO_LOGO_URL,
                        'language': 'en'
                    }, {
                        'value': fixtures.IDP_1_UI_INFO_LOGO_URL,
                        'language': 'es'
                    }
                ]
            }]
        }),
        ('identity_provider_with_organization_display_name', [
            IDENTITY_PROVIDER_WITH_ORGANIZATION_DISPLAY_NAME
        ], {
            'type':
            SAMLWebSSOAuthenticationProvider.FLOW_TYPE,
            'description':
            SAMLWebSSOAuthenticationProvider.NAME,
            'links': [{
                'rel':
                'authenticate',
                'href':
                'http://localhost/default/saml_authenticate?idp_entity_id=http%3A%2F%2Fidp2.hilbertteam.net%2Fidp%2Fshibboleth&provider=SAML+2.0+Web+SSO',
                'display_names': [{
                    'value':
                    fixtures.IDP_1_ORGANIZATION_EN_ORGANIZATION_DISPLAY_NAME,
                    'language': 'en'
                }, {
                    'value':
                    fixtures.IDP_1_ORGANIZATION_ES_ORGANIZATION_DISPLAY_NAME,
                    'language': 'es'
                }],
                'descriptions': [],
                'information_urls': [],
                'privacy_statement_urls': [],
                'logo_urls': []
            }]
        }),
        ('identity_provider_without_display_names_and_default_template', [
            IDENTITY_PROVIDER_WITHOUT_DISPLAY_NAMES,
            IDENTITY_PROVIDER_WITHOUT_DISPLAY_NAMES
        ], {
            'type':
            SAMLWebSSOAuthenticationProvider.FLOW_TYPE,
            'description':
            SAMLWebSSOAuthenticationProvider.NAME,
            'links': [{
                'rel':
                'authenticate',
                'href':
                'http://localhost/default/saml_authenticate?idp_entity_id=http%3A%2F%2Fidp1.hilbertteam.net%2Fidp%2Fshibboleth&provider=SAML+2.0+Web+SSO',
                'display_names': [{
                    'value':
                    SAMLConfiguration.IDP_DISPLAY_NAME_DEFAULT_TEMPLATE.format(
                        1),
                    'language':
                    'en'
                }],
                'descriptions': [],
                'information_urls': [],
                'privacy_statement_urls': [],
                'logo_urls': []
            }, {
                'rel':
                'authenticate',
                'href':
                'http://localhost/default/saml_authenticate?idp_entity_id=http%3A%2F%2Fidp1.hilbertteam.net%2Fidp%2Fshibboleth&provider=SAML+2.0+Web+SSO',
                'display_names': [{
                    'value':
                    SAMLConfiguration.IDP_DISPLAY_NAME_DEFAULT_TEMPLATE.format(
                        2),
                    'language':
                    'en'
                }],
                'descriptions': [],
                'information_urls': [],
                'privacy_statement_urls': [],
                'logo_urls': []
            }]
        })
    ])
    def test_authentication_document(self, name, identity_providers,
                                     expected_result):
        # Arrange
        provider = SAMLWebSSOAuthenticationProvider(self._default_library,
                                                    self._integration)
        configuration = create_autospec(spec=SAMLConfiguration)
        configuration.get_debug = MagicMock(return_value=False)
        configuration.get_strict = MagicMock(return_value=False)
        configuration.get_service_provider = MagicMock(
            return_value=SERVICE_PROVIDER)
        configuration.get_identity_providers = MagicMock(
            return_value=identity_providers)
        onelogin_configuration = SAMLOneLoginConfiguration(configuration)
        authentication_manager = SAMLAuthenticationManager(
            onelogin_configuration, SAMLSubjectParser())

        authentication_manager_factory = create_autospec(
            spec=SAMLAuthenticationManagerFactory)
        authentication_manager_factory.create = MagicMock(
            return_value=authentication_manager)

        with patch('api.saml.provider.SAMLAuthenticationManagerFactory') \
                as authentication_manager_factory_constructor:
            authentication_manager_factory_constructor.return_value = authentication_manager_factory

            # Act
            with self.app.test_request_context('/'):
                result = provider.authentication_flow_document(self._db)

            # Assert
            eq_(result, expected_result)

    @parameterized.expand([
        ('empty_subject', None,
         SAML_INVALID_SUBJECT.detailed('Subject is empty')),
        ('subject_is_patron_data', PatronData(permanent_id=12345),
         PatronData(permanent_id=12345)),
        ('subject_does_not_have_unique_id', Subject(None, None),
         SAML_INVALID_SUBJECT.detailed('Subject does not have a unique ID')),
        ('subject_has_unique_id',
         Subject(
             None,
             AttributeStatement([
                 Attribute(name=SAMLAttributes.eduPersonUniqueId.name,
                           values=['12345'])
             ])),
         PatronData(permanent_id='12345',
                    authorization_identifier='12345',
                    external_type='A',
                    complete=True))
    ])
    def test_remote_patron_lookup(self, name, subject, expected_result):
        # Arrange
        provider = SAMLWebSSOAuthenticationProvider(self._default_library,
                                                    self._integration)

        # Act
        result = provider.remote_patron_lookup(subject)

        # Assert
        if isinstance(result, ProblemDetail):
            eq_(result.response, expected_result.response)
        else:
            eq_(result, expected_result)

    @parameterized.expand([
        ('empty_subject', None,
         SAML_INVALID_SUBJECT.detailed('Subject is empty')),
        ('subject_does_not_have_unique_id', Subject(None, None),
         SAML_INVALID_SUBJECT.detailed('Subject does not have a unique ID')),
        ('subject_has_unique_id',
         Subject(
             None,
             AttributeStatement([
                 Attribute(name=SAMLAttributes.eduPersonUniqueId.name,
                           values=['12345'])
             ])),
         PatronData(permanent_id='12345',
                    authorization_identifier='12345',
                    external_type='A',
                    complete=True)),
        ('subject_has_unique_id_and_non_default_expiration_timeout',
         Subject(None,
                 AttributeStatement([
                     Attribute(name=SAMLAttributes.eduPersonUniqueId.name,
                               values=['12345'])
                 ]),
                 valid_till=datetime.timedelta(days=1)),
         PatronData(permanent_id='12345',
                    authorization_identifier='12345',
                    external_type='A',
                    complete=True))
    ])
    @freeze_time("2020-01-01 00:00:00")
    def test_saml_callback(self, name, subject, expected_result):
        # Arrange
        provider = SAMLWebSSOAuthenticationProvider(self._default_library,
                                                    self._integration)
        expected_credential = json.dumps(subject, cls=SubjectJSONEncoder)

        # Act
        result = provider.saml_callback(self._db, subject)

        # Assert
        if isinstance(result, ProblemDetail):
            eq_(result.response, expected_result.response)
        else:
            credential, patron, patron_data = result

            eq_(credential.credential, expected_credential)
            eq_(patron.external_identifier, expected_result.permanent_id)
            eq_(patron_data, expected_result)
            eq_(credential.expires,
                datetime.datetime.utcnow() + subject.valid_till)