Gestionando Sesiones y Autenticación en GraphQL con JWT y Cookies Seguras
Este tutorial te guiará a través de la implementación de un sistema de autenticación y gestión de sesiones en aplicaciones GraphQL. Exploraremos el uso de JSON Web Tokens (JWT) para la creación de tokens y cómo almacenarlos de forma segura en cookies HttpOnly, protegiendo así tus endpoints.
¡Hola, desarrollador! 👋 ¿Estás listo para llevar la seguridad de tus APIs GraphQL al siguiente nivel? En este tutorial, profundizaremos en cómo gestionar la autenticación y las sesiones de usuario utilizando JWT y cookies seguras. Este es un aspecto crucial para cualquier aplicación web moderna, y GraphQL no es una excepción.
🚀 Introducción a la Autenticación en GraphQL
La autenticación es el proceso de verificar la identidad de un usuario. En el contexto de las APIs, significa asegurarse de que quien realiza una solicitud es realmente quien dice ser. GraphQL, al ser una capa de consulta sobre tus datos, no impone un método de autenticación específico, lo que te da flexibilidad pero también la responsabilidad de elegir e implementar la estrategia adecuada.
Tradicionalmente, en APIs REST, se utilizan a menudo las sesiones basadas en cookies para mantener el estado del usuario. Con la proliferación de aplicaciones de una sola página (SPA) y móviles, los tokens como JWT (JSON Web Tokens) se han vuelto muy populares debido a su naturaleza stateless (sin estado), lo que los hace ideales para arquitecturas distribuidas.
En este tutorial, combinaremos lo mejor de ambos mundos: usaremos JWT para la seguridad de la información del usuario y cookies HttpOnly para el almacenamiento seguro del token, mitigando ataques como XSS (Cross-Site Scripting).
¿Por qué JWT y Cookies Seguras?
- JWT (JSON Web Tokens): Son una forma compacta y segura de transmitir información entre partes como un objeto JSON. Son firmados digitalmente, lo que garantiza su autenticidad e integridad. Contienen información útil (claims) sobre el usuario que puede ser verificada por el servidor sin necesidad de consultar una base de datos en cada solicitud.
- Cookies HttpOnly: Son un tipo especial de cookie que no es accesible a través del API
document.cookiede JavaScript. Esto significa que un atacante que logre inyectar código JavaScript malicioso en tu sitio (ataque XSS) no podrá robar el token de autenticación. Son la forma más segura de almacenar tokens de sesión en el navegador para evitar XSS. - Cookies
Secure: Solo se envían sobre conexiones HTTPS. Es esencial para la seguridad en producción.
🛠️ Configurando el Entorno de Desarrollo
Para este tutorial, asumiremos que tienes un servidor GraphQL básico configurado. Usaremos Node.js con Express y Apollo Server como ejemplo, pero los principios son aplicables a cualquier lenguaje o framework.
Requisitos previos:
- Node.js instalado
- Conocimientos básicos de JavaScript y GraphQL
Vamos a necesitar algunas dependencias:
npm init -y
npm install express apollo-server-express graphql jsonwebtoken cookie-parser bcrypt
express: Framework web para Node.js.apollo-server-express: Integración de Apollo Server con Express.graphql: La biblioteca principal de GraphQL.jsonwebtoken: Para crear y verificar JWTs.cookie-parser: Middleware para analizar encabezados de cookies.bcrypt: Para hashear contraseñas de forma segura.
Estructura del Proyecto
Crearemos una estructura simple para nuestro proyecto:
my-graphql-auth/
├── src/
│ ├── index.js # Archivo principal del servidor
│ ├── schema.js # Definiciones de tipos y resolvers GraphQL
│ └── auth.js # Funciones de autenticación y JWT
└── package.json
🔐 Generando y Verificando JWTs
Primero, configuremos las funciones para generar y verificar nuestros JWTs. Crearemos un archivo src/auth.js.
// src/auth.js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'supersecretkey'; // ¡Usa una clave segura en producción!
const TOKEN_EXPIRATION = '1h'; // El token expira en 1 hora
/**
* Genera un nuevo JWT para un usuario.
* @param {object} user - Objeto de usuario con ID y rol (o cualquier dato que quieras en el token).
* @returns {string} El token JWT generado.
*/
function generateToken(user) {
return jwt.sign(
{ userId: user.id, userRole: user.role },
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRATION }
);
}
/**
* Verifica un JWT.
* @param {string} token - El token JWT a verificar.
* @returns {object|null} El payload decodificado del token si es válido, o null si es inválido/expirado.
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null; // Token inválido o expirado
}
}
module.exports = { generateToken, verifyToken, JWT_SECRET, TOKEN_EXPIRATION };
🍪 Implementando Cookies Seguras (HttpOnly, Secure)
Ahora, veamos cómo integrar estas cookies en nuestro servidor Express con Apollo Server. Usaremos el middleware cookie-parser.
src/index.js (Configuración del servidor)
// src/index.js
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { generateToken, verifyToken } = require('./auth');
const { typeDefs, resolvers } = require('./schema'); // Aún no lo hemos creado, pero lo haremos
const app = express();
const PORT = process.env.PORT || 4000;
// Middleware para parsear cookies
app.use(cookieParser());
// --- Base de datos de usuarios de ejemplo (en un entorno real, sería una DB) ---
const users = [
{ id: '1', username: 'alice', passwordHash: '', role: 'ADMIN' },
{ id: '2', username: 'bob', passwordHash: '', role: 'USER' },
];
// Hash de contraseñas de ejemplo (en producción, esto se haría al registrar un usuario)
(async () => {
for (const user of users) {
user.passwordHash = await bcrypt.hash('password123', 10);
}
console.log('Contraseñas de ejemplo hasheadas.');
})();
// ---------------------------------------------------------------------------
// Contexto de Apollo Server: Se ejecuta en cada solicitud GraphQL
const getContext = async ({ req, res }) => {
const token = req.cookies.authToken; // Obtener el token de la cookie
let user = null;
if (token) {
const decodedToken = verifyToken(token);
if (decodedToken) {
// Aquí buscarías el usuario en tu base de datos si necesitas más info
// Por simplicidad, usamos los datos del token
user = { id: decodedToken.userId, role: decodedToken.userRole };
} else {
// Si el token es inválido/expirado, limpia la cookie
res.clearCookie('authToken');
}
}
return { req, res, user }; // El objeto 'user' estará disponible en los resolvers
};
const server = new ApolloServer({
typeDefs,
resolvers,
context: getContext,
});
async function startServer() {
await server.start();
server.applyMiddleware({ app, path: '/graphql', cors: { origin: 'http://localhost:3000', credentials: true } });
app.listen(PORT, () => {
console.log(`🚀 Servidor GraphQL listo en http://localhost:${PORT}${server.graphqlPath}`);
console.log(`Explora en http://localhost:${PORT}/graphql`);
});
}
startServer();
Explicación del Contexto:
- El
getContextes crucial. Se ejecuta antes de cada resolver de GraphQL. - Extraemos el
authTokende las cookies de la solicitud (req.cookies.authToken). - Verificamos el token con
verifyToken. - Si es válido, creamos un objeto
usery lo adjuntamos al contexto. Este objetouserserá accesible en todos nuestros resolvers, permitiéndonos saber qué usuario está haciendo la solicitud. - Si el token es inválido o expirado, limpiamos la cookie para forzar al usuario a iniciar sesión nuevamente.
- CORS: Es vital configurar
corscorrectamente concredentials: truepara que el navegador envíe y reciba cookies a través de dominios cruzados (por ejemplo, frontend enlocalhost:3000y backend enlocalhost:4000). También asegúrate de que eloriginsea correcto.
📝 Definición del Schema GraphQL
Ahora, definamos nuestro esquema GraphQL con mutaciones para login y logout, y una consulta me para obtener el usuario actual. Crearemos src/schema.js.
// src/schema.js
const { gql } = require('apollo-server-express');
const bcrypt = require('bcrypt');
const { generateToken, JWT_SECRET, TOKEN_EXPIRATION } = require('./auth');
// --- Base de datos de usuarios de ejemplo (se comparte con index.js) ---
const users = [
{ id: '1', username: 'alice', passwordHash: '', role: 'ADMIN' },
{ id: '2', username: 'bob', passwordHash: '', role: 'USER' },
];
// Hash de contraseñas de ejemplo para que funcionen los resolvers
(async () => {
for (const user of users) {
user.passwordHash = await bcrypt.hash('password123', 10);
}
})();
// ---------------------------------------------------------------------------
const typeDefs = gql`
type User {
id: ID!
username: String!
role: String!
}
type Query {
me: User # Para obtener el usuario actualmente autenticado
hello: String
}
type Mutation {
login(username: String!, password: String!): User # Para iniciar sesión
logout: Boolean # Para cerrar sesión
}
`;
const resolvers = {
Query: {
hello: () => '¡Hola desde GraphQL!',
me: (parent, args, context) => {
// Si el usuario está en el contexto, está autenticado
if (!context.user) {
throw new Error('No autenticado. Por favor, inicia sesión.');
}
// En un caso real, aquí buscarías el usuario completo por context.user.id
return users.find(u => u.id === context.user.id);
},
},
Mutation: {
login: async (parent, { username, password }, context) => {
const user = users.find(u => u.username === username);
if (!user) {
throw new Error('Usuario no encontrado.');
}
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
throw new Error('Contraseña incorrecta.');
}
// Si las credenciales son válidas, generamos un JWT
const token = generateToken(user);
// Y lo establecemos en una cookie HttpOnly y Secure
context.res.cookie('authToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Solo Secure en producción (HTTPS)
maxAge: 1000 * 60 * 60, // 1 hora de duración para la cookie
sameSite: 'Lax', // O 'None' si tu frontend está en un dominio diferente y necesitas CSRF protection
});
return user; // Devolver el usuario autenticado
},
logout: (parent, args, context) => {
// Simplemente limpiamos la cookie
context.res.clearCookie('authToken');
return true;
},
},
};
module.exports = { typeDefs, resolvers };
🔄 Flujo de Autenticación Completo
Veamos cómo funciona el flujo de autenticación paso a paso:
1. Inicio de Sesión (Mutation login)
- El cliente (frontend) envía una mutación
logincon credenciales de usuario. - El resolver
loginverifica las credenciales. - Si son válidas, se genera un JWT.
- Este JWT se establece como una cookie
authTokenen la respuesta HTTP (context.res.cookie). La cookie se marca comoHttpOnly(no accesible por JS) ySecure(solo HTTPS). - El cliente recibe la respuesta HTTP y el navegador almacena automáticamente la cookie.
2. Solicitudes Posteriores (Query me, otras consultas/mutaciones)
- En cualquier solicitud posterior a la API GraphQL, el navegador automáticamente adjunta la cookie
authToken(ya que es para el mismo dominio). - En el servidor, el middleware
cookieParserparsea la cookie. - La función
getContextextrae elauthTokende la cookie, lo verifica y, si es válido, decodifica eluserIdyuserRole, añadiéndolos al objetocontext.user. - Cualquier resolver puede acceder a
context.userpara determinar si el usuario está autenticado y cuál es su identidad y rol.
3. Cierre de Sesión (Mutation logout)
- El cliente envía una mutación
logout. - El resolver
logoutsimplemente indica al servidor que limpie la cookieauthToken(context.res.clearCookie). - El navegador elimina la cookie, y el usuario ya no está autenticado.
👮♀️ Autorización Basada en Roles
Una vez que tenemos la autenticación, el siguiente paso es la autorización, es decir, determinar si un usuario autenticado tiene permiso para realizar una acción o acceder a ciertos datos.
Podemos crear una simple función de ayuda para la autorización:
// src/schema.js (agregar a un archivo de utilidades si crece mucho)
function isAuthenticated(context) {
if (!context.user) {
throw new Error('No autenticado. Debes iniciar sesión.');
}
}
function hasRole(context, requiredRole) {
isAuthenticated(context); // Primero verifica si está autenticado
if (context.user.role !== requiredRole) {
throw new Error(`Acceso denegado. Se requiere el rol ${requiredRole}.`);
}
}
Ahora, podemos usar estas funciones en nuestros resolvers:
// src/schema.js (ejemplo en un resolver existente o nuevo)
// ... dentro de resolvers.Query o resolvers.Mutation ...
Query: {
// ...
adminDashboardData: (parent, args, context) => {
hasRole(context, 'ADMIN'); // Solo los administradores pueden acceder
// Lógica para devolver datos de administrador
return "Datos confidenciales del panel de administrador.";
},
},
Mutation: {
// ...
deleteUser: (parent, { id }, context) => {
hasRole(context, 'ADMIN');
// Lógica para eliminar usuario
return `Usuario ${id} eliminado por un administrador.`;
},
},
Tabla de Roles de Ejemplo
| Rol | Permisos |
|---|---|
| USER | Leer datos públicos, actualizar su propio perfil. |
| ADMIN | Todos los permisos de USER, gestionar otros usuarios, acceder a datos sensibles. |
📝 Consideraciones Adicionales y Mejores Prácticas
Renovación de Tokens
Los tokens tienen una duración limitada (TOKEN_EXPIRATION). Para mejorar la experiencia del usuario y la seguridad:
- Tokens de Refresco (Refresh Tokens): Para evitar que el usuario tenga que iniciar sesión cada hora (o la duración que establezcas), puedes implementar un sistema de tokens de refresco. Cuando el token de acceso expira, el cliente puede usar un token de refresco (generalmente de mayor duración y almacenado también en una cookie HttpOnly) para obtener un nuevo token de acceso sin requerir las credenciales de usuario nuevamente.
- El token de refresco debe ser de un solo uso y revocable, almacenado en una base de datos.
- Cuando el cliente use el token de refresco, emite un nuevo token de acceso Y un nuevo token de refresco.
¿Por qué Tokens de Refresco?
Un token de acceso de corta duración reduce la ventana de oportunidad para un atacante si el token es comprometido. Un token de refresco, más largo y almacenado de forma segura, permite una experiencia de usuario fluida sin comprometer la seguridad a largo plazo.Protección CSRF (Cross-Site Request Forgery)
Aunque sameSite='Lax' ayuda a mitigar CSRF, si usas sameSite='None' (necesario para dominios cruzados sin subdominios) o si el atacante puede inducir una solicitud GET a tu API, podrías ser vulnerable. Para protección completa, puedes:
- Tokens CSRF (
anti-CSRF tokens): Genera un token aleatorio en el servidor, envíalo al cliente (ej. en una cookie no HttpOnly o en el HTML de la página), y el cliente debe enviarlo de vuelta en un encabezado personalizado (ej.X-CSRF-Token) o en el cuerpo de la solicitud para mutaciones. El servidor verifica la coincidencia de ambos tokens.
Manejo de Errores
Siempre maneja los errores de autenticación y autorización de forma consistente. Usa los tipos de error de GraphQL o códigos de estado HTTP apropiados.
Auditoría y Logs
Registra los intentos de inicio de sesión fallidos y cualquier actividad sospechosa. Esto es crucial para la detección de intrusiones.
Seguridad HTTPS
Siempre, siempre despliega tu API GraphQL sobre HTTPS. Sin HTTPS, tus cookies y tokens viajan en texto plano y pueden ser interceptados fácilmente.
conclusión ✨
Has llegado al final de este completo tutorial sobre la gestión de sesiones y autenticación en GraphQL. Hemos cubierto los fundamentos de JWT y cookies HttpOnly, implementado la lógica de inicio y cierre de sesión, y añadido una capa de autorización básica. Al seguir estas prácticas, estarás construyendo APIs GraphQL más seguras y robustas.
Recuerda que la seguridad es un proceso continuo. Siempre mantente informado sobre las últimas amenazas y mejores prácticas. ¡Feliz codificación! 🚀
Tutoriales relacionados
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!