# Especificacion Tecnica Backend - MGN-001 Auth ## Identificacion | Campo | Valor | |-------|-------| | **Modulo** | MGN-001 | | **Nombre** | Auth - Autenticacion | | **Version** | 1.0 | | **Framework** | NestJS | | **Estado** | En Diseño | | **Autor** | System | | **Fecha** | 2025-12-05 | --- ## Estructura del Modulo ``` src/modules/auth/ ├── auth.module.ts ├── controllers/ │ └── auth.controller.ts ├── services/ │ ├── auth.service.ts │ ├── token.service.ts │ ├── password.service.ts │ └── blacklist.service.ts ├── guards/ │ ├── jwt-auth.guard.ts │ └── throttler.guard.ts ├── strategies/ │ └── jwt.strategy.ts ├── decorators/ │ ├── current-user.decorator.ts │ └── public.decorator.ts ├── dto/ │ ├── login.dto.ts │ ├── login-response.dto.ts │ ├── refresh-token.dto.ts │ ├── token-response.dto.ts │ ├── request-password-reset.dto.ts │ └── reset-password.dto.ts ├── interfaces/ │ ├── jwt-payload.interface.ts │ └── token-pair.interface.ts ├── entities/ │ ├── refresh-token.entity.ts │ ├── revoked-token.entity.ts │ ├── session-history.entity.ts │ ├── login-attempt.entity.ts │ ├── password-reset-token.entity.ts │ └── password-history.entity.ts └── constants/ └── auth.constants.ts ``` --- ## Entidades ### RefreshToken ```typescript // entities/refresh-token.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, Index } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Tenant } from '../../tenants/entities/tenant.entity'; @Entity({ schema: 'core_auth', name: 'refresh_tokens' }) @Index(['userId', 'tenantId'], { where: '"revoked_at" IS NULL AND "is_used" = false' }) export class RefreshToken { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id', type: 'uuid' }) userId: string; @ManyToOne(() => User, { onDelete: 'CASCADE' }) user: User; @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) tenant: Tenant; @Column({ type: 'varchar', length: 64, unique: true }) jti: string; @Column({ name: 'token_hash', type: 'varchar', length: 255 }) tokenHash: string; @Column({ name: 'family_id', type: 'uuid' }) familyId: string; @Column({ name: 'is_used', type: 'boolean', default: false }) isUsed: boolean; @Column({ name: 'used_at', type: 'timestamptz', nullable: true }) usedAt: Date | null; @Column({ name: 'replaced_by', type: 'uuid', nullable: true }) replacedBy: string | null; @Column({ name: 'device_info', type: 'varchar', length: 500, nullable: true }) deviceInfo: string | null; @Column({ name: 'ip_address', type: 'inet', nullable: true }) ipAddress: string | null; @Column({ name: 'expires_at', type: 'timestamptz' }) expiresAt: Date; @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) revokedAt: Date | null; @Column({ name: 'revoked_reason', type: 'varchar', length: 50, nullable: true }) revokedReason: string | null; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } ``` ### SessionHistory ```typescript // entities/session-history.entity.ts @Entity({ schema: 'core_auth', name: 'session_history' }) export class SessionHistory { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id', type: 'uuid' }) userId: string; @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; @Column({ type: 'varchar', length: 30 }) action: SessionAction; @Column({ name: 'device_info', type: 'varchar', length: 500, nullable: true }) deviceInfo: string | null; @Column({ name: 'ip_address', type: 'inet', nullable: true }) ipAddress: string | null; @Column({ type: 'jsonb', default: {} }) metadata: Record; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } export type SessionAction = | 'login' | 'logout' | 'logout_all' | 'refresh' | 'password_change' | 'password_reset' | 'account_locked' | 'account_unlocked'; ``` ### LoginAttempt ```typescript // entities/login-attempt.entity.ts @Entity({ schema: 'core_auth', name: 'login_attempts' }) export class LoginAttempt { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 255 }) email: string; @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) tenantId: string | null; @Column({ name: 'user_id', type: 'uuid', nullable: true }) userId: string | null; @Column({ type: 'boolean' }) success: boolean; @Column({ name: 'failure_reason', type: 'varchar', length: 50, nullable: true }) failureReason: string | null; @Column({ name: 'ip_address', type: 'inet' }) ipAddress: string; @Column({ name: 'user_agent', type: 'varchar', length: 500, nullable: true }) userAgent: string | null; @Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'NOW()' }) attemptedAt: Date; } ``` ### PasswordResetToken ```typescript // entities/password-reset-token.entity.ts @Entity({ schema: 'core_auth', name: 'password_reset_tokens' }) export class PasswordResetToken { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id', type: 'uuid' }) userId: string; @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; @Column({ name: 'token_hash', type: 'varchar', length: 255 }) tokenHash: string; @Column({ type: 'integer', default: 0 }) attempts: number; @Column({ name: 'expires_at', type: 'timestamptz' }) expiresAt: Date; @Column({ name: 'used_at', type: 'timestamptz', nullable: true }) usedAt: Date | null; @Column({ name: 'invalidated_at', type: 'timestamptz', nullable: true }) invalidatedAt: Date | null; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; } ``` --- ## Interfaces ### JwtPayload ```typescript // interfaces/jwt-payload.interface.ts export interface JwtPayload { sub: string; // User ID tid: string; // Tenant ID email: string; roles: string[]; permissions?: string[]; iat: number; // Issued At exp: number; // Expiration iss: string; // Issuer aud: string; // Audience jti: string; // JWT ID } export interface JwtRefreshPayload extends Pick { 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 { BlacklistService } from './blacklist.service'; @Injectable() export class TokenService { private readonly accessTokenExpiry = '15m'; private readonly refreshTokenExpiry = '7d'; constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, @InjectRepository(RefreshToken) private readonly refreshTokenRepository: Repository, private readonly blacklistService: BlacklistService, ) {} 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' }, ); } async blacklistAccessToken(jti: string, expiresIn?: number): Promise { const ttl = expiresIn || 900; // Default 15 min await this.blacklistService.blacklist(jti, ttl); } async isAccessTokenBlacklisted(jti: string): Promise { return this.blacklistService.isBlacklisted(jti); } 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 } } ``` ### PasswordService ```typescript // services/password.service.ts import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull, MoreThan } from 'typeorm'; import * as crypto from 'crypto'; import * as bcrypt from 'bcrypt'; import { User } from '../../users/entities/user.entity'; import { PasswordResetToken } from '../entities/password-reset-token.entity'; import { PasswordHistory } from '../entities/password-history.entity'; import { EmailService } from '../../notifications/services/email.service'; import { TokenService } from './token.service'; @Injectable() export class PasswordService { private readonly TOKEN_EXPIRY_HOURS = 1; private readonly MAX_ATTEMPTS = 3; private readonly PASSWORD_HISTORY_LIMIT = 5; constructor( @InjectRepository(User) private readonly userRepository: Repository, @InjectRepository(PasswordResetToken) private readonly resetTokenRepository: Repository, @InjectRepository(PasswordHistory) private readonly passwordHistoryRepository: Repository, private readonly emailService: EmailService, private readonly tokenService: TokenService, ) {} async requestPasswordReset(email: string): Promise { const user = await this.userRepository.findOne({ where: { email: email.toLowerCase() }, }); // No revelar si el email existe if (!user) { return; } // Invalidar tokens anteriores await this.resetTokenRepository.update( { userId: user.id, usedAt: IsNull(), invalidatedAt: IsNull() }, { invalidatedAt: new Date() }, ); // Generar nuevo token const token = crypto.randomBytes(32).toString('hex'); const tokenHash = await bcrypt.hash(token, 10); const expiresAt = new Date(Date.now() + this.TOKEN_EXPIRY_HOURS * 60 * 60 * 1000); await this.resetTokenRepository.save({ userId: user.id, tenantId: user.tenantId, tokenHash, expiresAt, }); // Enviar email await this.emailService.sendPasswordResetEmail(user.email, token, user.firstName); } async validateResetToken(token: string): Promise<{ valid: boolean; email?: string; reason?: string }> { const resetTokens = await this.resetTokenRepository.find({ where: { usedAt: IsNull(), invalidatedAt: IsNull(), expiresAt: MoreThan(new Date()), }, }); for (const resetToken of resetTokens) { const isMatch = await bcrypt.compare(token, resetToken.tokenHash); if (isMatch) { if (resetToken.attempts >= this.MAX_ATTEMPTS) { return { valid: false, reason: 'invalid' }; } const user = await this.userRepository.findOne({ where: { id: resetToken.userId }, }); return { valid: true, email: this.maskEmail(user?.email || ''), }; } } return { valid: false, reason: 'invalid' }; } async resetPassword(token: string, newPassword: string): Promise { // 1. Buscar token válido const resetTokens = await this.resetTokenRepository.find({ where: { usedAt: IsNull(), invalidatedAt: IsNull(), }, }); let matchedToken: PasswordResetToken | null = null; for (const resetToken of resetTokens) { const isMatch = await bcrypt.compare(token, resetToken.tokenHash); if (isMatch) { matchedToken = resetToken; break; } } if (!matchedToken) { throw new BadRequestException('Token de recuperación inválido'); } // 2. Verificar expiración if (new Date() > matchedToken.expiresAt) { throw new BadRequestException('Token de recuperación expirado'); } // 3. Verificar intentos if (matchedToken.attempts >= this.MAX_ATTEMPTS) { matchedToken.invalidatedAt = new Date(); await this.resetTokenRepository.save(matchedToken); throw new BadRequestException('Token invalidado por demasiados intentos'); } // 4. Obtener usuario const user = await this.userRepository.findOne({ where: { id: matchedToken.userId }, }); if (!user) { throw new BadRequestException('Usuario no encontrado'); } // 5. Verificar que no sea password anterior const isReused = await this.isPasswordReused(user.id, newPassword); if (isReused) { matchedToken.attempts += 1; await this.resetTokenRepository.save(matchedToken); throw new BadRequestException('No puedes usar una contraseña anterior'); } // 6. Hashear nuevo password const passwordHash = await bcrypt.hash(newPassword, 12); // 7. Guardar en historial await this.passwordHistoryRepository.save({ userId: user.id, tenantId: user.tenantId, passwordHash, }); // 8. Actualizar usuario user.passwordHash = passwordHash; await this.userRepository.save(user); // 9. Marcar token como usado matchedToken.usedAt = new Date(); await this.resetTokenRepository.save(matchedToken); // 10. Revocar todas las sesiones await this.tokenService.revokeAllUserTokens(user.id, 'password_change'); // 11. Enviar email de confirmación await this.emailService.sendPasswordChangedEmail(user.email, user.firstName); } private async isPasswordReused(userId: string, newPassword: string): Promise { const history = await this.passwordHistoryRepository.find({ where: { userId }, order: { createdAt: 'DESC' }, take: this.PASSWORD_HISTORY_LIMIT, }); for (const record of history) { const isMatch = await bcrypt.compare(newPassword, record.passwordHash); if (isMatch) return true; } return false; } private maskEmail(email: string): string { const [local, domain] = email.split('@'); const maskedLocal = local.charAt(0) + '***'; return `${maskedLocal}@${domain}`; } } ``` ### BlacklistService ```typescript // services/blacklist.service.ts import { Injectable } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; @Injectable() export class BlacklistService { private readonly PREFIX = 'token:blacklist:'; constructor( @InjectRedis() private readonly redis: Redis, ) {} async blacklist(jti: string, ttlSeconds: number): Promise { const key = `${this.PREFIX}${jti}`; await this.redis.set(key, '1', 'EX', ttlSeconds); } async isBlacklisted(jti: string): Promise { const key = `${this.PREFIX}${jti}`; const result = await this.redis.get(key); return result !== null; } async removeFromBlacklist(jti: string): Promise { const key = `${this.PREFIX}${jti}`; await this.redis.del(key); } } ``` --- ## Controller ```typescript // controllers/auth.controller.ts import { Controller, Post, Get, Body, Param, Req, Res, HttpCode, HttpStatus, UseGuards } from '@nestjs/common'; import { Response, Request } from 'express'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { AuthService } from '../services/auth.service'; import { TokenService } from '../services/token.service'; import { PasswordService } from '../services/password.service'; import { LoginDto } from '../dto/login.dto'; import { LoginResponseDto } from '../dto/login-response.dto'; import { RefreshTokenDto } from '../dto/refresh-token.dto'; import { TokenResponseDto } from '../dto/token-response.dto'; import { RequestPasswordResetDto } from '../dto/request-password-reset.dto'; import { ResetPasswordDto } from '../dto/reset-password.dto'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Public } from '../decorators/public.decorator'; import { CurrentUser } from '../decorators/current-user.decorator'; @ApiTags('Auth') @Controller('api/v1/auth') export class AuthController { constructor( private readonly authService: AuthService, private readonly tokenService: TokenService, private readonly passwordService: PasswordService, ) {} @Post('login') @Public() @Throttle({ default: { limit: 10, ttl: 60000 } }) // 10 requests/min @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Autenticar usuario' }) @ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 401, description: 'Credenciales inválidas' }) @ApiResponse({ status: 423, description: 'Cuenta bloqueada' }) async login( @Body() dto: LoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { 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 { BlacklistService } from '../services/blacklist.service'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { constructor( private reflector: Reflector, private blacklistService: BlacklistService, ) { super(); } async canActivate(context: ExecutionContext): Promise { // 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; } // Check if token is blacklisted const request = context.switchToHttp().getRequest(); const user = request.user; if (user?.jti) { const isBlacklisted = await this.blacklistService.isBlacklisted(user.jti); if (isBlacklisted) { throw new UnauthorizedException('Token revocado'); } } return true; } handleRequest(err: any, user: any, info: any) { if (err || !user) { throw err || new UnauthorizedException('Token inválido o expirado'); } return user; } } ``` ### Public Decorator ```typescript // decorators/public.decorator.ts import { SetMetadata } from '@nestjs/common'; export const IS_PUBLIC_KEY = 'isPublic'; export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); ``` ### CurrentUser Decorator ```typescript // decorators/current-user.decorator.ts import { createParamDecorator, ExecutionContext } from '@nestjs/common'; export const CurrentUser = createParamDecorator( (data: string, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user; return data ? user?.[data] : user; }, ); ``` --- ## Configuracion ### JWT Config ```typescript // config/jwt.config.ts import { registerAs } from '@nestjs/config'; export default registerAs('jwt', () => ({ accessToken: { algorithm: 'RS256', expiresIn: '15m', issuer: 'erp-core', audience: 'erp-api', }, refreshToken: { algorithm: 'RS256', expiresIn: '7d', issuer: 'erp-core', audience: 'erp-api', }, privateKey: process.env.JWT_PRIVATE_KEY, publicKey: process.env.JWT_PUBLIC_KEY, })); ``` ### Auth Module ```typescript // auth.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { ThrottlerModule } from '@nestjs/throttler'; import { AuthController } from './controllers/auth.controller'; import { AuthService } from './services/auth.service'; import { TokenService } from './services/token.service'; import { PasswordService } from './services/password.service'; import { BlacklistService } from './services/blacklist.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RefreshToken } from './entities/refresh-token.entity'; import { RevokedToken } from './entities/revoked-token.entity'; import { SessionHistory } from './entities/session-history.entity'; import { LoginAttempt } from './entities/login-attempt.entity'; import { PasswordResetToken } from './entities/password-reset-token.entity'; import { PasswordHistory } from './entities/password-history.entity'; @Module({ imports: [ TypeOrmModule.forFeature([ RefreshToken, RevokedToken, SessionHistory, LoginAttempt, PasswordResetToken, PasswordHistory, ]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({}), ThrottlerModule.forRoot([{ ttl: 60000, limit: 10, }]), ], controllers: [AuthController], providers: [ AuthService, TokenService, PasswordService, BlacklistService, JwtStrategy, JwtAuthGuard, ], exports: [ AuthService, TokenService, JwtAuthGuard, ], }) export class AuthModule {} ``` --- ## Manejo de Errores ### Error Responses ```typescript // Estructura estándar de error { "statusCode": 401, "message": "Credenciales inválidas", "error": "Unauthorized", "timestamp": "2025-12-05T10:30:00.000Z", "path": "/api/v1/auth/login" } ``` ### Códigos de Error | Código | Constante | Descripción | |--------|-----------|-------------| | AUTH001 | INVALID_CREDENTIALS | Email o password incorrecto | | AUTH002 | ACCOUNT_LOCKED | Cuenta bloqueada por intentos | | AUTH003 | ACCOUNT_INACTIVE | Cuenta deshabilitada | | AUTH004 | TOKEN_EXPIRED | Token expirado | | AUTH005 | TOKEN_INVALID | Token malformado o inválido | | AUTH006 | TOKEN_REVOKED | Token revocado | | AUTH007 | SESSION_COMPROMISED | Reuso de token detectado | | AUTH008 | RESET_TOKEN_EXPIRED | Token de reset expirado | | AUTH009 | RESET_TOKEN_USED | Token de reset ya usado | | AUTH010 | PASSWORD_REUSED | Password igual a anterior | --- ## Notas de Implementacion 1. **Seguridad:** - Siempre usar HTTPS en producción - Refresh token SOLO en httpOnly cookie - Rate limiting en todos los endpoints - No revelar existencia de emails 2. **Performance:** - Blacklist en Redis (no en PostgreSQL) - Índices apropiados en tablas de tokens - Connection pooling para BD 3. **Monitoreo:** - Loguear todos los eventos de auth - Alertas en detección de token replay - Métricas de intentos fallidos --- ## Historial de Cambios | Version | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-05 | System | Creación inicial | --- ## Aprobaciones | Rol | Nombre | Fecha | Firma | |-----|--------|-------|-------| | Tech Lead | - | - | [ ] | | Security | - | - | [ ] | | Backend Lead | - | - | [ ] |