Initial commit

This commit is contained in:
2026-01-12 08:17:17 -07:00
commit de5b6feef4
104 changed files with 12925 additions and 0 deletions

142
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,142 @@
"""
Core Configuration - ServiceManagerWeb
Configuración centralizada usando Pydantic Settings v2
"""
from functools import lru_cache
from typing import List, Optional
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
import os
class Settings(BaseSettings):
"""Configuración de la aplicación."""
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False
}
# ===================================
# GENERAL
# ===================================
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
DEBUG: bool = Field(default=False, env="DEBUG")
SECRET_KEY: str = Field(..., env="SECRET_KEY")
API_VERSION: str = Field(default="v1", env="API_VERSION")
# ===================================
# DATABASE
# ===================================
DATABASE_URL: str = Field(..., env="DATABASE_URL")
# ===================================
# REDIS
# ===================================
REDIS_URL: str = Field(..., env="REDIS_URL")
# ===================================
# JWT AUTHENTICATION
# ===================================
JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY")
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES")
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS")
# ===================================
# CORS
# ===================================
CORS_ORIGINS: str = Field(
default="http://localhost:3000,http://localhost:3001",
env="CORS_ORIGINS"
)
# ===================================
# EMAIL
# ===================================
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST")
SMTP_PORT: int = Field(default=587, env="SMTP_PORT")
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER")
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD")
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS")
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL")
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL")
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME")
# ===================================
# FILE UPLOADS
# ===================================
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB")
ALLOWED_FILE_EXTENSIONS: List[str] = Field(
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"],
env="ALLOWED_FILE_EXTENSIONS"
)
UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH")
@field_validator("ALLOWED_FILE_EXTENSIONS", mode='before')
@classmethod
def validate_file_extensions(cls, v):
if isinstance(v, str):
return [ext.strip().lower() for ext in v.split(",")]
return [ext.lower() for ext in v]
# ===================================
# SECURITY
# ===================================
RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED")
PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH")
# Argon2 settings
ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST")
ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST")
ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM")
# ===================================
# LOGGING
# ===================================
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT")
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE")
# ===================================
# FRONTEND URLS
# ===================================
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL")
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL")
# ===================================
# HEALTH CHECKS
# ===================================
HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT")
# ===================================
# CELERY
# ===================================
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
def is_production(self) -> bool:
"""Check if environment is production."""
return self.ENVIRONMENT.lower() == "production"
def is_development(self) -> bool:
"""Check if environment is development."""
return self.ENVIRONMENT.lower() == "development"
def is_testing(self) -> bool:
"""Check if environment is testing."""
return self.ENVIRONMENT.lower() == "testing"
@lru_cache()
def get_settings() -> Settings:
"""
Get cached settings instance.
Using lru_cache to create a singleton pattern for settings.
"""
return Settings()

View File

@@ -0,0 +1,94 @@
"""
Database Configuration - ServiceManagerWeb
SQLAlchemy 2.0 async setup con PostgreSQL
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, DateTime, func
from typing import AsyncGenerator
import uuid
from datetime import datetime
from app.core.config import get_settings
settings = get_settings()
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=5,
max_overflow=10,
pool_pre_ping=True, # Verify connections before use
pool_recycle=3600, # Recycle connections after 1 hour
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
)
class Base(DeclarativeBase):
"""Base class para todos los modelos SQLAlchemy."""
# Columnas comunes para auditoría
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now()
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency para obtener sesión de base de datos.
Yields:
AsyncSession: Sesión de base de datos
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def create_tables():
"""Crear todas las tablas en desarrollo."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_tables():
"""Eliminar todas las tablas (solo para testing)."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
# Health check function
async def check_database_health() -> bool:
"""
Verificar conectividad con la base de datos.
Returns:
bool: True si la conexión es exitosa
"""
try:
async with AsyncSessionLocal() as session:
await session.execute("SELECT 1")
return True
except Exception:
return False

145
backend/app/core/logging.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Structured Logging Configuration - ServiceManagerWeb
Configuración de logging estructurado con structlog
"""
import logging
import logging.config
import sys
from typing import Any, Dict
import structlog
from app.core.config import get_settings
settings = get_settings()
def add_correlation_id(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Agregar correlation ID a los logs si está disponible."""
# En un contexto de request real, esto vendría del middleware
# Por ahora es un placeholder
return event_dict
def configure_structlog():
"""Configurar structlog para logging estructurado."""
processors = [
# Add the log level and a timestamp to the event_dict
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
add_correlation_id,
]
if settings.LOG_FORMAT == "json":
processors.append(structlog.processors.JSONRenderer())
else:
processors.append(structlog.dev.ConsoleRenderer())
structlog.configure(
processors=processors,
wrapper_class=structlog.stdlib.BoundLogger,
logger_factory=structlog.stdlib.LoggerFactory(),
context_class=dict,
cache_logger_on_first_use=True,
)
def setup_logging():
"""Configurar el sistema de logging completo."""
# Configure structlog
configure_structlog()
# Configure standard library logging
logging_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.processors.JSONRenderer(),
},
"console": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.dev.ConsoleRenderer(colors=True),
},
},
"handlers": {
"console": {
"level": settings.LOG_LEVEL,
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "json" if settings.LOG_FORMAT == "json" else "console",
},
},
"loggers": {
"": { # root logger
"handlers": ["console"],
"level": settings.LOG_LEVEL,
"propagate": False,
},
"uvicorn": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"sqlalchemy": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
"celery": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
# Add file handler if specified
if settings.LOG_FILE:
logging_config["handlers"]["file"] = {
"level": settings.LOG_LEVEL,
"class": "logging.handlers.RotatingFileHandler",
"filename": settings.LOG_FILE,
"maxBytes": 10 * 1024 * 1024, # 10MB
"backupCount": 5,
"formatter": "json",
}
# Add file handler to all loggers
for logger_config in logging_config["loggers"].values():
logger_config["handlers"].append("file")
logging.config.dictConfig(logging_config)
# Convenience function to get logger
def get_logger(name: str = None) -> structlog.BoundLogger:
"""
Get a configured structlog logger.
Args:
name: Logger name (optional)
Returns:
Configured structlog logger
"""
return structlog.get_logger(name)

View File

@@ -0,0 +1,271 @@
"""
Security Utilities - ServiceManagerWeb
Funciones de seguridad para autenticación y autorización
"""
from datetime import datetime, timedelta
from typing import Optional, Union, Dict, Any
from passlib.context import CryptContext
from passlib.handlers.argon2 import argon2
from jose import JWTError, jwt
import pyotp
import secrets
import base64
import struct
from app.core.config import get_settings
settings = get_settings()
# Password hashing context
pwd_context = CryptContext(
schemes=["argon2"],
deprecated="auto",
argon2__time_cost=settings.ARGON2_TIME_COST,
argon2__memory_cost=settings.ARGON2_MEMORY_COST,
argon2__parallelism=settings.ARGON2_PARALLELISM,
)
class SecurityUtils:
"""Utilidades de seguridad centralizadas."""
@staticmethod
def hash_password(password: str) -> str:
"""
Hash a password using Argon2.
Args:
password: Plain text password
Returns:
Hashed password
"""
return pwd_context.hash(password)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash.
Args:
plain_password: Plain text password
hashed_password: Hashed password
Returns:
True if password matches
"""
return pwd_context.verify(plain_password, hashed_password)
@staticmethod
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
data: Token payload
expires_delta: Token expiration time
Returns:
JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
@staticmethod
def create_refresh_token(data: Dict[str, Any]) -> str:
"""
Create a JWT refresh token.
Args:
data: Token payload
Returns:
JWT refresh token string
"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
@staticmethod
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""
Verify and decode a JWT token.
Args:
token: JWT token string
Returns:
Token payload if valid, None otherwise
"""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
return payload
except JWTError:
return None
@staticmethod
def generate_totp_secret() -> str:
"""
Generate a base32-encoded secret for TOTP.
Returns:
Base32 encoded secret
"""
return pyotp.random_base32()
@staticmethod
def generate_totp_uri(secret: str, email: str, issuer_name: str = "ServiceManager") -> str:
"""
Generate TOTP URI for QR code.
Args:
secret: Base32 encoded secret
email: User email
issuer_name: Application name
Returns:
TOTP URI
"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(
name=email,
issuer_name=issuer_name
)
@staticmethod
def verify_totp(secret: str, token: str, window: int = 1) -> bool:
"""
Verify a TOTP token.
Args:
secret: Base32 encoded secret
token: TOTP token
window: Time window tolerance
Returns:
True if token is valid
"""
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=window)
@staticmethod
def generate_backup_codes(count: int = 8) -> list[str]:
"""
Generate backup codes for 2FA.
Args:
count: Number of codes to generate
Returns:
List of backup codes
"""
codes = []
for _ in range(count):
code = secrets.token_hex(4).upper()
# Format as XXXX-XXXX
formatted_code = f"{code[:4]}-{code[4:]}"
codes.append(formatted_code)
return codes
@staticmethod
def hash_token(token: str) -> str:
"""
Hash a token for secure storage.
Args:
token: Token to hash
Returns:
Hashed token
"""
return pwd_context.hash(token)
@staticmethod
def verify_hashed_token(token: str, hashed_token: str) -> bool:
"""
Verify a token against its hash.
Args:
token: Plain token
hashed_token: Hashed token
Returns:
True if token matches
"""
return pwd_context.verify(token, hashed_token)
@staticmethod
def generate_secure_token(length: int = 32) -> str:
"""
Generate a cryptographically secure random token.
Args:
length: Token length in bytes
Returns:
URL-safe base64 encoded token
"""
token = secrets.token_bytes(length)
return base64.urlsafe_b64encode(token).decode('utf-8').rstrip('=')
@staticmethod
def is_strong_password(password: str) -> tuple[bool, list[str]]:
"""
Check if password meets security requirements.
Args:
password: Password to check
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
if len(password) < settings.PASSWORD_MIN_LENGTH:
issues.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long")
if not any(c.islower() for c in password):
issues.append("Password must contain at least one lowercase letter")
if not any(c.isupper() for c in password):
issues.append("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in password):
issues.append("Password must contain at least one digit")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
issues.append("Password must contain at least one special character")
return len(issues) == 0, issues
# Create singleton instance
security = SecurityUtils()