Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/105 GA booking request flow #159

Merged
merged 14 commits into from
Nov 3, 2023
Merged
2 changes: 1 addition & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ SMTP_PORT=
# SMTP user credentials
SMTP_USER=
SMTP_PASS=
# Authorized email address for sending emails, leave empty to default to organizer
# Authorized email address for sending emails
SMTP_SENDER=

# -- TIERS --
Expand Down
225 changes: 122 additions & 103 deletions backend/src/appointment/controller/auth.py
Original file line number Diff line number Diff line change
@@ -1,103 +1,122 @@
"""Module: auth

Handle authentification with Auth0 and get subscription data.
"""
import logging
import os
import hashlib
import hmac
import secrets

from sqlalchemy.orm import Session
from ..database import repo, schemas, models
from fastapi_auth0 import Auth0, Auth0User
from auth0.authentication import GetToken
from auth0.management import Auth0 as ManageAuth0
from auth0.exceptions import Auth0Error, RateLimitError, TokenValidationError


domain = os.getenv("AUTH0_API_DOMAIN")
api_client_id = os.getenv("AUTH0_API_CLIENT_ID")
api_secret = os.getenv("AUTH0_API_SECRET")
api_audience = os.getenv("AUTH0_API_AUDIENCE")


class Auth:
def __init__(self):
"""verify Appointment subscription via Auth0, return user or None"""
scopes = {"read:calendars": "Read Calendar Ressources"} # TODO
self.auth0 = Auth0(domain=domain, api_audience=api_audience, scopes=scopes)

def persist_user(self, db: Session, user: Auth0User):
"""Sync authed user to Appointment db"""
if not db:
return None
# get the current user via the authed user
api = self.init_management_api()
if not api:
logging.warning(
"[auth.persist_user] A frontend authed user (ID: %s, name: %s) was not found via management API",
str(user.id),
user.name,
)
return None
authenticated_subscriber = api.users.get(user.id)
# check if user exists as subsriber
if authenticated_subscriber:
# search for subscriber in Appointment db
db_subscriber = repo.get_subscriber_by_email(db=db, email=authenticated_subscriber["email"])
# if authenticated subscriber doesn't exist yet, add them
if db_subscriber is None:
subscriber = schemas.SubscriberBase(
username=authenticated_subscriber["email"], # username == email for now
email=authenticated_subscriber["email"],
name=authenticated_subscriber["name"],
level=models.SubscriberLevel.pro, # TODO
)
db_subscriber = repo.create_subscriber(db=db, subscriber=subscriber)

# Generate an initial short link hash if they don't have one already
if db_subscriber.short_link_hash is None:
repo.update_subscriber(
db,
schemas.SubscriberAuth(
email=db_subscriber.email,
username=db_subscriber.username,
short_link_hash=secrets.token_hex(32),
),
db_subscriber.id,
)

return db_subscriber
return None

def init_management_api(self):
"""Helper function to get a management api token"""
try:
get_token = GetToken(domain, api_client_id, client_secret=api_secret)
token = get_token.client_credentials("https://{}/api/v2/".format(domain))
management = ManageAuth0(domain, token["access_token"])
except RateLimitError as error:
logging.error("[auth.init_management_api] A rate limit error occurred: " + str(error))
return None
except Auth0Error as error:
logging.error("[auth.init_management_api] An Auth0 error occurred: " + str(error))
return None
except TokenValidationError as error:
logging.error("[auth.init_management_api] A token validation error occurred" + str(error))
return None

return management


def sign_url(url: str):
"""helper to sign a url for given user data"""
secret = os.getenv("SIGNED_SECRET")

if not secret:
raise RuntimeError("Missing signed secret environment variable")

key = bytes(secret, "UTF-8")
message = f"{url}".encode()
signature = hmac.new(key, message, hashlib.sha256).hexdigest()
return signature
"""Module: auth

Handle authentification with Auth0 and get subscription data.
"""
import logging
import os
import hashlib
import hmac
import secrets

from sqlalchemy.orm import Session
from ..database import repo, schemas, models
from fastapi_auth0 import Auth0, Auth0User
from auth0.authentication import GetToken
from auth0.management import Auth0 as ManageAuth0
from auth0.exceptions import Auth0Error, RateLimitError, TokenValidationError


domain = os.getenv("AUTH0_API_DOMAIN")
api_client_id = os.getenv("AUTH0_API_CLIENT_ID")
api_secret = os.getenv("AUTH0_API_SECRET")
api_audience = os.getenv("AUTH0_API_AUDIENCE")


class Auth:
def __init__(self):
"""verify Appointment subscription via Auth0, return user or None"""
scopes = {"read:calendars": "Read Calendar Ressources"} # TODO
self.auth0 = Auth0(domain=domain, api_audience=api_audience, scopes=scopes)

def persist_user(self, db: Session, user: Auth0User):
"""Sync authed user to Appointment db"""
if not db:
return None
# get the current user via the authed user
api = self.init_management_api()
if not api:
logging.warning(
"[auth.persist_user] A frontend authed user (ID: %s, name: %s) was not found via management API",
str(user.id),
user.name,
)
return None
authenticated_subscriber = api.users.get(user.id)
# check if user exists as subsriber
if authenticated_subscriber:
# search for subscriber in Appointment db
db_subscriber = repo.get_subscriber_by_email(db=db, email=authenticated_subscriber["email"])
# if authenticated subscriber doesn't exist yet, add them
if db_subscriber is None:
subscriber = schemas.SubscriberBase(
username=authenticated_subscriber["email"], # username == email for now
email=authenticated_subscriber["email"],
name=authenticated_subscriber["name"],
level=models.SubscriberLevel.pro, # TODO
)
db_subscriber = repo.create_subscriber(db=db, subscriber=subscriber)

# Generate an initial short link hash if they don't have one already
if db_subscriber.short_link_hash is None:
repo.update_subscriber(
db,
schemas.SubscriberAuth(
email=db_subscriber.email,
username=db_subscriber.username,
short_link_hash=secrets.token_hex(32),
),
db_subscriber.id,
)

return db_subscriber
return None

def init_management_api(self):
"""Helper function to get a management api token"""
try:
get_token = GetToken(domain, api_client_id, client_secret=api_secret)
token = get_token.client_credentials("https://{}/api/v2/".format(domain))
management = ManageAuth0(domain, token["access_token"])
except RateLimitError as error:
logging.error("[auth.init_management_api] A rate limit error occurred: " + str(error))
return None
except Auth0Error as error:
logging.error("[auth.init_management_api] An Auth0 error occurred: " + str(error))
return None
except TokenValidationError as error:
logging.error("[auth.init_management_api] A token validation error occurred" + str(error))
return None

return management


def sign_url(url: str):
"""helper to sign a given url"""
secret = os.getenv("SIGNED_SECRET")

if not secret:
raise RuntimeError("Missing signed secret environment variable")

key = bytes(secret, "UTF-8")
message = f"{url}".encode()
signature = hmac.new(key, message, hashlib.sha256).hexdigest()
return signature


def signed_url_by_subscriber(subscriber: schemas.Subscriber):
"""helper to generated signed url for given subscriber"""
short_url = os.getenv("SHORT_BASE_URL")
base_url = f"{os.getenv('FRONTEND_URL')}/user"

# If we don't have a short url, then use the default url with /user added to it
if not short_url:
short_url = base_url

# We sign with a different hash that the end-user doesn't have access to
# We also need to use the default url, as short urls are currently setup as a redirect
url = f"{base_url}/{subscriber.username}/{subscriber.short_link_hash}"

signature = sign_url(url)

# We return with the signed url signature
return f"{short_url}/{subscriber.username}/{signature}"
52 changes: 45 additions & 7 deletions backend/src/appointment/controller/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ def create_event(
return event

def delete_events(self, start):
"""delete all events in given date range from the server"""
# Not used?
"""delete all events in given date range from the server
Not intended to be used in production. For cleaning purposes after testing only.
"""
pass


Expand Down Expand Up @@ -223,7 +224,9 @@ def create_event(
return event

def delete_events(self, start):
"""delete all events in given date range from the server"""
"""delete all events in given date range from the server
Not intended to be used in production. For cleaning purposes after testing only.
"""
calendar = self.client.calendar(url=self.url)
result = calendar.events()
count = 0
Expand Down Expand Up @@ -277,8 +280,8 @@ def send_vevent(
mail = InvitationMail(sender=organizer.email, to=attendee.email, attachments=[invite])
mail.send()

def available_slots_from_schedule(s: schemas.ScheduleBase):
"""This helper calculates a list of slots according to the given schedule."""
def available_slots_from_schedule(s: schemas.ScheduleBase) -> list[schemas.SlotBase]:
"""This helper calculates a list of slots according to the given schedule configuration."""
now = datetime.utcnow()
earliest_start = now + timedelta(minutes=s.earliest_booking)
farthest_end = now + timedelta(minutes=s.farthest_booking)
Expand Down Expand Up @@ -312,9 +315,9 @@ def available_slots_from_schedule(s: schemas.ScheduleBase):
pointer = next_date
return slots

def events_set_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.Event]):
def events_set_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.Event]) -> list[schemas.SlotBase]:
"""This helper removes all events from list A, which have a time collision with any event in list B
and returns all remaining elements from A as new list.
and returns all remaining elements from A as new list.
"""
available_slots = []
for a in a_list:
Expand All @@ -332,3 +335,38 @@ def events_set_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.E
if not collision_found:
available_slots.append(a)
return available_slots

def existing_events_for_schedule(
schedule: schemas.Schedule,
calendars: list[schemas.Calendar],
subscriber: schemas.Subscriber,
google_client: GoogleClient,
db
) -> list[schemas.Event]:
"""This helper retrieves all events existing in given calendars for the scheduled date range
"""
existingEvents = []
# handle calendar events
for calendar in calendars:
if calendar.provider == CalendarProvider.google:
con = GoogleConnector(
db=db,
google_client=google_client,
calendar_id=calendar.user,
subscriber_id=subscriber.id,
google_tkn=subscriber.google_tkn,
)
else:
con = CalDavConnector(calendar.url, calendar.user, calendar.password)
devmount marked this conversation as resolved.
Show resolved Hide resolved
farthest_end = datetime.utcnow() + timedelta(minutes=schedule.farthest_booking)
start = schedule.start_date.strftime("%Y-%m-%d")
end = schedule.end_date.strftime("%Y-%m-%d") if schedule.end_date else farthest_end.strftime("%Y-%m-%d")
devmount marked this conversation as resolved.
Show resolved Hide resolved
existingEvents.extend(con.list_events(start, end))
# handle already requested time slots
for slot in schedule.slots:
existingEvents.append(schemas.Event(
title=schedule.name,
start=slot.start.isoformat(),
end=(slot.start + timedelta(minutes=slot.duration)).isoformat(),
))
return existingEvents
5 changes: 4 additions & 1 deletion backend/src/appointment/controller/google_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ def sync_calendars(self, db, subscriber_id: int, token):
# add calendar
try:
repo.update_or_create_subscriber_calendar(
db=db, calendar=cal, calendar_url=calendar.get("id"), subscriber_id=subscriber_id
db=db,
calendar=cal,
calendar_url=calendar.get("id"),
subscriber_id=subscriber_id
)
except Exception as err:
logging.warning(
Expand Down
Loading