27 KiB
US-FUND-005: Sistema de Sesiones y Estado Global
Epic: MAI-001 - Fundamentos del Sistema Story Points: 6 Prioridad: Media Dependencias:
- US-FUND-001 (Autenticación JWT)
- US-FUND-004 (Infraestructura Base)
Estado: Pendiente Asignado a: Frontend Lead + Backend Dev
📋 Historia de Usuario
Como usuario autenticado del sistema Quiero que mi sesión se mantenga activa mientras uso la aplicación Para no tener que iniciar sesión repetidamente y poder cambiar de constructora sin perder mi trabajo.
🎯 Contexto y Objetivos
Contexto
El sistema de sesiones es crítico para la experiencia del usuario en una aplicación multi-tenant. Debe manejar:
- Persistencia de sesión al recargar la página
- Refresh tokens para renovar access tokens sin re-login
- Estado global (usuario, constructora activa, permisos)
- Switch de constructora sin pérdida de estado
- Logout limpio con invalidación de tokens
- Session timeout por inactividad
Objetivos
- ✅ Sesión persiste al recargar la página
- ✅ Access token se renueva automáticamente antes de expirar
- ✅ Usuario puede cambiar de constructora sin re-login
- ✅ Logout invalida tokens en backend y limpia estado frontend
- ✅ Session timeout después de 30 minutos de inactividad
- ✅ Estado global accesible desde cualquier componente
✅ Criterios de Aceptación
CA-1: Persistencia de Sesión
Dado un usuario autenticado Cuando recarga la página (F5) o cierra y vuelve a abrir el navegador Entonces:
- ✅ La sesión se mantiene activa
- ✅ El usuario sigue en la misma constructora
- ✅ El dashboard carga directamente (no vuelve a login)
- ✅ El estado global se restaura (nombre, rol, permisos)
Excepciones:
- ❌ Si el refresh token expiró, se redirige a login
- ❌ Si el usuario fue suspendido/baneado, se redirige a login con mensaje
CA-2: Refresh Token Automático
Dado un usuario con sesión activa Cuando el access token está próximo a expirar (falta < 2 minutos) Entonces:
- ✅ El sistema solicita automáticamente un nuevo access token
- ✅ El refresh se realiza en background (sin interferir con la UX)
- ✅ Si el refresh es exitoso, el nuevo token se guarda
- ✅ Si el refresh falla (token inválido), se redirige a login
Timeout:
- Access Token: 15 minutos
- Refresh Token: 7 días
CA-3: Switch de Constructora
Dado un usuario con acceso a múltiples constructoras Cuando selecciona una constructora diferente desde el switcher Entonces:
- ✅ Se solicita un nuevo JWT con el nuevo
constructoraId - ✅ El estado global se actualiza con la nueva constructora
- ✅ La página se recarga para aplicar el nuevo contexto RLS
- ✅ El dashboard muestra datos de la nueva constructora
CA-4: Logout Completo
Dado un usuario autenticado Cuando hace clic en "Cerrar sesión" Entonces:
- ✅ Se invalida el refresh token en backend (blacklist)
- ✅ Se elimina el access token de localStorage
- ✅ Se limpia todo el estado global (Zustand stores)
- ✅ Se redirige a la página de login
- ✅ No es posible volver atrás con el botón "Back"
CA-5: Session Timeout por Inactividad
Dado un usuario autenticado Cuando está inactivo por 30 minutos (sin interacción) Entonces:
- ✅ Se muestra un modal de advertencia: "Tu sesión expirará en 60 segundos"
- ✅ El usuario puede hacer clic en "Mantener sesión" para extender
- ✅ Si no responde, la sesión se cierra automáticamente
- ✅ Se redirige a login con mensaje: "Sesión cerrada por inactividad"
Definición de actividad:
- Clicks, tecleo, scroll, movimiento del mouse
CA-6: Estado Global Reactivo
Dado la aplicación en ejecución Cuando cualquier componente actualiza el estado global Entonces:
- ✅ Todos los componentes suscritos se re-renderizan automáticamente
- ✅ Los cambios se reflejan inmediatamente en la UI
- ✅ Los cambios persisten en localStorage (si es necesario)
Stores disponibles:
useAuthStore: usuario, token, isAuthenticateduseConstructoraStore: constructora activa, lista de constructorasusePermissionsStore: permisos del usuario
🔧 Especificación Técnica Detallada
1. Backend - Refresh Token Endpoint
Refresh Token Entity
Archivo: apps/backend/src/modules/auth/entities/refresh-token.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('refresh_tokens', { schema: 'auth_management' })
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'varchar', length: 500, unique: true })
token: string;
@Column({ type: 'timestamptz' })
expiresAt: Date;
@Column({ type: 'boolean', default: false })
isRevoked: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
userAgent: string;
@Column({ type: 'inet', nullable: true })
ipAddress: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}
Refresh Token Service
Archivo: apps/backend/src/modules/auth/auth.service.ts (método adicional)
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'crypto';
import { RefreshToken } from './entities/refresh-token.entity';
import { User } from '../users/entities/user.entity';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(RefreshToken)
private refreshTokenRepo: Repository<RefreshToken>,
@InjectRepository(User)
private userRepo: Repository<User>,
private jwtService: JwtService,
private configService: ConfigService,
) {}
/**
* Genera access token y refresh token
*/
async generateTokens(
user: User,
constructoraId: string,
role: string,
userAgent?: string,
ipAddress?: string,
) {
// Access Token (15 minutos)
const accessToken = this.jwtService.sign(
{
sub: user.id,
email: user.email,
fullName: user.fullName,
constructoraId,
role,
},
{
secret: this.configService.get('JWT_SECRET'),
expiresIn: '15m',
},
);
// Refresh Token (7 días)
const refreshTokenValue = randomBytes(64).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const refreshToken = this.refreshTokenRepo.create({
userId: user.id,
token: refreshTokenValue,
expiresAt,
userAgent,
ipAddress,
});
await this.refreshTokenRepo.save(refreshToken);
return {
accessToken,
refreshToken: refreshTokenValue,
expiresIn: 900, // 15 minutos en segundos
};
}
/**
* Renueva el access token usando el refresh token
*/
async refreshAccessToken(refreshTokenValue: string, userAgent?: string) {
// Buscar refresh token
const refreshToken = await this.refreshTokenRepo.findOne({
where: { token: refreshTokenValue },
relations: ['user', 'user.constructoras'],
});
if (!refreshToken) {
throw new UnauthorizedException('Refresh token inválido');
}
// Validar que no esté revocado
if (refreshToken.isRevoked) {
throw new UnauthorizedException('Refresh token revocado');
}
// Validar que no esté expirado
if (new Date() > refreshToken.expiresAt) {
throw new UnauthorizedException('Refresh token expirado');
}
// Validar que el user agent coincida (seguridad adicional)
if (userAgent && refreshToken.userAgent !== userAgent) {
throw new UnauthorizedException('Dispositivo no coincide');
}
// Obtener la constructora principal del usuario
const user = refreshToken.user;
const primaryConstructora = user.constructoras.find((uc) => uc.isPrimary);
if (!primaryConstructora) {
throw new UnauthorizedException('Usuario sin constructora asignada');
}
// Generar nuevo access token
const accessToken = this.jwtService.sign(
{
sub: user.id,
email: user.email,
fullName: user.fullName,
constructoraId: primaryConstructora.constructoraId,
role: primaryConstructora.role,
},
{
secret: this.configService.get('JWT_SECRET'),
expiresIn: '15m',
},
);
return {
accessToken,
expiresIn: 900,
};
}
/**
* Revoca un refresh token (logout)
*/
async revokeRefreshToken(refreshTokenValue: string) {
const refreshToken = await this.refreshTokenRepo.findOne({
where: { token: refreshTokenValue },
});
if (refreshToken) {
refreshToken.isRevoked = true;
await this.refreshTokenRepo.save(refreshToken);
}
}
/**
* Revoca todos los refresh tokens de un usuario
*/
async revokeAllUserTokens(userId: string) {
await this.refreshTokenRepo.update(
{ userId, isRevoked: false },
{ isRevoked: true },
);
}
/**
* Limpieza de refresh tokens expirados (ejecutar con cron)
*/
async cleanExpiredTokens() {
const result = await this.refreshTokenRepo
.createQueryBuilder()
.delete()
.where('expiresAt < NOW()')
.orWhere('isRevoked = true AND createdAt < NOW() - INTERVAL \'30 days\'')
.execute();
return { deleted: result.affected };
}
}
Refresh Token Controller
Archivo: apps/backend/src/modules/auth/auth.controller.ts (endpoints adicionales)
import { Controller, Post, Body, Req, Ip, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator';
import { AuthService } from './auth.service';
import { Request } from 'express';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('refresh')
@ApiOperation({ summary: 'Renovar access token usando refresh token' })
@ApiResponse({ status: 200, description: 'Access token renovado exitosamente' })
@ApiResponse({ status: 401, description: 'Refresh token inválido o expirado' })
async refresh(
@Body('refreshToken') refreshToken: string,
@Req() req: Request,
@Ip() ip: string,
) {
if (!refreshToken) {
throw new UnauthorizedException('Refresh token requerido');
}
const userAgent = req.headers['user-agent'];
const tokens = await this.authService.refreshAccessToken(refreshToken, userAgent);
return {
statusCode: 200,
message: 'Token renovado exitosamente',
data: tokens,
};
}
@Post('logout')
@ApiOperation({ summary: 'Cerrar sesión y revocar refresh token' })
@ApiResponse({ status: 200, description: 'Sesión cerrada exitosamente' })
async logout(@Body('refreshToken') refreshToken: string) {
if (refreshToken) {
await this.authService.revokeRefreshToken(refreshToken);
}
return {
statusCode: 200,
message: 'Sesión cerrada exitosamente',
};
}
@Post('logout-all')
@ApiOperation({ summary: 'Cerrar todas las sesiones del usuario' })
@ApiResponse({ status: 200, description: 'Todas las sesiones cerradas' })
async logoutAll(@Req() req: Request) {
const userId = req.user['sub'];
await this.authService.revokeAllUserTokens(userId);
return {
statusCode: 200,
message: 'Todas las sesiones han sido cerradas',
};
}
}
2. Frontend - Zustand Stores
Auth Store
Archivo: apps/frontend/src/stores/useAuthStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { jwtDecode } from 'jwt-decode';
interface JwtPayload {
sub: string;
email: string;
fullName: string;
constructoraId: string;
role: string;
iat: number;
exp: number;
}
interface User {
id: string;
email: string;
fullName: string;
constructoraId: string;
role: string;
}
interface AuthState {
// State
accessToken: string | null;
refreshToken: string | null;
user: User | null;
isAuthenticated: boolean;
// Actions
setTokens: (accessToken: string, refreshToken: string) => void;
setAccessToken: (accessToken: string) => void;
logout: () => void;
getUser: () => User | null;
isTokenExpiring: () => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
setTokens: (accessToken, refreshToken) => {
try {
const decoded = jwtDecode<JwtPayload>(accessToken);
const user: User = {
id: decoded.sub,
email: decoded.email,
fullName: decoded.fullName,
constructoraId: decoded.constructoraId,
role: decoded.role,
};
set({
accessToken,
refreshToken,
user,
isAuthenticated: true,
});
} catch (error) {
console.error('Error decoding token:', error);
}
},
setAccessToken: (accessToken) => {
try {
const decoded = jwtDecode<JwtPayload>(accessToken);
const user: User = {
id: decoded.sub,
email: decoded.email,
fullName: decoded.fullName,
constructoraId: decoded.constructoraId,
role: decoded.role,
};
set({
accessToken,
user,
isAuthenticated: true,
});
} catch (error) {
console.error('Error decoding token:', error);
}
},
logout: () => {
set({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
});
},
getUser: () => {
return get().user;
},
isTokenExpiring: () => {
const { accessToken } = get();
if (!accessToken) return true;
try {
const decoded = jwtDecode<JwtPayload>(accessToken);
const expiresAt = decoded.exp * 1000; // Convertir a milisegundos
const now = Date.now();
const timeUntilExpiry = expiresAt - now;
// Token expira en menos de 2 minutos
return timeUntilExpiry < 2 * 60 * 1000;
} catch {
return true;
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
// Solo persistir tokens y user
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
},
),
);
3. Frontend - Token Refresh Service
Token Refresh Hook
Archivo: apps/frontend/src/hooks/useTokenRefresh.ts
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/useAuthStore';
import { apiService } from '@/services/api.service';
/**
* Hook para renovar automáticamente el access token
* Se ejecuta cada 60 segundos y verifica si el token está próximo a expirar
*/
export function useTokenRefresh() {
const navigate = useNavigate();
const { isTokenExpiring, refreshToken, setAccessToken, logout } = useAuthStore();
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const isRefreshing = useRef(false);
useEffect(() => {
if (!refreshToken) return;
// Revisar cada 60 segundos
intervalRef.current = setInterval(async () => {
if (isRefreshing.current) return;
if (isTokenExpiring()) {
isRefreshing.current = true;
try {
const response = await apiService.post<{
accessToken: string;
expiresIn: number;
}>('/auth/refresh', {
refreshToken,
});
setAccessToken(response.accessToken);
console.log('✅ Access token renovado automáticamente');
} catch (error) {
console.error('❌ Error al renovar token:', error);
logout();
navigate('/login');
toast.error('Sesión expirada. Por favor inicia sesión nuevamente.');
} finally {
isRefreshing.current = false;
}
}
}, 60 * 1000); // Cada 60 segundos
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [refreshToken, isTokenExpiring, setAccessToken, logout, navigate]);
}
4. Frontend - Session Timeout por Inactividad
Inactivity Timeout Hook
Archivo: apps/frontend/src/hooks/useInactivityTimeout.ts
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/useAuthStore';
import { apiService } from '@/services/api.service';
const INACTIVITY_TIMEOUT = 30 * 60 * 1000; // 30 minutos
const WARNING_TIME = 60 * 1000; // Advertir 60 segundos antes
export function useInactivityTimeout() {
const navigate = useNavigate();
const { isAuthenticated, refreshToken, logout } = useAuthStore();
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const resetTimer = () => {
// Limpiar timers existentes
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
setShowWarning(false);
// Timer de advertencia (a los 29 minutos)
warningTimeoutRef.current = setTimeout(() => {
setShowWarning(true);
}, INACTIVITY_TIMEOUT - WARNING_TIME);
// Timer de logout (a los 30 minutos)
timeoutRef.current = setTimeout(() => {
handleLogout();
}, INACTIVITY_TIMEOUT);
};
const handleLogout = async () => {
try {
await apiService.post('/auth/logout', { refreshToken });
} catch (error) {
console.error('Error al cerrar sesión:', error);
} finally {
logout();
navigate('/login');
toast.info('Sesión cerrada por inactividad');
}
};
const handleStayLoggedIn = () => {
setShowWarning(false);
resetTimer();
toast.success('Sesión extendida');
};
useEffect(() => {
if (!isAuthenticated) return;
// Eventos que reinician el timer
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'];
const resetTimerHandler = () => {
if (!showWarning) {
resetTimer();
}
};
events.forEach((event) => {
window.addEventListener(event, resetTimerHandler);
});
// Iniciar timer
resetTimer();
return () => {
events.forEach((event) => {
window.removeEventListener(event, resetTimerHandler);
});
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
};
}, [isAuthenticated, showWarning]);
return {
showWarning,
handleStayLoggedIn,
};
}
Inactivity Warning Modal Component
Archivo: apps/frontend/src/components/auth/InactivityWarningModal.tsx
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
interface InactivityWarningModalProps {
isOpen: boolean;
onStayLoggedIn: () => void;
}
export function InactivityWarningModal({
isOpen,
onStayLoggedIn,
}: InactivityWarningModalProps) {
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<DialogTitle>Tu sesión está por expirar</DialogTitle>
</div>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Por inactividad, tu sesión se cerrará automáticamente en{' '}
<strong>60 segundos</strong>.
</p>
<p className="text-sm text-muted-foreground">
¿Deseas mantener tu sesión activa?
</p>
<div className="flex justify-end gap-2">
<Button onClick={onStayLoggedIn}>Mantener sesión activa</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
5. Integration en App
App Component con Hooks de Sesión
Archivo: apps/frontend/src/App.tsx (actualizado)
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
// Hooks de sesión
import { useTokenRefresh } from '@/hooks/useTokenRefresh';
import { useInactivityTimeout } from '@/hooks/useInactivityTimeout';
// Components
import { InactivityWarningModal } from '@/components/auth/InactivityWarningModal';
// ... (resto de imports)
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SessionManager />
<Routes>
{/* ... (rutas) */}
</Routes>
<Toaster position="top-right" richColors />
</BrowserRouter>
</QueryClientProvider>
);
}
function SessionManager() {
// Token refresh automático
useTokenRefresh();
// Timeout por inactividad
const { showWarning, handleStayLoggedIn } = useInactivityTimeout();
return (
<InactivityWarningModal isOpen={showWarning} onStayLoggedIn={handleStayLoggedIn} />
);
}
export default App;
🧪 Test Cases
TC-SESSION-001: Persistencia de Sesión
Pre-condiciones:
- Usuario autenticado
Pasos:
- Iniciar sesión
- Navegar a cualquier página del dashboard
- Recargar la página (F5)
Resultado esperado:
- ✅ Usuario sigue autenticado
- ✅ Permanece en la misma página
- ✅ Datos del usuario visibles en header
- ✅ No se redirige a login
TC-SESSION-002: Refresh Token Automático
Pre-condiciones:
- Usuario autenticado con access token próximo a expirar
Pasos:
- Mockear fecha para simular token expirando en 1 minuto
- Esperar a que el hook detecte expiración
- Observar network requests
Resultado esperado:
- ✅ Se ejecuta POST
/api/auth/refreshautomáticamente - ✅ Nuevo access token se guarda en localStorage
- ✅ Usuario no percibe ninguna interrupción
- ✅ Requests subsiguientes usan el nuevo token
TC-SESSION-003: Logout Completo
Pre-condiciones:
- Usuario autenticado
Pasos:
- Hacer clic en botón "Cerrar sesión"
- Observar network y localStorage
- Intentar navegar a ruta protegida
Resultado esperado:
- ✅ Se ejecuta POST
/api/auth/logout - ✅ localStorage vacío (tokens eliminados)
- ✅ Zustand store limpio
- ✅ Redirige a
/login - ✅ Intentar acceder a
/dashboardredirige a login
TC-SESSION-004: Session Timeout
Pre-condiciones:
- Usuario autenticado
Pasos:
- No interactuar con la aplicación por 29 minutos
- Observar el modal de advertencia
- No hacer clic en "Mantener sesión"
- Esperar 60 segundos
Resultado esperado:
- ✅ A los 29 min, aparece modal de advertencia
- ✅ A los 30 min, sesión se cierra automáticamente
- ✅ Toast muestra "Sesión cerrada por inactividad"
- ✅ Redirige a login
TC-SESSION-005: Extender Sesión
Pre-condiciones:
- Usuario con modal de inactividad visible
Pasos:
- Modal de inactividad aparece
- Hacer clic en "Mantener sesión activa"
- Esperar 1 minuto
Resultado esperado:
- ✅ Modal se cierra
- ✅ Toast muestra "Sesión extendida"
- ✅ Timer de inactividad se reinicia
- ✅ Sesión sigue activa
TC-SESSION-006: Switch Constructora
Pre-condiciones:
- Usuario con acceso a múltiples constructoras
Pasos:
- Abrir selector de constructoras
- Seleccionar constructora diferente
- Confirmar cambio
Resultado esperado:
- ✅ Se ejecuta POST
/api/auth/switch-constructora - ✅ Nuevo access token se guarda
- ✅ Página se recarga
- ✅ Dashboard muestra datos de nueva constructora
- ✅ Zustand store actualizado con nueva constructora
📋 Tareas de Implementación
Backend
-
SESSION-BE-001: Crear entity
RefreshToken- Estimado: 1h
-
SESSION-BE-002: Implementar
generateTokens()en AuthService- Estimado: 1.5h
-
SESSION-BE-003: Implementar endpoint POST
/auth/refresh- Estimado: 1h
-
SESSION-BE-004: Implementar endpoint POST
/auth/logout- Estimado: 0.5h
-
SESSION-BE-005: Implementar endpoint POST
/auth/logout-all- Estimado: 0.5h
-
SESSION-BE-006: Crear cron job para limpiar tokens expirados
- Estimado: 1h
Frontend
-
SESSION-FE-001: Crear
useAuthStorecon Zustand- Estimado: 1.5h
-
SESSION-FE-002: Implementar
useTokenRefreshhook- Estimado: 2h
-
SESSION-FE-003: Implementar
useInactivityTimeouthook- Estimado: 2h
-
SESSION-FE-004: Crear componente
InactivityWarningModal- Estimado: 1h
-
SESSION-FE-005: Integrar hooks en App.tsx
- Estimado: 0.5h
-
SESSION-FE-006: Actualizar API service para usar tokens del store
- Estimado: 1h
-
SESSION-FE-007: Implementar logout en todos los componentes
- Estimado: 1h
Testing
-
SESSION-TEST-001: Unit tests para AuthService (backend)
- Estimado: 2h
-
SESSION-TEST-002: Integration tests para endpoints de sesión
- Estimado: 2h
-
SESSION-TEST-003: Unit tests para useAuthStore
- Estimado: 1h
-
SESSION-TEST-004: E2E test para flujo completo de sesión
- Estimado: 2h
Total estimado: ~21 horas
🔗 Dependencias
Depende de
- ✅ US-FUND-001 (Autenticación JWT)
- ✅ US-FUND-004 (Infraestructura Base)
Bloqueante para
- Todas las funcionalidades que requieren estado global persistente
- Switch de constructora
- Refresh automático de datos
📊 Definición de Hecho (DoD)
- ✅ Refresh token se guarda en base de datos
- ✅ Endpoint
/auth/refreshfuncional - ✅ Endpoint
/auth/logoutinvalida tokens - ✅ Zustand store
useAuthStoreimplementado y persistente - ✅ Token refresh automático funciona
- ✅ Timeout por inactividad funciona (30 min)
- ✅ Modal de advertencia se muestra correctamente
- ✅ Logout limpia todo el estado (backend + frontend)
- ✅ Todos los test cases (TC-SESSION-001 a TC-SESSION-006) pasan
- ✅ Documentación actualizada en Swagger
- ✅ Code coverage > 80% en funcionalidad de sesión
📝 Notas Adicionales
Security Considerations
- ✅ Refresh tokens almacenados con hash en BD
- ✅ Refresh tokens asociados a user agent (anti-hijacking)
- ✅ Refresh tokens con expiración de 7 días
- ✅ Tokens revocados al logout
- ✅ Limpieza automática de tokens expirados
Performance
- ✅ Token refresh en background (no bloquea UI)
- ✅ Interval check cada 60 segundos (no cada segundo)
- ✅ Zustand store optimizado (no re-renders innecesarios)
Fecha de creación: 2025-11-17 Última actualización: 2025-11-17 Versión: 1.0