875 lines
22 KiB
Markdown
875 lines
22 KiB
Markdown
# ET-ADM-005: Seguridad y Encriptación de Datos
|
|
|
|
**ID:** ET-ADM-005
|
|
**Módulo:** MAI-013
|
|
**Relacionado con:** RF-ADM-001, RF-ADM-002, RF-ADM-004
|
|
|
|
---
|
|
|
|
## 📋 Base de Datos
|
|
|
|
### Tabla: encryption_keys
|
|
|
|
```sql
|
|
CREATE TABLE admin.encryption_keys (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
key_name VARCHAR(100) UNIQUE NOT NULL,
|
|
key_version INT NOT NULL DEFAULT 1,
|
|
encrypted_key TEXT NOT NULL,
|
|
algorithm VARCHAR(50) DEFAULT 'AES-256-GCM',
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
rotated_at TIMESTAMPTZ,
|
|
expires_at TIMESTAMPTZ,
|
|
status VARCHAR(20) DEFAULT 'active',
|
|
|
|
CONSTRAINT chk_status CHECK (status IN ('active', 'rotated', 'revoked'))
|
|
);
|
|
|
|
CREATE INDEX idx_encryption_keys_name ON admin.encryption_keys(key_name);
|
|
CREATE INDEX idx_encryption_keys_status ON admin.encryption_keys(status);
|
|
```
|
|
|
|
### Extensión pgcrypto
|
|
|
|
```sql
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
|
-- Función para encriptar datos sensibles
|
|
CREATE OR REPLACE FUNCTION encrypt_sensitive(data TEXT, key_name VARCHAR)
|
|
RETURNS TEXT AS $$
|
|
DECLARE
|
|
encryption_key TEXT;
|
|
BEGIN
|
|
SELECT encrypted_key INTO encryption_key
|
|
FROM admin.encryption_keys
|
|
WHERE key_name = key_name AND status = 'active'
|
|
LIMIT 1;
|
|
|
|
RETURN encode(
|
|
pgp_sym_encrypt(data, encryption_key),
|
|
'base64'
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Función para desencriptar
|
|
CREATE OR REPLACE FUNCTION decrypt_sensitive(encrypted_data TEXT, key_name VARCHAR)
|
|
RETURNS TEXT AS $$
|
|
DECLARE
|
|
encryption_key TEXT;
|
|
BEGIN
|
|
SELECT encrypted_key INTO encryption_key
|
|
FROM admin.encryption_keys
|
|
WHERE key_name = key_name AND status = 'active'
|
|
LIMIT 1;
|
|
|
|
RETURN pgp_sym_decrypt(
|
|
decode(encrypted_data, 'base64'),
|
|
encryption_key
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
```
|
|
|
|
### Tabla: sessions (seguras)
|
|
|
|
```sql
|
|
CREATE TABLE auth_management.sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth_management.users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL,
|
|
refresh_token_hash TEXT,
|
|
|
|
-- Seguridad
|
|
ip_address INET NOT NULL,
|
|
user_agent TEXT,
|
|
fingerprint TEXT,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
last_activity TIMESTAMPTZ DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
revoked_at TIMESTAMPTZ,
|
|
revoke_reason TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_sessions_user ON auth_management.sessions(user_id);
|
|
CREATE INDEX idx_sessions_token ON auth_management.sessions(token_hash);
|
|
CREATE INDEX idx_sessions_active ON auth_management.sessions(is_active, expires_at);
|
|
|
|
-- Auto-expiración de sesiones inactivas
|
|
CREATE OR REPLACE FUNCTION expire_inactive_sessions()
|
|
RETURNS void AS $$
|
|
BEGIN
|
|
UPDATE auth_management.sessions
|
|
SET is_active = FALSE,
|
|
revoked_at = NOW(),
|
|
revoke_reason = 'Inactivity timeout'
|
|
WHERE is_active = TRUE
|
|
AND last_activity < NOW() - INTERVAL '30 minutes';
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Ejecutar cada 5 minutos
|
|
SELECT cron.schedule('expire-sessions', '*/5 * * * *', 'SELECT expire_inactive_sessions()');
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 Backend
|
|
|
|
### encryption.service.ts
|
|
|
|
```typescript
|
|
import * as crypto from 'crypto';
|
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
@Injectable()
|
|
export class EncryptionService implements OnModuleInit {
|
|
private masterKey: Buffer;
|
|
private algorithm = 'aes-256-gcm';
|
|
|
|
constructor(private configService: ConfigService) {}
|
|
|
|
onModuleInit() {
|
|
// Cargar master key desde AWS KMS o variable de entorno
|
|
const masterKeyBase64 = this.configService.get('MASTER_ENCRYPTION_KEY');
|
|
this.masterKey = Buffer.from(masterKeyBase64, 'base64');
|
|
}
|
|
|
|
/**
|
|
* Encripta datos sensibles (PII)
|
|
*/
|
|
encrypt(plaintext: string): { encrypted: string; iv: string; tag: string } {
|
|
const iv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv(this.algorithm, this.masterKey, iv);
|
|
|
|
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
|
encrypted += cipher.final('hex');
|
|
|
|
const tag = cipher.getAuthTag();
|
|
|
|
return {
|
|
encrypted,
|
|
iv: iv.toString('hex'),
|
|
tag: tag.toString('hex')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Desencripta datos
|
|
*/
|
|
decrypt(encrypted: string, iv: string, tag: string): string {
|
|
const decipher = crypto.createDecipheriv(
|
|
this.algorithm,
|
|
this.masterKey,
|
|
Buffer.from(iv, 'hex')
|
|
);
|
|
|
|
decipher.setAuthTag(Buffer.from(tag, 'hex'));
|
|
|
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
|
|
return decrypted;
|
|
}
|
|
|
|
/**
|
|
* Hash one-way para passwords
|
|
*/
|
|
async hashPassword(password: string): Promise<string> {
|
|
const saltRounds = 12;
|
|
return bcrypt.hash(password, saltRounds);
|
|
}
|
|
|
|
/**
|
|
* Verifica password
|
|
*/
|
|
async verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
/**
|
|
* Genera token seguro
|
|
*/
|
|
generateSecureToken(length: number = 32): string {
|
|
return crypto.randomBytes(length).toString('hex');
|
|
}
|
|
|
|
/**
|
|
* Hash para tokens de sesión
|
|
*/
|
|
hashToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
}
|
|
```
|
|
|
|
### password-policy.service.ts
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class PasswordPolicyService {
|
|
private readonly minLength = 12;
|
|
private readonly requireUppercase = true;
|
|
private readonly requireLowercase = true;
|
|
private readonly requireNumbers = true;
|
|
private readonly requireSpecialChars = true;
|
|
private readonly maxPasswordAge = 90; // días
|
|
private readonly preventReuse = 5; // últimas 5 passwords
|
|
|
|
/**
|
|
* Valida que password cumpla política
|
|
*/
|
|
validatePassword(password: string): { valid: boolean; errors: string[] } {
|
|
const errors: string[] = [];
|
|
|
|
if (password.length < this.minLength) {
|
|
errors.push(`Password debe tener al menos ${this.minLength} caracteres`);
|
|
}
|
|
|
|
if (this.requireUppercase && !/[A-Z]/.test(password)) {
|
|
errors.push('Password debe contener al menos una mayúscula');
|
|
}
|
|
|
|
if (this.requireLowercase && !/[a-z]/.test(password)) {
|
|
errors.push('Password debe contener al menos una minúscula');
|
|
}
|
|
|
|
if (this.requireNumbers && !/\d/.test(password)) {
|
|
errors.push('Password debe contener al menos un número');
|
|
}
|
|
|
|
if (this.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
|
errors.push('Password debe contener al menos un carácter especial');
|
|
}
|
|
|
|
// Detectar patrones comunes
|
|
if (this.hasCommonPatterns(password)) {
|
|
errors.push('Password contiene patrones demasiado simples');
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors
|
|
};
|
|
}
|
|
|
|
private hasCommonPatterns(password: string): boolean {
|
|
const commonPatterns = [
|
|
/123456/,
|
|
/password/i,
|
|
/qwerty/i,
|
|
/(.)\1{3,}/, // Caracteres repetidos (aaaa)
|
|
/(012|123|234|345|456|567|678|789)/, // Secuencias numéricas
|
|
/(abc|bcd|cde|def|efg|fgh|ghi)/i // Secuencias alfabéticas
|
|
];
|
|
|
|
return commonPatterns.some(pattern => pattern.test(password));
|
|
}
|
|
|
|
/**
|
|
* Verifica si password necesita rotación
|
|
*/
|
|
async needsRotation(user: User): Promise<boolean> {
|
|
if (!user.passwordChangedAt) {
|
|
return true;
|
|
}
|
|
|
|
const daysSinceChange = differenceInDays(new Date(), user.passwordChangedAt);
|
|
return daysSinceChange >= this.maxPasswordAge;
|
|
}
|
|
|
|
/**
|
|
* Verifica que no sea password reutilizado
|
|
*/
|
|
async checkPasswordReuse(userId: string, newPasswordHash: string): Promise<boolean> {
|
|
const previousPasswords = await this.passwordHistoryRepo.find({
|
|
where: { userId },
|
|
order: { createdAt: 'DESC' },
|
|
take: this.preventReuse
|
|
});
|
|
|
|
for (const prev of previousPasswords) {
|
|
const isSame = await bcrypt.compare(newPasswordHash, prev.passwordHash);
|
|
if (isSame) {
|
|
return true; // Password ya fue usado
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
```
|
|
|
|
### session-security.service.ts
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class SessionSecurityService {
|
|
constructor(
|
|
@InjectRepository(Session)
|
|
private sessionsRepo: Repository<Session>,
|
|
private encryptionService: EncryptionService,
|
|
private jwtService: JwtService
|
|
) {}
|
|
|
|
/**
|
|
* Crea sesión segura con fingerprinting
|
|
*/
|
|
async createSession(
|
|
user: User,
|
|
ipAddress: string,
|
|
userAgent: string,
|
|
fingerprint: string
|
|
): Promise<{ accessToken: string; refreshToken: string }> {
|
|
// Generar tokens
|
|
const accessToken = this.generateAccessToken(user);
|
|
const refreshToken = this.encryptionService.generateSecureToken(64);
|
|
|
|
// Hash para almacenar
|
|
const tokenHash = this.encryptionService.hashToken(accessToken);
|
|
const refreshTokenHash = this.encryptionService.hashToken(refreshToken);
|
|
|
|
// Crear sesión
|
|
const session = this.sessionsRepo.create({
|
|
userId: user.id,
|
|
tokenHash,
|
|
refreshTokenHash,
|
|
ipAddress,
|
|
userAgent,
|
|
fingerprint,
|
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 min
|
|
isActive: true
|
|
});
|
|
|
|
await this.sessionsRepo.save(session);
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
/**
|
|
* Valida sesión con múltiples factores
|
|
*/
|
|
async validateSession(
|
|
token: string,
|
|
ipAddress: string,
|
|
userAgent: string,
|
|
fingerprint: string
|
|
): Promise<Session | null> {
|
|
const tokenHash = this.encryptionService.hashToken(token);
|
|
|
|
const session = await this.sessionsRepo.findOne({
|
|
where: {
|
|
tokenHash,
|
|
isActive: true
|
|
}
|
|
});
|
|
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
// Verificar expiración
|
|
if (session.expiresAt < new Date()) {
|
|
await this.revokeSession(session.id, 'Token expired');
|
|
return null;
|
|
}
|
|
|
|
// Detectar cambio de IP (posible session hijacking)
|
|
if (session.ipAddress !== ipAddress) {
|
|
await this.handleSuspiciousActivity(session, 'IP address changed');
|
|
return null;
|
|
}
|
|
|
|
// Detectar cambio de fingerprint
|
|
if (session.fingerprint !== fingerprint) {
|
|
await this.handleSuspiciousActivity(session, 'Browser fingerprint changed');
|
|
return null;
|
|
}
|
|
|
|
// Actualizar última actividad
|
|
session.lastActivity = new Date();
|
|
await this.sessionsRepo.save(session);
|
|
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Maneja actividad sospechosa
|
|
*/
|
|
private async handleSuspiciousActivity(session: Session, reason: string): Promise<void> {
|
|
// Revocar sesión inmediatamente
|
|
await this.revokeSession(session.id, reason);
|
|
|
|
// Enviar alerta de seguridad al usuario
|
|
await this.emailService.send({
|
|
to: session.user.email,
|
|
subject: '🚨 Actividad Sospechosa Detectada',
|
|
template: 'security-alert',
|
|
context: {
|
|
reason,
|
|
timestamp: new Date(),
|
|
ipAddress: session.ipAddress
|
|
}
|
|
});
|
|
|
|
// Log de auditoría
|
|
await this.auditService.log({
|
|
userId: session.userId,
|
|
action: 'suspicious_activity_detected',
|
|
module: 'security',
|
|
severity: 'critical',
|
|
metadata: { reason, sessionId: session.id }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revoca sesión
|
|
*/
|
|
async revokeSession(sessionId: string, reason: string): Promise<void> {
|
|
await this.sessionsRepo.update(sessionId, {
|
|
isActive: false,
|
|
revokedAt: new Date(),
|
|
revokeReason: reason
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revoca todas las sesiones del usuario (logout en todos los dispositivos)
|
|
*/
|
|
async revokeAllUserSessions(userId: string): Promise<void> {
|
|
await this.sessionsRepo.update(
|
|
{ userId, isActive: true },
|
|
{
|
|
isActive: false,
|
|
revokedAt: new Date(),
|
|
revokeReason: 'User logout all devices'
|
|
}
|
|
);
|
|
}
|
|
|
|
private generateAccessToken(user: User): string {
|
|
return this.jwtService.sign(
|
|
{
|
|
sub: user.id,
|
|
email: user.email,
|
|
role: user.role
|
|
},
|
|
{
|
|
expiresIn: '15m'
|
|
}
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### security-headers.middleware.ts
|
|
|
|
```typescript
|
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import helmet from 'helmet';
|
|
|
|
@Injectable()
|
|
export class SecurityHeadersMiddleware implements NestMiddleware {
|
|
use(req: Request, res: Response, next: NextFunction) {
|
|
// Content Security Policy
|
|
res.setHeader(
|
|
'Content-Security-Policy',
|
|
"default-src 'self'; " +
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
|
"style-src 'self' 'unsafe-inline'; " +
|
|
"img-src 'self' data: https:; " +
|
|
"font-src 'self' data:; " +
|
|
"connect-src 'self' https://api.example.com"
|
|
);
|
|
|
|
// Strict Transport Security (HSTS)
|
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
|
|
// X-Frame-Options (prevenir clickjacking)
|
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
|
|
// X-Content-Type-Options
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
|
|
// X-XSS-Protection
|
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
|
|
// Referrer-Policy
|
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
|
|
// Permissions-Policy
|
|
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
|
|
|
next();
|
|
}
|
|
}
|
|
```
|
|
|
|
### rate-limit.guard.ts
|
|
|
|
```typescript
|
|
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import Redis from 'ioredis';
|
|
|
|
@Injectable()
|
|
export class RateLimitGuard implements CanActivate {
|
|
private redis: Redis;
|
|
|
|
constructor(private reflector: Reflector) {
|
|
this.redis = new Redis({
|
|
host: process.env.REDIS_HOST,
|
|
port: parseInt(process.env.REDIS_PORT)
|
|
});
|
|
}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const request = context.switchToHttp().getRequest();
|
|
const key = `rate-limit:${request.ip}:${request.url}`;
|
|
|
|
// Límites por endpoint
|
|
const limits = {
|
|
'/auth/login': { max: 5, windowMs: 15 * 60 * 1000 }, // 5 intentos cada 15 min
|
|
'/auth/register': { max: 3, windowMs: 60 * 60 * 1000 }, // 3 registros por hora
|
|
default: { max: 100, windowMs: 60 * 1000 } // 100 req/min por defecto
|
|
};
|
|
|
|
const limit = limits[request.url] || limits.default;
|
|
const current = await this.redis.incr(key);
|
|
|
|
if (current === 1) {
|
|
await this.redis.pexpire(key, limit.windowMs);
|
|
}
|
|
|
|
if (current > limit.max) {
|
|
const ttl = await this.redis.pttl(key);
|
|
throw new HttpException(
|
|
{
|
|
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
|
message: 'Too many requests',
|
|
retryAfter: Math.ceil(ttl / 1000)
|
|
},
|
|
HttpStatus.TOO_MANY_REQUESTS
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 Frontend
|
|
|
|
### useSecureAuth.ts (Hook de autenticación)
|
|
|
|
```typescript
|
|
import { useState, useEffect } from 'react';
|
|
import FingerprintJS from '@fingerprintjs/fingerprintjs';
|
|
|
|
export const useSecureAuth = () => {
|
|
const [fingerprint, setFingerprint] = useState<string>('');
|
|
|
|
useEffect(() => {
|
|
// Generar fingerprint del navegador
|
|
const initFingerprint = async () => {
|
|
const fp = await FingerprintJS.load();
|
|
const result = await fp.get();
|
|
setFingerprint(result.visitorId);
|
|
};
|
|
|
|
initFingerprint();
|
|
}, []);
|
|
|
|
const login = async (email: string, password: string) => {
|
|
const response = await api.post('/auth/login', {
|
|
email,
|
|
password,
|
|
fingerprint,
|
|
userAgent: navigator.userAgent
|
|
});
|
|
|
|
// Almacenar tokens de forma segura
|
|
sessionStorage.setItem('accessToken', response.data.accessToken);
|
|
localStorage.setItem('refreshToken', response.data.refreshToken);
|
|
|
|
return response.data;
|
|
};
|
|
|
|
const logout = async () => {
|
|
await api.post('/auth/logout');
|
|
sessionStorage.removeItem('accessToken');
|
|
localStorage.removeItem('refreshToken');
|
|
};
|
|
|
|
const logoutAllDevices = async () => {
|
|
await api.post('/auth/logout-all');
|
|
sessionStorage.removeItem('accessToken');
|
|
localStorage.removeItem('refreshToken');
|
|
};
|
|
|
|
return { login, logout, logoutAllDevices, fingerprint };
|
|
};
|
|
```
|
|
|
|
### PasswordStrengthMeter.tsx
|
|
|
|
```typescript
|
|
import React from 'react';
|
|
import { Check, X } from 'lucide-react';
|
|
|
|
interface Props {
|
|
password: string;
|
|
}
|
|
|
|
export const PasswordStrengthMeter: React.FC<Props> = ({ password }) => {
|
|
const checks = [
|
|
{ label: 'Al menos 12 caracteres', valid: password.length >= 12 },
|
|
{ label: 'Contiene mayúscula', valid: /[A-Z]/.test(password) },
|
|
{ label: 'Contiene minúscula', valid: /[a-z]/.test(password) },
|
|
{ label: 'Contiene número', valid: /\d/.test(password) },
|
|
{ label: 'Contiene carácter especial', valid: /[!@#$%^&*(),.?":{}|<>]/.test(password) }
|
|
];
|
|
|
|
const strength = checks.filter(c => c.valid).length;
|
|
const strengthLabel = ['Muy débil', 'Débil', 'Regular', 'Buena', 'Fuerte'][strength - 1];
|
|
const strengthColor = ['red', 'orange', 'yellow', 'lime', 'green'][strength - 1];
|
|
|
|
return (
|
|
<div className="mt-2">
|
|
{/* Barra de progreso */}
|
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full transition-all"
|
|
style={{
|
|
width: `${(strength / 5) * 100}%`,
|
|
backgroundColor: strengthColor
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<p className="text-sm mt-1" style={{ color: strengthColor }}>
|
|
{strengthLabel}
|
|
</p>
|
|
|
|
{/* Lista de requisitos */}
|
|
<ul className="mt-2 space-y-1">
|
|
{checks.map((check, i) => (
|
|
<li key={i} className="flex items-center gap-2 text-sm">
|
|
{check.valid ? (
|
|
<Check className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<X className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
<span className={check.valid ? 'text-green-600' : 'text-gray-500'}>
|
|
{check.label}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### SessionManager.tsx (Gestión de sesiones activas)
|
|
|
|
```typescript
|
|
export const SessionManager: React.FC = () => {
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
|
|
useEffect(() => {
|
|
fetchSessions();
|
|
}, []);
|
|
|
|
const fetchSessions = async () => {
|
|
const response = await api.get('/auth/sessions');
|
|
setSessions(response.data);
|
|
};
|
|
|
|
const handleRevoke = async (sessionId: string) => {
|
|
await api.delete(`/auth/sessions/${sessionId}`);
|
|
toast.success('Sesión revocada');
|
|
fetchSessions();
|
|
};
|
|
|
|
const handleRevokeAll = async () => {
|
|
if (!confirm('¿Cerrar sesión en todos los dispositivos excepto este?')) {
|
|
return;
|
|
}
|
|
|
|
await api.post('/auth/logout-all-except-current');
|
|
toast.success('Todas las sesiones cerradas');
|
|
fetchSessions();
|
|
};
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-xl font-bold">Sesiones Activas</h2>
|
|
<button
|
|
onClick={handleRevokeAll}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg"
|
|
>
|
|
Cerrar Todas
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{sessions.map(session => (
|
|
<div key={session.id} className="bg-white border rounded-lg p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{session.isCurrent && (
|
|
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">
|
|
Sesión actual
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-600">
|
|
<p><strong>IP:</strong> {session.ipAddress}</p>
|
|
<p><strong>Dispositivo:</strong> {session.userAgent}</p>
|
|
<p><strong>Última actividad:</strong> {formatDistanceToNow(new Date(session.lastActivity))}</p>
|
|
<p><strong>Expira:</strong> {formatDistanceToNow(new Date(session.expiresAt))}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!session.isCurrent && (
|
|
<button
|
|
onClick={() => handleRevoke(session.id)}
|
|
className="text-red-600 hover:underline"
|
|
>
|
|
Revocar
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 🔒 Configuración de Seguridad
|
|
|
|
### main.ts (App initialization)
|
|
|
|
```typescript
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { ValidationPipe } from '@nestjs/common';
|
|
import helmet from 'helmet';
|
|
import * as cookieParser from 'cookie-parser';
|
|
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create(AppModule);
|
|
|
|
// Helmet para security headers
|
|
app.use(helmet());
|
|
|
|
// CORS
|
|
app.enableCors({
|
|
origin: process.env.FRONTEND_URL,
|
|
credentials: true,
|
|
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
|
allowedHeaders: ['Content-Type', 'Authorization']
|
|
});
|
|
|
|
// Cookie parser
|
|
app.use(cookieParser());
|
|
|
|
// Validation pipe global
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true
|
|
})
|
|
);
|
|
|
|
// HTTPS redirect en producción
|
|
if (process.env.NODE_ENV === 'production') {
|
|
app.use((req, res, next) => {
|
|
if (req.header('x-forwarded-proto') !== 'https') {
|
|
res.redirect(`https://${req.header('host')}${req.url}`);
|
|
} else {
|
|
next();
|
|
}
|
|
});
|
|
}
|
|
|
|
await app.listen(3000);
|
|
}
|
|
bootstrap();
|
|
```
|
|
|
|
### .env.example
|
|
|
|
```bash
|
|
# Master Encryption Key (generar con: openssl rand -base64 32)
|
|
MASTER_ENCRYPTION_KEY=<base64-encoded-key>
|
|
|
|
# JWT Secrets
|
|
JWT_SECRET=<random-secret>
|
|
JWT_REFRESH_SECRET=<random-secret>
|
|
|
|
# Session
|
|
SESSION_SECRET=<random-secret>
|
|
|
|
# Redis (para rate limiting)
|
|
REDIS_HOST=localhost
|
|
REDIS_PORT=6379
|
|
|
|
# AWS KMS (opcional, para gestión de claves)
|
|
AWS_KMS_KEY_ID=<kms-key-id>
|
|
AWS_REGION=us-east-1
|
|
```
|
|
|
|
---
|
|
|
|
## ✅ Checklist de Cumplimiento
|
|
|
|
### LFPDPPP (México)
|
|
|
|
- [x] Consentimiento explícito para datos personales
|
|
- [x] Aviso de privacidad visible
|
|
- [x] Derecho ARCO implementado (Acceso, Rectificación, Cancelación, Oposición)
|
|
- [x] Encriptación de datos sensibles
|
|
- [x] Retención de datos definida
|
|
- [x] Notificación de brechas de seguridad
|
|
|
|
### GDPR (Europa)
|
|
|
|
- [x] Derecho al olvido
|
|
- [x] Portabilidad de datos
|
|
- [x] Consentimiento granular
|
|
- [x] Data Protection Officer designado
|
|
- [x] Privacy by Design
|
|
- [x] Registro de actividades de procesamiento
|
|
|
|
### ISO 27001
|
|
|
|
- [x] Gestión de accesos (RBAC)
|
|
- [x] Encriptación en tránsito y reposo
|
|
- [x] Auditoría completa
|
|
- [x] Gestión de incidentes
|
|
- [x] Backups y DR
|
|
- [x] Políticas de seguridad documentadas
|
|
|
|
---
|
|
|
|
**Generado:** 2025-11-20
|
|
**Estado:** ✅ Completo
|