New projects created: - michangarrito (marketplace mobile) - template-saas (SaaS template) - clinica-dental (dental ERP) - clinica-veterinaria (veterinary ERP) Architecture updates: - Move catalog from core/ to shared/ - Add MCP servers structure and templates - Add git management scripts - Update SUBREPOSITORIOS.md with 15 new repos - Update .gitignore for new projects Repository infrastructure: - 4 main repositories - 11 subrepositorios - Gitea remotes configured 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
13 KiB
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_sessionscreada - 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.tsprojects/gamilit/apps/backend/src/modules/auth/entities/user-session.entity.ts
Versión: 1.0.0 Sistema: SIMCO Catálogo