711 lines
20 KiB
Markdown
711 lines
20 KiB
Markdown
# ET-AUTH-005: Especificación Técnica - Seguridad
|
|
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Estado:** ✅ Implementado
|
|
**Épica:** [OQI-001](../_MAP.md)
|
|
|
|
---
|
|
|
|
## Resumen
|
|
|
|
Esta especificación detalla las medidas de seguridad implementadas en el sistema de autenticación de OrbiQuant IA.
|
|
|
|
---
|
|
|
|
## Capas de Seguridad
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ SECURITY LAYERS │
|
|
├─────────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
│ │ LAYER 1: PERIMETER │ │
|
|
│ │ • Rate Limiting │ │
|
|
│ │ • WAF (Web Application Firewall) │ │
|
|
│ │ • DDoS Protection (Cloudflare) │ │
|
|
│ │ • IP Blacklisting │ │
|
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
│ │ LAYER 2: TRANSPORT │ │
|
|
│ │ • TLS 1.3 only │ │
|
|
│ │ • HSTS (HTTP Strict Transport Security) │ │
|
|
│ │ • Certificate Pinning (mobile) │ │
|
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
│ │ LAYER 3: APPLICATION │ │
|
|
│ │ • JWT RS256 Tokens │ │
|
|
│ │ • CSRF Protection │ │
|
|
│ │ • Input Validation (Zod) │ │
|
|
│ │ • Output Encoding │ │
|
|
│ │ • Security Headers │ │
|
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
│ │ LAYER 4: DATA │ │
|
|
│ │ • Password Hashing (bcrypt) │ │
|
|
│ │ • Token Encryption (AES-256-GCM) │ │
|
|
│ │ • PII Encryption │ │
|
|
│ │ • Database Encryption at Rest │ │
|
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Rate Limiting
|
|
|
|
### Configuración
|
|
|
|
```typescript
|
|
// apps/backend/src/core/middleware/rate-limiter.ts
|
|
import rateLimit from 'express-rate-limit';
|
|
import RedisStore from 'rate-limit-redis';
|
|
|
|
// Rate limiter general
|
|
export const generalRateLimiter = rateLimit({
|
|
windowMs: 60 * 1000, // 1 minuto
|
|
max: 100, // 100 requests por minuto
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
store: new RedisStore({
|
|
prefix: 'rl:general:',
|
|
client: redisClient,
|
|
}),
|
|
message: {
|
|
success: false,
|
|
error: {
|
|
code: 'RATE_LIMIT_EXCEEDED',
|
|
message: 'Demasiadas solicitudes, intenta más tarde',
|
|
},
|
|
},
|
|
});
|
|
|
|
// Rate limiter estricto para auth
|
|
export const authRateLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutos
|
|
max: 5, // 5 intentos
|
|
standardHeaders: true,
|
|
store: new RedisStore({
|
|
prefix: 'rl:auth:',
|
|
client: redisClient,
|
|
}),
|
|
keyGenerator: (req) => {
|
|
// Por IP + email
|
|
return `${req.ip}:${req.body?.email || 'unknown'}`;
|
|
},
|
|
handler: (req, res) => {
|
|
// Log intento sospechoso
|
|
securityLogger.warn('Rate limit exceeded', {
|
|
ip: req.ip,
|
|
email: req.body?.email,
|
|
path: req.path,
|
|
});
|
|
|
|
res.status(429).json({
|
|
success: false,
|
|
error: {
|
|
code: 'TOO_MANY_ATTEMPTS',
|
|
message: 'Demasiados intentos. Espera 15 minutos.',
|
|
retryAfter: 900,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
// Rate limiter para OTP
|
|
export const otpRateLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 3,
|
|
keyGenerator: (req) => req.body?.phone || req.ip,
|
|
store: new RedisStore({
|
|
prefix: 'rl:otp:',
|
|
client: redisClient,
|
|
}),
|
|
});
|
|
```
|
|
|
|
### Límites por Endpoint
|
|
|
|
| Endpoint | Límite | Ventana | Key |
|
|
|----------|--------|---------|-----|
|
|
| `/auth/login` | 5 | 15 min | IP + email |
|
|
| `/auth/register` | 3 | 1 hora | IP |
|
|
| `/auth/phone/send` | 3 | 15 min | phone |
|
|
| `/auth/forgot-password` | 3 | 1 hora | IP + email |
|
|
| `/auth/2fa/verify` | 5 | 15 min | IP + userId |
|
|
| General API | 100 | 1 min | IP |
|
|
|
|
---
|
|
|
|
## Password Hashing
|
|
|
|
### Implementación
|
|
|
|
```typescript
|
|
import bcrypt from 'bcryptjs';
|
|
|
|
const SALT_ROUNDS = 12; // ~300ms en hardware moderno
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return bcrypt.hash(password, SALT_ROUNDS);
|
|
}
|
|
|
|
export async function verifyPassword(
|
|
password: string,
|
|
hash: string
|
|
): Promise<boolean> {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
```
|
|
|
|
### Política de Contraseñas
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
|
|
export const passwordSchema = z
|
|
.string()
|
|
.min(8, 'Mínimo 8 caracteres')
|
|
.max(128, 'Máximo 128 caracteres')
|
|
.regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
|
|
.regex(/[a-z]/, 'Debe contener al menos una minúscula')
|
|
.regex(/[0-9]/, 'Debe contener al menos un número')
|
|
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'Debe contener al menos un carácter especial');
|
|
|
|
// Validación adicional contra lista negra
|
|
const COMMON_PASSWORDS = new Set([
|
|
'password123',
|
|
'12345678',
|
|
'qwerty123',
|
|
// ... más passwords comunes
|
|
]);
|
|
|
|
export function isPasswordAllowed(password: string, email: string): boolean {
|
|
const lowerPassword = password.toLowerCase();
|
|
|
|
// No puede ser igual al email
|
|
if (lowerPassword === email.toLowerCase()) {
|
|
return false;
|
|
}
|
|
|
|
// No puede estar en lista negra
|
|
if (COMMON_PASSWORDS.has(lowerPassword)) {
|
|
return false;
|
|
}
|
|
|
|
// No puede contener partes del email
|
|
const emailParts = email.split('@')[0].toLowerCase();
|
|
if (lowerPassword.includes(emailParts) && emailParts.length > 3) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Token Encryption
|
|
|
|
### Encriptación de OAuth Tokens
|
|
|
|
```typescript
|
|
import crypto from 'crypto';
|
|
|
|
const ENCRYPTION_KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY!, 'hex');
|
|
const ALGORITHM = 'aes-256-gcm';
|
|
const IV_LENGTH = 12;
|
|
const AUTH_TAG_LENGTH = 16;
|
|
|
|
export function encryptToken(token: string): string {
|
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
|
|
let encrypted = cipher.update(token, 'utf8', 'hex');
|
|
encrypted += cipher.final('hex');
|
|
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
// Format: iv:authTag:encrypted
|
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
}
|
|
|
|
export function decryptToken(encryptedToken: string): string {
|
|
const [ivHex, authTagHex, encrypted] = encryptedToken.split(':');
|
|
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const authTag = Buffer.from(authTagHex, 'hex');
|
|
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
decipher.setAuthTag(authTag);
|
|
|
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
|
|
return decrypted;
|
|
}
|
|
```
|
|
|
|
### Encriptación de 2FA Secret
|
|
|
|
```typescript
|
|
export function encrypt2FASecret(secret: string): string {
|
|
// Usar la misma función de encriptación
|
|
return encryptToken(secret);
|
|
}
|
|
|
|
export function decrypt2FASecret(encrypted: string): string {
|
|
return decryptToken(encrypted);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Headers
|
|
|
|
### Configuración Helmet
|
|
|
|
```typescript
|
|
import helmet from 'helmet';
|
|
|
|
export const securityHeaders = helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'unsafe-inline'"], // Para OAuth popups
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:', 'https:'],
|
|
connectSrc: ["'self'", 'https://api.orbiquant.com'],
|
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
|
objectSrc: ["'none'"],
|
|
mediaSrc: ["'self'"],
|
|
frameSrc: ["'self'", 'https://accounts.google.com'], // OAuth frames
|
|
},
|
|
},
|
|
crossOriginEmbedderPolicy: false, // Para OAuth
|
|
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
dnsPrefetchControl: { allow: true },
|
|
frameguard: { action: 'sameorigin' },
|
|
hsts: {
|
|
maxAge: 31536000, // 1 año
|
|
includeSubDomains: true,
|
|
preload: true,
|
|
},
|
|
ieNoOpen: true,
|
|
noSniff: true,
|
|
originAgentCluster: true,
|
|
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
|
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
xssFilter: true,
|
|
});
|
|
```
|
|
|
|
### Headers Personalizados
|
|
|
|
```typescript
|
|
export const customSecurityHeaders = (req, res, next) => {
|
|
// Prevenir clickjacking adicional
|
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
|
|
// Cache control para datos sensibles
|
|
if (req.path.startsWith('/auth/')) {
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
}
|
|
|
|
// Permissions policy
|
|
res.setHeader(
|
|
'Permissions-Policy',
|
|
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
|
|
);
|
|
|
|
next();
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Input Validation
|
|
|
|
### Sanitización
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
import sanitizeHtml from 'sanitize-html';
|
|
|
|
// Sanitizar strings
|
|
const sanitizedString = z.string().transform((val) =>
|
|
sanitizeHtml(val, {
|
|
allowedTags: [], // Sin HTML
|
|
allowedAttributes: {},
|
|
})
|
|
);
|
|
|
|
// Email validation
|
|
const emailSchema = z
|
|
.string()
|
|
.email('Email inválido')
|
|
.max(255)
|
|
.toLowerCase()
|
|
.trim();
|
|
|
|
// Phone validation (E.164)
|
|
const phoneSchema = z
|
|
.string()
|
|
.regex(/^\+[1-9]\d{1,14}$/, 'Formato de teléfono inválido');
|
|
|
|
// Prevenir injection
|
|
const safeIdSchema = z
|
|
.string()
|
|
.uuid('ID inválido');
|
|
```
|
|
|
|
### Validación de Request Body
|
|
|
|
```typescript
|
|
// Middleware de validación
|
|
export const validate = (schema: z.ZodSchema) => {
|
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
req.body = await schema.parseAsync(req.body);
|
|
next();
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: {
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Datos inválidos',
|
|
details: error.errors.map((e) => ({
|
|
field: e.path.join('.'),
|
|
message: e.message,
|
|
})),
|
|
},
|
|
});
|
|
}
|
|
next(error);
|
|
}
|
|
};
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Account Lockout
|
|
|
|
### Implementación
|
|
|
|
```typescript
|
|
const LOCKOUT_THRESHOLD = 5;
|
|
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutos
|
|
|
|
export async function handleFailedLogin(userId: string): Promise<void> {
|
|
const user = await db.query(
|
|
'SELECT failed_login_attempts, locked_until FROM users WHERE id = $1',
|
|
[userId]
|
|
);
|
|
|
|
const attempts = user.rows[0].failed_login_attempts + 1;
|
|
|
|
if (attempts >= LOCKOUT_THRESHOLD) {
|
|
// Bloquear cuenta
|
|
await db.query(
|
|
`UPDATE users
|
|
SET failed_login_attempts = $1,
|
|
locked_until = NOW() + INTERVAL '${LOCKOUT_DURATION_MS / 1000} seconds'
|
|
WHERE id = $2`,
|
|
[attempts, userId]
|
|
);
|
|
|
|
// Notificar al usuario
|
|
await sendSecurityAlert(userId, 'account_locked');
|
|
|
|
// Log de seguridad
|
|
securityLogger.warn('Account locked due to failed attempts', {
|
|
userId,
|
|
attempts,
|
|
});
|
|
} else {
|
|
await db.query(
|
|
'UPDATE users SET failed_login_attempts = $1 WHERE id = $2',
|
|
[attempts, userId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function resetFailedAttempts(userId: string): Promise<void> {
|
|
await db.query(
|
|
'UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1',
|
|
[userId]
|
|
);
|
|
}
|
|
|
|
export async function isAccountLocked(userId: string): Promise<boolean> {
|
|
const result = await db.query(
|
|
'SELECT locked_until FROM users WHERE id = $1',
|
|
[userId]
|
|
);
|
|
|
|
const lockedUntil = result.rows[0]?.locked_until;
|
|
return lockedUntil && new Date(lockedUntil) > new Date();
|
|
}
|
|
```
|
|
|
|
### Progresión de Bloqueo
|
|
|
|
```typescript
|
|
const LOCKOUT_PROGRESSION = [
|
|
{ threshold: 5, duration: 15 * 60 * 1000 }, // 5 intentos: 15 min
|
|
{ threshold: 10, duration: 60 * 60 * 1000 }, // 10 intentos: 1 hora
|
|
{ threshold: 15, duration: 24 * 60 * 60 * 1000 }, // 15 intentos: 24 horas
|
|
];
|
|
|
|
function getLockoutDuration(attempts: number): number {
|
|
for (let i = LOCKOUT_PROGRESSION.length - 1; i >= 0; i--) {
|
|
if (attempts >= LOCKOUT_PROGRESSION[i].threshold) {
|
|
return LOCKOUT_PROGRESSION[i].duration;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Logging
|
|
|
|
### Logger Configuration
|
|
|
|
```typescript
|
|
import winston from 'winston';
|
|
|
|
export const securityLogger = winston.createLogger({
|
|
level: 'info',
|
|
format: winston.format.combine(
|
|
winston.format.timestamp(),
|
|
winston.format.json()
|
|
),
|
|
defaultMeta: { service: 'auth-security' },
|
|
transports: [
|
|
new winston.transports.File({
|
|
filename: 'logs/security.log',
|
|
level: 'warn',
|
|
}),
|
|
new winston.transports.File({
|
|
filename: 'logs/security-audit.log',
|
|
level: 'info',
|
|
}),
|
|
],
|
|
});
|
|
|
|
// Eventos a logear
|
|
export enum SecurityEvent {
|
|
LOGIN_SUCCESS = 'LOGIN_SUCCESS',
|
|
LOGIN_FAILED = 'LOGIN_FAILED',
|
|
LOGOUT = 'LOGOUT',
|
|
PASSWORD_CHANGED = 'PASSWORD_CHANGED',
|
|
TWO_FA_ENABLED = 'TWO_FA_ENABLED',
|
|
TWO_FA_DISABLED = 'TWO_FA_DISABLED',
|
|
ACCOUNT_LOCKED = 'ACCOUNT_LOCKED',
|
|
TOKEN_REUSE_DETECTED = 'TOKEN_REUSE_DETECTED',
|
|
SUSPICIOUS_ACTIVITY = 'SUSPICIOUS_ACTIVITY',
|
|
NEW_DEVICE_LOGIN = 'NEW_DEVICE_LOGIN',
|
|
NEW_LOCATION_LOGIN = 'NEW_LOCATION_LOGIN',
|
|
}
|
|
|
|
export function logSecurityEvent(
|
|
event: SecurityEvent,
|
|
data: {
|
|
userId?: string;
|
|
ip?: string;
|
|
userAgent?: string;
|
|
details?: Record<string, unknown>;
|
|
}
|
|
): void {
|
|
securityLogger.info({
|
|
event,
|
|
...data,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CSRF Protection
|
|
|
|
### Implementación
|
|
|
|
```typescript
|
|
import { doubleCsrf } from 'csrf-csrf';
|
|
|
|
const { generateToken, doubleCsrfProtection } = doubleCsrf({
|
|
getSecret: () => process.env.CSRF_SECRET!,
|
|
cookieName: '__Host-csrf',
|
|
cookieOptions: {
|
|
httpOnly: true,
|
|
sameSite: 'strict',
|
|
secure: true,
|
|
path: '/',
|
|
},
|
|
getTokenFromRequest: (req) => req.headers['x-csrf-token'] as string,
|
|
});
|
|
|
|
// Aplicar a rutas que modifican datos
|
|
app.use('/api/v1/auth', doubleCsrfProtection);
|
|
|
|
// Endpoint para obtener token
|
|
app.get('/api/v1/csrf-token', (req, res) => {
|
|
res.json({ token: generateToken(req, res) });
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Anomaly Detection
|
|
|
|
### Detección de Acceso Sospechoso
|
|
|
|
```typescript
|
|
interface LoginContext {
|
|
userId: string;
|
|
ip: string;
|
|
userAgent: string;
|
|
location: LocationInfo;
|
|
timestamp: Date;
|
|
}
|
|
|
|
export async function detectAnomalies(
|
|
context: LoginContext
|
|
): Promise<string[]> {
|
|
const anomalies: string[] = [];
|
|
|
|
// Obtener historial de logins
|
|
const history = await getLoginHistory(context.userId, 30); // últimos 30 días
|
|
|
|
// 1. Nueva ubicación (país)
|
|
const knownCountries = new Set(history.map((h) => h.location?.countryCode));
|
|
if (!knownCountries.has(context.location.countryCode)) {
|
|
anomalies.push('new_country');
|
|
}
|
|
|
|
// 2. Nuevo dispositivo
|
|
const knownDevices = new Set(history.map((h) => h.deviceFingerprint));
|
|
const currentFingerprint = generateFingerprint(context.userAgent);
|
|
if (!knownDevices.has(currentFingerprint)) {
|
|
anomalies.push('new_device');
|
|
}
|
|
|
|
// 3. IP en blacklist
|
|
if (await isIpBlacklisted(context.ip)) {
|
|
anomalies.push('blacklisted_ip');
|
|
}
|
|
|
|
// 4. Velocidad imposible (viaje)
|
|
const lastLogin = history[0];
|
|
if (lastLogin) {
|
|
const travelSpeed = calculateTravelSpeed(
|
|
lastLogin.location,
|
|
context.location,
|
|
lastLogin.timestamp,
|
|
context.timestamp
|
|
);
|
|
if (travelSpeed > 1000) {
|
|
// > 1000 km/h
|
|
anomalies.push('impossible_travel');
|
|
}
|
|
}
|
|
|
|
// 5. Horario inusual
|
|
const hour = context.timestamp.getHours();
|
|
const usualHours = calculateUsualHours(history);
|
|
if (!usualHours.includes(hour)) {
|
|
anomalies.push('unusual_time');
|
|
}
|
|
|
|
return anomalies;
|
|
}
|
|
|
|
export async function handleAnomalies(
|
|
userId: string,
|
|
anomalies: string[],
|
|
context: LoginContext
|
|
): Promise<void> {
|
|
if (anomalies.includes('blacklisted_ip')) {
|
|
// Bloquear inmediatamente
|
|
throw new ForbiddenError('Access denied');
|
|
}
|
|
|
|
if (anomalies.includes('impossible_travel')) {
|
|
// Posible cuenta comprometida
|
|
await revokeAllSessions(userId);
|
|
await sendSecurityAlert(userId, 'impossible_travel_detected');
|
|
throw new UnauthorizedError('Security check required');
|
|
}
|
|
|
|
if (anomalies.includes('new_country') || anomalies.includes('new_device')) {
|
|
// Notificar pero permitir
|
|
await sendNewLoginNotification(userId, context);
|
|
}
|
|
|
|
// Log todas las anomalías
|
|
logSecurityEvent(SecurityEvent.SUSPICIOUS_ACTIVITY, {
|
|
userId,
|
|
ip: context.ip,
|
|
details: { anomalies },
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Secure Token Generation
|
|
|
|
```typescript
|
|
import crypto from 'crypto';
|
|
|
|
// Token de verificación de email (64 chars hex)
|
|
export function generateVerificationToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
// OTP numérico (6 dígitos)
|
|
export function generateOTP(): string {
|
|
return crypto.randomInt(100000, 999999).toString();
|
|
}
|
|
|
|
// Backup codes (8 chars alphanumeric)
|
|
export function generateBackupCodes(count: number = 10): string[] {
|
|
return Array.from({ length: count }, () =>
|
|
crypto.randomBytes(4).toString('hex').toUpperCase()
|
|
);
|
|
}
|
|
|
|
// State token para OAuth
|
|
export function generateStateToken(): string {
|
|
return crypto.randomBytes(16).toString('base64url');
|
|
}
|
|
|
|
// Session ID
|
|
export function generateSessionId(): string {
|
|
return crypto.randomUUID();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
|
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
|
|
- [NIST Digital Identity Guidelines](https://pages.nist.gov/800-63-3/)
|