Add logoutDevice functionality to manage device-specific logouts
Despliegue Automático / desplegar (push) Successful in 1m12s
Despliegue Automático / desplegar (push) Successful in 1m12s
This commit is contained in:
+139
-283
@@ -1,315 +1,171 @@
|
||||
# API de `api_notas`
|
||||
Aquí tienes la documentación completa generada en formato `README.md` a partir de la arquitectura y el código de tu API.
|
||||
|
||||
Esta guía resume las rutas disponibles, qué datos esperan y los tipos más importantes del proyecto.
|
||||
---
|
||||
|
||||
## Base URL
|
||||
# Documentación de la API: Notas Encriptadas (E2EE)
|
||||
|
||||
Todas las rutas cuelgan de:
|
||||
Esta API proporciona el backend para una aplicación de notas con sincronización **Offline-First** y **Cifrado de Extremo a Extremo (E2EE)**. Está construida sobre Node.js con Express y utiliza PostgreSQL (vía Sequelize) como base de datos.
|
||||
|
||||
```text
|
||||
/api
|
||||
```
|
||||
## 🏗 Arquitectura y Flujo de Datos
|
||||
|
||||
Ejemplo local:
|
||||
El sistema sigue un patrón estructurado en capas para separar responsabilidades:
|
||||
|
||||
```text
|
||||
http://localhost:3000/api
|
||||
```
|
||||
1. **Punto de Entrada e Inicialización:** El archivo `app.js` inicializa Express, habilita CORS y permite payloads JSON de hasta 50MB. El archivo `initDB.js` se encarga de sincronizar los modelos con la base de datos PostgreSQL utilizando Sequelize.
|
||||
|
||||
## Tipos principales
|
||||
|
||||
- `UUID`: identificador único en formato UUID.
|
||||
- `string`: texto plano o cifrado.
|
||||
- `boolean`: `true` o `false`.
|
||||
- `number`: valor numérico.
|
||||
- `ISO date string`: fecha en formato ISO 8601, por ejemplo `2026-05-18T10:00:00.000Z`.
|
||||
2. **Enrutamiento Principal (`index.js`):** Todo el tráfico entra por `/api`. De ahí, el router principal distribuye las peticiones hacia `/auth` (autenticación), `/user` (datos del usuario) y `/` (sincronización).
|
||||
|
||||
## Rutas disponibles
|
||||
|
||||
### `GET /api/status`
|
||||
3. **Controladores (`*Controller.js`):** Son la capa de seguridad y transporte. Validan que las peticiones vengan con un JWT válido en las cabeceras (Access Token), extraen el `userId` y envían los parámetros limpios a los servicios.
|
||||
|
||||
Comprueba que la API está viva.
|
||||
|
||||
Respuesta ejemplo:
|
||||
4. **Servicios (`*Service.js`):** Contienen toda la lógica de negocio profunda. Manejan la creación de tokens, el hasheo de contraseñas, las transacciones SQL y la lógica de resolución de conflictos durante la sincronización.
|
||||
|
||||
|
||||
5. **Modelos (`.js`):** Definen las tablas y relaciones estructurales en la base de datos.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🗄 Modelos de Base de Datos y Relaciones
|
||||
|
||||
El esquema de la base de datos se distribuye en cuatro entidades principales interconectadas:
|
||||
|
||||
* **User (`User.js`):** Almacena el `email` y `password` (hasheado con bcrypt) del usuario. También guarda la `encrypted_master_key`, que es la llave maestra para desencriptar las notas, pero cifrada con la contraseña del usuario para asegurar el E2EE. Un usuario tiene muchas Notas, Categorías y RefreshTokens.
|
||||
|
||||
|
||||
* **Note (`Note.js`):** Guarda los datos cifrados de cada nota (`title`, `body`), su orden (`position`), versión en el servidor (`serverVersion`) y estados de borrado (`delete`, `permanentDelete`).
|
||||
|
||||
|
||||
* **Category (`Category.js`):** Guarda información visual (`colorValue`, `iconCodePoint`) y el nombre cifrado (`encrypted_name`) de las categorías.
|
||||
|
||||
|
||||
* **Relación Nota-Categoría:** Las Notas y las Categorías tienen una relación de *Muchos a Muchos* (M:N) a través de una tabla intermedia llamada `NoteCategories`.
|
||||
|
||||
|
||||
* **RefreshToken (`RefreshToken.js`):** Gestiona las sesiones multidispositivo. Guarda un token largo, seguro y único asociado al `deviceName` del usuario. Si el usuario se elimina, sus tokens se borran en cascada.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Endpoints de la API
|
||||
|
||||
### 1. Autenticación (`/api/auth`)
|
||||
|
||||
Estos endpoints gestionan el registro, inicio de sesión y ciclo de vida de los tokens. El sistema usa un Access Token de corta duración (15 min) y un Refresh Token persistente en base de datos.
|
||||
|
||||
* **Registro de Usuario**
|
||||
* **Ruta:** `POST /api/auth/register`
|
||||
|
||||
* **Body:** `{ "email": "...", "password": "...", "encrypted_master_key": "...", "deviceName": "..." }`
|
||||
|
||||
* **Descripción:** Comprueba que el usuario no exista, hashea su contraseña, crea la cuenta y automáticamente inicia sesión, devolviendo ambos tokens (Access y Refresh) y la llave maestra.
|
||||
|
||||
|
||||
|
||||
|
||||
* **Inicio de Sesión**
|
||||
* **Ruta:** `POST /api/auth/login`
|
||||
|
||||
* **Body:** `{ "email": "...", "password": "...", "deviceName": "..." }`
|
||||
|
||||
* **Descripción:** Verifica credenciales y crea una nueva sesión para el dispositivo (registrando un nuevo Refresh Token en DB). Retorna los tokens y la `encrypted_master_key`.
|
||||
|
||||
|
||||
|
||||
|
||||
* **Refrescar Token**
|
||||
* **Ruta:** `POST /api/auth/refresh`
|
||||
|
||||
* **Body:** `{ "refresh_token": "..." }`
|
||||
|
||||
* **Descripción:** Intercambia un Refresh Token válido por un nuevo Access Token limpio de 15 minutos.
|
||||
|
||||
|
||||
|
||||
|
||||
* **Cerrar Sesión Local**
|
||||
* **Ruta:** `POST /api/auth/logout`
|
||||
|
||||
* **Body:** `{ "refresh_token": "..." }`
|
||||
|
||||
* **Descripción:** Borra el Refresh Token proporcionado de la base de datos, revocando el acceso a ese dispositivo específico.
|
||||
|
||||
|
||||
|
||||
|
||||
* **Borrado Total de Cuenta (Danger Zone)**
|
||||
* **Ruta:** `DELETE /api/auth/delete-all-data`
|
||||
|
||||
* **Headers:** `Authorization: Bearer <access_token>`
|
||||
|
||||
* **Descripción:** Mediante una transacción de base de datos, borra irremediablemente todas las notas, categorías y sesiones (tokens) asociadas al usuario actual.
|
||||
|
||||
|
||||
|
||||
|
||||
* **Listar Dispositivos Activos**
|
||||
* **Ruta:** `GET /api/auth/getSessionsInfo`
|
||||
|
||||
* **Headers:** `Authorization: Bearer <access_token>`
|
||||
|
||||
* **Descripción:** Devuelve un listado de todos los dispositivos (`deviceName`), fechas de creación y los IDs de los Refresh Tokens asociados al usuario.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### 2. Estadísticas de Usuario (`/api/user`)
|
||||
|
||||
* **Obtener Estadísticas**
|
||||
* **Ruta:** `GET /api/user/stats`
|
||||
|
||||
* **Headers:** `Authorization: Bearer <access_token>`
|
||||
|
||||
* **Descripción:** Descarga parcialmente las notas y categorías de la base de datos (solo los campos pesados como títulos y cuerpos) para calcular el peso en bytes (RAM-friendly) del almacenamiento en la nube del usuario. Devuelve totales formateados (KB/MB).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### 3. Sincronización Offline-First (`/api/sync`)
|
||||
|
||||
Este endpoint único procesa de forma bidireccional los cambios ocurridos tanto en la app local como en la base de datos del servidor.
|
||||
|
||||
* **Endpoint de Sincronización**
|
||||
* **Ruta:** `POST /api/sync`
|
||||
|
||||
* **Headers:** `Authorization: Bearer <access_token>`
|
||||
|
||||
* **Body:**
|
||||
```json
|
||||
{
|
||||
"message": "¡API funcionando correctamente!"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/auth/register`
|
||||
|
||||
Crea un usuario nuevo.
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "maria",
|
||||
"password": "123456",
|
||||
"encrypted_master_key": "texto_cifrado_opcional",
|
||||
"deviceName": "Flutter Pixel"
|
||||
}
|
||||
```
|
||||
|
||||
Campos:
|
||||
|
||||
- `email`: `string`, obligatorio.
|
||||
- `password`: `string`, obligatorio.
|
||||
- `encrypted_master_key`: `string | null`, opcional.
|
||||
|
||||
Respuesta ejemplo:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Usuario registrado con éxito",
|
||||
"accessToken": "jwt_access_token",
|
||||
"refreshToken": "token_largo_aleatorio"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Inicia sesión y devuelve tokens.
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "maria",
|
||||
"password": "123456",
|
||||
"deviceName": "Flutter Pixel"
|
||||
}
|
||||
```
|
||||
|
||||
Campos:
|
||||
|
||||
- `email`: `string`, obligatorio.
|
||||
- `password`: `string`, obligatorio.
|
||||
- `deviceName`: `string`, opcional.
|
||||
|
||||
Respuesta ejemplo:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Login exitoso",
|
||||
"accessToken": "jwt_access_token",
|
||||
"refreshToken": "token_largo_aleatorio",
|
||||
"encrypted_master_key": "texto_cifrado"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/auth/refresh`
|
||||
|
||||
Genera un nuevo `accessToken` usando un `refreshToken` válido.
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"refreshToken": "token_largo_aleatorio"
|
||||
}
|
||||
```
|
||||
|
||||
Campos:
|
||||
|
||||
- `refreshToken`: `string`, obligatorio.
|
||||
|
||||
Respuesta ejemplo:
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "nuevo_jwt_access_token"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Revoca la sesión del dispositivo actual.
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"refreshToken": "token_largo_aleatorio"
|
||||
}
|
||||
```
|
||||
|
||||
Campos:
|
||||
|
||||
- `refreshToken`: `string`, obligatorio.
|
||||
|
||||
Respuesta ejemplo:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Sesión cerrada en el dispositivo actual"
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/auth/delete-all-data`
|
||||
|
||||
Elimina todas las notas, categorías y refresh tokens del usuario autenticado. El usuario se conserva.
|
||||
|
||||
Headers:
|
||||
|
||||
- `Authorization: Bearer <accessToken>`: obligatorio.
|
||||
|
||||
Respuesta ejemplo:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Datos del usuario eliminados correctamente; se eliminaron todos los tokens."
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/sync`
|
||||
|
||||
Endpoint único de sincronización offline-first.
|
||||
|
||||
Autenticación requerida:
|
||||
|
||||
- Enviar `Authorization: Bearer <accessToken>` (obligatorio).
|
||||
|
||||
#### Request
|
||||
|
||||
Body ejemplo:
|
||||
|
||||
```json
|
||||
{
|
||||
"lastSyncAt": "2026-05-18T10:00:00.000Z",
|
||||
"lastSyncAt": "ISO String",
|
||||
"changes": {
|
||||
"categories": [
|
||||
{
|
||||
"id": "uuid-categoria-1",
|
||||
"encrypted_name": "texto_cifrado...",
|
||||
"serverVersion": 1,
|
||||
"delete": false,
|
||||
"updatedAt": "2026-05-18T10:05:00.000Z"
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": "uuid-nota-1",
|
||||
"categoryId": "uuid-categoria-1",
|
||||
"title": "titulo_cifrado...",
|
||||
"body": "cuerpo_cifrado...",
|
||||
"serverVersion": 1,
|
||||
"position": 2000,
|
||||
"delete": true,
|
||||
"permanentDelete": false,
|
||||
"updatedAt": "2026-05-18T10:10:00.000Z"
|
||||
}
|
||||
]
|
||||
"categories": [ ... ],
|
||||
"notes": [ ... ]
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Campos:
|
||||
|
||||
- `lastSyncAt`: `string` ISO, opcional. Si no se envía, el servidor usa `1970-01-01T00:00:00.000Z`.
|
||||
- `changes`: `object`, obligatorio.
|
||||
- `changes.categories`: `array`, opcional.
|
||||
- `changes.notes`: `array`, opcional.
|
||||
|
||||
#### Estructura de categoría
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "texto_cifrado",
|
||||
"serverVersion": 1,
|
||||
"delete": false,
|
||||
"isDirty": true,
|
||||
"colorValue": 4281558681,
|
||||
"iconCodePoint": 58896,
|
||||
"updatedAt": "2026-05-18T10:05:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Campos:
|
||||
* **Descripción y Flujo de Resolución:**
|
||||
1. Envuelve todo en una **Transacción SQL** para garantizar que o se guarda todo o no se guarda nada en caso de error.
|
||||
|
||||
- `id`: `UUID`, obligatorio.
|
||||
- `name`: `string` (cifrado), obligatorio. Equivale a `encrypted_name`; ambos contienen el nombre encriptado.
|
||||
- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio.
|
||||
- `delete`: `boolean`, opcional, por defecto `false`.
|
||||
- `isDirty`: `boolean`, opcional. El servidor lo ignora en la escritura y devuelve `false` en la respuesta de sync.
|
||||
- `colorValue`: `number`, opcional.
|
||||
- `iconCodePoint`: `number`, opcional.
|
||||
- `updatedAt`: `ISO date string`, opcional (solo informativo para UI).
|
||||
|
||||
#### Estructura de nota
|
||||
2. Recorre las `categories` entrantes. Si el `id` no existe, lo crea. Si existe, compara la `serverVersion`. Si la versión enviada coincide con la del servidor, actualiza los datos e incrementa la versión en el servidor. Si el servidor tiene una versión superior (conflicto), ignora el cambio del cliente y anota el ID para mandárselo de vuelta.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"categoryId": "uuid-categoria",
|
||||
"title": "titulo_cifrado",
|
||||
"body": "cuerpo_cifrado",
|
||||
"serverVersion": 1,
|
||||
"position": 2000,
|
||||
"delete": false,
|
||||
"updatedAt": "2026-05-18T10:10:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Campos:
|
||||
3. Repite exactamente el mismo proceso de validación transaccional e inserción de datos para las `notes`, actualizando también la tabla intermedia de categorías mediante el array `categoryIds` entrante.
|
||||
|
||||
- `id`: `UUID`, obligatorio.
|
||||
- `categoryId`: `UUID | null`, opcional.
|
||||
- `title`: `string`, obligatorio.
|
||||
- `body`: `string`, obligatorio.
|
||||
- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio.
|
||||
- `position`: `number`, opcional.
|
||||
- `delete`: `boolean`, opcional, por defecto `false`.
|
||||
- `permanentDelete`: `boolean`, opcional, por defecto `false`. Si llega en `true`, el servidor guarda la nota con `title` y `body` vacíos, `position` en `0` y `delete` en `true`.
|
||||
- `updatedAt`: `ISO date string`, opcional (solo informativo para UI).
|
||||
|
||||
#### Response
|
||||
4. Consulta a la base de datos cualquier nota o categoría cuyo `updatedAt` sea mayor al `lastSyncAt` proporcionado por el cliente, además de sumar los registros que hayan sufrido un conflicto de versión en los pasos anteriores.
|
||||
|
||||
Respuesta ejemplo (nuevo contrato):
|
||||
|
||||
```json
|
||||
{
|
||||
"serverTimestamp": "2026-05-18T10:20:00.000Z",
|
||||
"synced": true,
|
||||
"changes": {
|
||||
"categories": [],
|
||||
"notes": [
|
||||
{
|
||||
"id": "uuid-nota-2",
|
||||
"categoryId": null,
|
||||
"title": "titulo_cifrado...",
|
||||
"body": "cuerpo_cifrado...",
|
||||
"serverVersion": 3,
|
||||
"position": 0,
|
||||
"delete": false,
|
||||
"permanentDelete": false,
|
||||
"updatedAt": "2026-05-18T10:15:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Campos de salida:
|
||||
|
||||
- `serverTimestamp`: `string` ISO con la hora del servidor.
|
||||
- `synced`: `boolean`.
|
||||
- `changes.categories`: array con todas las categorías cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos.
|
||||
- `changes.notes`: array con todas las notas cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos y las copias creadas automáticamente en caso de conflicto.
|
||||
|
||||
## Reglas de sincronización
|
||||
|
||||
- El cliente debe guardar su último `lastSyncAt`.
|
||||
- Un borrado no elimina el registro localmente: se marca con `delete: true`.
|
||||
- El servidor decide conflictos comparando `serverVersion` (no `updatedAt`).
|
||||
- En categorías, solo se actualiza si `incoming.serverVersion === serverVersion`. Si no coincide, gana el servidor y no se crea duplicado.
|
||||
- Si la versión entrante de una nota coincide con la versión actual del servidor, acepta el cambio y sube versión (`v -> v+1`).
|
||||
- Si la versión del servidor es mayor que la entrante en una nota, el servidor crea una nueva nota duplicada con el contenido entrante para no perder datos.
|
||||
|
||||
## Notas importantes
|
||||
|
||||
- Las notas y categorías están pensadas para contenido cifrado del lado del cliente.
|
||||
- El servidor guarda `title`, `body` y `encrypted_name`, pero no interpreta el contenido.
|
||||
5. Devuelve al cliente un objeto estructurado con las notas y categorías actualizadas del servidor y el nuevo `serverTimestamp` (momento exacto en el que acabó la sincronización).
|
||||
@@ -132,5 +132,28 @@ const getSessionsInfo = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const logoutDevice = async (req, res) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.authorization || '';
|
||||
if (!authorizationHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authorization header missing or invalid' });
|
||||
}
|
||||
|
||||
const token = authorizationHeader.slice(7).trim();
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
const userId = payload && payload.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Usuario inválido en token' });
|
||||
|
||||
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID del dispositivo es obligatorio' });
|
||||
|
||||
const result = await authService.logoutDevice_id(userId, id);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Exportamos los handlers de auth.
|
||||
module.exports = { register, login, refresh, logout, deleteAllData, getSessionsInfo};
|
||||
module.exports = { register, login, refresh, logout, deleteAllData, getSessionsInfo, logoutDevice };
|
||||
@@ -19,4 +19,6 @@ router.delete('/delete-all-data', authController.deleteAllData);
|
||||
|
||||
router.get('/getSessionsInfo', authController.getSessionsInfo);
|
||||
|
||||
router.post('/logout-device', authController.logoutDevice); // Nueva ruta para cerrar sesión en un dispositivo específico
|
||||
|
||||
module.exports = router;
|
||||
@@ -117,6 +117,15 @@ class AuthService {
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async logoutDevice_id(userId, id) {
|
||||
const deletedCount = await RefreshToken.destroy({ where: { id, userId } });
|
||||
if (deletedCount === 0) {
|
||||
throw new Error('No se encontró la sesión para cerrar');
|
||||
}
|
||||
return { message: 'Sesión cerrada en el dispositivo especificado correctamente.' };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
Reference in New Issue
Block a user