308 lines
11 KiB
Python
308 lines
11 KiB
Python
# 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"""
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Restablecer Contraseña</title>
|
|
<style>
|
|
body {{
|
|
font-family: Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background-color: #f9f9f9;
|
|
}}
|
|
.header {{
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
|
|
color: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
}}
|
|
.content {{
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}}
|
|
.button {{
|
|
display: inline-block;
|
|
padding: 12px 30px;
|
|
background-color: #4F46E5;
|
|
color: white;
|
|
text-decoration: none;
|
|
border-radius: 5px;
|
|
margin: 20px 0;
|
|
font-weight: bold;
|
|
}}
|
|
.footer {{
|
|
margin-top: 40px;
|
|
text-align: center;
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #eee;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Restablecer Contraseña</h1>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<p>Hola {user_full_name},</p>
|
|
|
|
<p>Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva contraseña:</p>
|
|
|
|
<p style="text-align: center; margin: 30px 0;">
|
|
<a href="{reset_link}" class="button">Restablecer Contraseña</a>
|
|
</p>
|
|
|
|
<p>Si no solicitaste un restablecimiento de contraseña, puedes ignorar este correo electrónico de forma segura.</p>
|
|
|
|
<p>Este enlace expirará en 1 hora por razones de seguridad.</p>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>Este es un mensaje automático de BakeWise. Por favor, no respondas a este correo electrónico.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# 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"
|
|
) |