JSON WEBTOKENS!!
This commit is contained in:
371
JWT_AUTHENTICATION.md
Normal file
371
JWT_AUTHENTICATION.md
Normal file
@@ -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
|
||||||
@@ -8,3 +8,6 @@ pymongo
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
pika
|
pika
|
||||||
Pillow
|
Pillow
|
||||||
|
PyJWT
|
||||||
|
passlib[bcrypt]
|
||||||
|
python-multipart
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from domain.users import User
|
|||||||
from application.ports.user_repository import UserRepository
|
from application.ports.user_repository import UserRepository
|
||||||
from infrastructure.adapters.rabbitmq.sender import send_to_queue
|
from infrastructure.adapters.rabbitmq.sender import send_to_queue
|
||||||
from infrastructure.adapters.rabbitmq.messages import UserMessage, UserEventType
|
from infrastructure.adapters.rabbitmq.messages import UserMessage, UserEventType
|
||||||
|
from infrastructure.api.users.auth_service import auth_service
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
import re
|
import re
|
||||||
@@ -14,7 +15,8 @@ class CreateUser:
|
|||||||
self.repo = repo
|
self.repo = repo
|
||||||
|
|
||||||
def execute(self, nombre: str, apellido: str, email: str,
|
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]:
|
biografia: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Sends a create user message to RabbitMQ.
|
Sends a create user message to RabbitMQ.
|
||||||
@@ -25,6 +27,15 @@ class CreateUser:
|
|||||||
- Email no duplicado
|
- Email no duplicado
|
||||||
- Campos no vacíos
|
- 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:
|
Returns:
|
||||||
Dictionary with status and message
|
Dictionary with status and message
|
||||||
"""
|
"""
|
||||||
@@ -69,12 +80,19 @@ class CreateUser:
|
|||||||
|
|
||||||
fecha_creacion = datetime.now()
|
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
|
# Create message object
|
||||||
message = UserMessage(
|
message = UserMessage(
|
||||||
event_type=UserEventType.CREATE,
|
event_type=UserEventType.CREATE,
|
||||||
nombre=nombre.strip(),
|
nombre=nombre.strip(),
|
||||||
apellido=apellido.strip(),
|
apellido=apellido.strip(),
|
||||||
email=email.strip(),
|
email=email.strip(),
|
||||||
|
contraseña_hash=contraseña_hash,
|
||||||
fecha_nacimiento=fecha_nacimiento.isoformat(),
|
fecha_nacimiento=fecha_nacimiento.isoformat(),
|
||||||
fecha_creacion=fecha_creacion.isoformat(),
|
fecha_creacion=fecha_creacion.isoformat(),
|
||||||
calificacion=50.0,
|
calificacion=50.0,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class UserConsumer:
|
|||||||
nombre=message.nombre,
|
nombre=message.nombre,
|
||||||
apellido=message.apellido,
|
apellido=message.apellido,
|
||||||
email=message.email,
|
email=message.email,
|
||||||
|
contraseña_hash=message.contraseña_hash,
|
||||||
fecha_nacimiento=fecha_nacimiento,
|
fecha_nacimiento=fecha_nacimiento,
|
||||||
fecha_creacion=fecha_creacion,
|
fecha_creacion=fecha_creacion,
|
||||||
calificacion=message.calificacion,
|
calificacion=message.calificacion,
|
||||||
|
|||||||
@@ -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
|
||||||
api_title: str = "VoxPopuli Microservices"
|
api_title: str = "VoxPopuli Microservices"
|
||||||
api_version: str = "1.0.0"
|
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)"
|
description="Directorio para imágenes de reportes (dentro de storage_base_path)"
|
||||||
)
|
)
|
||||||
images_max_size_mb: int = Field(
|
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"
|
description="Tamaño máximo de imagen en MB"
|
||||||
)
|
)
|
||||||
images_allowed_types: list = Field(
|
images_allowed_types: list = Field(
|
||||||
@@ -53,7 +67,7 @@ class Settings(BaseSettings):
|
|||||||
description="Tipos MIME permitidos para imágenes"
|
description="Tipos MIME permitidos para imágenes"
|
||||||
)
|
)
|
||||||
images_compression_quality: int = Field(
|
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)"
|
description="Calidad de compresión WebP (0-100)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ class User:
|
|||||||
nombre: str
|
nombre: str
|
||||||
apellido: str
|
apellido: str
|
||||||
email: str
|
email: str
|
||||||
fecha_nacimiento: datetime
|
contraseña_hash: Optional[str] = None # Hash bcrypt de la contraseña
|
||||||
fecha_creacion: datetime
|
fecha_nacimiento: datetime = None
|
||||||
calificacion: float # 0-100
|
fecha_creacion: datetime = None
|
||||||
numero_reportes: int
|
calificacion: float = 50.0 # 0-100
|
||||||
url_foto_perfil: Optional[str]
|
numero_reportes: int = 0
|
||||||
biografia: Optional[str]
|
url_foto_perfil: Optional[str] = None
|
||||||
|
biografia: Optional[str] = None
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class UserModel(Base):
|
|||||||
nombre = Column(String(100), nullable=False, index=True)
|
nombre = Column(String(100), nullable=False, index=True)
|
||||||
apellido = 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)
|
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_nacimiento = Column(DateTime, nullable=False)
|
||||||
fecha_creacion = Column(DateTime, default=datetime.utcnow, nullable=False)
|
fecha_creacion = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
calificacion = Column(Float, default=50.0, nullable=False) # 0-100
|
calificacion = Column(Float, default=50.0, nullable=False) # 0-100
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class UserRepositorySQL(UserRepository):
|
|||||||
nombre=user.nombre,
|
nombre=user.nombre,
|
||||||
apellido=user.apellido,
|
apellido=user.apellido,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
|
contraseña_hash=user.contraseña_hash,
|
||||||
fecha_nacimiento=user.fecha_nacimiento,
|
fecha_nacimiento=user.fecha_nacimiento,
|
||||||
fecha_creacion=user.fecha_creacion,
|
fecha_creacion=user.fecha_creacion,
|
||||||
calificacion=user.calificacion,
|
calificacion=user.calificacion,
|
||||||
@@ -59,6 +60,15 @@ class UserRepositorySQL(UserRepository):
|
|||||||
logger.error(f"Error al buscar usuario por email {email}: {e}", exc_info=True)
|
logger.error(f"Error al buscar usuario por email {email}: {e}", exc_info=True)
|
||||||
raise
|
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]:
|
def find_all(self) -> List[User]:
|
||||||
"""Obtiene todos los usuarios"""
|
"""Obtiene todos los usuarios"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class UserMessage:
|
|||||||
nombre: Optional[str] = None
|
nombre: Optional[str] = None
|
||||||
apellido: Optional[str] = None
|
apellido: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
contraseña_hash: Optional[str] = None
|
||||||
fecha_nacimiento: Optional[str] = None # ISO format datetime string
|
fecha_nacimiento: Optional[str] = None # ISO format datetime string
|
||||||
fecha_creacion: Optional[str] = None # ISO format datetime string
|
fecha_creacion: Optional[str] = None # ISO format datetime string
|
||||||
calificacion: Optional[float] = None
|
calificacion: Optional[float] = None
|
||||||
|
|||||||
103
src/infrastructure/api/users/auth_service.py
Normal file
103
src/infrastructure/api/users/auth_service.py
Normal 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()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -7,6 +7,10 @@ class UserCreateRequest(BaseModel):
|
|||||||
nombre: str
|
nombre: str
|
||||||
apellido: str
|
apellido: str
|
||||||
email: 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
|
fecha_nacimiento: datetime
|
||||||
url_foto_perfil: Optional[str] = None
|
url_foto_perfil: Optional[str] = None
|
||||||
biografia: Optional[str] = None
|
biografia: Optional[str] = None
|
||||||
@@ -18,6 +22,18 @@ class UserUpdateRequest(BaseModel):
|
|||||||
url_foto_perfil: Optional[str] = None
|
url_foto_perfil: Optional[str] = None
|
||||||
biografia: 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):
|
class UserResponse(BaseModel):
|
||||||
"""Respuesta con datos de usuario"""
|
"""Respuesta con datos de usuario"""
|
||||||
user_id: int
|
user_id: int
|
||||||
@@ -33,3 +49,4 @@ class UserResponse(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,99 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
from infrastructure.api.users.schemas import UserCreateRequest, UserUpdateRequest, UserResponse
|
from infrastructure.api.users.schemas import (
|
||||||
|
UserCreateRequest, UserUpdateRequest, UserResponse,
|
||||||
|
UserLoginRequest, UserLoginResponse
|
||||||
|
)
|
||||||
from application.services.user_services import (
|
from application.services.user_services import (
|
||||||
CreateUser, GetUserById, GetUserByEmail, ListAllUsers, UpdateUser, DeleteUser
|
CreateUser, GetUserById, GetUserByEmail, ListAllUsers, UpdateUser, DeleteUser
|
||||||
)
|
)
|
||||||
from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL
|
from infrastructure.adapters.persistence.user_repository_sql import UserRepositorySQL
|
||||||
|
from infrastructure.api.users.auth_service import auth_service
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
user_repo = UserRepositorySQL()
|
user_repo = UserRepositorySQL()
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@router.post("/", status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def create_user(user_data: UserCreateRequest):
|
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:
|
try:
|
||||||
create_use_case = CreateUser(user_repo)
|
create_use_case = CreateUser(user_repo)
|
||||||
result = create_use_case.execute(
|
result = create_use_case.execute(
|
||||||
nombre=user_data.nombre,
|
nombre=user_data.nombre,
|
||||||
apellido=user_data.apellido,
|
apellido=user_data.apellido,
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
|
contraseña=user_data.contraseña,
|
||||||
fecha_nacimiento=user_data.fecha_nacimiento,
|
fecha_nacimiento=user_data.fecha_nacimiento,
|
||||||
url_foto_perfil=user_data.url_foto_perfil,
|
url_foto_perfil=user_data.url_foto_perfil,
|
||||||
biografia=user_data.biografia
|
biografia=user_data.biografia
|
||||||
|
|||||||
Reference in New Issue
Block a user