Fix session expiration handling
This commit is contained in:
+7
-1
@@ -2,10 +2,16 @@
|
||||
|
||||
## Versión actual
|
||||
|
||||
- Versión base: `1.0.2`
|
||||
- Versión base: `1.0.3`
|
||||
|
||||
## Historial
|
||||
|
||||
### 1.0.3 - 2026-04-23
|
||||
|
||||
- Se ajusta la duración completa de sesión web y Android a 15 minutos.
|
||||
- Se fuerza cierre local y retorno al inicio de sesión cuando la sesión expira o no puede renovarse.
|
||||
- Se traducen mensajes de expiración de sesión al español.
|
||||
|
||||
### 1.0.2 - 2026-04-08
|
||||
|
||||
- Se endurece el flujo biométrico Android para usar sesión por token en lugar de guardar la contraseña del usuario.
|
||||
|
||||
@@ -18,8 +18,8 @@ android {
|
||||
applicationId "net.linkedcorp.linkedsign"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 10002
|
||||
versionName "1.0.2"
|
||||
versionCode 10003
|
||||
versionName "1.0.3"
|
||||
|
||||
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
||||
}
|
||||
|
||||
@@ -214,8 +214,13 @@ class LinkedSignRepository(private val context: Context) {
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() != 401) throw mapHttpException(error)
|
||||
|
||||
val refreshToken = session.refreshToken ?: throw AppException("Tu sesión expiró. Ingresa nuevamente.")
|
||||
val refresh = unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||
val refreshToken = session.refreshToken ?: throwSessionExpired()
|
||||
val refresh = try {
|
||||
unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||
} catch (refreshError: Exception) {
|
||||
sessionStore.clearSession()
|
||||
throwSessionExpired()
|
||||
}
|
||||
val updated = session.copy(
|
||||
accessToken = refresh.accessToken,
|
||||
refreshToken = refresh.refreshToken
|
||||
@@ -226,6 +231,10 @@ class LinkedSignRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun throwSessionExpired(): Nothing {
|
||||
throw AppException("Tu sesión expiró. Vuelve a iniciar sesión.", "SESSION_EXPIRED")
|
||||
}
|
||||
|
||||
private suspend fun <T> execute(block: suspend () -> T): T {
|
||||
return try {
|
||||
block()
|
||||
|
||||
@@ -156,7 +156,8 @@ data class SignData(
|
||||
data class SessionSnapshot(
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val user: SessionUser? = null
|
||||
val user: SessionUser? = null,
|
||||
val expiresAt: Long? = null
|
||||
)
|
||||
|
||||
data class BiometricAccessState(
|
||||
|
||||
@@ -3,8 +3,10 @@ package net.linkedcorp.linkedsign.data
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import android.util.Base64
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.json.JSONObject
|
||||
|
||||
class SessionStore(context: Context) {
|
||||
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||
@@ -26,15 +28,27 @@ class SessionStore(context: Context) {
|
||||
val accessToken = preferences.getString(KEY_ACCESS, null)
|
||||
val refreshToken = preferences.getString(KEY_REFRESH, null)
|
||||
val userJson = preferences.getString(KEY_USER, null)
|
||||
val expiresAt = preferences.getLong(KEY_EXPIRES_AT, 0L).takeIf { it > 0L }
|
||||
|
||||
if (expiresAt != null && expiresAt <= System.currentTimeMillis()) {
|
||||
clearSession()
|
||||
return SessionSnapshot()
|
||||
}
|
||||
|
||||
val user = userJson?.let { userAdapter.fromJson(it) }
|
||||
return SessionSnapshot(accessToken, refreshToken, user)
|
||||
return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
|
||||
}
|
||||
|
||||
fun save(session: SessionSnapshot) {
|
||||
val expiresAt = session.expiresAt
|
||||
?: session.accessToken?.let { readAccessTokenExpiry(it) }
|
||||
?: (System.currentTimeMillis() + DEFAULT_SESSION_MS)
|
||||
|
||||
preferences.edit()
|
||||
.putString(KEY_ACCESS, session.accessToken)
|
||||
.putString(KEY_REFRESH, session.refreshToken)
|
||||
.putString(KEY_USER, session.user?.let { userAdapter.toJson(it) })
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -47,9 +61,21 @@ class SessionStore(context: Context) {
|
||||
.remove(KEY_ACCESS)
|
||||
.remove(KEY_REFRESH)
|
||||
.remove(KEY_USER)
|
||||
.remove(KEY_EXPIRES_AT)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun readAccessTokenExpiry(accessToken: String): Long? {
|
||||
return try {
|
||||
val payload = accessToken.split(".").getOrNull(1) ?: return null
|
||||
val decoded = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
|
||||
val exp = JSONObject(decoded).optLong("exp", 0L)
|
||||
if (exp > 0L) exp * 1000L else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun loadBiometricState(): BiometricAccessState {
|
||||
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
|
||||
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
|
||||
@@ -106,9 +132,11 @@ class SessionStore(context: Context) {
|
||||
private const val KEY_ACCESS = "access_token"
|
||||
private const val KEY_REFRESH = "refresh_token"
|
||||
private const val KEY_USER = "user"
|
||||
private const val KEY_EXPIRES_AT = "expires_at"
|
||||
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 DEFAULT_SESSION_MS = 15 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
|
||||
}
|
||||
|
||||
private fun normalize(error: Exception): AppException =
|
||||
if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
|
||||
private fun normalize(error: Exception): AppException {
|
||||
if (error is AppException && error.code == "SESSION_EXPIRED") {
|
||||
refreshState()
|
||||
}
|
||||
|
||||
return if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../config/env';
|
||||
import { loadSession, saveSession, clearSession } from '../storage/session';
|
||||
import { notifySessionExpired } from '../utils/sessionEvents';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -38,6 +39,7 @@ client.interceptors.response.use(
|
||||
const session = await loadSession();
|
||||
if (!session.refreshToken) {
|
||||
await clearSession();
|
||||
notifySessionExpired();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ client.interceptors.response.use(
|
||||
} catch (refreshError) {
|
||||
resolveQueue(refreshError, null);
|
||||
await clearSession();
|
||||
notifySessionExpired();
|
||||
throw refreshError;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { Alert, AppState } from 'react-native';
|
||||
import { changePassword as apiChangePassword, getMe, login as apiLogin, logout as apiLogout, register as apiRegister } from '../api/auth';
|
||||
import { initLocalDb, saveProfileCache } from '../storage/localDb';
|
||||
import { clearSession, loadSession, saveSession } from '../storage/session';
|
||||
import { SESSION_EXPIRED_MESSAGE, setSessionExpiredHandler } from '../utils/sessionEvents';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
@@ -11,6 +12,15 @@ export const AuthProvider = ({ children }) => {
|
||||
const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const clearLocalAuthState = useCallback(async ({ showAlert = false } = {}) => {
|
||||
await clearSession();
|
||||
setUser(null);
|
||||
setTokens({ accessToken: null, refreshToken: null });
|
||||
if (showAlert) {
|
||||
Alert.alert('Sesión expirada', SESSION_EXPIRED_MESSAGE);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bootstrap = async () => {
|
||||
initLocalDb();
|
||||
@@ -27,14 +37,56 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.data.user);
|
||||
saveProfileCache(data.data.user);
|
||||
} catch (error) {
|
||||
await clearSession();
|
||||
await clearLocalAuthState();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
}, []);
|
||||
}, [clearLocalAuthState]);
|
||||
|
||||
useEffect(() => {
|
||||
return setSessionExpiredHandler(() => {
|
||||
clearLocalAuthState({ showAlert: true });
|
||||
});
|
||||
}, [clearLocalAuthState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !tokens.accessToken) return undefined;
|
||||
|
||||
let timeoutId = null;
|
||||
let isMounted = true;
|
||||
|
||||
const enforceExpiry = async ({ showAlert = false } = {}) => {
|
||||
const session = await loadSession();
|
||||
if (!session.accessToken || !session.expiresAt || session.expiresAt <= Date.now()) {
|
||||
if (isMounted) {
|
||||
await clearLocalAuthState({ showAlert });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
clearLocalAuthState({ showAlert: true });
|
||||
}, Math.max(session.expiresAt - Date.now(), 0));
|
||||
};
|
||||
|
||||
enforceExpiry();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
enforceExpiry({ showAlert: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
subscription.remove();
|
||||
};
|
||||
}, [clearLocalAuthState, tokens.accessToken, user]);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const { data } = await apiLogin({ email, password });
|
||||
@@ -59,11 +111,9 @@ export const AuthProvider = ({ children }) => {
|
||||
} catch (error) {
|
||||
Alert.alert('Cerrar sesión', 'No se pudo notificar el cierre al backend. La sesión local se cerrará igual.');
|
||||
} finally {
|
||||
await clearSession();
|
||||
setUser(null);
|
||||
setTokens({ accessToken: null, refreshToken: null });
|
||||
await clearLocalAuthState();
|
||||
}
|
||||
}, []);
|
||||
}, [clearLocalAuthState]);
|
||||
|
||||
const requestPasswordChange = useCallback(async ({ currentPassword, newPassword }) => {
|
||||
return apiChangePassword({ currentPassword, newPassword });
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const SESSION_EXPIRED_MESSAGE = 'Tu sesión expiró. Vuelve a iniciar sesión.';
|
||||
|
||||
let sessionExpiredHandler = null;
|
||||
|
||||
export const setSessionExpiredHandler = (handler) => {
|
||||
sessionExpiredHandler = handler;
|
||||
return () => {
|
||||
if (sessionExpiredHandler === handler) {
|
||||
sessionExpiredHandler = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const notifySessionExpired = () => {
|
||||
if (sessionExpiredHandler) {
|
||||
sessionExpiredHandler();
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,7 @@ DB_USER=SignLinked
|
||||
DB_PASSWORD=
|
||||
JWT_SECRET=change_me
|
||||
JWT_ACCESS_EXPIRES=15m
|
||||
JWT_REFRESH_EXPIRES=7d
|
||||
JWT_REFRESH_EXPIRES=15m
|
||||
STORAGE_PATH=/tmp/linkedsign_storage
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkedsign-backend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"private": true,
|
||||
"description": "Initial backend for LinkedSign",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -11,6 +11,24 @@ const requireEnv = (name) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseDurationMs = (value, fallbackMs) => {
|
||||
if (!value) return fallbackMs;
|
||||
const match = String(value).trim().match(/^(\d+)(ms|s|m|h|d)?$/i);
|
||||
if (!match) return fallbackMs;
|
||||
|
||||
const amount = Number(match[1]);
|
||||
const unit = (match[2] || 'ms').toLowerCase();
|
||||
const multipliers = {
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return amount * multipliers[unit];
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
[
|
||||
'JWT_SECRET',
|
||||
@@ -53,7 +71,8 @@ const env = {
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'change_me',
|
||||
accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
|
||||
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
|
||||
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '15m',
|
||||
refreshExpiresMs: parseDurationMs(process.env.JWT_REFRESH_EXPIRES || '15m', 15 * 60 * 1000),
|
||||
},
|
||||
|
||||
storagePath: process.env.STORAGE_PATH || '/tmp/linkedsign_storage',
|
||||
|
||||
@@ -3,7 +3,12 @@ const { verify } = require('../utils/jwt');
|
||||
const authenticate = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, data: null, error: 'Authorization token required.' });
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'La sesión expiró. Vuelve a iniciar sesión.',
|
||||
code: 'SESSION_EXPIRED',
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
@@ -12,7 +17,12 @@ const authenticate = (req, res, next) => {
|
||||
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role, name: decoded.name };
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, data: null, error: 'Invalid or expired token.' });
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'La sesión expiró. Vuelve a iniciar sesión.',
|
||||
code: 'SESSION_EXPIRED',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const getRefreshCookieOptions = () => ({
|
||||
secure: env.nodeEnv === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
maxAge: env.jwt.refreshExpiresMs,
|
||||
});
|
||||
|
||||
const readRefreshToken = (req) => {
|
||||
@@ -51,7 +51,7 @@ const register = async (req, res, next) => {
|
||||
certificateFile: req.file,
|
||||
}, getContext(req));
|
||||
return sendSuccess(res, {
|
||||
message: 'Registration successful. Please check your email to verify your account.',
|
||||
message: 'Registro exitoso. Revisa tu correo para verificar tu cuenta.',
|
||||
userId: result.userId,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
@@ -83,7 +83,7 @@ const logout = async (req, res, next) => {
|
||||
try {
|
||||
await authService.logout(readRefreshToken(req));
|
||||
res.clearCookie(env.refreshCookieName, getRefreshCookieOptions());
|
||||
return sendSuccess(res, { message: 'Logged out.' }, 200);
|
||||
return sendSuccess(res, { message: 'Sesión cerrada.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ const logout = async (req, res, next) => {
|
||||
const changePassword = async (req, res, next) => {
|
||||
try {
|
||||
await authService.changePassword(req.user.id, req.body, getContext(req));
|
||||
return sendSuccess(res, { message: 'Password change requested. Please confirm the change from the email sent to your account.' }, 200);
|
||||
return sendSuccess(res, { message: 'Cambio de contraseña solicitado. Confirma el cambio desde el correo enviado a tu cuenta.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ const changePassword = async (req, res, next) => {
|
||||
const confirmPasswordChange = async (req, res, next) => {
|
||||
try {
|
||||
await authService.confirmPasswordChange(req.params.token);
|
||||
return sendSuccess(res, { message: 'Password changed successfully.' }, 200);
|
||||
return sendSuccess(res, { message: 'Contraseña cambiada correctamente.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ const requestUnlockPasswordChange = async (req, res, next) => {
|
||||
const verifyEmail = async (req, res, next) => {
|
||||
try {
|
||||
await authService.verifyEmail(req.params.token);
|
||||
return sendSuccess(res, { message: 'Email verified successfully.' }, 200);
|
||||
return sendSuccess(res, { message: 'Correo verificado correctamente.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const { getCertificatePath } = require('../../utils/storagePath');
|
||||
const { signAccess, signRefresh } = require('../../utils/jwt');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
const MAX_FAILED_LOGIN_ATTEMPTS = 3;
|
||||
@@ -157,7 +158,7 @@ const login = async ({ email, password }, context = {}) => {
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent });
|
||||
const err = new Error('Invalid credentials.');
|
||||
const err = new Error('Credenciales inválidas.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
@@ -210,7 +211,7 @@ const login = async ({ email, password }, context = {}) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const err = new Error('Invalid credentials.');
|
||||
const err = new Error('Credenciales inválidas.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
@@ -222,7 +223,7 @@ const login = async ({ email, password }, context = {}) => {
|
||||
try {
|
||||
({ verificationUrl } = await issueVerificationEmail(user));
|
||||
} catch (error) {
|
||||
const err = new Error('Your email is not verified and the verification email could not be resent. Please contact support.');
|
||||
const err = new Error('Tu correo no está verificado y no se pudo reenviar el correo de confirmación. Contacta soporte.');
|
||||
err.statusCode = 503;
|
||||
err.code = 'MAIL_SEND_FAILED';
|
||||
throw err;
|
||||
@@ -235,7 +236,7 @@ const login = async ({ email, password }, context = {}) => {
|
||||
userAgent: context.userAgent,
|
||||
metadata: { source: 'login' },
|
||||
});
|
||||
const err = new Error('Your email is not verified yet. A new verification link has been sent to your email.');
|
||||
const err = new Error('Tu correo aún no está verificado. Te enviamos un nuevo enlace de confirmación.');
|
||||
err.statusCode = 403;
|
||||
err.code = 'EMAIL_NOT_VERIFIED';
|
||||
err.data = { verificationUrl };
|
||||
@@ -246,7 +247,7 @@ const login = async ({ email, password }, context = {}) => {
|
||||
const accessToken = signAccess(payload);
|
||||
const refreshToken = signRefresh(payload);
|
||||
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs);
|
||||
await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt);
|
||||
|
||||
await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
|
||||
@@ -268,21 +269,22 @@ const login = async ({ email, password }, context = {}) => {
|
||||
|
||||
const refresh = async (token) => {
|
||||
if (!token) {
|
||||
const err = new Error('Invalid or expired refresh token.');
|
||||
const err = new Error('La sesión expiró. Vuelve a iniciar sesión.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const record = await authRepository.findRefreshToken(token);
|
||||
if (!record) {
|
||||
const err = new Error('Invalid or expired refresh token.');
|
||||
await authRepository.deleteRefreshToken(token);
|
||||
const err = new Error('La sesión expiró. Vuelve a iniciar sesión.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!record.is_active) {
|
||||
await authRepository.deleteUserRefreshTokens(record.user_id);
|
||||
const err = new Error('Session invalidated.');
|
||||
const err = new Error('La sesión fue invalidada. Vuelve a iniciar sesión.');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
@@ -292,7 +294,7 @@ const refresh = async (token) => {
|
||||
const payload = { sub: record.uid, email: record.email, role: record.role };
|
||||
const accessToken = signAccess(payload);
|
||||
const newRefreshToken = signRefresh(payload);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs);
|
||||
await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt);
|
||||
|
||||
return { accessToken, refreshToken: newRefreshToken };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
clearAccessToken,
|
||||
expireSession,
|
||||
getAccessToken,
|
||||
setAccessToken,
|
||||
} from '../utils/session';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
||||
|
||||
@@ -9,7 +15,7 @@ const client = axios.create({
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const token = getAccessToken();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
@@ -18,22 +24,30 @@ client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (!original) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
const hadAccessToken = Boolean(localStorage.getItem('accessToken'));
|
||||
const hadAccessToken = Boolean(getAccessToken());
|
||||
if (hadAccessToken) {
|
||||
try {
|
||||
const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true });
|
||||
const { accessToken } = data.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
setAccessToken(accessToken);
|
||||
original.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return client(original);
|
||||
} catch {
|
||||
localStorage.removeItem('accessToken');
|
||||
window.location.href = '/login';
|
||||
expireSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
clearAccessToken();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth';
|
||||
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
|
||||
import {
|
||||
clearAccessToken,
|
||||
expireSession,
|
||||
getAccessToken,
|
||||
getAccessTokenExpiryDelay,
|
||||
SESSION_EXPIRED_EVENT,
|
||||
setAccessToken,
|
||||
} from '../utils/session';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
@@ -8,13 +16,24 @@ export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const scheduleSessionExpiry = useCallback((token) => {
|
||||
const delay = getAccessTokenExpiryDelay(token);
|
||||
if (delay === null) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
expireSession();
|
||||
}, delay);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
getMe()
|
||||
.then(({ data }) => setUser(data.data.user))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('accessToken');
|
||||
clearAccessToken();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
@@ -22,17 +41,29 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSessionExpired = () => setUser(null);
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
if (!user || !token) return undefined;
|
||||
return scheduleSessionExpiry(token);
|
||||
}, [scheduleSessionExpiry, user]);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const { data } = await apiLogin({ email, password });
|
||||
const { accessToken, user: userData } = data.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
setAccessToken(accessToken);
|
||||
setUser(userData);
|
||||
return userData;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try { await apiLogout(); } catch { /* ignore */ }
|
||||
localStorage.removeItem('accessToken');
|
||||
clearAccessToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Download, PenTool } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { SESSION_EXPIRED_MESSAGE, translateApiError } from '../../utils/session';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form, setForm] = useState({ email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_URL || 'http://localhost:4000').replace(/\/$/, '');
|
||||
@@ -16,6 +18,13 @@ export default function LoginPage() {
|
||||
return /Android/i.test(navigator.userAgent || '');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('sessionExpired') === '1') {
|
||||
toast.error(SESSION_EXPIRED_MESSAGE);
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
@@ -26,7 +35,7 @@ export default function LoginPage() {
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
const code = err.response?.data?.code;
|
||||
const message = err.response?.data?.error || 'Error al iniciar sesión';
|
||||
const message = translateApiError(err, 'Error al iniciar sesión');
|
||||
|
||||
if (code === 'EMAIL_NOT_VERIFIED') {
|
||||
toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CheckSquare, FileCheck2, FileText, PenTool, ShieldCheck, Tag, X } from
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import { getLegalTemplatePreview, getLegalTemplates } from '../../api/public';
|
||||
import toast from 'react-hot-toast';
|
||||
import { translateApiError } from '../../utils/session';
|
||||
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
@@ -149,7 +150,7 @@ export default function RegisterPage() {
|
||||
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario y recibir los documentos legales firmados.');
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Error al registrarse');
|
||||
toast.error(translateApiError(err, 'Error al registrarse'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export const ACCESS_TOKEN_KEY = 'accessToken';
|
||||
export const SESSION_EXPIRED_MESSAGE = 'Tu sesión expiró. Vuelve a iniciar sesión.';
|
||||
export const SESSION_EXPIRED_EVENT = 'linkedsign:session-expired';
|
||||
|
||||
export const getAccessToken = () => localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
|
||||
export const setAccessToken = (token) => {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
export const clearAccessToken = () => {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
};
|
||||
|
||||
export const getAccessTokenExpiryDelay = (token) => {
|
||||
try {
|
||||
const [, payload] = token.split('.');
|
||||
const normalizedPayload = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const paddedPayload = normalizedPayload.padEnd(
|
||||
normalizedPayload.length + ((4 - (normalizedPayload.length % 4)) % 4),
|
||||
'='
|
||||
);
|
||||
const decoded = JSON.parse(window.atob(paddedPayload));
|
||||
if (!decoded.exp) return null;
|
||||
return Math.max(decoded.exp * 1000 - Date.now(), 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const translateApiError = (error, fallback = 'Ocurrió un error. Intenta nuevamente.') => {
|
||||
const code = error?.response?.data?.code;
|
||||
const message = error?.response?.data?.error;
|
||||
|
||||
if (code === 'SESSION_EXPIRED' || /token|session|sesión/i.test(message || '')) {
|
||||
return SESSION_EXPIRED_MESSAGE;
|
||||
}
|
||||
|
||||
return message || fallback;
|
||||
};
|
||||
|
||||
export const expireSession = ({ redirect = true } = {}) => {
|
||||
clearAccessToken();
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, {
|
||||
detail: { message: SESSION_EXPIRED_MESSAGE },
|
||||
}));
|
||||
|
||||
if (redirect && window.location.pathname !== '/login') {
|
||||
window.location.replace('/login?sessionExpired=1');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user