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
- 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>
65 KiB
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
-
Seguridad:
- Siempre usar HTTPS en producción
- Refresh token SOLO en httpOnly cookie
- Rate limiting en todos los endpoints
- No revelar existencia de emails
-
Performance:
- Blacklist en Redis (no en PostgreSQL)
- Índices apropiados en tablas de tokens
- Connection pooling para BD
-
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 | - | - | [ ] |