# 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 ```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; @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; } ``` ### UserMfa ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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; // 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 ```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 { 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, @InjectRepository(LoginAttempt) private readonly loginAttemptRepository: Repository, @InjectRepository(SessionHistory) private readonly sessionHistoryRepository: Repository, private readonly tokenService: TokenService, ) {} async login(dto: LoginDto, metadata: RequestMetadata): Promise { // 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 { // 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 { // 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 { 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 { 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 { 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, ): Promise { 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 { 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, private readonly permissionCacheService: PermissionCacheService, ) {} async generateTokenPair(user: any, metadata: any): Promise { 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 { // 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 { await this.refreshTokenRepository.update( { jti }, { revokedAt: new Date(), revokedReason: reason }, ); } async revokeAllUserTokens(userId: string, reason: string): Promise { const result = await this.refreshTokenRepository.update( { userId, revokedAt: IsNull() }, { revokedAt: new Date(), revokedReason: reason }, ); return result.affected || 0; } async revokeTokenFamily(familyId: string): Promise { 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 { await this.permissionCacheService.invalidateAllForUser(userId); } private async generateTokenPairWithFamily( user: any, familyId: string, metadata: any, ): Promise { // Similar a generateTokenPair pero usa familyId existente // ... implementation return {} as TokenPair; // Placeholder } private async getUserForRefresh(userId: string): Promise { // Obtener usuario con roles actualizados // ... implementation return {}; // Placeholder } } ``` ### MfaService ```typescript // 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, @InjectRepository(UserMfa) private readonly userMfaRepository: Repository, @InjectRepository(MfaAuditLog) private readonly mfaAuditLogRepository: Repository, ) {} /** * Inicia configuracion MFA para un usuario * Genera secret TOTP, QR code y codigos de respaldo */ async initiateMfaSetup(userId: string, context: MfaRequestContext): Promise { // 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; /** * Verifica codigo TOTP o codigo de respaldo * Incrementa intentos fallidos y bloquea si excede limite */ async verifyTotp(userId: string, code: string, context: MfaRequestContext): Promise; /** * Deshabilita MFA (requiere password + codigo TOTP/backup) */ async disableMfa(userId: string, password: string, code: string, context: MfaRequestContext): Promise; /** * Regenera codigos de respaldo (requiere verificacion TOTP) */ async regenerateBackupCodes(userId: string, code: string, context: MfaRequestContext): Promise; /** * Obtiene estado MFA de un usuario */ async getMfaStatus(userId: string): Promise; /** * Verifica si el usuario tiene MFA habilitado */ async isMfaEnabled(userId: string): Promise; } ``` ### TrustedDevicesService ```typescript // 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, @InjectRepository(User) private readonly userRepository: Repository, ) {} /** * 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; /** * Verifica si un dispositivo es confiable * Actualiza last_used_at y use_count */ async verifyDevice(userId: string, deviceInfo: DeviceInfo, ipAddress: string): Promise; /** * Lista todos los dispositivos confiables de un usuario */ async listDevices(userId: string, currentFingerprint?: string): Promise; /** * Revoca un dispositivo especifico */ async revokeDevice(userId: string, deviceId: string, reason?: string): Promise; /** * Revoca todos los dispositivos de un usuario (seguridad) */ async revokeAllDevices(userId: string, reason?: string): Promise; /** * Limpieza de dispositivos expirados (cron job) */ async cleanupExpiredDevices(): Promise; /** * Actualiza nivel de confianza de un dispositivo */ async updateTrustLevel(userId: string, deviceId: string, trustLevel: TrustLevel): Promise; } ``` ### EmailVerificationService ```typescript // 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, @InjectRepository(EmailVerificationToken) private readonly tokenRepository: Repository, 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; /** * Verifica email con token * Marca token como usado y activa usuario si estaba PENDING_VERIFICATION */ async verifyEmail(token: string, ipAddress?: string): Promise; /** * Reenvia email de verificacion con rate limiting */ async resendVerificationEmail(userId: string, ipAddress?: string): Promise; /** * Obtiene estado de verificacion de email */ async getVerificationStatus(userId: string): Promise; /** * Limpieza de tokens expirados (cron job) */ async cleanupExpiredTokens(): Promise; } ``` ### PermissionCacheService ```typescript // 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; /** * Cachea permisos de un usuario */ async setUserPermissions(userId: string, permissions: string[], ttl?: number): Promise; /** * Invalida cache de permisos de un usuario */ async invalidateUserPermissions(userId: string): Promise; // ===== Cache de Roles ===== /** * Obtiene roles cacheados para un usuario */ async getUserRoles(userId: string): Promise; /** * Cachea roles de un usuario */ async setUserRoles(userId: string, roles: string[], ttl?: number): Promise; /** * Invalida cache de roles de un usuario */ async invalidateUserRoles(userId: string): Promise; // ===== Verificacion de Permisos ===== /** * Verifica si usuario tiene un permiso especifico */ async hasPermission(userId: string, permission: string): Promise; /** * Verifica si usuario tiene alguno de los permisos */ async hasAnyPermission(userId: string, permissions: string[]): Promise; /** * Verifica si usuario tiene todos los permisos */ async hasAllPermissions(userId: string, permissions: string[]): Promise; // ===== Invalidacion Masiva ===== /** * Invalida todo el cache (permisos y roles) de un usuario */ async invalidateAllForUser(userId: string): Promise; /** * Invalida todo el cache de un tenant (usando SCAN) */ async invalidateAllForTenant(tenantId: string): Promise; } ``` ### ApiKeysService ```typescript // 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; /** * Lista todas las API keys de un usuario/tenant */ async findAll(filters: ApiKeyFilters): Promise; /** * Busca API key por ID */ async findById(id: string, tenantId: string): Promise; /** * Actualiza una API key */ async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise; /** * Revoca (soft delete) una API key */ async revoke(id: string, tenantId: string): Promise; /** * Elimina permanentemente una API key */ async delete(id: string, tenantId: string): Promise; /** * Valida una API key y retorna info del usuario * Usado por el middleware de autenticacion */ async validate(plainKey: string, clientIp?: string): Promise; /** * Regenera una API key (invalida la anterior) */ async regenerate(id: string, tenantId: string): Promise; } ``` ### OAuthService ```typescript // 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 = new Map(); constructor( @InjectRepository(User) private readonly userRepository: Repository, @InjectRepository(OAuthProvider) private readonly providerRepository: Repository, @InjectRepository(OAuthUserLink) private readonly userLinkRepository: Repository, @InjectRepository(OAuthState) private readonly stateRepository: Repository, @InjectRepository(Tenant) private readonly tenantRepository: Repository, 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; // ===== Gestion de Cuentas ===== /** * Obtiene links OAuth de un usuario */ async getUserOAuthLinks(userId: string): Promise; /** * Desvincula proveedor OAuth de un usuario * No permite si es el unico metodo de autenticacion */ async unlinkProvider(userId: string, providerId: string): Promise; /** * Limpieza de estados OAuth expirados (cron job) */ async cleanupExpiredStates(): Promise; } ``` --- ## 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 { 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 { 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 { // 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 { 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 { // Check if route is public const isPublic = this.reflector.getAllAndOverride(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 ```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 { 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 ```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 | 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 | - | - | [ ] |