From fa4cd8869bf6ca35e18caa70ec996cd3e6a42f20 Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:30:07 +0530 Subject: [PATCH 01/12] Create ratelimit.py --- backend/app/auth/ratelimit.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 backend/app/auth/ratelimit.py diff --git a/backend/app/auth/ratelimit.py b/backend/app/auth/ratelimit.py new file mode 100644 index 00000000..80bb0a71 --- /dev/null +++ b/backend/app/auth/ratelimit.py @@ -0,0 +1,29 @@ +from fastapi import Request, HTTPException, status +from collections import defaultdict +import time + +# In-memory storage for rate limiting +# In a production environment, you might want to use Redis or another shared storage. +rate_limit_data = defaultdict(lambda: {"count": 0, "timestamp": 0}) +RATE_LIMIT_DURATION = 60 # seconds +RATE_LIMIT_REQUESTS = 5 # requests + +def rate_limiter(request: Request): + """ + Rate limiting dependency to prevent brute force attacks. + """ + client_ip = request.client.host + current_time = time.time() + + if current_time - rate_limit_data[client_ip]["timestamp"] > RATE_LIMIT_DURATION: + # Reset counter if duration has passed + rate_limit_data[client_ip]["count"] = 1 + rate_limit_data[client_ip]["timestamp"] = current_time + else: + rate_limit_data[client_ip]["count"] += 1 + + if rate_limit_data[client_ip]["count"] > RATE_LIMIT_REQUESTS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Please try again later." + ) From 7a994c7869515a665c0b73da08630e67d3d5287a Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:30:50 +0530 Subject: [PATCH 02/12] Update routes.py --- backend/app/auth/routes.py | 67 +++++++------------------------------- 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index 8dbb7ee7..6b0e6d4c 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, Request from app.auth.schemas import ( EmailSignupRequest, EmailLoginRequest, GoogleLoginRequest, RefreshTokenRequest, PasswordResetRequest, PasswordResetConfirm, @@ -6,25 +6,22 @@ UserResponse, ErrorResponse ) from app.auth.service import auth_service -from app.auth.security import create_access_token, oauth2_scheme # Import oauth2_scheme -from fastapi.security import OAuth2PasswordRequestForm # Import OAuth2PasswordRequestForm +from app.auth.security import create_access_token, oauth2_scheme +from fastapi.security import OAuth2PasswordRequestForm from datetime import timedelta from app.config import settings +from app.auth.ratelimit import rate_limiter router = APIRouter(prefix="/auth", tags=["Authentication"]) -@router.post("/token", response_model=TokenResponse, include_in_schema=False) # include_in_schema=False to hide from docs if desired, or True to show +@router.post("/token", response_model=TokenResponse, include_in_schema=False) async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): """ OAuth2 compatible token login, get an access token for future requests. - This endpoint is used by Swagger UI for authorization. - It expects username (email) and password in form-data. """ try: - # Note: OAuth2PasswordRequestForm uses 'username' field for the user identifier. - # We'll treat it as email here. result = await auth_service.authenticate_user_with_email( - email=form_data.username, # form_data.username is the email + email=form_data.username, password=form_data.password ) @@ -37,25 +34,15 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends( except HTTPException: raise except Exception as e: - # It's good practice to log the exception here raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Authentication failed: {str(e)}" ) -@router.post("/signup/email", response_model=AuthResponse) +@router.post("/signup/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)]) async def signup_with_email(request: EmailSignupRequest): """ - Registers a new user using email, password, and name, and returns authentication tokens and user information. - - Args: - request: Contains the user's email, password, and name for registration. - - Returns: - An AuthResponse with access token, refresh token, and user details. - - Raises: - HTTPException: If registration fails or an unexpected error occurs. + Registers a new user using email, password, and name. """ try: result = await auth_service.create_user_with_email( @@ -64,13 +51,11 @@ async def signup_with_email(request: EmailSignupRequest): name=request.name ) - # Create access token access_token = create_access_token( data={"sub": str(result["user"]["_id"])}, expires_delta=timedelta(minutes=settings.access_token_expire_minutes) ) - # Convert ObjectId to string for response result["user"]["_id"] = str(result["user"]["_id"]) return AuthResponse( @@ -86,12 +71,10 @@ async def signup_with_email(request: EmailSignupRequest): detail=f"Registration failed: {str(e)}" ) -@router.post("/login/email", response_model=AuthResponse) +@router.post("/login/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)]) async def login_with_email(request: EmailLoginRequest): """ Authenticates a user using email and password credentials. - - On successful authentication, returns an access token, refresh token, and user information. Raises an HTTP 500 error if authentication fails due to an unexpected error. """ try: result = await auth_service.authenticate_user_with_email( @@ -99,13 +82,11 @@ async def login_with_email(request: EmailLoginRequest): password=request.password ) - # Create access token access_token = create_access_token( data={"sub": str(result["user"]["_id"])}, expires_delta=timedelta(minutes=settings.access_token_expire_minutes) ) - # Convert ObjectId to string for response result["user"]["_id"] = str(result["user"]["_id"]) return AuthResponse( @@ -125,19 +106,15 @@ async def login_with_email(request: EmailLoginRequest): async def login_with_google(request: GoogleLoginRequest): """ Authenticates or registers a user using a Google OAuth ID token. - - On success, returns an access token, refresh token, and user information. Raises an HTTP 500 error if Google authentication fails. """ try: result = await auth_service.authenticate_with_google(request.id_token) - # Create access token access_token = create_access_token( data={"sub": str(result["user"]["_id"])}, expires_delta=timedelta(minutes=settings.access_token_expire_minutes) ) - # Convert ObjectId to string for response result["user"]["_id"] = str(result["user"]["_id"]) return AuthResponse( @@ -157,16 +134,10 @@ async def login_with_google(request: GoogleLoginRequest): async def refresh_token(request: RefreshTokenRequest): """ Refreshes JWT tokens using a valid refresh token. - - Validates the provided refresh token, issues a new access token and refresh token if valid, and returns them. Raises a 401 error if the refresh token is invalid or revoked. - - Returns: - A TokenResponse containing the new access and refresh tokens. """ try: new_refresh_token = await auth_service.refresh_access_token(request.refresh_token) - # Get user from the new refresh token to create access token from app.database import get_database db = get_database() token_record = await db.refresh_tokens.find_one({ @@ -179,7 +150,7 @@ async def refresh_token(request: RefreshTokenRequest): status_code=status.HTTP_401_UNAUTHORIZED, detail="Failed to create new tokens" ) - # Create new access token + access_token = create_access_token( data={"sub": str(token_record["user_id"])}, expires_delta=timedelta(minutes=settings.access_token_expire_minutes) @@ -201,14 +172,10 @@ async def refresh_token(request: RefreshTokenRequest): async def verify_token(request: TokenVerifyRequest): """ Verifies an access token and returns the associated user information. - - Raises: - HTTPException: If the token is invalid or expired, returns a 401 Unauthorized error. """ try: user = await auth_service.verify_access_token(request.access_token) - # Convert ObjectId to string for response user["_id"] = str(user["_id"]) return UserResponse(**user) @@ -223,10 +190,7 @@ async def verify_token(request: TokenVerifyRequest): @router.post("/password/reset/request", response_model=SuccessResponse) async def request_password_reset(request: PasswordResetRequest): """ - Initiates a password reset process by sending a reset link to the provided email address. - - Returns: - SuccessResponse: Indicates whether the password reset email was sent if the email exists. + Initiates a password reset process by sending a reset link to the provided email. """ try: await auth_service.request_password_reset(request.email) @@ -244,15 +208,6 @@ async def request_password_reset(request: PasswordResetRequest): async def confirm_password_reset(request: PasswordResetConfirm): """ Resets a user's password using a valid password reset token. - - Args: - request: Contains the password reset token and the new password. - - Returns: - SuccessResponse indicating the password has been reset successfully. - - Raises: - HTTPException: If the reset token is invalid or an error occurs during the reset process. """ try: await auth_service.confirm_password_reset( From bd898dc09aba0c0e3621307dda92852e655bd114 Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:31:23 +0530 Subject: [PATCH 03/12] Update schemas.py --- backend/app/auth/schemas.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index 3676ec7a..65dadcf5 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -1,12 +1,24 @@ -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, validator from typing import Optional from datetime import datetime +import re -# Request Models class EmailSignupRequest(BaseModel): email: EmailStr - password: str = Field(..., min_length=6) - name: str = Field(..., min_length=1) + password: str = Field(..., min_length=8) + name: str = Field(..., min_length=1, max_length=100) + + @validator('password') + def password_complexity(cls, v): + if not re.search(r'[A-Z]', v): + raise ValueError('Password must contain at least one uppercase letter') + if not re.search(r'[a-z]', v): + raise ValueError('Password must contain at least one lowercase letter') + if not re.search(r'[0-9]', v): + raise ValueError('Password must contain at least one digit') + if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v): + raise ValueError('Password must contain at least one special character') + return v class EmailLoginRequest(BaseModel): email: EmailStr @@ -23,12 +35,23 @@ class PasswordResetRequest(BaseModel): class PasswordResetConfirm(BaseModel): reset_token: str - new_password: str = Field(..., min_length=6) + new_password: str = Field(..., min_length=8) + + @validator('new_password') + def password_complexity(cls, v): + if not re.search(r'[A-Z]', v): + raise ValueError('Password must contain at least one uppercase letter') + if not re.search(r'[a-z]', v): + raise ValueError('Password must contain at least one lowercase letter') + if not re.search(r'[0-9]', v): + raise ValueError('Password must contain at least one digit') + if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v): + raise ValueError('Password must contain at least one special character') + return v class TokenVerifyRequest(BaseModel): access_token: str -# Response Models class UserResponse(BaseModel): id: str = Field(alias="_id") email: str From 069993c9ad3a972a12ed846870ee5df62f9b335e Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:32:01 +0530 Subject: [PATCH 04/12] Update service.py --- backend/app/auth/service.py | 185 +++++++++++------------------------- 1 file changed, 58 insertions(+), 127 deletions(-) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 63a96e10..5466fbd3 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -11,10 +11,18 @@ from app.config import settings import os import json +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Account lockout settings +MAX_FAILED_ATTEMPTS = 5 +LOCKOUT_DURATION = timedelta(minutes=15) # Initialize Firebase Admin SDK if not firebase_admin._apps: - # First, check if we have credentials in environment variables if all([ settings.firebase_type, settings.firebase_project_id, @@ -22,12 +30,11 @@ settings.firebase_private_key, settings.firebase_client_email ]): - # Create a credential dictionary from environment variables cred_dict = { "type": settings.firebase_type, "project_id": settings.firebase_project_id, "private_key_id": settings.firebase_private_key_id, - "private_key": settings.firebase_private_key.replace("\\n", "\n"), # Replace escaped newlines + "private_key": settings.firebase_private_key.replace("\\n", "\n"), "client_email": settings.firebase_client_email, "client_id": settings.firebase_client_id, "auth_uri": settings.firebase_auth_uri, @@ -39,48 +46,26 @@ firebase_admin.initialize_app(cred, { 'projectId': settings.firebase_project_id, }) - print("Firebase initialized with credentials from environment variables") - # Fall back to service account JSON file if env vars are not available + logger.info("Firebase initialized with credentials from environment variables") elif os.path.exists(settings.firebase_service_account_path): cred = credentials.Certificate(settings.firebase_service_account_path) firebase_admin.initialize_app(cred, { 'projectId': settings.firebase_project_id, }) - print("Firebase initialized with service account file") + logger.info("Firebase initialized with service account file") else: - print("Firebase service account not found. Google auth will not work.") + logger.warning("Firebase service account not found. Google auth will not work.") class AuthService: def __init__(self): - # Initializes the AuthService instance. pass def get_db(self): - """ - Returns a database connection instance from the application's database module. - """ return get_database() async def create_user_with_email(self, email: str, password: str, name: str) -> Dict[str, Any]: - """ - Creates a new user account with the provided email, password, and name. - - Checks for existing users with the same email and raises an error if found. Stores the user with a hashed password and default profile fields, then generates and returns a refresh token along with the user data. - - Args: - email: The user's email address. - password: The user's plaintext password. - name: The user's display name. - - Returns: - A dictionary containing the created user document and a refresh token. - - Raises: - HTTPException: If a user with the given email already exists. - """ db = self.get_db() - # Check if user already exists existing_user = await db.users.find_one({"email": email}) if existing_user: raise HTTPException( @@ -88,7 +73,6 @@ async def create_user_with_email(self, email: str, password: str, name: str) -> detail="User with this email already exists" ) - # Create user document user_doc = { "email": email, "hashed_password": get_password_hash(password), @@ -97,14 +81,15 @@ async def create_user_with_email(self, email: str, password: str, name: str) -> "currency": "USD", "created_at": datetime.now(timezone.utc), "auth_provider": "email", - "firebase_uid": None + "firebase_uid": None, + "failed_login_attempts": 0, + "lockout_until": None } try: result = await db.users.insert_one(user_doc) user_doc["_id"] = str(result.inserted_id) - # Create refresh token refresh_token = await self._create_refresh_token_record(str(result.inserted_id)) return { @@ -118,24 +103,31 @@ async def create_user_with_email(self, email: str, password: str, name: str) -> ) async def authenticate_user_with_email(self, email: str, password: str) -> Dict[str, Any]: - """ - Authenticates a user using email and password credentials. - - Verifies the provided email and password against stored user data. If authentication succeeds, returns the user information and a new refresh token. Raises an HTTP 401 error if credentials are invalid. - - Returns: - A dictionary containing the authenticated user and a new refresh token. - """ db = self.get_db() user = await db.users.find_one({"email": email}) - if not user or not verify_password(password, user.get("hashed_password", "")): + + if not user: + logger.warning(f"Failed login attempt for non-existent user: {email}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + if user.get("lockout_until") and user["lockout_until"] > datetime.now(timezone.utc): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is locked. Please try again later." + ) + + if not verify_password(password, user.get("hashed_password", "")): + await self._handle_failed_login(user) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password" ) - # Create new refresh token + await self._reset_failed_login_attempts(user) refresh_token = await self._create_refresh_token_record(str(user["_id"])) return { @@ -143,20 +135,29 @@ async def authenticate_user_with_email(self, email: str, password: str) -> Dict[ "refresh_token": refresh_token } - async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: - """ - Authenticates a user using a Google OAuth ID token, creating a new user if necessary. + async def _handle_failed_login(self, user: Dict[str, Any]): + db = self.get_db() + new_attempts = user.get("failed_login_attempts", 0) + 1 - Verifies the provided Firebase ID token, retrieves or creates the corresponding user in the database, updates user information if needed, and issues a new refresh token. Raises an HTTP 400 error if the email is missing or if authentication fails, and HTTP 401 if the token is invalid. + update_data = {"$set": {"failed_login_attempts": new_attempts}} - Args: - id_token: The Firebase ID token obtained from Google OAuth. + if new_attempts >= MAX_FAILED_ATTEMPTS: + lockout_until = datetime.now(timezone.utc) + LOCKOUT_DURATION + update_data["$set"]["lockout_until"] = lockout_until + logger.warning(f"Account for user {user['email']} has been locked.") - Returns: - A dictionary containing the user data and a new refresh token. - """ + await db.users.update_one({"_id": user["_id"]}, update_data) + logger.warning(f"Failed login attempt for user: {user['email']}") + + async def _reset_failed_login_attempts(self, user: Dict[str, Any]): + db = self.get_db() + await db.users.update_one( + {"_id": user["_id"]}, + {"$set": {"failed_login_attempts": 0, "lockout_until": None}} + ) + + async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: try: - # Verify the Firebase ID token decoded_token = firebase_auth.verify_id_token(id_token) firebase_uid = decoded_token['uid'] email = decoded_token.get('email') @@ -171,14 +172,12 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: db = self.get_db() - # Check if user exists user = await db.users.find_one({"$or": [ {"email": email}, {"firebase_uid": firebase_uid} ]}) if user: - # Update user info if needed update_data = {} if user.get("firebase_uid") != firebase_uid: update_data["firebase_uid"] = firebase_uid @@ -192,7 +191,6 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: ) user.update(update_data) else: - # Create new user user_doc = { "email": email, "name": name, @@ -208,7 +206,6 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: user_doc["_id"] = result.inserted_id user = user_doc - # Create refresh token refresh_token = await self._create_refresh_token_record(str(user["_id"])) return { @@ -228,20 +225,8 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: ) async def refresh_access_token(self, refresh_token: str) -> str: - """ - Refreshes an access token by validating and rotating the provided refresh token. - - If the refresh token is valid and not expired, issues a new refresh token and revokes the old one. Raises an HTTP 401 error if the token is invalid, expired, or the associated user does not exist. - - Args: - refresh_token: The refresh token string to validate and rotate. - - Returns: - A new refresh token string. - """ db = self.get_db() - # Find and validate refresh token token_record = await db.refresh_tokens.find_one({ "token": refresh_token, "revoked": False, @@ -254,7 +239,6 @@ async def refresh_access_token(self, refresh_token: str) -> str: detail="Invalid or expired refresh token" ) - # Get user user = await db.users.find_one({"_id": token_record["user_id"]}) if not user: raise HTTPException( @@ -262,29 +246,16 @@ async def refresh_access_token(self, refresh_token: str) -> str: detail="User not found" ) - # Create new refresh token (token rotation) new_refresh_token = await self._create_refresh_token_record(str(user["_id"])) - # Revoke old token await db.refresh_tokens.update_one( {"_id": token_record["_id"]}, {"$set": {"revoked": True}} ) return new_refresh_token + async def verify_access_token(self, token: str) -> Dict[str, Any]: - """ - Verifies an access token and retrieves the associated user. - - Args: - token: The JWT access token to verify. - - Returns: - The user document corresponding to the token's subject. - - Raises: - HTTPException: If the token is invalid or the user does not exist. - """ from app.auth.security import verify_token payload = verify_token(token) @@ -297,7 +268,7 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: ) db = self.get_db() - user = await db.users.find_one({"_id": user_id}) + user = await db.users.find_one({"_id": ObjectId(user_id)}) if not user: raise HTTPException( @@ -308,23 +279,15 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: return user async def request_password_reset(self, email: str) -> bool: - """ - Initiates a password reset process for the specified email address. - - If the user exists, generates a password reset token with a 1-hour expiration and stores it in the database. The reset token and link are logged for development purposes. Always returns True to avoid revealing whether the email is registered. - """ db = self.get_db() user = await db.users.find_one({"email": email}) if not user: - # Don't reveal if email exists or not return True - # Generate reset token reset_token = generate_reset_token() - reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry + reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) - # Store reset token await db.password_resets.insert_one({ "user_id": user["_id"], "token": reset_token, @@ -333,32 +296,14 @@ async def request_password_reset(self, email: str) -> bool: "created_at": datetime.utcnow() }) - # For development/free tier: just log the reset token - # In production, you would send this via email - print(f"Password reset token for {email}: {reset_token}") - print(f"Reset link: https://yourapp.com/reset-password?token={reset_token}") + logger.info(f"Password reset token for {email}: {reset_token}") + logger.info(f"Reset link: https://yourapp.com/reset-password?token={reset_token}") return True async def confirm_password_reset(self, reset_token: str, new_password: str) -> bool: - """ - Confirms a password reset using a valid reset token and updates the user's password. - - Validates the reset token, updates the user's password, marks the token as used, and revokes all existing refresh tokens for the user to require re-authentication. - - Args: - reset_token: The password reset token to validate. - new_password: The new password to set for the user. - - Returns: - True if the password reset is successful. - - Raises: - HTTPException: If the reset token is invalid or expired. - """ db = self.get_db() - # Find and validate reset token reset_record = await db.password_resets.find_one({ "token": reset_token, "used": False, @@ -371,38 +316,25 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b detail="Invalid or expired reset token" ) - # Update user password new_hash = get_password_hash(new_password) await db.users.update_one( {"_id": reset_record["user_id"]}, {"$set": {"hashed_password": new_hash}} ) - # Mark token as used await db.password_resets.update_one( {"_id": reset_record["_id"]}, {"$set": {"used": True}} ) - # Revoke all refresh tokens for this user (force re-login) await db.refresh_tokens.update_many( {"user_id": reset_record["user_id"]}, {"$set": {"revoked": True}} ) return True + async def _create_refresh_token_record(self, user_id: str) -> str: - """ - Generates and stores a new refresh token for the specified user. - - Creates a refresh token with an expiration date and saves it in the database for token management and rotation. - - Args: - user_id: The unique identifier of the user for whom the refresh token is created. - - Returns: - The generated refresh token string. - """ db = self.get_db() refresh_token = create_refresh_token() @@ -418,5 +350,4 @@ async def _create_refresh_token_record(self, user_id: str) -> str: return refresh_token -# Create service instance auth_service = AuthService() From 596f54c42e2040781013001cf1fbfaaea7020d8b Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:32:49 +0530 Subject: [PATCH 05/12] Update security.py --- backend/app/auth/security.py | 72 ++---------------------------------- 1 file changed, 3 insertions(+), 69 deletions(-) diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index 53f1475e..aca3e699 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -7,53 +7,17 @@ from app.config import settings import secrets -# Password hashing with better bcrypt configuration -try: - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -except Exception: - # Fallback for bcrypt version compatibility issues - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") # Updated tokenUrl +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") def verify_password(plain_password: str, hashed_password: str) -> bool: - """ - Verifies whether a plaintext password matches a given hashed password. - - Args: - plain_password: The plaintext password to verify. - hashed_password: The hashed password to compare against. - - Returns: - True if the plaintext password matches the hash, otherwise False. - """ return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: - """ - Hashes a plaintext password using bcrypt. - - Args: - password: The plaintext password to hash. - - Returns: - The bcrypt-hashed password as a string. - """ return pwd_context.hash(password) def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: - """ - Creates a JWT access token embedding the provided data and an expiration time. - - If `expires_delta` is not specified, the token expires after the default duration from settings. The payload includes an expiration timestamp and a type field set to "access". The token is signed using the configured secret key and algorithm. - - Args: - data: The payload to include in the token. - expires_delta: Optional timedelta specifying how long the token is valid. - - Returns: - A signed JWT access token as a string. - """ to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta @@ -65,21 +29,9 @@ def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] return encoded_jwt def create_refresh_token() -> str: - """ - Generates a secure random refresh token as a URL-safe string. - - Returns: - A cryptographically secure, URL-safe refresh token string. - """ return secrets.token_urlsafe(32) def verify_token(token: str) -> Dict[str, Any]: - """ - Verifies and decodes a JWT token. - - If the token is invalid or cannot be verified, raises an HTTP 401 Unauthorized exception. - Returns the decoded token payload as a dictionary. - """ try: payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) return payload @@ -91,28 +43,10 @@ def verify_token(token: str) -> Dict[str, Any]: ) def generate_reset_token() -> str: - """ - Generates a secure, URL-safe token for password reset operations. - - Returns: - A random 32-byte URL-safe string suitable for use as a password reset token. - """ return secrets.token_urlsafe(32) def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]: - """ - Retrieves the current user based on the provided JWT token using centralized verification. - - Args: - token: The JWT token from which to extract the user information. - - Returns: - A dictionary containing the current user's information. - - Raises: - HTTPException: If the token is invalid or user information cannot be extracted. - """ - payload = verify_token(token) # Centralized JWT validation and error handling + payload = verify_token(token) user_id = payload.get("sub") if user_id is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") From 19ece5a79f31e845f813922501bb1b9c7811b781 Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:34:27 +0530 Subject: [PATCH 06/12] Update main.py --- backend/main.py | 81 +++++++++++-------------------------------------- 1 file changed, 17 insertions(+), 64 deletions(-) diff --git a/backend/main.py b/backend/main.py index 0fe083ad..1ef0f17b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,5 @@ -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response from contextlib import asynccontextmanager from app.database import connect_to_mongo, close_mongo_connection from app.auth.routes import router as auth_router @@ -8,18 +7,15 @@ from app.groups.routes import router as groups_router from app.expenses.routes import router as expenses_router, balance_router from app.config import settings +from fastapi.responses import JSONResponse @asynccontextmanager async def lifespan(app: FastAPI): # Startup - print("Lifespan: Connecting to MongoDB...") await connect_to_mongo() - print("Lifespan: MongoDB connected.") yield # Shutdown - print("Lifespan: Closing MongoDB connection...") await close_mongo_connection() - print("Lifespan: MongoDB connection closed.") app = FastAPI( title="Splitwiser API", @@ -30,78 +26,35 @@ async def lifespan(app: FastAPI): lifespan=lifespan ) -# CORS middleware - Enhanced configuration for production +# Security Headers Middleware +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; object-src 'none';" + return response + +# CORS middleware allowed_origins = [] if settings.allow_all_origins: - # Allow all origins in development mode allowed_origins = ["*"] - print("Development mode: CORS configured to allow all origins") elif settings.allowed_origins: - # Use specified origins in production mode - allowed_origins = [origin.strip() for origin in settings.allowed_origins.split(",") if origin.strip()] -else: - # Fallback to allow all origins if not specified (not recommended for production) - allowed_origins = ["*"] - -print(f"Allowed CORS origins: {allowed_origins}") + allowed_origins = [origin.strip() for origin in settings.allowed_origins.split(",")] app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], - allow_headers=[ - "Accept", - "Accept-Language", - "Content-Language", - "Content-Type", - "Authorization", - "X-Requested-With", - "Origin", - "Cache-Control", - "Pragma", - "X-CSRFToken" - ], - expose_headers=["*"], - max_age=3600, # Cache preflight responses for 1 hour + allow_methods=["*"], + allow_headers=["*"], ) -# Add a catch-all OPTIONS handler that should work for any path -@app.options("/{path:path}") -async def options_handler(request: Request, path: str): - """Handle all OPTIONS requests""" - print(f"OPTIONS request received for path: /{path}") - print(f"Origin: {request.headers.get('origin', 'No origin header')}") - - response = Response(status_code=200) - - # Manually set CORS headers for debugging - origin = request.headers.get("origin") - if origin and (origin in allowed_origins or "*" in allowed_origins): - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" - response.headers["Access-Control-Allow-Headers"] = "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Max-Age"] = "3600" - elif "*" in allowed_origins: - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" - response.headers["Access-Control-Allow-Headers"] = "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" - response.headers["Access-Control-Max-Age"] = "3600" - - return response - -# Health check @app.get("/health") async def health_check(): - """ - Returns the health status of the Splitwiser API service. - - This endpoint can be used for health checks and monitoring. - """ - return {"status": "healthy", "service": "Splitwiser API"} + return {"status": "healthy"} -# Include routers app.include_router(auth_router) app.include_router(user_router) app.include_router(groups_router) From 0c7f3c6e465c71b2dfd785a52cd0e37f97525acc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:19:38 +0000 Subject: [PATCH 07/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/app/auth/ratelimit.py | 8 +- backend/app/auth/routes.py | 149 ++++++++------- backend/app/auth/schemas.py | 44 +++-- backend/app/auth/security.py | 10 +- backend/app/auth/service.py | 341 ++++++++++++++++++---------------- backend/main.py | 83 ++++----- ui-poc/Home.py | 7 +- 7 files changed, 344 insertions(+), 298 deletions(-) diff --git a/backend/app/auth/ratelimit.py b/backend/app/auth/ratelimit.py index 80bb0a71..599b8aac 100644 --- a/backend/app/auth/ratelimit.py +++ b/backend/app/auth/ratelimit.py @@ -1,6 +1,7 @@ -from fastapi import Request, HTTPException, status -from collections import defaultdict import time +from collections import defaultdict + +from fastapi import HTTPException, Request, status # In-memory storage for rate limiting # In a production environment, you might want to use Redis or another shared storage. @@ -8,6 +9,7 @@ RATE_LIMIT_DURATION = 60 # seconds RATE_LIMIT_REQUESTS = 5 # requests + def rate_limiter(request: Request): """ Rate limiting dependency to prevent brute force attacks. @@ -25,5 +27,5 @@ def rate_limiter(request: Request): if rate_limit_data[client_ip]["count"] > RATE_LIMIT_REQUESTS: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many requests. Please try again later." + detail="Too many requests. Please try again later.", ) diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index fa695d1e..b55b20f4 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -1,19 +1,29 @@ -from fastapi import APIRouter, HTTPException, status, Depends, Request +from datetime import timedelta + +from app.auth.ratelimit import rate_limiter from app.auth.schemas import ( - EmailSignupRequest, EmailLoginRequest, GoogleLoginRequest, - RefreshTokenRequest, PasswordResetRequest, PasswordResetConfirm, - TokenVerifyRequest, AuthResponse, TokenResponse, SuccessResponse, - UserResponse, ErrorResponse + AuthResponse, + EmailLoginRequest, + EmailSignupRequest, + ErrorResponse, + GoogleLoginRequest, + PasswordResetConfirm, + PasswordResetRequest, + RefreshTokenRequest, + SuccessResponse, + TokenResponse, + TokenVerifyRequest, + UserResponse, ) -from app.auth.service import auth_service from app.auth.security import create_access_token, oauth2_scheme -from fastapi.security import OAuth2PasswordRequestForm -from datetime import timedelta +from app.auth.service import auth_service from app.config import settings -from app.auth.ratelimit import rate_limiter +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.security import OAuth2PasswordRequestForm router = APIRouter(prefix="/auth", tags=["Authentication"]) + @router.post("/token", response_model=TokenResponse, include_in_schema=False) async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): """ @@ -21,13 +31,13 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends( """ try: result = await auth_service.authenticate_user_with_email( - email=form_data.username, - password=form_data.password + email=form_data.username, password=form_data.password ) access_token = create_access_token( data={"sub": str(result["user"]["_id"])}, - expires_delta=timedelta(minutes=settings.access_token_expire_minutes) + expires_delta=timedelta( + minutes=settings.access_token_expire_minutes), ) return TokenResponse(access_token=access_token, token_type="bearer") @@ -36,72 +46,78 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends( except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Authentication failed: {str(e)}" + detail=f"Authentication failed: {str(e)}", ) -@router.post("/signup/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)]) + +@router.post( + "/signup/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)] +) async def signup_with_email(request: EmailSignupRequest): """ Registers a new user using email, password, and name. """ try: result = await auth_service.create_user_with_email( - email=request.email, - password=request.password, - name=request.name + email=request.email, password=request.password, name=request.name ) - + access_token = create_access_token( data={"sub": str(result["user"]["_id"])}, - expires_delta=timedelta(minutes=settings.access_token_expire_minutes) + expires_delta=timedelta( + minutes=settings.access_token_expire_minutes), ) - + result["user"]["_id"] = str(result["user"]["_id"]) - + return AuthResponse( access_token=access_token, refresh_token=result["refresh_token"], - user=UserResponse(**result["user"]) + user=UserResponse(**result["user"]), ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Registration failed: {str(e)}" + detail=f"Registration failed: {str(e)}", ) -@router.post("/login/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)]) + +@router.post( + "/login/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)] +) async def login_with_email(request: EmailLoginRequest): """ Authenticates a user using email and password credentials. """ try: result = await auth_service.authenticate_user_with_email( - email=request.email, - password=request.password + email=request.email, password=request.password ) - + access_token = create_access_token( data={"sub": str(result["user"]["_id"])}, - expires_delta=timedelta(minutes=settings.access_token_expire_minutes) + expires_delta=timedelta( + minutes=settings.access_token_expire_minutes), ) - + result["user"]["_id"] = str(result["user"]["_id"]) - + return AuthResponse( access_token=access_token, refresh_token=result["refresh_token"], - user=UserResponse(**result["user"]) + user=UserResponse(**result["user"]), ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Login failed: {str(e)}" + detail=f"Login failed: {str(e)}", ) + @router.post("/login/google", response_model=AuthResponse) async def login_with_google(request: GoogleLoginRequest): """ @@ -109,65 +125,68 @@ async def login_with_google(request: GoogleLoginRequest): """ try: result = await auth_service.authenticate_with_google(request.id_token) - + access_token = create_access_token( data={"sub": str(result["user"]["_id"])}, - expires_delta=timedelta(minutes=settings.access_token_expire_minutes) + expires_delta=timedelta( + minutes=settings.access_token_expire_minutes), ) - + result["user"]["_id"] = str(result["user"]["_id"]) - + return AuthResponse( access_token=access_token, refresh_token=result["refresh_token"], - user=UserResponse(**result["user"]) + user=UserResponse(**result["user"]), ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Google authentication failed: {str(e)}" + detail=f"Google authentication failed: {str(e)}", ) + @router.post("/refresh", response_model=TokenResponse) async def refresh_token(request: RefreshTokenRequest): """ Refreshes JWT tokens using a valid refresh token. """ try: - new_refresh_token = await auth_service.refresh_access_token(request.refresh_token) - + new_refresh_token = await auth_service.refresh_access_token( + request.refresh_token + ) + from app.database import get_database + db = get_database() - token_record = await db.refresh_tokens.find_one({ - "token": new_refresh_token, - "revoked": False - }) - + token_record = await db.refresh_tokens.find_one( + {"token": new_refresh_token, "revoked": False} + ) + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Failed to create new tokens" + detail="Failed to create new tokens", ) access_token = create_access_token( data={"sub": str(token_record["user_id"])}, - expires_delta=timedelta(minutes=settings.access_token_expire_minutes) - ) - - return TokenResponse( - access_token=access_token, - refresh_token=new_refresh_token + expires_delta=timedelta( + minutes=settings.access_token_expire_minutes), ) + + return TokenResponse(access_token=access_token, refresh_token=new_refresh_token) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Token refresh failed: {str(e)}" + detail=f"Token refresh failed: {str(e)}", ) + @router.post("/token/verify", response_model=UserResponse) async def verify_token(request: TokenVerifyRequest): """ @@ -175,18 +194,18 @@ async def verify_token(request: TokenVerifyRequest): """ try: user = await auth_service.verify_access_token(request.access_token) - + user["_id"] = str(user["_id"]) - + return UserResponse(**user) except HTTPException: raise except Exception as e: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired token" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" ) + @router.post("/password/reset/request", response_model=SuccessResponse) async def request_password_reset(request: PasswordResetRequest): """ @@ -195,15 +214,15 @@ async def request_password_reset(request: PasswordResetRequest): try: await auth_service.request_password_reset(request.email) return SuccessResponse( - success=True, - message="If the email exists, a reset link has been sent" + success=True, message="If the email exists, a reset link has been sent" ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Password reset request failed: {str(e)}" + detail=f"Password reset request failed: {str(e)}", ) + @router.post("/password/reset/confirm", response_model=SuccessResponse) async def confirm_password_reset(request: PasswordResetConfirm): """ @@ -211,17 +230,15 @@ async def confirm_password_reset(request: PasswordResetConfirm): """ try: await auth_service.confirm_password_reset( - reset_token=request.reset_token, - new_password=request.new_password + reset_token=request.reset_token, new_password=request.new_password ) return SuccessResponse( - success=True, - message="Password has been reset successfully" + success=True, message="Password has been reset successfully" ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Password reset failed: {str(e)}" - ) \ No newline at end of file + detail=f"Password reset failed: {str(e)}", + ) diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index aaa56ae7..3037441f 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -1,11 +1,8 @@ -from pydantic import BaseModel, EmailStr, Field, validator -from typing import Optional -from datetime import datetime import re from datetime import datetime from typing import Optional -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, validator class EmailSignupRequest(BaseModel): @@ -13,16 +10,19 @@ class EmailSignupRequest(BaseModel): password: str = Field(..., min_length=8) name: str = Field(..., min_length=1, max_length=100) - @validator('password') + @validator("password") def password_complexity(cls, v): - if not re.search(r'[A-Z]', v): - raise ValueError('Password must contain at least one uppercase letter') - if not re.search(r'[a-z]', v): - raise ValueError('Password must contain at least one lowercase letter') - if not re.search(r'[0-9]', v): - raise ValueError('Password must contain at least one digit') + if not re.search(r"[A-Z]", v): + raise ValueError( + "Password must contain at least one uppercase letter") + if not re.search(r"[a-z]", v): + raise ValueError( + "Password must contain at least one lowercase letter") + if not re.search(r"[0-9]", v): + raise ValueError("Password must contain at least one digit") if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v): - raise ValueError('Password must contain at least one special character') + raise ValueError( + "Password must contain at least one special character") return v @@ -47,22 +47,26 @@ class PasswordResetConfirm(BaseModel): reset_token: str new_password: str = Field(..., min_length=8) - @validator('new_password') + @validator("new_password") def password_complexity(cls, v): - if not re.search(r'[A-Z]', v): - raise ValueError('Password must contain at least one uppercase letter') - if not re.search(r'[a-z]', v): - raise ValueError('Password must contain at least one lowercase letter') - if not re.search(r'[0-9]', v): - raise ValueError('Password must contain at least one digit') + if not re.search(r"[A-Z]", v): + raise ValueError( + "Password must contain at least one uppercase letter") + if not re.search(r"[a-z]", v): + raise ValueError( + "Password must contain at least one lowercase letter") + if not re.search(r"[0-9]", v): + raise ValueError("Password must contain at least one digit") if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v): - raise ValueError('Password must contain at least one special character') + raise ValueError( + "Password must contain at least one special character") return v class TokenVerifyRequest(BaseModel): access_token: str + class UserResponse(BaseModel): id: str = Field(alias="_id") email: str diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index 2177ea4e..1cae45fe 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -8,14 +8,15 @@ from jose import JWTError, jwt from passlib.context import CryptContext - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") + def verify_password(plain_password: str, hashed_password: str) -> bool: -# Password hashing with better bcrypt configuration + + # Password hashing with better bcrypt configuration try: pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") except Exception: @@ -46,8 +47,8 @@ def get_password_hash(password: str) -> str: return pwd_context.hash(password) -def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: """ Hashes a plaintext password using bcrypt. @@ -92,7 +93,6 @@ def create_access_token( def create_refresh_token() -> str: - """ Generates a secure random refresh token as a URL-safe string. @@ -104,7 +104,6 @@ def create_refresh_token() -> str: def verify_token(token: str) -> Dict[str, Any]: - """ Verifies and decodes a JWT token. @@ -125,7 +124,6 @@ def verify_token(token: str) -> Dict[str, Any]: def generate_reset_token() -> str: - """ Generates a secure, URL-safe token for password reset operations. diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 35a0b69d..fd37f539 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,17 +1,24 @@ +import json +import logging +import os from datetime import datetime, timedelta, timezone -from typing import Optional, Dict, Any -from pymongo.errors import DuplicateKeyError -from bson import ObjectId -from fastapi import HTTPException, status -from app.database import get_database -from app.auth.security import get_password_hash, verify_password, create_refresh_token, generate_reset_token -from app.auth.schemas import UserResponse +from typing import Any, Dict, Optional + import firebase_admin -from firebase_admin import auth as firebase_auth, credentials +from app.auth.schemas import UserResponse +from app.auth.security import ( + create_refresh_token, + generate_reset_token, + get_password_hash, + verify_password, +) from app.config import settings -import os -import json -import logging +from app.database import get_database +from bson import ObjectId +from fastapi import HTTPException, status +from firebase_admin import auth as firebase_auth +from firebase_admin import credentials +from pymongo.errors import DuplicateKeyError # Configure logging logging.basicConfig(level=logging.INFO) @@ -23,13 +30,15 @@ # Initialize Firebase Admin SDK if not firebase_admin._apps: - if all([ - settings.firebase_type, - settings.firebase_project_id, - settings.firebase_private_key_id, - settings.firebase_private_key, - settings.firebase_client_email - ]): + if all( + [ + settings.firebase_type, + settings.firebase_project_id, + settings.firebase_private_key_id, + settings.firebase_private_key, + settings.firebase_client_email, + ] + ): cred_dict = { "type": settings.firebase_type, "project_id": settings.firebase_project_id, @@ -40,39 +49,50 @@ "auth_uri": settings.firebase_auth_uri, "token_uri": settings.firebase_token_uri, "auth_provider_x509_cert_url": settings.firebase_auth_provider_x509_cert_url, - "client_x509_cert_url": settings.firebase_client_x509_cert_url + "client_x509_cert_url": settings.firebase_client_x509_cert_url, } cred = credentials.Certificate(cred_dict) - firebase_admin.initialize_app(cred, { - 'projectId': settings.firebase_project_id, - }) - logger.info("Firebase initialized with credentials from environment variables") + firebase_admin.initialize_app( + cred, + { + "projectId": settings.firebase_project_id, + }, + ) + logger.info( + "Firebase initialized with credentials from environment variables") elif os.path.exists(settings.firebase_service_account_path): cred = credentials.Certificate(settings.firebase_service_account_path) - firebase_admin.initialize_app(cred, { - 'projectId': settings.firebase_project_id, - }) + firebase_admin.initialize_app( + cred, + { + "projectId": settings.firebase_project_id, + }, + ) logger.info("Firebase initialized with service account file") else: - logger.warning("Firebase service account not found. Google auth will not work.") + logger.warning( + "Firebase service account not found. Google auth will not work.") + class AuthService: def __init__(self): pass - + def get_db(self): return get_database() - async def create_user_with_email(self, email: str, password: str, name: str) -> Dict[str, Any]: + async def create_user_with_email( + self, email: str, password: str, name: str + ) -> Dict[str, Any]: db = self.get_db() - + existing_user = await db.users.find_one({"email": email}) if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User with this email already exists" + detail="User with this email already exists", ) - + user_doc = { "email": email, "hashed_password": get_password_hash(password), @@ -83,69 +103,71 @@ async def create_user_with_email(self, email: str, password: str, name: str) -> "auth_provider": "email", "firebase_uid": None, "failed_login_attempts": 0, - "lockout_until": None + "lockout_until": None, } - + try: result = await db.users.insert_one(user_doc) user_doc["_id"] = str(result.inserted_id) - - refresh_token = await self._create_refresh_token_record(str(result.inserted_id)) - - return { - "user": user_doc, - "refresh_token": refresh_token - } + + refresh_token = await self._create_refresh_token_record( + str(result.inserted_id) + ) + + return {"user": user_doc, "refresh_token": refresh_token} except DuplicateKeyError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User with this email already exists" + detail="User with this email already exists", ) - async def authenticate_user_with_email(self, email: str, password: str) -> Dict[str, Any]: + async def authenticate_user_with_email( + self, email: str, password: str + ) -> Dict[str, Any]: db = self.get_db() - + user = await db.users.find_one({"email": email}) - + if not user: - logger.warning(f"Failed login attempt for non-existent user: {email}") + logger.warning( + f"Failed login attempt for non-existent user: {email}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password" + detail="Incorrect email or password", ) - if user.get("lockout_until") and user["lockout_until"] > datetime.now(timezone.utc): + if user.get("lockout_until") and user["lockout_until"] > datetime.now( + timezone.utc + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Account is locked. Please try again later." + detail="Account is locked. Please try again later.", ) if not verify_password(password, user.get("hashed_password", "")): await self._handle_failed_login(user) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password" + detail="Incorrect email or password", ) - + await self._reset_failed_login_attempts(user) refresh_token = await self._create_refresh_token_record(str(user["_id"])) - - return { - "user": user, - "refresh_token": refresh_token - } + + return {"user": user, "refresh_token": refresh_token} async def _handle_failed_login(self, user: Dict[str, Any]): db = self.get_db() new_attempts = user.get("failed_login_attempts", 0) + 1 - + update_data = {"$set": {"failed_login_attempts": new_attempts}} - + if new_attempts >= MAX_FAILED_ATTEMPTS: lockout_until = datetime.now(timezone.utc) + LOCKOUT_DURATION update_data["$set"]["lockout_until"] = lockout_until - logger.warning(f"Account for user {user['email']} has been locked.") - + logger.warning( + f"Account for user {user['email']} has been locked.") + await db.users.update_one({"_id": user["_id"]}, update_data) logger.warning(f"Failed login attempt for user: {user['email']}") @@ -153,41 +175,40 @@ async def _reset_failed_login_attempts(self, user: Dict[str, Any]): db = self.get_db() await db.users.update_one( {"_id": user["_id"]}, - {"$set": {"failed_login_attempts": 0, "lockout_until": None}} + {"$set": {"failed_login_attempts": 0, "lockout_until": None}}, ) async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: try: decoded_token = firebase_auth.verify_id_token(id_token) - firebase_uid = decoded_token['uid'] - email = decoded_token.get('email') - name = decoded_token.get('name', email.split('@')[0] if email else 'User') - picture = decoded_token.get('picture') - + firebase_uid = decoded_token["uid"] + email = decoded_token.get("email") + name = decoded_token.get( + "name", email.split("@")[0] if email else "User") + picture = decoded_token.get("picture") + if not email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Email not provided by Google" + detail="Email not provided by Google", ) - + db = self.get_db() - - user = await db.users.find_one({"$or": [ - {"email": email}, - {"firebase_uid": firebase_uid} - ]}) - + + user = await db.users.find_one( + {"$or": [{"email": email}, {"firebase_uid": firebase_uid}]} + ) + if user: update_data = {} if user.get("firebase_uid") != firebase_uid: update_data["firebase_uid"] = firebase_uid if user.get("avatar") != picture and picture: update_data["avatar"] = picture - + if update_data: await db.users.update_one( - {"_id": user["_id"]}, - {"$set": update_data} + {"_id": user["_id"]}, {"$set": update_data} ) user.update(update_data) else: @@ -199,155 +220,159 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: "created_at": datetime.now(timezone.utc), "auth_provider": "google", "firebase_uid": firebase_uid, - "hashed_password": None + "hashed_password": None, } - + result = await db.users.insert_one(user_doc) user_doc["_id"] = result.inserted_id user = user_doc - + refresh_token = await self._create_refresh_token_record(str(user["_id"])) - - return { - "user": user, - "refresh_token": refresh_token - } - + + return {"user": user, "refresh_token": refresh_token} + except firebase_auth.InvalidIdTokenError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid Google ID token" + detail="Invalid Google ID token", ) except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Google authentication failed: {str(e)}" + detail=f"Google authentication failed: {str(e)}", ) async def refresh_access_token(self, refresh_token: str) -> str: db = self.get_db() - - token_record = await db.refresh_tokens.find_one({ - "token": refresh_token, - "revoked": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) - + + token_record = await db.refresh_tokens.find_one( + { + "token": refresh_token, + "revoked": False, + "expires_at": {"$gt": datetime.now(timezone.utc)}, + } + ) + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired refresh token" + detail="Invalid or expired refresh token", ) - + user = await db.users.find_one({"_id": token_record["user_id"]}) if not user: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found" + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) - + new_refresh_token = await self._create_refresh_token_record(str(user["_id"])) - + await db.refresh_tokens.update_one( - {"_id": token_record["_id"]}, - {"$set": {"revoked": True}} + {"_id": token_record["_id"]}, {"$set": {"revoked": True}} ) - - return new_refresh_token + + return new_refresh_token async def verify_access_token(self, token: str) -> Dict[str, Any]: from app.auth.security import verify_token - + payload = verify_token(token) user_id = payload.get("sub") - + if not user_id: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) - + db = self.get_db() user = await db.users.find_one({"_id": ObjectId(user_id)}) - + if not user: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found" + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) - + return user async def request_password_reset(self, email: str) -> bool: db = self.get_db() - + user = await db.users.find_one({"email": email}) if not user: return True - + reset_token = generate_reset_token() reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) - - await db.password_resets.insert_one({ - "user_id": user["_id"], - "token": reset_token, - "expires_at": reset_expires, - "used": False, - "created_at": datetime.utcnow() - }) - + + await db.password_resets.insert_one( + { + "user_id": user["_id"], + "token": reset_token, + "expires_at": reset_expires, + "used": False, + "created_at": datetime.utcnow(), + } + ) + logger.info(f"Password reset token for {email}: {reset_token}") - logger.info(f"Reset link: https://yourapp.com/reset-password?token={reset_token}") - + logger.info( + f"Reset link: https://yourapp.com/reset-password?token={reset_token}" + ) + return True async def confirm_password_reset(self, reset_token: str, new_password: str) -> bool: db = self.get_db() - - reset_record = await db.password_resets.find_one({ - "token": reset_token, - "used": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) - + + reset_record = await db.password_resets.find_one( + { + "token": reset_token, + "used": False, + "expires_at": {"$gt": datetime.now(timezone.utc)}, + } + ) + if not reset_record: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid or expired reset token" + detail="Invalid or expired reset token", ) - + new_hash = get_password_hash(new_password) await db.users.update_one( - {"_id": reset_record["user_id"]}, - {"$set": {"hashed_password": new_hash}} + {"_id": reset_record["user_id"]}, { + "$set": {"hashed_password": new_hash}} ) - + await db.password_resets.update_one( - {"_id": reset_record["_id"]}, - {"$set": {"used": True}} + {"_id": reset_record["_id"]}, {"$set": {"used": True}} ) - + await db.refresh_tokens.update_many( - {"user_id": reset_record["user_id"]}, - {"$set": {"revoked": True}} + {"user_id": reset_record["user_id"]}, {"$set": {"revoked": True}} ) - - return True + + return True async def _create_refresh_token_record(self, user_id: str) -> str: db = self.get_db() - + refresh_token = create_refresh_token() - expires_at = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days) - - await db.refresh_tokens.insert_one({ - "token": refresh_token, - "user_id": ObjectId(user_id) if isinstance(user_id, str) else user_id, - "expires_at": expires_at, - "revoked": False, - "created_at": datetime.now(timezone.utc) - }) - + expires_at = datetime.now(timezone.utc) + timedelta( + days=settings.refresh_token_expire_days + ) + + await db.refresh_tokens.insert_one( + { + "token": refresh_token, + "user_id": ObjectId(user_id) if isinstance(user_id, str) else user_id, + "expires_at": expires_at, + "revoked": False, + "created_at": datetime.now(timezone.utc), + } + ) + return refresh_token -auth_service = AuthService() \ No newline at end of file + +auth_service = AuthService() diff --git a/backend/main.py b/backend/main.py index ca086d3d..8025deb5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,3 @@ -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from app.auth.routes import router as auth_router @@ -8,15 +6,10 @@ from app.expenses.routes import balance_router from app.expenses.routes import router as expenses_router from app.groups.routes import router as groups_router - -from app.expenses.routes import router as expenses_router, balance_router -from app.config import settings -from fastapi.responses import JSONResponse - from app.user.routes import router as user_router from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response +from fastapi.responses import JSONResponse, Response @asynccontextmanager @@ -46,6 +39,8 @@ async def lifespan(app: FastAPI): ) # Security Headers Middleware + + @app.middleware("http") async def add_security_headers(request: Request, call_next): response = await call_next(request) @@ -61,7 +56,8 @@ async def add_security_headers(request: Request, call_next): allowed_origins = ["*"] elif settings.allowed_origins: - allowed_origins = [origin.strip() for origin in settings.allowed_origins.split(",")] + allowed_origins = [origin.strip() + for origin in settings.allowed_origins.split(",")] logger.debug("Development mode: CORS configured to allow all origins") elif settings.allowed_origins: @@ -88,12 +84,14 @@ async def add_security_headers(request: Request, call_next): allow_headers=["*"], ) + @app.get("/health") async def health_check(): return {"status": "healthy"} - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], - allow_headers=[ + allow_methods = ["GET", "POST", "PUT", + "DELETE", "OPTIONS", "HEAD", "PATCH"], + allow_headers = [ "Accept", "Accept-Language", "Content-Language", @@ -105,14 +103,14 @@ async def health_check(): "Pragma", "X-CSRFToken", ], - expose_headers=["*"], - max_age=3600, # Cache preflight responses for 1 hour + expose_headers = ["*"], + max_age = 3600, # Cache preflight responses for 1 hour ) -# Add a catch-all OPTIONS handler that should work for any path -@app.options("/{path:path}") -async def options_handler(request: Request, path: str): + # Add a catch-all OPTIONS handler that should work for any path + @ app.options("/{path:path}") + async def options_handler(request: Request, path: str): """Handle all OPTIONS requests""" logger.info(f"OPTIONS request received for path: /{path}") logger.info(f"Origin: {request.headers.get('origin', 'No origin header')}") @@ -124,46 +122,47 @@ async def options_handler(request: Request, path: str): if origin and (origin in allowed_origins or "*" in allowed_origins): response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Methods"] = ( - "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" - ) - response.headers["Access-Control-Allow-Headers"] = ( - "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" + "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" ) response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Max-Age"] = "3600" - elif "*" in allowed_origins: - response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Max-Age"] = "3600" + elif "*" in allowed_origins: + response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = ( - "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" + "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" ) - response.headers["Access-Control-Allow-Headers"] = ( - "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" + response.headers["Access-Control-Allow-Headers"] = ( + "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" ) - response.headers["Access-Control-Max-Age"] = "3600" + response.headers["Access-Control-Max-Age"] = "3600" - return response + return response -# Health check -@app.get("/health") -async def health_check(): - """ + # Health check + @ app.get("/health") + async def health_check(): + """ Returns the health status of the Splitwiser API service. This endpoint can be used for health checks and monitoring. """ - return {"status": "healthy", "service": "Splitwiser API"} + return {"status": "healthy", "service": "Splitwiser API"} -# Include routers + # Include routers -app.include_router(auth_router) -app.include_router(user_router) -app.include_router(groups_router) -app.include_router(expenses_router) -app.include_router(balance_router) + app.include_router(auth_router) + app.include_router(user_router) + app.include_router(groups_router) + app.include_router(expenses_router) + app.include_router(balance_router) -if __name__ == "__main__": - import uvicorn + if __name__ == "__main__": + import uvicorn - uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=settings.debug) + uvicorn.run("main:app", host="0.0.0.0", + port = 8000, reload = settings.debug) diff --git a/ui-poc/Home.py b/ui-poc/Home.py index d329e645..526e50c4 100644 --- a/ui-poc/Home.py +++ b/ui-poc/Home.py @@ -1,8 +1,9 @@ -from streamlit_cookies_manager import EncryptedCookieManager -import requests -from datetime import datetime import json +from datetime import datetime + +import requests import streamlit as st +from streamlit_cookies_manager import EncryptedCookieManager # Configure the page – must come immediately after importing Streamlit st.set_page_config( From 1546084abb7464e34e188bead17e33d7d72abbb8 Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:55:55 +0530 Subject: [PATCH 08/12] Update security.py --- backend/app/auth/security.py | 129 ++++------------------------------- 1 file changed, 15 insertions(+), 114 deletions(-) diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index 1cae45fe..d0aa3d2d 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -1,82 +1,25 @@ -import secrets from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional -from app.config import settings from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext +from app.config import settings +import secrets + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") - def verify_password(plain_password: str, hashed_password: str) -> bool: - - - # Password hashing with better bcrypt configuration -try: - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -except Exception: - # Fallback for bcrypt version compatibility issues - pwd_context = CryptContext( - schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12) - -oauth2_scheme = OAuth2PasswordBearer( - tokenUrl="/auth/token") # Updated tokenUrl - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """ - Verifies whether a plaintext password matches a given hashed password. - - Args: - plain_password: The plaintext password to verify. - hashed_password: The hashed password to compare against. - - Returns: - True if the plaintext password matches the hash, otherwise False. - """ - return pwd_context.verify(plain_password, hashed_password) - def get_password_hash(password: str) -> str: - return pwd_context.hash(password) - def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: - """ - Hashes a plaintext password using bcrypt. - - Args: - password: The plaintext password to hash. - - Returns: - The bcrypt-hashed password as a string. - """ - return pwd_context.hash(password) - - -def create_access_token( - data: Dict[str, Any], expires_delta: Optional[timedelta] = None -) -> str: - """ - Creates a JWT access token embedding the provided data and an expiration time. - - If `expires_delta` is not specified, the token expires after the default duration from settings. The payload includes an expiration timestamp and a type field set to "access". The token is signed using the configured secret key and algorithm. - - Args: - data: The payload to include in the token. - expires_delta: Optional timedelta specifying how long the token is valid. - - Returns: - A signed JWT access token as a string. - """ - to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta @@ -84,78 +27,36 @@ def create_access_token( expire = datetime.now(timezone.utc) + timedelta( minutes=settings.access_token_expire_minutes ) - + to_encode.update({"exp": expire, "type": "access"}) - encoded_jwt = jwt.encode( - to_encode, settings.secret_key, algorithm=settings.algorithm - ) + encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) return encoded_jwt - def create_refresh_token() -> str: - """ - Generates a secure random refresh token as a URL-safe string. - - Returns: - A cryptographically secure, URL-safe refresh token string. - """ - return secrets.token_urlsafe(32) - def verify_token(token: str) -> Dict[str, Any]: """ - Verifies and decodes a JWT token. - - If the token is invalid or cannot be verified, raises an HTTP 401 Unauthorized exception. - Returns the decoded token payload as a dictionary. + Verifies the JWT token and returns the payload. + Raises HTTPException for invalid tokens. """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) try: - payload = jwt.decode( - token, settings.secret_key, algorithms=[settings.algorithm] - ) + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) return payload except JWTError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - + raise credentials_exception def generate_reset_token() -> str: - """ - Generates a secure, URL-safe token for password reset operations. - - Returns: - A random 32-byte URL-safe string suitable for use as a password reset token. - """ - return secrets.token_urlsafe(32) - def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]: - payload = verify_token(token) - - """ - Retrieves the current user based on the provided JWT token using centralized verification. - - Args: - token: The JWT token from which to extract the user information. - - Returns: - A dictionary containing the current user's information. - - Raises: - HTTPException: If the token is invalid or user information cannot be extracted. - """ - payload = verify_token( - token) # Centralized JWT validation and error handling - user_id = payload.get("sub") if user_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload" - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") return {"_id": user_id} From 0dd4108a04d7719fccbf3363fb90516d180dc00d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:26:08 +0000 Subject: [PATCH 09/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/app/auth/security.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index d0aa3d2d..823f1ee7 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -1,25 +1,29 @@ +import secrets from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional +from app.config import settings from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext -from app.config import settings -import secrets - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") + def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) + def get_password_hash(password: str) -> str: return pwd_context.hash(password) -def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + +def create_access_token( + data: Dict[str, Any], expires_delta: Optional[timedelta] = None +) -> str: to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta @@ -27,14 +31,18 @@ def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] expire = datetime.now(timezone.utc) + timedelta( minutes=settings.access_token_expire_minutes ) - + to_encode.update({"exp": expire, "type": "access"}) - encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + encoded_jwt = jwt.encode( + to_encode, settings.secret_key, algorithm=settings.algorithm + ) return encoded_jwt + def create_refresh_token() -> str: return secrets.token_urlsafe(32) + def verify_token(token: str) -> Dict[str, Any]: """ Verifies the JWT token and returns the payload. @@ -46,17 +54,23 @@ def verify_token(token: str) -> Dict[str, Any]: headers={"WWW-Authenticate": "Bearer"}, ) try: - payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + payload = jwt.decode( + token, settings.secret_key, algorithms=[settings.algorithm] + ) return payload except JWTError: raise credentials_exception + def generate_reset_token() -> str: return secrets.token_urlsafe(32) + def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]: payload = verify_token(token) user_id = payload.get("sub") if user_id is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload" + ) return {"_id": user_id} From 8d1002bcc6f539e90520f23a2c86d6b273eae47e Mon Sep 17 00:00:00 2001 From: Ishita Date: Fri, 1 Aug 2025 14:56:27 +0530 Subject: [PATCH 10/12] Update main.py --- backend/main.py | 133 ++++++++---------------------------------------- 1 file changed, 21 insertions(+), 112 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8025deb5..513767aa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,15 @@ from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + from app.auth.routes import router as auth_router -from app.config import RequestResponseLoggingMiddleware, logger, settings +from app.config import settings from app.database import close_mongo_connection, connect_to_mongo -from app.expenses.routes import balance_router -from app.expenses.routes import router as expenses_router +from app.expenses.routes import balance_router, router as expenses_router from app.groups.routes import router as groups_router from app.user.routes import router as user_router -from fastapi import FastAPI, HTTPException, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, Response @asynccontextmanager @@ -20,14 +20,6 @@ async def lifespan(app: FastAPI): # Shutdown await close_mongo_connection() - logger.info("Lifespan: Connecting to MongoDB...") - await connect_to_mongo() - logger.info("Lifespan: MongoDB connected.") - yield - # Shutdown - logger.info("Lifespan: Closing MongoDB connection...") - await close_mongo_connection() - logger.info("Lifespan: MongoDB connection closed.") app = FastAPI( title="Splitwiser API", @@ -38,48 +30,31 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# Security Headers Middleware - +# Security Headers Middleware @app.middleware("http") async def add_security_headers(request: Request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" - response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; object-src 'none';" + response.headers[ + "Content-Security-Policy" + ] = "default-src 'self'; script-src 'self'; object-src 'none';" return response + # CORS middleware allowed_origins = [] if settings.allow_all_origins: allowed_origins = ["*"] - elif settings.allowed_origins: - allowed_origins = [origin.strip() - for origin in settings.allowed_origins.split(",")] - - logger.debug("Development mode: CORS configured to allow all origins") -elif settings.allowed_origins: - # Use specified origins in production mode - allowed_origins = [ - origin.strip() - for origin in settings.allowed_origins.split(",") - if origin.strip() - ] -else: - # Fallback to allow all origins if not specified (not recommended for production) - allowed_origins = ["*"] - -logger.info(f"Allowed CORS origins: {allowed_origins}") - -app.add_middleware(RequestResponseLoggingMiddleware) + allowed_origins = [origin.strip() for origin in settings.allowed_origins.split(",")] app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True, - allow_methods=["*"], allow_headers=["*"], ) @@ -89,80 +64,14 @@ async def add_security_headers(request: Request, call_next): async def health_check(): return {"status": "healthy"} - allow_methods = ["GET", "POST", "PUT", - "DELETE", "OPTIONS", "HEAD", "PATCH"], - allow_headers = [ - "Accept", - "Accept-Language", - "Content-Language", - "Content-Type", - "Authorization", - "X-Requested-With", - "Origin", - "Cache-Control", - "Pragma", - "X-CSRFToken", - ], - expose_headers = ["*"], - max_age = 3600, # Cache preflight responses for 1 hour -) +app.include_router(auth_router) +app.include_router(user_router) +app.include_router(groups_router) +app.include_router(expenses_router) +app.include_router(balance_router) + +if __name__ == "__main__": + import uvicorn - # Add a catch-all OPTIONS handler that should work for any path - @ app.options("/{path:path}") - async def options_handler(request: Request, path: str): - """Handle all OPTIONS requests""" - logger.info(f"OPTIONS request received for path: /{path}") - logger.info(f"Origin: {request.headers.get('origin', 'No origin header')}") - - response = Response(status_code=200) - - # Manually set CORS headers for debugging - origin = request.headers.get("origin") - if origin and (origin in allowed_origins or "*" in allowed_origins): - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Methods"] = ( - "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" - ) - response.headers["Access-Control-Allow-Headers"] = ( - "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" - ) - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Max-Age"] = "3600" - elif "*" in allowed_origins: - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = ( - "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" - ) - response.headers["Access-Control-Allow-Headers"] = ( - "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, X-CSRFToken" - ) - response.headers["Access-Control-Max-Age"] = "3600" - - return response - - - # Health check - @ app.get("/health") - async def health_check(): - """ - Returns the health status of the Splitwiser API service. - - This endpoint can be used for health checks and monitoring. - """ - return {"status": "healthy", "service": "Splitwiser API"} - - - # Include routers - - app.include_router(auth_router) - app.include_router(user_router) - app.include_router(groups_router) - app.include_router(expenses_router) - app.include_router(balance_router) - - if __name__ == "__main__": - import uvicorn - - uvicorn.run("main:app", host="0.0.0.0", - port = 8000, reload = settings.debug) + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=settings.debug) From e6d011b467f09d59c24ed906a8dc95319c44c71e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:26:41 +0000 Subject: [PATCH 11/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/main.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/main.py b/backend/main.py index 513767aa..9590437c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,15 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - from app.auth.routes import router as auth_router from app.config import settings from app.database import close_mongo_connection, connect_to_mongo -from app.expenses.routes import balance_router, router as expenses_router +from app.expenses.routes import balance_router +from app.expenses.routes import router as expenses_router from app.groups.routes import router as groups_router from app.user.routes import router as user_router +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse @asynccontextmanager @@ -38,9 +38,9 @@ async def add_security_headers(request: Request, call_next): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" - response.headers[ - "Content-Security-Policy" - ] = "default-src 'self'; script-src 'self'; object-src 'none';" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; script-src 'self'; object-src 'none';" + ) return response @@ -49,7 +49,8 @@ async def add_security_headers(request: Request, call_next): if settings.allow_all_origins: allowed_origins = ["*"] elif settings.allowed_origins: - allowed_origins = [origin.strip() for origin in settings.allowed_origins.split(",")] + allowed_origins = [origin.strip() + for origin in settings.allowed_origins.split(",")] app.add_middleware( CORSMiddleware, From 0dcfa8f44c69fffa479f92296c1734b52a87f694 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 2 Aug 2025 08:38:57 +0000 Subject: [PATCH 12/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/app/auth/schemas.py | 18 ++++++------------ backend/app/auth/security.py | 1 - backend/app/auth/service.py | 13 +++---------- backend/main.py | 3 +-- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index 3037441f..44ef2b81 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -13,16 +13,13 @@ class EmailSignupRequest(BaseModel): @validator("password") def password_complexity(cls, v): if not re.search(r"[A-Z]", v): - raise ValueError( - "Password must contain at least one uppercase letter") + raise ValueError("Password must contain at least one uppercase letter") if not re.search(r"[a-z]", v): - raise ValueError( - "Password must contain at least one lowercase letter") + raise ValueError("Password must contain at least one lowercase letter") if not re.search(r"[0-9]", v): raise ValueError("Password must contain at least one digit") if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v): - raise ValueError( - "Password must contain at least one special character") + raise ValueError("Password must contain at least one special character") return v @@ -50,16 +47,13 @@ class PasswordResetConfirm(BaseModel): @validator("new_password") def password_complexity(cls, v): if not re.search(r"[A-Z]", v): - raise ValueError( - "Password must contain at least one uppercase letter") + raise ValueError("Password must contain at least one uppercase letter") if not re.search(r"[a-z]", v): - raise ValueError( - "Password must contain at least one lowercase letter") + raise ValueError("Password must contain at least one lowercase letter") if not re.search(r"[0-9]", v): raise ValueError("Password must contain at least one digit") if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v): - raise ValueError( - "Password must contain at least one special character") + raise ValueError("Password must contain at least one special character") return v diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index a28731bd..a97d911a 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -18,7 +18,6 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") # Updated tokenUrl - def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index ecf7d100..4531e21a 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -130,8 +130,7 @@ async def authenticate_user_with_email( user = await db.users.find_one({"email": email}) if not user: - logger.warning( - f"Failed login attempt for non-existent user: {email}") + logger.warning(f"Failed login attempt for non-existent user: {email}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", @@ -166,8 +165,7 @@ async def _handle_failed_login(self, user: Dict[str, Any]): if new_attempts >= MAX_FAILED_ATTEMPTS: lockout_until = datetime.now(timezone.utc) + LOCKOUT_DURATION update_data["$set"]["lockout_until"] = lockout_until - logger.warning( - f"Account for user {user['email']} has been locked.") + logger.warning(f"Account for user {user['email']} has been locked.") await db.users.update_one({"_id": user["_id"]}, update_data) logger.warning(f"Failed login attempt for user: {user['email']}") @@ -268,7 +266,6 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: async def refresh_access_token(self, refresh_token: str) -> str: db = self.get_db() - # Find and validate refresh token try: token_record = await db.refresh_tokens.find_one( @@ -337,7 +334,6 @@ async def request_password_reset(self, email: str) -> bool: reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry - await db.password_resets.insert_one( { "user_id": user["_id"], @@ -358,7 +354,6 @@ async def request_password_reset(self, email: str) -> bool: async def confirm_password_reset(self, reset_token: str, new_password: str) -> bool: db = self.get_db() - try: # Find and validate reset token reset_record = await db.password_resets.find_one( @@ -408,8 +403,7 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b new_hash = get_password_hash(new_password) await db.users.update_one( - {"_id": reset_record["user_id"]}, { - "$set": {"hashed_password": new_hash}} + {"_id": reset_record["user_id"]}, {"$set": {"hashed_password": new_hash}} ) await db.password_resets.update_one( @@ -451,7 +445,6 @@ async def _create_refresh_token_record(self, user_id: str) -> str: detail=f"Failed to create refresh token: {str(e)}", ) - return refresh_token diff --git a/backend/main.py b/backend/main.py index 9590437c..1ee52eb4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -49,8 +49,7 @@ async def add_security_headers(request: Request, call_next): if settings.allow_all_origins: allowed_origins = ["*"] elif settings.allowed_origins: - allowed_origins = [origin.strip() - for origin in settings.allowed_origins.split(",")] + allowed_origins = [origin.strip() for origin in settings.allowed_origins.split(",")] app.add_middleware( CORSMiddleware,