# services/auth/app/api/password_reset.py """ Password reset API endpoints Handles forgot password and password reset functionality """ import logging from datetime import datetime, timedelta, timezone from typing import Dict, Any from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from app.services.auth_service import auth_service, AuthService from app.schemas.auth import PasswordReset, PasswordResetConfirm from app.core.security import SecurityManager from app.core.config import settings from app.repositories.password_reset_repository import PasswordResetTokenRepository from app.repositories.user_repository import UserRepository from app.models.users import User from shared.clients.notification_client import NotificationServiceClient import structlog # Configure logging logger = structlog.get_logger() # Create router router = APIRouter(prefix="/api/v1/auth", tags=["password-reset"]) async def get_auth_service() -> AuthService: """Dependency injection for auth service""" return auth_service def generate_reset_token() -> str: """Generate a secure password reset token""" import secrets return secrets.token_urlsafe(32) async def send_password_reset_email(email: str, reset_token: str, user_full_name: str): """Send password reset email in background using notification service""" try: # Construct reset link (this should match your frontend URL) # Use FRONTEND_URL from settings if available, otherwise fall back to gateway URL frontend_url = getattr(settings, 'FRONTEND_URL', settings.GATEWAY_URL) reset_link = f"{frontend_url}/reset-password?token={reset_token}" # Create HTML content for the password reset email in Spanish html_content = f""" Restablecer Contraseña

Restablecer Contraseña

Hola {user_full_name},

Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva contraseña:

Restablecer Contraseña

Si no solicitaste un restablecimiento de contraseña, puedes ignorar este correo electrónico de forma segura.

Este enlace expirará en 1 hora por razones de seguridad.

""" # Create text content as fallback text_content = f""" Hola {user_full_name}, Recibimos una solicitud para restablecer tu contraseña. Haz clic en el siguiente enlace para crear una nueva contraseña: {reset_link} Si no solicitaste un restablecimiento de contraseña, puedes ignorar este correo electrónico de forma segura. Este enlace expirará en 1 hora por razones de seguridad. Este es un mensaje automático de BakeWise. Por favor, no respondas a este correo electrónico. """ # Send email using the notification service notification_client = NotificationServiceClient(settings) # Send the notification using the send_email method await notification_client.send_email( tenant_id="system", # Using system tenant for password resets to_email=email, subject="Restablecer Contraseña", message=text_content, html_content=html_content, priority="high" ) logger.info(f"Password reset email sent successfully to {email}") except Exception as e: logger.error(f"Failed to send password reset email to {email}: {str(e)}") @router.post("/password/reset-request", summary="Request password reset", description="Send a password reset link to the user's email") async def request_password_reset( reset_request: PasswordReset, background_tasks: BackgroundTasks, auth_service: AuthService = Depends(get_auth_service) ) -> Dict[str, Any]: """ Request a password reset This endpoint: 1. Finds the user by email 2. Generates a password reset token 3. Stores the token in the database 4. Sends a password reset email to the user """ try: logger.info(f"Password reset request for email: {reset_request.email}") # Find user by email async with auth_service.database_manager.get_session() as session: user_repo = UserRepository(User, session) user = await user_repo.get_by_field("email", reset_request.email) if not user: # Don't reveal if email exists to prevent enumeration attacks logger.info(f"Password reset request for non-existent email: {reset_request.email}") return {"message": "If an account with this email exists, a reset link has been sent."} # Generate a secure reset token reset_token = generate_reset_token() # Set token expiration (e.g., 1 hour) expires_at = datetime.now(timezone.utc) + timedelta(hours=1) # Store the reset token in the database token_repo = PasswordResetTokenRepository(session) # Clean up any existing unused tokens for this user await token_repo.cleanup_expired_tokens() # Create new reset token await token_repo.create_token( user_id=str(user.id), token=reset_token, expires_at=expires_at ) # Commit the transaction await session.commit() # Send password reset email in background background_tasks.add_task( send_password_reset_email, user.email, reset_token, user.full_name ) logger.info(f"Password reset token created for user: {user.email}") return {"message": "If an account with this email exists, a reset link has been sent."} except HTTPException: raise except Exception as e: logger.error(f"Password reset request failed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Password reset request failed" ) @router.post("/password/reset", summary="Reset password with token", description="Reset user password using a valid reset token") async def reset_password( reset_confirm: PasswordResetConfirm, auth_service: AuthService = Depends(get_auth_service) ) -> Dict[str, Any]: """ Reset password using a valid reset token This endpoint: 1. Validates the reset token 2. Checks if the token is valid and not expired 3. Updates the user's password 4. Marks the token as used """ try: logger.info(f"Password reset attempt with token: {reset_confirm.token[:10]}...") # Validate password strength if not SecurityManager.validate_password(reset_confirm.new_password): errors = SecurityManager.get_password_validation_errors(reset_confirm.new_password) logger.warning(f"Password validation failed: {errors}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Password does not meet requirements: {'; '.join(errors)}" ) # Find the reset token in the database async with auth_service.database_manager.get_session() as session: token_repo = PasswordResetTokenRepository(session) reset_token_obj = await token_repo.get_token_by_value(reset_confirm.token) if not reset_token_obj: logger.warning(f"Invalid or expired password reset token: {reset_confirm.token[:10]}...") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired reset token" ) # Get the user associated with this token user_repo = UserRepository(User, session) user = await user_repo.get_by_id(str(reset_token_obj.user_id)) if not user: logger.error(f"User not found for reset token: {reset_confirm.token[:10]}...") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid reset token" ) # Hash the new password hashed_password = SecurityManager.hash_password(reset_confirm.new_password) # Update user's password await user_repo.update(str(user.id), { "hashed_password": hashed_password }) # Mark the reset token as used await token_repo.mark_token_as_used(str(reset_token_obj.id)) # Commit the transactions await session.commit() logger.info(f"Password successfully reset for user: {user.email}") return {"message": "Password has been reset successfully"} except HTTPException: raise except Exception as e: logger.error(f"Password reset failed: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Password reset failed" )