Refresh token con autenticación JWT: implementación en Node.js
Cuando se diseña una aplicación web, la autenticación es una de las piezas claves, ya que la seguridad depende en gran medida de este punto. La autenticación con tokens supuso un gran avance en este aspecto, y el refresh token llegó para complementarla y hacerla usable.
Autenticación
Los sistemas de autenticación se dividen según el modo en que verifican al usuario:
– Basados en algo conocido (password)
– Basados en algo poseído (tarjeta de identidad, usb, token)
– Basados en características físicas (voz, huellas, ojos)
Autenticación basada en token
Los tokens fueron introducidos en las aplicaciones web por la autenticación y autorización moderna. Podríamos decir que su uso se extendió gracias al protocolo OAuth (posteriormente OAuth2). Estos estaban centrados en la autorización, y no en la autenticación como se tiende a pensar.
Cuando hablamos de autenticación con tokens, podemos dividirlo en 2 tipos:
1. Autenticación tradicional o autenticación en servidor
Hasta hace poco ha sido el modo de autenticación más habitual. Cuando un usuario se loguea, el servidor le devuelve un token que típicamente es almacenado en una cookie. El servidor guarda la información de la sesión, bien en memoria o en base de datos (Redis, MongoDB …).
De este modo, cada vez que el usuario hace una petición con ese token, el servidor busca la información para saber qué usuario está intentando acceder y si es válida, ejecuta el método solicitado.
Este tipo de autenticación tiene varios problemas, como la sobrecarga provocada por toda la información de los usuarios autenticados. Así como de escalabilidad, ya que si hay varias instancias del servidor levantadas, tendrían que compartir de algún modo la información de la sesión para no hacerle logarse de nuevo.
Además, existen vulnerabilidades debidas a esta arquitectura (CORS, CSRF).
2. Autenticación sin estado basada en tokens
Para solucionar todos estos inconvenientes, surge la autenticación sin estado (stateless). Esto significa que el servidor no va a almacenar ninguna información, ni tampoco la sesión.
Cuando el usuario se autentica con sus credenciales o cualquier otro método, en la respuesta recibe un token (access token). A partir de ese momento, todas las peticiones que se hagan al API llevarán este token en una cabecera HTTP de modo que el servidor pueda identificar qué usuario hace la petición sin necesidad de buscar en base de datos ni en ningún otro sistema de almacenamiento.
Con este enfoque, la aplicación pasa a ser escalable, ya que es el propio cliente el que almacena su información de autenticación, y no el servidor. Así las peticiones pueden llegar a cualquier instancia del servidor y podrá ser atendida sin necesidad de sincronizaciones.
Diferentes plataformas podrán usar el mismo API.
Además se incrementa la seguridad, evitando vulnerabilidades CSRF, al no existir sesiones. Y si añadimos expiración al token la seguridad será aún mayor.
JWT (JSON Web Token)
JSON Web Token (JWT) es un estandar abierto basado en JSON para crear tokens de acceso que permiten el uso de recursos de una aplicación o API. Este token llevará incorporada la información del usuario que necesita el servidor para identificarlo, así como información adicional que pueda serle útil (roles, permisos, etc.).
Además podrá llevar incorporado tiempo de validez. Una vez pasado este tiempo de validez, el servidor no permitirá más el acceso a recursos con dicho token. En este paso, el usuario tendrá que conseguir un nuevo access token volviéndose a autenticar o con algún método adicional: refresh token.
JWT define JSON como el formato interno a usar por la información almacenada en el token. Además, puede llegar a ser muy útil si se usa junto a JSON Web Signature (JWS) y JSON Web Encryption (JWE).
La combinación de JWT junto con JWS y JWE nos permite no sólo autenticar al usuario, sino enviar la información encriptada para que sólo el servidor pueda extraerla, así como validar el contenido y asegurarse que no ha habido suplantaciones o modificaciones.
Un token JWT está formado por 3 partes separadas por un . siendo cada una de ellas:
- Cabecera (header): con el tipo (JWT) y el tipo de codificación
- Cuerpo (payload): Es donde se encontrará la información del usuario que permitirá al servidor discernir si puede o no acceder al recurso solicitado
- Firma de verificación (signature): Se aplicará la función de firmado a los otros dos campos del token para obtener el campo de verificación
Tipos de token
Hay muchos tipos de token, aunque en la autenticación con JWT los más típicos son el access token y el refresh token.
- Access token: Lleva contenida toda la información que necesita el servidor para saber si el usuario / dispositivo puede acceder al recurso que está solicitando o no. Suelen ser tokens caducos con un periodo de validez corto.
- Refresh token: El refresh token es usado para generar un nuevo access token. Típicamente, si el access token tiene fecha de expiración, una vez que caduca, el usuario tendría que autenticarse de nuevo para obtener un access token. Con el refresh token, este paso se puede saltar y con una petición al API obtener un nuevo access token que permita al usuario seguir accediendo a los recursos de la aplicación.
También puede ser necesario generar un nuevo access token cuando se quiere acceder a un recurso que no se ha accedido con anterioridad, aunque esto depende de las restricciones en la implementación del API.
El refresh token requiere una seguridad mayor a la hora de ser almacenado que el access token, ya que si fuera sustraido por terceras partes, podrían utilizarlo para obtener access tokens y acceder a los recursos protegidos de la aplicación. Para poder cortar un escenario como este, debe implementarse en el servidor algún sistema que permita invalidar un refresh token, además de establecer un tiempo de vida que obviamente debe ser más largo que el de los access tokens.
Refresh token y JWT. Implementación en Node.js
Para este ejemplo voy a saltarme la parte de base de datos y por tanto algunas comprobaciones de seguridad que deberían hacerse, aunque las iré comentando. El motivo es mostrar un código lo más sencillo posible y no condicionar la implementación a ningún sistema de permanencia.
En este primer código simplemente arrancamos un servidor node como haríamos con cualquier otra aplicación.
Lo primero que vamos a añadir es un método para que el usuario se autentique. El método de autenticación puede ser cualquiera, aunque el más típico es usar username y password. Este es el que hemos usado, aunque para simplificar el código no se comprueba contra base de datos y permitimos el acceso a todos los usuarios (con cualquier password).
En la respuesta retornaremos tanto el token JWT como el refresh token con el que podrá solicitar nuevos tokens de acceso. Como vemos en la implementación el token se está creando con un tiempo de validez de 300 segundos (5 minutos).
Con el módulo jsonwebtoken encriptaremos y generaremos la firma, es decir, automáticamente nos generará el token JWT simplemente pasándole el objeto a encriptar y la clave que usaremos tanto para encriptar como para desencriptar después.
Para el refresh token, simplemente generaremos un UID y lo almacenaremos en un objeto en memoria junto con el username del usuario asociado. Lo normal sería guardarlo en una base de datos con la información del usuario y la fecha de creación y de expiración (si es que queremos que tenga un tiempo limitado de validez).
También se podría hacer que fuera autocontenido, como los access tokens que creamos. La ventaja que daría esta implementación es no acceder a base de datos para sacar la información necesaria. Pero en este caso no nos permitiría saber si el refresh token ha sido puesto en la lista negra o anulado por un administrador, con lo que no nos interesa. O si se ha desabilitado al usuario por algún administrador tampoco nos daríamos cuenta. Por eso este tipo de tokens prefiero implementarlo sin información autocontenida.
Para solicitar un nuevo access token hemos creado el recurso /token. En él recibimos el refresh token y como control adicional el username del usuario que es dueño del refresh token. Aquí lo que haremos será comprobar que en nuestra lista de refresh tokens está el que nos envían y que tiene el mismo username asociado. Si es correcto, generamos un nuevo token con la información del usuario (que obtendríamos de la base de datos) y lo devolvemos.
Si en nuestra aplicación el administrador pudiera deshabilitar usuarios o refresh tokens temporalmente, tendríamos que comprobarlo también antes de generar el nuevo access token.
En esta arquitectura es necesario tener un modo de deshabilitar un refresh token, para los casos en los que pueda ser sustraído, y así evitar suplantaciones y mal uso.
En una aplicación en la que un usuario puede estar trabajando desde diferentes dispositivos, con una sola identidad (mismo username) pero con tokens diferentes en cada dispositivo, si pierde o le roban uno de estos, este método le permitiría al administrador borrar o deshabilitar el refresh token en cuestión sin necesidad de que el usuario se quede sin servicio en el resto de dispositivos. Ni que tenga que volver a autenticarse, ni cambiar su password, etc. Es decir, podría seguir trabajando sin que le influya en nada y sin riesgo de que puedan generarle nuevos access tokens desde el dispositivo sustraído. Es recomendable que los access tokens tengan un tiempo de vida corto para que en casos como este, se pueda volver a un estado seguro rápidamente.
Para ello hemos creado un recurso /token/reject por el que se puede deshabilitar un refresh token. En este caso simplemente lo borramos de nuestra lista en memoria. En una implementación completa habría que comprobar que el usuario que hace la petición es administrador o tiene los permisos para este recurso.
Por último, vamos a exponer un recurso al que sólo se podrá acceder enviando una cabecera con un token JWT conseguido con anterioridad, y que habrá sido generado por nuestra aplicación y firmado con nuestra clave (SECRET)
En este caso vamos a hacer uso de Passport. Passport es un middleware para autenticación en Node.js. Es muy flexible y modular. Esto se plasma en una gran cantidad de módulos, cada uno de los cuales implementa una estrategia de autenticación diferente (JWT, Twitter, Facebook, Google, Auth0, SAML … y así hasta más de 300). Podemos usar cualquiera de ellas, importándola y configurándola de manera muy sencilla y delegando la parte más compleja de la autenticación en Passport.
En primer lugar cargaremos el middleware y los objetos necesarios. Passport require que implementemos el métodos serializeUser (y dependiendo de la estrategia también el deserializeUser), que sirven para que el middleware almacene el objeto usuario en la sesión con los campos que queramos y le digamos por qué campo queremos que lo indexe. En nuestro ejemplo lo indexamos por el username, pero lo ideal sería usar un ID.
Lo cierto es que al ser autenticación sin estado las sesiones no tienen sentido, y es que Passport nunca llegará a usar la deserialización si sólo usamos JWT. La he dejado comentada por si quisiéramos introducir nuevas estrategias.
Para la configuración del módulo JWT sólo tenemos que decirle donde nos va a llegar el token en las peticiones, en nuestro caso lo esperamos en la cabecera Authorization, y cual es la clave para desencriptar los tokens JWT.
Y por último diremos qué queremos hacer con la información extraída del token cada vez que llega una petición a un recurso que usa esta autenticación. La variable jwtPayload tendrá el objeto usuario que encriptamos en el login del usuario:
La configuración de nuestra estrategia quedaría como sigue:
El recurso que vamos a crear para probar la autenticación es /test_jwt. Y simplemente le diremos a Passport que el acceso a ese path nos lo autentica con la estrategia “jwt”. Esto nos da una idea de que con Passport podemos autenticar cada recurso con una estrategia diferente, lo cual nos da una gran flexibilidad y de una manera muy sencilla.
Conclusión
El uso de JWT nos permite aumentar la eficiencia de nuestras aplicaciones evitando múltiples llamadas a la base de datos, y de este modo reducir la latencia. Además, con el uso de refresh tokens mejoramos la seguridad y usabilidad de esta arquitectura.
El uso de tokens para la autenticación sirve en gran número de proyectos, pero no es el Santo Grial que soluciona todos los problemas ni sirve para todos los productos, pero sí que debemos tenerla muy en cuenta al plantear cualquier solución.