Saltar al contenido

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.

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.

TypeScript
// src/actions/index.tsimport { 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—.

TypeScript
// src/middleware.tsimport { 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.

TypeScript
// src/env.d.tsdeclare 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.

ASTRO
---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.

TypeScript
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.

TypeScript
// src/pages/salir.tsimport type { APIRoute } from 'astro';export const POST: APIRoute = ({ session, redirect }) => {  session?.destroy();  return redirect('/');};