feat: version 2.7.0 -- menu Contraseñas (repositorio de credenciales privado)
Nuevo modulo backend/src/modules/vault/: contraseña y notas cifradas en el servidor (AES-256-GCM, mismo mecanismo ya usado para la clave SMTP). Acceso al menu protegido con un PIN de 4 digitos independiente del login (token corto propio, header X-Vault-Token, nunca el JWT de sesion). El PIN no participa del cifrado -- cambiarlo o restablecerlo por correo nunca afecta las contraseñas ya guardadas. Privado por usuario, sin excepcion. Cada accion sensible (crear/editar/eliminar/ revelar/desbloquear) queda en audit_log. Compartir con otro usuario queda para una proxima version. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getStatus = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.getStatus(req.currentUser));
|
||||
});
|
||||
|
||||
export const postPin = asyncHandler(async (req, res) => {
|
||||
await service.createPin(req.body.pin, req.currentUser);
|
||||
ok(res, { created: true });
|
||||
});
|
||||
|
||||
export const postUnlock = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.unlock(req.body.pin, req.currentUser));
|
||||
});
|
||||
|
||||
export const postChangePin = asyncHandler(async (req, res) => {
|
||||
await service.changePin(req.body.currentPin, req.body.newPin, req.currentUser);
|
||||
ok(res, { changed: true });
|
||||
});
|
||||
|
||||
export const postForgotPin = asyncHandler(async (req, res) => {
|
||||
await service.forgotPin(req.currentUser);
|
||||
ok(res, { sent: true });
|
||||
});
|
||||
|
||||
export const postResetPin = asyncHandler(async (req, res) => {
|
||||
await service.resetPin(req.body.token, req.body.newPin);
|
||||
ok(res, { reset: true });
|
||||
});
|
||||
|
||||
export const getEntries = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.listEntries(req.currentUser));
|
||||
});
|
||||
|
||||
export const getEntry = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.getEntry(Number(req.params.id), req.currentUser));
|
||||
});
|
||||
|
||||
export const postEntry = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createEntry(req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const putEntry = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateEntry(Number(req.params.id), req.body, req.currentUser));
|
||||
});
|
||||
|
||||
export const deleteEntry = asyncHandler(async (req, res) => {
|
||||
await service.removeEntry(Number(req.params.id), req.currentUser);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { env } from '../../config/env.js';
|
||||
import { fail } from '../../utils/http.js';
|
||||
|
||||
// Candado de acceso a /api/vault/entries*: token corto y propio (scope:'vault'), NUNCA el JWT de
|
||||
// login -- se pide recien despues de verificar el PIN (ver service.js#unlock). Va en un header
|
||||
// propio para no mezclarse con el Authorization normal, que sigue intacto.
|
||||
export function requireVaultUnlock(req, res, next) {
|
||||
const token = req.headers['x-vault-token'];
|
||||
if (!token) return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN');
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, env.jwtSecret);
|
||||
} catch {
|
||||
return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN');
|
||||
}
|
||||
if (payload.scope !== 'vault' || payload.sub !== req.currentUser.id) {
|
||||
return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN');
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
const LIST_SELECT = 'id, user_id, title, url, username, created_at, updated_at';
|
||||
const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`;
|
||||
|
||||
export async function findByUser(userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ${LIST_SELECT} FROM vault_entries WHERE user_id = ? ORDER BY title`,
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create(userId, { title, url, username, passwordEnc, notesEnc }) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, title, url || null, username || null, passwordEnc || null, notesEnc || null]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function update(id, { title, url, username, passwordEnc, notesEnc }) {
|
||||
await pool.query(
|
||||
`UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ? WHERE id = ?`,
|
||||
[title, url || null, username || null, passwordEnc || null, notesEnc || null, id]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM vault_entries WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// --- PIN de acceso al modulo (en la tabla users, ver migracion 017) ---
|
||||
|
||||
export async function getVaultPinHash(userId) {
|
||||
const [rows] = await pool.query('SELECT vault_pin_hash FROM users WHERE id = ?', [userId]);
|
||||
return rows[0]?.vault_pin_hash || null;
|
||||
}
|
||||
|
||||
export async function setVaultPinHash(userId, hash) {
|
||||
await pool.query(
|
||||
'UPDATE users SET vault_pin_hash = ?, vault_pin_reset_token = NULL, vault_pin_reset_token_expires = NULL WHERE id = ?',
|
||||
[hash, userId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function setPinResetToken(userId, token) {
|
||||
await pool.query(
|
||||
'UPDATE users SET vault_pin_reset_token = ?, vault_pin_reset_token_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?',
|
||||
[token, userId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function findByPinResetToken(token) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id FROM users WHERE vault_pin_reset_token = ? AND vault_pin_reset_token_expires > NOW()',
|
||||
[token]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { requireVaultUnlock } from './middleware.js';
|
||||
import {
|
||||
getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin,
|
||||
getEntries, getEntry, postEntry, putEntry, deleteEntry,
|
||||
} from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// PIN de 4 digitos: 10,000 combinaciones -- sin este limiter seria trivial de forzar.
|
||||
const unlockLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 8, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
// Publico, como auth/reset-password: el link del correo puede abrirse sin sesion activa.
|
||||
router.post('/reset-pin', unlockLimiter, postResetPin);
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/status', getStatus);
|
||||
router.post('/pin', postPin);
|
||||
router.post('/unlock', unlockLimiter, postUnlock);
|
||||
router.post('/change-pin', unlockLimiter, postChangePin);
|
||||
router.post('/forgot-pin', unlockLimiter, postForgotPin);
|
||||
|
||||
router.get('/entries', requireVaultUnlock, getEntries);
|
||||
router.get('/entries/:id', requireVaultUnlock, getEntry);
|
||||
router.post('/entries', requireVaultUnlock, postEntry);
|
||||
router.put('/entries/:id', requireVaultUnlock, putEntry);
|
||||
router.delete('/entries/:id', requireVaultUnlock, deleteEntry);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,144 @@
|
||||
import crypto from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import * as repo from './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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEntries(currentUser) {
|
||||
const rows = await repo.findByUser(currentUser.id);
|
||||
return rows; // sin password/notes -- la lista nunca trae datos sensibles
|
||||
}
|
||||
|
||||
// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita.
|
||||
export async function getEntry(id, currentUser) {
|
||||
const entry = await getOwnEntryOr404(id, currentUser);
|
||||
await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id });
|
||||
return decryptEntry(entry);
|
||||
}
|
||||
|
||||
export async function createEntry(payload, currentUser) {
|
||||
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
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,
|
||||
});
|
||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id });
|
||||
return stripSensitive(entry);
|
||||
}
|
||||
|
||||
export async function updateEntry(id, payload, currentUser) {
|
||||
await getOwnEntryOr404(id, currentUser);
|
||||
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const updated = await repo.update(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,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
// --- 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user