diff --git a/src/backend/database.py b/src/backend/database.py index f9a0236..24c1d40 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -350,6 +350,154 @@ class RedisDatabase: await self.redis.delete(key) # MFA and Email Verification operations + async def find_verification_token_by_email(self, email: str) -> Optional[Dict[str, Any]]: + """Find pending verification token by email address""" + try: + pattern = "email_verification:*" + cursor = 0 + email_lower = email.lower() + + while True: + cursor, keys = await self.redis.scan(cursor, match=pattern, count=100) + + for key in keys: + token_data = await self.redis.get(key) + if token_data: + verification_info = json.loads(token_data) + if (verification_info.get("email", "").lower() == email_lower and + not verification_info.get("verified", False)): + # Extract token from key + token = key.replace("email_verification:", "") + verification_info["token"] = token + return verification_info + + if cursor == 0: + break + + return None + + except Exception as e: + logger.error(f"โŒ Error finding verification token by email {email}: {e}") + return None + + async def get_pending_verifications_count(self) -> int: + """Get count of pending email verifications (admin function)""" + try: + pattern = "email_verification:*" + cursor = 0 + count = 0 + + while True: + cursor, keys = await self.redis.scan(cursor, match=pattern, count=100) + + for key in keys: + token_data = await self.redis.get(key) + if token_data: + verification_info = json.loads(token_data) + if not verification_info.get("verified", False): + count += 1 + + if cursor == 0: + break + + return count + + except Exception as e: + logger.error(f"โŒ Error counting pending verifications: {e}") + return 0 + + async def cleanup_expired_verification_tokens(self) -> int: + """Clean up expired verification tokens and return count of cleaned tokens""" + try: + pattern = "email_verification:*" + cursor = 0 + cleaned_count = 0 + current_time = datetime.now(timezone.utc) + + while True: + cursor, keys = await self.redis.scan(cursor, match=pattern, count=100) + + for key in keys: + token_data = await self.redis.get(key) + if token_data: + verification_info = json.loads(token_data) + expires_at = datetime.fromisoformat(verification_info.get("expires_at", "")) + + if current_time > expires_at: + await self.redis.delete(key) + cleaned_count += 1 + logger.debug(f"๐Ÿงน Cleaned expired verification token for {verification_info.get('email')}") + + if cursor == 0: + break + + if cleaned_count > 0: + logger.info(f"๐Ÿงน Cleaned up {cleaned_count} expired verification tokens") + + return cleaned_count + + except Exception as e: + logger.error(f"โŒ Error cleaning up expired verification tokens: {e}") + return 0 + + async def get_verification_attempts_count(self, email: str) -> int: + """Get the number of verification emails sent for an email in the last 24 hours""" + try: + key = f"verification_attempts:{email.lower()}" + data = await self.redis.get(key) + + if not data: + return 0 + + attempts_data = json.loads(data) + current_time = datetime.now(timezone.utc) + window_start = current_time - timedelta(hours=24) + + # Filter out old attempts + recent_attempts = [ + attempt for attempt in attempts_data + if datetime.fromisoformat(attempt) > window_start + ] + + return len(recent_attempts) + + except Exception as e: + logger.error(f"โŒ Error getting verification attempts count for {email}: {e}") + return 0 + + async def record_verification_attempt(self, email: str) -> bool: + """Record a verification email attempt""" + try: + key = f"verification_attempts:{email.lower()}" + current_time = datetime.now(timezone.utc) + + # Get existing attempts + data = await self.redis.get(key) + attempts_data = json.loads(data) if data else [] + + # Add current attempt + attempts_data.append(current_time.isoformat()) + + # Keep only last 24 hours of attempts + window_start = current_time - timedelta(hours=24) + recent_attempts = [ + attempt for attempt in attempts_data + if datetime.fromisoformat(attempt) > window_start + ] + + # Store with 24 hour expiration + await self.redis.setex( + key, + 24 * 60 * 60, # 24 hours + json.dumps(recent_attempts) + ) + + return True + + except Exception as e: + logger.error(f"โŒ Error recording verification attempt for {email}: {e}") + return False + async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool: """Store email verification token with user data""" try: diff --git a/src/backend/email_service.py b/src/backend/email_service.py index 3762fa8..9d219fa 100644 --- a/src/backend/email_service.py +++ b/src/backend/email_service.py @@ -163,13 +163,16 @@ email_service = EmailService() class EnhancedEmailService: def __init__(self): - self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com") - self.smtp_port = int(os.getenv("SMTP_PORT", "587")) - self.email_user = os.getenv("EMAIL_USER", "your-app@example.com") - self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password") + # Configure these in your .env file + self.smtp_server = os.getenv("SMTP_SERVER") + self.smtp_port = int(os.getenv("SMTP_PORT", "0")) + self.email_user = os.getenv("EMAIL_USER",) + self.email_password = os.getenv("EMAIL_PASSWORD") self.from_name = os.getenv("FROM_NAME", "Backstory") self.app_name = os.getenv("APP_NAME", "Backstory") self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com") + if not self.smtp_server or self.smtp_port == 0 or self.email_user is None or self.email_password is None: + raise ValueError("SMTP configuration is not set in the environment variables") def _get_template(self, template_name: str) -> dict: """Get email template by name""" @@ -279,6 +282,8 @@ class EnhancedEmailService: async def _send_email(self, to_email: str, subject: str, html_content: str): """Send email using SMTP with improved error handling""" try: + if not self.email_user: + raise ValueError("Email user is not configured") # Create message msg = MIMEMultipart('alternative') msg['From'] = f"{self.from_name} <{self.email_user}>" @@ -292,6 +297,9 @@ class EnhancedEmailService: # Send email with connection pooling and retry logic max_retries = 3 + if not self.smtp_server or self.smtp_port == 0 or not self.email_user or not self.email_password: + raise ValueError("SMTP configuration is not set in the environment variables") + for attempt in range(max_retries): try: with smtplib.SMTP(self.smtp_server, self.smtp_port) as server: @@ -364,4 +372,60 @@ class EmailRateLimiter: key, ttl_minutes * 60, json.dumps([timestamp.isoformat()]) - ) \ No newline at end of file + ) + +class VerificationEmailRateLimiter: + def __init__(self, database: RedisDatabase): + self.database = database + self.max_attempts_per_hour = 3 # Maximum 3 emails per hour + self.max_attempts_per_day = 10 # Maximum 10 emails per day + self.cooldown_minutes = 5 # 5 minute cooldown between emails + + async def can_send_verification_email(self, email: str) -> Tuple[bool, str]: + """ + Check if verification email can be sent based on rate limiting + Returns (can_send, reason_if_not) + """ + try: + email_lower = email.lower() + current_time = datetime.now(timezone.utc) + + # Check daily limit + daily_count = await self.database.get_verification_attempts_count(email) + if daily_count >= self.max_attempts_per_day: + return False, f"Daily limit reached. You can request up to {self.max_attempts_per_day} verification emails per day." + + # Check hourly limit + hour_ago = current_time - timedelta(hours=1) + hourly_key = f"verification_attempts:{email_lower}" + data = await self.database.redis.get(hourly_key) + + if data: + attempts_data = json.loads(data) + recent_attempts = [ + attempt for attempt in attempts_data + if datetime.fromisoformat(attempt) > hour_ago + ] + + if len(recent_attempts) >= self.max_attempts_per_hour: + return False, f"Hourly limit reached. You can request up to {self.max_attempts_per_hour} verification emails per hour." + + # Check cooldown period + if recent_attempts: + last_attempt = max(datetime.fromisoformat(attempt) for attempt in recent_attempts) + time_since_last = current_time - last_attempt + + if time_since_last.total_seconds() < self.cooldown_minutes * 60: + remaining_minutes = self.cooldown_minutes - int(time_since_last.total_seconds() / 60) + return False, f"Please wait {remaining_minutes} more minute(s) before requesting another email." + + return True, "OK" + + except Exception as e: + logger.error(f"โŒ Error checking verification email rate limit: {e}") + # On error, be conservative and deny + return False, "Rate limit check failed. Please try again later." + + async def record_email_sent(self, email: str): + """Record that a verification email was sent""" + await self.database.record_verification_attempt(email) diff --git a/src/backend/main.py b/src/backend/main.py index 95e6f9b..3594e04 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -45,7 +45,7 @@ from database import RedisDatabase, redis_manager, DatabaseManager from metrics import Metrics from llm_manager import llm_manager import entities -from email_service import email_service +from email_service import VerificationEmailRateLimiter, email_service from device_manager import DeviceManager # ============================= @@ -376,12 +376,12 @@ api_router = APIRouter(prefix="/api/1.0") # ============================ @api_router.post("/auth/login") -async def enhanced_login( +async def login( request: LoginRequest, http_request: Request, database: RedisDatabase = Depends(get_database) ): - """Enhanced login with device detection and MFA""" + """Login with device detection and MFA""" try: # Initialize managers auth_manager = AuthenticationManager(database) @@ -458,7 +458,7 @@ async def enhanced_login( return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: - logger.error(f"โŒ Enhanced login error: {e}") + logger.error(f"โŒ Login error: {e}") return JSONResponse( status_code=500, content=create_error_response("LOGIN_ERROR", "An error occurred during login") @@ -941,32 +941,142 @@ async def resend_verification_email( background_tasks: BackgroundTasks, database: RedisDatabase = Depends(get_database) ): - """Resend verification email""" + """Resend verification email with comprehensive rate limiting and validation""" try: - # Check if user exists and is pending - user_data = await database.get_user(request.email) + email_lower = request.email.lower().strip() - if user_data: + # Initialize rate limiter + rate_limiter = VerificationEmailRateLimiter(database) + + # Check rate limiting + can_send, reason = await rate_limiter.can_send_verification_email(email_lower) + if not can_send: + logger.warning(f"โš ๏ธ Verification email rate limit exceeded for {email_lower}: {reason}") return JSONResponse( - status_code=400, - content=create_error_response("ALREADY_VERIFIED", "Account is already verified") + status_code=429, + content=create_error_response("RATE_LIMITED", reason) ) - # Look for pending verification - # This would require scanning verification tokens (implement if needed) - # For now, return a generic success message + # Clean up expired tokens first + await database.cleanup_expired_verification_tokens() - return create_success_response({ - "message": "If your email is in our system and pending verification, a new verification email has been sent." + # Check if user already exists and is verified + user_data = await database.get_user(email_lower) + if user_data: + # User exists and is verified - don't reveal this for security + logger.info(f"๐Ÿ” Resend verification requested for already verified user: {email_lower}") + await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse + return create_success_response({ + "message": "If your email is in our system and pending verification, a new verification email has been sent." + }) + + # Look for pending verification token + verification_data = await database.find_verification_token_by_email(email_lower) + + if not verification_data: + # No pending verification found - don't reveal this for security + logger.info(f"๐Ÿ” Resend verification requested for non-existent pending verification: {email_lower}") + await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse + return create_success_response({ + "message": "If your email is in our system and pending verification, a new verification email has been sent." + }) + + # Check if verification token has expired + expires_at = datetime.fromisoformat(verification_data["expires_at"]) + current_time = datetime.now(timezone.utc) + + if current_time > expires_at: + # Token expired - clean it up and inform user + await database.redis.delete(f"email_verification:{verification_data['token']}") + logger.info(f"๐Ÿงน Cleaned up expired verification token for {email_lower}") + return JSONResponse( + status_code=400, + content=create_error_response( + "TOKEN_EXPIRED", + "Your verification link has expired. Please register again to create a new account." + ) + ) + + # Generate new verification token (invalidate old one) + old_token = verification_data["token"] + new_token = secrets.token_urlsafe(32) + + # Update verification data with new token and reset attempts + verification_data.update({ + "token": new_token, + "expires_at": (current_time + timedelta(hours=24)).isoformat(), + "resent_at": current_time.isoformat(), + "resend_count": verification_data.get("resend_count", 0) + 1 }) + # Store new token and remove old one + await database.redis.delete(f"email_verification:{old_token}") + await database.store_email_verification_token( + email_lower, + new_token, + verification_data["user_type"], + verification_data["user_data"] + ) + + # Get user name for email + user_data_container = verification_data["user_data"] + user_type = verification_data["user_type"] + + if user_type == "candidate": + candidate_data = user_data_container["candidate_data"] + user_name = candidate_data.get("fullName", "User") + elif user_type == "employer": + employer_data = user_data_container["employer_data"] + user_name = employer_data.get("companyName", "User") + else: + user_name = "User" + + # Record email attempt + await rate_limiter.record_email_sent(email_lower) + + # Send new verification email in background + background_tasks.add_task( + email_service.send_verification_email, + email_lower, + new_token, + user_name, + user_type + ) + + # Log security event + await database.log_security_event( + verification_data["user_data"].get("candidate_data", {}).get("id") or + verification_data["user_data"].get("employer_data", {}).get("id") or "unknown", + "verification_resend", + { + "email": email_lower, + "user_type": user_type, + "resend_count": verification_data.get("resend_count", 1), + "old_token_invalidated": old_token[:8] + "...", # Log partial token for debugging + "ip_address": "unknown" # You can extract this from request if needed + } + ) + + logger.info(f"โœ… Verification email resent to {email_lower} (attempt #{verification_data.get('resend_count', 1)})") + + return create_success_response({ + "message": "A new verification email has been sent to your email address. Please check your inbox and spam folder.", + "resendCount": verification_data.get("resend_count", 1) + }) + + except ValueError as ve: + logger.warning(f"โš ๏ธ Invalid resend verification request: {ve}") + return JSONResponse( + status_code=400, + content=create_error_response("VALIDATION_ERROR", str(ve)) + ) except Exception as e: - logger.error(f"โŒ Resend verification error: {e}") + logger.error(f"โŒ Resend verification email error: {e}") return JSONResponse( status_code=500, - content=create_error_response("RESEND_FAILED", "Failed to resend verification email") + content=create_error_response("RESEND_FAILED", "An error occurred while processing your request. Please try again later.") ) - + @api_router.post("/auth/mfa/request") async def request_mfa( request: MFARequest, @@ -1745,8 +1855,7 @@ async def search_jobs( # ============================ # Chat Endpoints # ============================ -# Enhanced Chat Session Endpoints with Username Association -# Add these modifications to your main.py file +# Chat Session Endpoints with Username Association @api_router.get("/chat/statistics") async def get_chat_statistics( current_user = Depends(get_current_user), @@ -1832,7 +1941,7 @@ async def archive_chat_session( ) # ============================ -# Chat Endpoints (Enhanced) +# Chat Endpoints # ============================ @api_router.post("/chat/sessions") @@ -2334,6 +2443,121 @@ async def get_candidate_chat_sessions( content=create_error_response("FETCH_ERROR", str(e)) ) +# ============================ +# Admin Endpoints +# ============================ +# @api_router.get("/admin/verification-stats") +async def get_verification_statistics( + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get verification statistics (admin only)""" + try: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + stats = { + "pending_verifications": await database.get_pending_verifications_count(), + "expired_tokens_cleaned": await database.cleanup_expired_verification_tokens() + } + + return create_success_response(stats) + + except Exception as e: + logger.error(f"โŒ Error getting verification stats: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("STATS_ERROR", str(e)) + ) + +@api_router.post("/admin/cleanup-verifications") +async def cleanup_verification_tokens( + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Manually trigger cleanup of expired verification tokens (admin only)""" + try: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + cleaned_count = await database.cleanup_expired_verification_tokens() + + logger.info(f"๐Ÿงน Manual cleanup completed by admin {current_user.id}: {cleaned_count} tokens cleaned") + + return create_success_response({ + "message": f"Cleanup completed. Removed {cleaned_count} expired verification tokens.", + "cleaned_count": cleaned_count + }) + + except Exception as e: + logger.error(f"โŒ Error in manual cleanup: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CLEANUP_ERROR", str(e)) + ) + +@api_router.get("/admin/pending-verifications") +async def get_pending_verifications( + current_user = Depends(get_current_user), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + database: RedisDatabase = Depends(get_database) +): + """Get list of pending email verifications (admin only)""" + try: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + pattern = "email_verification:*" + cursor = 0 + pending_verifications = [] + current_time = datetime.now(timezone.utc) + + while True: + cursor, keys = await database.redis.scan(cursor, match=pattern, count=100) + + for key in keys: + token_data = await database.redis.get(key) + if token_data: + verification_info = json.loads(token_data) + if not verification_info.get("verified", False): + expires_at = datetime.fromisoformat(verification_info.get("expires_at", "")) + + pending_verifications.append({ + "email": verification_info.get("email"), + "user_type": verification_info.get("user_type"), + "created_at": verification_info.get("created_at"), + "expires_at": verification_info.get("expires_at"), + "is_expired": current_time > expires_at, + "resend_count": verification_info.get("resend_count", 0) + }) + + if cursor == 0: + break + + # Sort by creation date (newest first) + pending_verifications.sort(key=lambda x: x["created_at"], reverse=True) + + # Apply pagination + total = len(pending_verifications) + start = (page - 1) * limit + end = start + limit + paginated_verifications = pending_verifications[start:end] + + paginated_response = create_paginated_response( + paginated_verifications, + page, limit, total + ) + + return create_success_response(paginated_response) + + except Exception as e: + logger.error(f"โŒ Error getting pending verifications: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + # ============================ # Health Check and Info Endpoints # ============================ @@ -2342,8 +2566,8 @@ async def get_redis() -> redis.Redis: return redis_manager.get_client() @app.get("/health") -async def enhanced_health_check(): - """Enhanced health check endpoint""" +async def health_check(): + """Health check endpoint""" try: database = db_manager.get_database() if not redis_manager.redis: diff --git a/src/backend/models.py b/src/backend/models.py index 035225d..94a2b27 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -401,7 +401,8 @@ class BaseUser(BaseModel): last_login: Optional[datetime] = Field(None, alias="lastLogin") profile_image: Optional[str] = Field(None, alias="profileImage") status: UserStatus - + is_admin: bool = Field(default=False, alias="isAdmin") + model_config = { "populate_by_name": True, # Allow both field names and aliases "use_enum_values": True # Use enum values instead of names