erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/historias-usuario/US-FUND-005-sistema-sesiones.md

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

  1. Sesión persiste al recargar la página
  2. Access token se renueva automáticamente antes de expirar
  3. Usuario puede cambiar de constructora sin re-login
  4. Logout invalida tokens en backend y limpia estado frontend
  5. Session timeout después de 30 minutos de inactividad
  6. 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, isAuthenticated
  • useConstructoraStore: constructora activa, lista de constructoras
  • usePermissionsStore: 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:

  1. Iniciar sesión
  2. Navegar a cualquier página del dashboard
  3. 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:

  1. Mockear fecha para simular token expirando en 1 minuto
  2. Esperar a que el hook detecte expiración
  3. Observar network requests

Resultado esperado:

  • Se ejecuta POST /api/auth/refresh automá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:

  1. Hacer clic en botón "Cerrar sesión"
  2. Observar network y localStorage
  3. 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 /dashboard redirige a login

TC-SESSION-004: Session Timeout

Pre-condiciones:

  • Usuario autenticado

Pasos:

  1. No interactuar con la aplicación por 29 minutos
  2. Observar el modal de advertencia
  3. No hacer clic en "Mantener sesión"
  4. 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:

  1. Modal de inactividad aparece
  2. Hacer clic en "Mantener sesión activa"
  3. 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:

  1. Abrir selector de constructoras
  2. Seleccionar constructora diferente
  3. 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 useAuthStore con Zustand

    • Estimado: 1.5h
  • SESSION-FE-002: Implementar useTokenRefresh hook

    • Estimado: 2h
  • SESSION-FE-003: Implementar useInactivityTimeout hook

    • 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/refresh funcional
  • Endpoint /auth/logout invalida tokens
  • Zustand store useAuthStore implementado 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