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

542 lines
13 KiB
Markdown

# 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
```sql
-- 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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)
```typescript
// 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`);
}
}
```
```bash
# 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