def test_uuid_and_label_creates_delegatedpatronid_from_credential(self): # This patron once used the old system to create an Adobe # account ID which was stored in a Credential. For whatever # reason, the migration script did not give them a # DelegatedPatronIdentifier. adobe = self.data_source def set_value(credential): credential.credential = "A dummy value" old_style_credential = Credential.lookup( self._db, adobe, self.model.VENDOR_ID_UUID_TOKEN_TYPE, self.bob_patron, set_value, True ) # Now uuid_and_label works. uuid, label = self.model.uuid_and_label(self.bob_patron) eq_("A dummy value", uuid) eq_("Delegated account ID A dummy value", label) # There is now an anonymized identifier associated with Bob's # patron account. internal = DataSource.lookup(self._db, DataSource.INTERNAL_PROCESSING) bob_anonymized_identifier = Credential.lookup( self._db, internal, AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, self.bob_patron, None ) # That anonymized identifier is associated with a # DelegatedPatronIdentifier whose delegated_identifier is # taken from the old-style Credential. [bob_delegated_patron_identifier] = self._db.query( DelegatedPatronIdentifier).filter( DelegatedPatronIdentifier.patron_identifier ==bob_anonymized_identifier.credential ).all() eq_("A dummy value", bob_delegated_patron_identifier.delegated_identifier) # If the DelegatedPatronIdentifier and the Credential # have different values, the DelegatedPatronIdentifier wins. old_style_credential.credential = "A different value." uuid, label = self.model.uuid_and_label(self.bob_patron) eq_("A dummy value", uuid) # We can even delete the old-style Credential, and # uuid_and_label will still give the value that was stored in # it. self._db.delete(old_style_credential) self._db.commit() uuid, label = self.model.uuid_and_label(self.bob_patron) eq_("A dummy value", uuid)
def get_patron_credential(self, patron, pin): """Create an OAuth token for the given patron.""" def refresh(credential): return self.refresh_patron_access_token( credential, patron, pin) return Credential.lookup( self._db, DataSource.OVERDRIVE, "OAuth Token", patron, refresh)
def patron_remote_identifier(self, patron): """Locate the identifier for the given Patron's account on the RBdigital side, creating a new RBdigital account if necessary. The identifier is cached in a persistent Credential object. :return: The remote identifier for this patron, taken from the corresponding Credential. """ def refresher(credential): remote_identifier = self.patron_remote_identifier_lookup(patron) if not remote_identifier: remote_identifier = self.create_patron(patron) credential.credential = remote_identifier credential.expires = None _db = Session.object_session(patron) credential = Credential.lookup( _db, DataSource.RB_DIGITAL, Credential.IDENTIFIER_FROM_REMOTE_SERVICE, patron, refresher_method=refresher, allow_persistent_token=True) if not credential.credential: refresher(credential) return credential.credential
def test_authdata_token_credential_lookup_success(self): # Create an authdata token Credential for Bob. now = datetime.datetime.utcnow() token, ignore = Credential.persistent_token_create( self._db, self.data_source, self.model.AUTHDATA_TOKEN_TYPE, self.bob_patron) # The token is persistent. eq_(None, token.expires) # Use that token to perform a lookup of Bob's Adobe Vendor ID # UUID. urn, label = self.model.authdata_lookup(token.credential) # There is now an anonymized identifier associated with Bob's # patron account. internal = DataSource.lookup(self._db, DataSource.INTERNAL_PROCESSING) bob_anonymized_identifier = Credential.lookup( self._db, internal, AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, self.bob_patron, None) # That anonymized identifier is associated with a # DelegatedPatronIdentifier whose delegated_identifier is a # UUID. [bob_delegated_patron_identifier ] = self._db.query(DelegatedPatronIdentifier).filter( DelegatedPatronIdentifier.patron_identifier == bob_anonymized_identifier.credential).all() # That UUID is the one returned by authdata_lookup. eq_(urn, bob_delegated_patron_identifier.delegated_identifier)
def test_username_password_lookup_success(self): urn, label = self.model.standard_lookup(self.credentials) # There is now an anonymized identifier associated with Bob's # patron account. internal = DataSource.lookup(self._db, DataSource.INTERNAL_PROCESSING) bob_anonymized_identifier = Credential.lookup( self._db, internal, AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, self.bob_patron, None ) # That anonymized identifier is associated with a # DelegatedPatronIdentifier whose delegated_identifier is a # UUID. [bob_delegated_patron_identifier] = self._db.query( DelegatedPatronIdentifier).filter( DelegatedPatronIdentifier.patron_identifier ==bob_anonymized_identifier.credential ).all() eq_("Delegated account ID %s" % urn, label) eq_(urn, bob_delegated_patron_identifier.delegated_identifier) assert urn.startswith("urn:uuid:0") assert urn.endswith('685b35c00f05')
def uuid_and_label(self, patron): """Create or retrieve a Vendor ID UUID and human-readable Vendor ID label for the given patron. """ if not patron: return None, None def generate_uuid(credential): # This is the first time a credential has ever been # created for this patron. Set the value of the # credential to a new UUID. print "GENERATING NEW UUID" credential.credential = self.uuid() credential = Credential.lookup( self._db, self.data_source, self.VENDOR_ID_UUID_TOKEN_TYPE, patron, generate_uuid, True) identifier = patron.authorization_identifier if not identifier: # Maybe this should be an error, but even though the lack # of an authorization identifier is a problem, the problem # should manifest when the patron tries to actually use # their credential. return "Unknown card number.", "Unknown card number" return credential.credential, "Card number " + identifier
def test_create_authdata(self): credential = self.model.create_authdata(self.bob_patron) # There's now a persistent token associated with Bob's # patron account, and that's the token returned by create_authdata() bob_authdata = Credential.lookup( self._db, self.data_source, self.model.AUTHDATA_TOKEN_TYPE, self.bob_patron, None) eq_(credential.credential, bob_authdata.credential)
def test_create_authdata(self): credential = self.model.create_authdata(self.bob_patron) # There's now a temporary token associated with Bob's # patron account, and that's the token returned by create_authdata() bob_authdata = Credential.lookup( self._db, self.data_source, self.model.TEMPORARY_TOKEN_TYPE, self.bob_patron, None) eq_(credential.credential, bob_authdata.credential)
def token(self): if (self._credential and self._credential.expires > datetime.utcnow()): return self._credential.credential credential = Credential.lookup( self._db, self.source, None, None, self.refresh_credential ) return credential.credential
def _adobe_patron_identifier(self, patron): _db = Session.object_session(patron) internal = DataSource.lookup(_db, DataSource.INTERNAL_PROCESSING) def refresh(credential): credential.credential = str(uuid.uuid1()) patron_identifier = Credential.lookup( _db, internal, AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, patron, refresher_method=refresh, allow_persistent_token=True ) return patron_identifier.credential
def get_or_create_patron_identifier_credential(cls, patron): _db = Session.object_session(patron) def refresh(credential): credential.credential = str(uuid.uuid1()) data_source = DataSource.lookup(_db, DataSource.INTERNAL_PROCESSING) patron_identifier_credential = Credential.lookup( _db, data_source, AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, patron, refresher_method=refresh, allow_persistent_token=True ) return patron_identifier_credential
def test_standard_lookup_success(self): urn, label = self.model.standard_lookup(dict(username="******", password="******")) # There is now a UUID associated with Bob's patron account, # and that's the UUID returned by standard_lookup(). bob_uuid = Credential.lookup( self._db, self.data_source, self.model.VENDOR_ID_UUID_TOKEN_TYPE, self.bob_patron, None) eq_("Card number 5", label) eq_(urn, bob_uuid.credential) assert urn.startswith("urn:uuid:0") assert urn.endswith('685b35c00f05')
def test_process_patron(self): patron = self._patron() # This patron has old-style and new-style Credentials that link # them to Adobe account IDs (hopefully the same ID, though that # doesn't matter here. def set_value(credential): credential.value = "a credential" # Data source doesn't matter -- even if it's incorrect, a Credential # of the appropriate type will be deleted. data_source = DataSource.lookup(self._db, DataSource.OVERDRIVE) # Create two Credentials that will be deleted and one that will be # left alone. for type in (AdobeVendorIDModel.VENDOR_ID_UUID_TOKEN_TYPE, AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, "Some other type" ): credential = Credential.lookup( self._db, data_source, type, patron, set_value, True ) eq_(3, len(patron.credentials)) # Run the patron through the script. script = AdobeAccountIDResetScript(self._db) # A dry run does nothing. script.delete = False script.process_patron(patron) self._db.commit() eq_(3, len(patron.credentials)) # Now try it for real. script.delete = True script.process_patron(patron) self._db.commit() # The two Adobe-related credentials are gone. The other one remains. [credential] = patron.credentials eq_("Some other type", credential.type)
def test_authdata_lookup_success(self): now = datetime.datetime.utcnow() temp_token, ignore = Credential.temporary_token_create( self._db, self.data_source, self.model.TEMPORARY_TOKEN_TYPE, self.bob_patron, datetime.timedelta(seconds=60)) old_expires = temp_token.expires assert temp_token.expires > now urn, label = self.model.authdata_lookup(temp_token.credential) # There is now a UUID associated with Bob's patron account, # and that's the UUID returned by standard_lookup(). bob_uuid = Credential.lookup( self._db, self.data_source, self.model.VENDOR_ID_UUID_TOKEN_TYPE, self.bob_patron, None) eq_(urn, bob_uuid.credential) eq_("Card number 5", label) # Having been used once, the temporary token has been expired. assert temp_token.expires < now
def test_smuggled_authdata_credential_success(self): # Bob's client has created a persistent token to authenticate him. now = datetime.datetime.utcnow() token, ignore = Credential.persistent_token_create( self._db, self.data_source, self.model.AUTHDATA_TOKEN_TYPE, self.bob_patron ) # But Bob's client can't trigger the operation that will cause # Adobe to authenticate him via that token, so it passes in # the token credential as the 'username' and leaves the # password blank. urn, label = self.model.standard_lookup( dict(username=token.credential) ) # There is now an anonymized identifier associated with Bob's # patron account. internal = DataSource.lookup(self._db, DataSource.INTERNAL_PROCESSING) bob_anonymized_identifier = Credential.lookup( self._db, internal, AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, self.bob_patron, None ) # That anonymized identifier is associated with a # DelegatedPatronIdentifier whose delegated_identifier is a # UUID. [bob_delegated_patron_identifier] = self._db.query( DelegatedPatronIdentifier).filter( DelegatedPatronIdentifier.patron_identifier ==bob_anonymized_identifier.credential ).all() # That UUID is the one returned by standard_lookup. eq_(urn, bob_delegated_patron_identifier.delegated_identifier) # A future attempt to authenticate with the token will succeed. urn, label = self.model.standard_lookup( dict(username=token.credential) ) eq_(urn, bob_delegated_patron_identifier.delegated_identifier)
class OPDSForDistributorsAPI(BaseCirculationAPI): NAME = "OPDS for Distributors" DESCRIPTION = _("Import books from a distributor that requires authentication to get the OPDS feed and download books.") SETTINGS = OPDSImporter.SETTINGS + [ { "key": ExternalIntegration.USERNAME, "label": _("Library's username or access key"), }, { "key": ExternalIntegration.PASSWORD, "label": _("Library's password or secret key"), } ] SUPPORTED_MEDIA_TYPES = [Representation.EPUB_MEDIA_TYPE] delivery_mechanism_to_internal_format = { (type, DeliveryMechanism.NO_DRM): type for type in SUPPORTED_MEDIA_TYPES } def __init__(self, _db, collection): self.collection_id = collection.id self.data_source_name = collection.external_integration.setting(Collection.DATA_SOURCE_NAME_SETTING).value self.username = collection.external_integration.username self.password = collection.external_integration.password self.feed_url = collection.external_account_id self.auth_url = None def _request_with_timeout(self, method, url, *args, **kwargs): """Wrapper around HTTP.request_with_timeout to be overridden for tests.""" return HTTP.request_with_timeout(method, url, *args, **kwargs) def _get_token(self, _db): # If this is the first time we're getting a token, we # need to find the authenticate url in the OPDS # authentication document. if not self.auth_url: response = self._request_with_timeout('GET', self.feed_url) if response.status_code != 401: # This feed doesn't require authentication, so # we need to find a link to the authentication document. feed = feedparser.parse(response.content) links = feed.get('feed', {}).get('links', []) auth_doc_links = [l for l in links if l['rel'] == "http://opds-spec.org/auth/document"] if not auth_doc_links: raise LibraryAuthorizationFailedException() auth_doc_link = auth_doc_links[0].get("href") response = self._request_with_timeout('GET', auth_doc_link) try: auth_doc = json.loads(response.content) except Exception, e: raise LibraryAuthorizationFailedException() auth_types = auth_doc.get('authentication', []) credentials_types = [t for t in auth_types if t['type'] == "http://opds-spec.org/auth/oauth/client_credentials"] if not credentials_types: raise LibraryAuthorizationFailedException() links = credentials_types[0].get('links', []) auth_links = [l for l in links if l.get("rel") == "authenticate"] if not auth_links: raise LibraryAuthorizationFailedException() self.auth_url = auth_links[0].get("href") def refresh(credential): headers = dict() auth_header = "Basic %s" % base64.b64encode("%s:%s" % (self.username, self.password)) headers['Authorization'] = auth_header headers['Content-Type'] = "application/x-www-form-urlencoded" body = dict(grant_type='client_credentials') token_response = self._request_with_timeout('POST', self.auth_url, data=body, headers=headers) token = json.loads(token_response.content) access_token = token.get("access_token") expires_in = token.get("expires_in") if not access_token or not expires_in: raise LibraryAuthorizationFailedException() credential.credential = access_token expires_in = expires_in credential.expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in) return Credential.lookup(_db, self.data_source_name, "OPDS For Distributors Bearer Token", patron=None, refresher_method=refresh, ).credential
class OPDSForDistributorsAPI(BaseCirculationAPI, HasSelfTests): NAME = "OPDS for Distributors" DESCRIPTION = _( "Import books from a distributor that requires authentication to get the OPDS feed and download books." ) SETTINGS = OPDSImporter.BASE_SETTINGS + [ { "key": ExternalIntegration.USERNAME, "label": _("Library's username or access key"), "required": True, }, { "key": ExternalIntegration.PASSWORD, "label": _("Library's password or secret key"), "required": True, } ] # In OPDS For Distributors, all items are gated through the # BEARER_TOKEN access control scheme. # # If the default client supports a given media type when # combined with the BEARER_TOKEN scheme, then we should import # titles with that media type... SUPPORTED_MEDIA_TYPES = [ format for (format, drm) in DeliveryMechanism.default_client_can_fulfill_lookup if drm == (DeliveryMechanism.BEARER_TOKEN) and format is not None ] # ...and we should map requests for delivery of that media type to # the (type, BEARER_TOKEN) DeliveryMechanism. delivery_mechanism_to_internal_format = { (type, DeliveryMechanism.BEARER_TOKEN): type for type in SUPPORTED_MEDIA_TYPES } def __init__(self, _db, collection): self.collection_id = collection.id self.external_integration_id = collection.external_integration.id self.data_source_name = collection.external_integration.setting( Collection.DATA_SOURCE_NAME_SETTING).value self.username = collection.external_integration.username self.password = collection.external_integration.password self.feed_url = collection.external_account_id self.auth_url = None def external_integration(self, _db): return get_one(_db, ExternalIntegration, id=self.external_integration_id) def _run_self_tests(self, _db): """Try to get a token.""" yield self.run_test("Negotiate a fulfillment token", self._get_token, _db) def _request_with_timeout(self, method, url, *args, **kwargs): """Wrapper around HTTP.request_with_timeout to be overridden for tests.""" return HTTP.request_with_timeout(method, url, *args, **kwargs) def _get_token(self, _db): # If this is the first time we're getting a token, we # need to find the authenticate url in the OPDS # authentication document. if not self.auth_url: # Keep track of the most recent URL we retrieved for error # reporting purposes. current_url = self.feed_url response = self._request_with_timeout('GET', current_url) if response.status_code != 401: # This feed doesn't require authentication, so # we need to find a link to the authentication document. feed = feedparser.parse(response.content) links = feed.get('feed', {}).get('links', []) auth_doc_links = [ l for l in links if l['rel'] == "http://opds-spec.org/auth/document" ] if not auth_doc_links: raise LibraryAuthorizationFailedException( "No authentication document link found in %s" % current_url) current_url = auth_doc_links[0].get("href") response = self._request_with_timeout('GET', current_url) try: auth_doc = json.loads(response.content) except Exception, e: raise LibraryAuthorizationFailedException( "Could not load authentication document from %s" % current_url) auth_types = auth_doc.get('authentication', []) credentials_types = [ t for t in auth_types if t['type'] == "http://opds-spec.org/auth/oauth/client_credentials" ] if not credentials_types: raise LibraryAuthorizationFailedException( "Could not find any credential-based authentication mechanisms in %s" % current_url) links = credentials_types[0].get('links', []) auth_links = [l for l in links if l.get("rel") == "authenticate"] if not auth_links: raise LibraryAuthorizationFailedException( "Could not find any authentication links in %s" % current_url) self.auth_url = auth_links[0].get("href") def refresh(credential): headers = dict() auth_header = "Basic %s" % base64.b64encode( "%s:%s" % (self.username, self.password)) headers['Authorization'] = auth_header headers['Content-Type'] = "application/x-www-form-urlencoded" body = dict(grant_type='client_credentials') token_response = self._request_with_timeout('POST', self.auth_url, data=body, headers=headers) token = json.loads(token_response.content) access_token = token.get("access_token") expires_in = token.get("expires_in") if not access_token or not expires_in: raise LibraryAuthorizationFailedException( "Document retrieved from %s is not a bearer token: %s" % (self.auth_url, token_response.content)) credential.credential = access_token expires_in = expires_in # We'll avoid edge cases by assuming the token expires 75% # into its useful lifetime. credential.expires = datetime.datetime.utcnow( ) + datetime.timedelta(seconds=expires_in * 0.75) return Credential.lookup( _db, self.data_source_name, "OPDS For Distributors Bearer Token", patron=None, refresher_method=refresh, )
def _patron_credential_lookup(self, patron, refresh): return Credential.lookup(self._db, DataSource.ODILO, "OAuth Token", patron, refresh)
def credential_object(self, refresh): """Look up the Credential object that allows us to use the Odilo API. """ return Credential.lookup(self._db, DataSource.ODILO, None, None, refresh)