Persist biometric access
This commit is contained in:
@@ -9,6 +9,7 @@ DB_PASSWORD=
|
||||
JWT_SECRET=change_me
|
||||
JWT_ACCESS_EXPIRES=15m
|
||||
JWT_REFRESH_EXPIRES=15m
|
||||
JWT_BIOMETRIC_EXPIRES=180d
|
||||
STORAGE_PATH=/tmp/linkedsign_storage
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkedsign-backend",
|
||||
"version": "1.1.3",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "Initial backend for LinkedSign",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user