Add subcription feature 5

This commit is contained in:
Urtzi Alfaro
2026-01-16 09:55:54 +01:00
parent 483a9f64cd
commit 6b43116efd
51 changed files with 1428 additions and 312 deletions

View File

@@ -0,0 +1,308 @@
# 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"
)