Initial commit - production deployment
This commit is contained in:
3
services/auth/app/api/__init__.py
Normal file
3
services/auth/app/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .internal_demo import router as internal_demo_router
|
||||
|
||||
__all__ = ["internal_demo_router"]
|
||||
214
services/auth/app/api/account_deletion.py
Normal file
214
services/auth/app/api/account_deletion.py
Normal 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"
|
||||
)
|
||||
657
services/auth/app/api/auth_operations.py
Normal file
657
services/auth/app/api/auth_operations.py
Normal 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
|
||||
|
||||
|
||||
372
services/auth/app/api/consent.py
Normal file
372
services/auth/app/api/consent.py
Normal 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"
|
||||
)
|
||||
121
services/auth/app/api/data_export.py
Normal file
121
services/auth/app/api/data_export.py
Normal 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"
|
||||
)
|
||||
229
services/auth/app/api/internal_demo.py
Normal file
229
services/auth/app/api/internal_demo.py
Normal 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"
|
||||
}
|
||||
1153
services/auth/app/api/onboarding_progress.py
Normal file
1153
services/auth/app/api/onboarding_progress.py
Normal file
File diff suppressed because it is too large
Load Diff
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"
|
||||
)
|
||||
662
services/auth/app/api/users.py
Normal file
662
services/auth/app/api/users.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user