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
19 KiB
19 KiB
Guía de Implementación: Autenticación
Versión: 1.0.0 Tiempo estimado: 2-4 horas (adaptación), 8-16 horas (desde cero) Complejidad: Media-Alta
Pre-requisitos
Antes de implementar, asegurar:
- NestJS configurado con TypeORM
- PostgreSQL con schemas creados
- Variables de entorno configuradas
- Dependencias npm instaladas
Paso 1: Instalar Dependencias
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt
npm install class-validator class-transformer
Paso 2: Crear DDL de Base de Datos
2.1 Schema auth.users
-- Schema: auth (si no existe)
CREATE SCHEMA IF NOT EXISTS auth;
-- Extensión para UUIDs
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Tabla de usuarios
CREATE TABLE auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
encrypted_password TEXT NOT NULL,
role VARCHAR(50) DEFAULT 'user',
status VARCHAR(50) DEFAULT 'active',
email_confirmed_at TIMESTAMPTZ,
phone TEXT,
phone_confirmed_at TIMESTAMPTZ,
is_super_admin BOOLEAN DEFAULT FALSE,
banned_until TIMESTAMPTZ,
last_sign_in_at TIMESTAMPTZ,
raw_user_meta_data JSONB DEFAULT '{}',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_auth_users_email ON auth.users(email);
CREATE INDEX idx_auth_users_role ON auth.users(role);
CREATE INDEX idx_auth_users_status ON auth.users(status);
-- Comentarios
COMMENT ON TABLE auth.users IS 'Tabla principal de usuarios del sistema';
COMMENT ON COLUMN auth.users.encrypted_password IS 'Password hasheado con bcrypt';
2.2 Schema auth_management.user_sessions
-- Schema: auth_management
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 NOT NULL,
ip_address INET,
user_agent TEXT,
device_type VARCHAR(50),
browser VARCHAR(100),
os VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
expires_at TIMESTAMPTZ NOT NULL,
last_activity_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_user_sessions_user_id ON auth_management.user_sessions(user_id);
CREATE INDEX idx_user_sessions_refresh_token ON auth_management.user_sessions(refresh_token);
CREATE INDEX idx_user_sessions_expires_at ON auth_management.user_sessions(expires_at);
COMMENT ON TABLE auth_management.user_sessions IS 'Sesiones activas de usuarios';
2.3 Tabla auth_attempts
CREATE TABLE auth_management.auth_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
success BOOLEAN NOT NULL,
ip_address INET NOT NULL,
user_agent TEXT,
failure_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_auth_attempts_email ON auth_management.auth_attempts(email);
CREATE INDEX idx_auth_attempts_ip ON auth_management.auth_attempts(ip_address);
CREATE INDEX idx_auth_attempts_created_at ON auth_management.auth_attempts(created_at);
COMMENT ON TABLE auth_management.auth_attempts IS 'Log de intentos de autenticación';
Paso 3: Crear Entities
3.1 User Entity
// src/modules/auth/entities/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Exclude } from 'class-transformer';
@Entity({ schema: 'auth', name: 'users' })
@Index('idx_auth_users_email', ['email'])
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text', unique: true })
email!: string;
@Column({ type: 'text', name: 'encrypted_password' })
@Exclude() // No serializar en respuestas
encrypted_password!: string;
@Column({ type: 'varchar', length: 50, default: 'user' })
role!: string;
@Column({ type: 'varchar', length: 50, default: 'active' })
status!: string;
@Column({ type: 'timestamp with time zone', nullable: true })
email_confirmed_at?: Date;
@Column({ type: 'boolean', default: false })
is_super_admin!: boolean;
@Column({ type: 'timestamp with time zone', nullable: true })
banned_until?: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
last_sign_in_at?: Date;
@Column({ type: 'jsonb', default: {} })
raw_user_meta_data!: Record<string, any>;
@Column({ type: 'timestamp with time zone', nullable: true })
deleted_at?: Date;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updated_at!: Date;
}
3.2 UserSession Entity
// src/modules/auth/entities/user-session.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity({ schema: 'auth_management', name: 'user_sessions' })
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' })
refresh_token!: string; // Hasheado con SHA256
@Column({ type: 'inet', nullable: true })
ip_address?: string;
@Column({ type: 'text', nullable: true })
user_agent?: 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: 'boolean', default: true })
is_active!: boolean;
@Column({ type: 'timestamp with time zone' })
expires_at!: Date;
@Column({ type: 'timestamp with time zone', default: () => 'NOW()' })
last_activity_at!: Date;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updated_at!: Date;
}
Paso 4: Crear DTOs
4.1 Login DTO
// src/modules/auth/dto/login.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginDto {
@ApiProperty({
description: 'Email del usuario',
example: 'usuario@example.com',
})
@IsEmail({}, { message: 'Email inválido' })
@IsNotEmpty({ message: 'Email es requerido' })
email!: string;
@ApiProperty({
description: 'Contraseña del usuario',
example: 'MySecurePassword123!',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Password debe tener al menos 8 caracteres' })
@IsNotEmpty({ message: 'Password es requerido' })
password!: string;
}
4.2 Register DTO
// src/modules/auth/dto/register-user.dto.ts
import { IsEmail, IsString, MinLength, IsOptional, IsObject } from 'class-validator';
export class RegisterUserDto {
@IsEmail({}, { message: 'El email debe ser válido' })
email!: string;
@IsString()
@MinLength(8, { message: 'La contraseña debe tener al menos 8 caracteres' })
password!: string;
@IsObject()
@IsOptional()
raw_user_meta_data?: Record<string, any>;
@IsString()
@IsOptional()
first_name?: string;
@IsString()
@IsOptional()
last_name?: string;
}
Paso 5: Crear JWT Strategy
// src/modules/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../services/auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') || 'dev-secret',
});
}
async validate(payload: any) {
const { sub: userId } = payload;
const user = await this.authService.validateUser(userId);
if (!user) {
throw new UnauthorizedException('Usuario no encontrado o inactivo');
}
return {
id: user.id,
sub: user.id,
email: user.email,
role: user.role,
is_active: !user.deleted_at,
email_verified: !!user.email_confirmed_at,
};
}
}
Paso 6: Crear Guards
6.1 JWT Auth Guard
// src/modules/auth/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
}
6.2 Roles Guard
// src/modules/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
return requiredRoles.some((role) => user.role === role);
}
}
6.3 Roles Decorator
// src/modules/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
Paso 7: Crear AuthService
// src/modules/auth/services/auth.service.ts
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { User } from '../entities/user.entity';
import { UserSession } from '../entities/user-session.entity';
import { LoginDto, RegisterUserDto } from '../dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(UserSession)
private readonly sessionRepository: Repository<UserSession>,
private readonly jwtService: JwtService,
) {}
async register(dto: RegisterUserDto, ip?: string, userAgent?: string) {
// Verificar email único
const existing = await this.userRepository.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException('Email ya registrado');
}
// Hashear password
const hashedPassword = await bcrypt.hash(dto.password, 10);
// Crear usuario
const user = this.userRepository.create({
email: dto.email,
encrypted_password: hashedPassword,
role: 'user',
raw_user_meta_data: dto.raw_user_meta_data || {},
});
await this.userRepository.save(user);
// Generar tokens
const tokens = await this.generateTokens(user);
// Crear sesión
await this.createSession(user.id, tokens.refreshToken, ip, userAgent);
return {
user: this.toUserResponse(user),
...tokens,
};
}
async login(dto: LoginDto, ip?: string, userAgent?: string) {
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
if (!user) {
throw new UnauthorizedException('Credenciales inválidas');
}
const isPasswordValid = await bcrypt.compare(dto.password, user.encrypted_password);
if (!isPasswordValid) {
throw new UnauthorizedException('Credenciales inválidas');
}
if (user.deleted_at) {
throw new UnauthorizedException('Usuario no activo');
}
// Generar tokens
const tokens = await this.generateTokens(user);
// Crear sesión
await this.createSession(user.id, tokens.refreshToken, ip, userAgent);
// Actualizar último login
user.last_sign_in_at = new Date();
await this.userRepository.save(user);
return {
user: this.toUserResponse(user),
...tokens,
};
}
async validateUser(userId: string): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (user && user.deleted_at) {
return null;
}
return user;
}
async refreshToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken);
const user = await this.validateUser(payload.sub);
if (!user) {
throw new UnauthorizedException('Usuario no encontrado');
}
// Verificar sesión
const hashedToken = crypto.createHash('sha256').update(refreshToken).digest('hex');
const session = await this.sessionRepository.findOne({
where: { user_id: user.id, refresh_token: hashedToken },
});
if (!session || new Date() > session.expires_at) {
throw new UnauthorizedException('Sesión expirada');
}
// Generar nuevos tokens
const tokens = await this.generateTokens(user);
// Actualizar sesión
session.refresh_token = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex');
session.expires_at = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
session.last_activity_at = new Date();
await this.sessionRepository.save(session);
return tokens;
} catch {
throw new UnauthorizedException('Refresh token inválido');
}
}
private async generateTokens(user: User) {
const payload = { sub: user.id, email: user.email, role: user.role };
return {
accessToken: this.jwtService.sign(payload, { expiresIn: '15m' }),
refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}
private async createSession(userId: string, refreshToken: string, ip?: string, userAgent?: string) {
const hashedRefreshToken = crypto.createHash('sha256').update(refreshToken).digest('hex');
const sessionToken = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const session = this.sessionRepository.create({
user_id: userId,
session_token: sessionToken,
refresh_token: hashedRefreshToken,
ip_address: ip || null,
user_agent: userAgent || null,
expires_at: expiresAt,
is_active: true,
});
return this.sessionRepository.save(session);
}
private toUserResponse(user: User) {
const { encrypted_password, ...userWithoutPassword } = user;
return {
...userWithoutPassword,
emailVerified: !!user.email_confirmed_at,
isActive: !user.deleted_at,
};
}
}
Paso 8: Crear AuthModule
// src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from './entities/user.entity';
import { UserSession } from './entities/user-session.entity';
import { AuthService } from './services/auth.service';
import { AuthController } from './controllers/auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'dev-secret',
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '15m',
},
}),
inject: [ConfigService],
}),
TypeOrmModule.forFeature([User, UserSession]),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule, PassportModule],
})
export class AuthModule {}
Paso 9: Crear AuthController
// src/modules/auth/controllers/auth.controller.ts
import { Controller, Post, Body, Req, UseGuards, Get, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthService } from '../services/auth.service';
import { LoginDto, RegisterUserDto, RefreshTokenDto } from '../dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Registrar nuevo usuario' })
async register(@Body() dto: RegisterUserDto, @Req() req: Request) {
const ip = req.ip;
const userAgent = req.headers['user-agent'];
return this.authService.register(dto, ip, userAgent);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login con email y password' })
async login(@Body() dto: LoginDto, @Req() req: Request) {
const ip = req.ip;
const userAgent = req.headers['user-agent'];
return this.authService.login(dto, ip, userAgent);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Renovar access token' })
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshToken(dto.refreshToken);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Obtener usuario actual' })
async me(@Req() req: Request) {
return req.user;
}
}
Checklist de Implementación
- Dependencias npm instaladas
- DDL de base de datos creado
- Entities creados y alineados con DDL
- DTOs con validaciones
- JWT Strategy configurado
- Guards (JWT y Roles) creados
- AuthService con login/register/refresh
- AuthModule exporta servicios necesarios
- AuthController con endpoints
- Variables de entorno configuradas
- Build pasa sin errores
- Tests básicos funcionando
Troubleshooting
Error: "Cannot find module 'bcrypt'"
npm install bcrypt
npm install -D @types/bcrypt
Error: "JWT secret not configured"
Verificar variable de entorno JWT_SECRET en .env
Error: "relation auth.users does not exist"
Ejecutar DDL para crear tablas antes de iniciar la aplicación
Código de Referencia
Ver implementación completa en:
projects/gamilit/apps/backend/src/modules/auth/
Versión: 1.0.0 Sistema: SIMCO Catálogo