GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
128
apps/control-panel-backend/app/core/api_standards.py
Normal file
128
apps/control-panel-backend/app/core/api_standards.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
GT 2.0 Control Panel Backend - CB-REST API Standards Integration
|
||||
|
||||
This module integrates the CB-REST standards into the Control Panel backend
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the api-standards package to the path
|
||||
api_standards_path = Path(__file__).parent.parent.parent.parent.parent / "packages" / "api-standards" / "src"
|
||||
if api_standards_path.exists():
|
||||
sys.path.insert(0, str(api_standards_path))
|
||||
|
||||
# Import CB-REST standards
|
||||
try:
|
||||
from response import StandardResponse, format_response, format_error
|
||||
from capability import (
|
||||
init_capability_verifier,
|
||||
verify_capability,
|
||||
require_capability,
|
||||
Capability,
|
||||
CapabilityToken
|
||||
)
|
||||
from errors import ErrorCode, APIError, raise_api_error
|
||||
from middleware import (
|
||||
RequestCorrelationMiddleware,
|
||||
CapabilityMiddleware,
|
||||
TenantIsolationMiddleware,
|
||||
RateLimitMiddleware
|
||||
)
|
||||
except ImportError as e:
|
||||
# Fallback for development - create minimal implementations
|
||||
print(f"Warning: Could not import api-standards package: {e}")
|
||||
|
||||
# Create minimal implementations for development
|
||||
class StandardResponse:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
def format_response(data, capability_used, request_id=None):
|
||||
return {
|
||||
"data": data,
|
||||
"error": None,
|
||||
"capability_used": capability_used,
|
||||
"request_id": request_id or "dev-mode"
|
||||
}
|
||||
|
||||
def format_error(code, message, capability_used="none", **kwargs):
|
||||
return {
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
**kwargs
|
||||
},
|
||||
"capability_used": capability_used,
|
||||
"request_id": kwargs.get("request_id", "dev-mode")
|
||||
}
|
||||
|
||||
class ErrorCode:
|
||||
CAPABILITY_INSUFFICIENT = "CAPABILITY_INSUFFICIENT"
|
||||
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
|
||||
INVALID_REQUEST = "INVALID_REQUEST"
|
||||
SYSTEM_ERROR = "SYSTEM_ERROR"
|
||||
|
||||
class APIError(Exception):
|
||||
def __init__(self, code, message, **kwargs):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.kwargs = kwargs
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# Export all CB-REST components
|
||||
__all__ = [
|
||||
'StandardResponse',
|
||||
'format_response',
|
||||
'format_error',
|
||||
'init_capability_verifier',
|
||||
'verify_capability',
|
||||
'require_capability',
|
||||
'Capability',
|
||||
'CapabilityToken',
|
||||
'ErrorCode',
|
||||
'APIError',
|
||||
'raise_api_error',
|
||||
'RequestCorrelationMiddleware',
|
||||
'CapabilityMiddleware',
|
||||
'TenantIsolationMiddleware',
|
||||
'RateLimitMiddleware'
|
||||
]
|
||||
|
||||
|
||||
def setup_api_standards(app, secret_key: str):
|
||||
"""
|
||||
Setup CB-REST API standards for the application
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
secret_key: Secret key for JWT signing
|
||||
"""
|
||||
# Initialize capability verifier
|
||||
if 'init_capability_verifier' in globals():
|
||||
init_capability_verifier(secret_key)
|
||||
|
||||
# Add middleware in correct order
|
||||
if 'RequestCorrelationMiddleware' in globals():
|
||||
app.add_middleware(RequestCorrelationMiddleware)
|
||||
|
||||
if 'RateLimitMiddleware' in globals():
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware,
|
||||
requests_per_minute=100 # Adjust based on your needs
|
||||
)
|
||||
|
||||
if 'TenantIsolationMiddleware' in globals():
|
||||
app.add_middleware(
|
||||
TenantIsolationMiddleware,
|
||||
enforce_isolation=True
|
||||
)
|
||||
|
||||
if 'CapabilityMiddleware' in globals():
|
||||
app.add_middleware(
|
||||
CapabilityMiddleware,
|
||||
exclude_paths=["/health", "/ready", "/metrics", "/docs", "/redoc", "/api/v1/auth/login"]
|
||||
)
|
||||
156
apps/control-panel-backend/app/core/auth.py
Normal file
156
apps/control-panel-backend/app/core/auth.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Authentication and authorization utilities
|
||||
"""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import HTTPException, Security, Depends, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
class JWTHandler:
|
||||
"""JWT token handler"""
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
user_email: str,
|
||||
user_type: str,
|
||||
current_tenant: Optional[dict] = None,
|
||||
available_tenants: Optional[list] = None,
|
||||
capabilities: Optional[list] = None,
|
||||
# For token refresh: preserve original login time and absolute expiry
|
||||
original_iat: Optional[datetime] = None,
|
||||
original_absolute_exp: Optional[float] = None,
|
||||
# Server-side session token (Issue #264)
|
||||
session_token: Optional[str] = None
|
||||
) -> str:
|
||||
"""Create a JWT access token with tenant context
|
||||
|
||||
NIST SP 800-63B AAL2 Compliant Session Management (Issues #242, #264):
|
||||
- exp: 12 hours (matches absolute timeout) - serves as JWT-level backstop
|
||||
- absolute_exp: Absolute timeout (12 hours) - NOT refreshable, forces re-login
|
||||
- iat: Original login time - preserved across token refreshes
|
||||
- session_id: Server-side session token for authoritative validation
|
||||
|
||||
The server-side session (via SessionService) enforces the 30-minute idle timeout
|
||||
by tracking last_activity_at. JWT exp is set to 12 hours so it doesn't block
|
||||
requests before the server-side session validation can check activity-based idle timeout.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Use original iat if refreshing, otherwise current time (new login)
|
||||
iat = original_iat if original_iat else now
|
||||
|
||||
# Calculate absolute expiry: iat + absolute timeout hours (only set on initial login)
|
||||
if original_absolute_exp is not None:
|
||||
absolute_exp = original_absolute_exp
|
||||
else:
|
||||
absolute_exp = (iat + timedelta(hours=settings.JWT_ABSOLUTE_TIMEOUT_HOURS)).timestamp()
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"email": user_email,
|
||||
"user_type": user_type,
|
||||
|
||||
# Current tenant context (most important)
|
||||
"current_tenant": current_tenant or {},
|
||||
|
||||
# Available tenants for switching
|
||||
"available_tenants": available_tenants or [],
|
||||
|
||||
# Base capabilities (rarely used - tenant-specific capabilities are in current_tenant)
|
||||
"capabilities": capabilities or [],
|
||||
|
||||
# NIST/OWASP Session Timeouts (Issues #242, #264)
|
||||
# exp: Idle timeout - 4 hours from now (refreshable)
|
||||
"exp": now + timedelta(minutes=settings.JWT_EXPIRES_MINUTES),
|
||||
# iat: Original login time (preserved across refreshes)
|
||||
"iat": iat,
|
||||
# absolute_exp: Absolute timeout from original login (NOT refreshable)
|
||||
"absolute_exp": absolute_exp,
|
||||
# session_id: Server-side session token for authoritative validation (Issue #264)
|
||||
# The server-side session is the source of truth - JWT expiry is secondary
|
||||
"session_id": session_token
|
||||
}
|
||||
|
||||
# Use HS256 with JWT_SECRET from settings (auto-generated by installer)
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
@staticmethod
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
"""Decode and validate a JWT token"""
|
||||
try:
|
||||
# Use HS256 with JWT_SECRET from settings (auto-generated by installer)
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired"
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""Get the current authenticated user"""
|
||||
|
||||
token = credentials.credentials
|
||||
payload = JWTHandler.decode_token(token)
|
||||
|
||||
user_id = int(payload["sub"])
|
||||
|
||||
# Get user from database
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is inactive"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Require the current user to be a super admin (control panel access)"""
|
||||
if current_user.user_type != "super_admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Super admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_super_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Require the current user to be a super admin"""
|
||||
if current_user.user_type != "super_admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Super admin access required"
|
||||
)
|
||||
return current_user
|
||||
145
apps/control-panel-backend/app/core/config.py
Normal file
145
apps/control-panel-backend/app/core/config.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Configuration settings for GT 2.0 Control Panel Backend
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field, validator
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
|
||||
# Application
|
||||
DEBUG: bool = Field(default=False, env="DEBUG")
|
||||
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
|
||||
SECRET_KEY: str = Field(default="PRODUCTION_SECRET_KEY_REQUIRED", env="SECRET_KEY")
|
||||
ALLOWED_ORIGINS: List[str] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:3001"],
|
||||
env="ALLOWED_ORIGINS"
|
||||
)
|
||||
|
||||
# Database (PostgreSQL direct connection)
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://postgres:gt2_admin_dev_password@postgres:5432/gt2_admin",
|
||||
env="DATABASE_URL"
|
||||
)
|
||||
|
||||
# Redis removed - PostgreSQL handles all session and caching needs
|
||||
|
||||
# MinIO removed - PostgreSQL handles all file storage
|
||||
|
||||
# Kubernetes
|
||||
KUBERNETES_IN_CLUSTER: bool = Field(default=False, env="KUBERNETES_IN_CLUSTER")
|
||||
KUBECONFIG_PATH: Optional[str] = Field(default=None, env="KUBECONFIG_PATH")
|
||||
|
||||
# ChromaDB
|
||||
CHROMADB_HOST: str = Field(default="localhost", env="CHROMADB_HOST")
|
||||
CHROMADB_PORT: int = Field(default=8000, env="CHROMADB_PORT")
|
||||
CHROMADB_AUTH_USER: str = Field(default="admin", env="CHROMADB_AUTH_USER")
|
||||
CHROMADB_AUTH_PASSWORD: str = Field(default="dev_chroma_password", env="CHROMADB_AUTH_PASSWORD")
|
||||
|
||||
# Dremio SQL Federation
|
||||
DREMIO_URL: Optional[str] = Field(default="http://dremio:9047", env="DREMIO_URL")
|
||||
DREMIO_USERNAME: Optional[str] = Field(default="admin", env="DREMIO_USERNAME")
|
||||
DREMIO_PASSWORD: Optional[str] = Field(default="admin123", env="DREMIO_PASSWORD")
|
||||
|
||||
# Service Authentication
|
||||
SERVICE_AUTH_TOKEN: Optional[str] = Field(default="internal-service-token", env="SERVICE_AUTH_TOKEN")
|
||||
|
||||
# JWT - NIST/OWASP Compliant Session Timeouts (Issue #242)
|
||||
JWT_SECRET: str = Field(default="dev-jwt-secret-change-in-production-32-chars-minimum", env="JWT_SECRET")
|
||||
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
|
||||
# JWT expiration: 12 hours (matches absolute timeout) - NIST SP 800-63B AAL2 compliant
|
||||
# Server-side session enforces 30-minute idle timeout via last_activity_at tracking
|
||||
# JWT exp serves as backstop - prevents tokens from being valid beyond absolute limit
|
||||
JWT_EXPIRES_MINUTES: int = Field(default=720, env="JWT_EXPIRES_MINUTES")
|
||||
# Absolute timeout: 12 hours - NIST SP 800-63B AAL2 maximum session duration
|
||||
JWT_ABSOLUTE_TIMEOUT_HOURS: int = Field(default=12, env="JWT_ABSOLUTE_TIMEOUT_HOURS")
|
||||
# Legacy support (deprecated - use JWT_EXPIRES_MINUTES instead)
|
||||
JWT_EXPIRES_HOURS: int = Field(default=4, env="JWT_EXPIRES_HOURS")
|
||||
|
||||
# Aliases for compatibility
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return self.JWT_SECRET
|
||||
|
||||
@property
|
||||
def algorithm(self) -> str:
|
||||
return self.JWT_ALGORITHM
|
||||
|
||||
# Encryption
|
||||
MASTER_ENCRYPTION_KEY: str = Field(
|
||||
default="dev-master-key-change-in-production-must-be-32-bytes-long",
|
||||
env="MASTER_ENCRYPTION_KEY"
|
||||
)
|
||||
|
||||
# Tenant Settings
|
||||
TENANT_DATA_DIR: str = Field(default="/data", env="TENANT_DATA_DIR")
|
||||
DEFAULT_TENANT_TEMPLATE: str = Field(default="basic", env="DEFAULT_TENANT_TEMPLATE")
|
||||
|
||||
# External AI Services
|
||||
GROQ_API_KEY: Optional[str] = Field(default=None, env="GROQ_API_KEY")
|
||||
GROQ_BASE_URL: str = Field(default="https://api.groq.com/openai/v1", env="GROQ_BASE_URL")
|
||||
|
||||
# Resource Cluster
|
||||
RESOURCE_CLUSTER_URL: str = Field(default="http://localhost:8003", env="RESOURCE_CLUSTER_URL")
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
|
||||
# RabbitMQ (for message bus)
|
||||
RABBITMQ_URL: str = Field(
|
||||
default="amqp://admin:dev_rabbitmq_password@localhost:5672/gt2",
|
||||
env="RABBITMQ_URL"
|
||||
)
|
||||
MESSAGE_BUS_SECRET_KEY: str = Field(
|
||||
default="PRODUCTION_MESSAGE_BUS_SECRET_REQUIRED",
|
||||
env="MESSAGE_BUS_SECRET_KEY"
|
||||
)
|
||||
|
||||
# Celery (for background tasks) - Using PostgreSQL instead of Redis
|
||||
CELERY_BROKER_URL: str = Field(
|
||||
default="db+postgresql://gt2_admin:dev_password_change_in_prod@postgres:5432/gt2_control_panel",
|
||||
env="CELERY_BROKER_URL"
|
||||
)
|
||||
CELERY_RESULT_BACKEND: str = Field(
|
||||
default="db+postgresql://gt2_admin:dev_password_change_in_prod@postgres:5432/gt2_control_panel",
|
||||
env="CELERY_RESULT_BACKEND"
|
||||
)
|
||||
|
||||
@validator('ALLOWED_ORIGINS', pre=True)
|
||||
def parse_cors_origins(cls, v):
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(',')]
|
||||
return v
|
||||
|
||||
@validator('MASTER_ENCRYPTION_KEY')
|
||||
def validate_encryption_key_length(cls, v):
|
||||
if len(v) < 32:
|
||||
raise ValueError('Master encryption key must be at least 32 characters long')
|
||||
return v
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get the global settings instance"""
|
||||
return settings
|
||||
|
||||
# Environment-specific configurations
|
||||
if settings.ENVIRONMENT == "production":
|
||||
# Production settings
|
||||
# Validation checks removed for flexibility
|
||||
pass
|
||||
else:
|
||||
# Development/Test settings
|
||||
import logging
|
||||
logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL.upper()))
|
||||
136
apps/control-panel-backend/app/core/database.py
Normal file
136
apps/control-panel-backend/app/core/database.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Database configuration and utilities for GT 2.0 Control Panel
|
||||
"""
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
import structlog
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20
|
||||
)
|
||||
|
||||
# Create sync engine for session management (Issue #264)
|
||||
# Uses psycopg2 instead of asyncpg for sync operations
|
||||
sync_database_url = settings.DATABASE_URL.replace("+asyncpg", "").replace("postgresql://", "postgresql+psycopg2://")
|
||||
if "+psycopg2" not in sync_database_url:
|
||||
sync_database_url = sync_database_url.replace("postgresql://", "postgresql+psycopg2://")
|
||||
|
||||
sync_engine = create_engine(
|
||||
sync_database_url,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10
|
||||
)
|
||||
|
||||
# Create session makers
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
sync_session_maker = sessionmaker(
|
||||
sync_engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models"""
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_session():
|
||||
"""Get database session context manager"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Dependency for getting async database session"""
|
||||
async with get_db_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_sync_db_session():
|
||||
"""Get synchronous database session context manager (for session management)"""
|
||||
session = sync_session_maker()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_sync_db():
|
||||
"""Dependency for getting synchronous database session (for session management)"""
|
||||
with get_sync_db_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables"""
|
||||
try:
|
||||
# Import all models to ensure they're registered
|
||||
from app.models import tenant, user, ai_resource, usage, audit, model_config, tenant_model_config
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# Create all tables
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info("Database tables created successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize database", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
async def check_db_connection():
|
||||
"""Check database connection health"""
|
||||
try:
|
||||
async with get_db_session() as session:
|
||||
await session.execute("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Database connection check failed", error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
def create_database_url(
|
||||
username: str,
|
||||
password: str,
|
||||
host: str,
|
||||
port: int,
|
||||
database: str,
|
||||
driver: str = "postgresql+asyncpg"
|
||||
) -> str:
|
||||
"""Create database URL from components"""
|
||||
return f"{driver}://{username}:{password}@{host}:{port}/{database}"
|
||||
29
apps/control-panel-backend/app/core/email.py
Normal file
29
apps/control-panel-backend/app/core/email.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Email Service for GT 2.0
|
||||
|
||||
SMTP integration using Brevo (formerly Sendinblue) for transactional emails.
|
||||
|
||||
Supported email types:
|
||||
- Budget alert emails (FR #257)
|
||||
"""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional, List
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
def get_smtp_config() -> dict:
|
||||
"""Get SMTP configuration from environment"""
|
||||
return {
|
||||
'host': os.getenv('SMTP_HOST', 'smtp-relay.brevo.com'),
|
||||
'port': int(os.getenv('SMTP_PORT', '587')),
|
||||
'username': os.getenv('SMTP_USERNAME'), # Brevo SMTP username (usually your email)
|
||||
'password': os.getenv('SMTP_PASSWORD'), # Brevo SMTP password (from SMTP settings)
|
||||
'from_email': os.getenv('SMTP_FROM_EMAIL', 'noreply@gt2.com'),
|
||||
'from_name': os.getenv('SMTP_FROM_NAME', 'GT 2.0 Platform'),
|
||||
'use_tls': os.getenv('SMTP_USE_TLS', 'true').lower() == 'true'
|
||||
}
|
||||
189
apps/control-panel-backend/app/core/tfa.py
Normal file
189
apps/control-panel-backend/app/core/tfa.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Two-Factor Authentication utilities for GT 2.0
|
||||
|
||||
Handles TOTP generation, verification, QR code generation, and secret encryption.
|
||||
"""
|
||||
import os
|
||||
import pyotp
|
||||
import qrcode
|
||||
import qrcode.image.pil
|
||||
import io
|
||||
import base64
|
||||
from typing import Optional, Tuple
|
||||
from cryptography.fernet import Fernet
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Get encryption key from environment
|
||||
TFA_ENCRYPTION_KEY = os.getenv("TFA_ENCRYPTION_KEY")
|
||||
TFA_ISSUER_NAME = os.getenv("TFA_ISSUER_NAME", "GT 2.0 Enterprise AI")
|
||||
|
||||
|
||||
class TFAManager:
|
||||
"""Manager for Two-Factor Authentication operations"""
|
||||
|
||||
def __init__(self):
|
||||
if not TFA_ENCRYPTION_KEY:
|
||||
raise ValueError("TFA_ENCRYPTION_KEY environment variable must be set")
|
||||
|
||||
# Initialize Fernet cipher for encryption
|
||||
self.cipher = Fernet(TFA_ENCRYPTION_KEY.encode())
|
||||
|
||||
def generate_secret(self) -> str:
|
||||
"""Generate a new TOTP secret (32-byte base32)"""
|
||||
secret = pyotp.random_base32()
|
||||
logger.info("Generated new TOTP secret")
|
||||
return secret
|
||||
|
||||
def encrypt_secret(self, secret: str) -> str:
|
||||
"""Encrypt TOTP secret using Fernet"""
|
||||
try:
|
||||
encrypted = self.cipher.encrypt(secret.encode())
|
||||
return encrypted.decode()
|
||||
except Exception as e:
|
||||
logger.error("Failed to encrypt TFA secret", error=str(e))
|
||||
raise
|
||||
|
||||
def decrypt_secret(self, encrypted_secret: str) -> str:
|
||||
"""Decrypt TOTP secret using Fernet"""
|
||||
try:
|
||||
decrypted = self.cipher.decrypt(encrypted_secret.encode())
|
||||
return decrypted.decode()
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt TFA secret", error=str(e))
|
||||
raise
|
||||
|
||||
def generate_qr_code_uri(self, secret: str, email: str, tenant_name: str) -> str:
|
||||
"""
|
||||
Generate otpauth:// URI for QR code scanning
|
||||
|
||||
Args:
|
||||
secret: TOTP secret (unencrypted)
|
||||
email: User's email address
|
||||
tenant_name: Tenant name for issuer branding (required, no fallback)
|
||||
|
||||
Returns:
|
||||
otpauth:// URI string
|
||||
"""
|
||||
issuer = f"{tenant_name} - GT AI OS"
|
||||
totp = pyotp.TOTP(secret)
|
||||
uri = totp.provisioning_uri(name=email, issuer_name=issuer)
|
||||
logger.info("Generated QR code URI", email=email, issuer=issuer, tenant_name=tenant_name)
|
||||
return uri
|
||||
|
||||
def generate_qr_code_image(self, uri: str) -> str:
|
||||
"""
|
||||
Generate base64-encoded QR code image from URI
|
||||
|
||||
Args:
|
||||
uri: otpauth:// URI
|
||||
|
||||
Returns:
|
||||
Base64-encoded PNG image data (data:image/png;base64,...)
|
||||
"""
|
||||
try:
|
||||
# Create QR code with PIL image factory
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
image_factory=qrcode.image.pil.PilImage,
|
||||
)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Create image using PIL
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Convert to base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
return f"data:image/png;base64,{img_str}"
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate QR code image", error=str(e))
|
||||
raise
|
||||
|
||||
def verify_totp(self, secret: str, code: str, window: int = 1) -> bool:
|
||||
"""
|
||||
Verify TOTP code with time window tolerance
|
||||
|
||||
Args:
|
||||
secret: TOTP secret (unencrypted)
|
||||
code: 6-digit code from user
|
||||
window: Time window tolerance (±30 seconds per window, default=1)
|
||||
|
||||
Returns:
|
||||
True if code is valid, False otherwise
|
||||
"""
|
||||
try:
|
||||
totp = pyotp.TOTP(secret)
|
||||
is_valid = totp.verify(code, valid_window=window)
|
||||
|
||||
if is_valid:
|
||||
logger.info("TOTP verification successful")
|
||||
else:
|
||||
logger.warning("TOTP verification failed")
|
||||
|
||||
return is_valid
|
||||
except Exception as e:
|
||||
logger.error("TOTP verification error", error=str(e))
|
||||
return False
|
||||
|
||||
def get_current_code(self, secret: str) -> str:
|
||||
"""
|
||||
Get current TOTP code (for testing/debugging only)
|
||||
|
||||
Args:
|
||||
secret: TOTP secret (unencrypted)
|
||||
|
||||
Returns:
|
||||
Current 6-digit TOTP code
|
||||
"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.now()
|
||||
|
||||
def setup_new_tfa(self, email: str, tenant_name: str) -> Tuple[str, str, str]:
|
||||
"""
|
||||
Complete setup for new TFA: generate secret, encrypt, create QR code
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
tenant_name: Tenant name for QR code issuer (required, no fallback)
|
||||
|
||||
Returns:
|
||||
Tuple of (encrypted_secret, qr_code_image, manual_entry_key)
|
||||
"""
|
||||
# Generate secret
|
||||
secret = self.generate_secret()
|
||||
|
||||
# Encrypt for storage
|
||||
encrypted_secret = self.encrypt_secret(secret)
|
||||
|
||||
# Generate QR code URI with tenant branding
|
||||
qr_code_uri = self.generate_qr_code_uri(secret, email, tenant_name)
|
||||
|
||||
# Generate QR code image (base64-encoded PNG for display in <img> tag)
|
||||
qr_code_image = self.generate_qr_code_image(qr_code_uri)
|
||||
|
||||
# Manual entry key (formatted for easier typing)
|
||||
manual_entry_key = ' '.join([secret[i:i+4] for i in range(0, len(secret), 4)])
|
||||
|
||||
logger.info("TFA setup completed", email=email, tenant_name=tenant_name)
|
||||
|
||||
return encrypted_secret, qr_code_image, manual_entry_key
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_tfa_manager: Optional[TFAManager] = None
|
||||
|
||||
|
||||
def get_tfa_manager() -> TFAManager:
|
||||
"""Get singleton TFAManager instance"""
|
||||
global _tfa_manager
|
||||
if _tfa_manager is None:
|
||||
_tfa_manager = TFAManager()
|
||||
return _tfa_manager
|
||||
Reference in New Issue
Block a user