diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9edf5..fc29799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,17 @@ ## Versión actual -- Versión base: `1.1.3` +- Versión base: `1.2.0` ## 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 - Se cambia en Android el botón de ojo por descarga real del PDF. diff --git a/VERSION b/VERSION index 781dcb0..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.3 +1.2.0 diff --git a/android/app/build.gradle b/android/app/build.gradle index 77d9a21..93856da 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,8 +18,8 @@ android { applicationId "net.linkedcorp.linkedsign" minSdk 26 targetSdk 35 - versionCode 10103 - versionName "1.1.3" + versionCode 10200 + versionName "1.2.0" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt index f8fb907..78965ac 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt @@ -29,12 +29,21 @@ interface LinkedSignApiService { @POST("auth/refresh") suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope + @POST("auth/biometric-login") + suspend fun biometricLogin(@Body body: BiometricTokenRequest): ApiEnvelope + @POST("auth/logout") suspend fun logout(@Body body: RefreshRequest): ApiEnvelope @GET("auth/me") suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope + @POST("auth/biometric-token") + suspend fun issueBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope + + @DELETE("auth/biometric-token") + suspend fun revokeBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope + @PATCH("auth/change-password") suspend fun changePassword( @Header("Authorization") authorization: String, diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt index e777c7a..ea988a3 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt @@ -77,7 +77,6 @@ class LinkedSignRepository(private val context: Context) { ) sessionStore.save(session) localCache.saveProfile(response.user) - sessionStore.updateBiometricSession(response.user.email, response.refreshToken) session } } @@ -110,41 +109,52 @@ class LinkedSignRepository(private val context: Context) { } suspend fun biometricLogin(): SessionSnapshot { - val biometric = sessionStore.loadBiometricRefreshToken() + val biometric = sessionStore.loadBiometricToken() ?: throw AppException("No hay sesión biométrica guardada.") - return execute { - val refresh = unwrap(service.refresh(RefreshRequest(biometric.second))) - val user = unwrap(service.me("Bearer ${refresh.accessToken}")).user - val session = SessionSnapshot( - accessToken = refresh.accessToken, - refreshToken = refresh.refreshToken, - user = user - ) - sessionStore.save(session) - localCache.saveProfile(user) - sessionStore.updateBiometricSession(user.email, refresh.refreshToken) - session + return try { + execute { + val response = unwrap(service.biometricLogin(BiometricTokenRequest(biometric.second))) + val session = SessionSnapshot( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + user = response.user + ) + sessionStore.save(session) + localCache.saveProfile(response.user) + sessionStore.updateBiometricSession(response.user.email, response.biometricToken) + session + } + } catch (error: AppException) { + if (error.code == "BIOMETRIC_EXPIRED" || error.code == "BIOMETRIC_INVALIDATED") { + sessionStore.declineBiometricAccess() + } + throw error } } fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState() - fun enableBiometricAccess() { + suspend fun enableBiometricAccess() { val session = sessionStore.load() 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.") - sessionStore.saveBiometricAccess(user.email, refreshToken) + val accessToken = session.accessToken ?: throw AppException("No hay sesión para activar biometría.") + val response = execute { + unwrap(service.issueBiometricToken("Bearer $accessToken")) + } + sessionStore.saveBiometricAccess(user.email, response.biometricToken) } - fun declineBiometricAccess() { - sessionStore.declineBiometricAccess() - } - - fun syncBiometricSession() { + suspend fun declineBiometricAccess() { val session = sessionStore.load() - val user = session.user ?: return - val refreshToken = session.refreshToken ?: return - sessionStore.updateBiometricSession(user.email, refreshToken) + val accessToken = session.accessToken + if (!accessToken.isNullOrBlank()) { + runCatching { + execute { + unwrap(service.revokeBiometricToken("Bearer $accessToken")) + } + } + } + sessionStore.declineBiometricAccess() } suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData = @@ -243,7 +253,6 @@ class LinkedSignRepository(private val context: Context) { refreshToken = refresh.refreshToken ) sessionStore.save(updated) - sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken) return updated } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt index 7814c62..bd32dbe 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt @@ -43,6 +43,21 @@ data class LoginData( 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( val message: String?, val userId: Int? diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt index 4f24f8a..0bcb4b7 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt @@ -75,22 +75,22 @@ class SessionStore(context: Context) { val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false) val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false) 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( enabled = enabled, configured = configured, 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() .putBoolean(KEY_BIO_ENABLED, true) .putBoolean(KEY_BIO_CONFIGURED, true) .putString(KEY_BIO_EMAIL, email) - .putString(KEY_BIO_REFRESH, refreshToken) + .putString(KEY_BIO_TOKEN, biometricToken) .apply() } @@ -99,28 +99,28 @@ class SessionStore(context: Context) { .putBoolean(KEY_BIO_ENABLED, false) .putBoolean(KEY_BIO_CONFIGURED, true) .remove(KEY_BIO_EMAIL) - .remove(KEY_BIO_REFRESH) + .remove(KEY_BIO_TOKEN) .apply() } - fun updateBiometricSession(email: String, refreshToken: String) { + fun updateBiometricSession(email: String, biometricToken: String) { if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) { return } preferences.edit() .putString(KEY_BIO_EMAIL, email) - .putString(KEY_BIO_REFRESH, refreshToken) + .putString(KEY_BIO_TOKEN, biometricToken) .apply() } - fun loadBiometricRefreshToken(): Pair? { + fun loadBiometricToken(): Pair? { val email = preferences.getString(KEY_BIO_EMAIL, null) - val refreshToken = preferences.getString(KEY_BIO_REFRESH, null) - if (email.isNullOrBlank() || refreshToken.isNullOrBlank()) { + val biometricToken = preferences.getString(KEY_BIO_TOKEN, null) + if (email.isNullOrBlank() || biometricToken.isNullOrBlank()) { return null } - return email to refreshToken + return email to biometricToken } companion object { @@ -131,7 +131,7 @@ class SessionStore(context: Context) { private const val KEY_BIO_ENABLED = "bio_enabled" private const val KEY_BIO_CONFIGURED = "bio_configured" 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 } } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt index 8d9040c..e6adee2 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt @@ -353,7 +353,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo appUnlocked = true } else { authMode = AuthMode.Login - viewModel.declineBiometricAccess() appUnlocked = true } }, @@ -455,8 +454,10 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo if (showBiometricOptIn) { AlertDialog( onDismissRequest = { - viewModel.declineBiometricAccess() - showBiometricOptIn = false + scope.launch { + viewModel.declineBiometricAccess() + showBiometricOptIn = false + } }, title = { Text("Activar acceso biométrico") }, text = { @@ -464,16 +465,23 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo }, confirmButton = { Button(onClick = { - viewModel.enableBiometricAccess() - showBiometricOptIn = false + scope.launch { + runCatching { viewModel.enableBiometricAccess() } + .onSuccess { showBiometricOptIn = false } + .onFailure { + snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo activar la huella.") + } + } }) { Text("Sí, usar huella") } }, dismissButton = { OutlinedButton(onClick = { - viewModel.declineBiometricAccess() - showBiometricOptIn = false + scope.launch { + viewModel.declineBiometricAccess() + showBiometricOptIn = false + } }) { Text("Ahora no") } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt index 7ce8598..a42e3e0 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt @@ -53,7 +53,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { suspend fun login(email: String, password: String) { val session = repository.login(email, password) - repository.syncBiometricSession() val (_, cache) = repository.loadLocalSnapshot() uiState = buildState(session, cache, loading = false) } @@ -162,12 +161,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { suspend fun accessToken(): String? = repository.getFreshAccessToken() - fun enableBiometricAccess() { + suspend fun enableBiometricAccess() { repository.enableBiometricAccess() refreshState() } - fun declineBiometricAccess() { + suspend fun declineBiometricAccess() { repository.declineBiometricAccess() refreshState() } diff --git a/backend/.env.example b/backend/.env.example index 1f881fe..7838e70 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/package.json b/backend/package.json index 905525b..d3d3402 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/config/env.js b/backend/src/config/env.js index d004d6b..3678c1d 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.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', diff --git a/backend/src/modules/auth/auth.controller.js b/backend/src/modules/auth/auth.controller.js index 928cee3..8b4e58f 100644 --- a/backend/src/modules/auth/auth.controller.js +++ b/backend/src/modules/auth/auth.controller.js @@ -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, diff --git a/backend/src/modules/auth/auth.repository.js b/backend/src/modules/auth/auth.repository.js index a61e365..70b9f57 100644 --- a/backend/src/modules/auth/auth.repository.js +++ b/backend/src/modules/auth/auth.repository.js @@ -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, diff --git a/backend/src/modules/auth/auth.routes.js b/backend/src/modules/auth/auth.routes.js index d8533be..78f32ae 100644 --- a/backend/src/modules/auth/auth.routes.js +++ b/backend/src/modules/auth/auth.routes.js @@ -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; diff --git a/backend/src/modules/auth/auth.service.js b/backend/src/modules/auth/auth.service.js index 3f96091..f0fcf52 100644 --- a/backend/src/modules/auth/auth.service.js +++ b/backend/src/modules/auth/auth.service.js @@ -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, diff --git a/backend/src/modules/auth/auth.validator.js b/backend/src/modules/auth/auth.validator.js index 27b266c..3f9e08f 100644 --- a/backend/src/modules/auth/auth.validator.js +++ b/backend/src/modules/auth/auth.validator.js @@ -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, }; diff --git a/frontend/package.json b/frontend/package.json index 33082dc..77119d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.1.3", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite",