def check_client_authorization(self, collection, client): """Verify that an IntegrationClient is whitelisted for access to the collection.""" external_library_urls = ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, collection.external_integration ).json_value if client.url not in [IntegrationClient.normalize_url(url) for url in external_library_urls]: raise AuthorizationFailedException()
def __init__(self, integration, library=None): _db = Session.object_session(integration) if not library: raise CannotLoadConfiguration("Google Analytics can't be configured without a library.") url_setting = ConfigurationSetting.for_externalintegration(ExternalIntegration.URL, integration) self.url = url_setting.value or self.DEFAULT_URL self.tracking_id = ConfigurationSetting.for_library_and_externalintegration( _db, self.TRACKING_ID, library, integration, ).value if not self.tracking_id: raise CannotLoadConfiguration("Missing tracking id for library %s" % library.short_name)
def setup(self): super(TestSharedCollectionAPI, self).setup() self.collection = self._collection(protocol="Mock") self.shared_collection = SharedCollectionAPI(self._db, api_map={"Mock": MockAPI}) self.api = self.shared_collection.api(self.collection) ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, self.collection.external_integration).value = json.dumps( ["http://library.org"]) self.client, ignore = IntegrationClient.register( self._db, "http://library.org") edition, self.pool = self._edition(with_license_pool=True, collection=self.collection) [self.delivery_mechanism] = self.pool.delivery_mechanisms
def save(self, db, setting_name, value): """Save the value as as a new configuration setting :param db: Database session :type db: sqlalchemy.orm.session.Session :param setting_name: Name of the library's configuration setting :type setting_name: string :param value: Value to be saved :type value: Any """ integration = self._integration_owner.external_integration(db) ConfigurationSetting.for_externalintegration( setting_name, integration).value = value
def setup(self): super(TestSharedCollectionAPI, self).setup() self.collection = self._collection(protocol="Mock") self.shared_collection = SharedCollectionAPI( self._db, api_map = { "Mock" : MockAPI } ) self.api = self.shared_collection.api(self.collection) ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, self.collection.external_integration ).value = json.dumps(["http://library.org"]) self.client, ignore = IntegrationClient.register(self._db, "http://library.org") edition, self.pool = self._edition( with_license_pool=True, collection=self.collection ) [self.delivery_mechanism] = self.pool.delivery_mechanisms
def load(self, db, setting_name): """Loads and returns the library's configuration setting :param db: Database session :type db: sqlalchemy.orm.session.Session :param setting_name: Name of the library's configuration setting :type setting_name: string :return: Any """ integration = self._integration_owner.external_integration(db) value = ConfigurationSetting.for_externalintegration( setting_name, integration).value return value
def __init__(self, integration, library=None): _db = Session.object_session(integration) if not library: raise CannotLoadConfiguration( "Google Analytics can't be configured without a library.") url_setting = ConfigurationSetting.for_externalintegration( ExternalIntegration.URL, integration) self.url = url_setting.value or self.DEFAULT_URL self.tracking_id = ConfigurationSetting.for_library_and_externalintegration( _db, self.TRACKING_ID, library, integration, ).value if not self.tracking_id: raise CannotLoadConfiguration( "Missing tracking id for library %s" % library.short_name)
def test_load_settings_correctly_loads_menu_values(self): # Arrange manager = create_autospec(spec=CirculationManager) manager._db = PropertyMock(return_value=self._db) controller = CollectionSettingsController(manager) # We'll be using affiliation_attributes configuration setting defined in the ProQuest integration. affiliation_attributes_key = ( ProQuestOPDS2ImporterConfiguration.affiliation_attributes.key) expected_affiliation_attributes = [ SAMLAttributeType.eduPersonPrincipalName.name, SAMLAttributeType.eduPersonScopedAffiliation.name, ] protocol_settings = [ ProQuestOPDS2ImporterConfiguration.affiliation_attributes. to_settings() ] collection_settings = None collection = self._default_collection # We need to explicitly set the value of "affiliation_attributes" configuration setting. ConfigurationSetting.for_externalintegration( affiliation_attributes_key, collection.external_integration).value = json.dumps( expected_affiliation_attributes) # Act settings = controller.load_settings(protocol_settings, collection, collection_settings) # Assert assert True == (affiliation_attributes_key in settings) # We want to make sure that the result setting array contains a correct value in a list format. saved_affiliation_attributes = settings[affiliation_attributes_key] assert expected_affiliation_attributes == saved_affiliation_attributes
def push(self, stage, url_for, catalog_url=None, do_get=HTTP.debuggable_get, do_post=HTTP.debuggable_post, key=None): """Attempt to register a library with a RemoteRegistry. NOTE: this method does a database commit (by calling _set_public_key) so that when the remote registry asks for the library's Authentication For OPDS document, the public key is found and included in that document. NOTE: This method is designed to be used in a controller. Other callers may use this method, but they must be able to render a ProblemDetail when there's a failure. NOTE: The application server must be running when this method is called, because part of the OPDS Directory Registration Protocol is the remote server retrieving the library's Authentication For OPDS document. :param stage: Either TESTING_STAGE or PRODUCTION_STAGE :param url_for: Flask url_for() or equivalent, used to generate URLs for the application server. :param do_get: Mockable method to make a GET request. :param do_post: Mockable method to make a POST request. :param key: Pass in an RsaKey object to use a specific public key rather than generating a new one. :return: A ProblemDetail if there was a problem; otherwise True. """ # Assume that the registration will fail. # # TODO: If a registration has previously succeeded, failure to # re-register probably means a maintenance of the status quo, # not a change of success to failure. But we don't have any way # of being sure. self.status_field.value = self.FAILURE_STATUS if stage not in self.VALID_REGISTRATION_STAGES: return INVALID_INPUT.detailed( _("%r is not a valid registration stage") % stage) # Before we can start the registration protocol, we must fetch # the remote catalog's URL and extract the link to the # registration resource that kicks off the protocol. catalog_url = catalog_url or self.integration.url response = do_get(catalog_url) if isinstance(response, ProblemDetail): return response result = self._extract_catalog_information(response) if isinstance(result, ProblemDetail): return result register_url, vendor_id = result # Store the vendor id as a ConfigurationSetting on the integration # -- it'll be the same value for all libraries. if vendor_id: ConfigurationSetting.for_externalintegration( AuthdataUtility.VENDOR_ID_KEY, self.integration).value = vendor_id # Set a public key for the library. encryptor = self._set_public_key(key) if isinstance(encryptor, ProblemDetail): return encryptor # Build the document we'll be sending to the registration URL. payload = self._create_registration_payload(url_for, stage) if isinstance(payload, ProblemDetail): return payload # Send the document. response = self._send_registration_request(register_url, payload, do_post) if isinstance(response, ProblemDetail): return response catalog = json.loads(response.content) # Process the result. return self._process_registration_result(catalog, encryptor, stage)
def test_register(self): # An auth document URL is required to register. assert_raises(InvalidInputException, self.shared_collection.register, self.collection, None) # If the url doesn't return a valid auth document, there's an exception. auth_response = "not json" def do_get(*args, **kwargs): return MockRequestsResponse(200, content=auth_response) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # The auth document also must have a link to the library's catalog. auth_response = json.dumps({"links": []}) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # If no external library URLs are configured, no one can register. auth_response = json.dumps( {"links": [{ "href": "http://library.org", "rel": "start" }]}) ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, self.collection.external_integration).value = None assert_raises(AuthorizationFailedException, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # If the library's URL isn't in the configuration, it can't register. auth_response = json.dumps({ "links": [{ "href": "http://differentlibrary.org", "rel": "start" }] }) ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, self.collection.external_integration).value = json.dumps( ["http://library.org"]) assert_raises(AuthorizationFailedException, self.shared_collection.register, self.collection, "http://differentlibrary.org/auth", do_get=do_get) # Or if the public key is missing from the auth document. auth_response = json.dumps( {"links": [{ "href": "http://library.org", "rel": "start" }]}) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) auth_response = json.dumps({ "public_key": { "type": "not RSA", "value": "123" }, "links": [{ "href": "http://library.org", "rel": "start" }] }) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) auth_response = json.dumps({ "public_key": { "type": "RSA" }, "links": [{ "href": "http://library.org", "rel": "start" }] }) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # Here's an auth document with a valid key. key = RSA.generate(2048) public_key = key.publickey().exportKey() encryptor = PKCS1_OAEP.new(key) auth_response = json.dumps({ "public_key": { "type": "RSA", "value": public_key }, "links": [{ "href": "http://library.org", "rel": "start" }] }) response = self.shared_collection.register(self.collection, "http://library.org/auth", do_get=do_get) # An IntegrationClient has been created. client = get_one( self._db, IntegrationClient, url=IntegrationClient.normalize_url("http://library.org/")) decrypted_secret = encryptor.decrypt( base64.b64decode( response.get("metadata", {}).get("shared_secret"))) eq_(client.shared_secret, decrypted_secret)
def test_push(self): # Test the other methods orchestrated by the push() method. class MockRegistry(RemoteRegistry): def fetch_catalog(self, catalog_url, do_get): # Pretend to fetch a root catalog and extract a # registration URL from it. self.fetch_catalog_called_with = (catalog_url, do_get) return "register_url", "vendor_id" class MockRegistration(Registration): def _create_registration_payload(self, url_for, stage): self.payload_ingredients = (url_for, stage) return dict(payload="this is it") def _create_registration_headers(self): self._create_registration_headers_called = True return dict(Header="Value") def _send_registration_request(self, register_url, headers, payload, do_post): self._send_registration_request_called_with = ( register_url, headers, payload, do_post, ) return MockRequestsResponse(200, content=json.dumps("you did it!")) def _process_registration_result(self, catalog, encryptor, stage): self._process_registration_result_called_with = ( catalog, encryptor, stage, ) return "all done!" # If there is no preexisting key pair set up for the library, # registration fails. (This normally won't happen because the # key pair is set up when the LibraryAuthenticator is # initialized.) library = self._default_library registry = MockRegistry(self.integration) registration = MockRegistration(registry, library) stage = Registration.TESTING_STAGE url_for = object() catalog_url = "http://catalog/" do_get = object() do_post = object() def push(): return registration.push(stage, url_for, catalog_url, do_get, do_post) result = push() expect = "Library %s has no key pair set." % library.short_name assert expect == result.detail # When a key pair is present, registration is kicked off, and # in this case it succeeds. key_pair_setting = ConfigurationSetting.for_library( Configuration.KEY_PAIR, library) public_key, private_key = Configuration.key_pair(key_pair_setting) result = push() assert "all done!" == result # But there were many steps towards this result. # First, MockRegistry.fetch_catalog() was called, in an attempt # to find the registration URL inside the root catalog. assert (catalog_url, do_get) == registry.fetch_catalog_called_with # fetch_catalog() returned a registration URL and # a vendor ID. The registration URL was used later on... # # The vendor ID was set as a ConfigurationSetting on # the ExternalIntegration associated with this registry. assert ("vendor_id" == ConfigurationSetting.for_externalintegration( AuthdataUtility.VENDOR_ID_KEY, self.integration).value) # _create_registration_payload was called to create the body # of the registration request. assert (url_for, stage) == registration.payload_ingredients # _create_registration_headers was called to create the headers # sent along with the request. assert True == registration._create_registration_headers_called # Then _send_registration_request was called, POSTing the # payload to "register_url", the registration URL we got earlier. results = registration._send_registration_request_called_with assert ( "register_url", { "Header": "Value" }, dict(payload="this is it"), do_post, ) == results # Finally, the return value of that method was loaded as JSON # and passed into _process_registration_result, along with # a cipher created from the private key. (That cipher would be used # to decrypt anything the foreign site signed using this site's # public key.) results = registration._process_registration_result_called_with message, cipher, actual_stage = results assert "you did it!" == message assert cipher._key.exportKey().decode("utf-8") == private_key assert actual_stage == stage # If a nonexistent stage is provided a ProblemDetail is the result. result = registration.push("no such stage", url_for, catalog_url, do_get, do_post) assert INVALID_INPUT.uri == result.uri assert "'no such stage' is not a valid registration stage" == result.detail # Now in reverse order, let's replace the mocked methods so # that they return ProblemDetail documents. This tests that if # there is a failure at any stage, the ProblemDetail is # propagated. # The push() function will no longer push anything, so rename it. cause_problem = push def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not process registration result") registration._process_registration_result = fail problem = cause_problem() assert "could not process registration result" == problem.detail def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not send registration request") registration._send_registration_request = fail problem = cause_problem() assert "could not send registration request" == problem.detail def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not create registration payload") registration._create_registration_payload = fail problem = cause_problem() assert "could not create registration payload" == problem.detail def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed("could not fetch catalog") registry.fetch_catalog = fail problem = cause_problem() assert "could not fetch catalog" == problem.detail
def from_config(cls, library, _db=None): """Initialize an AuthdataUtility from site configuration. :return: An AuthdataUtility if one is configured; otherwise None. :raise CannotLoadConfiguration: If an AuthdataUtility is incompletely configured. """ _db = _db or Session.object_session(library) if not _db: raise ValueError( "No database connection provided and could not derive one from Library object!" ) # Use a version of the library library = _db.merge(library, load=False) # Try to find an external integration with a configured Vendor ID. integrations = _db.query(ExternalIntegration).outerjoin( ExternalIntegration.libraries).filter( ExternalIntegration.protocol == ExternalIntegration.OPDS_REGISTRATION, ExternalIntegration.goal == ExternalIntegration.DISCOVERY_GOAL, Library.id == library.id) integration = None for possible_integration in integrations: vendor_id = ConfigurationSetting.for_externalintegration( cls.VENDOR_ID_KEY, possible_integration).value if vendor_id: integration = possible_integration break library_uri = ConfigurationSetting.for_library( Configuration.WEBSITE_URL, library).value if not integration: return None vendor_id = integration.setting(cls.VENDOR_ID_KEY).value library_short_name = ConfigurationSetting.for_library_and_externalintegration( _db, ExternalIntegration.USERNAME, library, integration).value secret = ConfigurationSetting.for_library_and_externalintegration( _db, ExternalIntegration.PASSWORD, library, integration).value other_libraries = None adobe_integration = ExternalIntegration.lookup( _db, ExternalIntegration.ADOBE_VENDOR_ID, ExternalIntegration.DRM_GOAL, library=library) if adobe_integration: other_libraries = adobe_integration.setting( cls.OTHER_LIBRARIES_KEY).json_value other_libraries = other_libraries or dict() if (not vendor_id or not library_uri or not library_short_name or not secret): raise CannotLoadConfiguration( "Short Client Token configuration is incomplete. " "vendor_id, username, password and " "Library website_url must all be defined.") if '|' in library_short_name: raise CannotLoadConfiguration( "Library short name cannot contain the pipe character.") return cls(vendor_id, library_uri, library_short_name, secret, other_libraries)
def test_push(self): """Test the other methods orchestrated by the push() method. """ class Mock(Registration): def _extract_catalog_information(self, response): self.initial_catalog_response = response return "register_url", "vendor_id" def _create_registration_payload(self, url_for, stage): self.payload_ingredients = (url_for, stage) return dict(payload="this is it") def _create_registration_headers(self): self._create_registration_headers_called = True return dict(Header="Value") def _send_registration_request(self, register_url, headers, payload, do_post): self._send_registration_request_called_with = (register_url, headers, payload, do_post) return MockRequestsResponse(200, content=json.dumps("you did it!")) def _process_registration_result(self, catalog, encryptor, stage): self._process_registration_result_called_with = (catalog, encryptor, stage) return "all done!" def mock_do_get(self, url): self.do_get_called_with = url return "A fake catalog" # If there is no preexisting key pair set up for the library, # registration fails. (This normally won't happen because the # key pair is set up when the LibraryAuthenticator is # initialized. library = self._default_library registration = Mock(self.registry, library) stage = Registration.TESTING_STAGE url_for = object() catalog_url = "http://catalog/" do_post = object() def push(): return registration.push(stage, url_for, catalog_url, registration.mock_do_get, do_post) result = push() expect = "Library %s has no key pair set." % library.short_name eq_(expect, result.detail) # When a key pair is present, registration is kicked off, and # in this case it succeeds. key_pair_setting = ConfigurationSetting.for_library( Configuration.KEY_PAIR, library) public_key, private_key = Configuration.key_pair(key_pair_setting) result = registration.push(stage, url_for, catalog_url, registration.mock_do_get, do_post) eq_("all done!", result) # But there were many steps towards this result. # First, do_get was called on the catalog URL. eq_(catalog_url, registration.do_get_called_with) # Then, the catalog was passed into _extract_catalog_information. eq_("A fake catalog", registration.initial_catalog_response) # _extract_catalog_information returned a registration URL and # a vendor ID. The registration URL was used later on... # # The vendor ID was set as a ConfigurationSetting on # the ExternalIntegration associated with this registry. eq_( "vendor_id", ConfigurationSetting.for_externalintegration( AuthdataUtility.VENDOR_ID_KEY, self.integration).value) # _create_registration_payload was called to create the body # of the registration request. eq_((url_for, stage), registration.payload_ingredients) # _create_registration_headers was called to create the headers # sent along with the request. eq_(True, registration._create_registration_headers_called) # Then _send_registration_request was called, POSTing the # payload to "register_url", the registration URL we got earlier. results = registration._send_registration_request_called_with eq_(("register_url", { "Header": "Value" }, dict(payload="this is it"), do_post), results) # Finally, the return value of that method was loaded as JSON # and passed into _process_registration_result, along with # a cipher created from the private key. (That cipher would be used # to decrypt anything the foreign site signed using this site's # public key.) results = registration._process_registration_result_called_with message, cipher, actual_stage = results eq_("you did it!", message) eq_(cipher._key.exportKey(), private_key) eq_(actual_stage, stage) # If a nonexistent stage is provided a ProblemDetail is the result. result = registration.push("no such stage", url_for, catalog_url, registration.mock_do_get, do_post) eq_(INVALID_INPUT.uri, result.uri) eq_("'no such stage' is not a valid registration stage", result.detail) # Now in reverse order, let's replace the mocked methods so # that they return ProblemDetail documents. This tests that if # there is a failure at any stage, the ProblemDetail is # propagated. # The push() function will no longer push anything, so rename it. cause_problem = push def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not process registration result") registration._process_registration_result = fail problem = cause_problem() eq_("could not process registration result", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not send registration request") registration._send_registration_request = fail problem = cause_problem() eq_("could not send registration request", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not create registration payload") registration._create_registration_payload = fail problem = cause_problem() eq_("could not create registration payload", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not extract catalog information") registration._extract_catalog_information = fail problem = cause_problem() eq_("could not extract catalog information", problem.detail)
def push(self, stage, url_for, catalog_url=None, do_get=HTTP.debuggable_get, do_post=HTTP.debuggable_post): """Attempt to register a library with a RemoteRegistry. NOTE: This method is designed to be used in a controller. Other callers may use this method, but they must be able to render a ProblemDetail when there's a failure. NOTE: The application server must be running when this method is called, because part of the OPDS Directory Registration Protocol is the remote server retrieving the library's Authentication For OPDS document. :param stage: Either TESTING_STAGE or PRODUCTION_STAGE :param url_for: Flask url_for() or equivalent, used to generate URLs for the application server. :param do_get: Mockable method to make a GET request. :param do_post: Mockable method to make a POST request. :return: A ProblemDetail if there was a problem; otherwise True. """ # Assume that the registration will fail. # # TODO: If a registration has previously succeeded, failure to # re-register probably means a maintenance of the status quo, # not a change of success to failure. But we don't have any way # of being sure. self.status_field.value = self.FAILURE_STATUS if stage not in self.VALID_REGISTRATION_STAGES: return INVALID_INPUT.detailed( _("%r is not a valid registration stage") % stage ) # Verify that a public/private key pair exists for this library. # This key pair is created during initialization of the # LibraryAuthenticator, so this should always be present. # # We can't just create the key pair here because the process # of pushing a registration involves the other site making a # request to the circulation manager. This means the key pair # needs to be committed to the database _before_ the push # attempt starts. key_pair = ConfigurationSetting.for_library( Configuration.KEY_PAIR, self.library).json_value if not key_pair: # TODO: We could create the key pair _here_. The database # session will be committed at the end of this request, # so the push attempt would succeed if repeated. return SHARED_SECRET_DECRYPTION_ERROR.detailed( _("Library %(library)s has no key pair set.", library=self.library.short_name) ) public_key, private_key = key_pair cipher = Configuration.cipher(private_key) # Before we can start the registration protocol, we must fetch # the remote catalog's URL and extract the link to the # registration resource that kicks off the protocol. catalog_url = catalog_url or self.integration.url response = do_get(catalog_url) if isinstance(response, ProblemDetail): return response result = self._extract_catalog_information(response) if isinstance(result, ProblemDetail): return result register_url, vendor_id = result # Store the vendor id as a ConfigurationSetting on the integration # -- it'll be the same value for all libraries. if vendor_id: ConfigurationSetting.for_externalintegration( AuthdataUtility.VENDOR_ID_KEY, self.integration ).value = vendor_id # Build the document we'll be sending to the registration URL. payload = self._create_registration_payload(url_for, stage) if isinstance(payload, ProblemDetail): return payload headers = self._create_registration_headers() if isinstance(headers, ProblemDetail): return headers # Send the document. response = self._send_registration_request( register_url, headers, payload, do_post ) if isinstance(response, ProblemDetail): return response catalog = json.loads(response.content) # Process the result. return self._process_registration_result(catalog, cipher, stage)
def push(self, stage, url_for, catalog_url=None, do_get=HTTP.debuggable_get, do_post=HTTP.debuggable_post): """Attempt to register a library with a RemoteRegistry. NOTE: This method is designed to be used in a controller. Other callers may use this method, but they must be able to render a ProblemDetail when there's a failure. NOTE: The application server must be running when this method is called, because part of the OPDS Directory Registration Protocol is the remote server retrieving the library's Authentication For OPDS document. :param stage: Either TESTING_STAGE or PRODUCTION_STAGE :param url_for: Flask url_for() or equivalent, used to generate URLs for the application server. :param do_get: Mockable method to make a GET request. :param do_post: Mockable method to make a POST request. :return: A ProblemDetail if there was a problem; otherwise True. """ # Assume that the registration will fail. # # TODO: If a registration has previously succeeded, failure to # re-register probably means a maintenance of the status quo, # not a change of success to failure. But we don't have any way # of being sure. self.status_field.value = self.FAILURE_STATUS if stage not in self.VALID_REGISTRATION_STAGES: return INVALID_INPUT.detailed( _("%r is not a valid registration stage") % stage) # Verify that a public/private key pair exists for this library. # This key pair is created during initialization of the # LibraryAuthenticator, so this should always be present. # # We can't just create the key pair here because the process # of pushing a registration involves the other site making a # request to the circulation manager. This means the key pair # needs to be committed to the database _before_ the push # attempt starts. key_pair = ConfigurationSetting.for_library(Configuration.KEY_PAIR, self.library).json_value if not key_pair: # TODO: We could create the key pair _here_. The database # session will be committed at the end of this request, # so the push attempt would succeed if repeated. return SHARED_SECRET_DECRYPTION_ERROR.detailed( _("Library %(library)s has no key pair set.", library=self.library.short_name)) public_key, private_key = key_pair cipher = Configuration.cipher(private_key) # Before we can start the registration protocol, we must fetch # the remote catalog's URL and extract the link to the # registration resource that kicks off the protocol. catalog_url = catalog_url or self.integration.url response = do_get(catalog_url) if isinstance(response, ProblemDetail): return response result = self._extract_catalog_information(response) if isinstance(result, ProblemDetail): return result register_url, vendor_id = result # Store the vendor id as a ConfigurationSetting on the integration # -- it'll be the same value for all libraries. if vendor_id: ConfigurationSetting.for_externalintegration( AuthdataUtility.VENDOR_ID_KEY, self.integration).value = vendor_id # Build the document we'll be sending to the registration URL. payload = self._create_registration_payload(url_for, stage) if isinstance(payload, ProblemDetail): return payload headers = self._create_registration_headers() if isinstance(headers, ProblemDetail): return headers # Send the document. response = self._send_registration_request(register_url, headers, payload, do_post) if isinstance(response, ProblemDetail): return response catalog = json.loads(response.content) # Process the result. return self._process_registration_result(catalog, cipher, stage)
def register(self, collection, auth_document_url, do_get=HTTP.get_with_timeout): """Register a library on an external circulation manager for access to this collection. The library's auth document url must be whitelisted in the collection's settings.""" if not auth_document_url: raise InvalidInputException( _("An authentication document URL is required to register a library.") ) auth_response = do_get(auth_document_url, allowed_response_codes=["2xx", "3xx"]) try: auth_document = json.loads(auth_response.content) except ValueError as e: raise RemoteInitiatedServerError( _( "Authentication document at %(auth_document_url)s was not valid JSON.", auth_document_url=auth_document_url, ), _("Remote authentication document"), ) links = auth_document.get("links") start_url = None for link in links: if link.get("rel") == "start": start_url = link.get("href") break if not start_url: raise RemoteInitiatedServerError( _( "Authentication document at %(auth_document_url)s did not contain a start link.", auth_document_url=auth_document_url, ), _("Remote authentication document"), ) external_library_urls = ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, collection.external_integration, ).json_value if not external_library_urls or start_url not in external_library_urls: raise AuthorizationFailedException( _( "Your library's URL is not one of the allowed URLs for this collection. Ask the collection administrator to add %(library_url)s to the list of allowed URLs.", library_url=start_url, ) ) public_key = auth_document.get("public_key") if ( not public_key or not public_key.get("type") == "RSA" or not public_key.get("value") ): raise RemoteInitiatedServerError( _( "Authentication document at %(auth_document_url)s did not contain an RSA public key.", auth_document_url=auth_document_url, ), _("Remote authentication document"), ) public_key = public_key.get("value") encryptor = Configuration.cipher(public_key) normalized_url = IntegrationClient.normalize_url(start_url) client = get_one(self._db, IntegrationClient, url=normalized_url) if not client: client, ignore = IntegrationClient.register(self._db, start_url) shared_secret = client.shared_secret.encode("utf-8") encrypted_secret = encryptor.encrypt(shared_secret) return dict(metadata=dict(shared_secret=base64.b64encode(encrypted_secret)))
links = auth_document.get("links") start_url = None for link in links: if link.get("rel") == "start": start_url = link.get("href") break if not start_url: raise RemoteInitiatedServerError( _("Authentication document at %(auth_document_url)s did not contain a start link.", auth_document_url=auth_document_url), _("Remote authentication document")) external_library_urls = ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, collection.external_integration).json_value if not external_library_urls or start_url not in external_library_urls: raise AuthorizationFailedException( _("Your library's URL is not one of the allowed URLs for this collection. Ask the collection administrator to add %(library_url)s to the list of allowed URLs.", library_url=start_url)) public_key = auth_document.get("public_key") if not public_key or not public_key.get( "type") == "RSA" or not public_key.get("value"): raise RemoteInitiatedServerError( _("Authentication document at %(auth_document_url)s did not contain an RSA public key.", auth_document_url=auth_document_url), _("Remote authentication document"))
def from_config(cls, library: Library, _db=None): """Initialize an AuthdataUtility from site configuration. The library must be successfully registered with a discovery integration in order for that integration to be a candidate to provide configuration for the AuthdataUtility. :return: An AuthdataUtility if one is configured; otherwise None. :raise CannotLoadConfiguration: If an AuthdataUtility is incompletely configured. """ _db = _db or Session.object_session(library) if not _db: raise ValueError( "No database connection provided and could not derive one from Library object!" ) # Use a version of the library library = _db.merge(library, load=False) # Try to find an external integration with a configured Vendor ID. integrations = (_db.query(ExternalIntegration).outerjoin( ExternalIntegration.libraries).filter( ExternalIntegration.protocol == ExternalIntegration.OPDS_REGISTRATION, ExternalIntegration.goal == ExternalIntegration.DISCOVERY_GOAL, Library.id == library.id, )) for possible_integration in integrations: vendor_id = ConfigurationSetting.for_externalintegration( cls.VENDOR_ID_KEY, possible_integration).value registration_status = ( ConfigurationSetting.for_library_and_externalintegration( _db, RegistrationConstants.LIBRARY_REGISTRATION_STATUS, library, possible_integration, ).value) if (vendor_id and registration_status == RegistrationConstants.SUCCESS_STATUS): integration = possible_integration break else: return None library_uri = ConfigurationSetting.for_library( Configuration.WEBSITE_URL, library).value vendor_id = integration.setting(cls.VENDOR_ID_KEY).value library_short_name = ConfigurationSetting.for_library_and_externalintegration( _db, ExternalIntegration.USERNAME, library, integration).value secret = ConfigurationSetting.for_library_and_externalintegration( _db, ExternalIntegration.PASSWORD, library, integration).value other_libraries = None adobe_integration = ExternalIntegration.lookup( _db, ExternalIntegration.ADOBE_VENDOR_ID, ExternalIntegration.DRM_GOAL, library=library, ) if adobe_integration: other_libraries = adobe_integration.setting( cls.OTHER_LIBRARIES_KEY).json_value other_libraries = other_libraries or dict() if not vendor_id or not library_uri or not library_short_name or not secret: raise CannotLoadConfiguration( "Short Client Token configuration is incomplete. " "vendor_id (%s), username (%s), password (%s) and " "Library website_url (%s) must all be defined." % (vendor_id, library_uri, library_short_name, secret)) if "|" in library_short_name: raise CannotLoadConfiguration( "Library short name cannot contain the pipe character.") return cls(vendor_id, library_uri, library_short_name, secret, other_libraries)
def test_push(self): """Test the other methods orchestrated by the push() method. """ class Mock(Registration): def _extract_catalog_information(self, response): self.initial_catalog_response = response return "register_url", "vendor_id" def _set_public_key(self, key): self._set_public_key_called_with = key return "an encryptor" def _create_registration_payload(self, url_for, stage): self.payload_ingredients = (url_for, stage) return dict(payload="this is it") def _send_registration_request(self, register_url, payload, do_post): self._send_registration_request_called_with = (register_url, payload, do_post) return MockRequestsResponse(200, content=json.dumps("you did it!")) def _process_registration_result(self, catalog, encryptor, stage): self._process_registration_result_called_with = (catalog, encryptor, stage) return "all done!" def mock_do_get(self, url): self.do_get_called_with = url return "A fake catalog" # First of all, test success. registration = Mock(self.registry, self._default_library) stage = Registration.TESTING_STAGE url_for = object() catalog_url = "http://catalog/" do_post = object() key = object() result = registration.push(stage, url_for, catalog_url, registration.mock_do_get, do_post, key) # Ultimately the push succeeded. eq_("all done!", result) # But there were many steps towards this result. # First, do_get was called on the catalog URL. eq_(catalog_url, registration.do_get_called_with) # Then, the catalog was passed into _extract_catalog_information. eq_("A fake catalog", registration.initial_catalog_response) # _extract_catalog_information returned a registration URL and # a vendor ID. The registration URL was used later on... # # The vendor ID was set as a ConfigurationSetting on # the ExternalIntegration associated with this registry. eq_( "vendor_id", ConfigurationSetting.for_externalintegration( AuthdataUtility.VENDOR_ID_KEY, self.integration).value) # _set_public_key() was called to create an encryptor object. # It returned an encryptor (here mocked as the string "an encryptor") # to be used later. eq_(key, registration._set_public_key_called_with) # _create_registration_payload was called to create the body # of the registration request. eq_((url_for, stage), registration.payload_ingredients) # Then _send_registration_request was called, POSTing the # payload to "register_url", the registration URL we got earlier. results = registration._send_registration_request_called_with eq_(("register_url", dict(payload="this is it"), do_post), results) # Finally, the return value of that method was loaded as JSON # and passed into _process_registration_result, along with # the encryptor obtained from _set_public_key() results = registration._process_registration_result_called_with eq_(("you did it!", "an encryptor", stage), results) # If a nonexistent stage is provided a ProblemDetail is the result. result = registration.push("no such stage", url_for, catalog_url, registration.mock_do_get, do_post, key) eq_(INVALID_INPUT.uri, result.uri) eq_("'no such stage' is not a valid registration stage", result.detail) # Now in reverse order, let's replace the mocked methods so # that they return ProblemDetail documents. This tests that if # there is a failure at any stage, the ProblemDetail is # propagated. def cause_problem(): """Try the same method call that worked before; it won't work anymore. """ return registration.push(stage, url_for, catalog_url, registration.mock_do_get, do_post, key) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not process registration result") registration._process_registration_result = fail problem = cause_problem() eq_("could not process registration result", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not send registration request") registration._send_registration_request = fail problem = cause_problem() eq_("could not send registration request", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not create registration payload") registration._create_registration_payload = fail problem = cause_problem() eq_("could not create registration payload", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed("could not set public key") registration._set_public_key = fail problem = cause_problem() eq_("could not set public key", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not extract catalog information") registration._extract_catalog_information = fail problem = cause_problem() eq_("could not extract catalog information", problem.detail)
def test_push(self): """Test the other methods orchestrated by the push() method. """ class Mock(Registration): def _extract_catalog_information(self, response): self.initial_catalog_response = response return "register_url", "vendor_id" def _create_registration_payload(self, url_for, stage): self.payload_ingredients = (url_for, stage) return dict(payload="this is it") def _create_registration_headers(self): self._create_registration_headers_called = True return dict(Header="Value") def _send_registration_request( self, register_url, headers, payload, do_post ): self._send_registration_request_called_with = ( register_url, headers, payload, do_post ) return MockRequestsResponse( 200, content=json.dumps("you did it!") ) def _process_registration_result(self, catalog, encryptor, stage): self._process_registration_result_called_with = ( catalog, encryptor, stage ) return "all done!" def mock_do_get(self, url): self.do_get_called_with = url return "A fake catalog" # If there is no preexisting key pair set up for the library, # registration fails. (This normally won't happen because the # key pair is set up when the LibraryAuthenticator is # initialized. library = self._default_library registration = Mock(self.registry, library) stage = Registration.TESTING_STAGE url_for = object() catalog_url = "http://catalog/" do_post = object() def push(): return registration.push( stage, url_for, catalog_url, registration.mock_do_get, do_post ) result = push() expect = "Library %s has no key pair set." % library.short_name eq_(expect, result.detail) # When a key pair is present, registration is kicked off, and # in this case it succeeds. key_pair_setting = ConfigurationSetting.for_library( Configuration.KEY_PAIR, library ) public_key, private_key = Configuration.key_pair(key_pair_setting) result = registration.push( stage, url_for, catalog_url, registration.mock_do_get, do_post ) eq_("all done!", result) # But there were many steps towards this result. # First, do_get was called on the catalog URL. eq_(catalog_url, registration.do_get_called_with) # Then, the catalog was passed into _extract_catalog_information. eq_("A fake catalog", registration.initial_catalog_response) # _extract_catalog_information returned a registration URL and # a vendor ID. The registration URL was used later on... # # The vendor ID was set as a ConfigurationSetting on # the ExternalIntegration associated with this registry. eq_( "vendor_id", ConfigurationSetting.for_externalintegration( AuthdataUtility.VENDOR_ID_KEY, self.integration ).value ) # _create_registration_payload was called to create the body # of the registration request. eq_((url_for, stage), registration.payload_ingredients) # _create_registration_headers was called to create the headers # sent along with the request. eq_(True, registration._create_registration_headers_called) # Then _send_registration_request was called, POSTing the # payload to "register_url", the registration URL we got earlier. results = registration._send_registration_request_called_with eq_( ("register_url", {"Header": "Value"}, dict(payload="this is it"), do_post), results ) # Finally, the return value of that method was loaded as JSON # and passed into _process_registration_result, along with # a cipher created from the private key. (That cipher would be used # to decrypt anything the foreign site signed using this site's # public key.) results = registration._process_registration_result_called_with message, cipher, actual_stage = results eq_("you did it!", message) eq_(cipher._key.exportKey(), private_key) eq_(actual_stage, stage) # If a nonexistent stage is provided a ProblemDetail is the result. result = registration.push( "no such stage", url_for, catalog_url, registration.mock_do_get, do_post ) eq_(INVALID_INPUT.uri, result.uri) eq_("'no such stage' is not a valid registration stage", result.detail) # Now in reverse order, let's replace the mocked methods so # that they return ProblemDetail documents. This tests that if # there is a failure at any stage, the ProblemDetail is # propagated. # The push() function will no longer push anything, so rename it. cause_problem = push def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not process registration result" ) registration._process_registration_result = fail problem = cause_problem() eq_("could not process registration result", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not send registration request" ) registration._send_registration_request = fail problem = cause_problem() eq_("could not send registration request", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not create registration payload" ) registration._create_registration_payload = fail problem = cause_problem() eq_("could not create registration payload", problem.detail) def fail(*args, **kwargs): return INVALID_REGISTRATION.detailed( "could not extract catalog information" ) registration._extract_catalog_information = fail problem = cause_problem() eq_("could not extract catalog information", problem.detail)
links = auth_document.get("links") start_url = None for link in links: if link.get("rel") == "start": start_url = link.get("href") break if not start_url: raise RemoteInitiatedServerError( _("Authentication document at %(auth_document_url)s did not contain a start link.", auth_document_url=auth_document_url), _("Remote authentication document")) external_library_urls = ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, collection.external_integration ).json_value if not external_library_urls or start_url not in external_library_urls: raise AuthorizationFailedException( _("Your library's URL is not one of the allowed URLs for this collection. Ask the collection administrator to add %(library_url)s to the list of allowed URLs.", library_url=start_url)) public_key = auth_document.get("public_key") if not public_key or not public_key.get("type") == "RSA" or not public_key.get("value"): raise RemoteInitiatedServerError( _("Authentication document at %(auth_document_url)s did not contain an RSA public key.", auth_document_url=auth_document_url), _("Remote authentication document")) public_key = public_key.get("value")
def test_register(self): # An auth document URL is required to register. assert_raises(InvalidInputException, self.shared_collection.register, self.collection, None) # If the url doesn't return a valid auth document, there's an exception. auth_response = "not json" def do_get(*args, **kwargs): return MockRequestsResponse(200, content=auth_response) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # The auth document also must have a link to the library's catalog. auth_response = json.dumps({"links": []}) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # If no external library URLs are configured, no one can register. auth_response = json.dumps({"links": [{"href": "http://library.org", "rel": "start"}]}) ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, self.collection.external_integration ).value = None assert_raises(AuthorizationFailedException, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # If the library's URL isn't in the configuration, it can't register. auth_response = json.dumps({"links": [{"href": "http://differentlibrary.org", "rel": "start"}]}) ConfigurationSetting.for_externalintegration( BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, self.collection.external_integration ).value = json.dumps(["http://library.org"]) assert_raises(AuthorizationFailedException, self.shared_collection.register, self.collection, "http://differentlibrary.org/auth", do_get=do_get) # Or if the public key is missing from the auth document. auth_response = json.dumps({"links": [{"href": "http://library.org", "rel": "start"}]}) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) auth_response = json.dumps({"public_key": { "type": "not RSA", "value": "123" }, "links": [{"href": "http://library.org", "rel": "start"}]}) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) auth_response = json.dumps({"public_key": { "type": "RSA" }, "links": [{"href": "http://library.org", "rel": "start"}]}) assert_raises(RemoteInitiatedServerError, self.shared_collection.register, self.collection, "http://library.org/auth", do_get=do_get) # Here's an auth document with a valid key. key = RSA.generate(2048) public_key = key.publickey().exportKey() encryptor = PKCS1_OAEP.new(key) auth_response = json.dumps({"public_key": { "type": "RSA", "value": public_key }, "links": [{"href": "http://library.org", "rel": "start"}]}) response = self.shared_collection.register(self.collection, "http://library.org/auth", do_get=do_get) # An IntegrationClient has been created. client = get_one(self._db, IntegrationClient, url=IntegrationClient.normalize_url("http://library.org/")) decrypted_secret = encryptor.decrypt(base64.b64decode(response.get("metadata", {}).get("shared_secret"))) eq_(client.shared_secret, decrypted_secret)