7139f1604e
Contraseñas: "Compartir grupo" aplica el mismo share de solo lectura a todas las credenciales de un grupo de una vez (el grupo sigue sin ser una unidad de permiso -- es una accion de conveniencia). El grupo y cada credencial se marcan con el icono de compartido. "Destruir grupo" ahora tiene dos opciones: solo desagrupar (como antes) o destruir tambien las N credenciales, con advertencia explicita del conteo. Fix de paso en ComboBox (afecta a toda la app): el panel se cerraba solo al abrirse si el trigger estaba cerca del borde de un contenedor con scroll, por un micro-scroll automatico del navegador al enfocar el boton. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import bcrypt from 'bcryptjs';
|
|
import jwt from 'jsonwebtoken';
|
|
import * as repo from './repository.js';
|
|
import * as usersRepo from '../users/repository.js';
|
|
import { env } from '../../config/env.js';
|
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
|
import { HttpError } from '../../utils/http.js';
|
|
import { logAudit } from '../../utils/audit.js';
|
|
import { sendMail } from '../../utils/mailer.js';
|
|
|
|
const PIN_RE = /^\d{4}$/;
|
|
const VAULT_TOKEN_EXPIRES = '30m';
|
|
|
|
// Catalogo fijo (debe coincidir con el del frontend: ContrasenasPage.jsx#ICONS y
|
|
// frontend/src/data/brandIcons.js#BRAND_ICONS) -- nunca se acepta un valor arbitrario, para no
|
|
// guardar basura ni abrir una via de inyeccion rara.
|
|
const ICON_KEYS = new Set([
|
|
'mail', 'globe', 'server', 'database', 'cloud', 'shield', 'lock', 'wifi',
|
|
'card', 'building', 'printer', 'phone', 'key', 'disk', 'router', 'user',
|
|
'b_google', 'b_gmail', 'b_googledrive', 'b_googlecloud', 'b_protonmail', 'b_zoho',
|
|
'b_github', 'b_gitlab', 'b_digitalocean', 'b_cloudflare', 'b_namecheap', 'b_godaddy',
|
|
'b_ovh', 'b_vultr', 'b_netlify', 'b_vercel', 'b_cpanel', 'b_plesk', 'b_webmin',
|
|
'b_nginx', 'b_apache', 'b_mysql', 'b_mariadb', 'b_postgresql', 'b_docker',
|
|
'b_cisco', 'b_fortinet', 'b_mikrotik', 'b_ubiquiti', 'b_tplink', 'b_openvpn', 'b_wireguard',
|
|
'b_pfsense', 'b_opnsense', 'b_qnap', 'b_synology', 'b_nordvpn', 'b_bitdefender',
|
|
'b_trendmicro', 'b_avast', 'b_kaspersky',
|
|
'b_whatsapp', 'b_telegram', 'b_zoom', 'b_discord', 'b_jitsi',
|
|
'b_facebook', 'b_instagram', 'b_x',
|
|
'b_paypal', 'b_stripe',
|
|
'b_teamviewer', 'b_anydesk',
|
|
'b_dropbox', 'b_backblaze', 'b_mega', 'b_veeam', 'b_nextcloud',
|
|
'b_bitwarden', 'b_lastpass',
|
|
'b_linux', 'b_ubuntu', 'b_apple', 'b_raspberrypi',
|
|
'b_wordpress', 'b_mailchimp', 'b_firebase',
|
|
]);
|
|
function sanitizeIconKey(key) {
|
|
return ICON_KEYS.has(key) ? key : null;
|
|
}
|
|
|
|
function newToken() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
async function getOwnEntryOr404(id, currentUser) {
|
|
const entry = await repo.findById(id);
|
|
if (!entry || entry.user_id !== currentUser.id) throw new HttpError(404, 'No encontrado');
|
|
return entry;
|
|
}
|
|
|
|
// Dueño o receptor de un share (solo lectura: nunca para editar/borrar/compartir/mover de
|
|
// grupo, eso sigue siendo getOwnEntryOr404). 404 tambien si no esta compartida -- nunca 403.
|
|
async function getReadableEntryOr404(id, currentUser) {
|
|
const entry = await repo.findById(id);
|
|
if (!entry) throw new HttpError(404, 'No encontrado');
|
|
if (entry.user_id === currentUser.id) return entry;
|
|
if (await repo.isSharedWith(id, currentUser.id)) return entry;
|
|
throw new HttpError(404, 'No encontrado');
|
|
}
|
|
|
|
async function getOwnGroupOr404(id, currentUser) {
|
|
const group = await repo.findGroupById(id);
|
|
if (!group || group.user_id !== currentUser.id) throw new HttpError(404, 'Grupo no encontrado');
|
|
return group;
|
|
}
|
|
|
|
function stripSensitive(entry) {
|
|
const { password_enc, notes_enc, ...rest } = entry;
|
|
return rest;
|
|
}
|
|
|
|
function decryptEntry(entry) {
|
|
return {
|
|
...stripSensitive(entry),
|
|
password: entry.password_enc ? decrypt(entry.password_enc) : '',
|
|
notes: entry.notes_enc ? decrypt(entry.notes_enc) : '',
|
|
};
|
|
}
|
|
|
|
// Buscar por nombre/usuario/enlace es trivial (van en texto plano). Las notas SI estan cifradas
|
|
// -- se descifran aca mismo, en el servidor, solo para comparar contra la busqueda; el contenido
|
|
// nunca sale hacia el cliente (se saca con stripSensitive antes de devolver, igual que siempre).
|
|
// No se audita como "view": nunca se le muestra el texto de la nota al usuario, solo si matchea.
|
|
export async function listEntries(currentUser, query) {
|
|
const q = query?.trim().toLowerCase();
|
|
if (!q) return repo.findVisibleToUser(currentUser.id);
|
|
|
|
const rows = await repo.findByUserForSearch(currentUser.id);
|
|
return rows
|
|
.filter((row) => {
|
|
if ([row.title, row.url, row.username].some((v) => v && v.toLowerCase().includes(q))) return true;
|
|
if (!row.notes_enc) return false;
|
|
try {
|
|
return decrypt(row.notes_enc).toLowerCase().includes(q);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
.map(stripSensitive);
|
|
}
|
|
|
|
// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita. Dueño o
|
|
// receptor de un share pueden revelar (acordado con el usuario: compartir da acceso real a la
|
|
// credencial, no solo a los metadatos) -- nunca a quien no tiene ninguna de las dos relaciones.
|
|
export async function getEntry(id, currentUser) {
|
|
const entry = await getReadableEntryOr404(id, currentUser);
|
|
await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id });
|
|
return decryptEntry(entry);
|
|
}
|
|
|
|
async function resolveGroupId(groupId, currentUser) {
|
|
if (!groupId) return null;
|
|
await getOwnGroupOr404(groupId, currentUser);
|
|
return groupId;
|
|
}
|
|
|
|
export async function createEntry(payload, currentUser) {
|
|
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
|
|
const groupId = await resolveGroupId(payload.groupId, currentUser);
|
|
const entry = await repo.create(currentUser.id, {
|
|
title: payload.title.trim(),
|
|
url: payload.url?.trim(),
|
|
username: payload.username?.trim(),
|
|
passwordEnc: payload.password ? encrypt(payload.password) : null,
|
|
notesEnc: payload.notes ? encrypt(payload.notes) : null,
|
|
iconKey: sanitizeIconKey(payload.iconKey),
|
|
groupId,
|
|
});
|
|
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id });
|
|
return stripSensitive(entry);
|
|
}
|
|
|
|
export async function updateEntry(id, payload, currentUser) {
|
|
const entry = await getOwnEntryOr404(id, currentUser);
|
|
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
|
|
const groupId = await resolveGroupId(payload.groupId, currentUser);
|
|
// Si no viene password/notes nuevo (ej. se edito solo el nombre sin pasar por "Revelar"
|
|
// primero), se conserva lo que ya estaba guardado -- nunca se borra por venir vacio.
|
|
const updated = await repo.update(id, {
|
|
title: payload.title.trim(),
|
|
url: payload.url?.trim(),
|
|
username: payload.username?.trim(),
|
|
passwordEnc: payload.password ? encrypt(payload.password) : entry.password_enc,
|
|
notesEnc: payload.notes !== undefined && payload.notes !== '' ? encrypt(payload.notes) : entry.notes_enc,
|
|
iconKey: sanitizeIconKey(payload.iconKey),
|
|
groupId,
|
|
});
|
|
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id });
|
|
return stripSensitive(updated);
|
|
}
|
|
|
|
export async function removeEntry(id, currentUser) {
|
|
await getOwnEntryOr404(id, currentUser);
|
|
await repo.remove(id);
|
|
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_entry', entityId: id });
|
|
}
|
|
|
|
// --- Grupos (carpetas de un solo nivel) ---
|
|
|
|
export const listGroups = (currentUser) => repo.findGroupsByUser(currentUser.id);
|
|
|
|
export async function createGroup(payload, currentUser) {
|
|
const name = payload.name?.trim();
|
|
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
|
|
const group = await repo.createGroup(currentUser.id, name, sanitizeIconKey(payload.iconKey));
|
|
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_group', entityId: group.id });
|
|
return group;
|
|
}
|
|
|
|
export async function updateGroup(id, payload, currentUser) {
|
|
await getOwnGroupOr404(id, currentUser);
|
|
const name = payload.name?.trim();
|
|
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
|
|
const group = await repo.updateGroup(id, name, sanitizeIconKey(payload.iconKey));
|
|
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_group', entityId: id });
|
|
return group;
|
|
}
|
|
|
|
// cascade=false (default): borrado "suave", las entradas quedan sin grupo (como siempre).
|
|
// cascade=true: ademas destruye TODAS las entradas del grupo -- irreversible, ver confirmacion
|
|
// fuerte en el frontend (ContrasenasPage.jsx#confirmDeleteGroup).
|
|
export async function removeGroup(id, currentUser, cascade = false) {
|
|
await getOwnGroupOr404(id, currentUser);
|
|
if (cascade) await repo.removeEntriesInGroup(id);
|
|
await repo.removeGroup(id);
|
|
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_group', entityId: id, detail: { cascade } });
|
|
}
|
|
|
|
// --- Compartir una entrada (solo lectura) ---
|
|
|
|
export async function listEntryShares(id, currentUser) {
|
|
await getOwnEntryOr404(id, currentUser);
|
|
return repo.listShares(id);
|
|
}
|
|
|
|
export async function shareEntry(id, targetUserId, currentUser) {
|
|
await getOwnEntryOr404(id, currentUser);
|
|
if (!targetUserId) throw new HttpError(400, 'Indica un usuario');
|
|
if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir una entrada contigo mismo');
|
|
const targetUser = await usersRepo.findById(targetUserId);
|
|
if (!targetUser) throw new HttpError(404, 'Usuario no encontrado');
|
|
const shares = await repo.addShare(id, targetUserId);
|
|
await logAudit({ userId: currentUser.id, action: 'share', entityType: 'vault_entry', entityId: id, detail: { userId: targetUserId } });
|
|
return shares;
|
|
}
|
|
|
|
export async function unshareEntry(id, targetUserId, currentUser) {
|
|
await getOwnEntryOr404(id, currentUser);
|
|
const shares = await repo.removeShare(id, targetUserId);
|
|
await logAudit({ userId: currentUser.id, action: 'unshare', entityType: 'vault_entry', entityId: id, detail: { userId: targetUserId } });
|
|
return shares;
|
|
}
|
|
|
|
// Compartir TODAS las entradas de un grupo de una vez (conveniencia desde el menu del grupo) --
|
|
// el grupo sigue sin ser una unidad de permiso: esto solo aplica el mismo share de siempre a
|
|
// cada entrada que tiene hoy ese group_id, una por una. Entradas que se agreguen al grupo
|
|
// despues NO heredan el share retroactivamente.
|
|
export async function shareGroup(groupId, targetUserId, currentUser) {
|
|
await getOwnGroupOr404(groupId, currentUser);
|
|
if (!targetUserId) throw new HttpError(400, 'Indica un usuario');
|
|
if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir contigo mismo');
|
|
const targetUser = await usersRepo.findById(targetUserId);
|
|
if (!targetUser) throw new HttpError(404, 'Usuario no encontrado');
|
|
const sharedCount = await repo.shareGroupEntries(groupId, targetUserId);
|
|
await logAudit({ userId: currentUser.id, action: 'share', entityType: 'vault_group', entityId: groupId, detail: { userId: targetUserId, entriesCount: sharedCount } });
|
|
return { sharedCount };
|
|
}
|
|
|
|
// --- PIN ---
|
|
|
|
export async function getStatus(currentUser) {
|
|
const hash = await repo.getVaultPinHash(currentUser.id);
|
|
return { hasPinSet: !!hash };
|
|
}
|
|
|
|
export async function createPin(pin, currentUser) {
|
|
if (!PIN_RE.test(pin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros');
|
|
const existing = await repo.getVaultPinHash(currentUser.id);
|
|
if (existing) throw new HttpError(400, 'Ya tenes un PIN creado -- usa "cambiar PIN" en su lugar');
|
|
const hash = await bcrypt.hash(pin, 12);
|
|
await repo.setVaultPinHash(currentUser.id, hash);
|
|
await logAudit({ userId: currentUser.id, action: 'create_pin', entityType: 'vault_pin', entityId: currentUser.id });
|
|
}
|
|
|
|
export async function unlock(pin, currentUser) {
|
|
if (!PIN_RE.test(pin)) throw new HttpError(400, 'PIN invalido');
|
|
const hash = await repo.getVaultPinHash(currentUser.id);
|
|
if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN');
|
|
const ok = await bcrypt.compare(pin, hash);
|
|
if (!ok) throw new HttpError(403, 'PIN incorrecto');
|
|
const vaultToken = jwt.sign({ sub: currentUser.id, scope: 'vault' }, env.jwtSecret, { expiresIn: VAULT_TOKEN_EXPIRES });
|
|
await logAudit({ userId: currentUser.id, action: 'unlock', entityType: 'vault_pin', entityId: currentUser.id });
|
|
return { vaultToken };
|
|
}
|
|
|
|
export async function changePin(currentPin, newPin, currentUser) {
|
|
if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN nuevo debe ser de 4 numeros');
|
|
const hash = await repo.getVaultPinHash(currentUser.id);
|
|
if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN');
|
|
const ok = await bcrypt.compare(currentPin, hash);
|
|
if (!ok) throw new HttpError(403, 'PIN actual incorrecto');
|
|
const newHash = await bcrypt.hash(newPin, 12);
|
|
await repo.setVaultPinHash(currentUser.id, newHash);
|
|
await logAudit({ userId: currentUser.id, action: 'change_pin', entityType: 'vault_pin', entityId: currentUser.id });
|
|
}
|
|
|
|
// Solo cambia el PIN -- nunca toca vault_entries. No es "recuperar datos", es resetear el candado.
|
|
export async function forgotPin(currentUser) {
|
|
const token = newToken();
|
|
await repo.setPinResetToken(currentUser.id, token);
|
|
const resetUrl = `${env.corsOrigin}/restablecer-pin?token=${token}`;
|
|
await sendMail({
|
|
to: currentUser.email,
|
|
subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net',
|
|
html: `<p>Hola ${currentUser.name},</p>
|
|
<p>Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia
|
|
el PIN -- tus contraseñas guardadas no se tocan ni se pierden.</p>
|
|
<p>Elegi un PIN nuevo en este enlace (vence en 1 hora):</p>
|
|
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
|
<p>Si no pediste esto, ignora el correo.</p>`,
|
|
});
|
|
}
|
|
|
|
export async function resetPin(token, newPin) {
|
|
if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros');
|
|
const user = await repo.findByPinResetToken(token);
|
|
if (!user) throw new HttpError(400, 'El enlace para restablecer el PIN es invalido o expiro');
|
|
const hash = await bcrypt.hash(newPin, 12);
|
|
await repo.setVaultPinHash(user.id, hash);
|
|
await logAudit({ userId: user.id, action: 'reset_pin', entityType: 'vault_pin', entityId: user.id });
|
|
}
|