Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from .internal_demo import router as internal_demo_router
__all__ = ["internal_demo_router"]

View File

@@ -0,0 +1,214 @@
"""
User self-service account deletion API for GDPR compliance
Implements Article 17 (Right to erasure / "Right to be forgotten")
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Request, BackgroundTasks
from pydantic import BaseModel, Field
from datetime import datetime, timezone
import structlog
from shared.auth.decorators import get_current_user_dep
from app.core.database import get_db
from app.services.admin_delete import AdminUserDeleteService
from app.models.users import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import httpx
logger = structlog.get_logger()
router = APIRouter()
class AccountDeletionRequest(BaseModel):
"""Request model for account deletion"""
confirm_email: str = Field(..., description="User's email for confirmation")
reason: str = Field(default="", description="Optional reason for deletion")
password: str = Field(..., description="User's password for verification")
class DeletionScheduleResponse(BaseModel):
"""Response for scheduled deletion"""
message: str
user_id: str
scheduled_deletion_date: str
grace_period_days: int = 30
@router.delete("/api/v1/auth/me/account")
async def request_account_deletion(
deletion_request: AccountDeletionRequest,
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Request account deletion (self-service)
GDPR Article 17 - Right to erasure ("right to be forgotten")
This initiates account deletion with a 30-day grace period.
During this period:
- Account is marked for deletion
- User can still log in and cancel deletion
- After 30 days, account is permanently deleted
Requires:
- Email confirmation matching logged-in user
- Current password verification
"""
try:
user_id = UUID(current_user["user_id"])
user_email = current_user.get("email")
if deletion_request.confirm_email.lower() != user_email.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email confirmation does not match your account email"
)
query = select(User).where(User.id == user_id)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
from app.core.security import SecurityManager
if not SecurityManager.verify_password(deletion_request.password, user.hashed_password):
logger.warning(
"account_deletion_invalid_password",
user_id=str(user_id),
ip_address=request.client.host if request.client else None
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password"
)
logger.info(
"account_deletion_requested",
user_id=str(user_id),
email=user_email,
reason=deletion_request.reason[:100] if deletion_request.reason else None,
ip_address=request.client.host if request.client else None
)
tenant_id = current_user.get("tenant_id")
if tenant_id:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
cancel_response = await client.get(
f"http://tenant-service:8000/api/v1/tenants/{tenant_id}/subscription/status",
headers={"Authorization": request.headers.get("Authorization")}
)
if cancel_response.status_code == 200:
subscription_data = cancel_response.json()
if subscription_data.get("status") in ["active", "pending_cancellation"]:
cancel_sub_response = await client.delete(
f"http://tenant-service:8000/api/v1/tenants/{tenant_id}/subscription",
headers={"Authorization": request.headers.get("Authorization")}
)
logger.info(
"subscription_cancelled_before_deletion",
user_id=str(user_id),
tenant_id=tenant_id,
subscription_status=subscription_data.get("status")
)
except Exception as e:
logger.warning(
"subscription_cancellation_failed_during_account_deletion",
user_id=str(user_id),
error=str(e)
)
deletion_service = AdminUserDeleteService(db)
result = await deletion_service.delete_admin_user_complete(
user_id=str(user_id),
requesting_user_id=str(user_id)
)
return {
"message": "Account deleted successfully",
"user_id": str(user_id),
"deletion_date": datetime.now(timezone.utc).isoformat(),
"data_retained": "Audit logs will be anonymized after legal retention period (1 year)",
"gdpr_article": "Article 17 - Right to erasure"
}
except HTTPException:
raise
except Exception as e:
logger.error(
"account_deletion_failed",
user_id=current_user.get("user_id"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to process account deletion request"
)
@router.get("/api/v1/auth/me/account/deletion-info")
async def get_deletion_info(
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get information about what will be deleted
Shows user exactly what data will be deleted when they request
account deletion. Transparency requirement under GDPR.
"""
try:
user_id = UUID(current_user["user_id"])
deletion_service = AdminUserDeleteService(db)
preview = await deletion_service.preview_user_deletion(str(user_id))
return {
"user_info": preview.get("user"),
"what_will_be_deleted": {
"account_data": "Your account, email, name, and profile information",
"sessions": "All active sessions and refresh tokens",
"consents": "Your consent history and preferences",
"security_data": "Login history and security logs",
"tenant_data": preview.get("tenant_associations"),
"estimated_records": preview.get("estimated_deletions")
},
"what_will_be_retained": {
"audit_logs": "Anonymized for 1 year (legal requirement)",
"financial_records": "Anonymized for 7 years (tax law)",
"anonymized_analytics": "Aggregated data without personal identifiers"
},
"process": {
"immediate_deletion": True,
"grace_period": "No grace period - deletion is immediate",
"reversible": False,
"completion_time": "Immediate"
},
"gdpr_rights": {
"article_17": "Right to erasure (right to be forgotten)",
"article_5_1_e": "Storage limitation principle",
"exceptions": "Data required for legal obligations will be retained in anonymized form"
},
"warning": "⚠️ This action is irreversible. All your data will be permanently deleted."
}
except Exception as e:
logger.error(
"deletion_info_failed",
user_id=current_user.get("user_id"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve deletion information"
)

View File

@@ -0,0 +1,657 @@
"""
Refactored Auth Operations with proper 3DS/3DS2 support
Implements SetupIntent-first architecture for secure registration flows
"""
import logging
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Request
from app.services.auth_service import auth_service, AuthService
from app.schemas.auth import UserRegistration, UserLogin, UserResponse
from app.models.users import User
from shared.exceptions.auth_exceptions import (
UserCreationError,
RegistrationError,
PaymentOrchestrationError
)
from shared.auth.decorators import get_current_user_dep
# Configure logging
logger = logging.getLogger(__name__)
# Create router
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
async def get_auth_service() -> AuthService:
"""Dependency injection for auth service"""
return auth_service
@router.post("/start-registration",
response_model=Dict[str, Any],
summary="Start secure registration with payment verification")
async def start_registration(
user_data: UserRegistration,
request: Request,
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
Start secure registration flow with SetupIntent-first approach
This is the FIRST step in the atomic registration architecture:
1. Creates Stripe customer via tenant service
2. Creates SetupIntent with confirm=True
3. Returns SetupIntent data to frontend
IMPORTANT: NO subscription or user is created in this step!
Two possible outcomes:
- requires_action=True: 3DS required, frontend must confirm SetupIntent then call complete-registration
- requires_action=False: No 3DS required, but frontend STILL must call complete-registration
In BOTH cases, the frontend must call complete-registration to create the subscription and user.
This ensures consistent flow and prevents duplicate subscriptions.
Args:
user_data: User registration data with payment info
Returns:
SetupIntent result with:
- requires_action: True if 3DS required, False if not
- setup_intent_id: SetupIntent ID for verification
- client_secret: For 3DS authentication (when requires_action=True)
- customer_id: Stripe customer ID
- Other SetupIntent metadata
Raises:
HTTPException: 400 for validation errors, 500 for server errors
"""
try:
logger.info(f"Starting secure registration flow, email={user_data.email}, plan={user_data.subscription_plan}")
# Validate required fields
if not user_data.email or not user_data.email.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
if not user_data.password or len(user_data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long"
)
if not user_data.full_name or not user_data.full_name.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Full name is required"
)
if user_data.subscription_plan and not user_data.payment_method_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payment method ID is required for subscription registration"
)
# Start secure registration flow
result = await auth_service.start_secure_registration_flow(user_data)
# Check if 3DS is required
if result.get('requires_action', False):
logger.info(f"Registration requires 3DS verification, email={user_data.email}, setup_intent_id={result.get('setup_intent_id')}")
return {
"requires_action": True,
"action_type": "setup_intent_confirmation",
"client_secret": result.get('client_secret'),
"setup_intent_id": result.get('setup_intent_id'),
"customer_id": result.get('customer_id'),
"payment_customer_id": result.get('payment_customer_id'),
"plan_id": result.get('plan_id'),
"payment_method_id": result.get('payment_method_id'),
"billing_cycle": result.get('billing_cycle'),
"coupon_info": result.get('coupon_info'),
"trial_info": result.get('trial_info'),
"email": result.get('email'),
"message": "Payment verification required. Frontend must confirm SetupIntent to handle 3DS."
}
else:
user = result.get('user')
user_id = user.id if user else None
logger.info(f"Registration completed without 3DS, email={user_data.email}, user_id={user_id}, subscription_id={result.get('subscription_id')}")
# Return complete registration result
user_data_response = None
if user:
user_data_response = {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active
}
return {
"requires_action": False,
"setup_intent_id": result.get('setup_intent_id'),
"user": user_data_response,
"subscription_id": result.get('subscription_id'),
"payment_customer_id": result.get('payment_customer_id'),
"status": result.get('status'),
"message": "Registration completed successfully"
}
except RegistrationError as e:
logger.error(f"Registration flow failed: {str(e)}, email: {user_data.email}",
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration failed: {str(e)}"
) from e
except PaymentOrchestrationError as e:
logger.error(f"Payment orchestration failed: {str(e)}, email: {user_data.email}",
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Payment setup failed: {str(e)}"
) from e
except Exception as e:
logger.error(f"Unexpected registration error: {str(e)}, email: {user_data.email}",
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration error: {str(e)}"
) from e
@router.post("/complete-registration",
response_model=Dict[str, Any],
summary="Complete registration after SetupIntent verification")
async def complete_registration(
verification_data: Dict[str, Any],
request: Request,
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
Complete registration after frontend confirms SetupIntent
This is the SECOND step in the atomic registration architecture:
1. Called after frontend confirms SetupIntent (with or without 3DS)
2. Verifies SetupIntent status with Stripe
3. Creates subscription with verified payment method (FIRST time subscription is created)
4. Creates user record in auth database
5. Saves onboarding progress
6. Generates auth tokens for auto-login
This endpoint is called in TWO scenarios:
1. After user completes 3DS authentication (requires_action=True flow)
2. Immediately after start-registration (requires_action=False flow)
In BOTH cases, this is where the subscription and user are actually created.
This ensures consistent flow and prevents duplicate subscriptions.
Args:
verification_data: Must contain:
- setup_intent_id: Verified SetupIntent ID
- user_data: Original user registration data
Returns:
Complete registration result with:
- user: Created user data
- subscription_id: Created subscription ID
- payment_customer_id: Stripe customer ID
- access_token: JWT access token
- refresh_token: JWT refresh token
Raises:
HTTPException: 400 if setup_intent_id is missing, 500 for server errors
"""
try:
# Validate required fields
if not verification_data.get('setup_intent_id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SetupIntent ID is required"
)
if not verification_data.get('user_data'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User data is required"
)
# Extract user data
user_data_dict = verification_data['user_data']
user_data = UserRegistration(**user_data_dict)
logger.info(f"Completing registration after SetupIntent verification, email={user_data.email}, setup_intent_id={verification_data['setup_intent_id']}")
# Complete registration with verified payment
result = await auth_service.complete_registration_with_verified_payment(
verification_data['setup_intent_id'],
user_data
)
logger.info(f"Registration completed successfully after 3DS, user_id={result['user'].id}, email={result['user'].email}, subscription_id={result.get('subscription_id')}")
return {
"success": True,
"user": {
"id": str(result['user'].id),
"email": result['user'].email,
"full_name": result['user'].full_name,
"is_active": result['user'].is_active,
"is_verified": result['user'].is_verified,
"created_at": result['user'].created_at.isoformat() if result['user'].created_at else None,
"role": result['user'].role
},
"subscription_id": result.get('subscription_id'),
"payment_customer_id": result.get('payment_customer_id'),
"status": result.get('status'),
"access_token": result.get('access_token'),
"refresh_token": result.get('refresh_token'),
"message": "Registration completed successfully after 3DS verification"
}
except RegistrationError as e:
logger.error(f"Registration completion after 3DS failed: {str(e)}, setup_intent_id: {verification_data.get('setup_intent_id')}, email: {user_data_dict.get('email') if user_data_dict else 'unknown'}",
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration completion failed: {str(e)}"
) from e
except Exception as e:
logger.error(f"Unexpected registration completion error: {str(e)}, setup_intent_id: {verification_data.get('setup_intent_id')}",
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration completion error: {str(e)}"
) from e
@router.post("/login",
response_model=Dict[str, Any],
summary="User login with subscription validation")
async def login(
login_data: UserLogin,
request: Request,
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
User login endpoint with subscription validation
This endpoint:
1. Validates user credentials
2. Checks if user has active subscription (if required)
3. Returns authentication tokens
4. Updates last login timestamp
Args:
login_data: User login credentials (email and password)
Returns:
Authentication tokens and user information
Raises:
HTTPException: 401 for invalid credentials, 403 for subscription issues
"""
try:
logger.info(f"Login attempt, email={login_data.email}")
# Validate required fields
if not login_data.email or not login_data.email.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
if not login_data.password or len(login_data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long"
)
# Call auth service to perform login
result = await auth_service.login_user(login_data)
logger.info(f"Login successful, email={login_data.email}, user_id={result['user'].id}")
# Extract tokens from result for top-level response
tokens = result.get('tokens', {})
return {
"success": True,
"access_token": tokens.get('access_token'),
"refresh_token": tokens.get('refresh_token'),
"token_type": tokens.get('token_type'),
"expires_in": tokens.get('expires_in'),
"user": {
"id": str(result['user'].id),
"email": result['user'].email,
"full_name": result['user'].full_name,
"is_active": result['user'].is_active,
"last_login": result['user'].last_login.isoformat() if result['user'].last_login else None
},
"subscription": result.get('subscription', {}),
"message": "Login successful"
}
except HTTPException:
# Re-raise HTTP exceptions (like 401 for invalid credentials)
raise
except Exception as e:
logger.error(f"Login failed: {str(e)}, email: {login_data.email}",
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Login failed: {str(e)}"
) from e
# ============================================================================
# TOKEN MANAGEMENT ENDPOINTS - NEWLY ADDED
# ============================================================================
@router.post("/refresh",
response_model=Dict[str, Any],
summary="Refresh access token using refresh token")
async def refresh_token(
request: Request,
refresh_data: Dict[str, Any],
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
Refresh access token using a valid refresh token
This endpoint:
1. Validates the refresh token
2. Generates new access and refresh tokens
3. Returns the new tokens
Args:
refresh_data: Dictionary containing refresh_token
Returns:
New authentication tokens
Raises:
HTTPException: 401 for invalid refresh tokens
"""
try:
logger.info("Token refresh request initiated")
# Extract refresh token from request
refresh_token = refresh_data.get("refresh_token")
if not refresh_token:
logger.warning("Refresh token missing from request")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Refresh token is required"
)
# Use service layer to refresh tokens
tokens = await auth_service.refresh_auth_tokens(refresh_token)
logger.info("Token refresh successful via service layer")
return {
"success": True,
"access_token": tokens.get("access_token"),
"refresh_token": tokens.get("refresh_token"),
"token_type": "bearer",
"expires_in": 1800, # 30 minutes
"message": "Token refresh successful"
}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Token refresh failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token refresh failed: {str(e)}"
) from e
@router.post("/verify",
response_model=Dict[str, Any],
summary="Verify token validity")
async def verify_token(
request: Request,
token_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Verify the validity of an access token
Args:
token_data: Dictionary containing access_token
Returns:
Token validation result
"""
try:
logger.info("Token verification request initiated")
# Extract access token from request
access_token = token_data.get("access_token")
if not access_token:
logger.warning("Access token missing from verification request")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Access token is required"
)
# Use service layer to verify token
result = await auth_service.verify_access_token(access_token)
logger.info("Token verification successful via service layer")
return {
"success": True,
"valid": result.get("valid"),
"user_id": result.get("user_id"),
"email": result.get("email"),
"message": "Token is valid"
}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Token verification failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token verification failed: {str(e)}"
) from e
@router.post("/logout",
response_model=Dict[str, Any],
summary="Logout and revoke refresh token")
async def logout(
request: Request,
logout_data: Dict[str, Any],
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
Logout user and revoke refresh token
Args:
logout_data: Dictionary containing refresh_token
Returns:
Logout confirmation
"""
try:
logger.info("Logout request initiated")
# Extract refresh token from request
refresh_token = logout_data.get("refresh_token")
if not refresh_token:
logger.warning("Refresh token missing from logout request")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Refresh token is required"
)
# Use service layer to revoke refresh token
try:
await auth_service.revoke_refresh_token(refresh_token)
logger.info("Logout successful via service layer")
return {
"success": True,
"message": "Logout successful"
}
except Exception as e:
logger.error(f"Error during logout: {str(e)}")
# Don't fail logout if revocation fails
return {
"success": True,
"message": "Logout successful (token revocation failed but user logged out)"
}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Logout failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Logout failed: {str(e)}"
) from e
@router.post("/change-password",
response_model=Dict[str, Any],
summary="Change user password")
async def change_password(
request: Request,
password_data: Dict[str, Any],
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
Change user password
Args:
password_data: Dictionary containing current_password and new_password
Returns:
Password change confirmation
"""
try:
logger.info("Password change request initiated")
# Extract user from request state
if not hasattr(request.state, 'user') or not request.state.user:
logger.warning("Unauthorized password change attempt - no user context")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
user_id = request.state.user.get("user_id")
if not user_id:
logger.warning("Unauthorized password change attempt - no user_id")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid user context"
)
# Extract password data
current_password = password_data.get("current_password")
new_password = password_data.get("new_password")
if not current_password or not new_password:
logger.warning("Password change missing required fields")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password and new password are required"
)
if len(new_password) < 8:
logger.warning("New password too short")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be at least 8 characters long"
)
# Use service layer to change password
await auth_service.change_user_password(user_id, current_password, new_password)
logger.info(f"Password change successful via service layer, user_id={user_id}")
return {
"success": True,
"message": "Password changed successfully"
}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Password change failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password change failed: {str(e)}"
) from e
@router.post("/verify-email",
response_model=Dict[str, Any],
summary="Verify user email")
async def verify_email(
request: Request,
email_data: Dict[str, Any],
auth_service: AuthService = Depends(get_auth_service)
) -> Dict[str, Any]:
"""
Verify user email (placeholder implementation)
Args:
email_data: Dictionary containing email and verification_token
Returns:
Email verification confirmation
"""
try:
logger.info("Email verification request initiated")
# Extract email and token
email = email_data.get("email")
verification_token = email_data.get("verification_token")
if not email or not verification_token:
logger.warning("Email verification missing required fields")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email and verification token are required"
)
# Use service layer to verify email
await auth_service.verify_user_email(email, verification_token)
logger.info("Email verification successful via service layer")
return {
"success": True,
"message": "Email verified successfully"
}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Email verification failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Email verification failed: {str(e)}"
) from e

View File

@@ -0,0 +1,372 @@
"""
User consent management API endpoints for GDPR compliance
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel, Field
from datetime import datetime, timezone
import structlog
import hashlib
from shared.auth.decorators import get_current_user_dep
from app.core.database import get_db
from app.models.consent import UserConsent, ConsentHistory
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
logger = structlog.get_logger()
router = APIRouter()
class ConsentRequest(BaseModel):
"""Request model for granting/updating consent"""
terms_accepted: bool = Field(..., description="Accept terms of service")
privacy_accepted: bool = Field(..., description="Accept privacy policy")
marketing_consent: bool = Field(default=False, description="Consent to marketing communications")
analytics_consent: bool = Field(default=False, description="Consent to analytics cookies")
consent_method: str = Field(..., description="How consent was given (registration, settings, cookie_banner)")
consent_version: str = Field(default="1.0", description="Version of terms/privacy policy")
class ConsentResponse(BaseModel):
"""Response model for consent data"""
id: str
user_id: str
terms_accepted: bool
privacy_accepted: bool
marketing_consent: bool
analytics_consent: bool
consent_version: str
consent_method: str
consented_at: str
withdrawn_at: Optional[str]
class ConsentHistoryResponse(BaseModel):
"""Response model for consent history"""
id: str
user_id: str
action: str
consent_snapshot: dict
created_at: str
def hash_text(text: str) -> str:
"""Create hash of consent text for verification"""
return hashlib.sha256(text.encode()).hexdigest()
@router.post("/api/v1/auth/me/consent", response_model=ConsentResponse, status_code=status.HTTP_201_CREATED)
async def record_consent(
consent_data: ConsentRequest,
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Record user consent for data processing
GDPR Article 7 - Conditions for consent
"""
try:
user_id = UUID(current_user["user_id"])
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
consent = UserConsent(
user_id=user_id,
terms_accepted=consent_data.terms_accepted,
privacy_accepted=consent_data.privacy_accepted,
marketing_consent=consent_data.marketing_consent,
analytics_consent=consent_data.analytics_consent,
consent_version=consent_data.consent_version,
consent_method=consent_data.consent_method,
ip_address=ip_address,
user_agent=user_agent,
consented_at=datetime.now(timezone.utc)
)
db.add(consent)
await db.flush()
history = ConsentHistory(
user_id=user_id,
consent_id=consent.id,
action="granted",
consent_snapshot=consent_data.dict(),
ip_address=ip_address,
user_agent=user_agent,
consent_method=consent_data.consent_method,
created_at=datetime.now(timezone.utc)
)
db.add(history)
await db.commit()
await db.refresh(consent)
logger.info(
"consent_recorded",
user_id=str(user_id),
consent_version=consent_data.consent_version,
method=consent_data.consent_method
)
return ConsentResponse(
id=str(consent.id),
user_id=str(consent.user_id),
terms_accepted=consent.terms_accepted,
privacy_accepted=consent.privacy_accepted,
marketing_consent=consent.marketing_consent,
analytics_consent=consent.analytics_consent,
consent_version=consent.consent_version,
consent_method=consent.consent_method,
consented_at=consent.consented_at.isoformat(),
withdrawn_at=consent.withdrawn_at.isoformat() if consent.withdrawn_at else None
)
except Exception as e:
await db.rollback()
logger.error("error_recording_consent", error=str(e), user_id=current_user.get("user_id"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to record consent"
)
@router.get("/api/v1/auth/me/consent/current", response_model=Optional[ConsentResponse])
async def get_current_consent(
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get current active consent for user
"""
try:
user_id = UUID(current_user["user_id"])
query = select(UserConsent).where(
and_(
UserConsent.user_id == user_id,
UserConsent.withdrawn_at.is_(None)
)
).order_by(UserConsent.consented_at.desc())
result = await db.execute(query)
consent = result.scalar_one_or_none()
if not consent:
return None
return ConsentResponse(
id=str(consent.id),
user_id=str(consent.user_id),
terms_accepted=consent.terms_accepted,
privacy_accepted=consent.privacy_accepted,
marketing_consent=consent.marketing_consent,
analytics_consent=consent.analytics_consent,
consent_version=consent.consent_version,
consent_method=consent.consent_method,
consented_at=consent.consented_at.isoformat(),
withdrawn_at=consent.withdrawn_at.isoformat() if consent.withdrawn_at else None
)
except Exception as e:
logger.error("error_getting_consent", error=str(e), user_id=current_user.get("user_id"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve consent"
)
@router.get("/api/v1/auth/me/consent/history", response_model=List[ConsentHistoryResponse])
async def get_consent_history(
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get complete consent history for user
GDPR Article 7(1) - Demonstrating consent
"""
try:
user_id = UUID(current_user["user_id"])
query = select(ConsentHistory).where(
ConsentHistory.user_id == user_id
).order_by(ConsentHistory.created_at.desc())
result = await db.execute(query)
history = result.scalars().all()
return [
ConsentHistoryResponse(
id=str(h.id),
user_id=str(h.user_id),
action=h.action,
consent_snapshot=h.consent_snapshot,
created_at=h.created_at.isoformat()
)
for h in history
]
except Exception as e:
logger.error("error_getting_consent_history", error=str(e), user_id=current_user.get("user_id"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve consent history"
)
@router.put("/api/v1/auth/me/consent", response_model=ConsentResponse)
async def update_consent(
consent_data: ConsentRequest,
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Update user consent preferences
GDPR Article 7(3) - Withdrawal of consent
"""
try:
user_id = UUID(current_user["user_id"])
query = select(UserConsent).where(
and_(
UserConsent.user_id == user_id,
UserConsent.withdrawn_at.is_(None)
)
).order_by(UserConsent.consented_at.desc())
result = await db.execute(query)
current_consent = result.scalar_one_or_none()
if current_consent:
current_consent.withdrawn_at = datetime.now(timezone.utc)
history = ConsentHistory(
user_id=user_id,
consent_id=current_consent.id,
action="updated",
consent_snapshot=current_consent.to_dict(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consent_method=consent_data.consent_method,
created_at=datetime.now(timezone.utc)
)
db.add(history)
new_consent = UserConsent(
user_id=user_id,
terms_accepted=consent_data.terms_accepted,
privacy_accepted=consent_data.privacy_accepted,
marketing_consent=consent_data.marketing_consent,
analytics_consent=consent_data.analytics_consent,
consent_version=consent_data.consent_version,
consent_method=consent_data.consent_method,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consented_at=datetime.now(timezone.utc)
)
db.add(new_consent)
await db.flush()
history = ConsentHistory(
user_id=user_id,
consent_id=new_consent.id,
action="granted" if not current_consent else "updated",
consent_snapshot=consent_data.dict(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consent_method=consent_data.consent_method,
created_at=datetime.now(timezone.utc)
)
db.add(history)
await db.commit()
await db.refresh(new_consent)
logger.info(
"consent_updated",
user_id=str(user_id),
consent_version=consent_data.consent_version
)
return ConsentResponse(
id=str(new_consent.id),
user_id=str(new_consent.user_id),
terms_accepted=new_consent.terms_accepted,
privacy_accepted=new_consent.privacy_accepted,
marketing_consent=new_consent.marketing_consent,
analytics_consent=new_consent.analytics_consent,
consent_version=new_consent.consent_version,
consent_method=new_consent.consent_method,
consented_at=new_consent.consented_at.isoformat(),
withdrawn_at=None
)
except Exception as e:
await db.rollback()
logger.error("error_updating_consent", error=str(e), user_id=current_user.get("user_id"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update consent"
)
@router.post("/api/v1/auth/me/consent/withdraw", status_code=status.HTTP_200_OK)
async def withdraw_consent(
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Withdraw all consent
GDPR Article 7(3) - Right to withdraw consent
"""
try:
user_id = UUID(current_user["user_id"])
query = select(UserConsent).where(
and_(
UserConsent.user_id == user_id,
UserConsent.withdrawn_at.is_(None)
)
)
result = await db.execute(query)
consents = result.scalars().all()
for consent in consents:
consent.withdrawn_at = datetime.now(timezone.utc)
history = ConsentHistory(
user_id=user_id,
consent_id=consent.id,
action="withdrawn",
consent_snapshot=consent.to_dict(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consent_method="user_withdrawal",
created_at=datetime.now(timezone.utc)
)
db.add(history)
await db.commit()
logger.info("consent_withdrawn", user_id=str(user_id), count=len(consents))
return {
"message": "Consent withdrawn successfully",
"withdrawn_count": len(consents)
}
except Exception as e:
await db.rollback()
logger.error("error_withdrawing_consent", error=str(e), user_id=current_user.get("user_id"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to withdraw consent"
)

View File

@@ -0,0 +1,121 @@
"""
User data export API endpoints for GDPR compliance
Implements Article 15 (Right to Access) and Article 20 (Right to Data Portability)
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
import structlog
from shared.auth.decorators import get_current_user_dep
from app.core.database import get_db
from app.services.data_export_service import DataExportService
logger = structlog.get_logger()
router = APIRouter()
@router.get("/api/v1/auth/me/export")
async def export_my_data(
current_user: dict = Depends(get_current_user_dep),
db = Depends(get_db)
):
"""
Export all personal data for the current user
GDPR Article 15 - Right of access by the data subject
GDPR Article 20 - Right to data portability
Returns complete user data in machine-readable JSON format including:
- Personal information
- Account data
- Consent history
- Security logs
- Audit trail
Response is provided in JSON format for easy data portability.
"""
try:
user_id = UUID(current_user["user_id"])
export_service = DataExportService(db)
data = await export_service.export_user_data(user_id)
logger.info(
"data_export_requested",
user_id=str(user_id),
email=current_user.get("email")
)
return JSONResponse(
content=data,
status_code=status.HTTP_200_OK,
headers={
"Content-Disposition": f'attachment; filename="user_data_export_{user_id}.json"',
"Content-Type": "application/json"
}
)
except Exception as e:
logger.error(
"data_export_failed",
user_id=current_user.get("user_id"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to export user data"
)
@router.get("/api/v1/auth/me/export/summary")
async def get_export_summary(
current_user: dict = Depends(get_current_user_dep),
db = Depends(get_db)
):
"""
Get a summary of what data would be exported
Useful for showing users what data we have about them
before they request full export.
"""
try:
user_id = UUID(current_user["user_id"])
export_service = DataExportService(db)
data = await export_service.export_user_data(user_id)
summary = {
"user_id": str(user_id),
"data_categories": {
"personal_data": bool(data.get("personal_data")),
"account_data": bool(data.get("account_data")),
"consent_data": bool(data.get("consent_data")),
"security_data": bool(data.get("security_data")),
"onboarding_data": bool(data.get("onboarding_data")),
"audit_logs": bool(data.get("audit_logs"))
},
"data_counts": {
"active_sessions": data.get("account_data", {}).get("active_sessions_count", 0),
"consent_changes": data.get("consent_data", {}).get("total_consent_changes", 0),
"login_attempts": len(data.get("security_data", {}).get("recent_login_attempts", [])),
"audit_logs": data.get("audit_logs", {}).get("total_logs_exported", 0)
},
"export_format": "JSON",
"gdpr_articles": ["Article 15 (Right to Access)", "Article 20 (Data Portability)"]
}
return summary
except Exception as e:
logger.error(
"export_summary_failed",
user_id=current_user.get("user_id"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate export summary"
)

View File

@@ -0,0 +1,229 @@
"""
Internal Demo Cloning API for Auth Service
Service-to-service endpoint for cloning authentication and user data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import structlog
import uuid
from datetime import datetime, timezone
from typing import Optional
import os
import sys
from pathlib import Path
import json
# Add shared path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from app.core.database import get_db
from app.models.users import User
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Base demo tenant IDs
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
Clone auth service data for a virtual demo tenant
Clones:
- Demo users (owner and staff)
Note: Tenant memberships are handled by the tenant service's internal_demo endpoint
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting auth data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Note: We don't check for existing users since User model doesn't have demo_session_id
# Demo users are identified by their email addresses from the seed data
# Idempotency is handled by checking if each user email already exists below
# Load demo users from JSON seed file
from shared.utils.seed_data_paths import get_seed_data_path
if demo_account_type == "professional":
json_file = get_seed_data_path("professional", "02-auth.json")
elif demo_account_type == "enterprise":
json_file = get_seed_data_path("enterprise", "02-auth.json")
elif demo_account_type == "enterprise_child":
# Child locations don't have separate auth data - they share parent's users
logger.info("enterprise_child uses parent tenant auth, skipping user cloning", virtual_tenant_id=virtual_tenant_id)
return {
"service": "auth",
"status": "completed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"details": {"users": 0, "note": "Child locations share parent auth"}
}
else:
raise ValueError(f"Invalid demo account type: {demo_account_type}")
# Load JSON data
import json
with open(json_file, 'r', encoding='utf-8') as f:
seed_data = json.load(f)
# Get demo users for this account type
demo_users_data = seed_data.get("users", [])
records_cloned = 0
# Create users and tenant memberships
for user_data in demo_users_data:
user_id = uuid.UUID(user_data["id"])
# Create user if not exists
user_result = await db.execute(
select(User).where(User.id == user_id)
)
existing_user = user_result.scalars().first()
if not existing_user:
# Apply date adjustments to created_at and updated_at
from shared.utils.demo_dates import adjust_date_for_demo
# Adjust created_at date
created_at_str = user_data.get("created_at", session_time.isoformat())
if isinstance(created_at_str, str):
try:
original_created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
adjusted_created_at = adjust_date_for_demo(original_created_at, session_time)
except ValueError:
adjusted_created_at = session_time
else:
adjusted_created_at = session_time
# Adjust updated_at date (same as created_at for demo users)
adjusted_updated_at = adjusted_created_at
# Get full_name from either "name" or "full_name" field
full_name = user_data.get("full_name") or user_data.get("name", "Demo User")
# For demo users, use a placeholder hashed password (they won't actually log in)
# In production, this would be properly hashed
demo_hashed_password = "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqNlI.eFKW" # "demo_password"
user = User(
id=user_id,
email=user_data["email"],
full_name=full_name,
hashed_password=demo_hashed_password,
is_active=user_data.get("is_active", True),
is_verified=True,
role=user_data.get("role", "member"),
language=user_data.get("language", "es"),
timezone=user_data.get("timezone", "Europe/Madrid"),
created_at=adjusted_created_at,
updated_at=adjusted_updated_at
)
db.add(user)
records_cloned += 1
# Note: Tenant memberships are handled by tenant service
# Only create users in auth service
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Auth data cloning completed",
virtual_tenant_id=virtual_tenant_id,
session_id=session_id,
records_cloned=records_cloned,
duration_ms=duration_ms
)
return {
"service": "auth",
"status": "completed",
"records_cloned": records_cloned,
"base_tenant_id": str(base_tenant_id),
"virtual_tenant_id": str(virtual_tenant_id),
"session_id": session_id,
"demo_account_type": demo_account_type,
"duration_ms": duration_ms
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone auth data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "auth",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check():
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "auth",
"clone_endpoint": "available",
"version": "1.0.0"
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,662 @@
"""
User management API routes
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Path, Body
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Dict, Any
import structlog
import uuid
from datetime import datetime, timezone
from app.core.database import get_db, get_background_db_session
from app.schemas.auth import UserResponse, PasswordChange
from app.schemas.users import UserUpdate, BatchUserRequest, OwnerUserCreate
from app.services.user_service import EnhancedUserService
from app.models.users import User
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.admin_delete import AdminUserDeleteService
from app.models import AuditLog
# Import unified authentication from shared library
from shared.auth.decorators import (
get_current_user_dep,
require_admin_role_dep
)
from shared.security import create_audit_logger, AuditSeverity, AuditAction
logger = structlog.get_logger()
router = APIRouter(tags=["users"])
# Initialize audit logger
audit_logger = create_audit_logger("auth-service", AuditLog)
@router.delete("/api/v1/auth/users/{user_id}")
async def delete_admin_user(
background_tasks: BackgroundTasks,
user_id: str = Path(..., description="User ID"),
current_user = Depends(require_admin_role_dep),
db: AsyncSession = Depends(get_db)
):
"""
Delete an admin user and all associated data across all services.
This operation will:
1. Cancel any active training jobs for user's tenants
2. Delete all trained models and artifacts
3. Delete all forecasts and predictions
4. Delete notification preferences and logs
5. Handle tenant ownership (transfer or delete)
6. Delete user account and authentication data
**Warning: This operation is irreversible!**
"""
# Validate user_id format
try:
uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID format"
)
# Quick validation that user exists before starting background task
deletion_service = AdminUserDeleteService(db)
user_info = await deletion_service._validate_admin_user(user_id)
if not user_info:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Admin user {user_id} not found"
)
# Log audit event for user deletion
try:
# Get tenant_id from current_user or use a placeholder for system-level operations
tenant_id_str = current_user.get("tenant_id", "00000000-0000-0000-0000-000000000000")
await audit_logger.log_deletion(
db_session=db,
tenant_id=tenant_id_str,
user_id=current_user["user_id"],
resource_type="user",
resource_id=user_id,
resource_data=user_info,
description=f"Admin {current_user.get('email', current_user['user_id'])} initiated deletion of user {user_info.get('email', user_id)}",
endpoint="/delete/{user_id}",
method="DELETE"
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
# Start deletion as background task for better performance
background_tasks.add_task(
execute_admin_user_deletion,
user_id=user_id,
requesting_user_id=current_user["user_id"]
)
return {
"success": True,
"message": f"Admin user deletion for {user_id} has been initiated",
"status": "processing",
"user_info": user_info,
"initiated_at": datetime.utcnow().isoformat(),
"note": "Deletion is processing in the background. Check logs for completion status."
}
# Add this background task function to services/auth/app/api/users.py:
async def execute_admin_user_deletion(user_id: str, requesting_user_id: str):
"""
Background task using shared infrastructure
"""
# ✅ Use the shared background session
async with get_background_db_session() as session:
deletion_service = AdminUserDeleteService(session)
result = await deletion_service.delete_admin_user_complete(
user_id=user_id,
requesting_user_id=requesting_user_id
)
logger.info("Background admin user deletion completed successfully",
user_id=user_id,
requesting_user=requesting_user_id,
result=result)
@router.get("/api/v1/auth/users/{user_id}/deletion-preview")
async def preview_user_deletion(
user_id: str = Path(..., description="User ID"),
db: AsyncSession = Depends(get_db)
):
"""
Preview what data would be deleted for an admin user.
This endpoint provides a dry-run preview of the deletion operation
without actually deleting any data.
"""
try:
uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID format"
)
deletion_service = AdminUserDeleteService(db)
# Get user info
user_info = await deletion_service._validate_admin_user(user_id)
if not user_info:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Admin user {user_id} not found"
)
# Get tenant associations
tenant_info = await deletion_service._get_user_tenant_info(user_id)
# Build preview
preview = {
"user": user_info,
"tenant_associations": tenant_info,
"estimated_deletions": {
"training_models": "All models for associated tenants",
"forecasts": "All forecasts for associated tenants",
"notifications": "All user notification data",
"tenant_memberships": tenant_info['total_tenants'],
"owned_tenants": f"{tenant_info['owned_tenants']} (will be transferred or deleted)"
},
"warning": "This operation is irreversible and will permanently delete all associated data"
}
return preview
@router.get("/api/v1/auth/users/{user_id}", response_model=UserResponse)
async def get_user_by_id(
user_id: str = Path(..., description="User ID"),
db: AsyncSession = Depends(get_db)
):
"""
Get user information by user ID.
This endpoint is for internal service-to-service communication.
It returns user details needed by other services (e.g., tenant service for enriching member data).
"""
try:
# Validate UUID format
try:
uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID format"
)
# Fetch user from database
from app.repositories import UserRepository
user_repo = UserRepository(User, db)
user = await user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found"
)
logger.debug("Retrieved user by ID", user_id=user_id, email=user.email)
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_verified=user.is_verified,
phone=user.phone,
language=user.language or "es",
timezone=user.timezone or "Europe/Madrid",
created_at=user.created_at,
last_login=user.last_login,
role=user.role,
tenant_id=None,
payment_customer_id=user.payment_customer_id,
default_payment_method_id=user.default_payment_method_id
)
except HTTPException:
raise
except Exception as e:
logger.error("Get user by ID error", user_id=user_id, error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user information"
)
@router.put("/api/v1/auth/users/{user_id}", response_model=UserResponse)
async def update_user_profile(
user_id: str = Path(..., description="User ID"),
update_data: UserUpdate = Body(..., description="User profile update data"),
current_user = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Update user profile information.
This endpoint allows users to update their profile information including:
- Full name
- Phone number
- Language preference
- Timezone
**Permissions:** Users can update their own profile, admins can update any user's profile
"""
try:
# Validate UUID format
try:
uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID format"
)
# Check permissions - user can update their own profile, admins can update any
if current_user["user_id"] != user_id:
# Check if current user has admin privileges
user_role = current_user.get("role", "user")
if user_role not in ["admin", "super_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions to update this user's profile"
)
# Fetch user from database
from app.repositories import UserRepository
user_repo = UserRepository(User, db)
user = await user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found"
)
# Prepare update data (only include fields that are provided)
update_fields = update_data.dict(exclude_unset=True)
if not update_fields:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No update data provided"
)
# Update user
updated_user = await user_repo.update(user_id, update_fields)
logger.info("User profile updated", user_id=user_id, updated_fields=list(update_fields.keys()))
# Log audit event for user profile update
try:
# Get tenant_id from current_user or use a placeholder for system-level operations
tenant_id_str = current_user.get("tenant_id", "00000000-0000-0000-0000-000000000000")
await audit_logger.log_event(
db_session=db,
tenant_id=tenant_id_str,
user_id=current_user["user_id"],
action=AuditAction.UPDATE.value,
resource_type="user",
resource_id=user_id,
severity=AuditSeverity.MEDIUM.value,
description=f"User {current_user.get('email', current_user['user_id'])} updated profile for user {user.email}",
changes={"updated_fields": list(update_fields.keys())},
audit_metadata={"updated_data": update_fields},
endpoint="/users/{user_id}",
method="PUT"
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
return UserResponse(
id=str(updated_user.id),
email=updated_user.email,
full_name=updated_user.full_name,
is_active=updated_user.is_active,
is_verified=updated_user.is_verified,
phone=updated_user.phone,
language=updated_user.language or "es",
timezone=updated_user.timezone or "Europe/Madrid",
created_at=updated_user.created_at,
last_login=updated_user.last_login,
role=updated_user.role,
tenant_id=None,
payment_customer_id=updated_user.payment_customer_id,
default_payment_method_id=updated_user.default_payment_method_id
)
except HTTPException:
raise
except Exception as e:
logger.error("Update user profile error", user_id=user_id, error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user profile"
)
@router.post("/api/v1/auth/users/create-by-owner", response_model=UserResponse)
async def create_user_by_owner(
user_data: OwnerUserCreate,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Create a new user account (owner/admin only - for pilot phase).
This endpoint allows tenant owners to directly create user accounts
with passwords during the pilot phase. In production, this will be
replaced with an invitation-based flow.
**Permissions:** Owner or Admin role required
**Security:** Password is hashed server-side before storage
"""
try:
# Verify caller has admin or owner privileges
# In pilot phase, we allow 'admin' role from auth service
user_role = current_user.get("role", "user")
if user_role not in ["admin", "super_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can create users directly"
)
# Validate email uniqueness
from app.repositories import UserRepository
user_repo = UserRepository(User, db)
existing_user = await user_repo.get_by_email(user_data.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email {user_data.email} already exists"
)
# Hash password
from app.core.security import SecurityManager
hashed_password = SecurityManager.hash_password(user_data.password)
# Create user
create_data = {
"email": user_data.email,
"full_name": user_data.full_name,
"hashed_password": hashed_password,
"phone": user_data.phone,
"role": user_data.role,
"language": user_data.language or "es",
"timezone": user_data.timezone or "Europe/Madrid",
"is_active": True,
"is_verified": False # Can be verified later if needed
}
new_user = await user_repo.create_user(create_data)
logger.info(
"User created by owner",
created_user_id=str(new_user.id),
created_user_email=new_user.email,
created_by=current_user.get("user_id"),
created_by_email=current_user.get("email")
)
# Return user response
return UserResponse(
id=str(new_user.id),
email=new_user.email,
full_name=new_user.full_name,
is_active=new_user.is_active,
is_verified=new_user.is_verified,
phone=new_user.phone,
language=new_user.language,
timezone=new_user.timezone,
created_at=new_user.created_at,
last_login=new_user.last_login,
role=new_user.role,
tenant_id=None # Will be set when added to tenant
)
except HTTPException:
raise
except Exception as e:
logger.error(
"Failed to create user by owner",
email=user_data.email,
error=str(e),
created_by=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user account"
)
@router.post("/api/v1/auth/users/batch", response_model=Dict[str, Any])
async def get_users_batch(
request: BatchUserRequest,
db: AsyncSession = Depends(get_db)
):
"""
Get multiple users by their IDs in a single request.
This endpoint is for internal service-to-service communication.
It efficiently fetches multiple user records needed by other services
(e.g., tenant service for enriching member lists).
Returns a dict mapping user_id -> user data, with null for non-existent users.
"""
try:
# Validate all UUIDs
validated_ids = []
for user_id in request.user_ids:
try:
uuid.UUID(user_id)
validated_ids.append(user_id)
except ValueError:
logger.warning(f"Invalid user ID format in batch request: {user_id}")
continue
if not validated_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No valid user IDs provided"
)
# Fetch users from database
from app.repositories import UserRepository
user_repo = UserRepository(User, db)
# Build response map
user_map = {}
for user_id in validated_ids:
user = await user_repo.get_by_id(user_id)
if user:
user_map[user_id] = {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_verified": user.is_verified,
"phone": user.phone,
"language": user.language or "es",
"timezone": user.timezone or "Europe/Madrid",
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_login": user.last_login.isoformat() if user.last_login else None,
"role": user.role
}
else:
user_map[user_id] = None
logger.debug(
"Batch user fetch completed",
requested_count=len(request.user_ids),
found_count=sum(1 for v in user_map.values() if v is not None)
)
return {
"users": user_map,
"requested_count": len(request.user_ids),
"found_count": sum(1 for v in user_map.values() if v is not None)
}
except HTTPException:
raise
except Exception as e:
logger.error("Batch user fetch error", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch users"
)
@router.get("/api/v1/auth/users/{user_id}/activity")
async def get_user_activity(
user_id: str = Path(..., description="User ID"),
current_user = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get user activity information.
This endpoint returns detailed activity information for a user including:
- Last login timestamp
- Account creation date
- Active session count
- Last activity timestamp
- User status information
**Permissions:** User can view their own activity, admins can view any user's activity
"""
try:
# Validate UUID format
try:
uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID format"
)
# Check permissions - user can view their own activity, admins can view any
if current_user["user_id"] != user_id:
# Check if current user has admin privileges
user_role = current_user.get("role", "user")
if user_role not in ["admin", "super_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions to view this user's activity"
)
# Initialize enhanced user service
from app.core.config import settings
from shared.database.base import create_database_manager
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
user_service = EnhancedUserService(database_manager)
# Get user activity data
activity_data = await user_service.get_user_activity(user_id)
if "error" in activity_data:
if activity_data["error"] == "User not found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get user activity: {activity_data['error']}"
)
logger.debug("Retrieved user activity", user_id=user_id)
return activity_data
except HTTPException:
raise
except Exception as e:
logger.error("Get user activity error", user_id=user_id, error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user activity information"
)
@router.patch("/api/v1/auth/users/{user_id}/tenant")
async def update_user_tenant(
user_id: str = Path(..., description="User ID"),
tenant_data: Dict[str, Any] = Body(..., description="Tenant data containing tenant_id"),
db: AsyncSession = Depends(get_db)
):
"""
Update user's tenant_id after tenant registration
This endpoint is called by the tenant service after a user creates their tenant.
It links the user to their newly created tenant.
"""
try:
# Log the incoming request data for debugging
logger.debug("Received tenant update request",
user_id=user_id,
tenant_data=tenant_data)
tenant_id = tenant_data.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="tenant_id is required"
)
logger.info("Updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
user_service = EnhancedUserService(db)
user = await user_service.get_user_by_id(uuid.UUID(user_id), session=db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# DEPRECATED: User-tenant relationships are now managed by tenant service
# This endpoint is kept for backward compatibility but does nothing
# The tenant service should manage user-tenant relationships internally
logger.warning("DEPRECATED: update_user_tenant endpoint called - user-tenant relationships are now managed by tenant service",
user_id=user_id,
tenant_id=tenant_id)
# Return success for backward compatibility, but don't actually update anything
return {
"success": True,
"user_id": str(user.id),
"tenant_id": tenant_id,
"message": "User-tenant relationships are now managed by tenant service. This endpoint is deprecated."
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update user tenant_id",
user_id=user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user tenant_id"
)