diff --git a/JWT_AUTHENTICATION.md b/JWT_AUTHENTICATION.md new file mode 100644 index 0000000..2cbf6bf --- /dev/null +++ b/JWT_AUTHENTICATION.md @@ -0,0 +1,371 @@ +# JWT Authentication for VoxPopuli Users API + +## Overview + +La API de Usuarios de VoxPopuli ahora incluye autenticación basada en **JSON Web Tokens (JWT)**. Esta documentación describe cómo usar e implementar esta característica. + +## Features + +✅ **Autenticación segura con JWT** - Tokens firmados con HS256 +✅ **Hashing de contraseñas** - Usando bcrypt para máxima seguridad +✅ **Contraseña por defecto** - "passwd123" para usuarios sin contraseña especificada +✅ **Expiración configurable** - Tokens con tiempo de vida configurable (default: 24 horas) + +## Configuration + +### Environment Variables + +Agregue las siguientes variables al archivo `.env`: + +```env +# JWT Configuration +JWT_SECRET_KEY=your-super-secret-key-change-in-production +JWT_ALGORITHM=HS256 +JWT_EXPIRATION_HOURS=24 +``` + +**Importante:** En producción, cambiar `JWT_SECRET_KEY` por una clave segura y aleatoria. + +### Installation + +Se han agregado las siguientes dependencias a `requirements.txt`: + +``` +PyJWT # Para crear y verificar JWT tokens +passlib[bcrypt] # Para hashing seguro de contraseñas +python-multipart # Para manejo de formularios en login +``` + +Para instalar las dependencias: + +```bash +pip install -r requirements.txt +``` + +## API Endpoints + +### 1. Crear Usuario (Registro) + +**POST** `/api/v1/users/` + +Crea un nuevo usuario. La contraseña es **opcional**; si no se proporciona, se usa "passwd123" por defecto. + +#### Request Body + +```json +{ + "nombre": "Juan", + "apellido": "Pérez", + "email": "juan@example.com", + "contraseña": "miContraseñaSegura123", // Opcional + "fecha_nacimiento": "1990-01-15T00:00:00", + "url_foto_perfil": "https://example.com/foto.jpg", // Opcional + "biografia": "Soy reportero comunitario" // Opcional +} +``` + +#### Response (202 Accepted) + +```json +{ + "status": "queued", + "message": "Usuario enviado a cola para procesamiento", + "email": "juan@example.com" +} +``` + +#### Casos de Error + +- **400 Bad Request**: Validación fallida (email inválido, campos vacíos) +- **409 Conflict**: Email ya está registrado + +--- + +### 2. Login de Usuario (Autenticación) + +**POST** `/api/v1/users/login` + +Autentica un usuario y retorna un JWT token. + +#### Request Body + +```json +{ + "email": "juan@example.com", + "contraseña": "miContraseñaSegura123" +} +``` + +#### Response (200 OK) + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "user_id": 1, + "email": "juan@example.com" +} +``` + +#### Casos de Error + +- **401 Unauthorized**: Email o contraseña incorrectos + +--- + +## JWT Token Usage + +### Token Structure + +Cada token JWT contiene el siguiente payload: + +```json +{ + "user_id": 1, + "email": "juan@example.com", + "exp": 1704067200, // Unix timestamp de expiración + "iat": 1703980800 // Unix timestamp de creación +} +``` + +### Authorization Header + +Para usar el token en requests a endpoints protegidos: + +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### Example cURL + +```bash +curl -X POST "http://localhost:8000/api/v1/users/login" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "juan@example.com", + "contraseña": "miContraseñaSegura123" + }' + +# Respuesta: +# { +# "access_token": "eyJhbGc...", +# "token_type": "bearer", +# "user_id": 1, +# "email": "juan@example.com" +# } +``` + +--- + +## Password Security + +### Password Hashing + +Las contraseñas se hashean usando **bcrypt** antes de guardarse en la base de datos: + +1. Usuario envía contraseña en texto plano +2. Se hashea con bcrypt (rounds=12 por defecto) +3. Se almacena solo el hash en la base de datos +4. Durante login, se verifica comparando el hash + +### Default Password + +Si un usuario no especifica contraseña durante el registro: + +``` +Contraseña por defecto: "passwd123" +Hash bcrypt: $2b$12$R9h7cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ee3GzxQ2n8/N7kDi +``` + +--- + +## Database Schema + +El modelo de usuario se ha actualizado con: + +```sql +ALTER TABLE usuarios ADD COLUMN contraseña_hash VARCHAR(255) NOT NULL DEFAULT '$2b$12$R9h7cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ee3GzxQ2n8/N7kDi'; +``` + +Campos relevantes: + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| `user_id` | INT | Identificador único (PK) | +| `email` | VARCHAR(255) | Email único | +| `contraseña_hash` | VARCHAR(255) | Hash bcrypt de la contraseña | +| `fecha_creacion` | DATETIME | Fecha de registro | + +--- + +## Services Architecture + +### AuthService (`src/infrastructure/api/users/auth_service.py`) + +Responsable de: + +- **hash_password()** - Hashea contraseñas con bcrypt +- **verify_password()** - Verifica contraseña contra hash +- **create_access_token()** - Genera JWT tokens +- **verify_token()** - Valida y decodifica tokens +- **get_default_password_hash()** - Retorna hash de "passwd123" + +### Example Usage + +```python +from infrastructure.api.users.auth_service import auth_service + +# Hashear contraseña +hashed = auth_service.hash_password("miContraseña") + +# Verificar contraseña +is_valid = auth_service.verify_password("miContraseña", hashed) + +# Crear token +token = auth_service.create_access_token(user_id=1, email="user@example.com") + +# Verificar token +payload = auth_service.verify_token(token) +# payload = {"user_id": 1, "email": "user@example.com", "exp": ..., "iat": ...} +``` + +--- + +## RabbitMQ Message Format + +El mensaje de creación de usuario ahora incluye el hash de contraseña: + +```python +@dataclass +class UserMessage: + event_type: UserEventType + user_id: Optional[int] = None + nombre: Optional[str] = None + apellido: Optional[str] = None + email: Optional[str] = None + contraseña_hash: Optional[str] = None # ✨ Nuevo + fecha_nacimiento: Optional[str] = None + fecha_creacion: Optional[str] = None + # ... campos adicionales +``` + +--- + +## Example Workflow + +### 1. Registrar usuario + +```bash +curl -X POST "http://localhost:8000/api/v1/users/" \ + -H "Content-Type: application/json" \ + -d '{ + "nombre": "Carlos", + "apellido": "López", + "email": "carlos@example.com", + "contraseña": "MiPass@2024", + "fecha_nacimiento": "1995-06-20T00:00:00" + }' +``` + +### 2. Login + +```bash +curl -X POST "http://localhost:8000/api/v1/users/login" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "carlos@example.com", + "contraseña": "MiPass@2024" + }' + +# Response: +# { +# "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", +# "token_type": "bearer", +# "user_id": 1, +# "email": "carlos@example.com" +# } +``` + +### 3. Usar token + +```bash +curl -X GET "http://localhost:8000/api/v1/users/1" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +--- + +## Security Best Practices + +### ✅ En Desarrollo + +- Se proporciona `JWT_SECRET_KEY` por defecto para facilitar testing +- Cambiar en `.env` si es necesario + +### ✅ En Producción + +1. **Cambiar `JWT_SECRET_KEY`** + ```bash + # Generar clave aleatoria + python -c "import secrets; print(secrets.token_urlsafe(32))" + ``` + +2. **Usar HTTPS** + - Los tokens deben viajar solo por HTTPS + +3. **Secrets Management** + - Usar servicios como AWS Secrets Manager, HashiCorp Vault + - No guardar secretos en código + +4. **Token Rotation** + - Considerar refresh tokens con corta expiración + - Implementar revocation list (blacklist) + +5. **CORS Configuration** + - Configurar CORS para permitir solo dominios autorizados + +--- + +## Troubleshooting + +### Error: "Token expired" + +El token tiene más de 24 horas (configurable). Hacer login nuevamente. + +### Error: "Email o contraseña incorrectos" + +Verificar: +- El email existe en la base de datos +- La contraseña es correcta +- El usuario ha sido procesado por el consumer (status "queued" → guardado) + +### Error: "Secret key not found" + +Asegurar que `JWT_SECRET_KEY` está configurado en: +1. `.env` file +2. Variable de entorno del sistema +3. O usando el default en `core/config.py` + +--- + +## Future Enhancements + +- [ ] Refresh tokens +- [ ] Token blacklist para logout +- [ ] Multi-factor authentication (MFA) +- [ ] OAuth2 integration +- [ ] Rate limiting en endpoint de login +- [ ] Account lockout después de N intentos fallidos + +--- + +## References + +- [JWT.io](https://jwt.io) - JWT documentation +- [PyJWT Documentation](https://pyjwt.readthedocs.io/) +- [Passlib Documentation](https://passlib.readthedocs.io/) +- [FastAPI Security](https://fastapi.tiangolo.com/tutorial/security/) + +--- + +**Última actualización:** 26 de abril de 2026 diff --git a/requirements.txt b/requirements.txt index 2ae592f..9cc49ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,6 @@ pymongo python-dotenv pika Pillow +PyJWT +passlib[bcrypt] +python-multipart diff --git a/src/application/services/user_services.py b/src/application/services/user_services.py index 5cf69a8..bdb134c 100644 --- a/src/application/services/user_services.py +++ b/src/application/services/user_services.py @@ -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, diff --git a/src/consumers/user_consumer.py b/src/consumers/user_consumer.py index 198ad23..18e8aa2 100644 --- a/src/consumers/user_consumer.py +++ b/src/consumers/user_consumer.py @@ -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, diff --git a/src/core/config.py b/src/core/config.py index bb1ee3e..2a89b8f 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -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)" ) diff --git a/src/domain/users.py b/src/domain/users.py index b278acb..fee67f0 100644 --- a/src/domain/users.py +++ b/src/domain/users.py @@ -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 diff --git a/src/infrastructure/adapters/persistence/models.py b/src/infrastructure/adapters/persistence/models.py index d957736..5d4244e 100644 --- a/src/infrastructure/adapters/persistence/models.py +++ b/src/infrastructure/adapters/persistence/models.py @@ -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 diff --git a/src/infrastructure/adapters/persistence/user_repository_sql.py b/src/infrastructure/adapters/persistence/user_repository_sql.py index 1a2b5ac..c4630e4 100644 --- a/src/infrastructure/adapters/persistence/user_repository_sql.py +++ b/src/infrastructure/adapters/persistence/user_repository_sql.py @@ -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: diff --git a/src/infrastructure/adapters/rabbitmq/messages.py b/src/infrastructure/adapters/rabbitmq/messages.py index 61ce6ed..18724f4 100644 --- a/src/infrastructure/adapters/rabbitmq/messages.py +++ b/src/infrastructure/adapters/rabbitmq/messages.py @@ -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 diff --git a/src/infrastructure/api/users/auth_service.py b/src/infrastructure/api/users/auth_service.py new file mode 100644 index 0000000..4feccf5 --- /dev/null +++ b/src/infrastructure/api/users/auth_service.py @@ -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() diff --git a/src/infrastructure/api/users/schemas.py b/src/infrastructure/api/users/schemas.py index c76c341..d309412 100644 --- a/src/infrastructure/api/users/schemas.py +++ b/src/infrastructure/api/users/schemas.py @@ -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 + diff --git a/src/infrastructure/api/users/users.py b/src/infrastructure/api/users/users.py index 66c2e42..ed06d14 100644 --- a/src/infrastructure/api/users/users.py +++ b/src/infrastructure/api/users/users.py @@ -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