diff --git a/.gitignore b/.gitignore index 416602cc..e465fdcf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ **/.DS_Store **/*.cache **/*.egg-info +**/test.db \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index 3a8816c9..b70928a3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Firebase +serviceAccountKey.json \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 4db9cae3..ae286e24 100644 --- a/backend/README.md +++ b/backend/README.md @@ -110,6 +110,21 @@ pdm run ruff format . ## Environment Variables Environment variables are currently stored in an .env file within the base repository (not the backend folder). You will need to copy the local environment variables stored in the following notion [page](https://www.notion.so/uwblueprintexecs/Environment-Variables-11910f3fb1dc80e4bc67d35c3d65d073?pvs=4) to get the database working. +### Firebase Configuration +To set up Firebase authentication: + +1. Place your `serviceAccountKey.json` file in the `backend/` directory + - This file should be obtained from your Firebase Console + - Go to Project Settings > Service Accounts > Generate New Private Key + - The file contains sensitive credentials and is automatically gitignored + +2. Ensure your `.env` file includes the following Firebase-related variables: + ``` + FIREBASE_WEB_API_KEY=your_web_api_key + ``` + You can find these values in your Firebase Console under Project Settings. + +Note: Never commit `serviceAccountKey.json` to version control. It's already added to `.gitignore` for security. ## Adding a new model When adding a new model, make sure to add it to `app/models/__init__.py` so that the migration script can pick it up when autogenerating the new migration. diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 00000000..22df43f6 --- /dev/null +++ b/backend/app/middleware/auth.py @@ -0,0 +1,53 @@ +import logging +from typing import List + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from ..utilities.constants import LOGGER_NAME +from ..utilities.service_utils import get_auth_service + +security = HTTPBearer() +logger = logging.getLogger(LOGGER_NAME("auth")) + + +def has_roles(required_roles: List[str]): + """ + FastAPI dependency that checks if the authenticated user has + one of the required roles. + + Args: + required_roles: List of roles that can access the endpoint + + Returns: + A dependency that validates the user has one of the specified roles + + Example: + @app.get("/admin-only") + async def admin_endpoint(authorized: bool = has_roles(["admin"])): + return {"message": "You have admin access"} + """ + + async def role_validator( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + auth_service=Depends(get_auth_service), + ) -> bool: + # Get the token from authorization header + token = credentials.credentials + + # Use the auth service to check if the user has the required role + is_authorized = auth_service.is_authorized_by_role(token, set(required_roles)) + + if not is_authorized: + logger.warning( + f"Access denied: user doesn't have required roles: {required_roles}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: requires one of these roles: {required_roles}", + ) + + return True + + return Depends(role_validator) diff --git a/backend/app/middleware/auth_middleware.py b/backend/app/middleware/auth_middleware.py new file mode 100644 index 00000000..401e2ac0 --- /dev/null +++ b/backend/app/middleware/auth_middleware.py @@ -0,0 +1,90 @@ +import logging +from typing import List + +import firebase_admin.auth +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.types import ASGIApp + +from app.utilities.constants import LOGGER_NAME + + +class AuthMiddleware(BaseHTTPMiddleware): + def __init__(self, app: ASGIApp, public_paths: List[str] = None): + super().__init__(app) + self.public_paths = public_paths or [] + self.logger = logging.getLogger(LOGGER_NAME("auth_middleware")) + + def is_public_path(self, path: str) -> bool: + return path in self.public_paths + + async def dispatch(self, request: Request, call_next): + if self.is_public_path(request.url.path): + self.logger.info(f"Skipping auth for public path: {request.url.path}") + return await call_next(request) + + # Get authentication token from header + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + self.logger.warning( + f"Missing or invalid auth header for {request.url.path}" + ) + return JSONResponse( + status_code=401, content={"detail": "Authentication required"} + ) + + token = auth_header.split(" ")[1] + try: + # Verify the token with Firebase + self.logger.info(f"Verifying token for request to {request.url.path}") + decoded_token = firebase_admin.auth.verify_id_token( + token, check_revoked=True + ) + + # Get Firebase user information + firebase_user = firebase_admin.auth.get_user(decoded_token["uid"]) + + request.state.user_id = decoded_token["uid"] + request.state.user_email = decoded_token.get("email") + request.state.email_verified = firebase_user.email_verified + request.state.user_claims = decoded_token.get("claims", {}) + + # Add complete user info for convenience + request.state.user_info = { + "uid": decoded_token["uid"], + "email": decoded_token.get("email"), + "name": decoded_token.get("name", ""), + "picture": decoded_token.get("picture", ""), + "email_verified": firebase_user.email_verified, + } + + response = await call_next(request) + + if isinstance(response, Response): + response.headers["X-Auth-User-ID"] = decoded_token["uid"] + + return response + + except firebase_admin.auth.RevokedIdTokenError: + self.logger.warning(f"Token has been revoked: {request.url.path}") + return JSONResponse( + status_code=401, + content={"detail": "Token has been revoked. Please reauthenticate."}, + ) + except firebase_admin.auth.ExpiredIdTokenError: + self.logger.warning(f"Token has expired: {request.url.path}") + return JSONResponse( + status_code=401, + content={"detail": "Token has expired. Please reauthenticate."}, + ) + except firebase_admin.auth.InvalidIdTokenError as e: + self.logger.warning(f"Invalid token: {request.url.path}, error: {str(e)}") + return JSONResponse( + status_code=401, content={"detail": "Invalid authentication token"} + ) + except Exception as e: + self.logger.error(f"Authentication error for {request.url.path}: {str(e)}") + return JSONResponse( + status_code=401, content={"detail": f"Authentication failed: {str(e)}"} + ) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index e69de29b..fc2557cd 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from ..middleware.auth import UserRole +from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token +from ..schemas.user import UserCreateRequest, UserCreateResponse +from ..services.implementations.auth_service import AuthService +from ..services.implementations.user_service import UserService +from ..utilities.service_utils import get_auth_service, get_user_service + +router = APIRouter(prefix="/auth", tags=["auth"]) +security = HTTPBearer() + + +# TODO: ADD RATE LIMITING +@router.post("/register", response_model=UserCreateResponse) +async def register_user( + user: UserCreateRequest, user_service: UserService = Depends(get_user_service) +): + try: + return await user_service.create_user(user) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/login", response_model=AuthResponse) +async def login( + request: Request, + credentials: LoginRequest, + auth_service: AuthService = Depends(get_auth_service), +): + try: + is_admin_portal = request.headers.get("X-Admin-Portal") == "true" + auth_response = auth_service.generate_token( + credentials.email, credentials.password + ) + if is_admin_portal and not auth_service.is_authorized_by_role( + auth_response.access_token, {UserRole.ADMIN} + ): + raise HTTPException( + status_code=403, + detail="Access denied. Admin privileges required for admin portal", + ) + + return auth_response + + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/logout") +async def logout( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + auth_service: AuthService = Depends(get_auth_service), +): + try: + user_id = request.state.user_id + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required") + + auth_service.revoke_tokens(user_id) + return {"message": "Successfully logged out"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/refresh", response_model=Token) +async def refresh( + refresh_data: RefreshRequest, auth_service: AuthService = Depends(get_auth_service) +): + try: + return auth_service.renew_token(refresh_data.refresh_token) + except Exception as e: + raise HTTPException(status_code=401, detail=str(e)) diff --git a/backend/app/routes/test.py b/backend/app/routes/test.py new file mode 100644 index 00000000..c4020bd5 --- /dev/null +++ b/backend/app/routes/test.py @@ -0,0 +1,99 @@ +from typing import Any, Dict + +from fastapi import APIRouter, Request + +from ..middleware.auth import has_roles +from ..schemas.user import UserRole + +router = APIRouter(prefix="/test", tags=["test"]) + + +@router.get("/middleware") +async def test_middleware(request: Request) -> Dict[str, Any]: + state_dict = { + key: getattr(request.state, key) + for key in dir(request.state) + if not key.startswith("_") and not callable(getattr(request.state, key)) + } + + return { + "message": "Authentication successful! User info from Firebase token:", + "middleware_state": state_dict, + "user_id": getattr(request.state, "user_id", None), + "user_email": getattr(request.state, "user_email", None), + "email_verified": getattr(request.state, "email_verified", None), + "user_claims": getattr(request.state, "user_claims", None), + "user_info": getattr(request.state, "user_info", None), + "request_id": getattr(request.state, "request_id", None), + "authorization_header": request.headers.get("Authorization", "Not provided"), + } + + +@router.get("/middleware-public") +async def test_middleware_public(request: Request) -> Dict[str, Any]: + state_dict = { + key: getattr(request.state, key) + for key in dir(request.state) + if not key.startswith("_") and not callable(getattr(request.state, key)) + } + + auth_header = request.headers.get("Authorization") + auth_message = "No authentication required for this endpoint" + if auth_header: + auth_message += " (but you provided an auth header anyway)" + + return { + "message": "Public endpoint - No authentication required", + "auth_status": auth_message, + "middleware_state": state_dict, + "request_id": getattr(request.state, "request_id", None), + "authorization_header": request.headers.get("Authorization", "Not provided"), + } + + +@router.get("/role-admin") +async def test_role_admin( + request: Request, authorized: bool = has_roles([UserRole.ADMIN]) +) -> Dict[str, Any]: + return { + "message": "Successfully accessed an admin-only endpoint", + "user_id": request.state.user_id, + "user_email": request.state.user_email, + "role": "admin", + } + + +@router.get("/role-volunteer") +async def test_role_volunteer( + request: Request, authorized: bool = has_roles([UserRole.VOLUNTEER]) +) -> Dict[str, Any]: + return { + "message": "Successfully accessed a volunteer-only endpoint", + "user_id": request.state.user_id, + "user_email": request.state.user_email, + "role": "volunteer", + } + + +@router.get("/role-participant") +async def test_role_participant( + request: Request, authorized: bool = has_roles([UserRole.PARTICIPANT]) +) -> Dict[str, Any]: + return { + "message": "Successfully accessed a participant-only endpoint", + "user_id": request.state.user_id, + "user_email": request.state.user_email, + "role": "participant", + } + + +@router.get("/role-multiple") +async def test_role_multiple( + request: Request, authorized: bool = has_roles([UserRole.ADMIN, UserRole.VOLUNTEER]) +) -> Dict[str, Any]: + return { + "message": "Successfully accessed an admin or volunteer endpoint", + "user_id": request.state.user_id, + "user_email": request.state.user_email, + "roles_allowed": ["admin", "volunteer"], + } diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index 03143411..797248b2 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from app.schemas.user import UserCreateRequest, UserCreateResponse +from app.middleware.auth import has_roles +from app.schemas.user import UserCreateRequest, UserCreateResponse, UserRole from app.services.implementations.user_service import UserService -from app.utilities.db_utils import get_db +from app.utilities.service_utils import get_user_service router = APIRouter( prefix="/users", @@ -15,13 +15,12 @@ # allow signup methods other than email (like sign up w Google)?? -def get_user_service(db: Session = Depends(get_db)): - return UserService(db) - - +# admin only manually create user, not sure if this is needed @router.post("/", response_model=UserCreateResponse) async def create_user( - user: UserCreateRequest, user_service: UserService = Depends(get_user_service) + user: UserCreateRequest, + user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.ADMIN]), ): try: return await user_service.create_user(user) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 00000000..3ed4a768 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, ConfigDict + +from .user import UserCreateResponse + + +class LoginRequest(BaseModel): + email: str + password: str + + +class Token(BaseModel): + """ + For authentication tokens from Firebase + Access tokens are short-lived and used to access resources + Refresh tokens are long-lived and used to obtain new access tokens + """ + + access_token: str + refresh_token: str + + model_config = ConfigDict(from_attributes=True) + + +class RefreshRequest(BaseModel): + """Request body for token refresh""" + + refresh_token: str + + +class AuthResponse(Token): + """Authentication response containing tokens and user info""" + + user: UserCreateResponse + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 5d4f360c..b2c07191 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -52,7 +52,7 @@ class UserCreateRequest(UserBase): """ password: Optional[str] = Field(None, min_length=8) - auth_id: Optional[str] = Field(None) # for signup with google sso + auth_id: Optional[str] = Field(None) signup_method: SignUpMethod = Field(default=SignUpMethod.PASSWORD) @field_validator("password") diff --git a/backend/app/server.py b/backend/app/server.py index e403f0e7..8069129f 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -7,7 +7,8 @@ from fastapi.middleware.cors import CORSMiddleware from . import models -from .routes import send_email, user +from .middleware.auth_middleware import AuthMiddleware +from .routes import auth, send_email, test, user from .utilities.constants import LOGGER_NAME from .utilities.firebase_init import initialize_firebase from .utilities.ses.ses_init import ensure_ses_templates @@ -16,6 +17,18 @@ log = logging.getLogger(LOGGER_NAME("server")) +PUBLIC_PATHS = [ + "/", + "/docs", + "/redoc", + "/openapi.json", + "/auth/login", + "/auth/register", + "/health", + "/test-middleware-public", + "/email/send-test-email", +] + @asynccontextmanager async def lifespan(_: FastAPI): @@ -44,8 +57,12 @@ async def lifespan(_: FastAPI): allow_methods=["*"], allow_headers=["*"], ) + +app.add_middleware(AuthMiddleware, public_paths=PUBLIC_PATHS) +app.include_router(auth.router) app.include_router(user.router) app.include_router(send_email.router) +app.include_router(test.router) @app.get("/") diff --git a/backend/app/services/implementations/auth_service.py b/backend/app/services/implementations/auth_service.py new file mode 100644 index 00000000..3acb111e --- /dev/null +++ b/backend/app/services/implementations/auth_service.py @@ -0,0 +1,102 @@ +import logging + +import firebase_admin.auth +from fastapi import HTTPException + +from app.utilities.constants import LOGGER_NAME + +from ...interfaces.auth_service import IAuthService +from ...schemas.auth import AuthResponse, Token +from ...utilities.firebase_rest_client import FirebaseRestClient + + +class AuthService(IAuthService): + def __init__(self, logger, user_service): + self.logger = logging.getLogger(LOGGER_NAME("auth_service")) + self.user_service = user_service + self.firebase_client = FirebaseRestClient(logger) + + def generate_token(self, email: str, password: str) -> AuthResponse: + try: + token = self.firebase_client.sign_in_with_password(email, password) + user = self.user_service.get_user_by_email(email) + return AuthResponse( + access_token=token.access_token, + refresh_token=token.refresh_token, + user=user, + ) + except Exception as e: + self.logger.error(f"Failed to generate token: {str(e)}") + raise HTTPException(status_code=401, detail="Authentication failed") + + def generate_token_for_oauth(self, id_token: str) -> Token: + # TODO: Implement OAuth token generation + pass + + def revoke_tokens(self, user_id: str) -> None: + try: + auth_id = self.user_service.get_auth_id_by_user_id(user_id) + firebase_admin.auth.revoke_refresh_tokens(auth_id) + except Exception as e: + self.logger.error(f"Failed to revoke tokens: {str(e)}") + raise + + def renew_token(self, refresh_token: str) -> Token: + return self.firebase_client.refresh_token(refresh_token) + + def reset_password(self, email: str) -> None: + try: + firebase_admin.auth.generate_password_reset_link(email) + except Exception as e: + self.logger.error(f"Failed to reset password: {str(e)}") + raise + + def send_email_verification_link(self, email: str) -> None: + try: + firebase_admin.auth.generate_email_verification_link(email) + except Exception as e: + self.logger.error(f"Failed to send verification email: {str(e)}") + raise + + def is_authorized_by_role(self, access_token: str, roles: set[str]) -> bool: + try: + decoded_token = firebase_admin.auth.verify_id_token( + access_token, check_revoked=True + ) + user_role = self.user_service.get_user_role_by_auth_id(decoded_token["uid"]) + firebase_user = firebase_admin.auth.get_user(decoded_token["uid"]) + result = firebase_user.email_verified and user_role in roles + return result + except Exception as e: + print(f"Authorization error: {str(e)}") + return False + + def is_authorized_by_user_id( + self, access_token: str, requested_user_id: str + ) -> bool: + try: + decoded_token = firebase_admin.auth.verify_id_token( + access_token, check_revoked=True + ) + token_user_id = self.user_service.get_user_id_by_auth_id( + decoded_token["uid"] + ) + firebase_user = firebase_admin.auth.get_user(decoded_token["uid"]) + return firebase_user.email_verified and token_user_id == requested_user_id + except Exception as e: + print(f"Authorization error: {str(e)}") + return False + + def is_authorized_by_email(self, access_token: str, requested_email: str) -> bool: + try: + decoded_token = firebase_admin.auth.verify_id_token( + access_token, check_revoked=True + ) + firebase_user = firebase_admin.auth.get_user(decoded_token["uid"]) + return ( + firebase_user.email_verified + and decoded_token["email"] == requested_email + ) + except Exception as e: + print(f"Authorization error: {str(e)}") + return False diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index c2148213..391cbf54 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from app.interfaces.user_service import IUserService -from app.models import User +from app.models import Role, User from app.schemas.user import ( SignUpMethod, UserCreateRequest, @@ -84,20 +84,35 @@ def delete_user_by_email(self, email: str): def delete_user_by_id(self, user_id: str): pass - def get_auth_id_by_user_id(self, user_id: str) -> str: - pass + def get_user_id_by_auth_id(self, auth_id: str) -> str: + """Get user ID for a user by their Firebase auth_id""" + user = self.db.query(User).filter(User.auth_id == auth_id).first() + if not user: + raise ValueError(f"User with auth_id {auth_id} not found") + return str(user.id) # Convert UUID to string def get_user_by_email(self, email: str): - pass + user = self.db.query(User).filter(User.email == email).first() + if not user: + raise ValueError(f"User with email {email} not found") + return user def get_user_by_id(self, user_id: str): pass - def get_user_id_by_auth_id(self, auth_id: str) -> str: - pass + def get_auth_id_by_user_id(self, user_id: str) -> str: + """Get Firebase auth_id for a user""" + user = self.db.query(User).filter(User.id == user_id).first() + if not user: + raise ValueError(f"User with id {user_id} not found") + return user.auth_id def get_user_role_by_auth_id(self, auth_id: str) -> str: - pass + """Get role name for a user by their Firebase auth_id""" + user = self.db.query(User).join(Role).filter(User.auth_id == auth_id).first() + if not user: + raise ValueError(f"User with auth_id {auth_id} not found") + return user.role.name def get_users(self): pass diff --git a/backend/app/utilities/firebase_rest_client.py b/backend/app/utilities/firebase_rest_client.py index 764686a8..735b04ce 100644 --- a/backend/app/utilities/firebase_rest_client.py +++ b/backend/app/utilities/firebase_rest_client.py @@ -2,7 +2,7 @@ import requests -from ..resources.token import Token +from ..schemas.auth import Token FIREBASE_SIGN_IN_URL = ( "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword" @@ -63,7 +63,10 @@ def sign_in_with_password(self, email, password): raise Exception("Failed to sign-in via Firebase REST API") - return Token(response_json["idToken"], response_json["refreshToken"]) + return Token( + access_token=response_json["idToken"], + refresh_token=response_json["refreshToken"], + ) # docs: https://firebase.google.com/docs/reference/rest/auth/#section-sign-in-with-oauth-credential def sign_in_with_google(self, id_token): @@ -146,4 +149,7 @@ def refresh_token(self, ref_token): raise Exception("Failed to refresh token via Firebase REST API") - return Token(response_json["id_token"], response_json["refresh_token"]) + return Token( + access_token=response_json["id_token"], + refresh_token=response_json["refresh_token"], + ) diff --git a/backend/app/utilities/service_utils.py b/backend/app/utilities/service_utils.py new file mode 100644 index 00000000..46893b6e --- /dev/null +++ b/backend/app/utilities/service_utils.py @@ -0,0 +1,17 @@ +import logging + +from fastapi import Depends +from sqlalchemy.orm import Session + +from ..services.implementations.auth_service import AuthService +from ..services.implementations.user_service import UserService +from .db_utils import get_db + + +def get_user_service(db: Session = Depends(get_db)): + return UserService(db) + + +def get_auth_service(db: Session = Depends(get_db)): + logger = logging.getLogger(__name__) + return AuthService(logger=logger, user_service=UserService(db)) diff --git a/backend/pdm.lock b/backend/pdm.lock index fd86902e..5cdc2910 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -5,7 +5,7 @@ groups = ["default"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:25df3f1d1ac88503375e4065bbb639f76ed00694c3c838384908755aba2ad608" +content_hash = "sha256:9e47b00aee014713a4fb48d43fd8d74aaabcaceca75495b77c55006ac79ecd86" [[metadata.targets]] requires_python = "==3.12.*" @@ -366,21 +366,21 @@ files = [ [[package]] name = "firebase-admin" -version = "6.5.0" +version = "6.8.0" requires_python = ">=3.7" summary = "Firebase Admin Python SDK" groups = ["default"] dependencies = [ - "cachecontrol>=0.12.6", + "cachecontrol>=0.12.14", "google-api-core[grpc]<3.0.0dev,>=1.22.1; platform_python_implementation != \"PyPy\"", "google-api-python-client>=1.7.8", - "google-cloud-firestore>=2.9.1; platform_python_implementation != \"PyPy\"", + "google-cloud-firestore>=2.19.0; platform_python_implementation != \"PyPy\"", "google-cloud-storage>=1.37.1", "pyjwt[crypto]>=2.5.0", ] files = [ - {file = "firebase_admin-6.5.0-py3-none-any.whl", hash = "sha256:fe34ee3ca0e625c5156b3931ca4b4b69b5fc344dbe51bba9706ff674ce277898"}, - {file = "firebase_admin-6.5.0.tar.gz", hash = "sha256:e716dde1447f0a1cd1523be76ff872df33c4e1a3c079564ace033b2ad60bcc4f"}, + {file = "firebase_admin-6.8.0-py3-none-any.whl", hash = "sha256:84d5fd82859c4d27b63338c3fe9724667dfe400aa2fd9fef0efffbf6e23bca82"}, + {file = "firebase_admin-6.8.0.tar.gz", hash = "sha256:24a9870219cfd6578586357858e00758aea26a39df74c53be5d803f5654d883e"}, ] [[package]] @@ -487,22 +487,23 @@ files = [ [[package]] name = "google-cloud-firestore" -version = "2.18.0" +version = "2.20.2" requires_python = ">=3.7" summary = "Google Cloud Firestore API client library" groups = ["default"] marker = "platform_python_implementation != \"PyPy\"" dependencies = [ - "google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0dev,>=1.34.0", - "google-auth!=2.24.0,!=2.25.0,<3.0.0dev,>=2.14.1", - "google-cloud-core<3.0.0dev,>=1.4.1", - "proto-plus<2.0.0dev,>=1.22.0", - "proto-plus<2.0.0dev,>=1.22.2; python_version >= \"3.11\"", - "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<6.0.0dev,>=3.20.2", + "google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0", + "google-auth!=2.24.0,!=2.25.0,<3.0.0,>=2.14.1", + "google-cloud-core<3.0.0,>=1.4.1", + "proto-plus<2.0.0,>=1.22.0", + "proto-plus<2.0.0,>=1.22.2; python_version >= \"3.11\"", + "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", + "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0dev,>=3.20.2", ] files = [ - {file = "google_cloud_firestore-2.18.0-py2.py3-none-any.whl", hash = "sha256:9a735860b692f39f93f900dd3390713ceb9b47ea82cda98360bb551f03d2b916"}, - {file = "google_cloud_firestore-2.18.0.tar.gz", hash = "sha256:3db5dd42334b9904d82b3786703a5a4b576810fb50f61b8fa83ecf4f17b7fdae"}, + {file = "google_cloud_firestore-2.20.2-py3-none-any.whl", hash = "sha256:0ff7b4c66e3ad2fe00f7d5d8c15127bf4ff8b316c6e4eb635ac51d9a9bcd828b"}, + {file = "google_cloud_firestore-2.20.2.tar.gz", hash = "sha256:0ad2e33fa7da0ba8fb7ccc324f91d3f57866b770e24840bd62f6a272f747c5f9"}, ] [[package]] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 51f1635d..51029cef 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "ruff>=0.6.7", "pyright>=1.1.381", "python-dotenv>=1.0.1", - "firebase-admin>=6.5.0", + "firebase-admin>=6.8.0", "pytest>=8.3.3", "inflection>=0.5.1", "pre-commit>=4.0.0", diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/unit/test_auth.py b/backend/tests/unit/test_auth.py new file mode 100644 index 00000000..01ef996a --- /dev/null +++ b/backend/tests/unit/test_auth.py @@ -0,0 +1,209 @@ +from unittest.mock import AsyncMock, patch + +import firebase_admin.auth +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.middleware.auth import has_roles +from app.middleware.auth_middleware import AuthMiddleware +from app.schemas.user import UserRole + +# To test: public paths, protected routes, missing token, invalid token, +# revoked token, expired token +# also test routes w/ different permissions? (e.g. admin, user, etc.) + +# Create a test FastAPI app and apply AuthMiddleware +app = FastAPI() +app.add_middleware(AuthMiddleware, public_paths=["/public"]) + + +@app.get("/public") +async def public_route(): + return {"message": "This is a public route"} + + +@app.get("/protected") +async def protected_route(): + return {"message": "This is a protected route"} + + +# make one for admin, volunteer, participant + + +@app.get("/admin") +async def admin_route(authorized: bool = has_roles([UserRole.ADMIN])): + return {"message": "This is an admin-only route"} + + +@app.get("/volunteer") +async def volunteer_route(authorized: bool = has_roles([UserRole.VOLUNTEER])): + return {"message": "This is a volunteer-only route"} + + +@app.get("/participant") +async def participant_route(authorized: bool = has_roles([UserRole.PARTICIPANT])): + return {"message": "This is a participant-only route"} + + +client = TestClient(app) + + +@pytest.fixture +def mock_firebase(): # makes mock objects to get mock responses - get sample token + """Fixture to mock Firebase authentication methods.""" + with ( + patch("firebase_admin.auth.verify_id_token") as mock_verify, + patch("firebase_admin.auth.get_user") as mock_get_user, + ): + mock_verify.return_value = { + "uid": "test_user", + "email": "test@example.com", + "name": "Test User", + "picture": "https://example.com/picture.jpg", + "claims": {"role": "admin"}, + } + + mock_get_user.return_value = AsyncMock(email_verified=True) + + yield mock_verify, mock_get_user + + +@pytest.fixture +def mock_firebase_admin(): + with ( + patch("firebase_admin.auth.verify_id_token") as mock_verify, + patch("firebase_admin.auth.get_user") as mock_get_user, + ): + mock_verify.return_value = { + "uid": "test_user", + "email": "test@example.com", + "name": "Test User", + "picture": "https://example.com/picture.jpg", + "claims": {"role": "admin"}, + } + + mock_get_user.return_value = AsyncMock(email_verified=True) + + yield mock_verify, mock_get_user + + +@pytest.fixture +def mock_firebase_volunteer(): + with ( + patch("firebase_admin.auth.verify_id_token") as mock_verify, + patch("firebase_admin.auth.get_user") as mock_get_user, + ): + mock_verify.return_value = { + "uid": "test_user", + "email": "test@example.com", + "name": "Test User", + "picture": "https://example.com/picture.jpg", + "claims": {"role": "volunteer"}, + } + + mock_get_user.return_value = AsyncMock(email_verified=True) + + yield mock_verify, mock_get_user + + +@pytest.fixture +def mock_firebase_participant(): + with ( + patch("firebase_admin.auth.verify_id_token") as mock_verify, + patch("firebase_admin.auth.get_user") as mock_get_user, + ): + mock_verify.return_value = { + "uid": "test_user", + "email": "test@example.com", + "name": "Test User", + "picture": "https://example.com/picture.jpg", + "claims": {"role": "participant"}, + } + + mock_get_user.return_value = AsyncMock(email_verified=True) + + yield mock_verify, mock_get_user + + +def test_public_route(): + """Public routes should be accessible without authentication.""" + response = client.get("/public") + assert response.status_code == 200 # successful + assert response.json() == {"message": "This is a public route"} + + +def test_protected_route_access_granted(mock_firebase): + # mock firebase specifies for admin rn + """Authenticated users with a valid token should be granted access.""" + headers = {"Authorization": "Bearer valid_token"} + response = client.get("/protected", headers=headers) + + assert response.status_code == 200 + assert response.json() == {"message": "This is a protected route"} + assert "X-Auth-User-ID" in response.headers + + +def test_protected_route_participant_admin(mock_firebase_admin): + headers = {"Authorization": "Bearer valid_token"} + response = client.get("/admin", headers=headers) + assert response.status_code == 403 + + +def test_protected_route_volunteer_admin(mock_firebase_volunteer): + headers = {"Authorization": "Bearer valid_token"} + response = client.get("/admin", headers=headers) + assert response.status_code == 403 + + +def test_protected_route_missing_token(): + """Requests without an authentication token should be denied.""" + response = client.get("/protected") + + assert response.status_code == 401 + assert response.json()["detail"] == "Authentication required" + + +def test_protected_route_invalid_token(): + """Requests with an invalid authentication token should be denied.""" + with patch( + "firebase_admin.auth.verify_id_token", side_effect=Exception("Invalid Token") + ): + headers = {"Authorization": "Bearer invalid_token"} + response = client.get("/protected", headers=headers) + + assert response.status_code == 401 + assert "Authentication failed: Invalid Token" in response.json()["detail"] + + +def test_protected_route_revoked_token(): + """Requests with a revoked token should be denied.""" + with patch( + "firebase_admin.auth.verify_id_token", + side_effect=firebase_admin.auth.RevokedIdTokenError(message="Token revoked"), + ): + headers = {"Authorization": "Bearer revoked_token"} + response = client.get("/protected", headers=headers) + print(response.json()) + + assert response.status_code == 401 + assert ( + "Token has been revoked. Please reauthenticate." + in response.json()["detail"] + ) + + +def test_protected_route_expired_token(): + """Requests with an expired token should be denied.""" + with patch( + "firebase_admin.auth.verify_id_token", + side_effect=firebase_admin.auth.ExpiredIdTokenError( + message="Token expired", cause="test" + ), + ): + headers = {"Authorization": "Bearer expired_token"} + response = client.get("/protected", headers=headers) + print(response.json()) + + assert response.status_code == 401 + assert "Token has expired. Please reauthenticate." in response.json()["detail"]