Patrón de autenticación con sesión
Componer sesión, middleware y locals en el flujo canónico de autenticación. El login que verifica credenciales, regenera el id de sesión contra la fijación y guarda solo el userId; el middleware que en cada petición lee ese id, resuelve el usuario y lo deposita en context.locals.user; las páginas que leen locals.user sin volver a buscarlo; y el cierre de sesión que destruye la sesión. Por qué se guarda la referencia y no la copia, y dónde se protege una ruta.
Las tres lecciones previas fueron piezas sueltas; esta las ensambla en el patrón que casi toda aplicación necesita: la autenticación. El montaje canónico tiene una coreografía precisa. El login verifica las credenciales, regenera el identificador de sesión para blindarse contra un ataque concreto, y guarda en la sesión el mínimo imprescindible: el userId. El middleware corre en cada petición, lee ese id, resuelve el usuario completo y lo deposita en context.locals.user. Las páginas leen locals.user sin volver a averiguar nada. Y el logout destruye la sesión. Cada pieza hace una cosa, y su composición es lo que produce una sesión de usuario robusta.
- Verificar credenciales y guardar solo el
userIden la sesión. - Regenerar el id de sesión al iniciar sesión para prevenir la fijación.
- Poblar
context.locals.userdesde el middleware en cada petición. - Proteger rutas y cerrar sesión con
session.destroy.
El login: verificar, regenerar, guardar la referencia
El punto de entrada natural es una action o un endpoint que recibe las credenciales. Su trabajo tiene tres actos en orden estricto. Primero, verificar: buscar al usuario y comparar la contraseña contra su hash almacenado —nunca contra un texto plano, y con un algoritmo lento como argon2 o bcrypt, materia que excede esta lección pero que se da por hecha—. Segundo, y crucial, regenerar el id de sesión con session.regenerate. Tercero, guardar en la sesión la identidad, y aquí la disciplina es guardar la referencia, no la copia: el userId, no el objeto de usuario entero.
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
export const server = {
entrar: defineAction({
accept: 'form',
input: z.object({ email: z.string().email(), password: z.string() }),
handler: async ({ email, password }, context) => {
const usuario = await buscarUsuario(email);
if (!usuario || !(await verificarHash(password, usuario.hash))) {
throw new Error('Credenciales invalidas');
}
context.session?.regenerate(); // nuevo id: anti-fijacion
context.session?.set('userId', usuario.id); // guarda la referencia, no el objeto
return { ok: true };
},
}),
};
¿Por qué solo el id? Porque el objeto de usuario —su nombre, su rol, su avatar— es un dato derivado que puede cambiar en la base de datos en cualquier momento. Si lo copiaras a la sesión, congelarías una foto que envejece: un usuario degradado de administrador seguiría siéndolo en su sesión hasta que caducara. Guardando solo el id, cada petición resuelve el usuario fresco desde la fuente de verdad. La sesión responde a quién eres; los detalles de qué eres se recalculan.
El middleware puebla locals.user
El middleware es el cuello de botella por el que pasa cada petición, y por eso es el sitio exacto para traducir el userId guardado en un objeto de usuario listo para usar. Lee la sesión, resuelve el usuario y lo deposita en context.locals.user —el maletín efímero por petición que estudiamos en el nivel del middleware—.
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const userId = await context.session?.get('userId');
context.locals.user = userId ? await cargarUsuario(userId) : null;
return next();
});
Para que locals.user esté tipado en las dos puntas, declara su forma una vez en src/env.d.ts con el espacio de nombres App.Locals. A partir de ahí, cualquier página que lea Astro.locals.user recibe autocompletado y la obligación de contemplar el caso nulo.
// src/env.d.ts
declare namespace App {
interface Locals {
user: { id: string; nombre: string; rol: 'admin' | 'user' } | null;
}
}
El reparto de responsabilidades es limpio: la sesión guarda el hecho persistente —este visitante es el usuario tal— y el middleware lo convierte, en cada petición, en un objeto rico que vive en locals solo mientras dura esa petición. La página, aguas abajo, se vuelve declarativa: pinta según Astro.locals.user sin saber que detrás hubo una cookie, un id de sesión y una consulta.
---
const { user } = Astro.locals;
---
{user ? <p>Hola, {user.nombre}</p> : <a href="/entrar">Iniciar sesión</a>}
Proteger rutas y cerrar sesión
Con locals.user resuelto, proteger una zona es leer una variable. El sitio idóneo para la guarda es el propio middleware, que puede cortar la petición antes de que llegue a una página privada y redirigir a quien no tenga sesión.
export const onRequest = defineMiddleware(async (context, next) => {
const userId = await context.session?.get('userId');
context.locals.user = userId ? await cargarUsuario(userId) : null;
const esPrivada = context.url.pathname.startsWith('/panel');
if (esPrivada && !context.locals.user) {
return context.redirect('/entrar');
}
return next();
});
El cierre de sesión es el gesto inverso al login y cabe en una línea: session.destroy borra la cookie y el objeto del almacén, dejando al visitante como anónimo. Conviene exponerlo como un endpoint POST —no un enlace GET— para que no lo dispare un prefetch ni una imagen incrustada.
// src/pages/salir.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = ({ session, redirect }) => {
session?.destroy();
return redirect('/');
};
El session.regenerate del login no es opcional ni decorativo: es la única defensa contra la fijación de sesión. Si al iniciar sesión conservaras el mismo id que tenía el visitante anónimo, un atacante que hubiera logrado plantar ese id de antemano quedaría dentro de la sesión ya autenticada. Regenerar el id en el instante en que la confianza sube —al entrar, y también al escalar permisos— invalida cualquier id previo y cierra esa puerta. Es la próxima lección quien lo diseca; por ahora, memoriza la regla: cada vez que el privilegio cambia, el id cambia.
flowchart TD LOGIN[login verifica credenciales] --> REG[session regenerate nuevo id] REG --> SET[session set userId] SET --> COOK[cookie con id opaco] COOK --> REQ[peticiones siguientes] REQ --> MW[middleware lee userId] MW --> LOAD[carga el usuario fresco] LOAD --> LOC[context locals user] LOC --> PAG[la pagina pinta segun el usuario] style REG fill:#f9e2af,color:#11111b style LOC fill:#89b4fa,color:#11111b style PAG fill:#a6e3a1,color:#11111b
Verificar
Compara la contrasena contra su hash lento. Nunca guardes ni compares texto plano.
Regenerar
session.regenerate en el login invalida el id anterior y frena la fijacion.
Guardar la referencia
En la sesion va el userId, no el objeto. El usuario se resuelve fresco en cada peticion.
Poblar y proteger
El middleware llena locals.user una vez y decide, en un punto, quien pasa.
Lo que hace hermoso a este patrón no es ninguna de sus piezas por separado, sino cómo cada una opera en un horizonte temporal distinto y encaja con las otras como engranajes de tamaños complementarios. La sesión vive en el tiempo largo: persiste entre peticiones, entre pestañas, entre días, y su único cometido es recordar un hecho mínimo e imperecedero —la identidad del visitante— reducido a su expresión más pequeña, un id. El middleware vive en el tiempo puntual de cada petición: corre una vez, en el borde, y su cometido es traducir aquel hecho persistente en un objeto útil para el aquí y ahora, resolviendo desde la fuente de verdad todo lo que es derivado y volátil —el rol actual, el nombre actual, los permisos actuales—. Y locals vive en el tiempo efímero del renderizado: nace con la petición, transporta el usuario ya resuelto hacia las páginas, y muere sin dejar rastro cuando la respuesta se envía. Observa la elegancia de la división: el dato que debe durar se guarda donde las cosas duran y se guarda mínimo, porque cuanto menos copies menos tendrás que sincronizar; el dato que debe estar fresco se recalcula en cada petición, porque una copia vieja de un permiso es un fallo de seguridad esperando a ocurrir; y el dato ya cocinado se transporta en un canal que se descarta, porque no tiene sentido persistir lo que se puede reconstruir. La tentación perezosa —guardar el usuario entero en la sesión y ahorrarse la consulta— rompe justamente el eslabón que sostiene la corrección: confunde el hecho duradero con su representación efímera, y paga el atajo con sesiones que mienten sobre quién eres ahora. Autenticar bien es, en última instancia, un ejercicio de asignar a cada dato el alcance temporal que le corresponde, y dejar que la composición de las tres primitivas haga el resto. Cuando el reparto es correcto, la seguridad no es un añadido nervioso: es una consecuencia de la estructura.
- Escribe una action
entrarque verifique credenciales, llame asession.regeneratey guardeuserIden la sesión. - En el middleware, resuelve el usuario desde
userIdy deposítalo encontext.locals.user, con su forma declarada enApp.Locals. - Protege una ruta bajo
/panelredirigiendo a/entrarcuandolocals.usersea nulo. - Añade un endpoint POST
/salirque llame asession.destroyy comprueba que la zona privada vuelve a estar cerrada.