Skip to content

Commit

Permalink
Add support for WAYFless URLs
Browse files Browse the repository at this point in the history
  • Loading branch information
vbessonov committed Jan 30, 2022
1 parent 323a019 commit 3de9643
Show file tree
Hide file tree
Showing 19 changed files with 943 additions and 465 deletions.
3 changes: 0 additions & 3 deletions api/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2818,9 +2818,6 @@ class BaseSAMLAuthenticationProvider(

FLOW_TYPE = "http://librarysimplified.org/authtype/SAML-2.0"

TOKEN_TYPE = "SAML 2.0 token"
TOKEN_DATA_SOURCE_NAME = "SAML 2.0"

SETTINGS = SAMLSettings()

LIBRARY_SETTINGS = []
616 changes: 374 additions & 242 deletions api/circulation.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions api/odl2.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
HasExternalIntegration,
)
from core.opds2_import import OPDS2Importer, OPDS2ImportMonitor, RWPMManifestParser
from core.opds_import import ConnectionConfiguration
from core.opds_import import OPDSImporterConfiguration
from core.util import first_or_default
from core.util.datetime_helpers import to_utc


class ODL2APIConfiguration(ConnectionConfiguration):
class ODL2APIConfiguration(OPDSImporterConfiguration):
skipped_license_formats = ConfigurationMetadata(
key="odl2_skipped_license_formats",
label=_("Skipped license formats"),
Expand Down
62 changes: 7 additions & 55 deletions api/proquest/credential.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import datetime
import json
import logging
from enum import Enum

from api.saml.metadata.model import SAMLAttributeType, SAMLSubjectJSONDecoder
from api.saml.credential import SAMLCredentialManager
from api.saml.metadata.model import SAMLAttributeType
from core.model import Credential, DataSource, DataSourceConstants, Patron
from core.util import first_or_default, is_session

Expand All @@ -14,62 +14,14 @@ class ProQuestCredentialType(Enum):
PROQUEST_JWT_TOKEN = "ProQuest JWT Token"


class ProQuestCredentialManager(object):
class ProQuestCredentialManager(SAMLCredentialManager):
"""Manages ProQuest credentials."""

def __init__(self):
"""Initialize a new instance of ProQuestCredentialManager class."""
self._logger = logging.getLogger(__name__)
super().__init__()

def _extract_saml_subject(self, credential):
"""Extract a SAML subject from SAML token.
:param credential: Credential object containing a SAML token
:type credential: core.model.credential.Credential
:return: SAML subject
:rtype: api.saml.metadata.Subject
"""
self._logger.debug("Started deserializing SAML token {0}".format(credential))

subject = json.loads(credential.credential, cls=SAMLSubjectJSONDecoder)

self._logger.debug(
"Finished deserializing SAML token {0}: {1}".format(credential, subject)
)

return subject

def _lookup_saml_token(self, db, patron):
"""Look up for a SAML token.
:param db: Database session
:type db: sqlalchemy.orm.session.Session
:param patron: Patron object
:type patron: core.model.patron.Patron
:return: SAML subject (if any)
:rtype: Optional[api.saml.metadata.Subject]
"""
self._logger.debug("Started looking up for a SAML token")

from api.authenticator import BaseSAMLAuthenticationProvider

credential = Credential.lookup_by_patron(
db,
BaseSAMLAuthenticationProvider.TOKEN_DATA_SOURCE_NAME,
BaseSAMLAuthenticationProvider.TOKEN_TYPE,
patron,
allow_persistent_token=False,
auto_create_datasource=True,
)

self._logger.debug(
"Finished looking up for a SAML token: {0}".format(credential)
)

return credential
self._logger: logging.Logger = logging.getLogger(__name__)

def lookup_proquest_token(self, db, patron):
"""Look up for a JWT bearer token used required to use ProQuest API.
Expand Down Expand Up @@ -197,13 +149,13 @@ def lookup_patron_affiliation_id(
)
)

saml_credential = self._lookup_saml_token(db, patron)
saml_credential = self.lookup_saml_token_by_patron(db, patron)

if not saml_credential:
self._logger.debug("Patron {0} does not have a SAML token".format(patron))
return None

saml_subject = self._extract_saml_subject(saml_credential)
saml_subject = self.extract_saml_token(saml_credential)

self._logger.debug(
"Patron {0} has the following SAML subject: {1}".format(
Expand Down
159 changes: 159 additions & 0 deletions api/saml/credential.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import datetime
import json
import logging
from copy import deepcopy
from typing import Dict, Optional

import sqlalchemy

from api.saml.metadata.model import (
SAMLNameIDFormat,
SAMLSubject,
SAMLSubjectJSONDecoder,
SAMLSubjectJSONEncoder,
)
from core.model import Credential, DataSource, Patron, get_one_or_create


class SAMLCredentialManager(object):
"""Manages SAML tokens.
By SAML tokens we may mean two different things:
- an encoded string containing a serialized SAML Subject object uniquely describing
a patron authenticated using SAML protocol;
- a Credential object containing the SAML token.
"""

TOKEN_TYPE = "SAML 2.0 token"
TOKEN_DATA_SOURCE_NAME = "SAML 2.0"

def __init__(self):
"""Initialize a new instance of SAMLCredentialManager class."""
self._logger: logging.Logger = logging.getLogger(__name__)

def _get_token_data_source(self, db: sqlalchemy.orm.session.Session) -> DataSource:
"""Return a data source used to store SAML credentials.
:param db: Database session
:return: Data source used to store SAML credentials
"""
# FIXME: This code will probably not work in a situation where a library has multiple SAML
# authentication mechanisms for its patrons.
# It'll look up a Credential from this data source but it won't be able to tell which IdP it came from.
datasource, _ = get_one_or_create(
db, DataSource, name=self.TOKEN_DATA_SOURCE_NAME
)

return datasource

@staticmethod
def _create_saml_token_value(subject: SAMLSubject) -> str:
"""Create a SAML token by serializing the SAML subject.
:param subject: SAML subject
:return: SAML token
"""
subject = deepcopy(subject)

# We should not save a transient Name ID because it changes each time
if (
subject.name_id
and subject.name_id.name_format == SAMLNameIDFormat.TRANSIENT.value
):
subject.name_id = None

token_value = json.dumps(subject, cls=SAMLSubjectJSONEncoder)

return token_value

def extract_saml_token(self, credential: Credential) -> SAMLSubject:
"""Extract a SAML subject from SAML token.
:param credential: Credential object containing a SAML token
:return: SAML subject
"""
self._logger.debug("Started deserializing SAML token {0}".format(credential))

subject = json.loads(credential.credential, cls=SAMLSubjectJSONDecoder)

self._logger.debug(
"Finished deserializing SAML token {0}: {1}".format(credential, subject)
)

return subject

def create_saml_token(
self,
db: sqlalchemy.orm.session.Session,
patron: Patron,
subject: SAMLSubject,
cm_session_lifetime: Optional[int] = None,
) -> Credential:
"""Create a Credential object that ties the given patron to the given provider token.
:param db: Database session
:param patron: Patron object
:param subject: SAML subject
:param cm_session_lifetime: (Optional) Circulation Manager's session lifetime expressed in days
:return: Credential object
"""
session_lifetime = subject.valid_till

if cm_session_lifetime:
session_lifetime = datetime.timedelta(days=int(cm_session_lifetime))

token = self._create_saml_token_value(subject)
data_source = self._get_token_data_source(db)

saml_token, _ = Credential.temporary_token_create(
db, data_source, self.TOKEN_TYPE, patron, session_lifetime, token
)

return saml_token

def lookup_saml_token_by_patron(
self, db: sqlalchemy.orm.session.Session, patron: Patron
) -> Optional[Credential]:
"""Look up for a SAML token.
:param db: Database session
:param patron: Patron object
:return: SAML subject (if any)
"""
self._logger.debug("Started looking up for a SAML token")

credential = Credential.lookup_by_patron(
db,
self.TOKEN_DATA_SOURCE_NAME,
self.TOKEN_TYPE,
patron,
allow_persistent_token=False,
auto_create_datasource=True,
)

self._logger.debug(
"Finished looking up for a SAML token: {0}".format(credential)
)

return credential

def lookup_saml_token_by_value(
self, db: sqlalchemy.orm.session.Session, token: Dict
) -> Optional[Credential]:
"""Look up for a SAML token.
:param db: Database session
:param token: SAML token
:return: SAML subject (if any)
"""
self._logger.debug("Started looking up for a SAML token")

credential = Credential.lookup_by_token(
db, self._get_token_data_source(db), self.TOKEN_TYPE, token
)

self._logger.debug(
"Finished looking up for a SAML token: {0}".format(credential)
)

return credential
Loading

0 comments on commit 3de9643

Please sign in to comment.