El problema
El panel de Denia Print empezó con un usuario y una contraseña compartidos. Con un equipo eso deja de servir: no ves quién cambió un pedido y no puedes quitarle el acceso a una sola persona.
Quería una cuenta propia para cada miembro, con roles y sin contraseñas. El panel corre sobre Cloudflare Pages Functions con Workers KV como almacenamiento, sin servidor de sesiones en el que apoyarse.
El enfoque
En dashboard.deniaprint.es se puede entrar de dos maneras: con una passkey (Touch ID, Face ID, Windows Hello o el móvil) o con un código enviado por correo que vale 10 minutos.
Passkeys. En el servidor uso @simplewebauthn/server. Las credenciales son detectables y la verificación del usuario es obligatoria, de modo que el navegador ofrece la passkey correcta sin pedir antes un correo. El identificador de usuario dentro de la passkey es un valor aleatorio, no la dirección de correo. La librería del navegador está en el repositorio y se sirve desde el host del panel, así que la Content Security Policy no necesita ninguna fuente adicional.
Los desafíos de inicio de sesión no guardan estado. Las opciones que envío al navegador llevan una caducidad firmada de 5 minutos. Pedir un desafío no cuesta ninguna escritura en KV. Solo tras una respuesta válida escribo una marca breve de “usado”, para que el desafío sirva una sola vez. Si esa marca no se puede escribir, el inicio de sesión se rechaza.
Códigos por correo. El código tiene 10 caracteres de un alfabeto sin caracteres parecidos (sin 0, O, 1 ni I), unos 50 bits. Mayúsculas, espacios y guiones no importan al teclearlo.
- La respuesta es siempre la misma, también para una dirección desconocida: el formulario no revela quién tiene cuenta. El correo se guarda y se envía después de responder, y una dirección desconocida no cuesta ninguna escritura en KV.
- Cada intento se cuenta antes de comparar. Si el recuento no se puede escribir, no hay comparación. Un código admite 5 intentos y funciona una sola vez.
- Cada petición se cuenta primero en la memoria del isolate (por IP, por dirección, por código) y solo después llega a KV. Por encima hay una regla de límite en el WAF de Cloudflare para las dos rutas de acceso.
Sesiones. Una sesión es una cookie firmada con la dirección de correo, un identificador de sesión y una caducidad de 30 días. La lista de sesiones de cada cuenta está en KV. Cada miembro ve sus passkeys y sesiones y puede cerrar sesión en todas partes. Un owner puede cerrar sesiones ajenas, eliminar passkeys y desactivar a un miembro. Un acceso desde un dispositivo nuevo para la cuenta envía un correo a ese miembro.
Roles. Hay tres: owner, admin y staff. Un rol es una lista de permisos en forma de texto, como orders.write o costs.read. Los endpoints piden un permiso, nunca el nombre de un rol, así que un rol a medida es una lista más y ningún endpoint cambia. Un endpoint que falta en la tabla exige el permiso de equipo, y un test comprueba que no falta ninguno. El staff ve el precio de venta de un pedido, pero no los precios de compra ni los márgenes: la API responde 403 en esos endpoints y quita los campos de coste de cada respuesta JSON.
Invitaciones. Una invitación es un enlace que funciona una vez y vale 7 días. Si el correo falla, el panel muestra el enlace para que el owner lo pase él mismo.
Las concesiones
- Cuentas en memoria. Para ahorrar lecturas de KV, cada isolate recuerda una cuenta y sus sesiones durante 60 segundos. Desactivar a un miembro corta sus sesiones al momento en ese isolate y en menos de 60 segundos en los demás.
- El correo como segunda puerta. El código por correo sigue disponible junto a las passkeys, porque la gente pierde el móvil y cambia de portátil. La cuenta es tan segura como el buzón que hay detrás.
- Los desafíos sin estado no salen gratis. Ahorran una escritura por cada visitante que abre la página de acceso, pero necesitan la marca de “usado” para seguir siendo de un solo uso.
- Una librería de navegador en el repositorio. Ningún host nuevo en la CSP, pero actualizarla es un paso manual.
- Retirar la contraseña antigua. El formulario antiguo siguió funcionando hasta que un owner entró una vez con passkey o con código. Desde entonces la ruta antigua responde 410. El último owner no se puede degradar ni desactivar.
Las cuentas de cliente de la tienda usan el mismo flujo con un código más corto, de 8 caracteres. El caso completo está en la página del proyecto Denia Print.