feat: autorregistro con verificacion de correo + aprobacion, superadmin y SMTP
- POST /api/auth/register: primer usuario del sistema -> superadmin con sesion directa (bootstrap, sin correo ni aprobacion); los siguientes quedan pending_verification y reciben un correo con enlace de un solo uso (24h). - GET /api/auth/verify-email: pasa a pending_approval y notifica in-app a los admin/superadmin activos. - admin/superadmin aprueban (POST /api/users/:id/approve, eligen rol y departamento) o rechazan (POST /api/users/:id/reject, borra y audita el email) desde Organizacion -> Usuarios (nuevo badge de estado + acciones). - login() da mensaje especifico segun el status de la cuenta en vez del generico "credenciales invalidas". - Recuperacion de clave (forgot/reset-password): respuesta siempre generica, token de 1 hora de un solo uso. - Nuevo modulo settings: SMTP configurable SOLO por superadmin desde Configuracion -> Correo (SMTP); clave cifrada AES-256-GCM (ENCRYPTION_KEY) en la tabla system_settings, nunca expuesta por la API. sendMail() degrada a modo dev (loguea en vez de enviar) si no hay SMTP configurado, y nunca deja que un fallo de envio tumbe al que lo llama. - Migracion 002_registration_and_smtp.sql: rol superadmin, columnas de ciclo de vida en users (status, tokens), tabla system_settings, y promueve al usuario mas antiguo del sistema a superadmin. - Fix: React.StrictMode disparaba el efecto de VerifyEmailPage dos veces; la segunda llamada (token ya consumido) mostraba "enlace invalido" pese a que la verificacion ya habia funcionado. Se agrega una guarda por token. - Fix (deuda tecnica v1.0.0): se reordena el montaje de rutas en app.js — subtasksRoutes (mount generico en /api) va al final, para no interceptar con su requireAuth interno una request sin token dirigida a un router mas especifico registrado despues. - Documentacion actualizada: AGENTS.md, SPEC.md, API.md, BASE_DE_DATOS.md, CONFIGURACION.md, ESTADO.md, CHANGELOG.md. Version 1.1.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Cifrado simetrico para secretos de terceros guardados en BD (ej. clave SMTP) — nunca en texto
|
||||
// plano. AES-256-GCM con ENCRYPTION_KEY (32 bytes hex) del entorno.
|
||||
const KEY = Buffer.from(process.env.ENCRYPTION_KEY || '', 'hex');
|
||||
|
||||
function requireKey() {
|
||||
if (KEY.length !== 32) {
|
||||
throw new Error('ENCRYPTION_KEY invalido o ausente (debe ser hex de 32 bytes / 64 caracteres)');
|
||||
}
|
||||
}
|
||||
|
||||
export function encrypt(text) {
|
||||
requireKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
export function decrypt(payload) {
|
||||
requireKey();
|
||||
const raw = Buffer.from(payload, 'base64');
|
||||
const iv = raw.subarray(0, 12);
|
||||
const authTag = raw.subarray(12, 28);
|
||||
const encrypted = raw.subarray(28);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getSmtpConfigInternal } from '../modules/settings/service.js';
|
||||
|
||||
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
|
||||
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
|
||||
export async function sendMail({ to, subject, html }) {
|
||||
const config = await getSmtpConfigInternal();
|
||||
|
||||
if (!config) {
|
||||
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
|
||||
return { sent: false, dev: true };
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: false,
|
||||
requireTLS: true,
|
||||
auth: { user: config.user, pass: config.pass },
|
||||
});
|
||||
|
||||
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
|
||||
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
|
||||
try {
|
||||
await transporter.sendMail({ from: config.from, to, subject, html });
|
||||
return { sent: true, dev: false };
|
||||
} catch (err) {
|
||||
console.error('[mail:error]', err.message);
|
||||
return { sent: false, dev: false, error: err.message };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user