workspace-v1/shared/catalog/auth/IMPLEMENTATION.md
rckrdmrd cb4c0681d3 feat(workspace): Add new projects and update architecture
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>
2026-01-07 04:43:28 -06:00

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