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
+8 -1
View File
@@ -2,10 +2,17 @@
## Versión actual ## Versión actual
- Versión base: `1.1.3` - Versión base: `1.2.0`
## Historial ## Historial
### 1.2.0 - 2026-04-23
- Se separa el acceso biométrico Android de la sesión normal usando un token biométrico persistente.
- La huella se mantiene aunque expire la sesión por inactividad.
- La huella solo se invalida al desactivarla o al cambiar contraseña.
- Se evita que “Continuar sin huella” desactive el acceso biométrico por error.
### 1.1.3 - 2026-04-23 ### 1.1.3 - 2026-04-23
- Se cambia en Android el botón de ojo por descarga real del PDF. - Se cambia en Android el botón de ojo por descarga real del PDF.
+1 -1
View File
@@ -1 +1 @@
1.1.3 1.2.0
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign" applicationId "net.linkedcorp.linkedsign"
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 10103 versionCode 10200
versionName "1.1.3" versionName "1.2.0"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
} }
@@ -29,12 +29,21 @@ interface LinkedSignApiService {
@POST("auth/refresh") @POST("auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData> suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData>
@POST("auth/biometric-login")
suspend fun biometricLogin(@Body body: BiometricTokenRequest): ApiEnvelope<BiometricLoginData>
@POST("auth/logout") @POST("auth/logout")
suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData> suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData>
@GET("auth/me") @GET("auth/me")
suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope<MeData> suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope<MeData>
@POST("auth/biometric-token")
suspend fun issueBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope<BiometricTokenData>
@DELETE("auth/biometric-token")
suspend fun revokeBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope<SimpleMessageData>
@PATCH("auth/change-password") @PATCH("auth/change-password")
suspend fun changePassword( suspend fun changePassword(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -77,7 +77,6 @@ class LinkedSignRepository(private val context: Context) {
) )
sessionStore.save(session) sessionStore.save(session)
localCache.saveProfile(response.user) localCache.saveProfile(response.user)
sessionStore.updateBiometricSession(response.user.email, response.refreshToken)
session session
} }
} }
@@ -110,41 +109,52 @@ class LinkedSignRepository(private val context: Context) {
} }
suspend fun biometricLogin(): SessionSnapshot { suspend fun biometricLogin(): SessionSnapshot {
val biometric = sessionStore.loadBiometricRefreshToken() val biometric = sessionStore.loadBiometricToken()
?: throw AppException("No hay sesión biométrica guardada.") ?: throw AppException("No hay sesión biométrica guardada.")
return execute { return try {
val refresh = unwrap(service.refresh(RefreshRequest(biometric.second))) execute {
val user = unwrap(service.me("Bearer ${refresh.accessToken}")).user val response = unwrap(service.biometricLogin(BiometricTokenRequest(biometric.second)))
val session = SessionSnapshot( val session = SessionSnapshot(
accessToken = refresh.accessToken, accessToken = response.accessToken,
refreshToken = refresh.refreshToken, refreshToken = response.refreshToken,
user = user user = response.user
) )
sessionStore.save(session) sessionStore.save(session)
localCache.saveProfile(user) localCache.saveProfile(response.user)
sessionStore.updateBiometricSession(user.email, refresh.refreshToken) sessionStore.updateBiometricSession(response.user.email, response.biometricToken)
session session
}
} catch (error: AppException) {
if (error.code == "BIOMETRIC_EXPIRED" || error.code == "BIOMETRIC_INVALIDATED") {
sessionStore.declineBiometricAccess()
}
throw error
} }
} }
fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState() fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState()
fun enableBiometricAccess() { suspend fun enableBiometricAccess() {
val session = sessionStore.load() val session = sessionStore.load()
val user = session.user ?: throw AppException("Primero debes iniciar sesión.") val user = session.user ?: throw AppException("Primero debes iniciar sesión.")
val refreshToken = session.refreshToken ?: throw AppException("No hay sesión para activar biometría.") val accessToken = session.accessToken ?: throw AppException("No hay sesión para activar biometría.")
sessionStore.saveBiometricAccess(user.email, refreshToken) val response = execute {
unwrap(service.issueBiometricToken("Bearer $accessToken"))
}
sessionStore.saveBiometricAccess(user.email, response.biometricToken)
} }
fun declineBiometricAccess() { suspend fun declineBiometricAccess() {
sessionStore.declineBiometricAccess()
}
fun syncBiometricSession() {
val session = sessionStore.load() val session = sessionStore.load()
val user = session.user ?: return val accessToken = session.accessToken
val refreshToken = session.refreshToken ?: return if (!accessToken.isNullOrBlank()) {
sessionStore.updateBiometricSession(user.email, refreshToken) runCatching {
execute {
unwrap(service.revokeBiometricToken("Bearer $accessToken"))
}
}
}
sessionStore.declineBiometricAccess()
} }
suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData = suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData =
@@ -243,7 +253,6 @@ class LinkedSignRepository(private val context: Context) {
refreshToken = refresh.refreshToken refreshToken = refresh.refreshToken
) )
sessionStore.save(updated) sessionStore.save(updated)
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
return updated return updated
} }
@@ -43,6 +43,21 @@ data class LoginData(
val user: SessionUser val user: SessionUser
) )
data class BiometricTokenRequest(
val biometricToken: String
)
data class BiometricTokenData(
val biometricToken: String
)
data class BiometricLoginData(
val accessToken: String,
val refreshToken: String,
val biometricToken: String,
val user: SessionUser
)
data class RegisterData( data class RegisterData(
val message: String?, val message: String?,
val userId: Int? val userId: Int?
@@ -75,22 +75,22 @@ class SessionStore(context: Context) {
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false) val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false) val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
val email = preferences.getString(KEY_BIO_EMAIL, null) val email = preferences.getString(KEY_BIO_EMAIL, null)
val refreshToken = preferences.getString(KEY_BIO_REFRESH, null) val biometricToken = preferences.getString(KEY_BIO_TOKEN, null)
return BiometricAccessState( return BiometricAccessState(
enabled = enabled, enabled = enabled,
configured = configured, configured = configured,
email = email, email = email,
hasCredentials = !email.isNullOrBlank() && !refreshToken.isNullOrBlank() hasCredentials = !email.isNullOrBlank() && !biometricToken.isNullOrBlank()
) )
} }
fun saveBiometricAccess(email: String, refreshToken: String) { fun saveBiometricAccess(email: String, biometricToken: String) {
preferences.edit() preferences.edit()
.putBoolean(KEY_BIO_ENABLED, true) .putBoolean(KEY_BIO_ENABLED, true)
.putBoolean(KEY_BIO_CONFIGURED, true) .putBoolean(KEY_BIO_CONFIGURED, true)
.putString(KEY_BIO_EMAIL, email) .putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_REFRESH, refreshToken) .putString(KEY_BIO_TOKEN, biometricToken)
.apply() .apply()
} }
@@ -99,28 +99,28 @@ class SessionStore(context: Context) {
.putBoolean(KEY_BIO_ENABLED, false) .putBoolean(KEY_BIO_ENABLED, false)
.putBoolean(KEY_BIO_CONFIGURED, true) .putBoolean(KEY_BIO_CONFIGURED, true)
.remove(KEY_BIO_EMAIL) .remove(KEY_BIO_EMAIL)
.remove(KEY_BIO_REFRESH) .remove(KEY_BIO_TOKEN)
.apply() .apply()
} }
fun updateBiometricSession(email: String, refreshToken: String) { fun updateBiometricSession(email: String, biometricToken: String) {
if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) { if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) {
return return
} }
preferences.edit() preferences.edit()
.putString(KEY_BIO_EMAIL, email) .putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_REFRESH, refreshToken) .putString(KEY_BIO_TOKEN, biometricToken)
.apply() .apply()
} }
fun loadBiometricRefreshToken(): Pair<String, String>? { fun loadBiometricToken(): Pair<String, String>? {
val email = preferences.getString(KEY_BIO_EMAIL, null) val email = preferences.getString(KEY_BIO_EMAIL, null)
val refreshToken = preferences.getString(KEY_BIO_REFRESH, null) val biometricToken = preferences.getString(KEY_BIO_TOKEN, null)
if (email.isNullOrBlank() || refreshToken.isNullOrBlank()) { if (email.isNullOrBlank() || biometricToken.isNullOrBlank()) {
return null return null
} }
return email to refreshToken return email to biometricToken
} }
companion object { companion object {
@@ -131,7 +131,7 @@ class SessionStore(context: Context) {
private const val KEY_BIO_ENABLED = "bio_enabled" private const val KEY_BIO_ENABLED = "bio_enabled"
private const val KEY_BIO_CONFIGURED = "bio_configured" private const val KEY_BIO_CONFIGURED = "bio_configured"
private const val KEY_BIO_EMAIL = "bio_email" private const val KEY_BIO_EMAIL = "bio_email"
private const val KEY_BIO_REFRESH = "bio_refresh_token" private const val KEY_BIO_TOKEN = "bio_token"
private const val DEFAULT_SESSION_MS = 15 * 60 * 1000L private const val DEFAULT_SESSION_MS = 15 * 60 * 1000L
} }
} }
@@ -353,7 +353,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
appUnlocked = true appUnlocked = true
} else { } else {
authMode = AuthMode.Login authMode = AuthMode.Login
viewModel.declineBiometricAccess()
appUnlocked = true appUnlocked = true
} }
}, },
@@ -455,8 +454,10 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
if (showBiometricOptIn) { if (showBiometricOptIn) {
AlertDialog( AlertDialog(
onDismissRequest = { onDismissRequest = {
viewModel.declineBiometricAccess() scope.launch {
showBiometricOptIn = false viewModel.declineBiometricAccess()
showBiometricOptIn = false
}
}, },
title = { Text("Activar acceso biométrico") }, title = { Text("Activar acceso biométrico") },
text = { text = {
@@ -464,16 +465,23 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
}, },
confirmButton = { confirmButton = {
Button(onClick = { Button(onClick = {
viewModel.enableBiometricAccess() scope.launch {
showBiometricOptIn = false runCatching { viewModel.enableBiometricAccess() }
.onSuccess { showBiometricOptIn = false }
.onFailure {
snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo activar la huella.")
}
}
}) { }) {
Text("Sí, usar huella") Text("Sí, usar huella")
} }
}, },
dismissButton = { dismissButton = {
OutlinedButton(onClick = { OutlinedButton(onClick = {
viewModel.declineBiometricAccess() scope.launch {
showBiometricOptIn = false viewModel.declineBiometricAccess()
showBiometricOptIn = false
}
}) { }) {
Text("Ahora no") Text("Ahora no")
} }
@@ -53,7 +53,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
suspend fun login(email: String, password: String) { suspend fun login(email: String, password: String) {
val session = repository.login(email, password) val session = repository.login(email, password)
repository.syncBiometricSession()
val (_, cache) = repository.loadLocalSnapshot() val (_, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false) uiState = buildState(session, cache, loading = false)
} }
@@ -162,12 +161,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
suspend fun accessToken(): String? = repository.getFreshAccessToken() suspend fun accessToken(): String? = repository.getFreshAccessToken()
fun enableBiometricAccess() { suspend fun enableBiometricAccess() {
repository.enableBiometricAccess() repository.enableBiometricAccess()
refreshState() refreshState()
} }
fun declineBiometricAccess() { suspend fun declineBiometricAccess() {
repository.declineBiometricAccess() repository.declineBiometricAccess()
refreshState() refreshState()
} }
+1
View File
@@ -9,6 +9,7 @@ DB_PASSWORD=
JWT_SECRET=change_me JWT_SECRET=change_me
JWT_ACCESS_EXPIRES=15m JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=15m JWT_REFRESH_EXPIRES=15m
JWT_BIOMETRIC_EXPIRES=180d
STORAGE_PATH=/tmp/linkedsign_storage STORAGE_PATH=/tmp/linkedsign_storage
MAIL_HOST=smtp.gmail.com MAIL_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkedsign-backend", "name": "linkedsign-backend",
"version": "1.1.3", "version": "1.2.0",
"private": true, "private": true,
"description": "Initial backend for LinkedSign", "description": "Initial backend for LinkedSign",
"main": "src/server.js", "main": "src/server.js",
+2
View File
@@ -73,6 +73,8 @@ const env = {
accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m', accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '15m', refreshExpires: process.env.JWT_REFRESH_EXPIRES || '15m',
refreshExpiresMs: parseDurationMs(process.env.JWT_REFRESH_EXPIRES || '15m', 15 * 60 * 1000), 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', 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) => { const logout = async (req, res, next) => {
try { try {
await authService.logout(readRefreshToken(req)); 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 = { module.exports = {
register, register,
login, login,
refresh, refresh,
biometricLogin,
logout, logout,
verifyEmail, verifyEmail,
me, me,
issueBiometricToken,
revokeBiometricToken,
changePassword, changePassword,
confirmPasswordChange, confirmPasswordChange,
getUnlockAccountRequest, getUnlockAccountRequest,
@@ -25,6 +25,21 @@ const ensureSchema = async () => {
await db.execute(column.sql); 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) => { const findByEmail = async (email) => {
@@ -172,6 +187,36 @@ const deleteUserRefreshTokens = async (userId) => {
await db.execute('DELETE FROM refresh_tokens WHERE user_id = ?', [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) => { const setResetToken = async (userId, token, expiresAt) => {
await db.execute( await db.execute(
'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?', 'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
@@ -216,6 +261,10 @@ module.exports = {
findRefreshToken, findRefreshToken,
deleteRefreshToken, deleteRefreshToken,
deleteUserRefreshTokens, deleteUserRefreshTokens,
saveBiometricToken,
findBiometricToken,
deleteBiometricToken,
deleteUserBiometricTokens,
setResetToken, setResetToken,
findByResetToken, findByResetToken,
updatePassword, updatePassword,
+4 -1
View File
@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const authController = require('./auth.controller'); 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 authenticate = require('../../middlewares/authenticate');
const { uploadTempCertificate } = require('../../config/multer'); const { uploadTempCertificate } = require('../../config/multer');
@@ -9,12 +9,15 @@ const router = express.Router();
router.post('/register', uploadTempCertificate.single('certificateFile'), validateRegisterMultipart, authController.register); router.post('/register', uploadTempCertificate.single('certificateFile'), validateRegisterMultipart, authController.register);
router.post('/login', validate(loginSchema), authController.login); router.post('/login', validate(loginSchema), authController.login);
router.post('/refresh', validate(refreshSchema), authController.refresh); router.post('/refresh', validate(refreshSchema), authController.refresh);
router.post('/biometric-login', validate(biometricTokenSchema), authController.biometricLogin);
router.post('/logout', authController.logout); router.post('/logout', authController.logout);
router.get('/verify/:token', authController.verifyEmail); router.get('/verify/:token', authController.verifyEmail);
router.get('/confirm-password-change/:token', authController.confirmPasswordChange); router.get('/confirm-password-change/:token', authController.confirmPasswordChange);
router.get('/unlock-account/:token', authController.getUnlockAccountRequest); router.get('/unlock-account/:token', authController.getUnlockAccountRequest);
router.post('/unlock-account/:token/request-password-change', validate(unlockAccountSchema), authController.requestUnlockPasswordChange); router.post('/unlock-account/:token/request-password-change', validate(unlockAccountSchema), authController.requestUnlockPasswordChange);
router.get('/me', authenticate, authController.me); 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); router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword);
module.exports = router; module.exports = router;
+76
View File
@@ -1,5 +1,6 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const authRepository = require('./auth.repository'); const authRepository = require('./auth.repository');
const passwordChangeRepository = require('./password-change.repository'); const passwordChangeRepository = require('./password-change.repository');
const auditService = require('../audit/audit.service'); const auditService = require('../audit/audit.service');
@@ -15,6 +16,7 @@ const env = require('../../config/env');
const SALT_ROUNDS = 12; const SALT_ROUNDS = 12;
const MAX_FAILED_LOGIN_ATTEMPTS = 3; const MAX_FAILED_LOGIN_ATTEMPTS = 3;
const createBiometricToken = () => crypto.randomBytes(48).toString('hex');
const buildDisplayNameFromEmail = (email) => { const buildDisplayNameFromEmail = (email) => {
const localPart = String(email).split('@')[0] || 'Usuario'; const localPart = String(email).split('@')[0] || 'Usuario';
@@ -300,11 +302,80 @@ const refresh = async (token) => {
return { accessToken, refreshToken: newRefreshToken }; 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) => { const logout = async (token) => {
if (!token) return; if (!token) return;
await authRepository.deleteRefreshToken(token); await authRepository.deleteRefreshToken(token);
}; };
const revokeBiometricToken = async (userId) => {
await authRepository.deleteUserBiometricTokens(userId);
};
const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => { const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => {
const user = await authRepository.findAuthUserById(userId); const user = await authRepository.findAuthUserById(userId);
if (!user || !user.is_active) { if (!user || !user.is_active) {
@@ -449,6 +520,8 @@ const confirmPasswordChange = async (token) => {
} }
await authRepository.updatePassword(request.userId, request.passwordHash); await authRepository.updatePassword(request.userId, request.passwordHash);
await authRepository.deleteUserRefreshTokens(request.userId);
await authRepository.deleteUserBiometricTokens(request.userId);
if (request.source === 'account_unlock') { if (request.source === 'account_unlock') {
await authRepository.clearAccountLock(request.userId); await authRepository.clearAccountLock(request.userId);
} }
@@ -485,7 +558,10 @@ module.exports = {
register, register,
login, login,
refresh, refresh,
issueBiometricToken,
biometricLogin,
logout, logout,
revokeBiometricToken,
verifyEmail, verifyEmail,
changePassword, changePassword,
confirmPasswordChange, confirmPasswordChange,
@@ -38,6 +38,10 @@ const refreshSchema = Joi.object({
refreshToken: Joi.string().optional(), refreshToken: Joi.string().optional(),
}); });
const biometricTokenSchema = Joi.object({
biometricToken: Joi.string().required(),
});
const changePasswordSchema = Joi.object({ const changePasswordSchema = Joi.object({
currentPassword: Joi.string().required(), currentPassword: Joi.string().required(),
newPassword: Joi.string().max(100).required().custom(isStrongPassword), newPassword: Joi.string().max(100).required().custom(isStrongPassword),
@@ -98,6 +102,7 @@ module.exports = {
validateRegisterMultipart, validateRegisterMultipart,
loginSchema, loginSchema,
refreshSchema, refreshSchema,
biometricTokenSchema,
changePasswordSchema, changePasswordSchema,
unlockAccountSchema, unlockAccountSchema,
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.1.3", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",