erp-core/docs/04-modelado/especificaciones-tecnicas/ET-auth-backend.md

46 KiB

Especificacion Tecnica Backend - MGN-001 Auth

Identificacion

Campo Valor
Modulo MGN-001
Nombre Auth - Autenticacion
Version 1.0
Framework NestJS
Estado En Diseño
Autor System
Fecha 2025-12-05

Estructura del Modulo

src/modules/auth/
├── auth.module.ts
├── controllers/
│   └── auth.controller.ts
├── services/
│   ├── auth.service.ts
│   ├── token.service.ts
│   ├── password.service.ts
│   └── blacklist.service.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
├── 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
└── 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;
}

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 { BlacklistService } from './blacklist.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 blacklistService: BlacklistService,
  ) {}

  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' },
    );
  }

  async blacklistAccessToken(jti: string, expiresIn?: number): Promise<void> {
    const ttl = expiresIn || 900; // Default 15 min
    await this.blacklistService.blacklist(jti, ttl);
  }

  async isAccessTokenBlacklisted(jti: string): Promise<boolean> {
    return this.blacklistService.isBlacklisted(jti);
  }

  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
  }
}

PasswordService

// services/password.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan } from 'typeorm';
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { User } from '../../users/entities/user.entity';
import { PasswordResetToken } from '../entities/password-reset-token.entity';
import { PasswordHistory } from '../entities/password-history.entity';
import { EmailService } from '../../notifications/services/email.service';
import { TokenService } from './token.service';

@Injectable()
export class PasswordService {
  private readonly TOKEN_EXPIRY_HOURS = 1;
  private readonly MAX_ATTEMPTS = 3;
  private readonly PASSWORD_HISTORY_LIMIT = 5;

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(PasswordResetToken)
    private readonly resetTokenRepository: Repository<PasswordResetToken>,
    @InjectRepository(PasswordHistory)
    private readonly passwordHistoryRepository: Repository<PasswordHistory>,
    private readonly emailService: EmailService,
    private readonly tokenService: TokenService,
  ) {}

  async requestPasswordReset(email: string): Promise<void> {
    const user = await this.userRepository.findOne({
      where: { email: email.toLowerCase() },
    });

    // No revelar si el email existe
    if (!user) {
      return;
    }

    // Invalidar tokens anteriores
    await this.resetTokenRepository.update(
      { userId: user.id, usedAt: IsNull(), invalidatedAt: IsNull() },
      { invalidatedAt: new Date() },
    );

    // Generar nuevo token
    const token = crypto.randomBytes(32).toString('hex');
    const tokenHash = await bcrypt.hash(token, 10);
    const expiresAt = new Date(Date.now() + this.TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);

    await this.resetTokenRepository.save({
      userId: user.id,
      tenantId: user.tenantId,
      tokenHash,
      expiresAt,
    });

    // Enviar email
    await this.emailService.sendPasswordResetEmail(user.email, token, user.firstName);
  }

  async validateResetToken(token: string): Promise<{ valid: boolean; email?: string; reason?: string }> {
    const resetTokens = await this.resetTokenRepository.find({
      where: {
        usedAt: IsNull(),
        invalidatedAt: IsNull(),
        expiresAt: MoreThan(new Date()),
      },
    });

    for (const resetToken of resetTokens) {
      const isMatch = await bcrypt.compare(token, resetToken.tokenHash);
      if (isMatch) {
        if (resetToken.attempts >= this.MAX_ATTEMPTS) {
          return { valid: false, reason: 'invalid' };
        }

        const user = await this.userRepository.findOne({
          where: { id: resetToken.userId },
        });

        return {
          valid: true,
          email: this.maskEmail(user?.email || ''),
        };
      }
    }

    return { valid: false, reason: 'invalid' };
  }

  async resetPassword(token: string, newPassword: string): Promise<void> {
    // 1. Buscar token válido
    const resetTokens = await this.resetTokenRepository.find({
      where: {
        usedAt: IsNull(),
        invalidatedAt: IsNull(),
      },
    });

    let matchedToken: PasswordResetToken | null = null;

    for (const resetToken of resetTokens) {
      const isMatch = await bcrypt.compare(token, resetToken.tokenHash);
      if (isMatch) {
        matchedToken = resetToken;
        break;
      }
    }

    if (!matchedToken) {
      throw new BadRequestException('Token de recuperación inválido');
    }

    // 2. Verificar expiración
    if (new Date() > matchedToken.expiresAt) {
      throw new BadRequestException('Token de recuperación expirado');
    }

    // 3. Verificar intentos
    if (matchedToken.attempts >= this.MAX_ATTEMPTS) {
      matchedToken.invalidatedAt = new Date();
      await this.resetTokenRepository.save(matchedToken);
      throw new BadRequestException('Token invalidado por demasiados intentos');
    }

    // 4. Obtener usuario
    const user = await this.userRepository.findOne({
      where: { id: matchedToken.userId },
    });

    if (!user) {
      throw new BadRequestException('Usuario no encontrado');
    }

    // 5. Verificar que no sea password anterior
    const isReused = await this.isPasswordReused(user.id, newPassword);
    if (isReused) {
      matchedToken.attempts += 1;
      await this.resetTokenRepository.save(matchedToken);
      throw new BadRequestException('No puedes usar una contraseña anterior');
    }

    // 6. Hashear nuevo password
    const passwordHash = await bcrypt.hash(newPassword, 12);

    // 7. Guardar en historial
    await this.passwordHistoryRepository.save({
      userId: user.id,
      tenantId: user.tenantId,
      passwordHash,
    });

    // 8. Actualizar usuario
    user.passwordHash = passwordHash;
    await this.userRepository.save(user);

    // 9. Marcar token como usado
    matchedToken.usedAt = new Date();
    await this.resetTokenRepository.save(matchedToken);

    // 10. Revocar todas las sesiones
    await this.tokenService.revokeAllUserTokens(user.id, 'password_change');

    // 11. Enviar email de confirmación
    await this.emailService.sendPasswordChangedEmail(user.email, user.firstName);
  }

  private async isPasswordReused(userId: string, newPassword: string): Promise<boolean> {
    const history = await this.passwordHistoryRepository.find({
      where: { userId },
      order: { createdAt: 'DESC' },
      take: this.PASSWORD_HISTORY_LIMIT,
    });

    for (const record of history) {
      const isMatch = await bcrypt.compare(newPassword, record.passwordHash);
      if (isMatch) return true;
    }

    return false;
  }

  private maskEmail(email: string): string {
    const [local, domain] = email.split('@');
    const maskedLocal = local.charAt(0) + '***';
    return `${maskedLocal}@${domain}`;
  }
}

BlacklistService

// services/blacklist.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class BlacklistService {
  private readonly PREFIX = 'token:blacklist:';

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

  async blacklist(jti: string, ttlSeconds: number): Promise<void> {
    const key = `${this.PREFIX}${jti}`;
    await this.redis.set(key, '1', 'EX', ttlSeconds);
  }

  async isBlacklisted(jti: string): Promise<boolean> {
    const key = `${this.PREFIX}${jti}`;
    const result = await this.redis.get(key);
    return result !== null;
  }

  async removeFromBlacklist(jti: string): Promise<void> {
    const key = `${this.PREFIX}${jti}`;
    await this.redis.del(key);
  }
}

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 { PasswordService } from '../services/password.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 passwordService: PasswordService,
  ) {}

  @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 { BlacklistService } from '../services/blacklist.service';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(
    private reflector: Reflector,
    private blacklistService: BlacklistService,
  ) {
    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;
    }

    // Check if token is blacklisted
    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (user?.jti) {
      const isBlacklisted = await this.blacklistService.isBlacklisted(user.jti);
      if (isBlacklisted) {
        throw new UnauthorizedException('Token revocado');
      }
    }

    return true;
  }

  handleRequest(err: any, user: any, info: any) {
    if (err || !user) {
      throw err || new UnauthorizedException('Token inválido 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 { PasswordService } from './services/password.service';
import { BlacklistService } from './services/blacklist.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
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';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      RefreshToken,
      RevokedToken,
      SessionHistory,
      LoginAttempt,
      PasswordResetToken,
      PasswordHistory,
    ]),
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({}),
    ThrottlerModule.forRoot([{
      ttl: 60000,
      limit: 10,
    }]),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    TokenService,
    PasswordService,
    BlacklistService,
    JwtStrategy,
    JwtAuthGuard,
  ],
  exports: [
    AuthService,
    TokenService,
    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 Creación inicial

Aprobaciones

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