Aplicación web para compartir protocolos: autenticación de sesión, OAuth y defensa contra IDOR
Al finalizar este tema
Podrás crear una aplicación web para compartir protocolos de experimentos de forma segura, combinando la autenticación de sesión, OAuth 2.0 e IDOR que aprendiste en clase. Comprenderás por qué herramientas prácticas como protocols.io tienen sistemas de permisos sofisticados y cómo puedes crear uno en Python/Node.
Este artículo es un ejemplo didáctico. Para implementaciones reales, utiliza servicios gestionados como Auth0, Clerk o Supabase Auth.
"¿Espera, si solo cambio la URL, puedo ver todos los protocolos de otros laboratorios?" — La trampa de IDOR
Supongamos que has creado un sitio web para compartir protocolos de experimentos.
- Inicio de sesión de usuario: OK
- Creación de protocolos: OK
- Visualización de protocolos específicos:
/api/protocols/42
Un amigo cambia ligeramente la URL: /api/protocols/43. Se abre el protocolo confidencial de otro laboratorio. Si solo cambias el número de la URL, puedes acceder a los datos de otros: esta es la vulnerabilidad IDOR (Referencia Directa a Objetos Inseguros).
La causa de este problema es interesante. Tu código comprueba si el usuario ha iniciado sesión (autenticación), pero no comprueba si el usuario tiene permiso para acceder a este recurso (autorización). La autenticación y la autorización son cosas diferentes.
Esta es la razón por la que A01: Control de acceso defectuoso está constantemente en el top 10 de OWASP. Los desarrolladores cometen este error repetidamente. Todos los servicios prácticos como protocols.io, GitHub y Notion se defienden contra esta vulnerabilidad con sistemas de permisos sofisticados.
En términos de informática, este problema se puede expresar como una interfaz sin una puerta de autorización. Cuando un punto final de la API recibe una solicitud, la autenticación es comprobar quién está haciendo la solicitud, y la autorización es comprobar si tiene permiso para acceder a este recurso específico. Si solo hay autenticación, el sistema se convierte en un estado en el que cualquiera que haya iniciado sesión puede acceder a cualquier recurso.
De la caja negra a los componentes
Componente 1: Autenticación basada en sesión
El patrón de autenticación más básico. Cuando un usuario inicia sesión correctamente, el servidor crea una sesión y transmite el ID de sesión al navegador a través de una cookie.
import express from "express";
import session from "express-session";
import bcrypt from "bcrypt";
import pg from "pg";
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000
}
}));
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
app.post("/api/login", async (req, res) => {
const { email, password } = req.body;
const result = await pool.query("SELECT id, password_hash FROM users WHERE email = $1", [email]);
if (result.rows.length === 0) {
return res.status(401).json({ error: "Invalid credentials" });
}
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: "Invalid credentials" });
}
req.session.userId = user.id;
res.json({ ok: true });
});
app.post("/api/logout", (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});Elementos de seguridad esenciales:
httpOnly: true— Imposibilidad de acceder a las cookies desde JavaScript (para evitar el robo de sesiones mediante XSS).secure: true— Transmisión solo a través de HTTPS (en producción).sameSite: "lax"— Protección parcial contra CSRF.bcrypt— Hash de las contraseñas (no almacenar en texto plano).- Almacenar el secreto de la sesión en una variable de entorno.
Componente 2: Middleware de autenticación
Middleware que se utiliza para los puntos finales a los que solo pueden acceder los usuarios autenticados.
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: "Login required" });
}
next();
}
app.get("/api/me", requireAuth, async (req, res) => {
const result = await pool.query("SELECT id, email, lab_id FROM users WHERE id = $1", [req.session.userId]);
res.json(result.rows[0]);
});Parte 3: Validación del propietario del recurso (defensa contra IDOR)
Punto clave: Al consultar un recurso, incluya siempre la condición de propietario en la cláusula WHERE.
app.get("/api/protocols/:id", requireAuth, async (req, res) => {
const { id } = req.params;
const result = await pool.query(
`SELECT p.*
FROM protocols p
WHERE p.id = $1
AND (
p.owner_user_id = $2
OR p.visibility = 'public'
OR EXISTS (
SELECT 1 FROM protocol_shares ps
WHERE ps.protocol_id = p.id AND ps.user_id = $2
)
)`,
[id, req.session.userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "Not found" });
}
res.json(result.rows[0]);
});Principios de seguridad:
WHERE id = $1, así como también la condición de propietario.- Si se intenta acceder a un protocolo que no es propiedad del usuario y que tampoco se ha compartido, se mostrará un error 404 (no un 403). Esto evita revelar que el protocolo "existe pero no se puede acceder".
- Los mismos principios se aplican a los puntos finales de eliminación y modificación.
Componente 4: Inicio de sesión social con OAuth 2.0
En lugar de utilizar el inicio de sesión por correo electrónico, se utilizan proveedores de autenticación externos como Google o GitHub.
import { OAuth2Client } from "google-auth-library";
const oauth = new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_REDIRECT_URI
});
app.get("/api/auth/google", (req, res) => {
const url = oauth.generateAuthUrl({
access_type: "offline",
scope: ["email", "profile"],
state: generateStateToken(req)
});
res.redirect(url);
});
app.get("/api/auth/google/callback", async (req, res) => {
const { code, state } = req.query;
if (!verifyStateToken(req, state)) {
return res.status(400).json({ error: "Invalid state" });
}
const { tokens } = await oauth.getToken(code);
const ticket = await oauth.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload();
const email = payload.email;
let user = await pool.query("SELECT id FROM users WHERE email = $1", [email]);
if (user.rows.length === 0) {
user = await pool.query(
"INSERT INTO users (email, oauth_provider, oauth_id) VALUES ($1, 'google', $2) RETURNING id",
[email, payload.sub]
);
}
req.session.userId = user.rows[0].id;
res.redirect("/");
});Elementos de seguridad esenciales de OAuth:
- Parámetro
state: defensa contra ataques CSRF. Se guarda un token aleatorio en la sesión al iniciar la solicitud y se verifica en la devolución de llamada. - Verificación de
id_token: verificación de la firma con la clave pública del proveedor. - Verificación de
audience: se comprueba si el token es para esta aplicación. - PKCE: obligatorio para aplicaciones nativas.
Ensamblaje de la canalización
Flujo completo de la aplicación web:
1. El usuario visita /login
2. Inicia sesión con correo electrónico y contraseña o pulsa "Sign in with Google"
3. Si tiene éxito, se emite una cookie de sesión y se redirige a /dashboard
4. /dashboard muestra la lista de sus protocolos
- GET /api/protocols?mine=true (WHERE owner = session.userId)
5. Pulsa un protocolo concreto
- GET /api/protocols/42 (incluye verificación de propiedad)
6. Añade un usuario con quien compartirlo
- POST /api/protocols/42/share (después de verificar que le pertenece)En cada punto final, implemente siempre la autenticación (¿quién es?) y la autorización (¿qué permisos tiene?).
Puntos pendientes: tres espacios en blanco que deben rellenar
Espacio en blanco 1: registro de auditoría
Registre todas las acciones sensibles. Realice un seguimiento de quién, cuándo y qué hizo.
app.use(async (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = function(body) {
// TODO 1: recopilar la información de la solicitud (userId, method, path, statusCode)
// TODO 2: insertarla en la tabla audit_logs (fire-and-forget asíncrono)
// TODO 3: llamar a originalJson
return originalJson(body);
};
next();
});Pista: pool.query("INSERT INTO audit_logs (user_id, method, path, status) VALUES ($1, $2, $3, $4)", [req.session.userId, req.method, req.path, res.statusCode]).catch(console.error);.
Espacio en blanco 2: Control de acceso basado en roles (RBAC)
Cada usuario tiene un rol (administrador, editor, visor) y cada rol tiene diferentes permisos.
function requireRole(role) {
return async (req, res, next) => {
// TODO 1: consultar la tabla users mediante session.userId
// TODO 2: verificar que el role del usuario sea igual o superior al requerido
// TODO 3: si no, devolver 403
next();
};
}
app.delete("/api/protocols/:id", requireAuth, requireRole("admin"), async (req, res) => {
// Solo los administradores pueden eliminar
});Espacio en blanco 3: Defensa contra el secuestro de sesión
Incluso si se roban las cookies de sesión, se invalidan cuando cambian la dirección IP o el agente de usuario.
function detectSessionAnomaly(req, res, next) {
if (!req.session.userId) return next();
const currentIp = req.ip;
const currentUA = req.headers["user-agent"];
// TODO 1: comparar con originalIp y originalUA guardados en la sesión
// TODO 2: si difieren mucho, destruir la sesión y exigir un nuevo inicio de sesión
// TODO 3: si son normales, pasar al siguiente middleware
next();
}Pista: En el primer inicio de sesión, use req.session.originalIp = req.ip; req.session.originalUA = req.headers["user-agent"];. En las solicitudes posteriores, use if (req.session.originalIp !== req.ip) { req.session.destroy(); return res.status(401).json({error: "Session anomaly"}); }.
Reflexiones: diferencias con los sistemas de autenticación del mundo real
Servicios administrados: La mayoría de las aplicaciones del mundo real utilizan servicios administrados como Auth0, Clerk, Supabase Auth y Firebase Auth. Lo que ha creado es una comprensión de lo que estos servicios hacen internamente.
PASETO / JWT: Tokens autocontenidos y verificables en lugar de sesiones. No se requiere un almacén de sesiones, lo que proporciona una mejor escalabilidad. Sin embargo, la invalidación (cierre de sesión) es más difícil de administrar.
MFA (autenticación multifactor): TOTP (Google Authenticator), WebAuthn (claves de hardware). Última línea de defensa en caso de robo de cuenta.
Arquitectura de confianza cero: Se vuelve a verificar la autenticación y la autorización en cada solicitud. No se utiliza la ubicación de la red (red interna) como base para la confianza.
Alternativas a las contraseñas: Passkey (WebAuthn) es el estándar más reciente. Inicio de sesión sin contraseña. Compatible con Apple, Google y Microsoft.
OWASP ASVS: Estándar de verificación de seguridad de aplicaciones. Lista de verificación por niveles de los requisitos de seguridad que su aplicación debe cumplir.
Proyectos de expansión
1. Soporte para Passkey: Agregue autenticación de hardware con una biblioteca WebAuthn.
2. Espacios de trabajo de equipo: Varios usuarios pertenecen al mismo espacio de trabajo y comparten protocolos. Enlace de invitación.
3. Sistema de tokens de API: Admite el acceso programático mediante claves de API en lugar de sesiones. Permisos basados en el ámbito.
4. Limitación de velocidad: Defensa contra ataques de fuerza bruta. Limite los intentos de inicio de sesión a 5 por minuto por dirección IP.
Mapa de componentes de este ejemplo
- [F] Autenticación de sesión: cookies, almacén de sesiones, httpOnly, secure, sameSite.
- [F] OAuth 2.0: flujo de código de autorización, parámetro de estado, verificación de id_token.
- [F] Defensa contra IDOR: Se requiere una condición de propiedad al consultar recursos. Elija entre 404 y 403.
- [W] Express·pg: enrutamiento y acceso a la base de datos (se proporciona un script completo).
[F] = usted lo implementa / [W] = se proporciona como código completo.