🛑 No necesitas pasaporte.js - Guía para la autentificación de node.js ✌️

Santiago Quinteros - CEO & CTO - Software on the road
By:
Santiago Quinteros

Introducción

Aunque los servicios de autenticación de terceros como Google Firebase, AWS Cognito y Auth0 están ganando popularidad, y las soluciones de biblioteca todo en uno como passport.js son el estándar de la industria, es común ver que los desarrolladores nunca entienden realmente todas las partes involucradas en el flujo de autenticación.

Esta serie de artículos sobre la autenticación node.js, tiene por objeto desmitificar conceptos como el JSON Web Token (JWT), inicio de sesión social (OAuth2), suplantación de identidad del usuario (un administrador puede iniciar sesión como un usuario específico sin contraseña), trampas de seguridad comunes y los vectores de ataque.

También hay un repositorio GitHub con un flujo de autenticación completo de node.js que puedes usar como base para tus proyectos.

Tabla de contenidos

Requerimientos del proyecto ✍️

Los requerimientos para este proyecto son:

  • Una base de datos para almacenar el correo electrónico y la contraseña del usuario, o el clientId y el clientSecret, o cualquier par de claves públicas y privadas.

  • Un algoritmo criptográfico fuerte y eficiente para encriptar las contraseñas.

En el momento de escribir este artículo, considero que Argon2 es el mejor algoritmo criptográfico que existe, por favor no use un simple algoritmo criptográfico como SHA256, SHA512 o MD5.

Por favor, consulte este impresionante post para obtener más detalles sobre Elijiendo un algoritmo de hasheo para la contraseña

Cómo crear un registro 🥇

Cuando se crea un usuario, la contraseña tiene que ser hasheada y almacenada en la base de datos junto con el correo electrónico y otros detalles personalizados (perfil de usuario, marca de tiempo, etc.)

Nota: Lea sobre la estructura del proyecto node.js en el artículo anteriorArquitectura del proyecto node.js a prueba de balas 🛡️**_

import * as argon2 from 'argon2';

class AuthService {
  public async SignUp(email, password, name): Promise<any> {
    const passwordHashed = await argon2.hash(password);

    const userRecord = await UserModel.create({
      password: passwordHashed,
      email,
      name,
    });
    return {
      // ASEGÚRATE DE NO DEVOLVER NUNCA LA CONTRASEÑA!!!!
      user: {
        email: userRecord.email,
        name: userRecord.name,
      },
    }
  }
}

El registro de usuario se ve así:

Registro de usuario - Base de datos MongoDB Robo3T para MongoDB

Cómo crear un inicio de seción 🥈

diagrama de inicio de sesión

Cuando el usuario realiza un inicio de seción, esto es lo que sucede:

  • El cliente envía un par de Identificaciones Públicas y una Clave Privada, normalmente un correo electrónico y una contraseña

  • El servidor busca al usuario en la base de datos utilizando el correo electrónico.

  • Si el usuario existe en la base de datos, el servidor hashea la contraseña enviada y la compara con la contraseña hasheada que está almacenada

  • Si la contraseña es válida, emite un JSON Web Token (o JWT)

Esta es la clave temporal que el cliente tiene que enviar en cada solicitud a un endpoint autenticado

import * as argon2 from 'argon2';

class AuthService {
  public async Login(email, password): Promise<any> {
    const userRecord = await UserModel.findOne({ email });
    if (!userRecord) {
      throw new Error('User not found')
    } else {
      const correctPassword = await argon2.verify(userRecord.password, password);
      if (!correctPassword) {
        throw new Error('Incorrect password')
      }
    }

    return {
      user: {
        email: userRecord.email,
        name: userRecord.name,
      },
      token: this.generateJWT(userRecord),
    }
  }
}

La verificación de la contraseña se realiza utilizando la biblioteca de argon2 para prevenir "ataques basados en el tiempo", que significa que cuando un atacante intenta forzar una contraseña basada en el sólido principio de cuánto tiempo tarda el servidor en responder.

En la siguiente sección, discutiremos cómo generar un JWT

Pero, qué es un JWT de todos modos? 👩🏫

Un JSON Web Token o JWT es un objeto JSON codificado, en un string o Token.

Puedes pensar que es un reemplazo de una cookie, con varias ventajas.

El Token tiene 3 partes y se ve así:

Ejemplo de JSON Web Token

Los datos del JWT pueden ser decodificados en el lado del cliente sin el Secreto o Firma.

Esto puede ser útil para transportar información o metadatos, codificados dentro del token, para ser usados en el frontend de la aplicación, como cosas como el rol de usuario, el perfil, la caducidad del token, etc.

Ejemplo de decodificación de JSON Web Token

Cómo generar JWT en node.js 🏭

Vamos a implementar la función generarToken necesaria para completar nuestro servicio de autenticación

Usando la biblioteca jsonwebtoken, que puedes encontrar en npmjs.com, somos capaces de generar un JWT.

import * as jwt from 'jsonwebtoken'
class AuthService {
  private generateToken(user) {

    const data =  {
      _id: user._id,
      name: user.name,
      email: user.email
    };
    const signature = 'MySuP3R_z3kr3t';
    const expiration = '6h';

    return jwt.sign({ data, }, signature, { expiresIn: expiration });
  }
}

Lo importante aquí son los datos codificados, nunca se debe enviar información sensible sobre el usuario.

La firma es el "secreto" que se utiliza para generar el JWT, y es muy importante para mantener esta firma segura.

Si se ve comprometida, un atacante podría generar tokens en nombre de los usuarios y robar sus sesiones.

Asegurando los endpoints y verificando el JWT ⚔️

El código del frontend es ahora requerido para enviar el JWT en cada solicitud a endpoint seguro.

Una buena práctica es incluir el JWT en un encabezado, comúnmente el encabezado de autorización.

Encabezado de autorización

Ahora en el backend, un middleware para las rutas express tiene que ser creado.

Middleware "isAuth"

import * as jwt from 'express-jwt';

// Asumimos que el JWT vendrá en el encabezado Autorización pero podría venir en el req.body de la solicitud o en un query param, tienes que decidir qué es lo mejor para ti.
const getTokenFromHeader = (req) => {
  if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
    return req.headers.authorization.split(' ')[1];
  }
}

export default jwt({
  secret: 'MySuP3R_z3kr3t', // Tiene que ser el mismo que usamos para firmar el JWT

  userProperty: 'token', // aquí es donde el próximo middleware puede encontrar los datos codificados generados en los servicios/auth:generateToken -> 'req.token'

  getToken: getTokenFromHeader, // Una función para obtener el token de la solicitud
})

Es muy útil tener un middleware para obtener el registro completo del usuario actual, de la base de datos, y adjuntarlo a la solicitud.

export default (req, res, next) => {
 const decodedTokenData = req.tokenData;
 const userRecord = await UserModel.findOne({ _id: decodedTokenData._id })

  req.currentUser = userRecord;

 if(!userRecord) {
   return res.status(401).end('User not found')
 } else {
   return next();
 }
}

Ahora las rutas pueden acceder al usuario actual que está realizando la solicitud.

  import isAuth from '../middlewares/isAuth';
  import attachCurrentUser from '../middlewares/attachCurrentUser';
  import ItemsModel from '../models/items';

  export default (app) => {
    app.get('/inventory/personal-items', isAuth, attachCurrentUser, (req, res) => {
      const user = req.currentUser;

      const userItems = await ItemsModel.find({ owner: user._id });

      return res.json(userItems).status(200);
    })
  }

La ruta "inventory/personal-items" está ahora asegurada, es necesario tener un JWT válido para acceder a ella, pero también utilizará el usuario actual de ese JWT para buscar en la base de datos los items correspondientes.

Por qué un JWT es seguro?

Una pregunta común que puedes tener después de leer esto es:

***Si los datos del JWT pueden ser decodificados en el lado del cliente, puede un JWT ser manipulado de manera que cambie el ID de usuario u otros datos?

Mientras que puedes decodificar un JWT fácilmente, no puedes codificarlo con nuevos datos sin tener el "Secreto" que se usó cuando se firmó el JWT.

Esto es el "por que" es tan importante no revelar nunca el secreto.

Nuestro servidor está comprobando la firma en el middleware IsAuth la librería express-jwt se encarga de eso.

Ahora que entendemos cómo funciona un JWT, pasemos a una función avanzada interesante.

Cómo hacerse pasar por un usuario 🕵️

La suplantación de identidad del usuario es una técnica que se utiliza para iniciar sesión como un usuario específico, sin conocer la contraseña del usuario.

Es una característica muy útil para que los superadministradores, desarrolladores o soporte técnico puedan resolver o debuggear un problema del usuario que sólo es visible con su sesión.

No es necesario tener la contraseña del usuario para utilizar la aplicación en su nombre, sólo generar un JWT con la firma correcta y los metadatos de usuario requeridos.

Vamos a crear un endpoint que puede generar un JWT para iniciar la sesión como un usuario específico, este endpoint sólo podrá ser utilizado por un usuario super-administrador

Primero, necesitamos establecer un rol más alto para el usuario super-administrador, hay muchas maneras de hacerlo, una simple es simplemente agregar una propiedad de "rol" en el registro de usuario en la base de datos.

rol super-administrador en el registro de la base de datos de usuarios

En segundo lugar, vamos a crear un nuevo middleware que compruebe el rol del usuario.

export default (requiredRole) => {
  return (req, res, next) => {
    if(req.currentUser.role === requiredRole) {
      return next();
    } else {
      return res.status(401).send('Action not allowed');
    }
  }
}

Ese middleware necesita ser colocado después de los middlewares "isAuth" and "attachCurrentUser".

Tercero, endpoint que genera un JWT para que el usuario se haga pasar por él.

  import isAuth from '../middlewares/isAuth';
  import attachCurrentUser from '../middlewares/attachCurrentUser';
  import roleRequired from '../middlwares/roleRequired';
  import UserModel from '../models/user';

  export default (app) => {
    app.post('/auth/signin-as-user', isAuth, attachCurrentUser, roleRequired('super-admin'), (req, res) => {
      const userEmail = req.body.email;

      const userRecord = await UserModel.findOne({ email: userEmail });

      if(!userRecord) {
        return res.status(404).send('User not found');
      }

      return res.json({
        user: {
          email: userRecord.email,
          name: userRecord.name
        },
        jwt: this.generateToken(userRecord)
      })
      .status(200);
    })
  }

Por lo tanto, no hay magia negra aquí, el súper-admin conoce el correo electrónico del usuario por el que se quiere hacer pasar, y la lógica es bastante similar a la del inicio de sesión, pero no se comprueba si la contraseña es correcta.

Eso es porque la contraseña no es necesaria, la seguridad del endpoint viene del middleware roleRequired.

Conclusión 🏗️

Si bien es bueno confiar en los servicios y librerias de autenticación de terceros, para ahorrar tiempo de desarrollo, también es necesario conocer la lógica y los principios subyacentes a la autenticación.

En este artículo exploramos las capacidades de JWT, por qué es importante elegir un buen algoritmo criptográfico para obtener las contraseñas, y cómo suplantar a un usuario, algo que no es tan simple si se utiliza una libreria como passport.js.

En la siguiente parte de esta serie, vamos a explorar las diferentes opciones para proporcionar autenticación de "Social Login" para nuestros clientes utilizando el protocolo OAuth2 y una alternativa más fácil, un proveedor de autenticación de terceros como Firebase.

Vea el repositorio ejemplo aquí 🔬

Recursos

Get the latest articles in your inbox.

Join the other 2000+ savvy node.js developers who get article updates. You will receive only high-quality articles about Node.js, Cloud Computing and Javascript front-end frameworks.


santypk4

CEO at Softwareontheroad.com