diff --git a/backend/app/auth/ratelimit.py b/backend/app/auth/ratelimit.py new file mode 100644 index 00000000..599b8aac --- /dev/null +++ b/backend/app/auth/ratelimit.py @@ -0,0 +1,31 @@ +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. +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.", + ) diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index 066e16f3..c8abd390 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -1,5 +1,6 @@ from datetime import timedelta +from app.auth.ratelimit import rate_limiter from app.auth.schemas import ( AuthResponse, EmailLoginRequest, @@ -14,32 +15,23 @@ TokenVerifyRequest, UserResponse, ) -from app.auth.security import create_access_token, oauth2_scheme # Import oauth2_scheme +from app.auth.security import create_access_token, oauth2_scheme from app.auth.service import auth_service from app.config import settings -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import ( # Import OAuth2PasswordRequestForm - OAuth2PasswordRequestForm, -) +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 -) # 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 - password=form_data.password, + email=form_data.username, password=form_data.password ) access_token = create_access_token( @@ -51,39 +43,29 @@ 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( email=request.email, password=request.password, 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( @@ -100,25 +82,23 @@ async def signup_with_email(request: EmailSignupRequest): ) -@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( email=request.email, 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( @@ -139,19 +119,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( @@ -172,18 +148,12 @@ 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() @@ -196,7 +166,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), @@ -216,14 +186,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) @@ -238,10 +204,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) @@ -259,15 +222,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( diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index c6ea1d5b..44ef2b81 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -1,14 +1,26 @@ +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 -# 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): @@ -30,14 +42,25 @@ 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 diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index 0884b8d4..a97d911a 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -19,47 +19,16 @@ 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 @@ -76,42 +45,29 @@ def create_access_token( 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] ) 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) @@ -129,6 +85,7 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]: 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( diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index eebe63d6..4531e21a 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,28 +1,35 @@ import json +import logging import os from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional import firebase_admin +from app.auth.schemas import UserResponse from app.auth.security import ( - create_access_token, create_refresh_token, generate_reset_token, get_password_hash, verify_password, ) -from app.config import logger, settings +from app.config import settings 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 jose import JWTError -from pymongo.errors import DuplicateKeyError, PyMongoError +from pymongo.errors import DuplicateKeyError + +# 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, @@ -32,14 +39,11 @@ 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, @@ -54,8 +58,10 @@ "projectId": settings.firebase_project_id, }, ) + logger.info("Firebase initialized with credentials from environment variables") # Fall back to service account JSON file if env vars are not available + elif os.path.exists(settings.firebase_service_account_path): cred = credentials.Certificate(settings.firebase_service_account_path) firebase_admin.initialize_app( @@ -71,37 +77,16 @@ 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( @@ -109,23 +94,23 @@ async def create_user_with_email( detail="User with this email already exists", ) - # Create user document user_doc = { "email": email, "hashed_password": get_password_hash(password), "name": name, - "imageUrl": None, + "avatar": None, "currency": "USD", "created_at": datetime.now(timezone.utc), "auth_provider": "email", "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) ) @@ -136,74 +121,65 @@ async def create_user_with_email( status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email already exists", ) - except Exception as e: - logger.exception("Unexpected error while creating user with email") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error", - ) async def authenticate_user_with_email( self, email: str, password: str ) -> Dict[str, Any]: - """ - Authenticates a user using email and password credentials. + db = self.get_db() - 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. + user = await db.users.find_one({"email": email}) - Returns: - A dictionary containing the authenticated user and a new refresh token. - """ - db = self.get_db() - try: - user = await db.users.find_one({"email": email}) - except PyMongoError as e: - logger.error(f"Database error during user lookup: {e}") + if not user: + logger.warning(f"Failed login attempt for non-existent user: {email}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error", + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", ) - if not user or not verify_password(password, user.get("hashed_password", "")): - logger.info("Authentication failed due to invalid credentials.") + if user.get("lockout_until") and user["lockout_until"] > datetime.now( + timezone.utc + ): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is locked. Please try again later.", ) - # Create new refresh token - try: - refresh_token = await self._create_refresh_token_record(str(user["_id"])) - except Exception as e: - logger.error(f"Failed to generate refresh token: {e}") + if not verify_password(password, user.get("hashed_password", "")): + await self._handle_failed_login(user) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to generate refresh token", + status_code=status.HTTP_401_UNAUTHORIZED, + 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} - 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. - """ - try: - # Verify the Firebase ID token - try: - decoded_token = firebase_auth.verify_id_token(id_token) - except firebase_auth.InvalidIdTokenError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid Google ID 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: + 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") @@ -217,26 +193,19 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: db = self.get_db() - # Check if user exists - try: - user = await db.users.find_one( - {"$or": [{"email": email}, {"firebase_uid": firebase_uid}]} - ) - except PyMongoError as e: - logger.error("Database error while checking user: %s", str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error", - ) + 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 - if user.get("imageUrl") != picture and picture: - update_data["imageUrl"] = picture + if user.get("avatar") != picture and picture: + update_data["avatar"] = picture if update_data: + try: await db.users.update_one( {"_id": user["_id"]}, {"$set": update_data} @@ -245,17 +214,17 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: except PyMongoError as e: logger.warning("Failed to update user profile: %s", str(e)) else: - # Create new user user_doc = { "email": email, "name": name, - "imageUrl": picture, + "avatar": picture, "currency": "USD", "created_at": datetime.now(timezone.utc), "auth_provider": "google", "firebase_uid": firebase_uid, "hashed_password": None, } + try: result = await db.users.insert_one(user_doc) user_doc["_id"] = result.inserted_id @@ -282,27 +251,19 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: ) return {"user": user, "refresh_token": refresh_token} - except HTTPException: - raise + + except firebase_auth.InvalidIdTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Google ID token", + ) except Exception as e: - logger.exception("Unexpected error during Google authentication") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Google authentication failed: {str(e)}", ) 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 @@ -327,66 +288,25 @@ async def refresh_access_token(self, refresh_token: str) -> str: detail="Invalid or expired refresh token", ) - # Get user - try: - user = await db.users.find_one({"_id": token_record["user_id"]}) - except PyMongoError as e: - logger.error("Error while fetching user: %s", str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error", - ) + 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" ) - # Create new refresh token (token rotation) - try: - new_refresh_token = await self._create_refresh_token_record( - str(user["_id"]) - ) - except Exception as e: - logger.error("Failed to create new refresh token: %s", str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create refresh token", - ) + new_refresh_token = await self._create_refresh_token_record(str(user["_id"])) - # Revoke old token - try: - await db.refresh_tokens.update_one( - {"_id": token_record["_id"]}, {"$set": {"revoked": True}} - ) - except PyMongoError as e: - logger.error("Failed to revoke old refresh token: %s", str(e)) - # No raise here since new token is safely issued + 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 - try: - payload = verify_token(token) - user_id = payload.get("sub") - except JWTError as e: - logger.warning("JWT verification failed: %s", str(e)) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" - ) + payload = verify_token(token) + user_id = payload.get("sub") if not user_id: raise HTTPException( @@ -394,15 +314,7 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: ) db = self.get_db() - - try: - user = await db.users.find_one({"_id": user_id}) - except Exception as e: - logger.error("Error while verifying token: %s", str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error", - ) + user = await db.users.find_one({"_id": ObjectId(user_id)}) if not user: raise HTTPException( @@ -412,53 +324,27 @@ 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() - try: - user = await db.users.find_one({"email": email}) - except PyMongoError as e: - logger.error( - f"Database error while fetching user by email {email}: {str(e)}" - ) - raise HTTPException( - status_code=500, detail="Internal server error during user lookup." - ) - + 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 - try: - # Store reset token - await db.password_resets.insert_one( - { - "user_id": user["_id"], - "token": reset_token, - "expires_at": reset_expires, - "used": False, - "created_at": datetime.utcnow(), - } - ) - except PyMongoError as e: - logger.error( - f"Database error while storing reset token for user {email}: {str(e)}" - ) - raise HTTPException( - status_code=500, detail="Internal server error during token storage." - ) + await db.password_resets.insert_one( + { + "user_id": user["_id"], + "token": reset_token, + "expires_at": reset_expires, + "used": False, + "created_at": datetime.utcnow(), + } + ) - # For development/free tier: just log the reset token - # In production, you would send this via email - logger.info(f"Password reset token for {email}: {reset_token[:6]}") + logger.info(f"Password reset token for {email}: {reset_token}") logger.info( f"Reset link: https://yourapp.com/reset-password?token={reset_token}" ) @@ -466,21 +352,6 @@ async def request_password_reset(self, email: str) -> bool: 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() try: @@ -526,22 +397,26 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b except Exception as e: logger.exception(f"Unexpected error during password reset: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error during password reset", + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired reset token", ) - async def _create_refresh_token_record(self, user_id: str) -> str: - """ - Generates and stores a new refresh token for the specified user. + new_hash = get_password_hash(new_password) + await db.users.update_one( + {"_id": reset_record["user_id"]}, {"$set": {"hashed_password": new_hash}} + ) - Creates a refresh token with an expiration date and saves it in the database for token management and rotation. + await db.password_resets.update_one( + {"_id": reset_record["_id"]}, {"$set": {"used": True}} + ) - Args: - user_id: The unique identifier of the user for whom the refresh token is created. + await db.refresh_tokens.update_many( + {"user_id": reset_record["user_id"]}, {"$set": {"revoked": True}} + ) - Returns: - The generated refresh token string. - """ + return True + + async def _create_refresh_token_record(self, user_id: str) -> str: db = self.get_db() refresh_token = create_refresh_token() @@ -573,5 +448,4 @@ async def _create_refresh_token_record(self, user_id: str) -> str: return refresh_token -# Create service instance auth_service = AuthService() diff --git a/backend/main.py b/backend/main.py index 3372ffb8..1ee52eb4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,28 +1,24 @@ from contextlib import asynccontextmanager 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.groups.routes import router as groups_router from app.user.routes import router as user_router -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response +from fastapi.responses import JSONResponse @asynccontextmanager async def lifespan(app: FastAPI): # Startup - 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( @@ -34,95 +30,41 @@ 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 = ["*"] - 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=["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""" - 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"} + return {"status": "healthy"} -# Include routers app.include_router(auth_router) app.include_router(user_router) app.include_router(groups_router)