Skip to content

Commit

Permalink
Merge changes from origin/main to upstream_v14.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
ttuovinen committed Feb 7, 2024
1 parent 6ed2f05 commit e6d3806
Show file tree
Hide file tree
Showing 12 changed files with 210 additions and 63 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Create loancheckouts table
Revision ID: d7ef6948af4e
Revises: cc084e35e037
Create Date: 2024-02-05 08:45:20.164531+00:00
"""
import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "d7ef6948af4e"
down_revision = "cc084e35e037"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"loancheckouts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("patron_id", sa.Integer(), nullable=True),
sa.Column("license_pool_id", sa.Integer(), nullable=True),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["license_pool_id"],
["licensepools.id"],
),
sa.ForeignKeyConstraint(["patron_id"], ["patrons.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_loancheckouts_license_pool_id"),
"loancheckouts",
["license_pool_id"],
unique=False,
)
op.create_index(
op.f("ix_loancheckouts_patron_id"), "loancheckouts", ["patron_id"], unique=False
)
op.create_index(
op.f("ix_loancheckouts_timestamp"), "loancheckouts", ["timestamp"], unique=False
)
# ### end Alembic commands ###


def downgrade() -> None:
op.drop_index(op.f("ix_loancheckouts_timestamp"), table_name="loancheckouts")
op.drop_index(op.f("ix_loancheckouts_patron_id"), table_name="loancheckouts")
op.drop_index(op.f("ix_loancheckouts_license_pool_id"), table_name="loancheckouts")
op.drop_table("loancheckouts")
# ### end Alembic commands ###
9 changes: 5 additions & 4 deletions api/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ def register_ekirjasto_provider(
self.ekirjasto_provider = provider

# Finland
def get_ekirjasto_provider(self) -> EkirjastoAuthenticationAPI:
def get_ekirjasto_provider(self) -> EkirjastoAuthenticationAPI | None:
return self.ekirjasto_provider

@property
Expand Down Expand Up @@ -495,9 +495,10 @@ def authenticated_patron(
return INVALID_EKIRJASTO_DELEGATE_TOKEN
provider = self.ekirjasto_provider
# Get decoded payload from the delegate token.
provider_token = provider.validate_ekirjasto_delegate_token(auth.token)
if isinstance(provider_token, ProblemDetail):
return provider_token
validate_result = provider.validate_ekirjasto_delegate_token(auth.token)
if isinstance(validate_result, ProblemDetail):
return validate_result
provider_token = validate_result
elif auth.type.lower() == "bearer":
# The patron wants to use an
# SAMLAuthenticationProvider. Figure out which one.
Expand Down
14 changes: 14 additions & 0 deletions api/circulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
get_one,
)
from core.model.integration import IntegrationConfiguration
from core.model.patron import LoanCheckout
from core.util.datetime_helpers import utc_now
from core.util.log import LoggerMixin

Expand Down Expand Up @@ -891,6 +892,18 @@ def _collect_event(
library, licensepool, name, neighborhood=neighborhood
)

# Finland
def _collect_checkout_history(
self, patron: Patron, license_pool: LicensePool
) -> None:
"""Save history for checkout, for later use in loan history or analytics"""
__transaction = self._db.begin_nested()
checkout = LoanCheckout(
patron=patron, license_pool=license_pool, timestamp=utc_now()
)
self._db.add(checkout)
__transaction.commit()

def _collect_checkout_event(self, patron: Patron, licensepool: LicensePool) -> None:
"""A simple wrapper around _collect_event for handling checkouts.
Expand Down Expand Up @@ -1104,6 +1117,7 @@ def borrow(
# Send out an analytics event to record the fact that
# a loan was initiated through the circulation
# manager.
self._collect_checkout_history(patron, licensepool)
self._collect_checkout_event(patron, licensepool)
return loan, None, new_loan_record

Expand Down
66 changes: 34 additions & 32 deletions api/ekirjasto_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ def __init__(
self.ekirjasto_environment = settings.ekirjasto_environment
self.delegate_expire_timemestamp = settings.delegate_expire_time

self.delegate_token_signing_secret = None
self.delegate_token_encrypting_secret = None
self.delegate_token_signing_secret: str | None = None
self.delegate_token_encrypting_secret: bytes | None = None

self.analytics = analytics

Expand Down Expand Up @@ -284,7 +284,7 @@ def get_credential_from_header(self, auth: Authorization) -> str | None:
# circulation API needs additional authentication.
return None

def get_patron_delegate_id(self, _db: Session, patron: Patron) -> str:
def get_patron_delegate_id(self, _db: Session, patron: Patron) -> str | None:
"""Find or randomly create an identifier to use when identifying
this patron from delegate token.
"""
Expand Down Expand Up @@ -343,17 +343,6 @@ def set_secrets(self, _db):
_db.commit()
self.delegate_token_encrypting_secret = secret.value.encode()

def _check_secrets_or_throw(self):
if (
self.delegate_token_signing_secret == None
or len(self.delegate_token_signing_secret) == 0
or self.delegate_token_encrypting_secret == None
or len(self.delegate_token_encrypting_secret) == 0
):
raise InternalServerError(
"Ekirjasto authenticator not fully setup, secrets are missing."
)

def create_ekirjasto_delegate_token(
self, provider_token: str, patron_delegate_id: str, expires: int
) -> str:
Expand All @@ -362,7 +351,15 @@ def create_ekirjasto_delegate_token(
The patron will use this as the authentication toekn to authentiacte againsy circulation backend.
"""
self._check_secrets_or_throw()
if not self.delegate_token_encrypting_secret:
raise InternalServerError(
"Error creating delegate token, encryption secret missing"
)

if not self.delegate_token_signing_secret:
raise InternalServerError(
"Error creating delegate token, signing secret missing"
)

# Encrypt the ekirjasto token with a128cbc-hs256 algorithm.
fernet = Fernet(self.delegate_token_encrypting_secret)
Expand All @@ -377,6 +374,7 @@ def create_ekirjasto_delegate_token(
iat=int(utc_now().timestamp()),
exp=expires,
)

return jwt.encode(
payload, self.delegate_token_signing_secret, algorithm="HS256"
)
Expand All @@ -392,7 +390,10 @@ def decode_ekirjasto_delegate_token(
return decoded payload
"""
self._check_secrets_or_throw()
if not self.delegate_token_signing_secret:
raise InternalServerError(
"Error decoding delegate token, signing secret missing"
)

options = dict(
verify_signature=True,
Expand All @@ -418,6 +419,10 @@ def decode_ekirjasto_delegate_token(
return decoded_payload

def _decrypt_ekirjasto_token(self, token: str):
if not self.delegate_token_encrypting_secret:
raise InternalServerError(
"Error decrypting ekirjasto token, signing secret missing"
)
fernet = Fernet(self.delegate_token_encrypting_secret)
encrypted_token = b64decode(token.encode("ascii"))
return fernet.decrypt(encrypted_token).decode()
Expand Down Expand Up @@ -445,7 +450,9 @@ def validate_ekirjasto_delegate_token(
return INVALID_EKIRJASTO_DELEGATE_TOKEN
return decoded_payload

def remote_refresh_token(self, token: str) -> (str, int):
def remote_refresh_token(
self, token: str
) -> tuple[ProblemDetail, None] | tuple[str, int]:
"""Refresh ekirjasto token with ekirjasto API call.
We assume that the token is valid, API call fails if not.
Expand All @@ -454,9 +461,9 @@ def remote_refresh_token(self, token: str) -> (str, int):
"""

if self.ekirjasto_environment == EkirjastoEnvironment.FAKE:
token = self.fake_ekirjasto_token
expires = utc_now() + datetime.timedelta(days=1)
return token, expires.timestamp()
fake_token = self.fake_ekirjasto_token
expire_date = utc_now() + datetime.timedelta(days=1)
return fake_token, int(expire_date.timestamp())

url = self._ekirjasto_api_url + "/v1/auth/refresh"

Expand All @@ -469,10 +476,6 @@ def remote_refresh_token(self, token: str) -> (str, int):
# Do nothing if authentication fails, e.g. token expired.
return INVALID_EKIRJASTO_TOKEN, None
elif response.status_code != 200:
msg = "Got unexpected response code %d. Content: %s" % (
response.status_code,
response.content,
)
return EKIRJASTO_REMOTE_AUTHENTICATION_FAILED, None
else:
try:
Expand All @@ -482,6 +485,7 @@ def remote_refresh_token(self, token: str) -> (str, int):

token = content["token"]
expires = content["exp"]

return token, expires

def remote_patron_lookup(
Expand Down Expand Up @@ -532,8 +536,6 @@ def remote_patron_lookup(

return self._userinfo_to_patrondata(content)

return EKIRJASTO_REMOTE_AUTHENTICATION_FAILED

def remote_authenticate(
self, ekirjasto_token: str | None
) -> PatronData | ProblemDetail | None:
Expand Down Expand Up @@ -608,7 +610,7 @@ def local_patron_lookup(

def ekirjasto_authenticate(
self, _db: Session, ekirjasto_token: str
) -> (Patron, bool):
) -> tuple[PatronData | Patron | ProblemDetail | None, bool]:
"""Authenticate patron with remote ekirjasto API and if necessary,
create authenticated patron if not in database.
Expand All @@ -620,17 +622,17 @@ def ekirjasto_authenticate(
log_method=self.logger().info,
message_prefix="authenticated_patron - ekirjasto_authenticate",
):
patron = self.authenticate_and_update_patron(_db, ekirjasto_token)
auth_result = self.authenticate_and_update_patron(_db, ekirjasto_token)

if isinstance(patron, PatronData):
if isinstance(auth_result, PatronData):
# We didn't find the patron, but authentication to external truth was
# succesfull, so we create a new patron with the information we have.
patron, is_new = patron.get_or_create_patron(
# successful, so we create a new patron with the information we have.
patron, is_new = auth_result.get_or_create_patron(
_db, self.library_id, analytics=self.analytics
)
patron.last_external_sync = utc_now()

return patron, is_new
return auth_result, is_new

def authenticated_patron(
self, _db: Session, authorization: dict | str
Expand Down
23 changes: 9 additions & 14 deletions api/ekirjasto_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,35 +34,30 @@ def __init__(self, circulation_manager, authenticator):

self._logger = logging.getLogger(__name__)

def _get_delegate_expire_timestamp(self, ekirjasto_token_expires: int) -> int:
def _get_delegate_expire_timestamp(self, ekirjasto_expire_millis: int) -> int:
"""Get the expire time to use for delegate token, it is calculated based on
expire time of the ekirjasto token.
:param ekirjasto_token_expires: Ekirjasto token expiration timestamp in milliseconds.
:param ekirjasto_expire_millis: Ekirjasto token expiration timestamp in milliseconds.
:return: Timestamp for the delegate token expiration.
:return: Timestamp for the delegate token expiration in seconds.
"""

# Ekirjasto expire is in milliseconds but JWT uses seconds.
ekirjasto_token_expires = int(ekirjasto_token_expires / 1000)

delegate_token_expires = (
delegate_expire_seconds = (
utc_now()
+ datetime.timedelta(
seconds=self._authenticator.ekirjasto_provider.delegate_expire_timemestamp
)
).timestamp()

# Use ekirjasto expire time at 70 % of the remaining duration, so we have some time to refresh it.
now_seconds = utc_now().timestamp()
ekirjasto_token_expires = (
ekirjasto_token_expires - now_seconds
) * 0.7 + now_seconds

if ekirjasto_token_expires < delegate_token_expires:
return int(ekirjasto_token_expires)
# Use ekirjasto expire time at 70 % of the remaining duration, so we have some time to refresh it.
ekirjasto_expire_seconds = (
(ekirjasto_expire_millis / 1000) - now_seconds
) * 0.7 + now_seconds

return int(delegate_token_expires)
return int(min(ekirjasto_expire_seconds, delegate_expire_seconds))

def get_tokens(self, authorization, validate_expire=False):
"""Extract possible delegate and ekirjasto tokens from the authorization header."""
Expand Down
1 change: 1 addition & 0 deletions core/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ def _bulk_operation(self):
Hold,
Loan,
LoanAndHoldMixin,
LoanCheckout,
Patron,
PatronProfileStorage,
)
Expand Down
26 changes: 26 additions & 0 deletions core/model/patron.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ class Patron(Base):
loans: Mapped[list[Loan]] = relationship(
"Loan", backref="patron", cascade="delete", uselist=True
)

loan_checkouts: Mapped[list[LoanCheckout]] = relationship(
"LoanCheckout", back_populates="patron", cascade="delete", uselist=True
)

holds: Mapped[list[Hold]] = relationship(
"Hold",
back_populates="patron",
Expand Down Expand Up @@ -571,6 +576,27 @@ def until(self, default_loan_period):
return start + default_loan_period


# Finland
class LoanCheckout(Base, LoanAndHoldMixin):
"""A model to keep track of loan history, i.e. past checkouts. Similar to `Loan` model with some fields omitted and timestamp added"""

__tablename__ = "loancheckouts"
id = Column(Integer, primary_key=True)

patron_id = Column(
Integer, ForeignKey("patrons.id", ondelete="CASCADE"), index=True
)
patron: Mapped[Patron] = relationship("Patron", back_populates="loan_checkouts")

license_pool_id = Column(Integer, ForeignKey("licensepools.id"), index=True)
license_pool: Mapped[LicensePool] = relationship("LicensePool")

timestamp = Column(DateTime(timezone=True), index=True)

def __lt__(self, other):
return self.timestamp < other.timestamp


class Hold(Base, LoanAndHoldMixin):
"""A patron is in line to check out a book."""

Expand Down
13 changes: 12 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit e6d3806

Please sign in to comment.