Ни одна система аутентификации не обходится без функции сброса пароля. Я лично никогда не отправил бы продукт, в котором не было бы этой функции. Необходимо предоставить пользователям возможность восстановить доступ к своим учетным записям / данным в случае утери или забытого пароля. В этой статье я продемонстрирую, как обрабатывать сброс пароля в ExpressJS.

В последних двух статьях я писал о том, как подключить приложение ExpressJS к базе данных MongoDB и построить систему регистрации и аутентификации пользователей.

Обе эти статьи связаны с сегодняшней статьей. Мы собираемся использовать мангуста и наши сохраненные пользовательские данные, чтобы разрешить сброс пароля.

Если вы читали эти статьи или у вас уже есть собственная система аутентификации, читайте дальше. Даже если вы используете другой стек технологий, вы все равно можете почерпнуть некоторые ценные идеи из этого подхода.

Как всегда, этот проект размещен на Github. Не стесняйтесь клонировать проект, чтобы получить доступ к исходному коду, который я использую в этой статье.

Процесс сброса пароля

Прежде чем мы углубимся в код, давайте сначала установим, как будет выглядеть процесс сброса пароля с точки зрения пользователя, а затем спроектируем его реализацию.

Взгляд пользователя

С точки зрения пользователя, процесс должен выглядеть следующим образом:

  1. Щелкните ссылку «Забыли пароль» на странице входа.
  2. Перенаправлен на страницу, для которой требуется адрес электронной почты.
  3. Получите ссылку для сброса пароля по электронной почте.
  4. Ссылка перенаправляет на страницу, требующую нового пароля и подтверждения пароля.
  5. После отправки перенаправлен на страницу входа с сообщением об успешном завершении.

Сбросить характеристики системы

Нам также необходимо понять некоторые характеристики хорошей системы сброса пароля:

  1. Для пользователя должна быть сгенерирована уникальная ссылка для сброса пароля, чтобы, когда пользователь переходит по ссылке, он мгновенно идентифицируется. Это означает включение в ссылку уникального токена.
  2. Ссылка для сброса пароля должна иметь срок действия (например, 2 часа), по истечении которого она становится недействительной и не может использоваться для сброса пароля.
  3. Ссылка для сброса должна истечь после сброса пароля, чтобы предотвратить использование одной и той же ссылки для сброса пароля несколько раз.
  4. Если пользователь запрашивает смену пароля несколько раз, не выполнив весь процесс, каждая сгенерированная ссылка должна аннулировать предыдущую. Это предотвращает наличие нескольких активных ссылок, по которым можно сбросить пароль.
  5. Если пользователь решит игнорировать ссылку для сброса пароля, отправленную на его электронную почту, его текущие учетные данные должны быть оставлены нетронутыми и действительными для будущей аутентификации.

Этапы реализации

Теперь у нас есть четкое представление о процессе сброса с точки зрения пользователя и характеристик системы сброса пароля. Вот шаги, которые мы предпримем для внедрения этой системы:

  1. Создайте модель мангуста под названием PasswordReset для управления активными запросами / токенами сброса пароля. Установленные здесь записи должны истечь через определенный период времени.
  2. Включите ссылку «Забыли пароль» в форму входа, которая ведет к маршруту, содержащему форму электронной почты.
  3. После того, как электронное письмо отправлено на почтовый маршрут, проверьте, существует ли пользователь с указанным адресом электронной почты.
  4. Если пользователь не существует, перенаправьте его обратно в форму ввода электронной почты и уведомите пользователя о том, что ни один пользователь с указанным адресом электронной почты не найден.
  5. Если пользователь существует, сгенерируйте токен сброса пароля и сохраните его в коллекции PasswordReset в документе, который ссылается на пользователя. Если в этой коллекции уже есть документ, связанный с этим пользователем, обновите / замените текущий документ (может быть только один для каждого пользователя).
  6. Создайте ссылку, которая включает в себя токен сброса пароля, отправьте ссылку пользователю по электронной почте.
  7. Перенаправить на страницу входа в систему с сообщением об успешном выполнении, предлагающим пользователю проверить свой адрес электронной почты на наличие ссылки для сброса.
  8. Как только пользователь щелкает ссылку, она должна привести к маршруту GET, который ожидает токен в качестве одного из параметров маршрута.
  9. В рамках этого маршрута извлеките токен и запросите коллекцию PasswordReset для этого токена. Если документ не найден, предупредите пользователя, что ссылка недействительна / срок действия истек.
  10. Если документ найден, загрузите форму для сброса пароля. Форма должна иметь 2 поля (новый пароль и поля подтверждения пароля).
  11. Когда форма будет отправлена, ее почтовый маршрут обновит пароль пользователя до нового пароля.
  12. Удалите документ сброса пароля, связанный с этим пользователем, в коллекции PasswordReset.
  13. Перенаправьте пользователя на страницу входа с сообщением об успешном завершении.

Реализация

Установка

Во-первых, нам нужно настроить проект. Установите пакет uuid для генерации уникального токена и пакет nodemailer для отправки писем.

npm install uuid nodemailer

Добавьте полный домен в переменные среды. Это понадобится нам, чтобы сгенерировать строку ссылки для заполнения, которую можно будет отправить пользователю по электронной почте.

DOMAIN=http://localhost:8000

Внесите некоторые изменения в файл записи приложения в следующих областях:

  1. Установите для параметра useCreateIndex значение true в параметрах подключения мангуста. Это заставляет сборку индекса мангуста по умолчанию использовать createIndex вместо обеспеченияIndex и предотвращает появление предупреждений об устаревании MongoDB.
  2. Импортируйте новый файл маршрута, который будет содержать все маршруты сброса под названием «сброс пароля». Эти маршруты мы создадим позже.
const connection = mongoose.connect(process.env.MONGO_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
  useCreateIndex: true
})
...
app.use('/', require('./routes/password-reset'))

Модели

Нам нужна специальная модель для обработки записей сброса пароля. В папке моделей создайте модель под названием PasswordReset со следующим кодом:

const { Schema, model } = require('mongoose')
const schema = new Schema({
  user: {
    type: Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  token: {
    type: Schema.Types.String,
    required: true
  }
}, {
  timestamps: true
})
schema.index({ 'updatedAt': 1 }, { expireAfterSeconds: 300 })
const PasswordReset = model('PasswordReset', schema)
module.exports = PasswordReset

У нас есть два свойства в этой модели: пользователь, который запросил сброс пароля, и уникальный токен, назначенный конкретному запросу.

Убедитесь, что для параметра timestamps установлено значение true, чтобы включить в документ поля createdAt и updatedAt.

После определения схемы создайте индекс для поля updatedAt со сроком действия 300 секунд (5 минут). Я установил это низкое значение для тестирования. В производстве вы можете увеличить это время до более практичного, например, до 2 часов.

В модели User, которую мы создали в этой статье (или в модели пользователя, которая у вас есть в настоящее время), обновите обработчик pre-save следующим образом:

userSchema.pre('save', async function(next){
  if (this.isNew || this.isModified('password')) this.password = await bcrypt.hash(this.password, saltRounds)
  next()
})

Сделайте это, чтобы убедиться, что поле пароля хешируется независимо от того, новый документ или поле пароля было изменено в существующем документе.

Маршруты

Создайте новый файл в папке маршрута с именем «password-reset.js». Это файл, который мы импортируем во входной файл приложения.

В этом файле импортируйте модели User и PasswordReset. Импортируйте функцию v4 из пакета uuid для генерации токена.

const router  = require('express').Router()
const { User, PasswordReset } = require('../models')
const { v4 } = require('uuid')
/* Create routes here */
module.exports = router

Создайте первые 2 маршрута. Эти маршруты связаны с формой, которая принимает адрес электронной почты пользователя.

router.get('/reset', (req, res) => res.render('reset.html'))
router.post('/reset', async (req, res) => {
  /* Flash email address for pre-population in case we redirect back to reset page. */
  req.flash('email', req.body.email)
/* Check if user with provided email exists. */
  const user = await User.findOne({ email: req.body.email })
  if (!user) {
    req.flash('error', 'User not found')
    return res.redirect('/reset')
  }
/* Create password reset token and save in collection along with user. 
     If there already is a record with current user, replace it. */
  const token = v4().toString().replace(/-/g, '')
  PasswordReset.updateOne({ 
    user: user._id 
  }, {
    user: user._id,
    token: token
  }, {
    upsert: true
  })
  .then( updateResponse => {
    /* Send email to user containing password reset link. */
    const resetLink = `${process.env.DOMAIN}/reset-confirm/${token}`
    console.log(resetLink)
req.flash('success', 'Check your email address for the password reset link!')
    return res.redirect('/login')
  })
  .catch( error => {
    req.flash('error', 'Failed to generate reset link, please try again')
    return res.redirect('/reset')
  })
})

Первый - это GET-путь к «/ reset». На этом маршруте отобразите шаблон "reset.html". Мы создадим этот шаблон позже.

Второй маршрут - это POST-маршрут для «/ reset». Этот маршрут ожидает адрес электронной почты пользователя в теле запроса. В этом маршруте:

  1. Верните электронное письмо обратно для предварительного заполнения на случай, если мы вернемся к форме электронной почты.
  2. Проверьте, существует ли пользователь с указанным адресом электронной почты. Если нет, высветите сообщение об ошибке и перенаправьте обратно в «/ reset».
  3. Создайте токен, используя v4.
  4. Обновить документ PasswordReset, связанный с текущим пользователем. Задайте для upsert значение true в параметрах, чтобы создать новый документ, если его еще нет.
  5. Если обновление прошло успешно, отправьте ссылку пользователю по электронной почте, высветите сообщение об успешном выполнении и перенаправьте на страницу входа.
  6. Если обновление не удалось, отобразите сообщение об ошибке и вернитесь на страницу электронной почты.

На данный момент мы регистрируем только ссылку на консоль. Мы реализуем логику электронной почты позже.

Создайте 2 маршрута, которые вступают в игру, когда пользователь переходит по ссылке, созданной по ссылке выше.

router.get('/reset-confirm/:token', async (req, res) => {
  const token = req.params.token
  const passwordReset = await PasswordReset.findOne({ token })
  res.render('reset-confirm.html', { 
    token: token,
    valid: passwordReset ? true : false
  })
})
router.post('/reset-confirm/:token', async (req, res) => {
  const token = req.params.token
  const passwordReset = await PasswordReset.findOne({ token })
  
  /* Update user */
  let user = await User.findOne({ _id: passwordReset.user })
  user.password = req.body.password
  
  user.save().then( async savedUser =>  {
    /* Delete password reset document in collection */
    await PasswordReset.deleteOne({ _id: passwordReset._id })
    /* Redirect to login page with success message */
    req.flash('success', 'Password reset successful')
    res.redirect('/login')
  }).catch( error => {
    /* Redirect back to reset-confirm page */
    req.flash('error', 'Failed to reset password please try again')
    return res.redirect(`/reset-confirm/${token}`)
  })
})

Первый маршрут - это маршрут получения, который ожидает токен в URL-адресе. Токен извлекается и затем проверяется. Проверьте токен, выполнив поиск в коллекции PasswordReset для документа с предоставленным токеном.

Если документ найден, установите для переменной шаблона «valid» значение true, в противном случае установите значение false. Обязательно передайте в шаблон сам токен. Мы будем использовать это в форме сброса пароля.

Проверьте действительность токена, выполнив поиск в коллекции PasswordReset по токену.

Второй маршрут - это маршрут POST, который принимает отправку формы для сброса пароля. Извлеките токен из URL-адреса, а затем получите связанный с ним документ для сброса пароля.

Обновите пользователя, связанного с этим конкретным документом для сброса пароля. Установите новый пароль и сохраните обновленного пользователя.

После обновления пользователя удалите документ сброса пароля, чтобы предотвратить его повторное использование для сброса пароля.

Выведите сообщение об успешном завершении и перенаправьте пользователя на страницу входа, где он сможет войти со своим новым паролем.

Если обновление не удалось, отобразите сообщение об ошибке и выполните перенаправление обратно в ту же форму.

Шаблоны

После того, как мы создали маршруты, нам нужно создать шаблоны

В папке представлений создайте файл шаблона «reset.html» со ​​следующим содержанием:

{% extends 'base.html' %}
{% set title = 'Reset' %}
{% block styles %}
{% endblock %}
{% block content %}
  <form action='/reset' method="POST">
    {% if messages.error %}
      <div class="alert alert-danger" role="alert">{{ messages.error }}</div>
    {% endif %}
    <div class="mb-3">
      <label for="name" class="form-label">Enter your email address</label>
      <input 
        type="text" 
        class="form-control {% if messages.error %}is-invalid{% endif %}" 
        id="email" 
        name="email"
        value="{{ messages.email or '' }}"
        required>
    </div>
    <div>
      <button type="submit" class="btn btn-primary">Send reset link</button>
    </div>
  </form>
{% endblock %}

Здесь у нас есть одно поле электронной почты, которое предварительно заполнено значением электронной почты, если оно было высвечено в предыдущем запросе.

Включите предупреждение, которое отображает сообщение об ошибке, если оно появилось из предыдущего запроса.

Создайте еще один шаблон в той же папке с именем «reset-confirm.html» со ​​следующим содержанием:

{% extends 'base.html' %}
{% set title = 'Confirm Reset' %}
{% block content %}
  {% if not valid %}
    <h1>Oops, looks like this link is expired, try to <a href="/reset">generate another reset link</a></h1>
  {% else %}
    <form action='/reset-confirm/{{ token }}' method="POST">
      {% if messages.error %}
        <div class="alert alert-danger" role="alert">{{ messages.error }}</div>
      {% endif %}
      <div class="mb-3">
        <label for="name" class="form-label">Password</label>
        <input 
          type="password" 
          class="form-control {% if messages.password_error %}is-invalid{% endif %}" 
          id="password" 
          name="password">
        <div class="invalid-feedback">{{ messages.password_error }}</div>
      </div>
      <div class="mb-3">
        <label for="name" class="form-label">Confirm password</label>
        <input 
          type="password" 
          class="form-control {% if messages.confirm_error %}is-invalid{% endif %}" 
          id="confirmPassword" 
          name="confirmPassword">
        <div class="invalid-feedback">{{ messages.confirm_error }}</div>
      </div>
      <div>
        <button type="submit" class="btn btn-primary">Confirm reset</button>
      </div>
    </form>
  {% endif %}
{% endblock %}

В этой форме проверьте значение «действительной» переменной, которую мы установили в маршруте GET, если false, отобразить сообщение с истекшим токеном. В противном случае отобразите форму сброса пароля.

Включите предупреждение, которое отображает сообщение об ошибке, если оно было высвечено в предыдущем запросе.

Перейдите к форме входа, которую мы создали в статье Регистрация и аутентификация, и добавьте следующий код в верхнюю часть формы:

{% if messages.success %}
    <div class="alert alert-success" role="alert">{{ messages.success }}</div>
{% endif %}

Это отображает сообщения об успешном выполнении, которые мы мигаем, когда создаем / отправляем ссылку сброса и когда мы обновляем пароль пользователя перед перенаправлением на страницу входа.

Почта

В предыдущем разделе маршрутов мы зарегистрировали ссылку сброса в консоли. В идеале мы должны отправлять электронное письмо пользователю, когда он запрашивает ссылку для сброса пароля.

В этом примере я использовал ethereal.email для создания тестовой учетной записи электронной почты в целях разработки. Отправляйтесь туда и создайте его (это процесс в один клик).

Создав тестовую учетную запись, добавьте в переменные среды следующие переменные:

EMAIL_HOST=smtp.ethereal.email EMAIL_NAME=Leanne Zulauf [email protected] EMAIL_PASSWORD=aDhwfMry1h3bbbR9Av EMAIL_PORT=587 EMAIL_SECURITY=STARTTLS

Это мои ценности на момент написания, вставьте сюда свои собственные значения.

Создайте файл helpers.js в корне проекта. В этом файле будет множество полезных функций, которые, вероятно, будут повторно использоваться во всем проекте.

Определите эти функции здесь, чтобы мы могли импортировать их, когда они нужны, а не повторять аналогичную логику во всем приложении.

const nodemailer = require('nodemailer')
module.exports = {
  sendEmail: async ({ to, subject, text }) => {
    /* Create nodemailer transporter using environment variables. */
    const transporter = nodemailer.createTransport({
      host: process.env.EMAIL_HOST,
      port: Number(process.env.EMAIL_PORT),
      auth: {
        user: process.env.EMAIL_ADDRESS,
        pass: process.env.EMAIL_PASSWORD
      }
    })
    /* Send the email */
    let info = await transporter.sendMail({
      from: `"${process.env.EMAIL_NAME}" <${process.env.EMAIL_ADDRESS}>`,
      to,
      subject,
      text
    })
    /* Preview only available when sending through an Ethereal account */
    console.log(`Message preview URL: ${nodemailer.getTestMessageUrl(info)}`)
  }
}

Экспорт объекта с различными функциями. Первая - это функция sendEmail.

Эта функция принимает адрес получателя, тему и текст электронного письма. Создайте транспортер NodeMailer, используя переменные среды, определенные ранее в параметрах. Отправьте электронное письмо, используя аргументы, переданные функции.

Последняя строка функции регистрирует URL-адрес сообщения в консоли, чтобы вы могли просмотреть сообщение в почте Ethereal. Тестовая учетная запись фактически не отправляет электронное письмо.

Вернитесь к маршрутам password-reset.js и добавьте функцию электронной почты. Сначала импортируйте функцию:

const { sendEmail } = require('../helpers')

В маршруте POST «/ reset» вместо регистрации ссылки сброса на консоли добавьте следующий код:

sendEmail({
      to: user.email, 
      subject: 'Password Reset',
      text: `Hi ${user.name}, here's your password reset link: ${resetLink}. 
      If you did not request this link, ignore it.`
    })

Отправьте дополнительное электронное письмо, чтобы уведомить пользователя об успешной смене пароля в маршруте POST «/ reset-confirm» после успешного обновления пользователя:

user.save().then( async savedUser =>  {
    /* Delete password reset document in collection */
    await PasswordReset.deleteOne({ _id: passwordReset._id })
    /* Send successful password reset email */
    sendEmail({
      to: user.email, 
      subject: 'Password Reset Successful',
      text: `Congratulations ${user.name}! Your password reset was successful.`
    })
    /* Redirect to login page with success message */
    req.flash('success', 'Password reset successful')
    res.redirect('/login')
  }).catch( error => {
    /* Redirect back to reset-confirm page */
    req.flash('error', 'Failed to reset password please try again')
    return res.redirect(`/reset-confirm/${token}`)
  })

Заключение

В этой статье я продемонстрировал, как реализовать функцию сброса пароля в ExpressJS с помощью NodeMailer.

В следующей статье я напишу о внедрении системы проверки электронной почты пользователя в ваше приложение Express. Я буду использовать подход, аналогичный тому, который использовался в этой статье, с предпочтительным почтовым пакетом NodeMailer.

Если вам понравилась эта статья, подумайте о том, чтобы подписаться на мой личный веб-сайт, чтобы получить ранний доступ к моему контенту, прежде чем он будет опубликован на Medium (не волнуйтесь, он по-прежнему бесплатный, без раздражающих всплывающих окон!). Также не стесняйтесь комментировать этот пост. Я хотел бы услышать ваши мысли!

Первоначально опубликовано на https://kelvinmwinuka.com 24 декабря 2020 г.