JSON WEBTOKENS!!

This commit is contained in:
2026-04-26 16:24:29 -06:00
parent 30efa0e098
commit 0e85231bae
12 changed files with 628 additions and 13 deletions

View File

@@ -2,6 +2,7 @@ from domain.users import User
from application.ports.user_repository import UserRepository
from infrastructure.adapters.rabbitmq.sender import send_to_queue
from infrastructure.adapters.rabbitmq.messages import UserMessage, UserEventType
from infrastructure.api.users.auth_service import auth_service
from datetime import datetime
from typing import List, Optional, Dict, Any
import re
@@ -14,7 +15,8 @@ class CreateUser:
self.repo = repo
def execute(self, nombre: str, apellido: str, email: str,
fecha_nacimiento: datetime, url_foto_perfil: Optional[str] = None,
fecha_nacimiento: datetime, contraseña: Optional[str] = None,
url_foto_perfil: Optional[str] = None,
biografia: Optional[str] = None) -> Dict[str, Any]:
"""
Sends a create user message to RabbitMQ.
@@ -25,6 +27,15 @@ class CreateUser:
- Email no duplicado
- Campos no vacíos
Args:
nombre: Nombre del usuario
apellido: Apellido del usuario
email: Email del usuario
fecha_nacimiento: Fecha de nacimiento
contraseña: Contraseña (opcional, se usa 'passwd123' por defecto)
url_foto_perfil: URL de la foto de perfil
biografia: Biografía del usuario
Returns:
Dictionary with status and message
"""
@@ -69,12 +80,19 @@ class CreateUser:
fecha_creacion = datetime.now()
# Hash de contraseña: usar la proporcionada o la por defecto
if contraseña:
contraseña_hash = auth_service.hash_password(contraseña)
else:
contraseña_hash = auth_service.get_default_password_hash()
# Create message object
message = UserMessage(
event_type=UserEventType.CREATE,
nombre=nombre.strip(),
apellido=apellido.strip(),
email=email.strip(),
contraseña_hash=contraseña_hash,
fecha_nacimiento=fecha_nacimiento.isoformat(),
fecha_creacion=fecha_creacion.isoformat(),
calificacion=50.0,

View File

@@ -69,6 +69,7 @@ class UserConsumer:
nombre=message.nombre,
apellido=message.apellido,
email=message.email,
contraseña_hash=message.contraseña_hash,
fecha_nacimiento=fecha_nacimiento,
fecha_creacion=fecha_creacion,
calificacion=message.calificacion,

View File

@@ -26,6 +26,20 @@ class Settings(BaseSettings):
)
# JWT Configuration
jwt_secret_key: str = Field(
default=os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production"),
description="Clave secreta para firmar JWT tokens"
)
jwt_algorithm: str = Field(
default="HS256",
description="Algoritmo para firmar JWT tokens"
)
jwt_expiration_hours: int = Field(
default=int(os.getenv("JWT_EXPIRATION_HOURS", "24")),
description="Horas de expiración del JWT token"
)
# API
api_title: str = "VoxPopuli Microservices"
api_version: str = "1.0.0"
@@ -45,7 +59,7 @@ class Settings(BaseSettings):
description="Directorio para imágenes de reportes (dentro de storage_base_path)"
)
images_max_size_mb: int = Field(
default=int(os.getenv("IMAGES_MAX_SIZE_MB", 4)),
default=int(os.getenv("IMAGES_MAX_SIZE_MB", "4")),
description="Tamaño máximo de imagen en MB"
)
images_allowed_types: list = Field(
@@ -53,7 +67,7 @@ class Settings(BaseSettings):
description="Tipos MIME permitidos para imágenes"
)
images_compression_quality: int = Field(
default=int(os.getenv("IMAGES_COMPRESSION_QUALITY", 80)),
default=int(os.getenv("IMAGES_COMPRESSION_QUALITY", "80")),
description="Calidad de compresión WebP (0-100)"
)

View File

@@ -9,9 +9,10 @@ class User:
nombre: str
apellido: str
email: str
fecha_nacimiento: datetime
fecha_creacion: datetime
calificacion: float # 0-100
numero_reportes: int
url_foto_perfil: Optional[str]
biografia: Optional[str]
contraseña_hash: Optional[str] = None # Hash bcrypt de la contraseña
fecha_nacimiento: datetime = None
fecha_creacion: datetime = None
calificacion: float = 50.0 # 0-100
numero_reportes: int = 0
url_foto_perfil: Optional[str] = None
biografia: Optional[str] = None

View File

@@ -10,6 +10,7 @@ class UserModel(Base):
nombre = Column(String(100), nullable=False, index=True)
apellido = Column(String(100), nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
contraseña_hash = Column(String(255), nullable=False, default="$2b$12$R9h7cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ee3GzxQ2n8/N7kDi") # Hash de "passwd123"
fecha_nacimiento = Column(DateTime, nullable=False)
fecha_creacion = Column(DateTime, default=datetime.utcnow, nullable=False)
calificacion = Column(Float, default=50.0, nullable=False) # 0-100

View File

@@ -20,6 +20,7 @@ class UserRepositorySQL(UserRepository):
nombre=user.nombre,
apellido=user.apellido,
email=user.email,
contraseña_hash=user.contraseña_hash,
fecha_nacimiento=user.fecha_nacimiento,
fecha_creacion=user.fecha_creacion,
calificacion=user.calificacion,
@@ -59,6 +60,15 @@ class UserRepositorySQL(UserRepository):
logger.error(f"Error al buscar usuario por email {email}: {e}", exc_info=True)
raise
def find_by_email_with_password(self, email: str) -> Optional[UserModel]:
"""Obtiene un usuario por email incluyendo el hash de contraseña (para autenticación)"""
try:
db_user = self.db.query(UserModel).filter(UserModel.email == email).first()
return db_user
except Exception as e:
logger.error(f"Error al buscar usuario por email {email}: {e}", exc_info=True)
raise
def find_all(self) -> List[User]:
"""Obtiene todos los usuarios"""
try:

View File

@@ -28,6 +28,7 @@ class UserMessage:
nombre: Optional[str] = None
apellido: Optional[str] = None
email: Optional[str] = None
contraseña_hash: Optional[str] = None
fecha_nacimiento: Optional[str] = None # ISO format datetime string
fecha_creacion: Optional[str] = None # ISO format datetime string
calificacion: Optional[float] = None

View File

@@ -0,0 +1,103 @@
import jwt
from datetime import datetime, timedelta
from passlib.context import CryptContext
from typing import Optional, Dict
from core.config import ConfSettings
# Configurar contexto para hashing de contraseñas
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class AuthService:
"""Servicio de autenticación con JWT y hashing de contraseñas"""
def __init__(self):
self.secret_key = ConfSettings.jwt_secret_key
self.algorithm = ConfSettings.jwt_algorithm
self.expiration_hours = ConfSettings.jwt_expiration_hours
def hash_password(self, password: str) -> str:
"""
Hashea una contraseña usando bcrypt
Args:
password: Contraseña en texto plano
Returns:
Hash bcrypt de la contraseña
"""
return pwd_context.hash(password)
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""
Verifica si una contraseña coincide con su hash
Args:
plain_password: Contraseña en texto plano
hashed_password: Hash bcrypt para verificar
Returns:
True si la contraseña es correcta, False en caso contrario
"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(self, user_id: int, email: str) -> str:
"""
Crea un JWT token de acceso
Args:
user_id: ID del usuario
email: Email del usuario
Returns:
Token JWT firmado
"""
payload = {
"user_id": user_id,
"email": email,
"exp": datetime.utcnow() + timedelta(hours=self.expiration_hours),
"iat": datetime.utcnow()
}
token = jwt.encode(
payload,
self.secret_key,
algorithm=self.algorithm
)
return token
def verify_token(self, token: str) -> Optional[Dict]:
"""
Verifica y decodifica un JWT token
Args:
token: Token JWT a verificar
Returns:
Payload decodificado si el token es válido, None en caso contrario
"""
try:
payload = jwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm]
)
return payload
except jwt.ExpiredSignatureError:
return None # Token expirado
except jwt.InvalidTokenError:
return None # Token inválido
def get_default_password_hash(self) -> str:
"""
Retorna el hash de la contraseña por defecto 'passwd123'
Returns:
Hash bcrypt de 'passwd123'
"""
return self.hash_password("passwd123")
# Instancia global
auth_service = AuthService()

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
@@ -7,6 +7,10 @@ class UserCreateRequest(BaseModel):
nombre: str
apellido: str
email: str
contraseña: Optional[str] = Field(
default=None,
description="Contraseña del usuario. Si no se proporciona, se usa 'passwd123' por defecto"
)
fecha_nacimiento: datetime
url_foto_perfil: Optional[str] = None
biografia: Optional[str] = None
@@ -18,6 +22,18 @@ class UserUpdateRequest(BaseModel):
url_foto_perfil: Optional[str] = None
biografia: Optional[str] = None
class UserLoginRequest(BaseModel):
"""Solicitud para login de usuario"""
email: str = Field(..., description="Email del usuario")
contraseña: str = Field(..., description="Contraseña del usuario")
class UserLoginResponse(BaseModel):
"""Respuesta de login con token JWT"""
access_token: str = Field(..., description="Token JWT de acceso")
token_type: str = Field(default="bearer", description="Tipo de token")
user_id: int = Field(..., description="ID del usuario")
email: str = Field(..., description="Email del usuario")
class UserResponse(BaseModel):
"""Respuesta con datos de usuario"""
user_id: int
@@ -33,3 +49,4 @@ class UserResponse(BaseModel):
class Config:
from_attributes = True

View File

@@ -1,24 +1,99 @@
from fastapi import APIRouter, HTTPException, status
from infrastructure.api.users.schemas import UserCreateRequest, UserUpdateRequest, UserResponse
from fastapi import APIRouter, HTTPException, status, Depends
from infrastructure.api.users.schemas import (
UserCreateRequest, UserUpdateRequest, UserResponse,
UserLoginRequest, UserLoginResponse
)
from application.services.user_services import (
CreateUser, GetUserById, GetUserByEmail, ListAllUsers, UpdateUser, DeleteUser
)
from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL
from infrastructure.api.users.auth_service import auth_service
import logging
router = APIRouter()
user_repo = UserRepositorySQL()
logger = logging.getLogger(__name__)
@router.post("/login", response_model=UserLoginResponse, status_code=status.HTTP_200_OK)
async def login_user(credentials: UserLoginRequest):
"""
Autentica un usuario y retorna un token JWT
**Parámetros:**
- email: Email del usuario
- contraseña: Contraseña del usuario
**Retorna:**
- access_token: Token JWT para usar en requests autenticados
- token_type: Tipo de token (bearer)
- user_id: ID del usuario
- email: Email confirmado
"""
try:
# Obtener usuario por email
get_use_case = GetUserByEmail(user_repo)
user = get_use_case.execute(credentials.email)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o contraseña incorrectos",
headers={"WWW-Authenticate": "Bearer"}
)
# Verificar contraseña
# Necesitamos obtener el hash de contraseña del modelo
user_model = user_repo.find_by_email_with_password(credentials.email)
if not user_model or not auth_service.verify_password(credentials.contraseña, user_model.contraseña_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o contraseña incorrectos",
headers={"WWW-Authenticate": "Bearer"}
)
# Crear token JWT
access_token = auth_service.create_access_token(
user_id=user.user_id,
email=user.email
)
return {
"access_token": access_token,
"token_type": "bearer",
"user_id": user.user_id,
"email": user.email
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error en login_user: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error interno del servidor"
)
@router.post("/", status_code=status.HTTP_202_ACCEPTED)
async def create_user(user_data: UserCreateRequest):
"""Crea un nuevo usuario - envía a cola de procesamiento con validaciones previas"""
"""
Crea un nuevo usuario - envía a cola de procesamiento con validaciones previas
**Parámetros:**
- nombre: Nombre del usuario (requerido)
- apellido: Apellido del usuario (requerido)
- email: Email del usuario (requerido)
- contraseña: Contraseña (opcional, por defecto "passwd123")
- fecha_nacimiento: Fecha de nacimiento (requerido)
- url_foto_perfil: URL de la foto de perfil (opcional)
- biografia: Biografía del usuario (opcional)
"""
try:
create_use_case = CreateUser(user_repo)
result = create_use_case.execute(
nombre=user_data.nombre,
apellido=user_data.apellido,
email=user_data.email,
contraseña=user_data.contraseña,
fecha_nacimiento=user_data.fecha_nacimiento,
url_foto_perfil=user_data.url_foto_perfil,
biografia=user_data.biografia