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>
168 lines
4.5 KiB
TypeScript
168 lines
4.5 KiB
TypeScript
/**
|
|
* SESSION MANAGEMENT SERVICE - REFERENCE IMPLEMENTATION
|
|
*
|
|
* @description Servicio para gestión de sesiones de usuario.
|
|
* Mantiene registro de sesiones activas, dispositivos y metadata.
|
|
*
|
|
* @usage Copiar y adaptar según necesidades del proyecto.
|
|
* @origin gamilit/apps/backend/src/modules/auth/services/session-management.service.ts
|
|
*/
|
|
|
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, MoreThan } from 'typeorm';
|
|
import * as crypto from 'crypto';
|
|
|
|
// Adaptar imports según proyecto
|
|
// import { UserSession } from '../entities';
|
|
|
|
@Injectable()
|
|
export class SessionManagementService {
|
|
constructor(
|
|
@InjectRepository(UserSession, 'auth')
|
|
private readonly sessionRepository: Repository<UserSession>,
|
|
) {}
|
|
|
|
/**
|
|
* Crear nueva sesión
|
|
*
|
|
* @param userId - ID del usuario
|
|
* @param metadata - Información del dispositivo/cliente
|
|
* @returns Sesión creada
|
|
*/
|
|
async createSession(
|
|
userId: string,
|
|
metadata: SessionMetadata,
|
|
): Promise<UserSession> {
|
|
const session = this.sessionRepository.create({
|
|
user_id: userId,
|
|
refresh_token: this.generateTokenHash(),
|
|
ip_address: metadata.ip,
|
|
user_agent: metadata.userAgent,
|
|
device_type: this.detectDeviceType(metadata.userAgent),
|
|
expires_at: this.calculateExpiry(7, 'days'),
|
|
is_revoked: false,
|
|
});
|
|
|
|
return this.sessionRepository.save(session);
|
|
}
|
|
|
|
/**
|
|
* Obtener sesiones activas de un usuario
|
|
*/
|
|
async getActiveSessions(userId: string): Promise<UserSession[]> {
|
|
return this.sessionRepository.find({
|
|
where: {
|
|
user_id: userId,
|
|
is_revoked: false,
|
|
expires_at: MoreThan(new Date()),
|
|
},
|
|
order: { last_activity_at: 'DESC' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revocar una sesión específica
|
|
*/
|
|
async revokeSession(sessionId: string, userId: string): Promise<void> {
|
|
const result = await this.sessionRepository.update(
|
|
{ id: sessionId, user_id: userId },
|
|
{ is_revoked: true },
|
|
);
|
|
|
|
if (result.affected === 0) {
|
|
throw new NotFoundException('Sesión no encontrada');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Revocar todas las sesiones de un usuario (excepto la actual)
|
|
*/
|
|
async revokeAllOtherSessions(userId: string, currentSessionId: string): Promise<number> {
|
|
const result = await this.sessionRepository
|
|
.createQueryBuilder()
|
|
.update()
|
|
.set({ is_revoked: true })
|
|
.where('user_id = :userId', { userId })
|
|
.andWhere('id != :currentSessionId', { currentSessionId })
|
|
.andWhere('is_revoked = false')
|
|
.execute();
|
|
|
|
return result.affected || 0;
|
|
}
|
|
|
|
/**
|
|
* Actualizar última actividad de sesión
|
|
*/
|
|
async updateLastActivity(sessionId: string): Promise<void> {
|
|
await this.sessionRepository.update(sessionId, {
|
|
last_activity_at: new Date(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validar sesión por refresh token
|
|
*/
|
|
async validateSession(refreshTokenHash: string): Promise<UserSession | null> {
|
|
return this.sessionRepository.findOne({
|
|
where: {
|
|
refresh_token: refreshTokenHash,
|
|
is_revoked: false,
|
|
expires_at: MoreThan(new Date()),
|
|
},
|
|
relations: ['user'],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Limpiar sesiones expiradas (para CRON job)
|
|
*/
|
|
async cleanupExpiredSessions(): Promise<number> {
|
|
const result = await this.sessionRepository
|
|
.createQueryBuilder()
|
|
.delete()
|
|
.where('expires_at < :now', { now: new Date() })
|
|
.orWhere('is_revoked = true')
|
|
.execute();
|
|
|
|
return result.affected || 0;
|
|
}
|
|
|
|
// ============ HELPERS PRIVADOS ============
|
|
|
|
private generateTokenHash(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
private detectDeviceType(userAgent: string): string {
|
|
if (/mobile/i.test(userAgent)) return 'mobile';
|
|
if (/tablet/i.test(userAgent)) return 'tablet';
|
|
return 'desktop';
|
|
}
|
|
|
|
private calculateExpiry(value: number, unit: 'hours' | 'days'): Date {
|
|
const ms = unit === 'hours' ? value * 3600000 : value * 86400000;
|
|
return new Date(Date.now() + ms);
|
|
}
|
|
}
|
|
|
|
// ============ TIPOS ============
|
|
|
|
interface SessionMetadata {
|
|
ip?: string;
|
|
userAgent?: string;
|
|
}
|
|
|
|
interface UserSession {
|
|
id: string;
|
|
user_id: string;
|
|
refresh_token: string;
|
|
ip_address?: string;
|
|
user_agent?: string;
|
|
device_type?: string;
|
|
expires_at: Date;
|
|
is_revoked: boolean;
|
|
last_activity_at?: Date;
|
|
user?: any;
|
|
}
|