Source code for api.firstbook2

from flask_babel import lazy_gettext as _
import jwt
from jwt.algorithms import HMACAlgorithm
import requests
import logging
import time

from .authenticator import (
    BasicAuthenticationProvider,
    PatronData,
)
from .config import (
    Configuration,
    CannotLoadConfiguration,
)
from .circulation_exceptions import RemoteInitiatedServerError
import urllib.parse
from core.model import (
    get_one_or_create,
    ExternalIntegration,
    Patron,
)


[docs]class FirstBookAuthenticationAPI(BasicAuthenticationProvider): NAME = 'First Book (deprecated)' DESCRIPTION = _(""" An authentication service for Open eBooks that authenticates using access codes and PINs. (This is the deprecated version.)""") DISPLAY_NAME = NAME DEFAULT_IDENTIFIER_LABEL = _("Access Code") LOGIN_BUTTON_IMAGE = "FirstBookLoginButton280.png" # The algorithm used to sign JWTs. ALGORITHM = 'HS256' # If FirstBook sends this message it means they accepted the # patron's credentials. SUCCESS_MESSAGE = 'Valid Code Pin Pair' # Server-side validation happens before the identifier # is converted to uppercase, which means lowercase characters # are valid. DEFAULT_IDENTIFIER_REGULAR_EXPRESSION = '^[A-Za-z0-9@]+$' DEFAULT_PASSWORD_REGULAR_EXPRESSION = '^[0-9]+$' SETTINGS = [ { "key": ExternalIntegration.URL, "format": "url", "label": _("URL"), "default": "https://ebooks.firstbook.org/api/", "required": True }, {"key": ExternalIntegration.PASSWORD, "label": _("Key"), "required": True}, ] + BasicAuthenticationProvider.SETTINGS log = logging.getLogger("First Book JWT authentication API") def __init__(self, library_id, integration, analytics=None, root=None, secret=None): super(FirstBookAuthenticationAPI, self).__init__( library_id, integration, analytics) root = root or integration.url secret = secret or integration.password if not (root and secret): raise CannotLoadConfiguration( "First Book server not configured." ) self.root = root self.secret = secret # Begin implementation of BasicAuthenticationProvider abstract # methods.
[docs] def remote_authenticate(self, username, password): # All FirstBook credentials are in upper-case. username = username.upper() # If they fail a PIN test, there is no authenticated patron. if not self.remote_pin_test(username, password): return None # FirstBook keeps track of absolutely no information # about the patron other than the permanent ID, # which is also the authorization identifier. return PatronData( permanent_id=username, authorization_identifier=username, )
# End implementation of BasicAuthenticationProvider abstract methods.
[docs] def remote_pin_test(self, barcode, pin): jwt = self.jwt(barcode, pin) url = self.root + jwt try: response = self.request(url) except requests.exceptions.ConnectionError as e: raise RemoteInitiatedServerError( str(e), self.NAME ) content = response.content.decode("utf8") if response.status_code != 200: msg = "Got unexpected response code %d. Content: %s" % ( response.status_code, content ) raise RemoteInitiatedServerError(msg, self.NAME) if self.SUCCESS_MESSAGE in content: return True return False
[docs] def jwt(self, barcode, pin): """Create and sign a JWT with the payload expected by the First Book API. """ now = str(int(time.time())) payload = dict( barcode=barcode, pin=pin, iat=now, ) return jwt.encode(payload, self.secret, algorithm=self.ALGORITHM)
[docs] def request(self, url): """Make an HTTP request. Defined solely so it can be overridden in the mock. """ return requests.get(url)
[docs]class MockFirstBookResponse(object): def __init__(self, status_code, content): self.status_code = status_code # Guarantee that the response content is always a bytestring, # as it would be in real life. if isinstance(content, str): content = content.encode("utf8") self.content = content
[docs]class MockFirstBookAuthenticationAPI(FirstBookAuthenticationAPI): SUCCESS = '"Valid Code Pin Pair"' FAILURE = '{"code":404,"message":"Access Code Pin Pair not found"}' def __init__(self, library, integration, valid={}, bad_connection=False, failure_status_code=None): super(MockFirstBookAuthenticationAPI, self).__init__( library, integration, root="http://example.com/", secret="secret" ) self.identifier_re = None self.password_re = None self.valid = valid self.bad_connection = bad_connection self.failure_status_code = failure_status_code self.request_urls = []
[docs] def request(self, url): self.request_urls.append(url) if self.bad_connection: # Simulate a bad connection. raise requests.exceptions.ConnectionError("Could not connect!") elif self.failure_status_code: # Simulate a server returning an unexpected error code. return MockFirstBookResponse( self.failure_status_code, "Error %s" % self.failure_status_code ) parsed = urllib.parse.urlparse(url) token = parsed.path.split("/")[-1] barcode, pin = self._decode(token) # The barcode and pin must be present in self.valid. if barcode in self.valid and self.valid[barcode] == pin: return MockFirstBookResponse(200, self.SUCCESS) else: return MockFirstBookResponse(200, self.FAILURE)
def _decode(self, token): # Decode a JWT. Only used in tests -- in production, this is # First Book's job. # The JWT must be signed with the shared secret. payload = jwt.decode(token, self.secret, algorithms=self.ALGORITHM) # The 'iat' field in the payload must be a recent timestamp. assert (time.time()-int(payload['iat'])) < 2 return payload['barcode'], payload['pin']
# Specify which of the classes defined in this module is the # authentication provider. AuthenticationProvider = FirstBookAuthenticationAPI