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
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
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)
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
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)
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
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
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
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
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)