Initial commit
This commit is contained in:
142
backend/app/core/config.py
Normal file
142
backend/app/core/config.py
Normal 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()
|
||||
94
backend/app/core/database.py
Normal file
94
backend/app/core/database.py
Normal 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
145
backend/app/core/logging.py
Normal 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)
|
||||
271
backend/app/core/security.py
Normal file
271
backend/app/core/security.py
Normal 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()
|
||||
Reference in New Issue
Block a user