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
542 lines
13 KiB
Markdown
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
|