erp-core/docs/01-fase-foundation/MGN-001-auth/especificaciones/ET-auth-backend.md
rckrdmrd 0086695b4c
Some checks failed
ERP Core CI / Backend Lint (push) Has been cancelled
ERP Core CI / Backend Unit Tests (push) Has been cancelled
ERP Core CI / Backend Integration Tests (push) Has been cancelled
ERP Core CI / Frontend Lint (push) Has been cancelled
ERP Core CI / Frontend Unit Tests (push) Has been cancelled
ERP Core CI / Frontend E2E Tests (push) Has been cancelled
ERP Core CI / Database DDL Validation (push) Has been cancelled
ERP Core CI / Backend Build (push) Has been cancelled
ERP Core CI / Frontend Build (push) Has been cancelled
ERP Core CI / CI Success (push) Has been cancelled
Performance Tests / Lighthouse CI (push) Has been cancelled
Performance Tests / Bundle Size Analysis (push) Has been cancelled
Performance Tests / k6 Load Tests (push) Has been cancelled
Performance Tests / Performance Summary (push) Has been cancelled
[SIMCO-V38] feat: Actualizar a SIMCO v3.8.0 + cambios backend
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8
- Actualizaciones en modulos CRM y OpenAPI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:53:05 -06:00

65 KiB

Especificacion Tecnica Backend - MGN-001 Auth

Identificacion

Campo Valor
Modulo MGN-001
Nombre Auth - Autenticacion
Version 2.0
Framework NestJS
Estado Implementado
Autor System
Fecha 2026-01-10

Estructura del Modulo

src/modules/auth/
├── auth.module.ts
├── mfa.service.ts                          # MFA/2FA (45 tests)
├── apiKeys.service.ts                      # API Keys
├── controllers/
│   └── auth.controller.ts
├── services/
│   ├── auth.service.ts
│   ├── token.service.ts
│   ├── trusted-devices.service.ts          # Dispositivos confiables (41 tests)
│   ├── email-verification.service.ts       # Verificacion email (32 tests)
│   └── permission-cache.service.ts         # Cache permisos (37 tests)
├── providers/
│   ├── oauth.service.ts                    # OAuth2 Google/Microsoft (32 tests)
│   ├── google.provider.ts
│   └── microsoft.provider.ts
├── guards/
│   ├── jwt-auth.guard.ts
│   └── throttler.guard.ts
├── strategies/
│   └── jwt.strategy.ts
├── decorators/
│   ├── current-user.decorator.ts
│   └── public.decorator.ts
├── dto/
│   ├── login.dto.ts
│   ├── login-response.dto.ts
│   ├── refresh-token.dto.ts
│   ├── token-response.dto.ts
│   ├── request-password-reset.dto.ts
│   ├── reset-password.dto.ts
│   ├── mfa.dto.ts
│   └── email-verification.dto.ts
├── interfaces/
│   ├── jwt-payload.interface.ts
│   └── token-pair.interface.ts
├── entities/
│   ├── refresh-token.entity.ts
│   ├── revoked-token.entity.ts
│   ├── session-history.entity.ts
│   ├── login-attempt.entity.ts
│   ├── password-reset-token.entity.ts
│   ├── password-history.entity.ts
│   ├── user-mfa.entity.ts                  # MFA config por usuario
│   ├── mfa-audit-log.entity.ts             # Auditoria MFA
│   ├── trusted-device.entity.ts            # Dispositivos confiables
│   ├── email-verification-token.entity.ts  # Tokens verificacion email
│   ├── verification-code.entity.ts         # Codigos de verificacion
│   ├── api-key.entity.ts                   # API Keys
│   ├── oauth-provider.entity.ts            # Configuracion OAuth
│   ├── oauth-state.entity.ts               # Estados OAuth CSRF
│   └── oauth-user-link.entity.ts           # Vinculos OAuth-Usuario
└── constants/
    └── auth.constants.ts

Entidades

RefreshToken

// entities/refresh-token.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column, ManyToOne,
  CreateDateColumn, Index
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Tenant } from '../../tenants/entities/tenant.entity';

@Entity({ schema: 'core_auth', name: 'refresh_tokens' })
@Index(['userId', 'tenantId'], { where: '"revoked_at" IS NULL AND "is_used" = false' })
export class RefreshToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'user_id', type: 'uuid' })
  userId: string;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  user: User;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
  tenant: Tenant;

  @Column({ type: 'varchar', length: 64, unique: true })
  jti: string;

  @Column({ name: 'token_hash', type: 'varchar', length: 255 })
  tokenHash: string;

  @Column({ name: 'family_id', type: 'uuid' })
  familyId: string;

  @Column({ name: 'is_used', type: 'boolean', default: false })
  isUsed: boolean;

  @Column({ name: 'used_at', type: 'timestamptz', nullable: true })
  usedAt: Date | null;

  @Column({ name: 'replaced_by', type: 'uuid', nullable: true })
  replacedBy: string | null;

  @Column({ name: 'device_info', type: 'varchar', length: 500, nullable: true })
  deviceInfo: string | null;

  @Column({ name: 'ip_address', type: 'inet', nullable: true })
  ipAddress: string | null;

  @Column({ name: 'expires_at', type: 'timestamptz' })
  expiresAt: Date;

  @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
  revokedAt: Date | null;

  @Column({ name: 'revoked_reason', type: 'varchar', length: 50, nullable: true })
  revokedReason: string | null;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}

SessionHistory

// entities/session-history.entity.ts
@Entity({ schema: 'core_auth', name: 'session_history' })
export class SessionHistory {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'user_id', type: 'uuid' })
  userId: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ type: 'varchar', length: 30 })
  action: SessionAction;

  @Column({ name: 'device_info', type: 'varchar', length: 500, nullable: true })
  deviceInfo: string | null;

  @Column({ name: 'ip_address', type: 'inet', nullable: true })
  ipAddress: string | null;

  @Column({ type: 'jsonb', default: {} })
  metadata: Record<string, any>;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}

export type SessionAction =
  | 'login'
  | 'logout'
  | 'logout_all'
  | 'refresh'
  | 'password_change'
  | 'password_reset'
  | 'account_locked'
  | 'account_unlocked';

LoginAttempt

// entities/login-attempt.entity.ts
@Entity({ schema: 'core_auth', name: 'login_attempts' })
export class LoginAttempt {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 255 })
  email: string;

  @Column({ name: 'tenant_id', type: 'uuid', nullable: true })
  tenantId: string | null;

  @Column({ name: 'user_id', type: 'uuid', nullable: true })
  userId: string | null;

  @Column({ type: 'boolean' })
  success: boolean;

  @Column({ name: 'failure_reason', type: 'varchar', length: 50, nullable: true })
  failureReason: string | null;

  @Column({ name: 'ip_address', type: 'inet' })
  ipAddress: string;

  @Column({ name: 'user_agent', type: 'varchar', length: 500, nullable: true })
  userAgent: string | null;

  @Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'NOW()' })
  attemptedAt: Date;
}

PasswordResetToken

// entities/password-reset-token.entity.ts
@Entity({ schema: 'core_auth', name: 'password_reset_tokens' })
export class PasswordResetToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'user_id', type: 'uuid' })
  userId: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ name: 'token_hash', type: 'varchar', length: 255 })
  tokenHash: string;

  @Column({ type: 'integer', default: 0 })
  attempts: number;

  @Column({ name: 'expires_at', type: 'timestamptz' })
  expiresAt: Date;

  @Column({ name: 'used_at', type: 'timestamptz', nullable: true })
  usedAt: Date | null;

  @Column({ name: 'invalidated_at', type: 'timestamptz', nullable: true })
  invalidatedAt: Date | null;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}

UserMfa

// entities/user-mfa.entity.ts
export enum MfaMethod {
  NONE = 'none',
  TOTP = 'totp',
  SMS = 'sms',
  EMAIL = 'email',
}

export enum MfaStatus {
  DISABLED = 'disabled',
  PENDING_SETUP = 'pending_setup',
  ENABLED = 'enabled',
}

@Entity({ schema: 'auth', name: 'user_mfa' })
@Index('idx_user_mfa_user', ['userId'], { unique: true })
@Index('idx_user_mfa_status', ['status'], { where: "status = 'enabled'" })
export class UserMfa {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', nullable: false, name: 'user_id', unique: true })
  userId: string;

  @Column({ type: 'enum', enum: MfaMethod, default: MfaMethod.NONE })
  method: MfaMethod;

  @Column({ type: 'enum', enum: MfaStatus, default: MfaStatus.DISABLED })
  status: MfaStatus;

  @Column({ type: 'varchar', length: 256, nullable: true, name: 'totp_secret' })
  totpSecret: string | null;

  @Column({ type: 'jsonb', default: [], name: 'backup_codes_hashes' })
  backupCodesHashes: string[];

  @Column({ type: 'integer', default: 0, name: 'backup_codes_used' })
  backupCodesUsed: number;

  @Column({ type: 'integer', default: 0, name: 'backup_codes_total' })
  backupCodesTotal: number;

  @Column({ type: 'timestamptz', nullable: true, name: 'backup_codes_regenerated_at' })
  backupCodesRegeneratedAt: Date | null;

  @Column({ type: 'timestamptz', nullable: true, name: 'enabled_at' })
  enabledAt: Date | null;

  @Column({ type: 'timestamptz', nullable: true, name: 'last_verified_at' })
  lastVerifiedAt: Date | null;

  @Column({ type: 'integer', default: 0, name: 'failed_attempts' })
  failedAttempts: number;

  @Column({ type: 'timestamptz', nullable: true, name: 'locked_until' })
  lockedUntil: Date | null;

  @OneToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: User;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
  updatedAt: Date | null;
}

TrustedDevice

// entities/trusted-device.entity.ts
export enum TrustLevel {
  STANDARD = 'standard',   // 30 dias
  HIGH = 'high',           // 90 dias
  TEMPORARY = 'temporary', // 24 horas
}

@Entity({ schema: 'auth', name: 'trusted_devices' })
@Index('idx_trusted_devices_user', ['userId'], { where: 'is_active' })
@Index('idx_trusted_devices_fingerprint', ['deviceFingerprint'])
export class TrustedDevice {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', nullable: false, name: 'user_id' })
  userId: string;

  @Column({ type: 'varchar', length: 128, nullable: false, name: 'device_fingerprint' })
  deviceFingerprint: string;

  @Column({ type: 'varchar', length: 128, nullable: true, name: 'device_name' })
  deviceName: string | null;

  @Column({ type: 'varchar', length: 32, nullable: true, name: 'device_type' })
  deviceType: string | null;

  @Column({ type: 'text', nullable: true, name: 'user_agent' })
  userAgent: string | null;

  @Column({ type: 'varchar', length: 64, nullable: true, name: 'browser_name' })
  browserName: string | null;

  @Column({ type: 'varchar', length: 64, nullable: true, name: 'os_name' })
  osName: string | null;

  @Column({ type: 'inet', nullable: false, name: 'registered_ip' })
  registeredIp: string;

  @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
  isActive: boolean;

  @Column({ type: 'enum', enum: TrustLevel, default: TrustLevel.STANDARD, name: 'trust_level' })
  trustLevel: TrustLevel;

  @Column({ type: 'timestamptz', nullable: true, name: 'trust_expires_at' })
  trustExpiresAt: Date | null;

  @Column({ type: 'timestamptz', nullable: false, name: 'last_used_at' })
  lastUsedAt: Date;

  @Column({ type: 'inet', nullable: true, name: 'last_used_ip' })
  lastUsedIp: string | null;

  @Column({ type: 'integer', default: 1, nullable: false, name: 'use_count' })
  useCount: number;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: User;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
  revokedAt: Date | null;

  @Column({ type: 'varchar', length: 128, nullable: true, name: 'revoked_reason' })
  revokedReason: string | null;
}

EmailVerificationToken

// entities/email-verification-token.entity.ts
@Entity({ schema: 'auth', name: 'email_verification_tokens' })
@Index('idx_email_verification_tokens_user_id', ['userId'])
@Index('idx_email_verification_tokens_token', ['token'])
@Index('idx_email_verification_tokens_expires_at', ['expiresAt'])
export class EmailVerificationToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', nullable: false, name: 'user_id' })
  userId: string;

  @Column({ type: 'varchar', length: 500, unique: true, nullable: false })
  token: string;

  @Column({ type: 'varchar', length: 255, nullable: false })
  email: string;

  @Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
  expiresAt: Date;

  @Column({ type: 'timestamp', nullable: true, name: 'used_at' })
  usedAt: Date | null;

  @Column({ type: 'inet', nullable: true, name: 'ip_address' })
  ipAddress: string | null;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: User;

  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
  createdAt: Date;

  // Metodos de utilidad
  isExpired(): boolean { return new Date() > this.expiresAt; }
  isUsed(): boolean { return this.usedAt !== null; }
  isValid(): boolean { return !this.isExpired() && !this.isUsed(); }
}

ApiKey

// entities/api-key.entity.ts
@Entity({ schema: 'auth', name: 'api_keys' })
@Index('idx_api_keys_lookup', ['keyIndex', 'isActive'], { where: 'is_active = TRUE' })
@Index('idx_api_keys_expiration', ['expirationDate'], { where: 'expiration_date IS NOT NULL' })
@Index('idx_api_keys_user', ['userId'])
@Index('idx_api_keys_tenant', ['tenantId'])
export class ApiKey {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', nullable: false, name: 'user_id' })
  userId: string;

  @Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'varchar', length: 255, nullable: false })
  name: string;

  @Column({ type: 'varchar', length: 16, nullable: false, name: 'key_index' })
  keyIndex: string;

  @Column({ type: 'varchar', length: 255, nullable: false, name: 'key_hash' })
  keyHash: string;

  @Column({ type: 'varchar', length: 100, nullable: true })
  scope: string | null;

  @Column({ type: 'inet', array: true, nullable: true, name: 'allowed_ips' })
  allowedIps: string[] | null;

  @Column({ type: 'timestamptz', nullable: true, name: 'expiration_date' })
  expirationDate: Date | null;

  @Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
  lastUsedAt: Date | null;

  @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
  isActive: boolean;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: User;

  @ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'tenant_id' })
  tenant: Tenant;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
  revokedAt: Date | null;

  @Column({ type: 'uuid', nullable: true, name: 'revoked_by' })
  revokedBy: string | null;
}

OAuthProvider

// entities/oauth-provider.entity.ts
@Entity({ schema: 'auth', name: 'oauth_providers' })
@Index('idx_oauth_providers_enabled', ['isEnabled'])
@Index('idx_oauth_providers_tenant', ['tenantId'])
@Index('idx_oauth_providers_code', ['code'])
export class OAuthProvider {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
  tenantId: string | null;

  @Column({ type: 'varchar', length: 50, nullable: false, unique: true })
  code: string;

  @Column({ type: 'varchar', length: 100, nullable: false })
  name: string;

  // Configuracion OAuth2
  @Column({ type: 'varchar', length: 255, nullable: false, name: 'client_id' })
  clientId: string;

  @Column({ type: 'varchar', length: 500, nullable: true, name: 'client_secret' })
  clientSecret: string | null;

  // Endpoints OAuth2
  @Column({ type: 'varchar', length: 500, nullable: false, name: 'authorization_endpoint' })
  authorizationEndpoint: string;

  @Column({ type: 'varchar', length: 500, nullable: false, name: 'token_endpoint' })
  tokenEndpoint: string;

  @Column({ type: 'varchar', length: 500, nullable: false, name: 'userinfo_endpoint' })
  userinfoEndpoint: string;

  @Column({ type: 'varchar', length: 500, default: 'openid profile email' })
  scope: string;

  // PKCE
  @Column({ type: 'boolean', default: true, name: 'pkce_enabled' })
  pkceEnabled: boolean;

  @Column({ type: 'varchar', length: 10, default: 'S256', name: 'code_challenge_method' })
  codeChallengeMethod: string | null;

  // Mapeo de claims
  @Column({ type: 'jsonb', name: 'claim_mapping', default: { sub: 'oauth_uid', email: 'email', name: 'name', picture: 'avatar_url' } })
  claimMapping: Record<string, any>;

  // UI
  @Column({ type: 'varchar', length: 100, nullable: true, name: 'button_text' })
  buttonText: string | null;

  @Column({ type: 'boolean', default: false, name: 'is_enabled' })
  isEnabled: boolean;

  @Column({ type: 'text', array: true, nullable: true, name: 'allowed_domains' })
  allowedDomains: string[] | null;

  @Column({ type: 'boolean', default: false, name: 'auto_create_users' })
  autoCreateUsers: boolean;

  @ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true })
  @JoinColumn({ name: 'tenant_id' })
  tenant: Tenant | null;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;
}

Interfaces

JwtPayload

// interfaces/jwt-payload.interface.ts
export interface JwtPayload {
  sub: string;      // User ID
  tid: string;      // Tenant ID
  email: string;
  roles: string[];
  permissions?: string[];
  iat: number;      // Issued At
  exp: number;      // Expiration
  iss: string;      // Issuer
  aud: string;      // Audience
  jti: string;      // JWT ID
}

export interface JwtRefreshPayload extends Pick<JwtPayload, 'sub' | 'tid' | 'jti' | 'iat' | 'exp' | 'iss' | 'aud'> {
  type: 'refresh';
}

TokenPair

// interfaces/token-pair.interface.ts
export interface TokenPair {
  accessToken: string;
  refreshToken: string;
  accessTokenExpiresAt: Date;
  refreshTokenExpiresAt: Date;
  refreshTokenId: string;
}

DTOs

LoginDto

// dto/login.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class LoginDto {
  @ApiProperty({
    description: 'Email del usuario',
    example: 'user@example.com',
  })
  @IsEmail({}, { message: 'Email inválido' })
  @IsNotEmpty({ message: 'Email es requerido' })
  email: string;

  @ApiProperty({
    description: 'Contraseña del usuario',
    example: 'SecurePass123!',
    minLength: 8,
  })
  @IsString()
  @MinLength(8, { message: 'Password debe tener mínimo 8 caracteres' })
  @MaxLength(128, { message: 'Password no puede exceder 128 caracteres' })
  @IsNotEmpty({ message: 'Password es requerido' })
  password: string;
}

LoginResponseDto

// dto/login-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';

export class LoginResponseDto {
  @ApiProperty({
    description: 'Access token JWT',
    example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
  })
  accessToken: string;

  @ApiProperty({
    description: 'Refresh token JWT',
    example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
  })
  refreshToken: string;

  @ApiProperty({
    description: 'Tipo de token',
    example: 'Bearer',
  })
  tokenType: string;

  @ApiProperty({
    description: 'Tiempo de expiración en segundos',
    example: 900,
  })
  expiresIn: number;

  @ApiProperty({
    description: 'Información básica del usuario',
  })
  user: {
    id: string;
    email: string;
    firstName: string;
    lastName: string;
    roles: string[];
  };
}

RefreshTokenDto

// dto/refresh-token.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class RefreshTokenDto {
  @ApiProperty({
    description: 'Refresh token para renovar sesión',
    example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
  })
  @IsString()
  @IsNotEmpty({ message: 'Refresh token es requerido' })
  refreshToken: string;
}

TokenResponseDto

// dto/token-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';

export class TokenResponseDto {
  @ApiProperty()
  accessToken: string;

  @ApiProperty()
  refreshToken: string;

  @ApiProperty()
  tokenType: string;

  @ApiProperty()
  expiresIn: number;
}

RequestPasswordResetDto

// dto/request-password-reset.dto.ts
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class RequestPasswordResetDto {
  @ApiProperty({
    description: 'Email del usuario',
    example: 'user@example.com',
  })
  @IsEmail({}, { message: 'Email inválido' })
  @IsNotEmpty({ message: 'Email es requerido' })
  email: string;
}

ResetPasswordDto

// dto/reset-password.dto.ts
import { IsString, MinLength, MaxLength, Matches, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class ResetPasswordDto {
  @ApiProperty({
    description: 'Token de recuperación',
  })
  @IsString()
  @IsNotEmpty({ message: 'Token es requerido' })
  token: string;

  @ApiProperty({
    description: 'Nueva contraseña',
    minLength: 8,
  })
  @IsString()
  @MinLength(8, { message: 'Password debe tener mínimo 8 caracteres' })
  @MaxLength(128, { message: 'Password no puede exceder 128 caracteres' })
  @Matches(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
    {
      message: 'Password debe incluir mayúscula, minúscula, número y carácter especial',
    },
  )
  @IsNotEmpty({ message: 'Password es requerido' })
  newPassword: string;

  @ApiProperty({
    description: 'Confirmación de nueva contraseña',
  })
  @IsString()
  @IsNotEmpty({ message: 'Confirmación de password es requerida' })
  confirmPassword: string;
}

Endpoints

Resumen de Endpoints

Metodo Ruta Descripcion Auth Rate Limit
POST /api/v1/auth/login Autenticar usuario No 10/min/IP
POST /api/v1/auth/refresh Renovar tokens No 1/seg
POST /api/v1/auth/logout Cerrar sesion Si 10/min
POST /api/v1/auth/logout-all Cerrar todas las sesiones Si 5/min
POST /api/v1/auth/password/request-reset Solicitar recuperacion No 3/hora/email
POST /api/v1/auth/password/reset Cambiar password No 5/hora/IP
GET /api/v1/auth/password/validate-token/:token Validar token No 10/min

POST /api/v1/auth/login

Autentica un usuario con email y password.

Request

// Headers
{
  "Content-Type": "application/json",
  "X-Tenant-Id": "tenant-uuid"  // Opcional si tenant único
}

// Body
{
  "email": "user@example.com",
  "password": "SecurePass123!"
}

Response Success (200)

{
  "accessToken": "eyJhbGciOiJSUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJSUzI1NiIs...",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@example.com",
    "firstName": "John",
    "lastName": "Doe",
    "roles": ["admin"]
  }
}

Response Errors

Code Mensaje Descripcion
400 "Datos de entrada inválidos" Validación fallida
401 "Credenciales inválidas" Email/password incorrecto
423 "Cuenta bloqueada" Demasiados intentos
403 "Cuenta inactiva" Usuario deshabilitado

POST /api/v1/auth/refresh

Renueva el par de tokens usando un refresh token válido.

Request

// Headers
{
  "Content-Type": "application/json"
}

// Body
{
  "refreshToken": "eyJhbGciOiJSUzI1NiIs..."
}

// O mediante httpOnly cookie (preferido)
// Cookie: refresh_token=eyJhbGciOiJSUzI1NiIs...

Response Success (200)

{
  "accessToken": "eyJhbGciOiJSUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJSUzI1NiIs...",
  "tokenType": "Bearer",
  "expiresIn": 900
}

Response Errors

Code Mensaje Descripcion
400 "Refresh token requerido" No se envió token
401 "Refresh token expirado" Token expiró
401 "Sesión comprometida" Token replay detectado
401 "Token revocado" Token fue revocado

POST /api/v1/auth/logout

Cierra la sesión actual del usuario.

Request

// Headers
{
  "Authorization": "Bearer eyJhbGciOiJSUzI1NiIs..."
}

// Cookie (refresh token)
// Cookie: refresh_token=eyJhbGciOiJSUzI1NiIs...

Response Success (200)

{
  "message": "Sesión cerrada exitosamente"
}

// Set-Cookie: refresh_token=; Max-Age=0; HttpOnly; Secure; SameSite=Strict

POST /api/v1/auth/logout-all

Cierra todas las sesiones activas del usuario.

Request

// Headers
{
  "Authorization": "Bearer eyJhbGciOiJSUzI1NiIs..."
}

Response Success (200)

{
  "message": "Todas las sesiones han sido cerradas",
  "sessionsRevoked": 3
}

POST /api/v1/auth/password/request-reset

Solicita un enlace de recuperación de contraseña.

Request

{
  "email": "user@example.com"
}

Response Success (200)

{
  "message": "Si el email está registrado, recibirás instrucciones para restablecer tu contraseña"
}

Nota: Siempre retorna 200 con mensaje genérico para no revelar existencia de emails.


POST /api/v1/auth/password/reset

Establece una nueva contraseña usando el token de recuperación.

Request

{
  "token": "a1b2c3d4e5f6...",
  "newPassword": "NewSecurePass123!",
  "confirmPassword": "NewSecurePass123!"
}

Response Success (200)

{
  "message": "Contraseña actualizada exitosamente. Por favor inicia sesión."
}

Response Errors

Code Mensaje Descripcion
400 "Token de recuperación expirado" Token > 1 hora
400 "Token ya fue utilizado" Token usado
400 "Token invalidado" 3+ intentos fallidos
400 "Las contraseñas no coinciden" Confirmación diferente
400 "No puede ser igual a contraseñas anteriores" Historial

GET /api/v1/auth/password/validate-token/:token

Valida si un token de recuperación es válido antes de mostrar el formulario.

Request

GET /api/v1/auth/password/validate-token/a1b2c3d4e5f6...

Response Success (200)

{
  "valid": true,
  "email": "u***@example.com"  // Email parcialmente oculto
}

Response Invalid (400)

{
  "valid": false,
  "reason": "expired" | "used" | "invalid"
}

Services

AuthService

// services/auth.service.ts
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../../users/entities/user.entity';
import { TokenService } from './token.service';
import { LoginDto } from '../dto/login.dto';
import { LoginResponseDto } from '../dto/login-response.dto';
import { LoginAttempt } from '../entities/login-attempt.entity';
import { SessionHistory } from '../entities/session-history.entity';

@Injectable()
export class AuthService {
  private readonly MAX_FAILED_ATTEMPTS = 5;
  private readonly LOCK_DURATION_MINUTES = 30;

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(LoginAttempt)
    private readonly loginAttemptRepository: Repository<LoginAttempt>,
    @InjectRepository(SessionHistory)
    private readonly sessionHistoryRepository: Repository<SessionHistory>,
    private readonly tokenService: TokenService,
  ) {}

  async login(dto: LoginDto, metadata: RequestMetadata): Promise<LoginResponseDto> {
    // 1. Buscar usuario por email
    const user = await this.userRepository.findOne({
      where: { email: dto.email.toLowerCase() },
      relations: ['roles'],
    });

    // 2. Verificar si existe
    if (!user) {
      await this.recordLoginAttempt(dto.email, null, false, 'invalid_credentials', metadata);
      throw new UnauthorizedException('Credenciales inválidas');
    }

    // 3. Verificar si está bloqueado
    if (this.isAccountLocked(user)) {
      await this.recordLoginAttempt(dto.email, user.id, false, 'account_locked', metadata);
      throw new ForbiddenException('Cuenta bloqueada. Intenta de nuevo más tarde.');
    }

    // 4. Verificar si está activo
    if (!user.isActive) {
      await this.recordLoginAttempt(dto.email, user.id, false, 'account_inactive', metadata);
      throw new ForbiddenException('Cuenta inactiva');
    }

    // 5. Verificar password
    const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
    if (!isPasswordValid) {
      await this.handleFailedLogin(user, metadata);
      throw new UnauthorizedException('Credenciales inválidas');
    }

    // 6. Login exitoso - resetear contador
    await this.resetFailedAttempts(user);

    // 7. Generar tokens
    const tokens = await this.tokenService.generateTokenPair(user, metadata);

    // 8. Registrar sesión
    await this.recordLoginAttempt(dto.email, user.id, true, null, metadata);
    await this.recordSessionHistory(user.id, user.tenantId, 'login', metadata);

    // 9. Construir respuesta
    return {
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
      tokenType: 'Bearer',
      expiresIn: 900, // 15 minutos
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        roles: user.roles.map(r => r.name),
      },
    };
  }

  async logout(userId: string, tenantId: string, refreshTokenJti: string, accessTokenJti: string): Promise<void> {
    // 1. Revocar refresh token
    await this.tokenService.revokeRefreshToken(refreshTokenJti, 'user_logout');

    // 2. Blacklistear access token
    await this.tokenService.blacklistAccessToken(accessTokenJti);

    // 3. Registrar en historial
    await this.recordSessionHistory(userId, tenantId, 'logout', {});
  }

  async logoutAll(userId: string, tenantId: string): Promise<number> {
    // 1. Revocar todos los refresh tokens
    const revokedCount = await this.tokenService.revokeAllUserTokens(userId, 'logout_all');

    // 2. Registrar en historial
    await this.recordSessionHistory(userId, tenantId, 'logout_all', {
      sessionsRevoked: revokedCount,
    });

    return revokedCount;
  }

  private isAccountLocked(user: User): boolean {
    if (!user.lockedUntil) return false;
    return new Date() < user.lockedUntil;
  }

  private async handleFailedLogin(user: User, metadata: RequestMetadata): Promise<void> {
    user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;

    if (user.failedLoginAttempts >= this.MAX_FAILED_ATTEMPTS) {
      user.lockedUntil = new Date(Date.now() + this.LOCK_DURATION_MINUTES * 60 * 1000);
      await this.recordSessionHistory(user.id, user.tenantId, 'account_locked', {});
    }

    await this.userRepository.save(user);
    await this.recordLoginAttempt(user.email, user.id, false, 'invalid_credentials', metadata);
  }

  private async resetFailedAttempts(user: User): Promise<void> {
    if (user.failedLoginAttempts > 0 || user.lockedUntil) {
      user.failedLoginAttempts = 0;
      user.lockedUntil = null;
      await this.userRepository.save(user);
    }
  }

  private async recordLoginAttempt(
    email: string,
    userId: string | null,
    success: boolean,
    failureReason: string | null,
    metadata: RequestMetadata,
  ): Promise<void> {
    await this.loginAttemptRepository.save({
      email,
      userId,
      success,
      failureReason,
      ipAddress: metadata.ipAddress,
      userAgent: metadata.userAgent,
    });
  }

  private async recordSessionHistory(
    userId: string,
    tenantId: string,
    action: SessionAction,
    metadata: Record<string, any>,
  ): Promise<void> {
    await this.sessionHistoryRepository.save({
      userId,
      tenantId,
      action,
      metadata,
    });
  }
}

interface RequestMetadata {
  ipAddress: string;
  userAgent: string;
  deviceInfo?: string;
}

TokenService

// services/token.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { RefreshToken } from '../entities/refresh-token.entity';
import { JwtPayload, JwtRefreshPayload } from '../interfaces/jwt-payload.interface';
import { TokenPair } from '../interfaces/token-pair.interface';
import { PermissionCacheService } from './permission-cache.service';

@Injectable()
export class TokenService {
  private readonly accessTokenExpiry = '15m';
  private readonly refreshTokenExpiry = '7d';

  constructor(
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
    @InjectRepository(RefreshToken)
    private readonly refreshTokenRepository: Repository<RefreshToken>,
    private readonly permissionCacheService: PermissionCacheService,
  ) {}

  async generateTokenPair(user: any, metadata: any): Promise<TokenPair> {
    const accessTokenJti = uuidv4();
    const refreshTokenJti = uuidv4();
    const familyId = uuidv4();

    // Generar Access Token
    const accessPayload: JwtPayload = {
      sub: user.id,
      tid: user.tenantId,
      email: user.email,
      roles: user.roles.map((r: any) => r.name),
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 900, // 15 min
      iss: 'erp-core',
      aud: 'erp-api',
      jti: accessTokenJti,
    };

    const accessToken = this.jwtService.sign(accessPayload, {
      algorithm: 'RS256',
      privateKey: this.configService.get('JWT_PRIVATE_KEY'),
    });

    // Generar Refresh Token
    const refreshPayload: JwtRefreshPayload = {
      sub: user.id,
      tid: user.tenantId,
      jti: refreshTokenJti,
      type: 'refresh',
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 días
      iss: 'erp-core',
      aud: 'erp-api',
    };

    const refreshToken = this.jwtService.sign(refreshPayload, {
      algorithm: 'RS256',
      privateKey: this.configService.get('JWT_PRIVATE_KEY'),
    });

    // Almacenar refresh token en BD
    const tokenHash = await bcrypt.hash(refreshToken, 10);
    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

    await this.refreshTokenRepository.save({
      userId: user.id,
      tenantId: user.tenantId,
      jti: refreshTokenJti,
      tokenHash,
      familyId,
      ipAddress: metadata.ipAddress,
      deviceInfo: metadata.userAgent,
      expiresAt,
    });

    return {
      accessToken,
      refreshToken,
      accessTokenExpiresAt: new Date(accessPayload.exp * 1000),
      refreshTokenExpiresAt: expiresAt,
      refreshTokenId: refreshTokenJti,
    };
  }

  async refreshTokens(refreshToken: string): Promise<TokenPair> {
    // 1. Decodificar token
    let decoded: JwtRefreshPayload;
    try {
      decoded = this.jwtService.verify(refreshToken, {
        algorithms: ['RS256'],
        publicKey: this.configService.get('JWT_PUBLIC_KEY'),
      });
    } catch (error) {
      throw new UnauthorizedException('Refresh token inválido');
    }

    // 2. Buscar en BD
    const storedToken = await this.refreshTokenRepository.findOne({
      where: { jti: decoded.jti },
    });

    if (!storedToken) {
      throw new UnauthorizedException('Refresh token no encontrado');
    }

    // 3. Verificar si está revocado
    if (storedToken.revokedAt) {
      throw new UnauthorizedException('Token revocado');
    }

    // 4. Detectar reuso (token replay attack)
    if (storedToken.isUsed) {
      // ALERTA DE SEGURIDAD: Token replay detectado
      await this.revokeTokenFamily(storedToken.familyId);
      throw new UnauthorizedException('Sesión comprometida. Por favor inicia sesión nuevamente.');
    }

    // 5. Verificar expiración
    if (new Date() > storedToken.expiresAt) {
      throw new UnauthorizedException('Refresh token expirado');
    }

    // 6. Marcar como usado
    storedToken.isUsed = true;
    storedToken.usedAt = new Date();

    // 7. Obtener usuario
    const user = await this.getUserForRefresh(decoded.sub);

    // 8. Generar nuevos tokens (misma familia)
    const newTokens = await this.generateTokenPairWithFamily(
      user,
      storedToken.familyId,
      { ipAddress: storedToken.ipAddress, userAgent: storedToken.deviceInfo },
    );

    // 9. Actualizar token anterior con referencia al nuevo
    storedToken.replacedBy = newTokens.refreshTokenId;
    await this.refreshTokenRepository.save(storedToken);

    return newTokens;
  }

  async revokeRefreshToken(jti: string, reason: string): Promise<void> {
    await this.refreshTokenRepository.update(
      { jti },
      { revokedAt: new Date(), revokedReason: reason },
    );
  }

  async revokeAllUserTokens(userId: string, reason: string): Promise<number> {
    const result = await this.refreshTokenRepository.update(
      { userId, revokedAt: IsNull() },
      { revokedAt: new Date(), revokedReason: reason },
    );
    return result.affected || 0;
  }

  async revokeTokenFamily(familyId: string): Promise<void> {
    await this.refreshTokenRepository.update(
      { familyId, revokedAt: IsNull() },
      { revokedAt: new Date(), revokedReason: 'security_breach' },
    );
  }

  /**
   * Invalida el cache de permisos para un usuario
   * Se llama cuando se revocan tokens o cambian permisos
   */
  async invalidatePermissionCache(userId: string): Promise<void> {
    await this.permissionCacheService.invalidateAllForUser(userId);
  }

  private async generateTokenPairWithFamily(
    user: any,
    familyId: string,
    metadata: any,
  ): Promise<TokenPair> {
    // Similar a generateTokenPair pero usa familyId existente
    // ... implementation
    return {} as TokenPair; // Placeholder
  }

  private async getUserForRefresh(userId: string): Promise<any> {
    // Obtener usuario con roles actualizados
    // ... implementation
    return {}; // Placeholder
  }
}

MfaService

// mfa.service.ts
/**
 * Servicio de Autenticacion Multi-Factor (MFA/2FA)
 *
 * Funcionalidades:
 * - Configuracion TOTP con QR code
 * - Verificacion de codigos TOTP
 * - Gestion de codigos de respaldo (backup codes)
 * - Auditoria de eventos MFA
 * - Bloqueo por intentos fallidos
 *
 * Tests: 45 tests en mfa.service.spec.ts
 */

// Configuracion
const BACKUP_CODES_COUNT = 10;
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15;
const TOTP_WINDOW = 1; // Tolerancia de 1 paso para drift de tiempo

export interface MfaRequestContext {
  ipAddress: string;
  userAgent: string;
  deviceFingerprint?: string;
}

@Injectable()
export class MfaService {
  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
    @InjectRepository(UserMfa) private readonly userMfaRepository: Repository<UserMfa>,
    @InjectRepository(MfaAuditLog) private readonly mfaAuditLogRepository: Repository<MfaAuditLog>,
  ) {}

  /**
   * Inicia configuracion MFA para un usuario
   * Genera secret TOTP, QR code y codigos de respaldo
   */
  async initiateMfaSetup(userId: string, context: MfaRequestContext): Promise<MfaSetupResponseDto> {
    // Genera secret TOTP
    const secret = authenticator.generateSecret();
    const otpauthUri = authenticator.keyuri(user.email, 'ERP Generic', secret);
    const qrCodeDataUrl = await QRCode.toDataURL(otpauthUri);

    // Genera codigos de respaldo (10 codigos formato XXXX-XXXX)
    const { codes, hashes } = await this.generateBackupCodes();

    // Crea/actualiza registro con estado PENDING_SETUP
    // ...

    return { secret, otpauthUri, qrCodeDataUrl, backupCodes: codes };
  }

  /**
   * Habilita MFA despues de verificar codigo de configuracion
   */
  async enableMfa(userId: string, code: string, context: MfaRequestContext): Promise<MfaEnabledResponseDto>;

  /**
   * Verifica codigo TOTP o codigo de respaldo
   * Incrementa intentos fallidos y bloquea si excede limite
   */
  async verifyTotp(userId: string, code: string, context: MfaRequestContext): Promise<VerifyTotpResponseDto>;

  /**
   * Deshabilita MFA (requiere password + codigo TOTP/backup)
   */
  async disableMfa(userId: string, password: string, code: string, context: MfaRequestContext): Promise<MfaDisabledResponseDto>;

  /**
   * Regenera codigos de respaldo (requiere verificacion TOTP)
   */
  async regenerateBackupCodes(userId: string, code: string, context: MfaRequestContext): Promise<BackupCodesResponseDto>;

  /**
   * Obtiene estado MFA de un usuario
   */
  async getMfaStatus(userId: string): Promise<MfaStatusResponseDto>;

  /**
   * Verifica si el usuario tiene MFA habilitado
   */
  async isMfaEnabled(userId: string): Promise<boolean>;
}

TrustedDevicesService

// services/trusted-devices.service.ts
/**
 * Servicio de Gestion de Dispositivos Confiables
 *
 * Permite bypass de MFA para dispositivos previamente autenticados.
 * Niveles de confianza: STANDARD (30d), HIGH (90d), TEMPORARY (24h)
 *
 * Tests: 41 tests en trusted-devices.service.spec.ts
 */

const DEFAULT_TRUST_DURATION_DAYS = 30;
const HIGH_TRUST_DURATION_DAYS = 90;
const TEMPORARY_TRUST_DURATION_HOURS = 24;
const MAX_TRUSTED_DEVICES_PER_USER = 10;

export interface DeviceInfo {
  userAgent: string;
  deviceName?: string;
  deviceType?: string;
  browserName?: string;
  osName?: string;
}

@Injectable()
export class TrustedDevicesService {
  constructor(
    @InjectRepository(TrustedDevice) private readonly trustedDeviceRepository: Repository<TrustedDevice>,
    @InjectRepository(User) private readonly userRepository: Repository<User>,
  ) {}

  /**
   * Genera fingerprint del dispositivo (hash SHA256 de userAgent + IP)
   */
  generateFingerprint(deviceInfo: DeviceInfo, ipAddress: string): string;

  /**
   * Agrega dispositivo confiable para un usuario
   * Si excede limite (10), elimina el mas antiguo
   */
  async addDevice(
    userId: string,
    deviceInfo: DeviceInfo,
    context: RequestContext,
    trustLevel: TrustLevel = TrustLevel.STANDARD
  ): Promise<AddDeviceResult>;

  /**
   * Verifica si un dispositivo es confiable
   * Actualiza last_used_at y use_count
   */
  async verifyDevice(userId: string, deviceInfo: DeviceInfo, ipAddress: string): Promise<boolean>;

  /**
   * Lista todos los dispositivos confiables de un usuario
   */
  async listDevices(userId: string, currentFingerprint?: string): Promise<TrustedDeviceDto[]>;

  /**
   * Revoca un dispositivo especifico
   */
  async revokeDevice(userId: string, deviceId: string, reason?: string): Promise<void>;

  /**
   * Revoca todos los dispositivos de un usuario (seguridad)
   */
  async revokeAllDevices(userId: string, reason?: string): Promise<number>;

  /**
   * Limpieza de dispositivos expirados (cron job)
   */
  async cleanupExpiredDevices(): Promise<number>;

  /**
   * Actualiza nivel de confianza de un dispositivo
   */
  async updateTrustLevel(userId: string, deviceId: string, trustLevel: TrustLevel): Promise<TrustedDevice>;
}

EmailVerificationService

// services/email-verification.service.ts
/**
 * Servicio de Verificacion de Email
 *
 * Maneja el flujo de verificacion de email para nuevos usuarios:
 * - Generacion de tokens seguros (crypto.randomBytes)
 * - Envio de emails de verificacion
 * - Verificacion de tokens y activacion de cuentas
 * - Rate limiting para reenvios (2 min cooldown)
 *
 * Tests: 32 tests en email-verification.service.spec.ts
 */

@Injectable()
export class EmailVerificationService {
  private readonly TOKEN_EXPIRY_HOURS = 24;
  private readonly RESEND_COOLDOWN_MINUTES = 2;

  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
    @InjectRepository(EmailVerificationToken) private readonly tokenRepository: Repository<EmailVerificationToken>,
    private readonly emailService: EmailService,
  ) {}

  /**
   * Genera token de verificacion seguro (32 bytes hex)
   */
  generateVerificationToken(): string {
    return crypto.randomBytes(32).toString('hex');
  }

  /**
   * Envia email de verificacion al usuario
   * Invalida tokens anteriores antes de crear uno nuevo
   */
  async sendVerificationEmail(userId: string, email: string, ipAddress?: string): Promise<SendVerificationResponse>;

  /**
   * Verifica email con token
   * Marca token como usado y activa usuario si estaba PENDING_VERIFICATION
   */
  async verifyEmail(token: string, ipAddress?: string): Promise<VerifyEmailResponse>;

  /**
   * Reenvia email de verificacion con rate limiting
   */
  async resendVerificationEmail(userId: string, ipAddress?: string): Promise<SendVerificationResponse>;

  /**
   * Obtiene estado de verificacion de email
   */
  async getVerificationStatus(userId: string): Promise<EmailVerificationStatusResponse>;

  /**
   * Limpieza de tokens expirados (cron job)
   */
  async cleanupExpiredTokens(): Promise<number>;
}

PermissionCacheService

// services/permission-cache.service.ts
/**
 * Servicio de Cache de Permisos en Redis
 *
 * Proporciona lookups rapidos de permisos con fallback graceful
 * cuando Redis no esta disponible.
 *
 * Tests: 37 tests en permission-cache.service.spec.ts
 */

@Injectable()
export class PermissionCacheService {
  readonly DEFAULT_TTL = 300; // 5 minutos
  readonly KEY_PREFIX = 'perm:';

  constructor(@InjectRedis() private readonly redis: Redis) {}

  // ===== Cache de Permisos =====

  /**
   * Obtiene permisos cacheados para un usuario
   */
  async getUserPermissions(userId: string): Promise<string[] | null>;

  /**
   * Cachea permisos de un usuario
   */
  async setUserPermissions(userId: string, permissions: string[], ttl?: number): Promise<void>;

  /**
   * Invalida cache de permisos de un usuario
   */
  async invalidateUserPermissions(userId: string): Promise<void>;

  // ===== Cache de Roles =====

  /**
   * Obtiene roles cacheados para un usuario
   */
  async getUserRoles(userId: string): Promise<string[] | null>;

  /**
   * Cachea roles de un usuario
   */
  async setUserRoles(userId: string, roles: string[], ttl?: number): Promise<void>;

  /**
   * Invalida cache de roles de un usuario
   */
  async invalidateUserRoles(userId: string): Promise<void>;

  // ===== Verificacion de Permisos =====

  /**
   * Verifica si usuario tiene un permiso especifico
   */
  async hasPermission(userId: string, permission: string): Promise<boolean>;

  /**
   * Verifica si usuario tiene alguno de los permisos
   */
  async hasAnyPermission(userId: string, permissions: string[]): Promise<boolean>;

  /**
   * Verifica si usuario tiene todos los permisos
   */
  async hasAllPermissions(userId: string, permissions: string[]): Promise<boolean>;

  // ===== Invalidacion Masiva =====

  /**
   * Invalida todo el cache (permisos y roles) de un usuario
   */
  async invalidateAllForUser(userId: string): Promise<void>;

  /**
   * Invalida todo el cache de un tenant (usando SCAN)
   */
  async invalidateAllForTenant(tenantId: string): Promise<void>;
}

ApiKeysService

// apiKeys.service.ts
/**
 * Servicio de Gestion de API Keys
 *
 * Permite autenticacion programatica mediante API keys:
 * - Generacion de keys seguras con PBKDF2
 * - Validacion con whitelist de IPs
 * - Expiracion configurable
 * - Scopes para control de acceso
 */

const API_KEY_PREFIX = 'mgn_';
const KEY_LENGTH = 32; // 256 bits
const HASH_ITERATIONS = 100000;
const HASH_DIGEST = 'sha512';

export interface CreateApiKeyDto {
  user_id: string;
  tenant_id: string;
  name: string;
  scope?: string;
  allowed_ips?: string[];
  expiration_days?: number;
}

@Injectable()
export class ApiKeysService {
  /**
   * Crea una nueva API key
   * La key plain solo se retorna una vez, no puede recuperarse
   */
  async create(dto: CreateApiKeyDto): Promise<ApiKeyWithPlainKey>;

  /**
   * Lista todas las API keys de un usuario/tenant
   */
  async findAll(filters: ApiKeyFilters): Promise<ApiKey[]>;

  /**
   * Busca API key por ID
   */
  async findById(id: string, tenantId: string): Promise<ApiKey | null>;

  /**
   * Actualiza una API key
   */
  async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise<ApiKey>;

  /**
   * Revoca (soft delete) una API key
   */
  async revoke(id: string, tenantId: string): Promise<void>;

  /**
   * Elimina permanentemente una API key
   */
  async delete(id: string, tenantId: string): Promise<void>;

  /**
   * Valida una API key y retorna info del usuario
   * Usado por el middleware de autenticacion
   */
  async validate(plainKey: string, clientIp?: string): Promise<ApiKeyValidationResult>;

  /**
   * Regenera una API key (invalida la anterior)
   */
  async regenerate(id: string, tenantId: string): Promise<ApiKeyWithPlainKey>;
}

OAuthService

// providers/oauth.service.ts
/**
 * Servicio de Autenticacion OAuth2
 *
 * Soporta Google y Microsoft con:
 * - PKCE para seguridad adicional
 * - State management para proteccion CSRF
 * - Creacion/vinculacion automatica de usuarios
 * - Configuracion por tenant
 *
 * Tests: 32 tests en oauth.service.spec.ts
 */

const STATE_EXPIRY_MINUTES = 10;

export type OAuthProviderType = 'google' | 'microsoft';

export interface OAuthLoginResult {
  isNewUser: boolean;
  userId: string;
  tenantId: string;
  accessToken: string;
  refreshToken: string;
  sessionId: string;
  expiresAt: Date;
}

@Injectable()
export class OAuthService {
  private providers: Map<OAuthProviderType, IOAuthProvider> = new Map();

  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
    @InjectRepository(OAuthProvider) private readonly providerRepository: Repository<OAuthProvider>,
    @InjectRepository(OAuthUserLink) private readonly userLinkRepository: Repository<OAuthUserLink>,
    @InjectRepository(OAuthState) private readonly stateRepository: Repository<OAuthState>,
    @InjectRepository(Tenant) private readonly tenantRepository: Repository<Tenant>,
    private readonly tokenService: TokenService,
  ) {}

  // ===== PKCE Helpers =====

  private generateCodeVerifier(): string {
    return crypto.randomBytes(32).toString('base64url');
  }

  private generateCodeChallenge(verifier: string): string {
    return crypto.createHash('sha256').update(verifier).digest('base64url');
  }

  // ===== OAuth Flow =====

  /**
   * Inicia flujo OAuth - genera URL de autorizacion
   * Guarda state con PKCE code_verifier en BD
   */
  async initiateOAuth(
    providerType: OAuthProviderType,
    returnUrl: string | undefined,
    metadata: RequestMetadata,
    linkUserId?: string
  ): Promise<{ authUrl: string; state: string }>;

  /**
   * Maneja callback OAuth
   * Valida state, intercambia code por tokens, crea/vincula usuario
   */
  async handleCallback(
    providerType: OAuthProviderType,
    code: string,
    state: string,
    metadata: RequestMetadata
  ): Promise<OAuthLoginResult>;

  // ===== Gestion de Cuentas =====

  /**
   * Obtiene links OAuth de un usuario
   */
  async getUserOAuthLinks(userId: string): Promise<OAuthUserLink[]>;

  /**
   * Desvincula proveedor OAuth de un usuario
   * No permite si es el unico metodo de autenticacion
   */
  async unlinkProvider(userId: string, providerId: string): Promise<void>;

  /**
   * Limpieza de estados OAuth expirados (cron job)
   */
  async cleanupExpiredStates(): Promise<number>;
}

Controller

// controllers/auth.controller.ts
import {
  Controller, Post, Get, Body, Param, Req, Res,
  HttpCode, HttpStatus, UseGuards
} from '@nestjs/common';
import { Response, Request } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from '../services/auth.service';
import { TokenService } from '../services/token.service';
import { MfaService } from '../mfa.service';
import { TrustedDevicesService } from '../services/trusted-devices.service';
import { EmailVerificationService } from '../services/email-verification.service';
import { ApiKeysService } from '../apiKeys.service';
import { OAuthService } from '../providers/oauth.service';
import { LoginDto } from '../dto/login.dto';
import { LoginResponseDto } from '../dto/login-response.dto';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { TokenResponseDto } from '../dto/token-response.dto';
import { RequestPasswordResetDto } from '../dto/request-password-reset.dto';
import { ResetPasswordDto } from '../dto/reset-password.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator';
import { CurrentUser } from '../decorators/current-user.decorator';

@ApiTags('Auth')
@Controller('api/v1/auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly tokenService: TokenService,
    private readonly mfaService: MfaService,
    private readonly trustedDevicesService: TrustedDevicesService,
    private readonly emailVerificationService: EmailVerificationService,
    private readonly apiKeysService: ApiKeysService,
    private readonly oauthService: OAuthService,
  ) {}

  @Post('login')
  @Public()
  @Throttle({ default: { limit: 10, ttl: 60000 } }) // 10 requests/min
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Autenticar usuario' })
  @ApiResponse({ status: 200, type: LoginResponseDto })
  @ApiResponse({ status: 401, description: 'Credenciales inválidas' })
  @ApiResponse({ status: 423, description: 'Cuenta bloqueada' })
  async login(
    @Body() dto: LoginDto,
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ): Promise<LoginResponseDto> {
    const metadata = {
      ipAddress: req.ip,
      userAgent: req.headers['user-agent'] || '',
    };

    const result = await this.authService.login(dto, metadata);

    // Set refresh token as httpOnly cookie
    res.cookie('refresh_token', result.refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 días
    });

    return result;
  }

  @Post('refresh')
  @Public()
  @Throttle({ default: { limit: 1, ttl: 1000 } }) // 1 request/sec
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Renovar tokens' })
  @ApiResponse({ status: 200, type: TokenResponseDto })
  @ApiResponse({ status: 401, description: 'Token inválido o expirado' })
  async refresh(
    @Body() dto: RefreshTokenDto,
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ): Promise<TokenResponseDto> {
    // Preferir cookie sobre body
    const refreshToken = req.cookies['refresh_token'] || dto.refreshToken;

    const tokens = await this.tokenService.refreshTokens(refreshToken);

    // Update cookie
    res.cookie('refresh_token', tokens.refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
    });

    return {
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
      tokenType: 'Bearer',
      expiresIn: 900,
    };
  }

  @Post('logout')
  @UseGuards(JwtAuthGuard)
  @HttpCode(HttpStatus.OK)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Cerrar sesión' })
  @ApiResponse({ status: 200, description: 'Sesión cerrada' })
  async logout(
    @CurrentUser() user: any,
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ): Promise<{ message: string }> {
    const refreshToken = req.cookies['refresh_token'];
    const accessTokenJti = user.jti;

    await this.authService.logout(
      user.sub,
      user.tid,
      refreshToken, // Extraer jti del refresh token
      accessTokenJti,
    );

    // Clear cookie
    res.clearCookie('refresh_token');

    return { message: 'Sesión cerrada exitosamente' };
  }

  @Post('logout-all')
  @UseGuards(JwtAuthGuard)
  @Throttle({ default: { limit: 5, ttl: 60000 } })
  @HttpCode(HttpStatus.OK)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Cerrar todas las sesiones' })
  @ApiResponse({ status: 200, description: 'Todas las sesiones cerradas' })
  async logoutAll(
    @CurrentUser() user: any,
    @Res({ passthrough: true }) res: Response,
  ): Promise<{ message: string; sessionsRevoked: number }> {
    const count = await this.authService.logoutAll(user.sub, user.tid);

    res.clearCookie('refresh_token');

    return {
      message: 'Todas las sesiones han sido cerradas',
      sessionsRevoked: count,
    };
  }

  @Post('password/request-reset')
  @Public()
  @Throttle({ default: { limit: 3, ttl: 3600000 } }) // 3/hora
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Solicitar recuperación de contraseña' })
  @ApiResponse({ status: 200, description: 'Email enviado si existe' })
  async requestPasswordReset(
    @Body() dto: RequestPasswordResetDto,
  ): Promise<{ message: string }> {
    await this.passwordService.requestPasswordReset(dto.email);
    return {
      message: 'Si el email está registrado, recibirás instrucciones para restablecer tu contraseña',
    };
  }

  @Get('password/validate-token/:token')
  @Public()
  @Throttle({ default: { limit: 10, ttl: 60000 } })
  @ApiOperation({ summary: 'Validar token de recuperación' })
  @ApiResponse({ status: 200 })
  async validateResetToken(
    @Param('token') token: string,
  ): Promise<{ valid: boolean; email?: string; reason?: string }> {
    return this.passwordService.validateResetToken(token);
  }

  @Post('password/reset')
  @Public()
  @Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5/hora
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Restablecer contraseña' })
  @ApiResponse({ status: 200, description: 'Contraseña actualizada' })
  @ApiResponse({ status: 400, description: 'Token inválido o expirado' })
  async resetPassword(
    @Body() dto: ResetPasswordDto,
  ): Promise<{ message: string }> {
    if (dto.newPassword !== dto.confirmPassword) {
      throw new BadRequestException('Las contraseñas no coinciden');
    }

    await this.passwordService.resetPassword(dto.token, dto.newPassword);

    return {
      message: 'Contraseña actualizada exitosamente. Por favor inicia sesión.',
    };
  }
}

Guards y Decorators

JwtAuthGuard

// guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { TokenService } from '../services/token.service';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(
    private reflector: Reflector,
    private tokenService: TokenService,
  ) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Check if route is public
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    // Run passport strategy
    const canActivate = await super.canActivate(context);
    if (!canActivate) {
      return false;
    }

    // Verificar que el token no haya sido revocado
    const request = context.switchToHttp().getRequest();
    const user = request.user;

    // La validacion de tokens revocados se hace en el TokenService
    // mediante consulta a la BD de refresh_tokens

    return true;
  }

  handleRequest(err: any, user: any, info: any) {
    if (err || !user) {
      throw err || new UnauthorizedException('Token invalido o expirado');
    }
    return user;
  }
}

Public Decorator

// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

CurrentUser Decorator

// decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return data ? user?.[data] : user;
  },
);

Configuracion

JWT Config

// config/jwt.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('jwt', () => ({
  accessToken: {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'erp-core',
    audience: 'erp-api',
  },
  refreshToken: {
    algorithm: 'RS256',
    expiresIn: '7d',
    issuer: 'erp-core',
    audience: 'erp-api',
  },
  privateKey: process.env.JWT_PRIVATE_KEY,
  publicKey: process.env.JWT_PUBLIC_KEY,
}));

Auth Module

// auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ThrottlerModule } from '@nestjs/throttler';
import { AuthController } from './controllers/auth.controller';
import { AuthService } from './services/auth.service';
import { TokenService } from './services/token.service';
import { MfaService } from './mfa.service';
import { TrustedDevicesService } from './services/trusted-devices.service';
import { EmailVerificationService } from './services/email-verification.service';
import { PermissionCacheService } from './services/permission-cache.service';
import { ApiKeysService } from './apiKeys.service';
import { OAuthService } from './providers/oauth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
// Entidades
import { RefreshToken } from './entities/refresh-token.entity';
import { RevokedToken } from './entities/revoked-token.entity';
import { SessionHistory } from './entities/session-history.entity';
import { LoginAttempt } from './entities/login-attempt.entity';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { PasswordHistory } from './entities/password-history.entity';
import { UserMfa } from './entities/user-mfa.entity';
import { MfaAuditLog } from './entities/mfa-audit-log.entity';
import { TrustedDevice } from './entities/trusted-device.entity';
import { EmailVerificationToken } from './entities/email-verification-token.entity';
import { ApiKey } from './entities/api-key.entity';
import { OAuthProvider } from './entities/oauth-provider.entity';
import { OAuthState } from './entities/oauth-state.entity';
import { OAuthUserLink } from './entities/oauth-user-link.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      RefreshToken,
      RevokedToken,
      SessionHistory,
      LoginAttempt,
      PasswordResetToken,
      PasswordHistory,
      UserMfa,
      MfaAuditLog,
      TrustedDevice,
      EmailVerificationToken,
      ApiKey,
      OAuthProvider,
      OAuthState,
      OAuthUserLink,
    ]),
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({}),
    ThrottlerModule.forRoot([{
      ttl: 60000,
      limit: 10,
    }]),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    TokenService,
    MfaService,
    TrustedDevicesService,
    EmailVerificationService,
    PermissionCacheService,
    ApiKeysService,
    OAuthService,
    JwtStrategy,
    JwtAuthGuard,
  ],
  exports: [
    AuthService,
    TokenService,
    MfaService,
    TrustedDevicesService,
    EmailVerificationService,
    PermissionCacheService,
    ApiKeysService,
    OAuthService,
    JwtAuthGuard,
  ],
})
export class AuthModule {}

Manejo de Errores

Error Responses

// Estructura estándar de error
{
  "statusCode": 401,
  "message": "Credenciales inválidas",
  "error": "Unauthorized",
  "timestamp": "2025-12-05T10:30:00.000Z",
  "path": "/api/v1/auth/login"
}

Códigos de Error

Código Constante Descripción
AUTH001 INVALID_CREDENTIALS Email o password incorrecto
AUTH002 ACCOUNT_LOCKED Cuenta bloqueada por intentos
AUTH003 ACCOUNT_INACTIVE Cuenta deshabilitada
AUTH004 TOKEN_EXPIRED Token expirado
AUTH005 TOKEN_INVALID Token malformado o inválido
AUTH006 TOKEN_REVOKED Token revocado
AUTH007 SESSION_COMPROMISED Reuso de token detectado
AUTH008 RESET_TOKEN_EXPIRED Token de reset expirado
AUTH009 RESET_TOKEN_USED Token de reset ya usado
AUTH010 PASSWORD_REUSED Password igual a anterior

Notas de Implementacion

  1. Seguridad:

    • Siempre usar HTTPS en producción
    • Refresh token SOLO en httpOnly cookie
    • Rate limiting en todos los endpoints
    • No revelar existencia de emails
  2. Performance:

    • Blacklist en Redis (no en PostgreSQL)
    • Índices apropiados en tablas de tokens
    • Connection pooling para BD
  3. Monitoreo:

    • Loguear todos los eventos de auth
    • Alertas en detección de token replay
    • Métricas de intentos fallidos

Historial de Cambios

Version Fecha Autor Cambios
1.0 2025-12-05 System Creacion inicial
2.0 2026-01-10 System Documentacion de servicios implementados: MfaService (45 tests), TrustedDevicesService (41 tests), EmailVerificationService (32 tests), PermissionCacheService (37 tests), ApiKeysService, OAuthService (32 tests). Eliminacion de referencias a servicios inexistentes (password.service.ts, blacklist.service.ts). Adicion de nuevas entidades: UserMfa, TrustedDevice, EmailVerificationToken, ApiKey, OAuthProvider.

Aprobaciones

Rol Nombre Fecha Firma
Tech Lead - - [ ]
Security - - [ ]
Backend Lead - - [ ]