No tienes acceso a esta clase

¡Continúa aprendiendo! Únete y comienza a potenciar tu carrera

Cambio Seguro de Contraseña con Token y Expiración

18/20
Recursos

¿Cómo gestionar el cambio de contraseña con tokens en nuestra aplicación?

En el mundo del desarrollo web, la seguridad es primordial. Una de las prácticas esenciales es manejar adecuadamente los cambios de contraseña, asegurando que todo el flujo resguarde la información del usuario. En este contenido, exploraremos cómo implementar un sistema seguro de recuperación y cambio de contraseñas utilizando tokens.

¿Cuál es el flujo del proceso de recuperación de password?

El proceso de recuperación de contraseñas es más que simplemente enviar un correo con un enlace de restablecimiento. Involucra varios pasos para garantizar que solo el usuario legítimo pueda cambiar su contraseña:

  1. Interfaz de recuperación de contraseña: El usuario proporciona su email en un formulario.
  2. Validación del correo: Se verifica que el email existe en la base de datos.
  3. Generación del token: Se crea un token de quince minutos y se envía al usuario por correo.
  4. Recepción del correo: El usuario recibe un correo con un link que incluye el token.
  5. Interfaz para cambiar la contraseña: Al hacer clic en el link, el usuario accede a un formulario para introducir una nueva contraseña.
  6. Validación y cambio de contraseña: Se recibe el token y la nueva contraseña, validando el token y el usuario.

¿Cómo se implementa el cambio de contraseña?

Para cambiar la contraseña, es esencial que el backend procese correctamente el nuevo password y valide el token. Aquí algunos pasos clave de la implementación:

  • División del Router: Aparte del endpoint de login, se crea uno nuevo para el cambio de contraseña.
  • Validación del token y datos: Si los datos no son válidos, se retorna un error. En caso de alguna irregularidad, podemos utilizar bibliotecas como Boom para manejar errores y lanzar excepciones.
try {
  const payload = JWT.verify(token, process.env.SECRET);
  const user = await User.findOne({ _id: payload.sub });

  if (!user || user.recoveryToken !== token) {
    throw Boom.unauthorized('Token inválido o usuario no encontrado');
  }

  // Eliminar el token y actualizar la contraseña
  user.recoveryToken = null;
  user.password = bcrypt.hashSync(newPassword, saltRounds);
  await user.save();
} catch (error) {
  handleErrors(error);
}

¿Qué aspectos de seguridad se deben considerar?

La seguridad en la gestión de contraseñas es esencial para proteger a los usuarios. Algunos aspectos a considerar incluyen:

  • Capa de validación de datos: Implementar validaciones para asegurar que los token y password satisfagan criterios de seguridad, como ser alfanuméricos o tener una longitud mínima y máxima.
  • Eliminación del token: Una vez cambiada la contraseña, el token debe ser invalidado para evitar su reutilización.
  • Hashes seguros de contraseñas: Utilizar funciones de hash como bcrypt para garantizar que las contraseñas no sean almacenadas en texto plano.
  • No exponer tokens en las respuestas: Evitar devolver información sensible como recoveryToken o contraseñas en las respuestas de la API.

¿Qué podemos aprender de este práctica?

Implementar un sistema seguro de recuperación de contraseñas supone un aprendizaje valioso en la gestión de seguridad de aplicaciones. Al crear este tipo de funcionalidades, practicamos habilidades cruciales como:

  • Integración con servicios de correo electrónico: Aprendimos a enviar correos utilizando servidores SMTP como los de Gmail.
  • Gestión de tokens seguros: Asignamos y validamos tokens firmados con secret keys, asegurando un proceso de cambio de contraseña confiable.
  • Desarrollo de flujo de usuario: Creamos interfaces intuitivas que guían al usuario a través del proceso de recuperación y actualización de contraseña.

El camino hacia una gestión de seguridad efectiva es continuo, pero cada implementación brinda un avance significativo en tus habilidades como desarrollador. Sigue adelante, cada desafío que superas enriquece tu experiencia y te aproxima más a la excelencia.

Aportes 17

Preguntas 8

Ordenar por:

¿Quieres ver más aportes, preguntas y respuestas de la comunidad?

Hola de nuevo.
También les quiero dejar mi aporte de cómo elaboré mis validaciones para los nuevos endpoints con los routes de login, recovery y change-password.
Primero, creé un nuevo archivo dentro de los schemas llamado auth.schema.js con el siguiente contenido:

const Joi = require('joi');

const email = Joi.string().email(),
  password = Joi.string().min(8),
  newPassword = Joi.string().min(8),
  token = Joi.string().regex(
    /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*$/
  );

const loginAuthSchema = Joi.object({
  email: email.required(),
  password: password.required(),
});

const recoveryAuthSchema = Joi.object({
  email: email.required(),
});

const changePasswordAuthSchema = Joi.object({
  token: token.required(),
  newPassword: newPassword.required(),
});

module.exports = {
  loginAuthSchema,
  recoveryAuthSchema,
  changePasswordAuthSchema,
};

Finalmente, importé el middleware de validatorHandler y el schema creado anteriormente dentro de auth.router:

const express = require('express'),
  passport = require('passport'),
  AuthService = require('./../services/auth.service'),
  service = new AuthService(),
  router = express.Router(),
  validatorHandler = require('../middlewares/validator.handler'),
  {
    loginAuthSchema,
    recoveryAuthSchema,
    changePasswordAuthSchema,
  } = require('../schemas/auth.schema');

router.post(
  '/login',
  validatorHandler(loginAuthSchema, 'body'),
  passport.authenticate('local', { session: false }),
  async (req, res, next) => {
    try {
      const user = req.user;
      res.json(service.signToken(user));
    } catch (error) {
      next(error);
    }
  }
);

router.post(
  '/recovery',
  validatorHandler(recoveryAuthSchema, 'body'),
  async (req, res, next) => {
    try {
      const { email } = req.body;
      const rta = await service.sendRecovery(email);
      res.json(rta);
    } catch (error) {
      next(error);
    }
  }
);

router.post(
  '/change-password',
  validatorHandler(changePasswordAuthSchema, 'body'),
  async (req, res, next) => {
    try {
      const { token, newPassword } = req.body;
      const rta = await service.changePassword(token, newPassword);
      res.json(rta);
    } catch (error) {
      next(error);
    }
  }
);

module.exports = router;

En una corta búsqueda que realicé en internet, encontré un método que utiliza regex para la validación del token. Sin embargo, esto no queire decir que sea su única forma de validación, lo pueden alterar a su gusto y conveniencia, al igual que añadir más reglas de validación para el password.

Una vez más, espero haya sido de utilidad para todos.

Saludos.

Hola gente.
En caso de que no deseen revelar el recoveryToken en el momento de hacer login, simplemente deben añadir una línea de código dentro del método getUser en auth.service:

async getUser(email, password) {
  const user = await service.findByEmail(email);
  if (!user) {
    throw boom.unauthorized();
  } else {
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      throw boom.unauthorized();
    } else {
      delete user.dataValues.password;
      delete user.dataValues.recoveryToken; // esta es la linea de codigo que se debe agregar
      return user;
    }
  }
}

Espero les sea de utilidad.

Saludos.

El email enviado al usuario para recuperar contraseña contiene un link para ello, al dar click en el link nos debe enviar a una vista para la recuperación de la contraseña y al dar click en Confirm, debemos recibir el token y la nueva contraseña. Por lo tanto, se debe hacer el proceso de validación del token (válido y sin expirar), si existe el usuario, etc.

En el auth.router.js se crea un nuevo endpoint /change-password para cambiar el password. Este endpoint va a obtener el token y la nueva contraseña del body, y ejecuta el método changePassword para validar que el token y la nueva contraseña sean correctos.

router.post('/change-password', async (req, res, next) => {
  try {
    const { token, newPassword } = req.body;
    const rta = await service.changePassword(token, newPassword);
    res.json(rta);
  } catch (error) {
    next(error);
  }
});

El método changePassword recibe el token y la nueva contraseña, primero se hace una validación donde se verifica el token, esto retorna el payload que tiene ese token. Del payload se obtiene el user (payload.sub) y lo busca en la BD con el método findOne.

Del usuario encontrado, se verifica que el campo recoveryToken sea el que está en la BD.

Posteriormente, para actualizar la contraseña se necesita borrar el token y hashear la nueva contraseña (service.update).

async changePassword(token, newPassword) {
    try {
      const payload = await jwt.verify(token, config.jwtRecoverySecret);
      const user = await service.findOne(payload.sub);

      if (user.recoveryToken !== token) {
        throw boom.unauthorized();
      }

      const hash = await bcrypt.hash(newPassword, 10);
      await service.update(user.id, { recoveryToken: null, password: hash });
      return { message: 'Password changed' };
    } catch (error) {
      throw boom.unauthorized();
    }
  }

Como buena practica evitemos atrapar errores en los proveedores o servicios, podemos lanzarlos desde los servicios, y debido a que ese service esta siendo llamado desde el controller dentro de un try, pues que el controller o router se encargue de atraparlos y mostrarlos.

router.post('/change-password',
  async (req, res, next) => {
    try {
      const { token, newPassword } = req.body;
      const mailInfo = await authService.changePassword(token, newPassword);
      res.json(mailInfo);
    } catch (error) {
      next(error);
    }
  }
)
  async changePassword(token, newPassword) {
    const payload = jwt.verify(token, process.env.JWT_SECRET);

    const user = await userService.findOne(payload.sub); // id

    if (!user || user.recoveryToken !== token) {
      throw boom.unauthorized();
    }

    const hash = bcrypt.hash(newPassword, 10);
    await userService.update(user.id, { recoveryToken: null, password: hash });
    return { message: 'Password updated' }
  }

También se puede crear desde el back una ruta que envíe html al client con un formulario
para cambiar la contraseña

router.get('/recover/form',
  validUserRecoveryToken,
  passport.authenticate('jwt', {session: false}),
  async (req, res, next) => {
    try {
      const user = req.user;
      const {token} = this.generateUserToken(user);
      await userService.update(user.id, {recoveryToken: token});

        const newPasswordForm =`
          <h1>Recover Password</h1>
          <form action="/api/v1/auth/recover/end?token=${token}" method="post">
            <div>
              <label for="newPassword">New Password:</label>
              <input type="password" id="newPassword" name="newPassword" />
            </div>
            <div>
              <label for="passwordConfirm">Repeat Password</label>
              <input type="password" id="passwordConfirm" name="passwordConfirm" />
            </div>
            <input type="submit" value="Submit" />
          </form>
        `
      res.send(newPasswordForm)
    } catch (error) {
      next(error);
    }
  }
);

para manejar las contraseñas se puede crear una tabla aparte en la base de datos, es decir, en vez de guardarla directamente en “users”, se crea una tabla “auth” donde iría el password y el id del usuario correspondiente.
Es un poco más complicado de manejar, pero de esta forma no tendríamos que estar tan al pendiente de no mandar info delicada cuando se manda información del usuario.
pd: este curso esta INCREÍBLE 😃 ❤️

Token Expired

Les comento que yo quise implementar un mensaje explícito de error por si el token había expirado. Encontré en la documentación que el método .verify() puede recibir un callback cuyo primer argumento es un error, este error tiene la forma:

      err = {
        name: 'TokenExpiredError',
        message: 'jwt expired',
        expiredAt: 1408621000
      }

Así que implementé esta lógica:

  async changePassword(token, newPassword) {
    const payload = jwt.verify(token, config.jwtSecret, (err, decoded) => {
      if (err) {
        throw boom.notAcceptable(err.name);
      }
      return decoded;
    }); //como saber si el token expiró?
    const user = await userService.findOne(payload.sub);
    if (token !== user.recoveryToken) {
      throw boom.notAcceptable("Sorry, valid but not the same token");
    }

    await userService.update(user.id, {
      recoveryToken: null,
      password: newPassword
    });
    return { message: "password changed successfully" };
  }

P.D: Sugiero que no se use Try -Catch en el servicio para que boom sí pueda lanzar errores donde se lo indicamos y los errores mayores los recoge el router que sí implementa Try - Catch.

para no mostrar el recoveryToken lo hice de la siguiente manera

  async getUser(email,password) {
    const user = await service.findByEmail(email);
    if(!user) {
      throw boom.unauthorized();
    }
    const isMatch = await bcrypt.compare(password, user.password);
    if(!isMatch){
      throw boom.unauthorized();
    }
    return { ...user.dataValues, password: undefined, recoveryToken: undefined};
  }
Hola! excelente curso y ya que se llega casi al final alguien me puede guiar en como seria la implmentacion de este back con el front?... Gracias
en que curso explican el frontend ya integrado con el backend? pregunto porque yo hice algunos de frontend pero solo era HTML, CSS y javascript y no hacian peticiones hacia el backend
Cuando generas el token de recuperación y te llega al correo, podrías copiar ese token de recuperación y usarlo para acceder a cualquier endpoint, ya que al estar firmado con el mismo secreto que el access token sería valido
Bueno por algún motivo no me dejo responder la pregunta de Luis la cual es: Se me ocurrió en que se podría hacer un hash de los tokens para así darle una capa de seguridad al token, pero... esto realmente es posible? como por ejemplo con bcrypt? seguiria funcionando? Esta es mi respuesta: Hola Luis👋 Me dejaste pensando y busque poco . Sabemos que JWT firma el token no lo encripta , cual es la diferencia? , que cuando firmas el token el payload es accesible y cuando lo encriptas ya no lo es , entiendo la intención de usar bycript pero ten en cuenta que bycript no encripta como tal hace un proceso de hash unidireccional que impediría revertir el estado original del token, un método para lograr la encriptación es de la propia librería jose aquí te dejo un código de ejemplo Espero te sirva💚
hola

Me daba unauthorized y no lograba dar con la causa. Observé que para hacer el llamado al método changePassword, debo enviar los parametros como arrays.

async changePassword({ token, newPassword })

Este curso no tiene desperdicio 🥳

Para no enviar el recoveryToken en el json vamos a auth.service.js:

signToken(user) {
    const payload = {
      sub: user.id,
      role: user.role
    }
    const token = jwt.sign(payload, config.jwtSecret);
    delete user.dataValues.recoveryToken;
    return {
      user,
      token
    };
  }

este curso fue 95% perfecto ese 5% se le resta por que el endpoint profile.auth no existe y no sé que hace ni como lo hace 😦 … o en el curso de next.js como se haria para con el mismo backend mantener una sesion logeada y que no te mande inauthorizado despues de hacer refres(F5 | Ctrl +R)?!