Persist biometric access

This commit is contained in:
2026-04-23 10:54:19 -05:00
parent ae5649f408
commit 8194c4dee4
18 changed files with 269 additions and 55 deletions
+2
View File
@@ -73,6 +73,8 @@ const env = {
accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '15m',
refreshExpiresMs: parseDurationMs(process.env.JWT_REFRESH_EXPIRES || '15m', 15 * 60 * 1000),
biometricExpires: process.env.JWT_BIOMETRIC_EXPIRES || '180d',
biometricExpiresMs: parseDurationMs(process.env.JWT_BIOMETRIC_EXPIRES || '180d', 180 * 24 * 60 * 60 * 1000),
},
storagePath: process.env.STORAGE_PATH || '/tmp/linkedsign_storage',
@@ -79,6 +79,16 @@ const refresh = async (req, res, next) => {
}
};
const biometricLogin = async (req, res, next) => {
try {
const result = await authService.biometricLogin(req.body.biometricToken);
res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions());
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const logout = async (req, res, next) => {
try {
await authService.logout(readRefreshToken(req));
@@ -148,13 +158,34 @@ const me = async (req, res, next) => {
}
};
const issueBiometricToken = async (req, res, next) => {
try {
const result = await authService.issueBiometricToken(req.user.id);
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const revokeBiometricToken = async (req, res, next) => {
try {
await authService.revokeBiometricToken(req.user.id);
return sendSuccess(res, { message: 'Acceso biométrico desactivado.' }, 200);
} catch (err) {
return next(err);
}
};
module.exports = {
register,
login,
refresh,
biometricLogin,
logout,
verifyEmail,
me,
issueBiometricToken,
revokeBiometricToken,
changePassword,
confirmPasswordChange,
getUnlockAccountRequest,
@@ -25,6 +25,21 @@ const ensureSchema = async () => {
await db.execute(column.sql);
}
}
await db.execute(`
CREATE TABLE IF NOT EXISTS biometric_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_biometric_token (token),
KEY idx_biometric_user_id (user_id),
CONSTRAINT fk_biometric_tokens_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
)
`);
};
const findByEmail = async (email) => {
@@ -172,6 +187,36 @@ const deleteUserRefreshTokens = async (userId) => {
await db.execute('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]);
};
const saveBiometricToken = async (userId, token, expiresAt) => {
const hashedToken = hashRefreshToken(token);
await db.execute('DELETE FROM biometric_tokens WHERE user_id = ?', [userId]);
await db.execute(
'INSERT INTO biometric_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
[userId, hashedToken, expiresAt]
);
};
const findBiometricToken = async (token) => {
const hashedToken = hashRefreshToken(token);
const [rows] = await db.execute(
`SELECT bt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active
FROM biometric_tokens bt
JOIN users u ON bt.user_id = u.id
WHERE bt.token = ? AND bt.expires_at > NOW()`,
[hashedToken]
);
return rows[0] || null;
};
const deleteBiometricToken = async (token) => {
const hashedToken = hashRefreshToken(token);
await db.execute('DELETE FROM biometric_tokens WHERE token = ?', [hashedToken]);
};
const deleteUserBiometricTokens = async (userId) => {
await db.execute('DELETE FROM biometric_tokens WHERE user_id = ?', [userId]);
};
const setResetToken = async (userId, token, expiresAt) => {
await db.execute(
'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
@@ -216,6 +261,10 @@ module.exports = {
findRefreshToken,
deleteRefreshToken,
deleteUserRefreshTokens,
saveBiometricToken,
findBiometricToken,
deleteBiometricToken,
deleteUserBiometricTokens,
setResetToken,
findByResetToken,
updatePassword,
+4 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const authController = require('./auth.controller');
const { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema, unlockAccountSchema } = require('./auth.validator');
const { validate, validateRegisterMultipart, loginSchema, refreshSchema, biometricTokenSchema, changePasswordSchema, unlockAccountSchema } = require('./auth.validator');
const authenticate = require('../../middlewares/authenticate');
const { uploadTempCertificate } = require('../../config/multer');
@@ -9,12 +9,15 @@ const router = express.Router();
router.post('/register', uploadTempCertificate.single('certificateFile'), validateRegisterMultipart, authController.register);
router.post('/login', validate(loginSchema), authController.login);
router.post('/refresh', validate(refreshSchema), authController.refresh);
router.post('/biometric-login', validate(biometricTokenSchema), authController.biometricLogin);
router.post('/logout', authController.logout);
router.get('/verify/:token', authController.verifyEmail);
router.get('/confirm-password-change/:token', authController.confirmPasswordChange);
router.get('/unlock-account/:token', authController.getUnlockAccountRequest);
router.post('/unlock-account/:token/request-password-change', validate(unlockAccountSchema), authController.requestUnlockPasswordChange);
router.get('/me', authenticate, authController.me);
router.post('/biometric-token', authenticate, authController.issueBiometricToken);
router.delete('/biometric-token', authenticate, authController.revokeBiometricToken);
router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword);
module.exports = router;
+76
View File
@@ -1,5 +1,6 @@
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const authRepository = require('./auth.repository');
const passwordChangeRepository = require('./password-change.repository');
const auditService = require('../audit/audit.service');
@@ -15,6 +16,7 @@ const env = require('../../config/env');
const SALT_ROUNDS = 12;
const MAX_FAILED_LOGIN_ATTEMPTS = 3;
const createBiometricToken = () => crypto.randomBytes(48).toString('hex');
const buildDisplayNameFromEmail = (email) => {
const localPart = String(email).split('@')[0] || 'Usuario';
@@ -300,11 +302,80 @@ const refresh = async (token) => {
return { accessToken, refreshToken: newRefreshToken };
};
const issueBiometricToken = async (userId) => {
const user = await authRepository.findAuthUserById(userId);
if (!user || !user.is_active) {
const err = new Error('User not found.');
err.statusCode = 404;
throw err;
}
const biometricToken = createBiometricToken();
const expiresAt = new Date(Date.now() + env.jwt.biometricExpiresMs);
await authRepository.saveBiometricToken(userId, biometricToken, expiresAt);
return { biometricToken };
};
const biometricLogin = async (token) => {
if (!token) {
const err = new Error('La sesión biométrica expiró. Inicia sesión con correo y contraseña.');
err.statusCode = 401;
err.code = 'BIOMETRIC_EXPIRED';
throw err;
}
const record = await authRepository.findBiometricToken(token);
if (!record) {
await authRepository.deleteBiometricToken(token);
const err = new Error('La sesión biométrica expiró. Inicia sesión con correo y contraseña.');
err.statusCode = 401;
err.code = 'BIOMETRIC_EXPIRED';
throw err;
}
if (!record.is_active) {
await authRepository.deleteUserBiometricTokens(record.user_id);
const err = new Error('La sesión biométrica fue invalidada. Inicia sesión con correo y contraseña.');
err.statusCode = 401;
err.code = 'BIOMETRIC_INVALIDATED';
throw err;
}
await authRepository.deleteBiometricToken(token);
const payload = { sub: record.uid, email: record.email, role: record.role, name: record.name };
const accessToken = signAccess(payload);
const refreshToken = signRefresh(payload);
const biometricToken = createBiometricToken();
await authRepository.saveRefreshToken(record.uid, refreshToken, new Date(Date.now() + env.jwt.refreshExpiresMs));
await authRepository.saveBiometricToken(record.uid, biometricToken, new Date(Date.now() + env.jwt.biometricExpiresMs));
const signaturePreferences = await usersService.getSignaturePreferences(record.uid);
return {
accessToken,
refreshToken,
biometricToken,
user: {
id: record.uid,
name: record.name,
email: record.email,
role: record.role,
signaturePreferences,
},
};
};
const logout = async (token) => {
if (!token) return;
await authRepository.deleteRefreshToken(token);
};
const revokeBiometricToken = async (userId) => {
await authRepository.deleteUserBiometricTokens(userId);
};
const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => {
const user = await authRepository.findAuthUserById(userId);
if (!user || !user.is_active) {
@@ -449,6 +520,8 @@ const confirmPasswordChange = async (token) => {
}
await authRepository.updatePassword(request.userId, request.passwordHash);
await authRepository.deleteUserRefreshTokens(request.userId);
await authRepository.deleteUserBiometricTokens(request.userId);
if (request.source === 'account_unlock') {
await authRepository.clearAccountLock(request.userId);
}
@@ -485,7 +558,10 @@ module.exports = {
register,
login,
refresh,
issueBiometricToken,
biometricLogin,
logout,
revokeBiometricToken,
verifyEmail,
changePassword,
confirmPasswordChange,
@@ -38,6 +38,10 @@ const refreshSchema = Joi.object({
refreshToken: Joi.string().optional(),
});
const biometricTokenSchema = Joi.object({
biometricToken: Joi.string().required(),
});
const changePasswordSchema = Joi.object({
currentPassword: Joi.string().required(),
newPassword: Joi.string().max(100).required().custom(isStrongPassword),
@@ -98,6 +102,7 @@ module.exports = {
validateRegisterMultipart,
loginSchema,
refreshSchema,
biometricTokenSchema,
changePasswordSchema,
unlockAccountSchema,
};