Initial LinkedSign platform
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
|
||||
const env = require('./config/env');
|
||||
const routes = require('./routes');
|
||||
const authRateLimiter = require('./middlewares/authRateLimiter');
|
||||
const notFound = require('./middlewares/notFound');
|
||||
const errorHandler = require('./middlewares/errorHandler');
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 'loopback');
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.corsOrigin,
|
||||
})
|
||||
);
|
||||
app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev'));
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/auth/login', authRateLimiter);
|
||||
app.use(routes);
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,17 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const env = require('./env');
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: env.db.host,
|
||||
port: env.db.port,
|
||||
database: env.db.name,
|
||||
user: env.db.user,
|
||||
password: env.db.password,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
timezone: '+00:00',
|
||||
charset: 'utf8mb4',
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
@@ -0,0 +1,45 @@
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
|
||||
const env = {
|
||||
port: Number(process.env.PORT) || 4000,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
|
||||
db: {
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
name: process.env.DB_NAME || 'signdb',
|
||||
user: process.env.DB_USER || 'SignLinked',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'change_me',
|
||||
accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
|
||||
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
|
||||
},
|
||||
|
||||
storagePath: process.env.STORAGE_PATH || '/tmp/linkedsign_storage',
|
||||
|
||||
p12: {
|
||||
defaultPath: process.env.P12_DEFAULT_PATH || null,
|
||||
defaultPassword: process.env.P12_DEFAULT_PASSWORD || '',
|
||||
},
|
||||
|
||||
mail: {
|
||||
host: process.env.MAIL_HOST || 'smtp.gmail.com',
|
||||
port: Number(process.env.MAIL_PORT) || 587,
|
||||
secure: process.env.MAIL_SECURE === 'true',
|
||||
rejectUnauthorized: process.env.MAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
|
||||
user: process.env.MAIL_USER || '',
|
||||
pass: process.env.MAIL_PASS || '',
|
||||
from: process.env.MAIL_FROM || 'noreply@linkedsign.local',
|
||||
},
|
||||
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
};
|
||||
|
||||
module.exports = env;
|
||||
@@ -0,0 +1,57 @@
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const { getDocumentPath, getCertificatePath } = require('../utils/storagePath');
|
||||
|
||||
const documentStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
try {
|
||||
cb(null, await getDocumentPath());
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const safe = path.basename(file.originalname || 'document.pdf').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${Date.now()}-${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
const certificateStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
try {
|
||||
const userId = req.user?.id || 'unknown';
|
||||
cb(null, await getCertificatePath(userId));
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const safe = path.basename(file.originalname || 'cert.p12').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${Date.now()}-${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadDocument = multer({
|
||||
storage: documentStorage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype !== 'application/pdf') {
|
||||
return cb(new Error('Only PDF files are allowed.'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadCertificate = multer({
|
||||
storage: certificateStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.p12', '.pfx'].includes(ext)) {
|
||||
return cb(new Error('Only P12/PFX certificate files are allowed.'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = { uploadDocument, uploadCertificate };
|
||||
@@ -0,0 +1,15 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const authRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = authRateLimiter;
|
||||
@@ -0,0 +1,19 @@
|
||||
const { verify } = require('../utils/jwt');
|
||||
|
||||
const authenticate = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, data: null, error: 'Authorization token required.' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const decoded = verify(token);
|
||||
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role, name: decoded.name };
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, data: null, error: 'Invalid or expired token.' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = authenticate;
|
||||
@@ -0,0 +1,29 @@
|
||||
const { sendError } = require('../utils/apiResponse');
|
||||
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
const statusCode = err.statusCode || 500;
|
||||
if (statusCode >= 500) {
|
||||
console.error(`[ERROR ${statusCode}]`, err.message);
|
||||
console.error(err.stack?.split('\n').slice(0, 6).join('\n'));
|
||||
}
|
||||
const message = err.code
|
||||
? (err.message || 'Request error')
|
||||
: (statusCode >= 500 ? 'Internal server error' : err.message || 'Request error');
|
||||
|
||||
if (err.code) {
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
data: err.data || null,
|
||||
error: message,
|
||||
code: err.code,
|
||||
});
|
||||
}
|
||||
|
||||
return sendError(res, message, statusCode);
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
@@ -0,0 +1,28 @@
|
||||
const path = require('path');
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
const fileValidator = (req, res, next) => {
|
||||
const file = req.file;
|
||||
|
||||
if (!file) {
|
||||
const error = new Error('Invalid file upload');
|
||||
error.statusCode = 400;
|
||||
return next(error);
|
||||
}
|
||||
|
||||
const extension = path.extname(file.originalname || '').toLowerCase();
|
||||
const isPdfMimeType = file.mimetype === 'application/pdf';
|
||||
const isPdfExtension = extension === '.pdf';
|
||||
const isValidSize = typeof file.size === 'number' && file.size <= MAX_FILE_SIZE;
|
||||
|
||||
if (!isPdfMimeType || !isPdfExtension || !isValidSize) {
|
||||
const error = new Error('Invalid file upload');
|
||||
error.statusCode = 400;
|
||||
return next(error);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = fileValidator;
|
||||
@@ -0,0 +1,7 @@
|
||||
const { sendError } = require('../utils/apiResponse');
|
||||
|
||||
const notFound = (req, res) => {
|
||||
return sendError(res, `Route not found: ${req.method} ${req.originalUrl}`, 404);
|
||||
};
|
||||
|
||||
module.exports = notFound;
|
||||
@@ -0,0 +1,11 @@
|
||||
const auditModel = {
|
||||
id: 'number',
|
||||
type: 'string',
|
||||
userId: 'number|null',
|
||||
email: 'string|null',
|
||||
ip: 'string',
|
||||
userAgent: 'string',
|
||||
timestamp: 'Date',
|
||||
};
|
||||
|
||||
module.exports = auditModel;
|
||||
@@ -0,0 +1,44 @@
|
||||
const db = require('../../config/db');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const saveEvent = async (event) => {
|
||||
try {
|
||||
await db.execute(
|
||||
'INSERT INTO audit_logs (type, user_id, email, ip, user_agent, metadata) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
event.type,
|
||||
event.userId || null,
|
||||
event.email || null,
|
||||
event.ip || null,
|
||||
event.userAgent || null,
|
||||
event.metadata ? JSON.stringify(event.metadata) : null,
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
// Fallback to file log if DB fails
|
||||
try {
|
||||
const logsDir = path.resolve(process.cwd(), 'logs');
|
||||
await fs.mkdir(logsDir, { recursive: true });
|
||||
await fs.appendFile(
|
||||
path.resolve(logsDir, 'audit.log'),
|
||||
`${JSON.stringify({ ...event, _fallback: true })}\n`
|
||||
);
|
||||
} catch {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getEvents = async ({ type, userId, limit = 100, offset = 0 } = {}) => {
|
||||
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
|
||||
const params = [];
|
||||
if (type) { sql += ' AND type = ?'; params.push(type); }
|
||||
if (userId) { sql += ' AND user_id = ?'; params.push(userId); }
|
||||
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
const [rows] = await db.execute(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
module.exports = { saveEvent, getEvents };
|
||||
@@ -0,0 +1,11 @@
|
||||
const auditRepository = require('./audit.repository');
|
||||
|
||||
const logEvent = async ({ type, userId = null, email = null, ip = null, userAgent = null, metadata = null } = {}) => {
|
||||
await auditRepository.saveEvent({ type, userId, email, ip, userAgent, metadata });
|
||||
};
|
||||
|
||||
const getEvents = async (filters) => {
|
||||
return auditRepository.getEvents(filters);
|
||||
};
|
||||
|
||||
module.exports = { logEvent, getEvents };
|
||||
@@ -0,0 +1,90 @@
|
||||
const authService = require('./auth.service');
|
||||
const usersService = require('../users/users.service');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
|
||||
const getContext = (req) => ({
|
||||
ip: req.ip?.replace('::ffff:', '') || null,
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
});
|
||||
|
||||
const register = async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.register(req.body);
|
||||
return sendSuccess(res, {
|
||||
message: 'Registration successful. Please check your email to verify your account.',
|
||||
userId: result.userId,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.login(req.body, getContext(req));
|
||||
return sendSuccess(res, result, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.refresh(req.body.refreshToken);
|
||||
return sendSuccess(res, result, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async (req, res, next) => {
|
||||
try {
|
||||
await authService.logout(req.body.refreshToken);
|
||||
return sendSuccess(res, { message: 'Logged out.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (req, res, next) => {
|
||||
try {
|
||||
await authService.changePassword(req.user.id, req.body, getContext(req));
|
||||
return sendSuccess(res, { message: 'Password change requested. Please confirm the change from the email sent to your account.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPasswordChange = async (req, res, next) => {
|
||||
try {
|
||||
await authService.confirmPasswordChange(req.params.token);
|
||||
return sendSuccess(res, { message: 'Password changed successfully.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyEmail = async (req, res, next) => {
|
||||
try {
|
||||
await authService.verifyEmail(req.params.token);
|
||||
return sendSuccess(res, { message: 'Email verified successfully.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const me = async (req, res, next) => {
|
||||
try {
|
||||
const signaturePreferences = await usersService.getSignaturePreferences(req.user.id);
|
||||
return sendSuccess(res, {
|
||||
user: {
|
||||
...req.user,
|
||||
signaturePreferences,
|
||||
},
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { register, login, refresh, logout, verifyEmail, me, changePassword, confirmPasswordChange };
|
||||
@@ -0,0 +1,115 @@
|
||||
const db = require('../../config/db');
|
||||
|
||||
const findByEmail = async (email) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id, name, email, role, is_verified, is_active, created_at FROM users WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const findAuthUserById = async (id) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const createUser = async ({ name, email, passwordHash, verificationToken, verificationTokenExpires }) => {
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO users (name, email, password_hash, verification_token, verification_token_expires)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[name, email, passwordHash, verificationToken, verificationTokenExpires]
|
||||
);
|
||||
return result.insertId;
|
||||
};
|
||||
|
||||
const verifyEmail = async (token) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id FROM users WHERE verification_token = ? AND verification_token_expires > NOW() AND is_verified = 0',
|
||||
[token]
|
||||
);
|
||||
if (!rows[0]) return null;
|
||||
await db.execute(
|
||||
'UPDATE users SET is_verified = 1, verification_token = NULL, verification_token_expires = NULL WHERE id = ?',
|
||||
[rows[0].id]
|
||||
);
|
||||
return rows[0].id;
|
||||
};
|
||||
|
||||
const updateVerificationToken = async (userId, token, expiresAt) => {
|
||||
await db.execute(
|
||||
'UPDATE users SET verification_token = ?, verification_token_expires = ? WHERE id = ?',
|
||||
[token, expiresAt, userId]
|
||||
);
|
||||
};
|
||||
|
||||
const saveRefreshToken = async (userId, token, expiresAt) => {
|
||||
await db.execute(
|
||||
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
|
||||
[userId, token, expiresAt]
|
||||
);
|
||||
};
|
||||
|
||||
const findRefreshToken = async (token) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT rt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.token = ? AND rt.expires_at > NOW()',
|
||||
[token]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const deleteRefreshToken = async (token) => {
|
||||
await db.execute('DELETE FROM refresh_tokens WHERE token = ?', [token]);
|
||||
};
|
||||
|
||||
const deleteUserRefreshTokens = async (userId) => {
|
||||
await db.execute('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]);
|
||||
};
|
||||
|
||||
const setResetToken = async (userId, token, expiresAt) => {
|
||||
await db.execute(
|
||||
'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
|
||||
[token, expiresAt, userId]
|
||||
);
|
||||
};
|
||||
|
||||
const findByResetToken = async (token) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > NOW()',
|
||||
[token]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const updatePassword = async (userId, passwordHash) => {
|
||||
await db.execute(
|
||||
'UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?',
|
||||
[passwordHash, userId]
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
findByEmail,
|
||||
findById,
|
||||
findAuthUserById,
|
||||
createUser,
|
||||
verifyEmail,
|
||||
updateVerificationToken,
|
||||
saveRefreshToken,
|
||||
findRefreshToken,
|
||||
deleteRefreshToken,
|
||||
deleteUserRefreshTokens,
|
||||
setResetToken,
|
||||
findByResetToken,
|
||||
updatePassword,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const authController = require('./auth.controller');
|
||||
const { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator');
|
||||
const authenticate = require('../../middlewares/authenticate');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/register', validate(registerSchema), authController.register);
|
||||
router.post('/login', validate(loginSchema), authController.login);
|
||||
router.post('/refresh', validate(refreshSchema), authController.refresh);
|
||||
router.post('/logout', authController.logout);
|
||||
router.get('/verify/:token', authController.verifyEmail);
|
||||
router.get('/confirm-password-change/:token', authController.confirmPasswordChange);
|
||||
router.get('/me', authenticate, authController.me);
|
||||
router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,286 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const authRepository = require('./auth.repository');
|
||||
const passwordChangeRepository = require('./password-change.repository');
|
||||
const auditService = require('../audit/audit.service');
|
||||
const usersService = require('../users/users.service');
|
||||
const mailService = require('../mail/mail.service');
|
||||
const { signAccess, signRefresh } = require('../../utils/jwt');
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
const buildDisplayNameFromEmail = (email) => {
|
||||
const localPart = String(email).split('@')[0] || 'Usuario';
|
||||
const normalized = localPart
|
||||
.replace(/[._-]+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
|
||||
return normalized || 'Usuario';
|
||||
};
|
||||
|
||||
const issueVerificationEmail = async (user) => {
|
||||
const verificationToken = uuidv4();
|
||||
const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await authRepository.updateVerificationToken(user.id, verificationToken, verificationTokenExpires);
|
||||
|
||||
const mailResult = await mailService.sendVerificationEmail({
|
||||
name: user.name || buildDisplayNameFromEmail(user.email),
|
||||
email: user.email,
|
||||
verificationToken,
|
||||
});
|
||||
|
||||
return {
|
||||
verificationToken,
|
||||
verificationUrl: mailResult.verificationUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const register = async ({ email, password }) => {
|
||||
const existing = await authRepository.findByEmail(email);
|
||||
if (existing) {
|
||||
const err = new Error('Email already registered.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const name = buildDisplayNameFromEmail(email);
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
const verificationToken = uuidv4();
|
||||
const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const userId = await authRepository.createUser({
|
||||
name,
|
||||
email,
|
||||
passwordHash,
|
||||
verificationToken,
|
||||
verificationTokenExpires,
|
||||
});
|
||||
let mailResult;
|
||||
try {
|
||||
mailResult = await mailService.sendVerificationEmail({
|
||||
name,
|
||||
email,
|
||||
verificationToken,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = new Error('The account was created, but the verification email could not be sent. Please contact support or try again later.');
|
||||
err.statusCode = 503;
|
||||
err.code = 'MAIL_SEND_FAILED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'USER_REGISTERED',
|
||||
userId,
|
||||
email,
|
||||
metadata: {
|
||||
verificationEmailSent: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { userId, verificationToken, verificationUrl: mailResult.verificationUrl };
|
||||
};
|
||||
|
||||
const login = async ({ email, password }, context = {}) => {
|
||||
const user = await authRepository.findByEmail(email.trim().toLowerCase());
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent });
|
||||
const err = new Error('Invalid credentials.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.password_hash);
|
||||
if (!passwordMatch) {
|
||||
await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
|
||||
const err = new Error('Invalid credentials.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!user.is_verified) {
|
||||
let verificationUrl = null;
|
||||
try {
|
||||
({ verificationUrl } = await issueVerificationEmail(user));
|
||||
} catch (error) {
|
||||
const err = new Error('Your email is not verified and the verification email could not be resent. Please contact support.');
|
||||
err.statusCode = 503;
|
||||
err.code = 'MAIL_SEND_FAILED';
|
||||
throw err;
|
||||
}
|
||||
await auditService.logEvent({
|
||||
type: 'VERIFICATION_EMAIL_RESENT',
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: { source: 'login' },
|
||||
});
|
||||
const err = new Error('Your email is not verified yet. A new verification link has been sent to your email.');
|
||||
err.statusCode = 403;
|
||||
err.code = 'EMAIL_NOT_VERIFIED';
|
||||
err.data = { verificationUrl };
|
||||
throw err;
|
||||
}
|
||||
|
||||
const payload = { sub: user.id, email: user.email, role: user.role, name: user.name };
|
||||
const accessToken = signAccess(payload);
|
||||
const refreshToken = signRefresh(payload);
|
||||
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt);
|
||||
|
||||
await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
|
||||
|
||||
const signaturePreferences = await usersService.getSignaturePreferences(user.id);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
signaturePreferences,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const refresh = async (token) => {
|
||||
const record = await authRepository.findRefreshToken(token);
|
||||
if (!record) {
|
||||
const err = new Error('Invalid or expired refresh token.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!record.is_active) {
|
||||
await authRepository.deleteUserRefreshTokens(record.user_id);
|
||||
const err = new Error('Session invalidated.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
await authRepository.deleteRefreshToken(token);
|
||||
|
||||
const payload = { sub: record.uid, email: record.email, role: record.role };
|
||||
const accessToken = signAccess(payload);
|
||||
const newRefreshToken = signRefresh(payload);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt);
|
||||
|
||||
return { accessToken, refreshToken: newRefreshToken };
|
||||
};
|
||||
|
||||
const logout = async (token) => {
|
||||
await authRepository.deleteRefreshToken(token);
|
||||
};
|
||||
|
||||
const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => {
|
||||
const user = await authRepository.findAuthUserById(userId);
|
||||
if (!user || !user.is_active) {
|
||||
const err = new Error('User not found.');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!passwordMatch) {
|
||||
const err = new Error('Current password is incorrect.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const isSamePassword = await bcrypt.compare(newPassword, user.password_hash);
|
||||
if (isSamePassword) {
|
||||
const err = new Error('The new password must be different from the current password.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
|
||||
const confirmationToken = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
await passwordChangeRepository.saveRequest(confirmationToken, {
|
||||
userId,
|
||||
email: user.email,
|
||||
passwordHash,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
await mailService.sendPasswordChangeConfirmationEmail({
|
||||
name: user.name || buildDisplayNameFromEmail(user.email),
|
||||
email: user.email,
|
||||
confirmationToken,
|
||||
});
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'PASSWORD_CHANGE_REQUESTED',
|
||||
userId,
|
||||
email: user.email,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
});
|
||||
|
||||
return { confirmationToken };
|
||||
};
|
||||
|
||||
const confirmPasswordChange = async (token) => {
|
||||
const request = await passwordChangeRepository.findRequest(token);
|
||||
if (!request) {
|
||||
const err = new Error('Invalid or expired password change token.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (new Date(request.expiresAt) < new Date()) {
|
||||
await passwordChangeRepository.deleteRequest(token);
|
||||
const err = new Error('Invalid or expired password change token.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const user = await authRepository.findAuthUserById(request.userId);
|
||||
if (!user || !user.is_active) {
|
||||
await passwordChangeRepository.deleteRequest(token);
|
||||
const err = new Error('User not found.');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
await authRepository.updatePassword(request.userId, request.passwordHash);
|
||||
await passwordChangeRepository.deleteRequest(token);
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'PASSWORD_CHANGED',
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
await mailService.sendPasswordChangedEmail({
|
||||
name: user.name || buildDisplayNameFromEmail(user.email),
|
||||
email: user.email,
|
||||
changedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PASSWORD_CHANGED_EMAIL_FAILED]', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyEmail = async (token) => {
|
||||
const userId = await authRepository.verifyEmail(token);
|
||||
if (!userId) {
|
||||
const err = new Error('Invalid or expired verification token.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
module.exports = { register, login, refresh, logout, verifyEmail, changePassword, confirmPasswordChange };
|
||||
@@ -0,0 +1,48 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const isStrongPassword = (value, helpers) => {
|
||||
const rules = [
|
||||
{ test: value.length >= 8, message: 'at least 8 characters' },
|
||||
{ test: /[A-Z]/.test(value), message: 'one uppercase letter' },
|
||||
{ test: /[a-z]/.test(value), message: 'one lowercase letter' },
|
||||
{ test: /\d/.test(value), message: 'one number' },
|
||||
{ test: /[^A-Za-z0-9]/.test(value), message: 'one special character' },
|
||||
];
|
||||
|
||||
const missing = rules.filter((rule) => !rule.test).map((rule) => rule.message);
|
||||
if (missing.length > 0) {
|
||||
return helpers.message(`Password must include ${missing.join(', ')}.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const registerSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().max(100).required().custom(isStrongPassword),
|
||||
});
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().required(),
|
||||
});
|
||||
|
||||
const refreshSchema = Joi.object({
|
||||
refreshToken: Joi.string().required(),
|
||||
});
|
||||
|
||||
const changePasswordSchema = Joi.object({
|
||||
currentPassword: Joi.string().required(),
|
||||
newPassword: Joi.string().max(100).required().custom(isStrongPassword),
|
||||
});
|
||||
|
||||
const validate = (schema) => (req, res, next) => {
|
||||
const { error } = schema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
const messages = error.details.map((d) => d.message);
|
||||
return res.status(400).json({ success: false, data: null, error: messages.join('; ') });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema };
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const storageDir = path.resolve(process.cwd(), 'storage', 'auth');
|
||||
const storageFile = path.join(storageDir, 'password-change-requests.json');
|
||||
|
||||
const readRequests = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(storageFile, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeRequests = async (data) => {
|
||||
await fs.mkdir(storageDir, { recursive: true });
|
||||
await fs.writeFile(storageFile, JSON.stringify(data, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const saveRequest = async (token, data) => {
|
||||
const current = await readRequests();
|
||||
current[token] = data;
|
||||
await writeRequests(current);
|
||||
};
|
||||
|
||||
const findRequest = async (token) => {
|
||||
const current = await readRequests();
|
||||
return current[token] || null;
|
||||
};
|
||||
|
||||
const deleteRequest = async (token) => {
|
||||
const current = await readRequests();
|
||||
if (!current[token]) return;
|
||||
delete current[token];
|
||||
await writeRequests(current);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
saveRequest,
|
||||
findRequest,
|
||||
deleteRequest,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
const certificatesService = require('./certificates.service');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
|
||||
const getCtx = (req) => ({ ip: req.ip?.replace('::ffff:', ''), userAgent: req.headers['user-agent'] });
|
||||
|
||||
const upload = async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) { const e = new Error('No file uploaded.'); e.statusCode = 400; throw e; }
|
||||
const { password, alias } = req.body;
|
||||
const cert = await certificatesService.upload(req.file, password || '', req.user.id, alias, getCtx(req));
|
||||
return sendSuccess(res, cert, 201);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const list = async (req, res, next) => {
|
||||
try {
|
||||
const certs = await certificatesService.list(req.user.id);
|
||||
return sendSuccess(res, certs, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const getOne = async (req, res, next) => {
|
||||
try {
|
||||
const cert = await certificatesService.getOne(Number(req.params.id), req.user.id);
|
||||
return sendSuccess(res, cert, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const remove = async (req, res, next) => {
|
||||
try {
|
||||
await certificatesService.remove(Number(req.params.id), req.user.id, getCtx(req));
|
||||
return sendSuccess(res, { message: 'Certificate deleted.' }, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
module.exports = { upload, list, getOne, remove };
|
||||
@@ -0,0 +1,78 @@
|
||||
const db = require('../../config/db');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const chainMetadataDir = path.resolve(process.cwd(), 'storage', 'certificates', 'metadata');
|
||||
const chainMetadataFile = path.join(chainMetadataDir, 'chain-status.json');
|
||||
|
||||
const readChainMetadata = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(chainMetadataFile, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeChainMetadata = async (data) => {
|
||||
await fs.mkdir(chainMetadataDir, { recursive: true });
|
||||
await fs.writeFile(chainMetadataFile, JSON.stringify(data, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const create = async ({ userId, alias, filename, storagePath, subjectCn, issuerCn, validFrom, validTo, serialNumber }) => {
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO certificates (user_id, alias, filename, storage_path, subject_cn, issuer_cn, valid_from, valid_to, serial_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, alias, filename, storagePath, subjectCn || null, issuerCn || null, validFrom || null, validTo || null, serialNumber || null]
|
||||
);
|
||||
return result.insertId;
|
||||
};
|
||||
|
||||
const findByUser = async (userId) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id, user_id, alias, filename, subject_cn, issuer_cn, valid_from, valid_to, serial_number, is_active, created_at FROM certificates WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await db.execute('SELECT * FROM certificates WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const deactivate = async (id) => {
|
||||
await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
await db.execute('DELETE FROM certificates WHERE id = ?', [id]);
|
||||
};
|
||||
|
||||
const saveChainStatus = async (certId, metadata) => {
|
||||
const current = await readChainMetadata();
|
||||
current[String(certId)] = metadata;
|
||||
await writeChainMetadata(current);
|
||||
};
|
||||
|
||||
const getChainStatus = async (certId) => {
|
||||
const current = await readChainMetadata();
|
||||
return current[String(certId)] || null;
|
||||
};
|
||||
|
||||
const removeChainStatus = async (certId) => {
|
||||
const current = await readChainMetadata();
|
||||
delete current[String(certId)];
|
||||
await writeChainMetadata(current);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
findByUser,
|
||||
findById,
|
||||
deactivate,
|
||||
remove,
|
||||
saveChainStatus,
|
||||
getChainStatus,
|
||||
removeChainStatus,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const { uploadCertificate } = require('../../config/multer');
|
||||
const authenticate = require('../../middlewares/authenticate');
|
||||
const certificatesController = require('./certificates.controller');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.post('/', uploadCertificate.single('file'), certificatesController.upload);
|
||||
router.get('/', certificatesController.list);
|
||||
router.get('/:id', certificatesController.getOne);
|
||||
router.delete('/:id', certificatesController.remove);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,168 @@
|
||||
const fs = require('fs/promises');
|
||||
const forge = require('node-forge');
|
||||
const certificatesRepository = require('./certificates.repository');
|
||||
const auditService = require('../audit/audit.service');
|
||||
|
||||
const makeError = (msg, status, code) => {
|
||||
const e = new Error(msg);
|
||||
e.statusCode = status;
|
||||
if (code) e.code = code;
|
||||
return e;
|
||||
};
|
||||
|
||||
const tryParsePkcs12 = (pfxBuffer, password) => {
|
||||
const p12Der = forge.util.createBuffer(pfxBuffer.toString('binary'));
|
||||
const p12Asn1 = forge.asn1.fromDer(p12Der);
|
||||
return forge.pkcs12.pkcs12FromAsn1(p12Asn1, password || '');
|
||||
};
|
||||
|
||||
const parseCertificate = (pfxBuffer, password) => {
|
||||
let p12;
|
||||
try {
|
||||
p12 = tryParsePkcs12(pfxBuffer, password);
|
||||
} catch (err) {
|
||||
const msg = err.message || '';
|
||||
if (msg.includes('MAC') || msg.includes('password') || msg.includes('integrity')) {
|
||||
throw makeError('Contraseña incorrecta o requerida para este certificado.', 422, 'PASSWORD_REQUIRED');
|
||||
}
|
||||
throw makeError('No se pudo leer el archivo P12. Verifica que sea un certificado válido.', 400);
|
||||
}
|
||||
|
||||
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
||||
const certificates = certBags[forge.pki.oids.certBag] || [];
|
||||
const cert = certificates[0]?.cert;
|
||||
if (!cert) throw makeError('El archivo P12 no contiene un certificado válido.', 400);
|
||||
|
||||
const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||
const privateKey = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]?.key;
|
||||
if (!privateKey) throw makeError('El archivo P12 no contiene una clave privada.', 400);
|
||||
|
||||
const now = new Date();
|
||||
const validTo = cert.validity.notAfter;
|
||||
const validFrom = cert.validity.notBefore;
|
||||
|
||||
if (now < validFrom) {
|
||||
throw makeError(`El certificado aún no es válido. Válido desde: ${validFrom.toLocaleDateString('es-EC')}.`, 400);
|
||||
}
|
||||
if (now > validTo) {
|
||||
throw makeError(`El certificado está vencido desde el ${validTo.toLocaleDateString('es-EC')}.`, 400);
|
||||
}
|
||||
|
||||
const getAttr = (subject, name) => subject.getField(name)?.value || null;
|
||||
return {
|
||||
subjectCn: getAttr(cert.subject, 'CN'),
|
||||
issuerCn: getAttr(cert.issuer, 'CN'),
|
||||
validFrom,
|
||||
validTo,
|
||||
serialNumber: cert.serialNumber,
|
||||
certificateCount: certificates.length,
|
||||
chainStatus: certificates.length > 1 ? 'complete' : 'leaf_only',
|
||||
};
|
||||
};
|
||||
|
||||
const upload = async (file, password, userId, alias, context = {}) => {
|
||||
const buffer = await fs.readFile(file.path);
|
||||
const info = parseCertificate(buffer, password); // throws with code if password needed
|
||||
|
||||
const certId = await certificatesRepository.create({
|
||||
userId,
|
||||
alias: alias || file.originalname,
|
||||
filename: file.filename,
|
||||
storagePath: file.path,
|
||||
...info,
|
||||
});
|
||||
|
||||
await certificatesRepository.saveChainStatus(certId, {
|
||||
certificateCount: info.certificateCount,
|
||||
chainStatus: info.chainStatus,
|
||||
});
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'CERTIFICATE_UPLOAD',
|
||||
userId,
|
||||
ip: context.ip,
|
||||
metadata: {
|
||||
certId,
|
||||
alias,
|
||||
subject: info.subjectCn,
|
||||
certificateCount: info.certificateCount,
|
||||
chainStatus: info.chainStatus,
|
||||
},
|
||||
});
|
||||
|
||||
const cert = await certificatesRepository.findById(certId);
|
||||
const { storage_path, ...safeFields } = cert;
|
||||
return {
|
||||
...safeFields,
|
||||
certificate_count: info.certificateCount,
|
||||
chain_status: info.chainStatus,
|
||||
};
|
||||
};
|
||||
|
||||
const list = async (userId) => {
|
||||
const certs = await certificatesRepository.findByUser(userId);
|
||||
return Promise.all(
|
||||
certs.map(async (cert) => {
|
||||
const chain = await certificatesRepository.getChainStatus(cert.id);
|
||||
return {
|
||||
...cert,
|
||||
certificate_count: chain?.certificateCount || null,
|
||||
chain_status: chain?.chainStatus || null,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const getOne = async (id, userId) => {
|
||||
const cert = await certificatesRepository.findById(id);
|
||||
if (!cert) throw makeError('Certificate not found.', 404);
|
||||
if (cert.user_id !== userId) throw makeError('Access denied.', 403);
|
||||
const { storage_path, ...safeFields } = cert;
|
||||
const chain = await certificatesRepository.getChainStatus(id);
|
||||
return {
|
||||
...safeFields,
|
||||
certificate_count: chain?.certificateCount || null,
|
||||
chain_status: chain?.chainStatus || null,
|
||||
};
|
||||
};
|
||||
|
||||
const remove = async (id, userId, context = {}) => {
|
||||
const cert = await certificatesRepository.findById(id);
|
||||
if (!cert) throw makeError('Certificate not found.', 404);
|
||||
if (cert.user_id !== userId) throw makeError('Access denied.', 403);
|
||||
try { await fs.unlink(cert.storage_path); } catch { /* already gone */ }
|
||||
await certificatesRepository.remove(id);
|
||||
await certificatesRepository.removeChainStatus(id);
|
||||
await auditService.logEvent({ type: 'CERTIFICATE_DELETE', userId, ip: context.ip, metadata: { certId: id } });
|
||||
};
|
||||
|
||||
const loadForSigning = async (certId, userId, password) => {
|
||||
const cert = await certificatesRepository.findById(certId);
|
||||
if (!cert) throw makeError('Certificate not found.', 404);
|
||||
if (cert.user_id !== userId) throw makeError('Access denied.', 403);
|
||||
|
||||
if (cert.valid_to && new Date() > new Date(cert.valid_to)) {
|
||||
throw makeError(`El certificado "${cert.alias}" está vencido desde el ${new Date(cert.valid_to).toLocaleDateString('es-EC')}.`, 400);
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(cert.storage_path);
|
||||
let p12;
|
||||
try {
|
||||
p12 = tryParsePkcs12(buffer, password);
|
||||
} catch {
|
||||
throw makeError('Contraseña del certificado incorrecta o requerida.', 422, 'CERT_PASSWORD_REQUIRED');
|
||||
}
|
||||
|
||||
const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
||||
const privateKey = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]?.key;
|
||||
const certificate = certBags[forge.pki.oids.certBag]?.[0]?.cert;
|
||||
|
||||
if (!privateKey || !certificate) {
|
||||
throw makeError('No se pudo extraer la clave del certificado. Verifica la contraseña.', 400, 'CERT_PASSWORD_REQUIRED');
|
||||
}
|
||||
|
||||
return { privateKey, certificate };
|
||||
};
|
||||
|
||||
module.exports = { upload, list, getOne, remove, loadForSigning };
|
||||
@@ -0,0 +1,44 @@
|
||||
const documentsService = require('./documents.service');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
|
||||
const getCtx = (req) => ({ ip: req.ip?.replace('::ffff:', ''), userAgent: req.headers['user-agent'] });
|
||||
|
||||
const upload = async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
const err = new Error('No file uploaded.'); err.statusCode = 400; throw err;
|
||||
}
|
||||
const doc = await documentsService.upload(req.file, req.user.id, getCtx(req));
|
||||
return sendSuccess(res, doc, 201);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const list = async (req, res, next) => {
|
||||
try {
|
||||
const result = await documentsService.list(req.user.id, req.query);
|
||||
return sendSuccess(res, result, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const getOne = async (req, res, next) => {
|
||||
try {
|
||||
const doc = await documentsService.getOne(Number(req.params.id), req.user.id);
|
||||
return sendSuccess(res, doc, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const download = async (req, res, next) => {
|
||||
try {
|
||||
const doc = await documentsService.getOne(Number(req.params.id), req.user.id);
|
||||
res.download(doc.storage_path, doc.filename);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const remove = async (req, res, next) => {
|
||||
try {
|
||||
await documentsService.remove(Number(req.params.id), req.user.id, getCtx(req));
|
||||
return sendSuccess(res, { message: 'Document deleted.' }, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
module.exports = { upload, list, getOne, download, remove };
|
||||
@@ -0,0 +1,40 @@
|
||||
const db = require('../../config/db');
|
||||
|
||||
const create = async ({ userId, title, filename, storagePath, fileSize, hashSha256 }) => {
|
||||
const [result] = await db.execute(
|
||||
'INSERT INTO documents (user_id, title, filename, storage_path, file_size, hash_sha256) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[userId, title, filename, storagePath, fileSize, hashSha256 || null]
|
||||
);
|
||||
return result.insertId;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT d.*, u.name as owner_name, u.email as owner_email FROM documents d JOIN users u ON d.user_id = u.id WHERE d.id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const findByUser = async (userId, { limit = 50, offset = 0 } = {}) => {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT * FROM documents WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
|
||||
[userId, limit, offset]
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const countByUser = async (userId) => {
|
||||
const [rows] = await db.execute('SELECT COUNT(*) as total FROM documents WHERE user_id = ?', [userId]);
|
||||
return rows[0].total;
|
||||
};
|
||||
|
||||
const updateStatus = async (id, status) => {
|
||||
await db.execute('UPDATE documents SET status = ? WHERE id = ?', [status, id]);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
await db.execute('DELETE FROM documents WHERE id = ?', [id]);
|
||||
};
|
||||
|
||||
module.exports = { create, findById, findByUser, countByUser, updateStatus, remove };
|
||||
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const { uploadDocument } = require('../../config/multer');
|
||||
const authenticate = require('../../middlewares/authenticate');
|
||||
const documentsController = require('./documents.controller');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.post('/', uploadDocument.single('file'), documentsController.upload);
|
||||
router.get('/', documentsController.list);
|
||||
router.get('/:id', documentsController.getOne);
|
||||
router.get('/:id/download', documentsController.download);
|
||||
router.delete('/:id', documentsController.remove);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,59 @@
|
||||
const fs = require('fs/promises');
|
||||
const crypto = require('crypto');
|
||||
const documentsRepository = require('./documents.repository');
|
||||
const auditService = require('../audit/audit.service');
|
||||
|
||||
const upload = async (file, userId, context = {}) => {
|
||||
const buffer = await fs.readFile(file.path);
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
const title = file.originalname.replace(/\.pdf$/i, '');
|
||||
const docId = await documentsRepository.create({
|
||||
userId,
|
||||
title,
|
||||
filename: file.filename,
|
||||
storagePath: file.path,
|
||||
fileSize: file.size,
|
||||
hashSha256: hash,
|
||||
});
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'DOCUMENT_UPLOAD',
|
||||
userId,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: { docId, filename: file.originalname, size: file.size },
|
||||
});
|
||||
|
||||
return documentsRepository.findById(docId);
|
||||
};
|
||||
|
||||
const list = async (userId, query = {}) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100);
|
||||
const offset = Number(query.offset) || 0;
|
||||
const [rows, total] = await Promise.all([
|
||||
documentsRepository.findByUser(userId, { limit, offset }),
|
||||
documentsRepository.countByUser(userId),
|
||||
]);
|
||||
return { rows, total, limit, offset };
|
||||
};
|
||||
|
||||
const getOne = async (id, userId) => {
|
||||
const doc = await documentsRepository.findById(id);
|
||||
if (!doc) {
|
||||
const err = new Error('Document not found.'); err.statusCode = 404; throw err;
|
||||
}
|
||||
if (doc.user_id !== userId) {
|
||||
const err = new Error('Access denied.'); err.statusCode = 403; throw err;
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
|
||||
const remove = async (id, userId, context = {}) => {
|
||||
const doc = await getOne(id, userId);
|
||||
try { await fs.unlink(doc.storage_path); } catch { /* already deleted */ }
|
||||
await documentsRepository.remove(id);
|
||||
await auditService.logEvent({ type: 'DOCUMENT_DELETE', userId, ip: context.ip, metadata: { docId: id } });
|
||||
};
|
||||
|
||||
module.exports = { upload, list, getOne, remove };
|
||||
@@ -0,0 +1,15 @@
|
||||
const healthService = require('./health.service');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
|
||||
const getHealth = async (req, res, next) => {
|
||||
try {
|
||||
const result = await healthService.getHealth();
|
||||
return sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getHealth,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
const getStatus = async () => {
|
||||
return {
|
||||
status: 'ok',
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const healthController = require('./health.controller');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', healthController.getHealth);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,14 @@
|
||||
const healthRepository = require('./health.repository');
|
||||
|
||||
const getHealth = async () => {
|
||||
const baseStatus = await healthRepository.getStatus();
|
||||
|
||||
return {
|
||||
...baseStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getHealth,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const env = require('../../config/env');
|
||||
|
||||
let transporter;
|
||||
|
||||
const getTransporter = () => {
|
||||
if (transporter) return transporter;
|
||||
|
||||
if (env.mail.user && env.mail.pass) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: env.mail.host,
|
||||
port: env.mail.port,
|
||||
secure: env.mail.secure,
|
||||
auth: {
|
||||
user: env.mail.user,
|
||||
pass: env.mail.pass,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: env.mail.rejectUnauthorized,
|
||||
},
|
||||
});
|
||||
return transporter;
|
||||
}
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
streamTransport: true,
|
||||
newline: 'unix',
|
||||
buffer: true,
|
||||
});
|
||||
|
||||
return transporter;
|
||||
};
|
||||
|
||||
const sendMail = async ({ to, subject, html, text }) => {
|
||||
const activeTransporter = getTransporter();
|
||||
const info = await activeTransporter.sendMail({
|
||||
from: env.mail.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
return info;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
const env = require('../../config/env');
|
||||
const mailRepository = require('./mail.repository');
|
||||
|
||||
const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`;
|
||||
const subject = 'Confirma tu cuenta de LinkedSign';
|
||||
const text = [
|
||||
`Hola ${name},`,
|
||||
'',
|
||||
'Confirma tu cuenta de LinkedSign usando este enlace:',
|
||||
verificationUrl,
|
||||
'',
|
||||
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
|
||||
].join('\n');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2>
|
||||
<p>Hola ${name},</p>
|
||||
<p>Para activar tu cuenta, confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${verificationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
|
||||
Confirmar cuenta
|
||||
</a>
|
||||
</p>
|
||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
|
||||
<p><a href="${verificationUrl}">${verificationUrl}</a></p>
|
||||
<p>Si no solicitaste esta cuenta, puedes ignorar este mensaje.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = await mailRepository.sendMail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
if (!env.mail.user || !env.mail.pass) {
|
||||
console.log('[mail] SMTP no configurado. Enlace de verificación:');
|
||||
console.log(verificationUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
verificationUrl,
|
||||
messageId: info.messageId || null,
|
||||
};
|
||||
};
|
||||
|
||||
const sendPasswordChangedEmail = async ({ name, email, changedAt }) => {
|
||||
const subject = 'Tu contraseña de LinkedSign fue actualizada';
|
||||
const formattedDate = changedAt.toLocaleString('es-EC');
|
||||
const text = [
|
||||
`Hola ${name},`,
|
||||
'',
|
||||
'Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.',
|
||||
`Fecha: ${formattedDate}`,
|
||||
'',
|
||||
'Si no realizaste este cambio, contacta soporte inmediatamente.',
|
||||
].join('\n');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Contraseña actualizada</h2>
|
||||
<p>Hola ${name},</p>
|
||||
<p>Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.</p>
|
||||
<p><strong>Fecha:</strong> ${formattedDate}</p>
|
||||
<p>Si no realizaste este cambio, contacta soporte inmediatamente.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = await mailRepository.sendMail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: info.messageId || null,
|
||||
};
|
||||
};
|
||||
|
||||
const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationToken }) => {
|
||||
const confirmationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/confirm-password-change/${confirmationToken}`;
|
||||
const subject = 'Confirma el cambio de contraseña de LinkedSign';
|
||||
const text = [
|
||||
`Hola ${name},`,
|
||||
'',
|
||||
'Recibimos una solicitud para cambiar la contraseña de tu cuenta.',
|
||||
'Para aplicar el cambio, confirma la solicitud usando este enlace:',
|
||||
confirmationUrl,
|
||||
'',
|
||||
'Si no realizaste esta solicitud, ignora este mensaje.',
|
||||
].join('\n');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Confirma el cambio de contraseña</h2>
|
||||
<p>Hola ${name},</p>
|
||||
<p>Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.</p>
|
||||
<p>Para aplicar el cambio, confirma la solicitud usando este botón:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${confirmationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
|
||||
Confirmar cambio de contraseña
|
||||
</a>
|
||||
</p>
|
||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
|
||||
<p><a href="${confirmationUrl}">${confirmationUrl}</a></p>
|
||||
<p>Si no realizaste esta solicitud, ignora este mensaje.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = await mailRepository.sendMail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
return {
|
||||
confirmationUrl,
|
||||
messageId: info.messageId || null,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendVerificationEmail,
|
||||
sendPasswordChangedEmail,
|
||||
sendPasswordChangeConfirmationEmail,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
const signaturesService = require('./signatures.service');
|
||||
const signaturesRepository = require('./signatures.repository');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
|
||||
const getCtx = (req) => ({
|
||||
ip: req.ip?.replace('::ffff:', ''),
|
||||
userAgent: req.headers['user-agent'],
|
||||
userName: req.user?.name || req.user?.email,
|
||||
userEmail: req.user?.email || null,
|
||||
frontendUrl: req.headers.origin || null,
|
||||
});
|
||||
|
||||
const sign = async (req, res, next) => {
|
||||
try {
|
||||
const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 } = req.body;
|
||||
const docId = Number(documentId);
|
||||
const certId = Number(certificateId);
|
||||
if (!docId || !certId || isNaN(docId) || isNaN(certId)) {
|
||||
const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e;
|
||||
}
|
||||
const result = await signaturesService.sign(
|
||||
{ documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 },
|
||||
req.user.id,
|
||||
getCtx(req)
|
||||
);
|
||||
return sendSuccess(res, result, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const getSignatures = async (req, res, next) => {
|
||||
try {
|
||||
const sigs = await signaturesService.getSignatures(Number(req.params.documentId), req.user.id);
|
||||
return sendSuccess(res, sigs, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const listSignedDocuments = async (req, res, next) => {
|
||||
try {
|
||||
const rows = await signaturesService.listSignedDocuments(req.user.id);
|
||||
return sendSuccess(res, rows, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const verify = async (req, res, next) => {
|
||||
try {
|
||||
const result = await signaturesService.verifySignature(req.params.code);
|
||||
return sendSuccess(res, result, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const download = async (req, res, next) => {
|
||||
try {
|
||||
const sig = await signaturesRepository.findById(Number(req.params.id));
|
||||
if (!sig) { const e = new Error('Signature not found.'); e.statusCode = 404; throw e; }
|
||||
if (sig.user_id !== req.user.id) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
|
||||
res.download(sig.storage_path, sig.signed_filename);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
const remove = async (req, res, next) => {
|
||||
try {
|
||||
await signaturesService.removeSignedDocument(Number(req.params.id), req.user.id, getCtx(req));
|
||||
return sendSuccess(res, { message: 'Signed document deleted.' }, 200);
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
|
||||
module.exports = { sign, getSignatures, listSignedDocuments, verify, download, remove };
|
||||
@@ -0,0 +1,114 @@
|
||||
const db = require('../../config/db');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const verificationDir = path.resolve(process.cwd(), 'storage', 'signatures');
|
||||
const verificationFile = path.join(verificationDir, 'verification-codes.json');
|
||||
|
||||
const readVerificationMap = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(verificationFile, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeVerificationMap = async (data) => {
|
||||
await fs.mkdir(verificationDir, { recursive: true });
|
||||
await fs.writeFile(verificationFile, JSON.stringify(data, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const create = async ({ documentId, userId, certificateId, signedFilename, storagePath, reason, location }) => {
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO signatures (document_id, user_id, certificate_id, signed_filename, storage_path, reason, location)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[documentId, userId, certificateId, signedFilename, storagePath, reason || null, location || null]
|
||||
);
|
||||
return result.insertId;
|
||||
};
|
||||
|
||||
const findByDocument = async (documentId) => {
|
||||
const [rows] = await db.execute(
|
||||
`SELECT s.*, u.name as signer_name, u.email as signer_email, c.alias as cert_alias
|
||||
FROM signatures s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
JOIN certificates c ON s.certificate_id = c.id
|
||||
WHERE s.document_id = ?
|
||||
ORDER BY s.signed_at ASC`,
|
||||
[documentId]
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await db.execute('SELECT * FROM signatures WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const findByUser = async (userId) => {
|
||||
const [rows] = await db.execute(
|
||||
`SELECT
|
||||
s.id,
|
||||
s.document_id,
|
||||
s.user_id,
|
||||
s.certificate_id,
|
||||
s.signed_filename,
|
||||
s.storage_path,
|
||||
s.reason,
|
||||
s.location,
|
||||
s.signed_at,
|
||||
d.title AS document_title,
|
||||
d.filename AS original_filename,
|
||||
c.alias AS certificate_alias
|
||||
FROM signatures s
|
||||
JOIN documents d ON s.document_id = d.id
|
||||
JOIN certificates c ON s.certificate_id = c.id
|
||||
WHERE s.user_id = ?
|
||||
ORDER BY s.signed_at DESC, s.id DESC`,
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const saveVerificationRecord = async (code, data) => {
|
||||
const current = await readVerificationMap();
|
||||
current[code] = data;
|
||||
await writeVerificationMap(current);
|
||||
};
|
||||
|
||||
const findVerificationRecord = async (code) => {
|
||||
const current = await readVerificationMap();
|
||||
return current[code] || null;
|
||||
};
|
||||
|
||||
const deleteVerificationRecordBySignatureId = async (signatureId) => {
|
||||
const current = await readVerificationMap();
|
||||
let changed = false;
|
||||
|
||||
Object.keys(current).forEach((code) => {
|
||||
if (current[code]?.signatureId === signatureId) {
|
||||
delete current[code];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
await writeVerificationMap(current);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
await db.execute('DELETE FROM signatures WHERE id = ?', [id]);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
findByDocument,
|
||||
findById,
|
||||
findByUser,
|
||||
saveVerificationRecord,
|
||||
findVerificationRecord,
|
||||
deleteVerificationRecordBySignatureId,
|
||||
remove,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const authenticate = require('../../middlewares/authenticate');
|
||||
const signaturesController = require('./signatures.controller');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/verify/:code', signaturesController.verify);
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.post('/sign', signaturesController.sign);
|
||||
router.get('/mine', signaturesController.listSignedDocuments);
|
||||
router.get('/document/:documentId', signaturesController.getSignatures);
|
||||
router.get('/:id/download', signaturesController.download);
|
||||
router.delete('/:id', signaturesController.remove);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,200 @@
|
||||
const fs = require('fs/promises');
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const signaturesRepository = require('./signatures.repository');
|
||||
const documentsRepository = require('../documents/documents.repository');
|
||||
const certificatesRepository = require('../certificates/certificates.repository');
|
||||
const certificatesService = require('../certificates/certificates.service');
|
||||
const { signPdf } = require('../signing/signing.service');
|
||||
const auditService = require('../audit/audit.service');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, userId, context = {}) => {
|
||||
const doc = await documentsRepository.findById(documentId);
|
||||
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
|
||||
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
|
||||
if (doc.status === 'signed') { const e = new Error('Document already signed.'); e.statusCode = 409; throw e; }
|
||||
|
||||
const cert = await certificatesRepository.findById(certificateId);
|
||||
if (!cert) { const e = new Error('Certificate not found.'); e.statusCode = 404; throw e; }
|
||||
if (cert.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
|
||||
|
||||
if (cert.valid_to && new Date() > new Date(cert.valid_to)) {
|
||||
const e = new Error(`El certificado "${cert.alias}" está vencido.`); e.statusCode = 400; throw e;
|
||||
}
|
||||
|
||||
// Load p12 buffer — throws CERT_PASSWORD_REQUIRED if wrong/missing password
|
||||
// We need the raw buffer for signing; re-use loadForSigning just to validate password
|
||||
await certificatesService.loadForSigning(certificateId, userId, certPassword);
|
||||
const p12Buffer = await fs.readFile(cert.storage_path);
|
||||
|
||||
const signerName = cert.subject_cn || context.userName || 'Firmante';
|
||||
const verificationCode = uuidv4();
|
||||
const frontendBaseUrl = (context.frontendUrl || env.frontendUrl || '').replace(/\/$/, '');
|
||||
const verificationUrl = `${frontendBaseUrl}/verify/${verificationCode}`;
|
||||
|
||||
let signedFilename, signedPath;
|
||||
try {
|
||||
({ signedFilename, signedPath } = await signPdf({
|
||||
pdfPath: doc.storage_path,
|
||||
p12Buffer,
|
||||
p12Password: certPassword || '',
|
||||
signerName,
|
||||
reason: reason || 'Firmado digitalmente',
|
||||
location: location || '',
|
||||
page: page || 0,
|
||||
rect: rect || null,
|
||||
signatureStyle: signatureStyle || 'classic',
|
||||
qrPosition: qrPosition || 'left',
|
||||
qrBackground: qrBackground || 'transparent',
|
||||
qrBorder: qrBorder || 'none',
|
||||
signatureImageBase64: signatureImageBase64 || null,
|
||||
verificationUrl,
|
||||
}));
|
||||
} catch (signErr) {
|
||||
const msg = signErr.message || '';
|
||||
// Re-map known signing errors
|
||||
if (msg.includes('MAC') || msg.includes('password') || msg.includes('passphrase')) {
|
||||
const e = new Error('Contraseña del certificado incorrecta.');
|
||||
e.statusCode = 422; e.code = 'CERT_PASSWORD_REQUIRED'; throw e;
|
||||
}
|
||||
if (msg.includes('encrypt') || msg.includes('decrypt') || msg.includes('permission')) {
|
||||
const e = new Error('El PDF está protegido y no puede firmarse.'); e.statusCode = 400; throw e;
|
||||
}
|
||||
// Unknown error — re-throw with detail
|
||||
const e = new Error(`Error al firmar el documento: ${msg}`); e.statusCode = 500; throw e;
|
||||
}
|
||||
|
||||
const sigId = await signaturesRepository.create({
|
||||
documentId,
|
||||
userId,
|
||||
certificateId,
|
||||
signedFilename,
|
||||
storagePath: signedPath,
|
||||
reason,
|
||||
location,
|
||||
});
|
||||
|
||||
const signedBuffer = await fs.readFile(signedPath);
|
||||
const signedHashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex');
|
||||
|
||||
await signaturesRepository.saveVerificationRecord(verificationCode, {
|
||||
signatureId: sigId,
|
||||
documentId,
|
||||
certificateId,
|
||||
userId,
|
||||
verificationCode,
|
||||
verificationUrl,
|
||||
signerName,
|
||||
signerEmail: context.userEmail || null,
|
||||
certificateSubjectCn: cert.subject_cn || null,
|
||||
certificateIssuerCn: cert.issuer_cn || null,
|
||||
signedFilename,
|
||||
signedPath,
|
||||
signedHashSha256,
|
||||
reason: reason || 'Firmado digitalmente',
|
||||
location: location || '',
|
||||
signedAt: new Date().toISOString(),
|
||||
documentTitle: doc.title,
|
||||
});
|
||||
|
||||
await documentsRepository.updateStatus(documentId, 'signed');
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'DOCUMENT_SIGNED',
|
||||
userId,
|
||||
ip: context.ip,
|
||||
metadata: { documentId, certificateId, sigId },
|
||||
});
|
||||
|
||||
return { sigId, signedFilename, documentId, verificationCode, verificationUrl };
|
||||
};
|
||||
|
||||
const getSignatures = async (documentId, userId) => {
|
||||
const doc = await documentsRepository.findById(documentId);
|
||||
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
|
||||
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
|
||||
return signaturesRepository.findByDocument(documentId);
|
||||
};
|
||||
|
||||
const listSignedDocuments = async (userId) => {
|
||||
return signaturesRepository.findByUser(userId);
|
||||
};
|
||||
|
||||
const verifySignature = async (verificationCode) => {
|
||||
const record = await signaturesRepository.findVerificationRecord(verificationCode);
|
||||
if (!record) {
|
||||
const error = new Error('Verification record not found.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const signature = await signaturesRepository.findById(record.signatureId);
|
||||
if (!signature) {
|
||||
const error = new Error('Signature not found.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const signedBuffer = await fs.readFile(record.signedPath);
|
||||
const currentHashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex');
|
||||
const isValid = currentHashSha256 === record.signedHashSha256;
|
||||
|
||||
return {
|
||||
isValid,
|
||||
verificationCode: record.verificationCode,
|
||||
verificationUrl: record.verificationUrl,
|
||||
signerName: record.signerName,
|
||||
signerEmail: record.signerEmail,
|
||||
certificateSubjectCn: record.certificateSubjectCn,
|
||||
certificateIssuerCn: record.certificateIssuerCn,
|
||||
signedFilename: record.signedFilename,
|
||||
documentTitle: record.documentTitle,
|
||||
reason: record.reason,
|
||||
location: record.location,
|
||||
signedAt: record.signedAt,
|
||||
integrity: {
|
||||
expectedHashSha256: record.signedHashSha256,
|
||||
currentHashSha256,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const removeSignedDocument = async (signatureId, userId, context = {}) => {
|
||||
const signature = await signaturesRepository.findById(signatureId);
|
||||
if (!signature) {
|
||||
const error = new Error('Signature not found.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (signature.user_id !== userId) {
|
||||
const error = new Error('Access denied.');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const document = await documentsRepository.findById(signature.document_id);
|
||||
|
||||
try { await fs.unlink(signature.storage_path); } catch {}
|
||||
await signaturesRepository.deleteVerificationRecordBySignatureId(signatureId);
|
||||
await signaturesRepository.remove(signatureId);
|
||||
|
||||
if (document && document.user_id === userId) {
|
||||
try { await fs.unlink(document.storage_path); } catch {}
|
||||
await documentsRepository.remove(document.id);
|
||||
}
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'SIGNED_DOCUMENT_DELETE',
|
||||
userId,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: {
|
||||
signatureId,
|
||||
documentId: signature.document_id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { sign, getSignatures, listSignedDocuments, verifySignature, removeSignedDocument };
|
||||
@@ -0,0 +1,313 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const QRCode = require('qrcode');
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
const { pdflibAddPlaceholder } = require('@signpdf/placeholder-pdf-lib');
|
||||
const { SignPdf } = require('@signpdf/signpdf');
|
||||
const { P12Signer } = require('@signpdf/signer-p12');
|
||||
const { getSignedDocumentPath } = require('../../utils/storagePath');
|
||||
|
||||
// ── Color helpers ──────────────────────────────────────────────────────────────
|
||||
const C = {
|
||||
primary: rgb(0.31, 0.27, 0.90),
|
||||
dark: rgb(0.10, 0.10, 0.30),
|
||||
gray: rgb(0.35, 0.35, 0.45),
|
||||
lightGray: rgb(0.60, 0.60, 0.65),
|
||||
bgBlue: rgb(0.95, 0.97, 1.00),
|
||||
bgDark: rgb(0.12, 0.14, 0.25),
|
||||
bgGray: rgb(0.96, 0.96, 0.97),
|
||||
white: rgb(1.00, 1.00, 1.00),
|
||||
accent: rgb(0.20, 0.60, 0.85),
|
||||
green: rgb(0.10, 0.55, 0.30),
|
||||
bgGreen: rgb(0.93, 0.98, 0.94),
|
||||
};
|
||||
|
||||
// ── Shared text truncation ─────────────────────────────────────────────────────
|
||||
function truncate(text, font, size, maxWidth) {
|
||||
if (!text) return '';
|
||||
const ellipsis = '...';
|
||||
if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
|
||||
let t = text;
|
||||
while (t.length > 0 && font.widthOfTextAtSize(t + ellipsis, size) > maxWidth) t = t.slice(0, -1);
|
||||
return t + ellipsis;
|
||||
}
|
||||
|
||||
function getSignatureWidth(style, signerName = '') {
|
||||
if (style !== 'qr') return 200;
|
||||
const normalizedName = String(signerName).trim();
|
||||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||||
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
||||
}
|
||||
|
||||
// ── Style: CLASSIC (blue bar + DS badge) ──────────────────────────────────────
|
||||
async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
|
||||
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue, borderColor: C.primary, borderWidth: 1 });
|
||||
page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.primary });
|
||||
|
||||
const nameSize = signerName.length > 24 ? 7 : 8.5;
|
||||
page.drawText('DS', { x: sx + 7, y: sy + sh - 18, size: 9, font: f1, color: C.primary });
|
||||
page.drawText(truncate(signerName, f1, nameSize, sw - 30), { x: sx + 22, y: sy + sh - 17, size: nameSize, font: f1, color: C.dark });
|
||||
if (reason) page.drawText(`Razon: ${truncate(reason, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 30, size: 7, font: f2, color: C.gray });
|
||||
page.drawText(`Fecha: ${dateStr} ${timeStr}`, { x: sx + 8, y: sy + sh - 41, size: 7, font: f2, color: C.gray });
|
||||
if (location) page.drawText(`Lugar: ${truncate(location, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 52, size: 7, font: f2, color: C.gray });
|
||||
page.drawText('Firma digital', { x: sx + sw - 54, y: sy + 4, size: 6, font: f2, color: C.lightGray });
|
||||
}
|
||||
|
||||
// ── Style: QR CODE ─────────────────────────────────────────────────────────────
|
||||
async function drawStyleQR(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, verificationUrl, qrPosition, qrBackground, qrBorder }) {
|
||||
const f1 = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const useSoftBackground = qrBackground === 'soft';
|
||||
const useBorder = qrBorder === 'line';
|
||||
|
||||
const qrContent = verificationUrl || [
|
||||
'FIRMA DIGITAL VERIFICADA',
|
||||
`Firmante: ${signerName}`,
|
||||
reason ? `Razon: ${reason}` : '',
|
||||
`Fecha: ${dateStr} ${timeStr}`,
|
||||
location ? `Lugar: ${location}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
// Generate QR as PNG buffer
|
||||
const qrPng = await QRCode.toBuffer(qrContent, {
|
||||
type: 'png',
|
||||
width: 100,
|
||||
margin: 1,
|
||||
color: { dark: '#1f1b6e', light: useSoftBackground ? '#f2f4ff' : '#0000' },
|
||||
});
|
||||
|
||||
const qrImage = await pdfDoc.embedPng(qrPng);
|
||||
const qrSize = Math.min(sh - 10, 44);
|
||||
const qrOnRight = qrPosition === 'right';
|
||||
const textPadding = 6;
|
||||
const textX = qrOnRight ? sx + textPadding : sx + qrSize + 6;
|
||||
const textW = sw - qrSize - 10;
|
||||
|
||||
// Background
|
||||
if (useSoftBackground) {
|
||||
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue });
|
||||
}
|
||||
if (useBorder) {
|
||||
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, borderColor: C.primary, borderWidth: 1 });
|
||||
}
|
||||
const qrX = qrOnRight ? sx + sw - qrSize - 2 : sx + 2;
|
||||
if (useSoftBackground) {
|
||||
page.drawRectangle({ x: qrOnRight ? sx + sw - qrSize - 4 : sx, y: sy, width: qrSize + 4, height: sh, color: C.bgBlue });
|
||||
}
|
||||
page.drawImage(qrImage, { x: qrX, y: sy + 4, width: qrSize, height: qrSize });
|
||||
|
||||
const dividerX = qrOnRight ? sx + sw - qrSize - 4 : textX - 2;
|
||||
page.drawLine({ start: { x: dividerX, y: sy + 4 }, end: { x: dividerX, y: sy + sh - 4 }, thickness: 0.5, color: C.primary, opacity: 0.4 });
|
||||
|
||||
// Text on right
|
||||
const nameSize = signerName.length > 26 ? 6.8 : 7.6;
|
||||
page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 13, size: nameSize, font: f1, color: C.dark });
|
||||
if (reason) page.drawText(truncate(reason, f2, 6.2, textW), { x: textX, y: sy + sh - 23, size: 6.2, font: f2, color: C.gray });
|
||||
page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 33, size: 6.2, font: f2, color: C.gray });
|
||||
page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 43, size: 6.2, font: f2, color: C.gray });
|
||||
if (location) page.drawText(truncate(location, f2, 5.6, textW), { x: textX, y: sy + sh - 52, size: 5.6, font: f2, color: C.lightGray });
|
||||
page.drawText('Escanee el QR para verificar', { x: sx + 2, y: sy + 2, size: 5, font: f2, color: C.lightGray });
|
||||
}
|
||||
|
||||
// ── Style: PROFESSIONAL (dark header + fields) ────────────────────────────────
|
||||
async function drawStyleProfessional(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
|
||||
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
const headerH = 16;
|
||||
|
||||
// Background
|
||||
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgGray, borderColor: rgb(0.70, 0.72, 0.78), borderWidth: 0.75 });
|
||||
// Dark header bar
|
||||
page.drawRectangle({ x: sx, y: sy + sh - headerH, width: sw, height: headerH, color: C.bgDark });
|
||||
|
||||
page.drawText('FIRMA DIGITAL', { x: sx + 6, y: sy + sh - headerH + 4, size: 7.5, font: f1, color: C.white });
|
||||
page.drawText('Firmado electronicamente', { x: sx + sw - 88, y: sy + sh - headerH + 4, size: 5.5, font: f2, color: rgb(0.7, 0.7, 0.85) });
|
||||
|
||||
// Separator line below header
|
||||
page.drawLine({ start: { x: sx, y: sy + sh - headerH - 1 }, end: { x: sx + sw, y: sy + sh - headerH - 1 }, thickness: 0.5, color: C.primary, opacity: 0.3 });
|
||||
|
||||
// Signer name (prominent)
|
||||
const nameSize = signerName.length > 28 ? 7.5 : 9;
|
||||
page.drawText(truncate(signerName, f1, nameSize, sw - 10), { x: sx + 6, y: sy + sh - headerH - 14, size: nameSize, font: f1, color: C.dark });
|
||||
|
||||
// Separator line
|
||||
const lineY = sy + sh - headerH - 20;
|
||||
page.drawLine({ start: { x: sx + 4, y: lineY }, end: { x: sx + sw - 4, y: lineY }, thickness: 0.4, color: C.lightGray, opacity: 0.6 });
|
||||
|
||||
// Fields in two columns
|
||||
const col1X = sx + 6;
|
||||
const col2X = sx + sw / 2 + 2;
|
||||
const fieldW = sw / 2 - 10;
|
||||
|
||||
page.drawText('Razon:', { x: col1X, y: lineY - 11, size: 5.5, font: f1, color: C.gray });
|
||||
page.drawText(truncate(reason || 'Firmado digitalmente', f2, 6, fieldW), { x: col1X, y: lineY - 20, size: 6, font: f2, color: C.dark });
|
||||
|
||||
page.drawText('Fecha:', { x: col2X, y: lineY - 11, size: 5.5, font: f1, color: C.gray });
|
||||
page.drawText(`${dateStr}`, { x: col2X, y: lineY - 20, size: 6, font: f2, color: C.dark });
|
||||
page.drawText(`${timeStr}`, { x: col2X, y: lineY - 28, size: 6, font: f2, color: C.dark });
|
||||
|
||||
if (location) {
|
||||
page.drawText('Lugar:', { x: col1X, y: lineY - 30, size: 5.5, font: f1, color: C.gray });
|
||||
page.drawText(truncate(location, f2, 6, fieldW), { x: col1X, y: lineY - 39, size: 6, font: f2, color: C.dark });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Style: IMAGE (custom handwritten image + info) ────────────────────────────
|
||||
async function drawStyleImage(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, signatureImageBase64 }) {
|
||||
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
// Background
|
||||
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.white, borderColor: rgb(0.75, 0.75, 0.80), borderWidth: 0.75 });
|
||||
page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.green });
|
||||
|
||||
const imgAreaW = sw * 0.45;
|
||||
const imgAreaH = sh - 10;
|
||||
|
||||
if (signatureImageBase64) {
|
||||
try {
|
||||
// Strip data URL prefix if present
|
||||
const base64Data = signatureImageBase64.replace(/^data:image\/\w+;base64,/, '');
|
||||
const imgBuf = Buffer.from(base64Data, 'base64');
|
||||
|
||||
let embeddedImg;
|
||||
try {
|
||||
embeddedImg = await pdfDoc.embedPng(imgBuf);
|
||||
} catch {
|
||||
embeddedImg = await pdfDoc.embedJpg(imgBuf);
|
||||
}
|
||||
|
||||
const scale = Math.min(imgAreaW / embeddedImg.width, imgAreaH / embeddedImg.height);
|
||||
const drawW = embeddedImg.width * scale;
|
||||
const drawH = embeddedImg.height * scale;
|
||||
page.drawImage(embeddedImg, {
|
||||
x: sx + 6 + (imgAreaW - drawW) / 2,
|
||||
y: sy + (sh - drawH) / 2,
|
||||
width: drawW, height: drawH,
|
||||
});
|
||||
} catch { /* ignore embed errors */ }
|
||||
} else {
|
||||
// Placeholder if no image provided
|
||||
page.drawRectangle({ x: sx + 6, y: sy + 8, width: imgAreaW, height: imgAreaH, color: C.bgGray, borderColor: rgb(0.8, 0.8, 0.85), borderWidth: 0.5 });
|
||||
page.drawText('Firma', { x: sx + 6 + imgAreaW / 2 - 10, y: sy + sh / 2 - 4, size: 7, font: f2, color: C.lightGray });
|
||||
}
|
||||
|
||||
// Vertical divider
|
||||
const divX = sx + imgAreaW + 8;
|
||||
page.drawLine({ start: { x: divX, y: sy + 4 }, end: { x: divX, y: sy + sh - 4 }, thickness: 0.5, color: rgb(0.8, 0.8, 0.85) });
|
||||
|
||||
// Text right side
|
||||
const textX = divX + 5;
|
||||
const textW = sw - imgAreaW - 16;
|
||||
const nameSize = signerName.length > 20 ? 7 : 8.5;
|
||||
page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 14, size: nameSize, font: f1, color: C.dark });
|
||||
page.drawLine({ start: { x: textX, y: sy + sh - 18 }, end: { x: textX + textW, y: sy + sh - 18 }, thickness: 0.4, color: C.green, opacity: 0.5 });
|
||||
if (reason) page.drawText(truncate(reason, f2, 6.5, textW), { x: textX, y: sy + sh - 29, size: 6.5, font: f2, color: C.gray });
|
||||
page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 39, size: 6.5, font: f2, color: C.gray });
|
||||
page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 49, size: 6.5, font: f2, color: C.gray });
|
||||
if (location) page.drawText(truncate(location, f2, 6, textW), { x: textX, y: sy + sh - 59, size: 6, font: f2, color: C.lightGray });
|
||||
}
|
||||
|
||||
// ── Main sign function ─────────────────────────────────────────────────────────
|
||||
const signPdf = async ({
|
||||
pdfPath, p12Buffer, p12Password,
|
||||
signerName, reason = '', location = '',
|
||||
page = 0, rect = null,
|
||||
signatureStyle = 'classic', signatureImageBase64 = null,
|
||||
qrPosition = 'left',
|
||||
qrBackground = 'transparent',
|
||||
qrBorder = 'none',
|
||||
verificationUrl = null,
|
||||
}) => {
|
||||
const pdfBytes = await fs.readFile(pdfPath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
||||
const pages = pdfDoc.getPages();
|
||||
const tp = pages[Math.min(page, pages.length - 1)];
|
||||
const { width: pw, height: ph } = tp.getSize();
|
||||
const cropBox = typeof tp.getCropBox === 'function'
|
||||
? tp.getCropBox()
|
||||
: { x: 0, y: 0, width: pw, height: ph };
|
||||
|
||||
const defaultWidth = getSignatureWidth(signatureStyle, signerName);
|
||||
const requestedRect = rect || {
|
||||
x: cropBox.width - (defaultWidth + 22),
|
||||
y: 18,
|
||||
width: defaultWidth,
|
||||
height: 65,
|
||||
};
|
||||
const resolvedWidth = requestedRect.width || defaultWidth;
|
||||
const resolvedX = Math.min(
|
||||
Math.max(requestedRect.x, 0),
|
||||
Math.max(0, cropBox.width - resolvedWidth - 4)
|
||||
);
|
||||
const resolvedY = Math.min(
|
||||
Math.max(requestedRect.y, 0),
|
||||
Math.max(0, cropBox.height - (requestedRect.height || 65) - 4)
|
||||
);
|
||||
const sigRect = {
|
||||
...requestedRect,
|
||||
x: resolvedX + cropBox.x,
|
||||
y: resolvedY + cropBox.y,
|
||||
width: resolvedWidth,
|
||||
};
|
||||
const { x: sx, y: sy, width: sw, height: sh } = sigRect;
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
|
||||
const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder };
|
||||
|
||||
// ── Draw visual stamp on page content stream ─────────────────────
|
||||
const style = signatureStyle || 'classic';
|
||||
if (style === 'qr') await drawStyleQR(pdfDoc, tp, sx, sy, sw, sh, info);
|
||||
else if (style === 'professional') await drawStyleProfessional(pdfDoc, tp, sx, sy, sw, sh, info);
|
||||
else if (style === 'image') await drawStyleImage(pdfDoc, tp, sx, sy, sw, sh, info);
|
||||
else await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info);
|
||||
|
||||
// ── Save page with visual stamp ───────────────────────────────────
|
||||
const pdfWithStamp = Buffer.from(await pdfDoc.save({ useObjectStreams: false }));
|
||||
|
||||
// ── Add cryptographic signature placeholder ───────────────────────
|
||||
const pdfDocForSign = await PDFDocument.load(pdfWithStamp);
|
||||
pdflibAddPlaceholder({
|
||||
pdfDoc: pdfDocForSign,
|
||||
reason: reason || 'Firma digital',
|
||||
contactInfo: signerName,
|
||||
name: signerName,
|
||||
location: location || '',
|
||||
signatureLength: 32768,
|
||||
rect: [sx, sy, sx + sw, sy + sh],
|
||||
pageIndex: Math.min(page, pdfDocForSign.getPageCount() - 1),
|
||||
});
|
||||
|
||||
const pdfWithPlaceholder = Buffer.from(await pdfDocForSign.save({ useObjectStreams: false }));
|
||||
|
||||
// ── Sign ──────────────────────────────────────────────────────────
|
||||
const signer = new P12Signer(p12Buffer, { passphrase: p12Password || '' });
|
||||
const signInstance = new SignPdf();
|
||||
const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────
|
||||
const destDir = await getSignedDocumentPath();
|
||||
const ext = path.extname(pdfPath);
|
||||
const base = path.basename(pdfPath, ext).replace(/^\d+-/, '');
|
||||
const nowForFilename = new Date();
|
||||
const year = String(nowForFilename.getFullYear());
|
||||
const month = String(nowForFilename.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(nowForFilename.getDate()).padStart(2, '0');
|
||||
const hours = String(nowForFilename.getHours()).padStart(2, '0');
|
||||
const minutes = String(nowForFilename.getMinutes()).padStart(2, '0');
|
||||
const dateSuffix = `${year}${month}${day}${hours}${minutes}`;
|
||||
const signedFilename = `${base}_${dateSuffix}${ext}`;
|
||||
const signedPath = path.join(destDir, signedFilename);
|
||||
await fs.writeFile(signedPath, signedBuf);
|
||||
|
||||
return { signedFilename, signedPath };
|
||||
};
|
||||
|
||||
module.exports = { signPdf };
|
||||
@@ -0,0 +1,25 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { loadCertificate } = require('./signing.service');
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const certificatePath = process.argv[2];
|
||||
const password = process.argv[3] || '';
|
||||
|
||||
if (!certificatePath) {
|
||||
throw new Error('Certificate path is required.');
|
||||
}
|
||||
|
||||
const filePath = path.resolve(certificatePath);
|
||||
const pfxBuffer = await fs.readFile(filePath);
|
||||
const result = await loadCertificate(pfxBuffer, password);
|
||||
|
||||
console.log('privateKey exists:', Boolean(result.privateKey));
|
||||
console.log('certificate exists:', Boolean(result.certificate));
|
||||
} catch (error) {
|
||||
console.error('Error loading certificate:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const usersService = require('./users.service');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
|
||||
const updateSignatureAppearancePreference = async (req, res, next) => {
|
||||
try {
|
||||
const signaturePreferences = await usersService.updateSignaturePreferences(req.user.id, req.body);
|
||||
return sendSuccess(res, { signaturePreferences }, 200);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateSignatureAppearancePreference,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const preferencesDir = path.resolve(process.cwd(), 'storage', 'preferences');
|
||||
const preferencesFile = path.join(preferencesDir, 'signature-styles.json');
|
||||
|
||||
const normalizeSignaturePreferences = (value) => {
|
||||
if (!value) {
|
||||
return {
|
||||
signatureStyle: 'qr',
|
||||
qrPosition: 'left',
|
||||
qrBackground: 'transparent',
|
||||
qrBorder: 'none',
|
||||
signatureImageBase64: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return {
|
||||
signatureStyle: value,
|
||||
qrPosition: 'left',
|
||||
qrBackground: 'transparent',
|
||||
qrBorder: 'none',
|
||||
signatureImageBase64: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
signatureStyle: value.signatureStyle || 'qr',
|
||||
qrPosition: value.qrPosition || 'left',
|
||||
qrBackground: value.qrBackground || 'transparent',
|
||||
qrBorder: value.qrBorder || 'none',
|
||||
signatureImageBase64: value.signatureImageBase64 || null,
|
||||
};
|
||||
};
|
||||
|
||||
const readPreferences = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(preferencesFile, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writePreferences = async (data) => {
|
||||
await fs.mkdir(preferencesDir, { recursive: true });
|
||||
await fs.writeFile(preferencesFile, JSON.stringify(data, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const getSignaturePreferences = async (userId) => {
|
||||
const preferences = await readPreferences();
|
||||
return normalizeSignaturePreferences(preferences[String(userId)]);
|
||||
};
|
||||
|
||||
const setSignaturePreferences = async (userId, signaturePreferences) => {
|
||||
const preferences = await readPreferences();
|
||||
preferences[String(userId)] = normalizeSignaturePreferences(signaturePreferences);
|
||||
await writePreferences(preferences);
|
||||
return preferences[String(userId)];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSignaturePreferences,
|
||||
setSignaturePreferences,
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const authenticate = require('../../middlewares/authenticate');
|
||||
const usersController = require('./users.controller');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.patch('/me/preferences/signature-appearance', usersController.updateSignatureAppearancePreference);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,49 @@
|
||||
const usersRepository = require('./users.repository');
|
||||
|
||||
const ALLOWED_SIGNATURE_STYLES = ['classic', 'qr', 'professional', 'image'];
|
||||
const ALLOWED_QR_POSITIONS = ['left', 'right'];
|
||||
const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft'];
|
||||
const ALLOWED_QR_BORDERS = ['none', 'line'];
|
||||
|
||||
const getSignaturePreferences = async (userId) => {
|
||||
return usersRepository.getSignaturePreferences(userId);
|
||||
};
|
||||
|
||||
const updateSignaturePreferences = async (userId, { signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }) => {
|
||||
if (!ALLOWED_SIGNATURE_STYLES.includes(signatureStyle)) {
|
||||
const error = new Error('Invalid signature style.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!ALLOWED_QR_POSITIONS.includes(qrPosition)) {
|
||||
const error = new Error('Invalid QR position.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!ALLOWED_QR_BACKGROUNDS.includes(qrBackground)) {
|
||||
const error = new Error('Invalid QR background.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!ALLOWED_QR_BORDERS.includes(qrBorder)) {
|
||||
const error = new Error('Invalid QR border.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return usersRepository.setSignaturePreferences(userId, {
|
||||
signatureStyle,
|
||||
qrPosition,
|
||||
qrBackground,
|
||||
qrBorder,
|
||||
signatureImageBase64: signatureImageBase64 || null,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSignaturePreferences,
|
||||
updateSignaturePreferences,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const authRoutes = require('../modules/auth/auth.routes');
|
||||
const certificatesRoutes = require('../modules/certificates/certificates.routes');
|
||||
const documentsRoutes = require('../modules/documents/documents.routes');
|
||||
const signaturesRoutes = require('../modules/signatures/signatures.routes');
|
||||
const usersRoutes = require('../modules/users/users.routes');
|
||||
const healthRoutes = require('../modules/health/health.routes');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/certificates', certificatesRoutes);
|
||||
router.use('/documents', documentsRoutes);
|
||||
router.use('/signatures', signaturesRoutes);
|
||||
router.use('/users', usersRoutes);
|
||||
router.use('/health', healthRoutes);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,6 @@
|
||||
const app = require('./app');
|
||||
const env = require('./config/env');
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`LinkedSign backend running on port ${env.port}`);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const sendSuccess = (res, data, statusCode = 200) => {
|
||||
return res.status(statusCode).json({
|
||||
success: true,
|
||||
data,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
const sendError = (res, error, statusCode = 400) => {
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendSuccess,
|
||||
sendError,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const getUserCertificatePath = async (userId) => {
|
||||
const fullPath = path.resolve(
|
||||
'/home/mgonzalez/desarrollo/react/linkedsign/backend/storage/certificates/users',
|
||||
String(userId)
|
||||
);
|
||||
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getUserCertificatePath,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const env = require('../config/env');
|
||||
|
||||
const signAccess = (payload) => {
|
||||
return jwt.sign(payload, env.jwt.secret, { expiresIn: env.jwt.accessExpires });
|
||||
};
|
||||
|
||||
const signRefresh = (payload) => {
|
||||
return jwt.sign(payload, env.jwt.secret, { expiresIn: env.jwt.refreshExpires });
|
||||
};
|
||||
|
||||
const verify = (token) => {
|
||||
return jwt.verify(token, env.jwt.secret);
|
||||
};
|
||||
|
||||
module.exports = { signAccess, signRefresh, verify };
|
||||
@@ -0,0 +1,31 @@
|
||||
const toBase64Url = (value) => {
|
||||
return Buffer.from(value)
|
||||
.toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
};
|
||||
|
||||
const createMockJwt = (payload = {}) => {
|
||||
const header = {
|
||||
alg: 'HS256',
|
||||
typ: 'JWT',
|
||||
};
|
||||
|
||||
const body = {
|
||||
sub: payload.email || 'mock-user',
|
||||
role: 'user',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
mock: true,
|
||||
};
|
||||
|
||||
return [
|
||||
toBase64Url(JSON.stringify(header)),
|
||||
toBase64Url(JSON.stringify(body)),
|
||||
'mock-signature',
|
||||
].join('.');
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createMockJwt,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const env = require('../config/env');
|
||||
|
||||
const getDocumentPath = async () => {
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear());
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const fullPath = path.join(env.storagePath, 'documents', year, month);
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
const getSignedDocumentPath = async () => {
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear());
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const fullPath = path.join(env.storagePath, 'signed', year, month);
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
const getCertificatePath = async (userId) => {
|
||||
const fullPath = path.join(env.storagePath, 'certificates', String(userId));
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
module.exports = { getDocumentPath, getSignedDocumentPath, getCertificatePath };
|
||||
Reference in New Issue
Block a user