46 KiB
46 KiB
Especificacion Tecnica Backend - MGN-001 Auth
Identificacion
| Campo | Valor |
|---|---|
| Modulo | MGN-001 |
| Nombre | Auth - Autenticacion |
| Version | 1.0 |
| Framework | NestJS |
| Estado | En Diseño |
| Autor | System |
| Fecha | 2025-12-05 |
Estructura del Modulo
src/modules/auth/
├── auth.module.ts
├── controllers/
│ └── auth.controller.ts
├── services/
│ ├── auth.service.ts
│ ├── token.service.ts
│ ├── password.service.ts
│ └── blacklist.service.ts
├── guards/
│ ├── jwt-auth.guard.ts
│ └── throttler.guard.ts
├── strategies/
│ └── jwt.strategy.ts
├── decorators/
│ ├── current-user.decorator.ts
│ └── public.decorator.ts
├── dto/
│ ├── login.dto.ts
│ ├── login-response.dto.ts
│ ├── refresh-token.dto.ts
│ ├── token-response.dto.ts
│ ├── request-password-reset.dto.ts
│ └── reset-password.dto.ts
├── interfaces/
│ ├── jwt-payload.interface.ts
│ └── token-pair.interface.ts
├── entities/
│ ├── refresh-token.entity.ts
│ ├── revoked-token.entity.ts
│ ├── session-history.entity.ts
│ ├── login-attempt.entity.ts
│ ├── password-reset-token.entity.ts
│ └── password-history.entity.ts
└── constants/
└── auth.constants.ts
Entidades
RefreshToken
// entities/refresh-token.entity.ts
import {
Entity, PrimaryGeneratedColumn, Column, ManyToOne,
CreateDateColumn, Index
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Tenant } from '../../tenants/entities/tenant.entity';
@Entity({ schema: 'core_auth', name: 'refresh_tokens' })
@Index(['userId', 'tenantId'], { where: '"revoked_at" IS NULL AND "is_used" = false' })
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
tenant: Tenant;
@Column({ type: 'varchar', length: 64, unique: true })
jti: string;
@Column({ name: 'token_hash', type: 'varchar', length: 255 })
tokenHash: string;
@Column({ name: 'family_id', type: 'uuid' })
familyId: string;
@Column({ name: 'is_used', type: 'boolean', default: false })
isUsed: boolean;
@Column({ name: 'used_at', type: 'timestamptz', nullable: true })
usedAt: Date | null;
@Column({ name: 'replaced_by', type: 'uuid', nullable: true })
replacedBy: string | null;
@Column({ name: 'device_info', type: 'varchar', length: 500, nullable: true })
deviceInfo: string | null;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string | null;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt: Date | null;
@Column({ name: 'revoked_reason', type: 'varchar', length: 50, nullable: true })
revokedReason: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}
SessionHistory
// entities/session-history.entity.ts
@Entity({ schema: 'core_auth', name: 'session_history' })
export class SessionHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 30 })
action: SessionAction;
@Column({ name: 'device_info', type: 'varchar', length: 500, nullable: true })
deviceInfo: string | null;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string | null;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}
export type SessionAction =
| 'login'
| 'logout'
| 'logout_all'
| 'refresh'
| 'password_change'
| 'password_reset'
| 'account_locked'
| 'account_unlocked';
LoginAttempt
// entities/login-attempt.entity.ts
@Entity({ schema: 'core_auth', name: 'login_attempts' })
export class LoginAttempt {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
email: string;
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string | null;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Column({ type: 'boolean' })
success: boolean;
@Column({ name: 'failure_reason', type: 'varchar', length: 50, nullable: true })
failureReason: string | null;
@Column({ name: 'ip_address', type: 'inet' })
ipAddress: string;
@Column({ name: 'user_agent', type: 'varchar', length: 500, nullable: true })
userAgent: string | null;
@Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'NOW()' })
attemptedAt: Date;
}
PasswordResetToken
// entities/password-reset-token.entity.ts
@Entity({ schema: 'core_auth', name: 'password_reset_tokens' })
export class PasswordResetToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'token_hash', type: 'varchar', length: 255 })
tokenHash: string;
@Column({ type: 'integer', default: 0 })
attempts: number;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'used_at', type: 'timestamptz', nullable: true })
usedAt: Date | null;
@Column({ name: 'invalidated_at', type: 'timestamptz', nullable: true })
invalidatedAt: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}
Interfaces
JwtPayload
// interfaces/jwt-payload.interface.ts
export interface JwtPayload {
sub: string; // User ID
tid: string; // Tenant ID
email: string;
roles: string[];
permissions?: string[];
iat: number; // Issued At
exp: number; // Expiration
iss: string; // Issuer
aud: string; // Audience
jti: string; // JWT ID
}
export interface JwtRefreshPayload extends Pick<JwtPayload, 'sub' | 'tid' | 'jti' | 'iat' | 'exp' | 'iss' | 'aud'> {
type: 'refresh';
}
TokenPair
// interfaces/token-pair.interface.ts
export interface TokenPair {
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: Date;
refreshTokenExpiresAt: Date;
refreshTokenId: string;
}
DTOs
LoginDto
// dto/login.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
description: 'Email del usuario',
example: 'user@example.com',
})
@IsEmail({}, { message: 'Email inválido' })
@IsNotEmpty({ message: 'Email es requerido' })
email: string;
@ApiProperty({
description: 'Contraseña del usuario',
example: 'SecurePass123!',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Password debe tener mínimo 8 caracteres' })
@MaxLength(128, { message: 'Password no puede exceder 128 caracteres' })
@IsNotEmpty({ message: 'Password es requerido' })
password: string;
}
LoginResponseDto
// dto/login-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class LoginResponseDto {
@ApiProperty({
description: 'Access token JWT',
example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
})
accessToken: string;
@ApiProperty({
description: 'Refresh token JWT',
example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
})
refreshToken: string;
@ApiProperty({
description: 'Tipo de token',
example: 'Bearer',
})
tokenType: string;
@ApiProperty({
description: 'Tiempo de expiración en segundos',
example: 900,
})
expiresIn: number;
@ApiProperty({
description: 'Información básica del usuario',
})
user: {
id: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
};
}
RefreshTokenDto
// dto/refresh-token.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({
description: 'Refresh token para renovar sesión',
example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty({ message: 'Refresh token es requerido' })
refreshToken: string;
}
TokenResponseDto
// dto/token-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class TokenResponseDto {
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
@ApiProperty()
tokenType: string;
@ApiProperty()
expiresIn: number;
}
RequestPasswordResetDto
// dto/request-password-reset.dto.ts
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email del usuario',
example: 'user@example.com',
})
@IsEmail({}, { message: 'Email inválido' })
@IsNotEmpty({ message: 'Email es requerido' })
email: string;
}
ResetPasswordDto
// dto/reset-password.dto.ts
import { IsString, MinLength, MaxLength, Matches, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ResetPasswordDto {
@ApiProperty({
description: 'Token de recuperación',
})
@IsString()
@IsNotEmpty({ message: 'Token es requerido' })
token: string;
@ApiProperty({
description: 'Nueva contraseña',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Password debe tener mínimo 8 caracteres' })
@MaxLength(128, { message: 'Password no puede exceder 128 caracteres' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{
message: 'Password debe incluir mayúscula, minúscula, número y carácter especial',
},
)
@IsNotEmpty({ message: 'Password es requerido' })
newPassword: string;
@ApiProperty({
description: 'Confirmación de nueva contraseña',
})
@IsString()
@IsNotEmpty({ message: 'Confirmación de password es requerida' })
confirmPassword: string;
}
Endpoints
Resumen de Endpoints
| Metodo | Ruta | Descripcion | Auth | Rate Limit |
|---|---|---|---|---|
| POST | /api/v1/auth/login |
Autenticar usuario | No | 10/min/IP |
| POST | /api/v1/auth/refresh |
Renovar tokens | No | 1/seg |
| POST | /api/v1/auth/logout |
Cerrar sesion | Si | 10/min |
| POST | /api/v1/auth/logout-all |
Cerrar todas las sesiones | Si | 5/min |
| POST | /api/v1/auth/password/request-reset |
Solicitar recuperacion | No | 3/hora/email |
| POST | /api/v1/auth/password/reset |
Cambiar password | No | 5/hora/IP |
| GET | /api/v1/auth/password/validate-token/:token |
Validar token | No | 10/min |
POST /api/v1/auth/login
Autentica un usuario con email y password.
Request
// Headers
{
"Content-Type": "application/json",
"X-Tenant-Id": "tenant-uuid" // Opcional si tenant único
}
// Body
{
"email": "user@example.com",
"password": "SecurePass123!"
}
Response Success (200)
{
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
"refreshToken": "eyJhbGciOiJSUzI1NiIs...",
"tokenType": "Bearer",
"expiresIn": 900,
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"roles": ["admin"]
}
}
Response Errors
| Code | Mensaje | Descripcion |
|---|---|---|
| 400 | "Datos de entrada inválidos" | Validación fallida |
| 401 | "Credenciales inválidas" | Email/password incorrecto |
| 423 | "Cuenta bloqueada" | Demasiados intentos |
| 403 | "Cuenta inactiva" | Usuario deshabilitado |
POST /api/v1/auth/refresh
Renueva el par de tokens usando un refresh token válido.
Request
// Headers
{
"Content-Type": "application/json"
}
// Body
{
"refreshToken": "eyJhbGciOiJSUzI1NiIs..."
}
// O mediante httpOnly cookie (preferido)
// Cookie: refresh_token=eyJhbGciOiJSUzI1NiIs...
Response Success (200)
{
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
"refreshToken": "eyJhbGciOiJSUzI1NiIs...",
"tokenType": "Bearer",
"expiresIn": 900
}
Response Errors
| Code | Mensaje | Descripcion |
|---|---|---|
| 400 | "Refresh token requerido" | No se envió token |
| 401 | "Refresh token expirado" | Token expiró |
| 401 | "Sesión comprometida" | Token replay detectado |
| 401 | "Token revocado" | Token fue revocado |
POST /api/v1/auth/logout
Cierra la sesión actual del usuario.
Request
// Headers
{
"Authorization": "Bearer eyJhbGciOiJSUzI1NiIs..."
}
// Cookie (refresh token)
// Cookie: refresh_token=eyJhbGciOiJSUzI1NiIs...
Response Success (200)
{
"message": "Sesión cerrada exitosamente"
}
// Set-Cookie: refresh_token=; Max-Age=0; HttpOnly; Secure; SameSite=Strict
POST /api/v1/auth/logout-all
Cierra todas las sesiones activas del usuario.
Request
// Headers
{
"Authorization": "Bearer eyJhbGciOiJSUzI1NiIs..."
}
Response Success (200)
{
"message": "Todas las sesiones han sido cerradas",
"sessionsRevoked": 3
}
POST /api/v1/auth/password/request-reset
Solicita un enlace de recuperación de contraseña.
Request
{
"email": "user@example.com"
}
Response Success (200)
{
"message": "Si el email está registrado, recibirás instrucciones para restablecer tu contraseña"
}
Nota: Siempre retorna 200 con mensaje genérico para no revelar existencia de emails.
POST /api/v1/auth/password/reset
Establece una nueva contraseña usando el token de recuperación.
Request
{
"token": "a1b2c3d4e5f6...",
"newPassword": "NewSecurePass123!",
"confirmPassword": "NewSecurePass123!"
}
Response Success (200)
{
"message": "Contraseña actualizada exitosamente. Por favor inicia sesión."
}
Response Errors
| Code | Mensaje | Descripcion |
|---|---|---|
| 400 | "Token de recuperación expirado" | Token > 1 hora |
| 400 | "Token ya fue utilizado" | Token usado |
| 400 | "Token invalidado" | 3+ intentos fallidos |
| 400 | "Las contraseñas no coinciden" | Confirmación diferente |
| 400 | "No puede ser igual a contraseñas anteriores" | Historial |
GET /api/v1/auth/password/validate-token/:token
Valida si un token de recuperación es válido antes de mostrar el formulario.
Request
GET /api/v1/auth/password/validate-token/a1b2c3d4e5f6...
Response Success (200)
{
"valid": true,
"email": "u***@example.com" // Email parcialmente oculto
}
Response Invalid (400)
{
"valid": false,
"reason": "expired" | "used" | "invalid"
}
Services
AuthService
// services/auth.service.ts
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../../users/entities/user.entity';
import { TokenService } from './token.service';
import { LoginDto } from '../dto/login.dto';
import { LoginResponseDto } from '../dto/login-response.dto';
import { LoginAttempt } from '../entities/login-attempt.entity';
import { SessionHistory } from '../entities/session-history.entity';
@Injectable()
export class AuthService {
private readonly MAX_FAILED_ATTEMPTS = 5;
private readonly LOCK_DURATION_MINUTES = 30;
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(LoginAttempt)
private readonly loginAttemptRepository: Repository<LoginAttempt>,
@InjectRepository(SessionHistory)
private readonly sessionHistoryRepository: Repository<SessionHistory>,
private readonly tokenService: TokenService,
) {}
async login(dto: LoginDto, metadata: RequestMetadata): Promise<LoginResponseDto> {
// 1. Buscar usuario por email
const user = await this.userRepository.findOne({
where: { email: dto.email.toLowerCase() },
relations: ['roles'],
});
// 2. Verificar si existe
if (!user) {
await this.recordLoginAttempt(dto.email, null, false, 'invalid_credentials', metadata);
throw new UnauthorizedException('Credenciales inválidas');
}
// 3. Verificar si está bloqueado
if (this.isAccountLocked(user)) {
await this.recordLoginAttempt(dto.email, user.id, false, 'account_locked', metadata);
throw new ForbiddenException('Cuenta bloqueada. Intenta de nuevo más tarde.');
}
// 4. Verificar si está activo
if (!user.isActive) {
await this.recordLoginAttempt(dto.email, user.id, false, 'account_inactive', metadata);
throw new ForbiddenException('Cuenta inactiva');
}
// 5. Verificar password
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
await this.handleFailedLogin(user, metadata);
throw new UnauthorizedException('Credenciales inválidas');
}
// 6. Login exitoso - resetear contador
await this.resetFailedAttempts(user);
// 7. Generar tokens
const tokens = await this.tokenService.generateTokenPair(user, metadata);
// 8. Registrar sesión
await this.recordLoginAttempt(dto.email, user.id, true, null, metadata);
await this.recordSessionHistory(user.id, user.tenantId, 'login', metadata);
// 9. Construir respuesta
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenType: 'Bearer',
expiresIn: 900, // 15 minutos
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles: user.roles.map(r => r.name),
},
};
}
async logout(userId: string, tenantId: string, refreshTokenJti: string, accessTokenJti: string): Promise<void> {
// 1. Revocar refresh token
await this.tokenService.revokeRefreshToken(refreshTokenJti, 'user_logout');
// 2. Blacklistear access token
await this.tokenService.blacklistAccessToken(accessTokenJti);
// 3. Registrar en historial
await this.recordSessionHistory(userId, tenantId, 'logout', {});
}
async logoutAll(userId: string, tenantId: string): Promise<number> {
// 1. Revocar todos los refresh tokens
const revokedCount = await this.tokenService.revokeAllUserTokens(userId, 'logout_all');
// 2. Registrar en historial
await this.recordSessionHistory(userId, tenantId, 'logout_all', {
sessionsRevoked: revokedCount,
});
return revokedCount;
}
private isAccountLocked(user: User): boolean {
if (!user.lockedUntil) return false;
return new Date() < user.lockedUntil;
}
private async handleFailedLogin(user: User, metadata: RequestMetadata): Promise<void> {
user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;
if (user.failedLoginAttempts >= this.MAX_FAILED_ATTEMPTS) {
user.lockedUntil = new Date(Date.now() + this.LOCK_DURATION_MINUTES * 60 * 1000);
await this.recordSessionHistory(user.id, user.tenantId, 'account_locked', {});
}
await this.userRepository.save(user);
await this.recordLoginAttempt(user.email, user.id, false, 'invalid_credentials', metadata);
}
private async resetFailedAttempts(user: User): Promise<void> {
if (user.failedLoginAttempts > 0 || user.lockedUntil) {
user.failedLoginAttempts = 0;
user.lockedUntil = null;
await this.userRepository.save(user);
}
}
private async recordLoginAttempt(
email: string,
userId: string | null,
success: boolean,
failureReason: string | null,
metadata: RequestMetadata,
): Promise<void> {
await this.loginAttemptRepository.save({
email,
userId,
success,
failureReason,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
});
}
private async recordSessionHistory(
userId: string,
tenantId: string,
action: SessionAction,
metadata: Record<string, any>,
): Promise<void> {
await this.sessionHistoryRepository.save({
userId,
tenantId,
action,
metadata,
});
}
}
interface RequestMetadata {
ipAddress: string;
userAgent: string;
deviceInfo?: string;
}
TokenService
// services/token.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { RefreshToken } from '../entities/refresh-token.entity';
import { JwtPayload, JwtRefreshPayload } from '../interfaces/jwt-payload.interface';
import { TokenPair } from '../interfaces/token-pair.interface';
import { BlacklistService } from './blacklist.service';
@Injectable()
export class TokenService {
private readonly accessTokenExpiry = '15m';
private readonly refreshTokenExpiry = '7d';
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepository: Repository<RefreshToken>,
private readonly blacklistService: BlacklistService,
) {}
async generateTokenPair(user: any, metadata: any): Promise<TokenPair> {
const accessTokenJti = uuidv4();
const refreshTokenJti = uuidv4();
const familyId = uuidv4();
// Generar Access Token
const accessPayload: JwtPayload = {
sub: user.id,
tid: user.tenantId,
email: user.email,
roles: user.roles.map((r: any) => r.name),
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 900, // 15 min
iss: 'erp-core',
aud: 'erp-api',
jti: accessTokenJti,
};
const accessToken = this.jwtService.sign(accessPayload, {
algorithm: 'RS256',
privateKey: this.configService.get('JWT_PRIVATE_KEY'),
});
// Generar Refresh Token
const refreshPayload: JwtRefreshPayload = {
sub: user.id,
tid: user.tenantId,
jti: refreshTokenJti,
type: 'refresh',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 días
iss: 'erp-core',
aud: 'erp-api',
};
const refreshToken = this.jwtService.sign(refreshPayload, {
algorithm: 'RS256',
privateKey: this.configService.get('JWT_PRIVATE_KEY'),
});
// Almacenar refresh token en BD
const tokenHash = await bcrypt.hash(refreshToken, 10);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await this.refreshTokenRepository.save({
userId: user.id,
tenantId: user.tenantId,
jti: refreshTokenJti,
tokenHash,
familyId,
ipAddress: metadata.ipAddress,
deviceInfo: metadata.userAgent,
expiresAt,
});
return {
accessToken,
refreshToken,
accessTokenExpiresAt: new Date(accessPayload.exp * 1000),
refreshTokenExpiresAt: expiresAt,
refreshTokenId: refreshTokenJti,
};
}
async refreshTokens(refreshToken: string): Promise<TokenPair> {
// 1. Decodificar token
let decoded: JwtRefreshPayload;
try {
decoded = this.jwtService.verify(refreshToken, {
algorithms: ['RS256'],
publicKey: this.configService.get('JWT_PUBLIC_KEY'),
});
} catch (error) {
throw new UnauthorizedException('Refresh token inválido');
}
// 2. Buscar en BD
const storedToken = await this.refreshTokenRepository.findOne({
where: { jti: decoded.jti },
});
if (!storedToken) {
throw new UnauthorizedException('Refresh token no encontrado');
}
// 3. Verificar si está revocado
if (storedToken.revokedAt) {
throw new UnauthorizedException('Token revocado');
}
// 4. Detectar reuso (token replay attack)
if (storedToken.isUsed) {
// ALERTA DE SEGURIDAD: Token replay detectado
await this.revokeTokenFamily(storedToken.familyId);
throw new UnauthorizedException('Sesión comprometida. Por favor inicia sesión nuevamente.');
}
// 5. Verificar expiración
if (new Date() > storedToken.expiresAt) {
throw new UnauthorizedException('Refresh token expirado');
}
// 6. Marcar como usado
storedToken.isUsed = true;
storedToken.usedAt = new Date();
// 7. Obtener usuario
const user = await this.getUserForRefresh(decoded.sub);
// 8. Generar nuevos tokens (misma familia)
const newTokens = await this.generateTokenPairWithFamily(
user,
storedToken.familyId,
{ ipAddress: storedToken.ipAddress, userAgent: storedToken.deviceInfo },
);
// 9. Actualizar token anterior con referencia al nuevo
storedToken.replacedBy = newTokens.refreshTokenId;
await this.refreshTokenRepository.save(storedToken);
return newTokens;
}
async revokeRefreshToken(jti: string, reason: string): Promise<void> {
await this.refreshTokenRepository.update(
{ jti },
{ revokedAt: new Date(), revokedReason: reason },
);
}
async revokeAllUserTokens(userId: string, reason: string): Promise<number> {
const result = await this.refreshTokenRepository.update(
{ userId, revokedAt: IsNull() },
{ revokedAt: new Date(), revokedReason: reason },
);
return result.affected || 0;
}
async revokeTokenFamily(familyId: string): Promise<void> {
await this.refreshTokenRepository.update(
{ familyId, revokedAt: IsNull() },
{ revokedAt: new Date(), revokedReason: 'security_breach' },
);
}
async blacklistAccessToken(jti: string, expiresIn?: number): Promise<void> {
const ttl = expiresIn || 900; // Default 15 min
await this.blacklistService.blacklist(jti, ttl);
}
async isAccessTokenBlacklisted(jti: string): Promise<boolean> {
return this.blacklistService.isBlacklisted(jti);
}
private async generateTokenPairWithFamily(
user: any,
familyId: string,
metadata: any,
): Promise<TokenPair> {
// Similar a generateTokenPair pero usa familyId existente
// ... implementation
return {} as TokenPair; // Placeholder
}
private async getUserForRefresh(userId: string): Promise<any> {
// Obtener usuario con roles actualizados
// ... implementation
return {}; // Placeholder
}
}
PasswordService
// services/password.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan } from 'typeorm';
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { User } from '../../users/entities/user.entity';
import { PasswordResetToken } from '../entities/password-reset-token.entity';
import { PasswordHistory } from '../entities/password-history.entity';
import { EmailService } from '../../notifications/services/email.service';
import { TokenService } from './token.service';
@Injectable()
export class PasswordService {
private readonly TOKEN_EXPIRY_HOURS = 1;
private readonly MAX_ATTEMPTS = 3;
private readonly PASSWORD_HISTORY_LIMIT = 5;
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(PasswordResetToken)
private readonly resetTokenRepository: Repository<PasswordResetToken>,
@InjectRepository(PasswordHistory)
private readonly passwordHistoryRepository: Repository<PasswordHistory>,
private readonly emailService: EmailService,
private readonly tokenService: TokenService,
) {}
async requestPasswordReset(email: string): Promise<void> {
const user = await this.userRepository.findOne({
where: { email: email.toLowerCase() },
});
// No revelar si el email existe
if (!user) {
return;
}
// Invalidar tokens anteriores
await this.resetTokenRepository.update(
{ userId: user.id, usedAt: IsNull(), invalidatedAt: IsNull() },
{ invalidatedAt: new Date() },
);
// Generar nuevo token
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = await bcrypt.hash(token, 10);
const expiresAt = new Date(Date.now() + this.TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);
await this.resetTokenRepository.save({
userId: user.id,
tenantId: user.tenantId,
tokenHash,
expiresAt,
});
// Enviar email
await this.emailService.sendPasswordResetEmail(user.email, token, user.firstName);
}
async validateResetToken(token: string): Promise<{ valid: boolean; email?: string; reason?: string }> {
const resetTokens = await this.resetTokenRepository.find({
where: {
usedAt: IsNull(),
invalidatedAt: IsNull(),
expiresAt: MoreThan(new Date()),
},
});
for (const resetToken of resetTokens) {
const isMatch = await bcrypt.compare(token, resetToken.tokenHash);
if (isMatch) {
if (resetToken.attempts >= this.MAX_ATTEMPTS) {
return { valid: false, reason: 'invalid' };
}
const user = await this.userRepository.findOne({
where: { id: resetToken.userId },
});
return {
valid: true,
email: this.maskEmail(user?.email || ''),
};
}
}
return { valid: false, reason: 'invalid' };
}
async resetPassword(token: string, newPassword: string): Promise<void> {
// 1. Buscar token válido
const resetTokens = await this.resetTokenRepository.find({
where: {
usedAt: IsNull(),
invalidatedAt: IsNull(),
},
});
let matchedToken: PasswordResetToken | null = null;
for (const resetToken of resetTokens) {
const isMatch = await bcrypt.compare(token, resetToken.tokenHash);
if (isMatch) {
matchedToken = resetToken;
break;
}
}
if (!matchedToken) {
throw new BadRequestException('Token de recuperación inválido');
}
// 2. Verificar expiración
if (new Date() > matchedToken.expiresAt) {
throw new BadRequestException('Token de recuperación expirado');
}
// 3. Verificar intentos
if (matchedToken.attempts >= this.MAX_ATTEMPTS) {
matchedToken.invalidatedAt = new Date();
await this.resetTokenRepository.save(matchedToken);
throw new BadRequestException('Token invalidado por demasiados intentos');
}
// 4. Obtener usuario
const user = await this.userRepository.findOne({
where: { id: matchedToken.userId },
});
if (!user) {
throw new BadRequestException('Usuario no encontrado');
}
// 5. Verificar que no sea password anterior
const isReused = await this.isPasswordReused(user.id, newPassword);
if (isReused) {
matchedToken.attempts += 1;
await this.resetTokenRepository.save(matchedToken);
throw new BadRequestException('No puedes usar una contraseña anterior');
}
// 6. Hashear nuevo password
const passwordHash = await bcrypt.hash(newPassword, 12);
// 7. Guardar en historial
await this.passwordHistoryRepository.save({
userId: user.id,
tenantId: user.tenantId,
passwordHash,
});
// 8. Actualizar usuario
user.passwordHash = passwordHash;
await this.userRepository.save(user);
// 9. Marcar token como usado
matchedToken.usedAt = new Date();
await this.resetTokenRepository.save(matchedToken);
// 10. Revocar todas las sesiones
await this.tokenService.revokeAllUserTokens(user.id, 'password_change');
// 11. Enviar email de confirmación
await this.emailService.sendPasswordChangedEmail(user.email, user.firstName);
}
private async isPasswordReused(userId: string, newPassword: string): Promise<boolean> {
const history = await this.passwordHistoryRepository.find({
where: { userId },
order: { createdAt: 'DESC' },
take: this.PASSWORD_HISTORY_LIMIT,
});
for (const record of history) {
const isMatch = await bcrypt.compare(newPassword, record.passwordHash);
if (isMatch) return true;
}
return false;
}
private maskEmail(email: string): string {
const [local, domain] = email.split('@');
const maskedLocal = local.charAt(0) + '***';
return `${maskedLocal}@${domain}`;
}
}
BlacklistService
// services/blacklist.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
@Injectable()
export class BlacklistService {
private readonly PREFIX = 'token:blacklist:';
constructor(
@InjectRedis() private readonly redis: Redis,
) {}
async blacklist(jti: string, ttlSeconds: number): Promise<void> {
const key = `${this.PREFIX}${jti}`;
await this.redis.set(key, '1', 'EX', ttlSeconds);
}
async isBlacklisted(jti: string): Promise<boolean> {
const key = `${this.PREFIX}${jti}`;
const result = await this.redis.get(key);
return result !== null;
}
async removeFromBlacklist(jti: string): Promise<void> {
const key = `${this.PREFIX}${jti}`;
await this.redis.del(key);
}
}
Controller
// controllers/auth.controller.ts
import {
Controller, Post, Get, Body, Param, Req, Res,
HttpCode, HttpStatus, UseGuards
} from '@nestjs/common';
import { Response, Request } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from '../services/auth.service';
import { TokenService } from '../services/token.service';
import { PasswordService } from '../services/password.service';
import { LoginDto } from '../dto/login.dto';
import { LoginResponseDto } from '../dto/login-response.dto';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { TokenResponseDto } from '../dto/token-response.dto';
import { RequestPasswordResetDto } from '../dto/request-password-reset.dto';
import { ResetPasswordDto } from '../dto/reset-password.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator';
import { CurrentUser } from '../decorators/current-user.decorator';
@ApiTags('Auth')
@Controller('api/v1/auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly tokenService: TokenService,
private readonly passwordService: PasswordService,
) {}
@Post('login')
@Public()
@Throttle({ default: { limit: 10, ttl: 60000 } }) // 10 requests/min
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Autenticar usuario' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Credenciales inválidas' })
@ApiResponse({ status: 423, description: 'Cuenta bloqueada' })
async login(
@Body() dto: LoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const metadata = {
ipAddress: req.ip,
userAgent: req.headers['user-agent'] || '',
};
const result = await this.authService.login(dto, metadata);
// Set refresh token as httpOnly cookie
res.cookie('refresh_token', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 días
});
return result;
}
@Post('refresh')
@Public()
@Throttle({ default: { limit: 1, ttl: 1000 } }) // 1 request/sec
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Renovar tokens' })
@ApiResponse({ status: 200, type: TokenResponseDto })
@ApiResponse({ status: 401, description: 'Token inválido o expirado' })
async refresh(
@Body() dto: RefreshTokenDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<TokenResponseDto> {
// Preferir cookie sobre body
const refreshToken = req.cookies['refresh_token'] || dto.refreshToken;
const tokens = await this.tokenService.refreshTokens(refreshToken);
// Update cookie
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenType: 'Bearer',
expiresIn: 900,
};
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Cerrar sesión' })
@ApiResponse({ status: 200, description: 'Sesión cerrada' })
async logout(
@CurrentUser() user: any,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<{ message: string }> {
const refreshToken = req.cookies['refresh_token'];
const accessTokenJti = user.jti;
await this.authService.logout(
user.sub,
user.tid,
refreshToken, // Extraer jti del refresh token
accessTokenJti,
);
// Clear cookie
res.clearCookie('refresh_token');
return { message: 'Sesión cerrada exitosamente' };
}
@Post('logout-all')
@UseGuards(JwtAuthGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Cerrar todas las sesiones' })
@ApiResponse({ status: 200, description: 'Todas las sesiones cerradas' })
async logoutAll(
@CurrentUser() user: any,
@Res({ passthrough: true }) res: Response,
): Promise<{ message: string; sessionsRevoked: number }> {
const count = await this.authService.logoutAll(user.sub, user.tid);
res.clearCookie('refresh_token');
return {
message: 'Todas las sesiones han sido cerradas',
sessionsRevoked: count,
};
}
@Post('password/request-reset')
@Public()
@Throttle({ default: { limit: 3, ttl: 3600000 } }) // 3/hora
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Solicitar recuperación de contraseña' })
@ApiResponse({ status: 200, description: 'Email enviado si existe' })
async requestPasswordReset(
@Body() dto: RequestPasswordResetDto,
): Promise<{ message: string }> {
await this.passwordService.requestPasswordReset(dto.email);
return {
message: 'Si el email está registrado, recibirás instrucciones para restablecer tu contraseña',
};
}
@Get('password/validate-token/:token')
@Public()
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({ summary: 'Validar token de recuperación' })
@ApiResponse({ status: 200 })
async validateResetToken(
@Param('token') token: string,
): Promise<{ valid: boolean; email?: string; reason?: string }> {
return this.passwordService.validateResetToken(token);
}
@Post('password/reset')
@Public()
@Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5/hora
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Restablecer contraseña' })
@ApiResponse({ status: 200, description: 'Contraseña actualizada' })
@ApiResponse({ status: 400, description: 'Token inválido o expirado' })
async resetPassword(
@Body() dto: ResetPasswordDto,
): Promise<{ message: string }> {
if (dto.newPassword !== dto.confirmPassword) {
throw new BadRequestException('Las contraseñas no coinciden');
}
await this.passwordService.resetPassword(dto.token, dto.newPassword);
return {
message: 'Contraseña actualizada exitosamente. Por favor inicia sesión.',
};
}
}
Guards y Decorators
JwtAuthGuard
// guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { BlacklistService } from '../services/blacklist.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private reflector: Reflector,
private blacklistService: BlacklistService,
) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Check if route is public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
// Run passport strategy
const canActivate = await super.canActivate(context);
if (!canActivate) {
return false;
}
// Check if token is blacklisted
const request = context.switchToHttp().getRequest();
const user = request.user;
if (user?.jti) {
const isBlacklisted = await this.blacklistService.isBlacklisted(user.jti);
if (isBlacklisted) {
throw new UnauthorizedException('Token revocado');
}
}
return true;
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Token inválido o expirado');
}
return user;
}
}
Public Decorator
// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
CurrentUser Decorator
// decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
Configuracion
JWT Config
// config/jwt.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
accessToken: {
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'erp-core',
audience: 'erp-api',
},
refreshToken: {
algorithm: 'RS256',
expiresIn: '7d',
issuer: 'erp-core',
audience: 'erp-api',
},
privateKey: process.env.JWT_PRIVATE_KEY,
publicKey: process.env.JWT_PUBLIC_KEY,
}));
Auth Module
// auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ThrottlerModule } from '@nestjs/throttler';
import { AuthController } from './controllers/auth.controller';
import { AuthService } from './services/auth.service';
import { TokenService } from './services/token.service';
import { PasswordService } from './services/password.service';
import { BlacklistService } from './services/blacklist.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RefreshToken } from './entities/refresh-token.entity';
import { RevokedToken } from './entities/revoked-token.entity';
import { SessionHistory } from './entities/session-history.entity';
import { LoginAttempt } from './entities/login-attempt.entity';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { PasswordHistory } from './entities/password-history.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
RefreshToken,
RevokedToken,
SessionHistory,
LoginAttempt,
PasswordResetToken,
PasswordHistory,
]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({}),
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 10,
}]),
],
controllers: [AuthController],
providers: [
AuthService,
TokenService,
PasswordService,
BlacklistService,
JwtStrategy,
JwtAuthGuard,
],
exports: [
AuthService,
TokenService,
JwtAuthGuard,
],
})
export class AuthModule {}
Manejo de Errores
Error Responses
// Estructura estándar de error
{
"statusCode": 401,
"message": "Credenciales inválidas",
"error": "Unauthorized",
"timestamp": "2025-12-05T10:30:00.000Z",
"path": "/api/v1/auth/login"
}
Códigos de Error
| Código | Constante | Descripción |
|---|---|---|
| AUTH001 | INVALID_CREDENTIALS | Email o password incorrecto |
| AUTH002 | ACCOUNT_LOCKED | Cuenta bloqueada por intentos |
| AUTH003 | ACCOUNT_INACTIVE | Cuenta deshabilitada |
| AUTH004 | TOKEN_EXPIRED | Token expirado |
| AUTH005 | TOKEN_INVALID | Token malformado o inválido |
| AUTH006 | TOKEN_REVOKED | Token revocado |
| AUTH007 | SESSION_COMPROMISED | Reuso de token detectado |
| AUTH008 | RESET_TOKEN_EXPIRED | Token de reset expirado |
| AUTH009 | RESET_TOKEN_USED | Token de reset ya usado |
| AUTH010 | PASSWORD_REUSED | Password igual a anterior |
Notas de Implementacion
-
Seguridad:
- Siempre usar HTTPS en producción
- Refresh token SOLO en httpOnly cookie
- Rate limiting en todos los endpoints
- No revelar existencia de emails
-
Performance:
- Blacklist en Redis (no en PostgreSQL)
- Índices apropiados en tablas de tokens
- Connection pooling para BD
-
Monitoreo:
- Loguear todos los eventos de auth
- Alertas en detección de token replay
- Métricas de intentos fallidos
Historial de Cambios
| Version | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0 | 2025-12-05 | System | Creación inicial |
Aprobaciones
| Rol | Nombre | Fecha | Firma |
|---|---|---|---|
| Tech Lead | - | - | [ ] |
| Security | - | - | [ ] |
| Backend Lead | - | - | [ ] |