Crear Páginas Web con Lógica de NegocioBeta

0%
Bloque Técnico-Operativo

Módulo 02

Autenticación: usuarios, sesiones y permisos

2hSupabase AuthNext.jsTypeScript

Registro, login, recuperación de contraseña y roles con Supabase Auth: la diferencia entre saber quién es tu usuario y decidir qué puede hacer.

Recursos descargables

Checklist de Seguridad de Autenticación

Lista de verificación en 6 bloques (credenciales, sesiones, rutas, roles, formularios y prueba de fuego) para auditar el login de cualquier app

Markdown
En este módulo
01
Cómo funciona la autenticación web Sesiones, cookies y JWT explicados sin misterio

Julián necesita dos cosas de ReservaYa que parecen una sola: que cada cliente tenga su cuenta con su historial de citas, y que solo él pueda entrar al panel donde se ven las ventas del mes. Un cliente curioso que escriba /admin en la URL no puede ver la caja de la barbería.

Eso son dos problemas distintos: autenticación (¿quién eres?) y autorización (¿qué puedes hacer?). Este módulo resuelve ambos.

1.1. Autenticación vs. autorización

ConceptoPregunta que respondeEjemplo en ReservaYa
Autenticación¿Quién eres?Iniciar sesión con email y contraseña
Autorización¿Qué puedes hacer?Solo el rol admin ve el panel de ventas

1.2. El problema: HTTP no tiene memoria

Cada petición al servidor es independiente: el servidor no recuerda que hace 5 segundos iniciaste sesión. Para "recordarte", después del login el servidor te entrega una credencial que el navegador reenvía en cada petición. Hay dos formas clásicas de hacerlo:

Clásico
Sesiones en servidor
Cómo: el servidor guarda "sesión #123 = Julián" y te da una cookie con el número
Ventaja: se puede invalidar al instante
Costo: el servidor debe guardar estado de cada usuario
Moderno
JWT (JSON Web Token)
Cómo: el servidor firma un token que dice "soy Julián, expiro en 1 hora"; no guarda nada
Ventaja: escala sin estado; cualquier servidor puede verificar la firma
Costo: no se puede "borrar" antes de que expire — por eso duran poco y se renuevan
⚙ Qué hay dentro de un JWT

Un JWT son tres bloques separados por puntos: header.payload.firma. El payload es legible por cualquiera (dice tu id, tu email y cuándo expira) — lo que lo hace confiable es la firma: solo el servidor con la clave secreta pudo generarla. Si alguien modifica el payload, la firma deja de coincidir y el token se rechaza.

Supabase Auth usa exactamente este esquema: un access_token JWT de corta vida más un refresh_token para renovarlo, ambos guardados en cookies.

Nunca guardes contraseñas en texto plano

Las contraseñas se almacenan hasheadas (bcrypt/argon2): una transformación irreversible. Ni siquiera tú, dueño del sistema, puedes leer la contraseña de un cliente. Supabase hace esto por ti — otra razón para no inventar tu propio sistema de login.

02
Registro y login con Supabase Auth El flujo completo de cuentas en ReservaYa, con código listo para usar

Supabase Auth ya trae el trabajo pesado: hashing de contraseñas, emails de verificación, tokens y renovación de sesión. Nuestro trabajo es conectarlo bien con Next.js.

2.1. El cliente de servidor y el middleware

En el módulo 1 creamos el cliente de navegador. Ahora el de servidor, que lee la sesión desde las cookies, en lib/supabase/server.ts:

// lib/supabase/server.ts
// Cliente de Supabase para Server Components y Server Actions
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          // Server Components no pueden escribir cookies; el middleware lo hará
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {}
        },
      },
    }
  );
}

El middleware renueva la sesión en cada petición para que el JWT nunca llegue vencido. Crea middleware.ts en la raíz:

// middleware.ts — corre antes de cada petición
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // Renueva el token si está por vencer y obtiene el usuario actual
  const { data: { user } } = await supabase.auth.getUser();

  // Regla de negocio: /admin y /mis-citas requieren sesión
  const rutaProtegida = ["/admin", "/mis-citas"].some((r) =>
    request.nextUrl.pathname.startsWith(r)
  );
  if (rutaProtegida && !user) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return response;
}

export const config = {
  // Evita correr el middleware en archivos estáticos
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg)$).*)"],
};

2.2. Registro de clientes

El formulario de registro llama a una Server Action — la validación vive en el servidor, como manda la regla de oro:

// app/registro/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function registrar(formData: FormData) {
  const email = String(formData.get("email") ?? "").trim();
  const password = String(formData.get("password") ?? "");
  const nombre = String(formData.get("nombre") ?? "").trim();

  // Validación en servidor: el frontend puede saltarse la del navegador
  if (!email.includes("@") || password.length < 8 || nombre.length < 2) {
    redirect("/registro?error=datos-invalidos");
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      // Datos extra que viajan al perfil del usuario
      data: { nombre },
      emailRedirectTo: "http://localhost:3000/auth/confirmado",
    },
  });

  if (error) redirect("/registro?error=" + encodeURIComponent(error.message));
  redirect("/registro/revisa-tu-correo");
}

2.3. Login y logout

// app/login/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function iniciarSesion(formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email: String(formData.get("email") ?? ""),
    password: String(formData.get("password") ?? ""),
  });

  // Mensaje genérico: nunca reveles si el email existe o no
  if (error) redirect("/login?error=credenciales-invalidas");
  redirect("/mis-citas");
}

export async function cerrarSesion() {
  const supabase = await createClient();
  await supabase.auth.signOut();
  redirect("/");
}
Detalle que protege a tus clientes

Ante un login fallido responde siempre "credenciales inválidas", nunca "ese correo no existe". Si distingues los casos, un atacante puede descubrir qué emails están registrados en tu negocio (enumeración de usuarios).

03
Recuperación de contraseña y verificación de email Los flujos que evitan que pierdas clientes por una contraseña olvidada

Un cliente que no puede entrar es una cita que no se agenda. El flujo de recuperación tiene dos pasos: pedir el enlace por email, y definir la nueva contraseña.

// app/recuperar/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function solicitarRecuperacion(formData: FormData) {
  const supabase = await createClient();

  await supabase.auth.resetPasswordForEmail(
    String(formData.get("email") ?? ""),
    { redirectTo: "http://localhost:3000/recuperar/nueva-clave" }
  );

  // Respondemos igual exista o no el correo (anti-enumeración)
  redirect("/recuperar/enviado");
}

// En la página /recuperar/nueva-clave, ya con el enlace del correo:
export async function definirNuevaClave(formData: FormData) {
  const password = String(formData.get("password") ?? "");
  if (password.length < 8) redirect("/recuperar/nueva-clave?error=corta");

  const supabase = await createClient();
  const { error } = await supabase.auth.updateUser({ password });

  if (error) redirect("/recuperar/nueva-clave?error=expirado");
  redirect("/login?mensaje=clave-actualizada");
}
Verificación de email

Supabase envía el correo de confirmación automáticamente al registrarse (configurable en Authentication → Providers → Email). Mantenla activada: garantiza que el email de cada reserva llega a una casilla real. En el módulo 5 personalizaremos estos correos con la marca de la barbería.

04
Roles y permisos: cliente vs. administrador La autorización que separa la agenda pública del panel del dueño

ReservaYa tiene dos roles: cliente (reserva y ve sus propias citas) y admin (Julián: gestiona servicios, barberos y ve todas las citas). El rol se guarda en una tabla perfiles conectada a los usuarios de Supabase Auth.

4.1. La tabla de perfiles

Ejecuta esto en el SQL Editor de Supabase:

-- Tabla de perfiles: extiende auth.users con datos del negocio
create table public.perfiles (
  id uuid primary key references auth.users(id) on delete cascade,
  nombre text not null,
  telefono text,
  rol text not null default 'cliente' check (rol in ('cliente', 'admin')),
  creado_en timestamptz not null default now()
);

-- Cada registro nuevo en auth.users crea su perfil automáticamente
create function public.crear_perfil()
returns trigger language plpgsql security definer as $$
begin
  insert into public.perfiles (id, nombre)
  values (new.id, coalesce(new.raw_user_meta_data->>'nombre', 'Sin nombre'));
  return new;
end; $$;

create trigger al_crear_usuario
  after insert on auth.users
  for each row execute function public.crear_perfil();

4.2. Proteger el panel de administración

El middleware ya exige sesión para /admin. Falta la autorización: verificar el rol en el servidor, en el layout del panel:

// app/admin/layout.tsx — puerta de entrada del panel
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export default async function AdminLayout({
  children,
}: { children: React.ReactNode }) {
  const supabase = await createClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  // Autorización: consultamos el rol en la base de datos
  const { data: perfil } = await supabase
    .from("perfiles")
    .select("rol")
    .eq("id", user.id)
    .single();

  if (perfil?.rol !== "admin") redirect("/"); // cliente intentando entrar

  return <section>{children}</section>;
}
Ocultar el botón no es seguridad

Esconder el enlace "Panel admin" en el menú del cliente es cosmética, no protección. La verificación real ocurre en el servidor (layout + middleware + RLS en el módulo 3). Asume siempre que el usuario conoce todas tus URLs.

✗ Errores comunes de autenticación
  • Construir tu propio sistema de login desde cero. Solución: usa un proveedor probado (Supabase Auth, Auth0, Clerk). El hashing, la expiración de tokens y los correos ya están resueltos y auditados.
  • Validar el rol solo en el frontend. Solución: la verificación de rol === 'admin' debe ejecutarse en el servidor en cada ruta protegida, como en el layout de arriba.
  • Guardar el rol dentro del formulario o del localStorage. Solución: el rol vive en la base de datos y se consulta con el id del usuario autenticado. Nada que el usuario pueda editar decide sus permisos.
  • Revelar en el login si el email existe. Solución: mensajes genéricos ("credenciales inválidas", "si el correo existe, enviamos el enlace").
✏ Ejercicio: roles para un restaurante

El restaurante "La Sazón de la Abuela" en Barranquilla quiere digitalizar sus pedidos. Trabajan: la dueña, 2 cajeros y 4 meseros. Diseña su esquema de autorización:

  1. Define 3 roles y qué puede hacer cada uno (la dueña ve reportes de ventas; el cajero cobra y cierra pedidos; el mesero crea pedidos pero no puede anularlos).
  2. Escribe el check SQL de la columna rol para esos 3 roles, siguiendo el ejemplo de la tabla perfiles.
  3. Decide: ¿qué rutas protegería el middleware y qué verificaría cada layout? Escríbelo como reglas "si el rol es X, puede entrar a Y".

Descarga el checklist de seguridad de autenticación en los recursos del módulo y aplícalo a tu solución.

4.3. Mini-proyecto del módulo

🔨
ReservaYa · Entrega 2

Tu app ahora tiene: registro con verificación de email, login/logout, recuperación de contraseña, la tabla perfiles con roles, y el panel /admin al que solo entra Julián. Crea dos cuentas de prueba, promueve una a admin desde el SQL Editor (update perfiles set rol = 'admin' where id = '...') y verifica que la otra no puede entrar a /admin.

✓ Lo que aprendiste en este módulo
  • Autenticación ≠ autorización: una dice quién eres (sesión), la otra qué puedes hacer (rol). Ambas se verifican en el servidor.
  • JWT + cookies es el mecanismo con el que Supabase mantiene la sesión; el middleware la renueva en cada petición.
  • Los roles viven en la base de datos (tabla perfiles), nunca en algo que el usuario pueda editar.
  • ReservaYa ya tiene usuarios: clientes que se registran y un panel de administración que solo el dueño puede abrir.