Skip to content

Commit

Permalink
Merge branch 'main' into stage
Browse files Browse the repository at this point in the history
  • Loading branch information
MelissaAutumn committed Jan 12, 2024
2 parents bab105f + 8bd4106 commit 7180ab7
Show file tree
Hide file tree
Showing 30 changed files with 10,638 additions and 689 deletions.
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ test = [
"Faker==20.1.0",
"httpx==0.25.1",
"pytest==7.4.3",
"freezegun==1.4.0",
]
deploy = ['appointment[cli]', 'appointment[db]']

Expand Down
17 changes: 16 additions & 1 deletion backend/src/appointment/controller/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,23 @@
import os
import hashlib
import hmac
import datetime

from ..database import repo, schemas
from sqlalchemy.orm import Session

from .apis.fxa_client import FxaClient
from ..database import repo, schemas, models


def logout(db: Session, subscriber: models.Subscriber, fxa_client: FxaClient | None, deny_previous_tokens=True):
"""Sets a minimum valid issued at time (time). This prevents access tokens issued earlier from working."""
if deny_previous_tokens:
subscriber.minimum_valid_iat_time = datetime.datetime.now(datetime.UTC)
db.add(subscriber)
db.commit()

if os.getenv('AUTH_SCHEME') == 'fxa':
fxa_client.logout()


def sign_url(url: str):
Expand Down
3 changes: 0 additions & 3 deletions backend/src/appointment/database/database.py

This file was deleted.

26 changes: 15 additions & 11 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time
from sqlalchemy_utils import StringEncryptedType, ChoiceType
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
from sqlalchemy.orm import relationship
from sqlalchemy.orm import relationship, as_declarative, declared_attr
from sqlalchemy.sql import func
from .database import Base


def secret():
Expand Down Expand Up @@ -73,6 +72,17 @@ class MeetingLinkProviderType(enum.StrEnum):
google_meet = 'google_meet'


@as_declarative()
class Base:
"""Base model, contains anything we want to be on every model."""
@declared_attr
def __tablename__(cls):
return cls.__name__.lower()

time_created = Column(DateTime, server_default=func.now(), index=True)
time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now(), index=True)


class Subscriber(Base):
__tablename__ = "subscribers"

Expand All @@ -92,6 +102,9 @@ class Subscriber(Base):
google_state_expires_at = Column(DateTime)
short_link_hash = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False)

# Only accept the times greater than the one specified in the `iat` claim of the jwt token
minimum_valid_iat_time = Column('minimum_valid_iat_time', StringEncryptedType(DateTime, secret, AesEngine, "pkcs5", length=255))

calendars = relationship("Calendar", cascade="all,delete", back_populates="owner")
slots = relationship("Slot", cascade="all,delete", back_populates="subscriber")
external_connections = relationship("ExternalConnections", cascade="all,delete", back_populates="owner")
Expand Down Expand Up @@ -125,8 +138,6 @@ class Appointment(Base):

id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("calendars.id"))
time_created = Column(DateTime, server_default=func.now())
time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now())
duration = Column(Integer)
title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255))
location_type = Column(Enum(LocationType), default=LocationType.inperson)
Expand Down Expand Up @@ -204,8 +215,6 @@ class Schedule(Base):
farthest_booking = Column(Integer, default=20160) # in minutes, defaults to 2 weeks
weekdays = Column(JSON, default="[1,2,3,4,5]") # list of ISO weekdays, Mo-Su => 1-7
slot_duration = Column(Integer, default=30) # defaults to 30 minutes
time_created = Column(DateTime, server_default=func.now())
time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now())

# What (if any) meeting link will we generate once the meeting is booked
meeting_link_provider = Column(StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), default=MeetingLinkProviderType.none, index=False)
Expand All @@ -230,8 +239,6 @@ class Availability(Base):
# Can't book if it's less than X minutes before start time:
min_time_before_meeting = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True)
slot_duration = Column(Integer) # Size of the Slot that can be booked.
time_created = Column(DateTime, server_default=func.now())
time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now())

schedule = relationship("Schedule", back_populates="availabilities")

Expand All @@ -246,7 +253,4 @@ class ExternalConnections(Base):
type = Column(Enum(ExternalConnectionType), index=True)
type_id = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True)
token = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False)
time_created = Column(DateTime, server_default=func.now())
time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now())

owner = relationship("Subscriber", back_populates="external_connections")
4 changes: 2 additions & 2 deletions backend/src/appointment/database/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def delete_attendees_by_subscriber(db: Session, subscriber_id: int):
"""


def get_subscriber(db: Session, subscriber_id: int):
def get_subscriber(db: Session, subscriber_id: int) -> models.Subscriber | None:
"""retrieve subscriber by id"""
return db.get(models.Subscriber, subscriber_id)

Expand Down Expand Up @@ -610,7 +610,7 @@ def delete_external_connections_by_type_id(db: Session, subscriber_id: int, type
return True


def get_external_connections_by_type(db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None):
def get_external_connections_by_type(db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None) -> models.ExternalConnections | None:
"""Return a subscribers external connections by type, and optionally type id"""
query = (
db.query(models.ExternalConnections)
Expand Down
11 changes: 10 additions & 1 deletion backend/src/appointment/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,22 @@ def get_user_from_token(db, token: str):
try:
payload = jwt.decode(token, os.getenv('JWT_SECRET'), algorithms=[os.getenv('JWT_ALGO')])
sub = payload.get("sub")
iat = payload.get("iat")
if sub is None:
raise InvalidTokenException()
except JWTError:
raise InvalidTokenException()

id = sub.replace('uid-', '')
return repo.get_subscriber(db, int(id))
subscriber = repo.get_subscriber(db, int(id))

# Token has been expired by us - temp measure to avoid spinning a refresh system, or a deny list for this issue
if subscriber.minimum_valid_iat_time and not iat:
raise InvalidTokenException()
elif subscriber.minimum_valid_iat_time and subscriber.minimum_valid_iat_time.timestamp() > int(iat):
raise InvalidTokenException()

return subscriber


def get_subscriber(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""add minimum_valid_iat_time to subscribers
Revision ID: ad7cc2de5ff8
Revises: 0dc429ca07f5
Create Date: 2024-01-09 16:52:20.941572
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import StringEncryptedType
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine


def secret():
return os.getenv("DB_SECRET")


# revision identifiers, used by Alembic.
revision = 'ad7cc2de5ff8'
down_revision = '0dc429ca07f5'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('subscribers', sa.Column('minimum_valid_iat_time', StringEncryptedType(sa.DateTime, secret, AesEngine, "pkcs5", length=255)))


def downgrade() -> None:
op.drop_column('subscribers', 'minimum_valid_iat_time')
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""add time_created and time_updated to tables
Revision ID: 502c76bc79e0
Revises: 0dc429ca07f5
Create Date: 2024-01-10 22:59:02.194281
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import func

# revision identifiers, used by Alembic.
revision = '502c76bc79e0'
down_revision = '0dc429ca07f5'
branch_labels = None
depends_on = None

affected_tables = ['attendees', 'calendars', 'slots', 'subscribers']
index_tables = ['appointments', 'availabilities', 'external_connections', 'schedules', 'slots', ]

def upgrade() -> None:
for table in affected_tables:
op.add_column(table, sa.Column('time_created', sa.DateTime, server_default=func.now(), index=True))
# Slots already has this column...
if table != 'slots':
op.add_column(table, sa.Column('time_updated', sa.DateTime, server_default=func.now(), index=True))

# Fix some existing time_* columns
for table in index_tables:
op.create_index('ix_time_created', table, ['time_created'])
op.create_index('ix_time_updated', table, ['time_updated'])


def downgrade() -> None:
for table in affected_tables:
op.drop_column(table, 'time_created')
if table != 'slots':
op.drop_column(table, 'time_updated')

for table in index_tables:
op.drop_index('ix_time_created', table)
op.drop_index('ix_time_updated', table)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Merge Commit
Revision ID: ea551afc14fc
Revises: ad7cc2de5ff8, 502c76bc79e0
Create Date: 2024-01-12 18:01:38.962773
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'ea551afc14fc'
down_revision = ('ad7cc2de5ff8', '502c76bc79e0')
branch_labels = None
depends_on = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
17 changes: 10 additions & 7 deletions backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ..dependencies.database import get_db
from ..dependencies.auth import get_subscriber

from ..controller import auth
from ..controller.apis.fxa_client import FxaClient
from ..dependencies.fxa import get_fxa_client
from ..exceptions.fxa_api import NotInAllowListException
Expand All @@ -42,7 +43,10 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.now(UTC) + timedelta(minutes=15)
to_encode.update({"exp": expire})
to_encode.update({
"exp": expire,
"iat": int(datetime.now(UTC).timestamp())
})
encoded_jwt = jwt.encode(to_encode, os.getenv('JWT_SECRET'), algorithm=os.getenv('JWT_ALGO'))
return encoded_jwt

Expand Down Expand Up @@ -194,15 +198,14 @@ def token(


@router.get('/logout')
def logout(subscriber: Subscriber = Depends(get_subscriber), fxa_client: FxaClient = Depends(get_fxa_client)):
def logout(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), fxa_client: FxaClient = Depends(get_fxa_client)):
"""Logout a given subscriber session"""

# We don't actually have to do anything for non-fxa schemes
if os.getenv('AUTH_SCHEME') != 'fxa':
return True
if os.getenv('AUTH_SCHEME') == 'fxa':
fxa_client.setup(subscriber.id, subscriber.get_external_connection(ExternalConnectionType.fxa).token)

fxa_client.setup(subscriber.id, subscriber.get_external_connection(ExternalConnectionType.fxa).token)
fxa_client.logout()
# Don't set a minimum_valid_iat_time here.
auth.logout(db, subscriber, fxa_client, deny_previous_tokens=False)

return True

Expand Down
36 changes: 29 additions & 7 deletions backend/src/appointment/routes/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session

from ..controller import auth, data
from ..controller.apis.fxa_client import FxaClient
from ..database import repo, models
from ..database import repo, models, schemas
from ..dependencies.database import get_db
from ..dependencies.fxa import get_webhook_auth, get_fxa_client
from ..exceptions.account_api import AccountDeletionSubscriberFail
from ..exceptions.fxa_api import MissingRefreshTokenException

router = APIRouter()


@router.post("/fxa-process")
def fxa_process(
request: Request,
db: Session = Depends(get_db),
decoded_token: dict = Depends(get_webhook_auth),
fxa_client: FxaClient = Depends(get_fxa_client)
Expand All @@ -42,20 +43,41 @@ def fxa_process(
break

try:
fxa_client.logout()
auth.logout(db, subscriber, fxa_client)
except MissingRefreshTokenException:
logging.warning("Subscriber doesn't have refresh token.")
except requests.exceptions.HTTPError as ex:
logging.error(f"Error logging out user: {ex.response}")
case 'https://schemas.accounts.firefox.com/event/profile-change':
if event_data.get('email') is not None:
# Update the subscriber's email (and username for now)
# Update the subscriber's email, we do this first in case there's a problem with get_profile()
subscriber.email = event_data.get('email')
subscriber.username = subscriber.email
db.add(subscriber)
db.commit()

try:
profile = fxa_client.get_profile()
# Update profile with fxa info
repo.update_subscriber(db, schemas.SubscriberIn(
avatar_url=profile['avatar'],
name=profile['displayName'] if 'displayName' in profile else profile['email'].split('@')[0],
username=subscriber.username
), subscriber.id)
except Exception as ex:
logging.error(f"Error updating user: {ex}")

# Finally log the subscriber out
try:
auth.logout(db, subscriber, fxa_client)
except MissingRefreshTokenException:
logging.warning("Subscriber doesn't have refresh token.")
except requests.exceptions.HTTPError as ex:
logging.error(f"Error logging out user: {ex.response}")
case 'https://schemas.accounts.firefox.com/event/delete-user':
# TODO: We have a delete function, but it's not up-to-date
logging.warning(f"Deletion request came in for {subscriber.id}")
try:
data.delete_account(db, subscriber)
except AccountDeletionSubscriberFail as ex:
logging.error(f"Account deletion webhook failed: {ex.message}")

case _:
logging.warning(f"Ignoring event {event}")
2 changes: 1 addition & 1 deletion backend/test/factory/subscriber_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def _make_subscriber(level, name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE
subscriber = repo.create_subscriber(db, schemas.SubscriberBase(
name=name if factory_has_value(name) else fake.name(),
username=username if factory_has_value(username) else fake.name(),
email=email if factory_has_value(FAKER_RANDOM_VALUE) else fake.email(),
email=email if factory_has_value(email) else fake.email(),
level=level,
timezone='America/Vancouver'
))
Expand Down
Loading

0 comments on commit 7180ab7

Please sign in to comment.