Como ya comentamos, la autenticación basada en sesiones es uno de los mecanismos más utilizados en aplicaciones web tradicionales, especialmente en aquellas ejecutadas en navegadores. Este enfoque permite al servidor identificar a un usuario autenticado y recordarlo a lo largo de su interacción con la aplicación.
En este documento, aprenderemos cómo funcionan las sesiones, cómo configurarlas en Express y cómo definir mecanismos de autenticación y validación de usuarios.
La autenticación basada en sesiones resuelve una limitación del protocolo HTTP, el cual es sin estado. Esto significa que cada petición que el cliente envía al servidor es independiente y no guarda información sobre peticiones anteriores. Sin un mecanismo adicional, el servidor no puede “recordar” quién está interactuando con él.
Para solucionar esto, se introducen las sesiones:
Si las cookies no están permitidas, la solución más común en aplicaciones tradicionales es enviar el ID de sesión en la URL como parámetro (query string) o incluirlo en el cuerpo de las peticiones. Sin embargo, ambas opciones pueden comprometer la seguridad si no se implementan correctamente. Para aplicaciones modernas o APIs, la mejor alternativa es migrar a autenticación basada en tokens (como JWT), ya que no dependen de cookies y son más seguras y escalables.
Para poder trabajar con sesiones en Express vamos a instalar el módulo express-session. Es un middleware que permite, en cada petición que requiera una comprobación, determinar si el usuario ya se ha validado y con qué credenciales, antes de dejarle acceder a lo que busca o no.
Así que lo primero que haremos será instalar el módulo:
npm install express-session
Después, lo incorporamos a nuestro servidor Express junto con el resto de módulos:
const express = require('express');
const session = require('express-session');
...
A continuación, configuramos la sesión dentro de la aplicación Express:
let app = express();
...
app.use(session({
secret: '1234',
resave: true,
saveUninitialized: false
}));
Los parámetros de configuración que hemos empleado son:
secret
: una clave de cifrado para la sesión, que se empleará para enviarla cifrada entre cliente y servidor. Es algo similar a la palabra secreta para cifrar un token, en la autenticación basada en tokens.resave
: se emplea para refrescar la sesión con cada nuevo acceso, de forma que mientras sigamos accediendo a la aplicación dentro del tiempo de caducidad establecido para la sesión, éste se renueva automáticamentesaveUninitialized
: sirve para guardar sesiones aunque no se hayan completado. Se utiliza si queremos almacenar en sesión datos de usuarios que no se hayan validado, por ejemplo. En nuestro caso no habilitaremos esta opción.Existen otros parámetros y opciones de configuración, tal y como podemos consultar en la web del repositorio NPM.
NOTA: la configuración de la sesión deberá hacerse ANTES de definir los enrutadores, ya que de lo contrario este middleware se aplicará después de procesar las rutas, y no tendrá efecto.
En todo proceso de autenticación debe haber una validación previa, donde el usuario envíe sus credenciales y se cotejen con las existentes en la base de datos, antes de dejarle acceder.
Vamos a suponer, por simplicidad, que tenemos los usuarios cargados en un array, con su nombre de usuario y su password:
const usuarios = [
{ usuario: 'nacho', password: '12345' },
{ usuario: 'pepe', password: 'pepe111' }
];
Ahora tendríamos que definir una ruta que, normalmente por POST, recogiera las credenciales que envía el usuario y las cotejara con ese array. Si concuerda con algún usuario almacenado, se guarda en la sesión el nombre del usuario que accedió al sistema, y se puede redirigir a alguna página de inicio. En caso contrario, se puede redirigir a una página de login:
app.post('/login', (req, res) => {
let login = req.body.login;
let password = req.body.password;
let existeUsuario = usuarios.filter(usuario =>
usuario.usuario == login && usuario.password == password);
if (existeUsuario.length > 0)
{
req.session.usuario = existeUsuario[0].usuario;
res.render('index');
} else {
res.render('login',
{error: "Usuario o contraseña incorrectos"});
}
});
Una vez validado el usuario, debemos definir una función middleware que se encargará de aplicarse en cada ruta que queramos proteger. Lo que hará será comprobar si hay algún usuario en sesión. En caso afirmativo, dejará pasar la petición. De lo contrario, enviará a la página de validación o login, por ejemplo.
let autenticacion = (req, res, next) => {
if (req.session && req.session.usuario)
return next();
else
res.render('login');
};
Sólo nos queda aplicar este middleware en cada ruta que requiera validación por parte del usuario. Esto se hace en la misma llamada a get, post, put o delete:
app.get('/protegido', autenticacion, (req, res) => {
res.render('protegido');
});
Notar que pasamos como segundo parámetro el middleware de autenticación. Si pasa ese filtro, se ejecutará el código del get
. En caso contrario, el middleware está configurado para renderizar la vista de login.
Nuestra aplicación también puede tener distintos roles para los usuarios registrados. Por ejemplo, podemos tener administradores y usuarios normales. Esto se suele definir con un campo extra en la información de los usuarios:
const usuarios = [
{ usuario: 'nacho', password: '12345', rol: 'admin' },
{ usuario: 'pepe', password: 'pepe111', rol: 'normal' }
];
Cuando un usuario valide sus credenciales, además de almacenar su nombre de usuario en sesión, también podemos (debemos) almacenar su rol. Así que la ruta que valida el usuario se ve modificada para añadir este nuevo dato en sesión:
app.post('/login', (req, res) => {
let login = req.body.login;
let password = req.body.password;
...
if (existeUsuario.length > 0)
{
req.session.usuario = existeUsuario[0].usuario;
req.session.rol = existeUsuario[0].rol;
res.render('index');
} else {
...
}
});
Para poder comprobar si un usuario validado tiene el rol adecuado para acceder a un recurso, podemos definir otra función middleware que compruebe si el rol del usuario es el que se necesita (el que se le pasa como parámetro a la función):
let rol = (rol) => {
return (req, res, next) => {
if (rol === req.session.rol)
next();
else
res.render('login');
}
}
NOTA: el ejemplo que acabamos de ver es una muestra de cómo podemos definir middleware que necesite parámetros adicionales además de los tres que todo middleware debe tener (petición, respuesta y siguiente función a llamar). Basta con definir una función con los parámetros necesarios, y que internamente devuelva la función middleware con los tres parámetros base.
Si queremos aplicar los dos middleware a una ruta determinada (es decir, comprobar si el usuario está autenticado y, además, si tiene el rol adecuado), podemos pasarlos uno tras otro, separados por comas, en la definición de la ruta. Por ejemplo, a esta ruta sólo deben poder acceder usuarios validados que tengan rol de administrador:
app.get('/protegidoAdmin', autenticacion,
rol('admin'), (req, res) => {
res.render('protegido_admin');
})
Además de las opciones vistas anteriormente, hay algunas operaciones más que, si bien pueden ser secundarias, conviene tener presentes cuando trabajamos con autenticación basada en sesiones.
Por un lado, está la posibilidad de hacer logout y salir de la sesión. Para esto, podemos definir una ruta que responda a esta petición, y destruya los datos de sesión del usuario, redirigiendo después a otro recurso:
app.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/');
});
Para hacer que la información de la sesión esté accesible desde las vistas, tenemos que definir un middleware que copie el contenido de req.session
a res.locals.session
.
res.locals
es un objeto en Express.js que se utiliza para compartir variables locales entre el middleware y las vistas renderizadas de la aplicación. Permite almacenar valores o datos que queremos que estén disponibles en todas las plantillas cuando se renderizan con un motor de plantillas (como Nunjucks, EJS, Pug, etc.).app.use((req, res, next) => {
res.locals.session = req.session;
next();
});
NOTA: este middleware debe definirse después del middleware que configura la sesión y antes de los enrutadores, para que tenga efecto al renderizar las vistas.
Después, podemos acceder a esta sesión desde las vistas, a través de la variable session
que hemos definido en la respuesta (res.locals
). Por ejemplo, así podríamos ver si un usuario está ya logueado, para mostrar o no el botón de “Login”:
{% if (session and session.usuario) %}
<a class="btn btn-dark" href="/logout">Logout</a>
{% else %}
<a class="btn btn-dark" href="/login">Login</a>
{% endif %}
Además, podemos establecer el tiempo de vida de la sesión, cuando la configuramos. Podemos hacerlo utilizando indistintamente el atributo expires
o el atributo maxAge
, aunque con una sintaxis algo distinta según cuál utilicemos. Debemos indicar el número de milisegundos de vida, contando desde el momento actual, por lo que se suele utilizar Date.now()
en estos cálculos. Así definiríamos, por ejemplo, una sesión de 30 minutos:
app.use(session({
secret: '1234',
resave: true,
saveUninitialized: false,
expires: new Date(Date.now() + (30 * 60 * 1000))
}));
Aquí puedes descargar un ejemplo completo para probar estos mecanismos. Se tiene una página de inicio pública, una restringida para usuarios validados y otra restringida para usuarios administradores. Se dispone también de un formulario de login y de una ruta de logout.
Ejercicio 1:
Crea una copia del ejercicio LibrosWeb_v4 y llámala LibrosWebSesiones. A partir de esa base, vamos a añadir ahora autenticación basada en sesiones. Instala el middleware express-session en el proyecto, y configúralo como en el ejemplo visto antes. Define a mano en el servidor principal un array con nombres y passwords de usuarios autorizados, y protege las rutas que permitan hacer cualquier modificación sobre el catálogo de libros. En concreto, sólo los usuarios validados podrán:
- Ver el formulario de inserción de libros e insertar libros (enviar el formulario anterior)
- Borrar libros
- Ver el formulario de edición de libros y editar libros (enviar el formulario)
Añade para ello una vista
login.njk
al conjunto de vistas de la aplicación. Puedes emplear el mismo formulario de login que en el ejemplo, y también añade las dos rutas para mostrar el formulario y para recoger los datos y validar el usuario. En caso de validación exitosa, se renderizará la vista del listado de libros. En caso contrario, el formulario de login con un mensaje de error, como en el ejemplo proporcionado.Añade también una función de logout al menú de la aplicación, que sólo será visible si el usuario ya está validado, y que permitirá destruir su sesión y redirigir al listado de libros.