workspace-v1/shared/libs/session-management/IMPLEMENTATION.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

13 KiB

Guía de Implementación: Gestión de Sesiones

Versión: 1.0.0 Tiempo estimado: 1-2 horas (adaptación) Complejidad: Media


Pre-requisitos

  • NestJS con TypeORM configurado
  • Tabla user_sessions creada
  • Módulo de autenticación existente

Paso 1: Crear DDL

-- Schema auth_management (si no existe)
CREATE SCHEMA IF NOT EXISTS auth_management;

-- Tabla de sesiones
CREATE TABLE auth_management.user_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    tenant_id UUID,
    session_token TEXT NOT NULL UNIQUE,
    refresh_token TEXT,
    user_agent TEXT,
    ip_address INET,
    device_type VARCHAR(50) CHECK (device_type IN ('desktop', 'mobile', 'tablet', 'unknown')),
    browser VARCHAR(100),
    os VARCHAR(100),
    country VARCHAR(100),
    city VARCHAR(100),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    last_activity_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    revoked_at TIMESTAMPTZ,
    metadata JSONB DEFAULT '{}',

    CONSTRAINT valid_expires CHECK (expires_at > created_at)
);

-- Índices para performance
CREATE INDEX idx_user_sessions_user_id ON auth_management.user_sessions(user_id);
CREATE INDEX idx_user_sessions_tenant_id ON auth_management.user_sessions(tenant_id);
CREATE INDEX idx_user_sessions_session_token ON auth_management.user_sessions(session_token);
CREATE INDEX idx_user_sessions_expires_at ON auth_management.user_sessions(expires_at);
CREATE INDEX idx_user_sessions_is_active ON auth_management.user_sessions(is_active);

-- Comentarios
COMMENT ON TABLE auth_management.user_sessions IS 'Sesiones activas de usuarios con tracking de dispositivo';
COMMENT ON COLUMN auth_management.user_sessions.refresh_token IS 'Token hasheado con SHA256, nunca texto plano';
COMMENT ON COLUMN auth_management.user_sessions.device_type IS 'Tipo de dispositivo detectado: desktop, mobile, tablet, unknown';

Paso 2: Crear Entity

// src/modules/auth/entities/user-session.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  Index,
} from 'typeorm';
import { Exclude } from 'class-transformer';

@Entity({ schema: 'auth_management', name: 'user_sessions' })
@Index(['user_id'])
@Index(['session_token'])
@Index(['expires_at'])
export class UserSession {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column({ type: 'uuid' })
  user_id!: string;

  @Column({ type: 'uuid', nullable: true })
  tenant_id?: string;

  @Column({ type: 'text', unique: true })
  session_token!: string;

  @Column({ type: 'text', nullable: true })
  @Exclude() // IMPORTANTE: No serializar en respuestas
  refresh_token?: string;

  @Column({ type: 'text', nullable: true })
  user_agent?: string;

  @Column({ type: 'inet', nullable: true })
  ip_address?: string;

  @Column({ type: 'varchar', length: 50, nullable: true })
  device_type?: string;

  @Column({ type: 'varchar', length: 100, nullable: true })
  browser?: string;

  @Column({ type: 'varchar', length: 100, nullable: true })
  os?: string;

  @Column({ type: 'varchar', length: 100, nullable: true })
  country?: string;

  @Column({ type: 'varchar', length: 100, nullable: true })
  city?: string;

  @Column({ type: 'timestamp with time zone', default: () => 'NOW()' })
  created_at!: Date;

  @Column({ type: 'timestamp with time zone', default: () => 'NOW()' })
  last_activity_at!: Date;

  @Column({ type: 'timestamp with time zone' })
  expires_at!: Date;

  @Column({ type: 'boolean', default: true })
  is_active!: boolean;

  @Column({ type: 'timestamp with time zone', nullable: true })
  revoked_at?: Date;

  @Column({ type: 'jsonb', default: {} })
  metadata!: Record<string, any>;
}

Paso 3: Crear DTOs

// src/modules/auth/dto/create-user-session.dto.ts
import { IsString, IsUUID, IsOptional, IsDateString } from 'class-validator';

export class CreateUserSessionDto {
  @IsUUID()
  user_id!: string;

  @IsUUID()
  @IsOptional()
  tenant_id?: string;

  @IsString()
  session_token!: string;

  @IsString()
  @IsOptional()
  refresh_token?: string;

  @IsString()
  @IsOptional()
  user_agent?: string;

  @IsString()
  @IsOptional()
  ip_address?: string;

  @IsString()
  @IsOptional()
  device_type?: string;

  @IsString()
  @IsOptional()
  browser?: string;

  @IsString()
  @IsOptional()
  os?: string;

  @IsDateString()
  expires_at!: string;
}

// src/modules/auth/dto/user-session-response.dto.ts
export class UserSessionResponseDto {
  id!: string;
  device_type?: string;
  browser?: string;
  os?: string;
  ip_address?: string;
  country?: string;
  city?: string;
  created_at!: Date;
  last_activity_at!: Date;
  is_current?: boolean; // Calculado en runtime
}

Paso 4: Crear Service

// src/modules/auth/services/session-management.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import * as crypto from 'crypto';
import { UserSession } from '../entities/user-session.entity';
import { CreateUserSessionDto } from '../dto/create-user-session.dto';

@Injectable()
export class SessionManagementService {
  private readonly MAX_SESSIONS_PER_USER = 5;

  constructor(
    @InjectRepository(UserSession)
    private readonly sessionRepository: Repository<UserSession>,
  ) {}

  /**
   * Crear nueva sesión
   * - Limpia sesiones expiradas
   * - Si hay 5+, elimina la más antigua
   * - Hashea el refresh token
   */
  async createSession(dto: CreateUserSessionDto): Promise<UserSession> {
    // Limpiar sesiones expiradas
    await this.deleteExpiredSessions(dto.user_id);

    // Verificar límite
    const count = await this.countActiveSessions(dto.user_id);
    if (count >= this.MAX_SESSIONS_PER_USER) {
      await this.deleteOldestSession(dto.user_id);
    }

    // Hashear refresh token
    const hashedRefreshToken = dto.refresh_token
      ? this.hashToken(dto.refresh_token)
      : null;

    // Crear sesión
    const session = this.sessionRepository.create({
      ...dto,
      refresh_token: hashedRefreshToken,
      expires_at: new Date(dto.expires_at),
    });

    return this.sessionRepository.save(session);
  }

  /**
   * Validar sesión y actualizar actividad
   */
  async validateSession(sessionId: string): Promise<UserSession | null> {
    const session = await this.sessionRepository.findOne({
      where: { id: sessionId },
    });

    if (!session) return null;

    // Validar expiración
    if (new Date() > session.expires_at) {
      await this.sessionRepository.delete({ id: sessionId });
      return null;
    }

    // Actualizar última actividad
    session.last_activity_at = new Date();
    await this.sessionRepository.save(session);

    return session;
  }

  /**
   * Renovar sesión
   */
  async refreshSession(sessionId: string, newExpiresAt: Date): Promise<UserSession> {
    const session = await this.validateSession(sessionId);

    if (!session) {
      throw new NotFoundException('Sesión no encontrada o expirada');
    }

    session.expires_at = newExpiresAt;
    session.last_activity_at = new Date();

    return this.sessionRepository.save(session);
  }

  /**
   * Revocar sesión específica (con validación de ownership)
   */
  async revokeSession(sessionId: string, userId: string): Promise<{ message: string }> {
    const session = await this.sessionRepository.findOne({
      where: { id: sessionId, user_id: userId }, // Validación de ownership
    });

    if (!session) {
      throw new NotFoundException('Sesión no encontrada');
    }

    session.is_active = false;
    session.revoked_at = new Date();
    await this.sessionRepository.save(session);

    return { message: 'Sesión cerrada correctamente' };
  }

  /**
   * Revocar todas las sesiones excepto la actual
   */
  async revokeAllSessions(
    userId: string,
    currentSessionId: string,
  ): Promise<{ message: string; count: number }> {
    const sessions = await this.sessionRepository.find({
      where: { user_id: userId, is_active: true },
    });

    const toRevoke = sessions.filter((s) => s.id !== currentSessionId);
    const now = new Date();

    for (const session of toRevoke) {
      session.is_active = false;
      session.revoked_at = now;
    }

    await this.sessionRepository.save(toRevoke);

    return {
      message: 'Sesiones cerradas correctamente',
      count: toRevoke.length,
    };
  }

  /**
   * Limpiar sesiones expiradas (para cron job)
   */
  async cleanExpiredSessions(): Promise<number> {
    const result = await this.sessionRepository.delete({
      expires_at: LessThan(new Date()),
    });
    return result.affected || 0;
  }

  /**
   * Obtener sesiones activas del usuario
   */
  async getSessions(userId: string): Promise<UserSession[]> {
    return this.sessionRepository.find({
      where: { user_id: userId, is_active: true },
      order: { last_activity_at: 'DESC' },
      select: [
        'id',
        'device_type',
        'browser',
        'os',
        'ip_address',
        'country',
        'city',
        'created_at',
        'last_activity_at',
      ],
    });
  }

  /**
   * Buscar sesión por refresh token hasheado
   */
  async findByRefreshToken(
    userId: string,
    refreshToken: string,
  ): Promise<UserSession | null> {
    const hashedToken = this.hashToken(refreshToken);
    return this.sessionRepository.findOne({
      where: {
        user_id: userId,
        refresh_token: hashedToken,
        is_active: true,
      },
    });
  }

  // === Helpers privados ===

  private async countActiveSessions(userId: string): Promise<number> {
    return this.sessionRepository.count({
      where: { user_id: userId, is_active: true },
    });
  }

  private async deleteOldestSession(userId: string): Promise<void> {
    const oldest = await this.sessionRepository.findOne({
      where: { user_id: userId },
      order: { created_at: 'ASC' },
    });

    if (oldest) {
      await this.sessionRepository.delete({ id: oldest.id });
    }
  }

  private async deleteExpiredSessions(userId: string): Promise<void> {
    await this.sessionRepository.delete({
      user_id: userId,
      expires_at: LessThan(new Date()),
    });
  }

  private hashToken(token: string): string {
    return crypto.createHash('sha256').update(token).digest('hex');
  }
}

Paso 5: Registrar en Módulo

// src/modules/auth/auth.module.ts
import { SessionManagementService } from './services/session-management.service';
import { UserSession } from './entities/user-session.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([User, UserSession]),
    // ...
  ],
  providers: [
    AuthService,
    SessionManagementService, // Agregar
    JwtStrategy,
  ],
  exports: [
    AuthService,
    SessionManagementService, // Exportar si se usa en otros módulos
  ],
})
export class AuthModule {}

Paso 6: Agregar Endpoints

// src/modules/auth/controllers/users.controller.ts
import { Controller, Get, Delete, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { SessionManagementService } from '../services/session-management.service';

@ApiTags('Users')
@Controller('users')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UsersController {
  constructor(
    private readonly sessionService: SessionManagementService,
  ) {}

  @Get('sessions')
  async getSessions(@Request() req) {
    return this.sessionService.getSessions(req.user.id);
  }

  @Delete('sessions/:id')
  async revokeSession(@Param('id') sessionId: string, @Request() req) {
    return this.sessionService.revokeSession(sessionId, req.user.id);
  }

  @Post('sessions/revoke-all')
  async revokeAllSessions(
    @Request() req,
    @Body() body: { currentSessionId: string },
  ) {
    return this.sessionService.revokeAllSessions(req.user.id, body.currentSessionId);
  }
}

Paso 7: Configurar Cron Job (Opcional)

// src/modules/tasks/tasks.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SessionManagementService } from '@/modules/auth/services/session-management.service';

@Injectable()
export class TasksService {
  private readonly logger = new Logger(TasksService.name);

  constructor(
    private readonly sessionService: SessionManagementService,
  ) {}

  @Cron(CronExpression.EVERY_HOUR)
  async cleanExpiredSessions() {
    const count = await this.sessionService.cleanExpiredSessions();
    this.logger.log(`Limpiadas ${count} sesiones expiradas`);
  }
}
# Instalar @nestjs/schedule si no está instalado
npm install @nestjs/schedule

Checklist de Implementación

  • DDL creado y ejecutado
  • Entity alineada con DDL
  • DTOs con validaciones
  • Service con todos los métodos
  • Service registrado en módulo
  • Endpoints agregados al controller
  • Cron job configurado (opcional)
  • Build pasa sin errores
  • Tests básicos funcionando

Código de Referencia

Ver implementación completa en:

  • projects/gamilit/apps/backend/src/modules/auth/services/session-management.service.ts
  • projects/gamilit/apps/backend/src/modules/auth/entities/user-session.entity.ts

Versión: 1.0.0 Sistema: SIMCO Catálogo