Initial LinkedSign platform
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user