Autenticación vía JWT en express.Js (parte 1)

27 de mayo 2019ComentariosjavascriptDavid Poza SuárezComentarios

¡Aviso! este artículo va a ser completo. Vamos a ver cómo funcionan los sistemas de autenticación basados en tokens (objetos que almacenan las credenciales de acceso de un usuario), en concreto el estándar JWT, que es el acrónimo de JSON Web Token, un método de autenticación basado en JSON y que se representa como una cadena de texto, que debemos incluir en cada petición (diría que casi obligatoriamente https) a nuestra API para que pueda identificar qué usuario está realizándola y qué permisos tiene. Se ha popularizado mucho en los últimos años debido a las SPA (Single page application), el IoT (internet of things) y las apps de dispositivos móviles.

Estructura

jwt estructura 1024x194

Dicho token se almacena en el cliente y se trata de una cadena que consta de tres partes separadas por un punto, estando cada una de ellas codificada en base64Url.

Base64

La codificación en base 64, se llama así porque genera una cadena ascii para representar cualquier dato binario, usando para ello un alfabeto de 64 caracteres. La versión URL elimina del alfabeto los caracteres "=", "/" y "+".

El algoritmo coge cualquier dato, lo pasa a binario, y va tomando porciones de 6 bits (ya que 2^6=64), que en decimal nos indica el índice(empezando en 0) del carácter equivalente en el alfabeto escogido.

NOTA: Cuando hemos convertido a binario se van cogiendo bytes de tres en tres (es decir 24 bits). Si al final del chorro de bits no llegamos a tener tres bytes, se completan usando el caracter "=" que equivale a 00111101. Además si no podemos formar un byte completo entonces en lugar de añadir "=", nos quedamos con los bits restantes y rellenamos con 0 hasta formar el byte.

Campos

  • header (base 64Url del JSON.stringify). Dicho json contiene las propiedades:

    • alg: indica el algoritmo usado para la firma, por ejemplo: 'HS256'
    • typ: indica el tipo de token, en este caso 'JWT'
  • payload (base 64Url del JSON.stringify): contiene los privilegios o claims. El estándar define unos cuantos posibles:

    • sub: identificador del usuario
    • iat: timestamp del momento de creación.
    • exp: timestamp en segundos a partir del cual el token dejará de ser válido. Es importante, desde el punto de vista de la seguridad, generar tokens con caducidad.

En el payload podemos incluir campos personalizados, aquellos que usemos frecuentemente y nos ahorren peticiones contra la base de datos por ejemplo. Uno típico podría ser el nivel de privilegios que tiene el usuario. La "necesidad" es usar https es obvia, ya que el payload (datos sensibles), está simplemente codificado en base64 y cualquiera que intercepte el token podrá ver su contenido.

  • firma (base64Url): Este bloque es la cadena formada por el header y el payload, ambos ya en base64, concatenados usando un punto, y por último transformada en hash usando el algoritmo indicado en la cabecera, en este caso HMAC_SHA256, usando para ello un SECRET definido en alguna parte de nuestra aplicación. Por último este tercer fragmento también se codifica en base64Url. Es este fragmento el que debe ser validado por el servidor, que es el único que posee el SECRET.

ÚTIL: Podemos codificar o validar(si sabemos el secret) un token jwt usando la siguiente herramienta online: https://jwt.io/

Algoritmos para JWT

JWT admite una serie de algoritmos criptográficos que combinan un cifrado más un hash, que es más seguro que usar solo un hash:

  • HS256: El el algoritmo usado por defecto, que consiste en un cifrado de clave simétrica HMAC (necesita una clave o SECRET para realizar el cifrado) con el algoritmo de hash SHA-256 (que produce una salida de 256 bits).
  • HS512: cifrado de clave simétrica HMAC (necesita una clave o SECRET para realizar el cifrado) con el algoritmo de hash SHA-512 (que produce una salida de 512 bits).
  • RS256: cifrado de clave simétrica RSASSA-PKCS1-v1_5 con el algoritmo de hash SHA-256. NOTA: Los cifrados asimétricos son interesantes en aplicaciones desacopladas, donde podemos tener la clave privada en el servidor y la clave pública en el cliente, por ejemplo.
  • RS512: cifrado de clave asimétrica RSASSA-PKCS1-v1_5 con el algoritmo de hash SHA-512.
  • PS256: cifrado de clave asimétrica RSASSA-PSS con el algoritmo de hash SHA-256. Este es el reemplazo de RSA-PKCS
  • PS512: cifrado de clave asimétrica RSASSA-PSS con el algoritmo de hash SHA-512.
  • ES256: cifrado de clave asimétrica ECDSA con el algoritmo de hash SHA-256. ECDSA utiliza claves más pequeñas y es más eficiente que RSA, Actualmente es el algoritmo que usan las criptomonedas Bitcoin y Ethereum, por ejemplo.
  • ES512: cifrado de clave asimétrica ECDSA con el algoritmo de hash SHA-512.

SHA significa Secure Hash Algorithm y a día de hoy SHA256 y SHA512 (Ambos de la familia SHA-2), siguen sin ser quebrantados, a diferencia de SHA-1. Además ya existe SHA-3, que sigue un enfoque diferente a la anterior versión y que aún no se sabe si es mejor ni si será su reemplazo en el futuro.

seguridad hashes 1024x266

Ventajas frente a sesiones + cookies

funcionamiento de cookies vs tokens

funcionamiento de cookies vs tokens

  • Inicialmente al menos, las cookies daban problemas en el desarrollo de apps, pues carecían de soporte completo, sin embargo jwt permite usar el mismo backend en multitud de plataformas, sea una app de un dispositivo Android, iOS, Windows, un navegador o una nevera, lo que queramos, pues únicamente requieren entender el protocolo http.
  • Es un mecanismo que no requiere guardar ningún estado en el servidor (stateless), por ello un API REST, siendo puristas, no debería hacer uso de sesiones sino de tokens, ya que una sesión está guardándose de algún modo en el servidor. Contienen en sí mismos todo lo necesario para ser validados.
  • Además un token puede contener datos extra (por ejemplo el nivel de permisos) que nos resulten útiles, no únicamente el id de sesión, como en las sesiones. Estos pueden evitarnos algunas consultas habituales a base de datos.
  • Nos da libertad total tanto a la hora de almacenar el token en el cliente (localStorage, sessionStorage, cookie...), como a la hora de enviarlo en cada petición (GET, POST, Authorization header).
  • Una cookie va atada a un dominio mientras el token podría usarse desde diferentes dominios sin problema.
  • Mitiga el uso de ataques CSRF (siempre que no usemos cookies para almacenar los token). Este tipo de ataques realizan peticiones falseadas desde un servidor a otro de forma oculta dentro del html, que ha sido manipulado para ello. De modo que no es necesario robar la cookie para poder llevar a cabo acciones sobre el backend usando los privilegios del propietario de la misma.

Usando librería passport

Passport es una librería Javascript tremendamente popular, que mediante diferentes módulos extra, implementa lo que denomina "estrategias" para dotar a nuestra aplicación de capacidad de autenticación mediante diversos sistemas (JWT, OAuth, Google, Facebook, Twitter, Github, hasta un total de 502 estrategias).

Dependencias

Para este ejemplo vamos a usar los siguientes paquetes:

  • dotenv: para parsear el fichero .env independientemente del sistema operativo. En este fichero guardaremos la configuración de bd y del algoritmo de encriptación de jwt.
  • express: este ejemplo de api se basará en este microframework.
  • mongoose: es el cliente para bases de datos mongodb, no incluiremos el driver mongodb ya que mongoose ya lo incluye como dependencia.
  • passport: es la popular librería base para implementar un sistema de autenticación.
  • passport-local: nos permite crear middleware passport con estrategia de tipo local.
  • passport-jwt: nos permite crear middleware passport con estrategia de tipo jwt, que permitirá recibir un token vía cabecera y hacer su validación.
  • jsonwebtoken: Es una librería que nos permite la creación de tokens jwt. Antes usaba jwt-simple, sin embargo ésta es ahora mucho más usada. Además es la que usa passport-jwt como dependencia para verificar la firma del token.

Estructura del proyecto

Aunque el uso de express.js es sencillo, conviene un mínimo de organización a la hora de empezar a crear ficheros de modo que sus diferentes partes queden claramente separadas y sea sencillo hacer crecer nuestro API REST. La siguiente propuesta es sencilla pero muy válida:

express project structure

Un router es un contenedor de toda la gestión de rutas y middlewares. Por defecto express arranca con un router, pero podemos crear varios para separar las rutas y middleware en varios ficheros. De tal modo que podamos asignar determinadas url a diferentes router, quedando todo el código organizado en diferentes ficheros. Se crea mediante let router = express.Router(); y se enganchan a una ruta mediante app.use('/ruta', router);

Flujo passport-jwt

Flujo desde el registro hasta el acceso a una ruta protegida mediante passport-local y passport-jwt. A continuación lo explico en detalle.

Flujo desde el registro hasta el acceso a una ruta protegida mediante passport-local y passport-jwt. A continuación lo explico en detalle.

Configuración de estrategias

En este punto simplemente vamos a indicarle a passport que vamos a habilitar las estrategias local y jwt, para ello lanzamos passport.use(estrategia). Seguidamente inyectamos, junto con el resto de middleware que vayamos a usar, el de passport: app.use(passport.initialize()).

La estrategia local admite opciones como cambiar el nombre de los parámetros de nombre de usuario y password, que va a recibir bien con el verbo GET o con POST. Otra opción que debemos deshabilitar es el uso de sesiones, pues jwt es stateless y no queremos que cree ningún fichero de sesión en el servidor. El callback que recibe lo llamaremos callback de verificación y lo veremos luego.

passport.use(new LocalStrategy({
    usernameField: "username",
    passwordField: "password",
    session: false
}, (username, password, done)=>{
    //callback de verificación
}));

La estrategia jwt admite entre sus opciones el método que va a usar para extraer el token de una petición protegida con autenticación, el SECRET para el algoritmo y éste en sí. En nuestro caso, usaremos la opción más habitual, que es recoger el token de el campo Authorization de la cabecera de la petición. Pero tenemos más opciones para recoger el token:

  • Header, campo Authorization: ExtractJWT.fromAuthHeaderAsBearerToken()
  • Header, campo definido por nosotros: ExtractJWT.fromHeader(header_name)
  • GET: ExtractJWT.fromUrlQueryParameter(param_name)
  • POST: ExtractJWT.fromBodyField(field_name)

Dicho campo será una cadena compuesta por "Bearer " + token. De nuevo el callback de verificación tan solo lo dejo indicado, pues lo veremos más adelante.

let opts = {}
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken();
opts.secretOrKey = process.env.JWT\_SECRET;
opts.algorithms = \[process.env.JWT\_ALGORITHM\];
passport.use(new JwtStrategy(opts, (jwt\_payload, done)=>{
    //callback de verificación
}));

Caso registro

El registro en nuestro caso es la parte más sencilla pues no necesitamos ni tan siquiera hacerlo pasar por el middleware de passport, pues los usuarios se registra contra una base de datos a la que tenemos acceso y podemos atacar directamente.

La única peculiaridad es que debemos comprobar si ya existe el usuario antes de intentar crearlo y que su password no la vamos a guardar en ningún caso como texto plano sino como un hash creado con un algoritmo llamado Blowfish, gracias a la librería bcrypt.

La peculiaridad del hashing es que matemáticamente solo es posible codificar pero no decodificar, es decir, solo va en un sentido. Por lo tanto la comprobación de la validez de la contraseña lo que hace es crear el hash de la contraseña que introduce el usuario en texto plano y comparar los hashes, no al contrario, porque a partir de un hash no podemos obtener la entrada original.

La función bcrypt.hashSync() genera automáticamente un SALT de 128bits para realizar el hashing. El SALT es una cadena que el algoritmo de hashing usa como parámetro de para variar los resultados que produce una misma entrada. Esto es así para evitar ataques por fuerza bruta usando diccionarios ya variando el SALT logramos que una entrada no tenga siempre la misma salida. El SALT de guarda dentro del hash para poder hacer la comparación con cadenas de texto plano.

Además bcrypt es un algoritmo adaptativo, que tiene un segundo parámetro, el número de rounds (a día de hoy se recomiendan 16, aunque por defecto viene a con un valor de 10). Este valor permite indicar al algoritmo que realice más o menos iteraciones, y por ello no es más que el indicativo del coste de procesamiento que tiene el cálculo, y que conforme el hardware avanza debería aumentarse.

Si todo va bien devolveremos un json con el documento completo del usuario salvado en base de datos.

En cuanto a los errores vamos a lanzarlos al siguiente middleware. Además vamos a usar nuestros propios tipos de errores, para poder controlar luego los diferentes códigos de error de las respuestas que lanzaremos.

register: (req, res, next) => {
    User.findOne({ username: req.body.username })
        .then(data => { //si la consulta se ejecuta
            if (data) { //si el usuario existe
                throw new error\_types.InfoError("user already exists");
            }
            else { //si no existe el usuario se crea/registra
                console.log("creando usuario");
                var hash = bcrypt.hashSync(req.body.password, parseInt(process.env.BCRYPT\_ROUNDS));
                let document = new User({
                    username: req.body.username,
                    first\_name: req.body.first\_name || '',
                    last\_name: req.body.last\_name || '',
                    email: req.body.email || '',
                    password: hash,
                    login\_count: 0
                });
                return document.save();
            }
        })
        .then(data => { //usuario registrado con exito, pasamos al siguiente manejador
            res.json({ data: data });
        })
        .catch(err => { //error en registro, lo pasamos al manejador de errores
            next(err);
        })
}

Caso login

Para el endpoint de login de usuario vamos a invocar directamente el middleware authenticate de passport. Como parámetros vamos a indicar que use la estrategia local (previamente habilitada). La estrategia local es un tipo de estrategia en blanco que en realidad nos permite hacer lo que queramos para verificar el usuario. Cuando se ejecuta el middleware automáticamente se llama al callback de verificación de la estrategia indicada como parámetro, y quedamos a la espera de que se retornen datos de usuario o error en el callback de autenticación,

login: (req, res, next) => {
    passport.authenticate("local", { session: false }, (error, user) => {
        console.log("ejecutando \*callback auth\* de authenticate para estrategia local");
        if (error || !user) {
            next(new error\_types.Error404("username or password not correct."))
        }
        else {
            console.log("\*\*\* comienza generacion token\*\*\*\*\*");
            const payload = {
                sub: user.\_id,
                iat: Date.now() + parseInt(process.env.JWT\_EXPIRATION),
                username: user.username
            };
            /\* NOTA: Si estuviesemos usando sesiones, al usar un callback personalizado,
            es nuestra responsabilidad crear la sesión.
            Por lo que deberiamos llamar a req.logIn(user, (error)=>{}) aquí\*/
            /\*solo inficamos el payload ya que el header ya lo crea la lib jsonwebtoken internamente
            para el calculo de la firma y así obtener el token\*/
            const token = jwt.sign(JSON.stringify(payload), process.env.JWT\_SECRET, {algorithm: process.env.JWT\_ALGORITHM});
            res.json({ data: { token: token } });
        }
    })(req, res);
}

Verificación LocalStrategy

Como decía ahora estamos ejecutando la verificación local, que nos permite hacer cualquier cosa a partir del username y la password. En este caso validaremos contra la base de datos. Solo hay que tener en cuenta que debemos comparar la password en texto plano que nos llega por GET/POST (OJO: de ahí que recomiende con todas mis fuerzas usar https) contra el hash en base de datos. Para lo cual usaremos compareSync de bcrypt, que recibe primero el texto plano y después el hash.

Y por convención (habitualmente en nodejs), se ejecutará una función callback llamada done(err, data) según los diferentes casos.

(username, password, done)=>{
    console.log("ejecutando \*callback verify\* de estategia local");
    User.findOne({username:username})
    .then(data=>{
        if(data === null) return done(null, false); //el usuario no existe
        else if(!bcrypt.compareSync(password, data.password)) { return done(null, false); } //no coincide la password
        return done(null, data); //login ok
    })
    .catch(err=>done(err, null)) // error en DB
}

CB de authenticate local

Al ejecutar done(err,data) en el callback de verificación, volvemos al callback de autenticación que estaba esperando a los resultados.

Aquí sencillamente comprobamos si nos ha llegado un error para lanzarlo al middleware correspondiente y si no es así preparamos el payload del usando los datos de usuario que hemos recibido y finalmente generamos el token jwt gracias a la librería jsonwebtoken.

(error, user) => {
    console.log("ejecutando \*callback auth\* de authenticate para estrategia local");
    if (error || !user) {
        next(new error\_types.Error404("username or password not correct."))
    }
    else {
        console.log("\*\*\* comienza generacion token\*\*\*\*\*");
        const payload = {
            sub: user.\_id,
            iat: Date.now() + parseInt(process.env.JWT\_EXPIRATION),
            username: user.username
        };

        /\* NOTA: Si estuviesemos usando sesiones, al usar un callback personalizado,
        es nuestra responsabilidad crear la sesión.
        Por lo que deberiamos llamar a req.logIn(user, (error)=>{}) aquí\*/

        /\*solo inficamos el payload ya que el header ya lo crea la lib jsonwebtoken internamente
        para el calculo de la firma y así obtener el token\*/
        const token = jwt.sign(JSON.stringify(payload), process.env.JWT\_SECRET, {algorithm:"HS256"});
        res.json({ data: { token: token } });
    }
}

Caso acceso ruta protegida

El último caso es cuando el usuario intenta acceder a una url que hemos protegido para que solo sea accesible por usuarios correctamente autenticados.

Esto lo logramos inyectando un middleware antes de cada controlador de petición, durante la asignación de rutas en el router. Dicho middleware en realidad es solo un envoltorio del middleware passport.authenticate("jwt", ...), lo he diseñado así para no tener que cambiar mucho el código en el siguiente ejemplo donde mostraré cómo podemos crear el mismo sistema sin usar passport. Al ejecutar authenticate saltamos a ejecutar el callback de verificación de la estrategia jwt y quedamos a la espera de los datos.

router.get('/protected', customMdw.ensureAuthenticated, SampleController.protected);

Verificación JwtStrategy

La función de verificación recibe el payload del jwt, donde habremos almacenado diferentes campos, algunos puede que opcionales, pero uno de ellos debería ser (según el estándar) el campo sub, que identifica al usuario. Con ello realizamos una búsqueda en la base de datos y ejecutamos done(err,data) para devolver los resultados a donde lo dejamos en la verificación y ejecutar el callback de autenticación.

(jwt\_payload, done)=>{
    console.log("ejecutando \*callback verify\* de estrategia jwt");
    User.findOne({\_id: jwt\_payload.sub})
        .then(data=>{
        if (data === null) { //no existe el usuario
            //podríamos registrar el usuario
            return done(null, false);
        }
        /\*encontramos el usuario así que procedemos a devolverlo para
        inyectarlo en req.user de la petición en curso\*/
        else
            return done(null, data);
        })
        .catch(err=>done(err, null)) //si hay un error lo devolvemos
}

CB de authenticate jwt

Dicho callback de autenticación recibe los parámetros que hemos enviado con done(err,data) como parámetro error y otro con los datos de usuario que habíamos rescatado de la base de datos. Además recibe un tercer parámetro opcional que llamaremos info, que informará si existen errores relacionado con la validez del token (firma incorrecta, token caducado, etc).

Si no hay usuario entonces debemos lanzar un error con código 403, para indicar que el usuario no está autorizado. Mientras que si existe algún problema con el token entonces lanzaremos un 401, para indicar acceso prohibido.

En caso de no existir error aprovechamos el middleware para inyectar en la request los datos de usuario, de modo que podamos usarlos dentro de la lógica de el endpoint protegido en concreto. Podríamos presentar una pagina de saludo, podriamos hacer un endpoint que consulte la base de datos a partir del id de usuario... en fin, infinitas posibilidades.

ensureAuthenticated: (req,res,next)=>{
    passport.authenticate('jwt', {session: false}, (err, user, info)=>{
        console.log("ejecutando \*callback auth\* de authenticate para estrategia jwt");
        //si hubo un error relacionado con la validez del token (error en su firma, caducado, etc)
        if(info){ return next(new error\_types.Error401(info.message)); }
        //si hubo un error en la consulta a la base de datos
        if (err) { return next(err); }
        //si el token está firmado correctamente pero no pertenece a un usuario existente
        if (!user) { return next(new error\_types.Error403("You are not allowed to access.")); }

        //inyectamos los datos de usuario en la request
        req.user = user;
        next();
    })(req, res, next);
},

protected: (req,res)=>{
    res.send(\`Ok ${req.user.first\_name} ${req.user.last\_name}, bienvenido a la ruta protegida.\`);
}

Código

Como siempre, tenéis el código disponible en mi repo: https://github.com/davidpoza/passport-jwt-example

Conclusión

En este extenso artículo hemos visto cómo elaborar un sistema de autenticación sencillo y seguro para un microservicio escrito con express.js y usando el paquete passport, que es interesante porque nos abre las puertas a multitud de implementaciones de estrategias, de modo que podríamos dar soporte para Facebook o Google en nuestro servicio de forma bastante rápida.

No obstante como me gusta comprender, dentro de lo posible, los conceptos importantes haré una segunda parte de este artículo volviendo a realizar una implementación del sistema de autenticación basado en jwt pero sin esta vez sin usar el paquete passport.