# 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; } ``` --- ## 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, ) {} /** * Crear nueva sesión * - Limpia sesiones expiradas * - Si hay 5+, elimina la más antigua * - Hashea el refresh token */ async createSession(dto: CreateUserSessionDto): Promise { // 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 { 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 { 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 { 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 { 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 { 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 { return this.sessionRepository.count({ where: { user_id: userId, is_active: true }, }); } private async deleteOldestSession(userId: string): Promise { 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 { 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