# 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

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

---

## DTOs

### LoginDto

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

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

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

#### Response Success (200)

```typescript
{
  "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

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

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

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

#### Response Success (200)

```typescript
{
  "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

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

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

#### Response Success (200)

```typescript
{
  "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

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

#### Response Success (200)

```typescript
{
  "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

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

#### Response Success (200)

```typescript
{
  "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

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

#### Response Success (200)

```typescript
{
  "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)

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

#### Response Invalid (400)

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

---

## Services

### AuthService

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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 | - | - | [ ] |
