Skip to content
Open
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
5 changes: 4 additions & 1 deletion .env_template
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
YOUTUBE_API_KEY=
MONGO_URI=
MONGO_DB=
JWT_SECRET_KEY=
STAGE=
DAILY_SUN_URL=
DAILY_SUN_URL=
GOOGLE_APPLICATION_CREDENTIALS=
FIREBASE_CREDENTIALS_HOST_PATH=./firebase-service-account-key.json
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,23 @@ To start the project, run the following command in the terminal
Create a Mongo database named `score_db` and another named `daily_sun_db`. A partnership with the Daily Sun has given us access to their articles which we copy and paginate the results for frontend.

Add /graphql to the url to access the interactive GraphQL platform

## Authentication

The backend verifies Google Firebase ID tokens and then issues its own JWT access
and refresh tokens. Configure these environment variables before starting the
server:

`JWT_SECRET_KEY` must be a long, random secret used to sign backend JWTs.

`GOOGLE_APPLICATION_CREDENTIALS` must point to the Firebase service-account JSON
file. For Docker Compose, set `FIREBASE_CREDENTIALS_HOST_PATH` to the host path
of that file; it is mounted into the container automatically.

Clients should call `signupUser` once with the Firebase `idToken`, or call
`loginUser` for an existing account. Send the returned access token on protected
requests using:

`Authorization: Bearer <access_token>`

Use the refresh token with `refreshAccessToken` after the access token expires.
23 changes: 23 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import argparse
import os
import signal
import sys
import time
Expand All @@ -23,6 +24,28 @@
from src.utils.team_loader import TeamLoader
from src.database import db, client

import firebase_admin
from firebase_admin import credentials

SERVICE_ACCOUNT_PATH = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")


def initialize_firebase():
"""Initialize Firebase Admin once so Firebase ID tokens can be verified."""
if not firebase_admin._apps:
if not SERVICE_ACCOUNT_PATH:
raise ValueError(
"GOOGLE_APPLICATION_CREDENTIALS is not set. "
"Set it to the Firebase service-account JSON path."
)
cred = credentials.Certificate(SERVICE_ACCOUNT_PATH)
firebase_admin.initialize_app(cred)
logging.info("Firebase app initialized.")
return firebase_admin.get_app()


initialize_firebase()

app = Flask(__name__)

# CORS: allow frontend (different origin) to call this API
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ services:
app:
image: cornellappdev/score-dev:${IMAGE_TAG}
env_file: .env
environment:
GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase.json
ports:
- "8000:8000"
volumes:
- ./ca-certificate.crt:/etc/ssl/ca-certificate.crt:ro # Mount MongoDB cert inside the container, ro for read only
- ${FIREBASE_CREDENTIALS_HOST_PATH:-./firebase-service-account-key.json}:/app/secrets/firebase.json:ro

scraper:
image: cornellappdev/score-dev:${IMAGE_TAG}
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ Flask-APScheduler
python-dotenv
pytz
gunicorn
firebase-admin==7.3.0
8 changes: 8 additions & 0 deletions src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ def setup_database_indexes():
# JWT blocklist: fast lookup by jti
db["token_blocklist"].create_index([("jti", 1)], background=True)

# One application user may be linked to only one Firebase account.
try:
db["users"].create_index(
[("firebase_uid", 1)], unique=True, sparse=True, background=True
)
except (DuplicateKeyError, OperationFailure) as e:
print(f"Warning: Could not create unique index on users.firebase_uid: {e}")

print("✅ MongoDB indexes created successfully")
except Exception as e:
print(f"❌ Failed to create MongoDB indexes: {e}")
Expand Down
3 changes: 2 additions & 1 deletion src/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .game import Game
from .team import Team
from .youtube_video import YoutubeVideo
from .article import Article
from .article import Article
from .user import User
48 changes: 48 additions & 0 deletions src/models/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional


def utc_now():
now = datetime.now(timezone.utc)
return now.replace(microsecond=(now.microsecond // 1000) * 1000)


@dataclass
class User:
"""Application user linked to an identity managed by Firebase."""

firebase_uid: Optional[str]
email: Optional[str] = None
name: Optional[str] = None
favorite_game_ids: list = field(default_factory=list)
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
id: object = None

def to_dict(self):
document = {
"firebase_uid": self.firebase_uid,
"email": self.email,
"name": self.name,
"favorite_game_ids": list(self.favorite_game_ids),
"created_at": self.created_at,
"updated_at": self.updated_at,
}
if self.id is not None:
document["_id"] = self.id
return document

@classmethod
def from_dict(cls, data):
if data is None:
return None
return cls(
id=data.get("_id"),
firebase_uid=data.get("firebase_uid"),
email=data.get("email"),
name=data.get("name"),
favorite_game_ids=list(data.get("favorite_game_ids") or []),
created_at=data.get("created_at") or utc_now(),
updated_at=data.get("updated_at") or utc_now(),
)
3 changes: 1 addition & 2 deletions src/mutations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,4 @@
from .signup_user import SignupUser
from .refresh_access_token import RefreshAccessToken
from .logout_user import LogoutUser
from .add_favorite_game import AddFavoriteGame
from .remove_favorite_game import RemoveFavoriteGame
from .favorite_game_mutations import AddFavoriteGame, RemoveFavoriteGame
25 changes: 0 additions & 25 deletions src/mutations/add_favorite_game.py

This file was deleted.

40 changes: 40 additions & 0 deletions src/mutations/favorite_game_mutations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from graphql import GraphQLError
from graphene import Boolean, Mutation, String

from flask_jwt_extended import get_jwt_identity
from src.services.game_service import GameService
from src.services.user_service import UserService
from src.utils.graphql_errors import graphql_jwt_required


class AddFavoriteGame(Mutation):
class Arguments:
game_id = String(required=True, description="ID of the game to add to favorites.")

success = Boolean()

@graphql_jwt_required()
def mutate(self, info, game_id):
user_id = get_jwt_identity()
if not UserService.require_user(user_id):
raise GraphQLError("User not found.")
if not GameService.get_game_by_id(game_id):
raise GraphQLError("Game not found.")
if not UserService.add_favorite_game(user_id, game_id):
raise GraphQLError("User not found.")
return AddFavoriteGame(success=True)


class RemoveFavoriteGame(Mutation):
class Arguments:
game_id = String(required=True, description="ID of the game to remove from favorites.")

success = Boolean()

@graphql_jwt_required()
def mutate(self, info, game_id):
user_id = get_jwt_identity()
if not UserService.require_user(user_id):
raise GraphQLError("User not found.")
UserService.remove_favorite_game(user_id, game_id)
return RemoveFavoriteGame(success=True)
34 changes: 28 additions & 6 deletions src/mutations/login_user.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
from graphql import GraphQLError
from graphene import Mutation, String, Field
from graphene import Field, Mutation, String

from firebase_admin import auth as firebase_auth
from flask_jwt_extended import create_access_token, create_refresh_token
from src.database import db
from src.services.user_service import UserService
from src.types import UserType

_TOKEN_ERRORS = (
firebase_auth.InvalidIdTokenError,
firebase_auth.ExpiredIdTokenError,
firebase_auth.RevokedIdTokenError,
)


class LoginUser(Mutation):
class Arguments:
net_id = String(required=True, description="User's net ID (e.g. Cornell netid).")
id_token = String(required=True, description="Google Firebase ID token from the client.")

access_token = String()
refresh_token = String()
user = Field(UserType, required=True)

def mutate(self, info, id_token):
try:
decoded = firebase_auth.verify_id_token(id_token)
except _TOKEN_ERRORS as err:
raise GraphQLError("Invalid or expired token.") from err
except ValueError as err:
raise GraphQLError("Invalid or expired token.") from err

firebase_uid = decoded.get("uid")
provider = decoded.get("firebase", {}).get("sign_in_provider")
if not firebase_uid or provider != "google.com":
raise GraphQLError("Google authentication required.")

def mutate(self, info, net_id):
user = db["users"].find_one({"net_id": net_id})
user = UserService.get_user_by_firebase_uid(firebase_uid)
if not user:
raise GraphQLError("User not found.")
identity = str(user["_id"])
identity = str(user.id)
return LoginUser(
access_token=create_access_token(identity=identity),
refresh_token=create_refresh_token(identity=identity),
user=user,
)
21 changes: 0 additions & 21 deletions src/mutations/remove_favorite_game.py

This file was deleted.

55 changes: 37 additions & 18 deletions src/mutations/signup_user.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,52 @@
from graphql import GraphQLError
from graphene import Mutation, String
from graphene import Field, Mutation, String

from firebase_admin import auth as firebase_auth
from flask_jwt_extended import create_access_token, create_refresh_token
from src.database import db
from pymongo.errors import DuplicateKeyError
from src.services.user_service import UserService
from src.types import UserType

_TOKEN_ERRORS = (
firebase_auth.InvalidIdTokenError,
firebase_auth.ExpiredIdTokenError,
firebase_auth.RevokedIdTokenError,
)


class SignupUser(Mutation):
class Arguments:
net_id = String(required=True, description="User's net ID (e.g. Cornell netid).")
name = String(required=False, description="Display name.")
email = String(required=False, description="Email address.")
id_token = String(required=True, description="Google Firebase ID token from the client.")

access_token = String()
refresh_token = String()
user = Field(UserType, required=True)

def mutate(self, info, id_token):
try:
decoded = firebase_auth.verify_id_token(id_token)
except _TOKEN_ERRORS as err:
raise GraphQLError("Invalid or expired token.") from err
except ValueError as err:
raise GraphQLError("Invalid or expired token.") from err

firebase_uid = decoded.get("uid")
provider = decoded.get("firebase", {}).get("sign_in_provider")
if not firebase_uid or provider != "google.com":
raise GraphQLError("Google authentication required.")

try:
user = UserService.create_user(
firebase_uid,
decoded.get("email"),
decoded.get("name"),
)
except DuplicateKeyError as err:
raise GraphQLError("User already exists.") from err

def mutate(self, info, net_id, name=None, email=None):
if db["users"].find_one({"net_id": net_id}):
raise GraphQLError("Net ID already exists.")
user_doc = {
"net_id": net_id,
"favorite_game_ids": [],
}
if name is not None:
user_doc["name"] = name
if email is not None:
user_doc["email"] = email
result = db["users"].insert_one(user_doc)
identity = str(result.inserted_id)
identity = str(user.id)
return SignupUser(
access_token=create_access_token(identity=identity),
refresh_token=create_refresh_token(identity=identity),
user=user,
)
3 changes: 2 additions & 1 deletion src/queries/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .game_query import GameQuery
from .team_query import TeamQuery
from .youtube_video_query import YoutubeVideoQuery
from .article_query import ArticleQuery
from .article_query import ArticleQuery
from .user_query import UserQuery
Loading
Loading