Persist biometric access
This commit is contained in:
+8
-1
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
Reference in New Issue
Block a user