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

Feature/tdbytes #96

Merged
merged 5 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .docker/docker-compose.development.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: "3.5"
name: backend
services:
tdctl_api:
Expand Down
1 change: 0 additions & 1 deletion .docker/docker-compose.production.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: "3.5"
services:
tdctl_api:
image: ghcr.io/td-org-uit-no/tdctl-api/tdctl-api:latest
Expand Down
35 changes: 20 additions & 15 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,51 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.api import stats
from app.api import kiosk, stats
from .config import config

from .api import members, auth, events, admin, mail, jobs
from .db import setup_db


def create_app():

app = FastAPI(
title='TDCTL-API',
title="TDCTL-API",
version="0.1",
description='''TDCTL-database API.
Everything related to Tromsøstudentenes Dataforening''',
contact={'name': 'td', 'email': '[email protected]'},
description="""TDCTL-database API.
Everything related to Tromsøstudentenes Dataforening""",
contact={"name": "td", "email": "[email protected]"},
docs_url="/",
)

# CORS Middleware
app.add_middleware(
CORSMiddleware,
allow_origins=['http://localhost:3000', 'https://td-uit.no'],
allow_origins=["http://localhost:3000", "https://td-uit.no"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Fetch config object
env = os.getenv('API_ENV', 'default')
env = os.getenv("API_ENV", "default")
app.config = config[env]

# Routers
app.include_router(members.router, prefix="/api/member", tags=['members'])
app.include_router(auth.router, prefix="/api/auth", tags=['auth'])
app.include_router(events.router, prefix="/api/event", tags=['event'])
app.include_router(admin.router, prefix="/api/admin", tags=['admin'])
app.include_router(mail.router, prefix="/api/mail", tags=['mail'])
app.include_router(jobs.router, prefix="/api/jobs", tags=['job'])
app.include_router(members.router, prefix="/api/member", tags=["members"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(events.router, prefix="/api/event", tags=["event"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(mail.router, prefix="/api/mail", tags=["mail"])
app.include_router(jobs.router, prefix="/api/jobs", tags=["job"])
app.include_router(kiosk.router, prefix="/api/kiosk", tags=["kiosk"])
# only visible in development
app.include_router(stats.router, prefix="/api/stats", tags=['stats'], include_in_schema=app.config.ENV!='production')
app.include_router(
stats.router,
prefix="/api/stats",
tags=["stats"],
include_in_schema=app.config.ENV != "production",
)

setup_db(app)
# Set tokens to expire at at "exp"
Expand Down
83 changes: 83 additions & 0 deletions app/api/kiosk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from datetime import datetime
from uuid import UUID, uuid4

from fastapi import APIRouter, Depends, HTTPException, Request, Response

from app.auth_helpers import authorize, authorize_admin, authorize_kiosk_admin
from app.db import get_database

from ..models import AccessTokenPayload, KioskSuggestionPayload, Role

router = APIRouter()


@router.post("/suggestion")
def add_suggestion(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not relevant now, but we should maybe look to guard against users spamming suggestions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, many endpoints could use some rate limiting mechanism

request: Request,
newSuggestion: KioskSuggestionPayload,
token: AccessTokenPayload = Depends(authorize),
):
db = get_database(request)

member = db.members.find_one({"id": UUID(token.user_id)})
if member is None:
raise HTTPException(404)

id = uuid4()
formatted_product = newSuggestion.product.lower().capitalize()

suggestion = {
"id": id,
"product": formatted_product,
"member": member,
"timestamp": datetime.now(),
}

db.kioskSuggestions.insert_one(suggestion)

return Response(status_code=201)


@router.get("/suggestions")
def get_suggestions(
request: Request, token: AccessTokenPayload = Depends(authorize_kiosk_admin)
):
db = get_database(request)

# Kiosk admin has access to list, but only admin should get member names
isAdmin = token.role == Role.admin

# Return all suggestions in collection
suggestions = db.kioskSuggestions.aggregate([{"$sort": {"date": -1}}])

# Only return username to admins
ret = [
{
"id": s["id"],
"product": s["product"],
"username": s["member"].get("realName", None) if isAdmin else "-",
"timestamp": s["timestamp"],
}
for s in suggestions
]

if len(ret) == 0:
raise HTTPException(404, "No kiosk suggestions found")

return ret


@router.delete("/suggestion/{id}")
def delete_suggestion(
request: Request,
id: str,
token: AccessTokenPayload = Depends(authorize_admin),
):
db = get_database(request)

res = db.kioskSuggestions.find_one_and_delete({"id": UUID(id)})

if not res:
raise HTTPException(404, "Suggestion not found")

return Response(status_code=200)
124 changes: 82 additions & 42 deletions app/auth_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
security_scheme = HTTPBearer()
optional_security = HTTPBearer(auto_error=False)

SCOPES = ['https://www.googleapis.com/auth/gmail.compose']
KEY_PATH = '.config/mail_credentials.json'
SCOPES = ["https://www.googleapis.com/auth/gmail.compose"]
KEY_PATH = ".config/mail_credentials.json"


def get_google_credentials(impersonate_email: str):
"""
Expand All @@ -24,118 +25,157 @@ def get_google_credentials(impersonate_email: str):
impersonate_email
- Mail for service email to impersonate when sending emails.
"""
credentials = service_account.Credentials.from_service_account_file(KEY_PATH,scopes=SCOPES).with_subject(impersonate_email)
credentials = service_account.Credentials.from_service_account_file(
KEY_PATH, scopes=SCOPES
).with_subject(impersonate_email)
return credentials


def authorize_admin(request: Request):
payload = authorize(request)

if payload.role != Role.admin:
raise HTTPException(403, 'Insufficient privileges to access this resource')
raise HTTPException(403, "Insufficient privileges to access this resource")
return payload


def authorize_kiosk_admin(request: Request):
payload = authorize(request)

if payload.role != Role.admin and payload.role != Role.kiosk_admin:
raise HTTPException(403, "Insufficient privileges to access this resource")
return payload


def authorize(request: Request):
'''
"""
Takes a request and goes through all the steps of authenticating it.
On valid request, function will return the payload.
If token used in the request is invalid, a number of errors can be raised
and returned.
'''
"""
access_token = request.cookies.get("access_token")
if not access_token:
raise HTTPException(
401, 'Access token is not present')
raise HTTPException(401, "Access token is not present")

payload = AccessTokenPayload.model_validate(decode_token(
access_token, request.app.config))
payload = AccessTokenPayload.model_validate(
decode_token(access_token, request.app.config)
)

if not payload.access_token:
raise HTTPException(
401, 'The token is not an access token. Is this a refresh token?')
401, "The token is not an access token. Is this a refresh token?"
)
return payload


def optional_authentication(request: Request):
'''
Allows login to be optional, and if token is provided parse it using authorize. This gives the possibility for endpoints
"""
Allows login to be optional, and if token is provided parse it using authorize. This gives the possibility for endpoints
to change behavior based on the user being logged in or not
'''
"""
access_token = request.cookies.get("access_token")
if access_token:
try:
# Don't use decode_token function as it raises an exception which we
# do not want on optional authentication
payload = AccessTokenPayload.model_validate(decode(access_token, request.app.config.SECRET_KEY, algorithms=['HS256']))
payload = AccessTokenPayload.model_validate(
decode(
access_token, request.app.config.SECRET_KEY, algorithms=["HS256"]
)
)
return payload.access_token and payload or None
except:
return None

return None


def role_required(accessToken: AccessTokenPayload, role: Role):
if accessToken.role != role:
raise HTTPException(403, 'No privileges to access this resource')
raise HTTPException(403, "No privileges to access this resource")


def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
# Set cookie to expire in one year. Note that the token may still be invalid
# even if the cookie is not expired, so this doesn't mean that the token
# can't be revoked
expiration = 31536000
secure = os.environ.get("API_ENV") == "production"
response.set_cookie('access_token', access_token, httponly = True, path = "/", secure=secure, expires=expiration)
response.set_cookie('refresh_token', refresh_token, httponly = True, path = "/", secure=secure, expires=expiration)
response.set_cookie(
"access_token",
access_token,
httponly=True,
path="/",
secure=secure,
expires=expiration,
)
response.set_cookie(
"refresh_token",
refresh_token,
httponly=True,
path="/",
secure=secure,
expires=expiration,
)


def delete_auth_cookies(response: Response):
response.delete_cookie('access_token')
response.delete_cookie('refresh_token')
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")


def create_token(user: MemberDB, config: Config):
payload = {
# Token lifetime
'exp': datetime.utcnow() + timedelta(days=0, hours=1),
'iat': datetime.utcnow(),
'user_id': user.id.hex,
'role': user.role,
'access_token': True # Separates it from refresh token
"exp": datetime.utcnow() + timedelta(days=0, hours=1),
"iat": datetime.utcnow(),
"user_id": user.id.hex,
"role": user.role,
"access_token": True, # Separates it from refresh token
}
return encode(payload, config.SECRET_KEY, algorithm='HS256')
return encode(payload, config.SECRET_KEY, algorithm="HS256")


def create_refresh_token(user: MemberDB, config: Config):
return encode({
# Token lifetime
"exp": datetime.utcnow() + timedelta(days=7), # Expiry
"iat": datetime.utcnow(), # Issued at
"jti": uuid4().hex, # Token id
"user_id": user.id.hex
}, config.SECRET_KEY, algorithm='HS256')
return encode(
{
# Token lifetime
"exp": datetime.utcnow() + timedelta(days=7), # Expiry
"iat": datetime.utcnow(), # Issued at
"jti": uuid4().hex, # Token id
"user_id": user.id.hex,
},
config.SECRET_KEY,
algorithm="HS256",
)


def decode_token(token: str, config: Config) -> dict:
try:
# Attempt to decode the token found in header
return decode(token, config.SECRET_KEY, algorithms=['HS256'])
return decode(token, config.SECRET_KEY, algorithms=["HS256"])
except DecodeError:
# Missing segments
raise HTTPException(401, 'Token has incorrect format.')
raise HTTPException(401, "Token has incorrect format.")
except ExpiredSignatureError:
raise HTTPException(401, 'Token has expired.')
raise HTTPException(401, "Token has expired.")
# except:
# raise HTTPException(401, 'Unknown error')


def blacklist_token(refreshToken: RefreshTokenPayload, db: Database):
'''
"""
Blacklists the provided (decoded) refresh token.
'''
"""
# Insert token into database
db.tokens.insert_one(refreshToken.model_dump())


def is_blacklisted(refreshToken: RefreshTokenPayload, db: Database):
'''
"""
Check if the provided (decoded) refresh token is blacklisted.
'''
if (db.tokens.find_one({"jti": refreshToken.jti})):
"""
if db.tokens.find_one({"jti": refreshToken.jti}):
return True
return False
Loading
Loading