Add subcription feature 5
This commit is contained in:
308
services/auth/app/api/password_reset.py
Normal file
308
services/auth/app/api/password_reset.py
Normal 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"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
39
services/auth/app/models/password_reset_tokens.py
Normal file
39
services/auth/app/models/password_reset_tokens.py
Normal 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})>"
|
||||
124
services/auth/app/repositories/password_reset_repository.py
Normal file
124
services/auth/app/repositories/password_reset_repository.py
Normal 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)}")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Add password reset token table
|
||||
|
||||
Revision ID: 20260116_add_password_reset_token_table
|
||||
Revises: 20260113_add_payment_columns_to_users.py
|
||||
Create Date: 2026-01-16 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers
|
||||
revision = '20260116_add_password_reset_token_table'
|
||||
down_revision = '20260113_add_payment_columns_to_users.py'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create password_reset_tokens table
|
||||
op.create_table(
|
||||
'password_reset_tokens',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('token', sa.String(length=255), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('is_used', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("timezone('utc', CURRENT_TIMESTAMP)")),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('token'),
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
|
||||
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
|
||||
op.create_index('ix_password_reset_tokens_expires_at', 'password_reset_tokens', ['expires_at'])
|
||||
op.create_index('ix_password_reset_tokens_is_used', 'password_reset_tokens', ['is_used'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes
|
||||
op.drop_index('ix_password_reset_tokens_is_used', table_name='password_reset_tokens')
|
||||
op.drop_index('ix_password_reset_tokens_expires_at', table_name='password_reset_tokens')
|
||||
op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens')
|
||||
op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('password_reset_tokens')
|
||||
@@ -214,8 +214,8 @@ async def register_bakery(
|
||||
subscription_id=str(existing_subscription.id)
|
||||
)
|
||||
else:
|
||||
# Create starter subscription with 14-day trial
|
||||
trial_end_date = datetime.now(timezone.utc) + timedelta(days=14)
|
||||
# Create starter subscription with 0-day trial
|
||||
trial_end_date = datetime.now(timezone.utc)
|
||||
next_billing_date = trial_end_date
|
||||
|
||||
await subscription_repo.create_subscription({
|
||||
@@ -229,10 +229,10 @@ async def register_bakery(
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Default free trial subscription created for new tenant",
|
||||
"Default subscription created for new tenant",
|
||||
tenant_id=str(result.id),
|
||||
plan="starter",
|
||||
trial_days=14
|
||||
trial_days=0
|
||||
)
|
||||
except Exception as subscription_error:
|
||||
logger.error(
|
||||
|
||||
@@ -47,7 +47,7 @@ class TenantSettings(BaseServiceSettings):
|
||||
|
||||
# Subscription Plans
|
||||
DEFAULT_PLAN: str = os.getenv("DEFAULT_PLAN", "basic")
|
||||
TRIAL_PERIOD_DAYS: int = int(os.getenv("TRIAL_PERIOD_DAYS", "14"))
|
||||
TRIAL_PERIOD_DAYS: int = int(os.getenv("TRIAL_PERIOD_DAYS", "0"))
|
||||
|
||||
# Plan Limits
|
||||
BASIC_PLAN_LOCATIONS: int = int(os.getenv("BASIC_PLAN_LOCATIONS", "1"))
|
||||
|
||||
@@ -107,7 +107,7 @@ class CouponRepository:
|
||||
self,
|
||||
code: str,
|
||||
tenant_id: Optional[str],
|
||||
base_trial_days: int = 14
|
||||
base_trial_days: int = 0
|
||||
) -> tuple[bool, Optional[CouponRedemption], Optional[str]]:
|
||||
"""
|
||||
Redeem a coupon for a tenant.
|
||||
@@ -289,9 +289,9 @@ class CouponRepository:
|
||||
}
|
||||
|
||||
if coupon.discount_type == DiscountType.TRIAL_EXTENSION:
|
||||
trial_end = calculate_trial_end_date(14, coupon.discount_value)
|
||||
trial_end = calculate_trial_end_date(0, coupon.discount_value)
|
||||
preview["trial_end_date"] = trial_end.isoformat()
|
||||
preview["total_trial_days"] = 14 + coupon.discount_value
|
||||
preview["total_trial_days"] = 0 + coupon.discount_value
|
||||
|
||||
return preview
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class CouponService:
|
||||
self,
|
||||
coupon_code: str,
|
||||
tenant_id: str,
|
||||
base_trial_days: int = 14
|
||||
base_trial_days: int = 0
|
||||
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
Redeem a coupon for a tenant
|
||||
|
||||
@@ -824,7 +824,7 @@ class SubscriptionOrchestrationService:
|
||||
self,
|
||||
tenant_id: str,
|
||||
coupon_code: str,
|
||||
base_trial_days: int = 14
|
||||
base_trial_days: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Orchestrate coupon redemption workflow
|
||||
|
||||
Reference in New Issue
Block a user