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"
)

View File

@@ -355,6 +355,12 @@ class SecurityManager:
"""Generate secure random token"""
import secrets
return secrets.token_urlsafe(length)
@staticmethod
def generate_reset_token() -> str:
"""Generate a secure password reset token"""
import secrets
return secrets.token_urlsafe(32)
@staticmethod
def mask_sensitive_data(data: str, visible_chars: int = 4) -> str:

View File

@@ -6,7 +6,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api import auth_operations, users, onboarding_progress, consent, data_export, account_deletion, internal_demo
from app.api import auth_operations, users, onboarding_progress, consent, data_export, account_deletion, internal_demo, password_reset
from shared.service_base import StandardFastAPIService
from shared.messaging import UnifiedEventPublisher
@@ -170,3 +170,4 @@ service.add_router(consent.router, tags=["gdpr", "consent"])
service.add_router(data_export.router, tags=["gdpr", "data-export"])
service.add_router(account_deletion.router, tags=["gdpr", "account-deletion"])
service.add_router(internal_demo.router, tags=["internal-demo"])
service.add_router(password_reset.router, tags=["password-reset"])

View File

@@ -15,6 +15,7 @@ from .tokens import RefreshToken, LoginAttempt
from .onboarding import UserOnboardingProgress, UserOnboardingSummary
from .consent import UserConsent, ConsentHistory
from .deletion_job import DeletionJob
from .password_reset_tokens import PasswordResetToken
__all__ = [
'User',
@@ -25,5 +26,6 @@ __all__ = [
'UserConsent',
'ConsentHistory',
'DeletionJob',
'PasswordResetToken',
"AuditLog",
]

View File

@@ -0,0 +1,39 @@
# services/auth/app/models/password_reset_tokens.py
"""
Password reset token model for authentication service
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, String, DateTime, Boolean, Index
from sqlalchemy.dialects.postgresql import UUID
from shared.database.base import Base
class PasswordResetToken(Base):
"""
Password reset token model
Stores temporary tokens for password reset functionality
"""
__tablename__ = "password_reset_tokens"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
token = Column(String(255), nullable=False, unique=True, index=True)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_used = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
used_at = Column(DateTime(timezone=True), nullable=True)
# Add indexes for better performance
__table_args__ = (
Index('ix_password_reset_tokens_user_id', 'user_id'),
Index('ix_password_reset_tokens_token', 'token'),
Index('ix_password_reset_tokens_expires_at', 'expires_at'),
Index('ix_password_reset_tokens_is_used', 'is_used'),
)
def __repr__(self):
return f"<PasswordResetToken(id={self.id}, user_id={self.user_id}, token={self.token[:10]}..., is_used={self.is_used})>"

View File

@@ -0,0 +1,124 @@
# services/auth/app/repositories/password_reset_repository.py
"""
Password reset token repository
Repository for password reset token operations
"""
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, text
from datetime import datetime, timezone
import structlog
import uuid
from .base import AuthBaseRepository
from app.models.password_reset_tokens import PasswordResetToken
from shared.database.exceptions import DatabaseError
logger = structlog.get_logger()
class PasswordResetTokenRepository(AuthBaseRepository):
"""Repository for password reset token operations"""
def __init__(self, session: AsyncSession):
super().__init__(PasswordResetToken, session)
async def create_token(self, user_id: str, token: str, expires_at: datetime) -> PasswordResetToken:
"""Create a new password reset token"""
try:
token_data = {
"user_id": user_id,
"token": token,
"expires_at": expires_at,
"is_used": False
}
reset_token = await self.create(token_data)
logger.debug("Password reset token created",
user_id=user_id,
token_id=reset_token.id,
expires_at=expires_at)
return reset_token
except Exception as e:
logger.error("Failed to create password reset token",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to create password reset token: {str(e)}")
async def get_token_by_value(self, token: str) -> Optional[PasswordResetToken]:
"""Get password reset token by token value"""
try:
stmt = select(PasswordResetToken).where(
and_(
PasswordResetToken.token == token,
PasswordResetToken.is_used == False,
PasswordResetToken.expires_at > datetime.now(timezone.utc)
)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Failed to get password reset token by value", error=str(e))
raise DatabaseError(f"Failed to get password reset token: {str(e)}")
async def mark_token_as_used(self, token_id: str) -> Optional[PasswordResetToken]:
"""Mark a password reset token as used"""
try:
return await self.update(token_id, {
"is_used": True,
"used_at": datetime.now(timezone.utc)
})
except Exception as e:
logger.error("Failed to mark password reset token as used",
token_id=token_id,
error=str(e))
raise DatabaseError(f"Failed to mark token as used: {str(e)}")
async def cleanup_expired_tokens(self) -> int:
"""Clean up expired password reset tokens"""
try:
now = datetime.now(timezone.utc)
# Delete expired tokens
query = text("""
DELETE FROM password_reset_tokens
WHERE expires_at < :now OR is_used = true
""")
result = await self.session.execute(query, {"now": now})
deleted_count = result.rowcount
logger.info("Cleaned up expired password reset tokens",
deleted_count=deleted_count)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup expired password reset tokens", error=str(e))
raise DatabaseError(f"Token cleanup failed: {str(e)}")
async def get_valid_token_for_user(self, user_id: str) -> Optional[PasswordResetToken]:
"""Get a valid (unused, not expired) password reset token for a user"""
try:
stmt = select(PasswordResetToken).where(
and_(
PasswordResetToken.user_id == user_id,
PasswordResetToken.is_used == False,
PasswordResetToken.expires_at > datetime.now(timezone.utc)
)
).order_by(PasswordResetToken.created_at.desc())
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Failed to get valid token for user",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get valid token for user: {str(e)}")

View File

@@ -351,6 +351,8 @@ class AuthService:
'subscription_id': result['subscription_id'],
'payment_customer_id': result['payment_customer_id'],
'status': result['status'],
'access_token': result.get('access_token'),
'refresh_token': result.get('refresh_token'),
'message': 'Registration completed successfully after 3DS verification'
}
@@ -593,9 +595,129 @@ class AuthService:
detail=f"Token generation failed: {str(e)}"
) from e
async def request_password_reset(self, email: str) -> bool:
"""
Request a password reset for a user
Args:
email: User's email address
Returns:
True if request was processed (whether user exists or not)
"""
try:
logger.info(f"Processing password reset request for email: {email}")
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
# Find user by email (don't reveal if user exists)
user = await user_repo.get_by_field("email", email)
if not user:
logger.info(f"Password reset request for non-existent email: {email}")
return True # Don't reveal if email exists
# Generate reset token
reset_token = SecurityManager.generate_reset_token()
# Set expiration (1 hour)
from datetime import datetime, timedelta, timezone
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
# Store reset token
token_repo = PasswordResetTokenRepository(session)
# Clean up expired tokens
await token_repo.cleanup_expired_tokens()
# Create new token
await token_repo.create_token(
user_id=str(user.id),
token=reset_token,
expires_at=expires_at
)
# Commit transaction
await session.commit()
logger.info(f"Password reset token created for user: {email}")
return True
except Exception as e:
logger.error(f"Password reset request failed: {str(e)}", exc_info=True)
return False
async def reset_password_with_token(self, token: str, new_password: str) -> bool:
"""
Reset password using a valid reset token
Args:
token: Password reset token
new_password: New password to set
Returns:
True if password was successfully reset
"""
try:
logger.info(f"Processing password reset with token: {token[:10]}...")
# Validate password strength
if not SecurityManager.validate_password(new_password):
errors = SecurityManager.get_password_validation_errors(new_password)
logger.warning(f"Password validation failed: {errors}")
raise ValueError(f"Password does not meet requirements: {'; '.join(errors)}")
async with self.database_manager.get_session() as session:
# Find the reset token
token_repo = PasswordResetTokenRepository(session)
reset_token_obj = await token_repo.get_token_by_value(token)
if not reset_token_obj:
logger.warning(f"Invalid or expired password reset token: {token[:10]}...")
raise ValueError("Invalid or expired reset token")
# Get the user
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: {token[:10]}...")
raise ValueError("Invalid reset token")
# Hash the new password
hashed_password = SecurityManager.hash_password(new_password)
# Update user's password
await user_repo.update(str(user.id), {
"hashed_password": hashed_password
})
# Mark token as used
await token_repo.mark_token_as_used(str(reset_token_obj.id))
# Commit transaction
await session.commit()
logger.info(f"Password successfully reset for user: {user.email}")
return True
except ValueError:
# Re-raise value errors (validation errors)
raise
except Exception as e:
logger.error(f"Password reset failed: {str(e)}", exc_info=True)
return False
# Import database manager for singleton instance
from app.core.database import database_manager
# Import required modules for the password reset functionality
from app.repositories.password_reset_repository import PasswordResetTokenRepository
from shared.clients.notification_client import NotificationServiceClient
# Singleton instance for dependency injection
auth_service = AuthService(database_manager=database_manager)