Initial LinkedSign platform

This commit is contained in:
2026-04-06 20:33:59 -05:00
commit 1074c7c9d2
159 changed files with 19072 additions and 0 deletions
@@ -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 };
+115
View File
@@ -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,
};
+17
View File
@@ -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;
+286
View File
@@ -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,
};