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