python

Мега-Учебник Flask, Часть X: Поддержка электронной почты (издание 2018)

  • пятница, 9 февраля 2018 г. в 03:14:51
https://habrahabr.ru/post/348566/
  • Разработка веб-сайтов
  • Python


Miguel Grinberg




<<< предыдущая следующая >>>


Это десятая часть серии Mask-Tutorial Flask, в которой я расскажу вам, как приложение может отправлять электронные письма вашим пользователям и как создать функцию восстановления пароля при поддержке адреса электронной почты.


Под спойлером приведен список статей этой серии.


Оглавление

Примечание 1: Если вы ищете старые версии данного курса, это здесь.


Примечание 2: Если вдруг Вы захотели бы выступить в поддержку моей(Мигеля) работы, или просто не имеете терпения дожидаться статьи неделю, я (Мигель Гринберг)предлагаю полную версию данного руководства(на английском языке) в виде электронной книги или видео. Для получения более подробной информации посетите learn.miguelgrinberg.com.


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


Почему приложение должно что то отправлять по электронной почте своим пользователям? Существует много причин, но одна из них — решение проблем, связанных с аутентификацией. В этой главе я собираюсь добавить функцию сброса пароля для пользователей, которые постоянно забывают свой пароль. Когда пользователь запрашивает сброс пароля, приложение отправляет электронное письмо со специально созданной ссылкой. Затем пользователю необходимо щелкнуть эту ссылку, чтобы получить доступ к форме, в которой можно установить новый пароль.


Ссылки GitHub для этой главы: Browse, Zip, Diff.


Введение в Flask-Mail


Что касается отправки электронной почты, то Flask имеет для этой цели расширение под названием Flask-Mail, которое поможет сделать эту задачу очень простой. Как всегда, оно (это расширение) устанавливается с помощью pip:


(venv) $ pip install flask-mail

Ссылки на сброс пароля должны содержать в себе безопасный токен. Чтобы сгенерировать эти токены, я собираюсь использовать JSON Web Tokens, который также имеет популярный пакет для Python:


(venv) $ pip install pyjwt

Расширение Flask-Mail настроено из объекта app.config. Помните, когда в главе 7 я добавил конфигурацию электронной почты для отправки электронной почты всякий раз, когда произошла ошибка в производстве? Тогда я не сказал вам об этом, но мой выбор переменных конфигурации был смоделирован после требований Flask-Mail, поэтому нет необходимости в какой-либо дополнительной работе, переменные конфигурации уже находятся в приложении.


Как и большинство расширений Flask, вам нужно создать экземпляр сразу после создания приложения Flask. В этом случае это объект класса Mail:


# ...
from flask_mail import Mail

app = Flask(__name__)
# ...
mail = Mail(app)

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


(venv) $ python -m smtpd -n -c DebuggingServer localhost:8025

Чтобы настроить этот сервер, необходимо установить две переменные среды:


(venv) $ export MAIL_SERVER=localhost
(venv) $ export MAIL_PORT=8025

Если вы предпочитаете отправлять электронные письма "по-взрослому", вам необходимо использовать настоящий почтовый сервер. Если он у вас есть, вам просто нужно установить для него переменные среды MAIL_SERVER, MAIL_PORT, MAIL_USE_TLS, MAIL_USERNAME и MAIL_PASSWORD. Для особо ленивых, напоминаю, как использовать учетную запись Gmail для отправки электронной почты со следующими настройками:


(venv) $ export MAIL_SERVER=smtp.googlemail.com
(venv) $ export MAIL_PORT=587
(venv) $ export MAIL_USE_TLS=1
(venv) $ export MAIL_USERNAME=<your-gmail-username>
(venv) $ export MAIL_PASSWORD=<your-gmail-password>

Если вы используете Microsoft Windows, вам необходимо заменить export на set в каждой из указанных выше export-ных инструкций.


Помните, что параметры безопасности вашей учетной записи Gmail могут препятствовать приложению отправлять электронную почту через нее, если вы явно не разрешаете «менее безопасным приложениям» доступ к вашей учетной записи Gmail. Вы можете прочитать об этом здесь, и если вас беспокоит безопасность вашей учетной записи, вы можете создать вторичную, которую вы настраиваете только для проверки электронных писем, или вы можете временно включить параметр разрешить доступ «менее безопасные приложениям» для запуска своих тестов, а затем вернуться назад к более безопасному по умолчанию.


Использование Flask-Mail


Для демонстрации работы Flask-Mail, я покажу вам, как отправить электронное письмо из оболочки Python. Для этого запустите Python с flask shell, а затем выполните следующие команды:


>>> from flask_mail import Message
>>> from app import mail
>>> msg = Message('test subject', sender=app.config['ADMINS'][0],
... recipients=['your-email@example.com'])
>>> msg.body = 'text body'
>>> msg.html = '<h1>HTML body</h1>'
>>> mail.send(msg)

Фрагмент кода, приведенный выше, отправит электронное письмо на список адресов электронной почты, которые вы указали в аргументе recipients. В качестве отправителя (sender) я использую настройку администратора (я добавил переменную в конфиге ADMINS см.в главе 7). Письмо будет иметь простой текст в HTML-версии, поэтому в зависимости от того, как настроен ваш почтовый клиент, вы можете увидеть тот или иной вариант.


Короче, это довольно просто. Теперь давайте интегрируем электронные письма в приложение.


Простой Email Framework


Начнем с написания вспомогательной функции, отправляющей электронное письмо, и в общих чертах повторяет собой версию упражнения flask shell из предыдущего раздела. Я положу эту функцию в новый модуль под названием app/email.py:


from flask_mail import Message
from app import mail

def send_email(subject, sender, recipients, text_body, html_body):
    msg = Message(subject, sender=sender, recipients=recipients)
    msg.body = text_body
    msg.html = html_body
    mail.send(msg)

Flask-Mail поддерживает некоторые интересные функции, которые я здесь не использую. Такие как списки Cc и Bcc. Обязательно ознакомьтесь с документацией Flask-Mail, если вас заинтересовали эти параметры.


Запрос сброса пароля


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


<p>
    Forgot Your Password?
    <a href="{{ url_for('reset_password_request') }}">Click to Reset It</a>
</p>

Когда пользователь нажмет на ссылку Click to Reset It, появится новая веб-форма, которая запрашивает адрес электронной почты пользователя в качестве способа инициирования процесса сброса пароля. Вот класс формы:


class ResetPasswordRequestForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Email()])
    submit = SubmitField('Request Password Reset')

И вот соответствующий HTML-шаблон:


{% extends "base.html" %}

{% block content %}
    <h1>Reset Password</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.email.label }}<br>
            {{ form.email(size=64) }}<br>
            {% for error in form.email.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

Понадобится также view-функция для обработки этой формы:


from app.forms import ResetPasswordRequestForm
from app.email import send_password_reset_email

@app.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = ResetPasswordRequestForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user:
            send_password_reset_email(user)
        flash('Check your email for the instructions to reset your password')
        return redirect(url_for('login'))
    return render_template('reset_password_request.html',
                           title='Reset Password', form=form)

Это функция просмотра сильно смахивает на другие, которые обрабатывают форму. Стартуем с того, что пользователь не вошел в систему. Если пользователь вошел в систему, то нет смысла использовать функцию сброса пароля, а следует перенаправить вывод на страницу index.


Когда форма отправлена ​​и действительна, я ищу пользователя по электронной почте, предоставленной пользователем в форме. Если пользователь найден, отправлю электронное письмо с сбросом пароля. Для этого используется вспомогательная функцию send_password_reset_email(). Я покажу вам эту функцию ниже.


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


Токены сброса пароля


Прежде чем реализовать функцию send_password_reset_email(), мне нужно придумать способ создания ссылки на запрос пароля. Это будет ссылка, которая будет отправлена ​​пользователю по электронной почте. При щелчке по ссылке пользователю предоставляется страница, где может быть установлен новый пароль. Сложная часть этого плана — убедиться, что для сброса пароля учетной записи можно использовать только действительные ссылки сброса.


Ссылки будут снабжены токеном, и этот токен будет проверен, прежде чем разрешить изменение пароля, в качестве доказательства того, что пользователь, который запросил электронное письмо, имеет доступ к адресу электронной почты в учетной записи. Очень популярным стандартом токена для этого типа процесса является JSON Web Token, или JWT. Самое приятное в JWT заключается в том, что они самодостаточны. Вы можете отправить токен пользователю по электронной почте, и когда пользователь нажмет на ссылку, которая возвращает токен обратно в приложение, его можно проверить самостоятельно.


Что бы разобраться как работают JWT? Ничего лучше не придумать, как испытать это в сеансе оболочки Python:


>>> import jwt
>>> token = jwt.encode({'a': 'b'}, 'my-secret', algorithm='HS256')
>>> token
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiJ9.dvOo58OBDHiuSHD4uW88nfJikhYAXc_sfUHq1mDi4G0'
>>> jwt.decode(token, 'my-secret', algorithms=['HS256'])
{'a': 'b'}

Словарь {'a': 'b'} представляет собой пример полезной нагрузки, которая будет записана в токен. Чтобы сделать токен безопасным, необходимо предоставить секретный ключ для использования при создании криптографической подписи. В этом примере я использовал строку my-secret, но с приложением я собираюсь использовать SECRET_KEY из конфигурации. Аргумент algorithm указывает, как должен генерироваться токен. Наиболее широко используется алгоритм HS256.


Как видите, итоговый токен — это длинная последовательность символов. Но не думайте, что это зашифрованный токен. Содержимое токена, включая полезную нагрузку, может быть легко декодировано любым пользователем (не верите мне? Скопируйте вышеуказанный токен, а затем введите его в отладчик JWT, чтобы просмотреть его содержимое). Что делает маркер безопасным, так это то, что полезная нагрузка имеет подпись. Если кто-то пытался подделать или манипулировать полезной нагрузкой в ​​токене, тогда подпись будет признана недействительной, а для создания новой подписи необходим секретный ключ. Когда токен проверен, содержимое полезной нагрузки декодируется и возвращается обратно вызывающему абоненту. Если подпись токена была подтверждена, то полезной нагрузке можно доверять как аутентичной.


Полезная нагрузка, которую я буду использовать для токенов сброса пароля, будет иметь формат {'reset_password': user_id, 'exp': token_expiration}. Поле exp является стандартным для JWT, и если оно присутствует, то это указывает на время истечения срока действия токена. Если у токена есть действительная подпись, но она превысила отметку времени истечения срока действия, то такая подпись будет считаться недействительной. Для функции сброса пароля я собираюсь дать этим токенам 10 минут жизни.


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


Поскольку эти токены принадлежат пользователям, я собираюсь написать функции генерации и проверки токена как методы в модели User:


from time import time
import jwt
from app import app

class User(UserMixin, db.Model):
    # ...

    def get_reset_password_token(self, expires_in=600):
        return jwt.encode(
            {'reset_password': self.id, 'exp': time() + expires_in},
            app.config['SECRET_KEY'], algorithm='HS256').decode('utf-8')

    @staticmethod
    def verify_reset_password_token(token):
        try:
            id = jwt.decode(token, app.config['SECRET_KEY'],
                            algorithms=['HS256'])['reset_password']
        except:
            return
        return User.query.get(id)

Функция get_reset_password_token() генерирует токен JWT в виде строки. Обратите внимание, что decode('utf-8') необходим, потому что функция jwt.encode() возвращает токен в виде последовательности байтов, но в приложении удобнее иметь токен в виде строки.


Функция проверки verify_reset_password_token() является статическим методом, что означает, что он может быть вызван непосредственно из класса. Статический метод похож на метод класса, с той лишь разницей, что статические методы не требуют создавать экземпляр класса. Если попроще, то отсутствует первый аргумент self. Этот метод принимает токен и пытается его декодировать, вызывая функцию jwt.decode() PyJWT. Если токен не может быть проверен или истек его срок, будет вызвано исключение, и в этом случае я перехвачу его, чтобы предотвратить последствия ошибки, а затем возвращу None. Если токен действителен, тогда значение ключа reset_password из полезной нагрузки токена является идентификатором пользователя, поэтому я могу загрузить пользователя и вернуть его на страницу.


Отправка электронной почты для сброса пароля


Теперь, когда у меня есть токены, я могу сгенерировать электронные письма для сброса пароля. Функция send_password_reset_email() зависит от функции send_email(), которую я написал выше.


from flask import render_template
from app import app

# ...

def send_password_reset_email(user):
    token = user.get_reset_password_token()
    send_email('[Microblog] Reset Your Password',
               sender=app.config['ADMINS'][0],
               recipients=[user.email],
               text_body=render_template('email/reset_password.txt',
                                         user=user, token=token),
               html_body=render_template('email/reset_password.html',
                                         user=user, token=token))

Интересной частью этой функции является то, что текст и содержимое HTML для электронных писем генерируются из шаблонов с использованием знакомой функции render_template(). Шаблоны принимают пользователя и токен в качестве аргументов, так что может генерироваться персонализированное сообщение электронной почты. Вот текстовый шаблон для сброса пароля:


Дорогой наш {{ user.username }},

ты забыл пароль? И похоже, не впервый раз. Бывает!

Чтобы сбросить пароль, жмакни на следующую ссылку:

{{ url_for('reset_password', token=token, _external=True) }}

Если ты не запрашивал сброс пароля, просто забей на это сообщение.

С уважением,

Команда Microblog

Или вот так, поприличней, в HTML-версии почти того же письма с более приличным текстом:


<p>Dear {{ user.username }},</p>
<p>
    To reset your password
    <a href="{{ url_for('reset_password', token=token, _external=True) }}">
        click here
    </a>.
</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('reset_password', token=token, _external=True) }}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p>
<p>The Microblog Team</p>

Обратите внимание, что маршрут reset_password, на который ссылается вызов url_for() в этих двух шаблонах электронной почты, еще не существует, он будет добавлен в следующем разделе.


Сброс пароля пользователя


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


from app.forms import ResetPasswordForm

@app.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    user = User.verify_reset_password_token(token)
    if not user:
        return redirect(url_for('index'))
    form = ResetPasswordForm()
    if form.validate_on_submit():
        user.set_password(form.password.data)
        db.session.commit()
        flash('Your password has been reset.')
        return redirect(url_for('login'))
    return render_template('reset_password.html', form=form)

В этой функции просмотра я сначала удостоверяюсь, что пользователь не вошел в систему, а затем я определяю, кто пользователь, вызывая метод проверки токена в классе User. Этот метод возвращает пользователя, если токен действителен, или None, если нет. Если токен недействителен, я перенаправляюсь на домашнюю страницу index.


Если токен действителен, то я представляю пользователю вторую форму, в которой запрашивается новый пароль. Эта форма обрабатывается таким же образом, как и предыдущие формы, и в результате удачной отправки формы я вызываю метод set_password() класса User, чтобы изменить пароль, а затем перенаправить на страницу входа, где теперь пользователь может войти.


Вот класс ResetPasswordForm:


class ResetPasswordForm(FlaskForm):
    password = PasswordField('Password', validators=[DataRequired()])
    password2 = PasswordField(
        'Repeat Password', validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('Request Password Reset')

А это соответствующий шаблон HTML:


{% extends "base.html" %}

{% block content %}
    <h1>Reset Your Password</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.password.label }}<br>
            {{ form.password(size=32) }}<br>
            {% for error in form.password.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password2.label }}<br>
            {{ form.password2(size=32) }}<br>
            {% for error in form.password2.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

Вот сейчас функция сброса пароля завершена. Давайте, попробуйте.


Асинхронные сообщения


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


Хотелось бы, чтобы функция send_email() была асинхронной. Что это значит? Это означает, что при вызове этой функции задача отправки электронной почты запланирована в фоновом режиме, освобождая send_email() для немедленного возврата, чтобы приложение могло продолжать работать одновременно с отправляемым электронным письмом.


У Python есть поддержка для запуска асинхронных задач, фактически более чем одним способом. Могут выполняться поточные, и многопроцессорные (threading и multiprocessing) модули. Запуск фонового потока для отправленного сообщения намного менее ресурсоемкий, чем запуск совершенно нового процесса, поэтому я собираюсь пойти именно таким путем:


from threading import Thread
# ...

def send_async_email(app, msg):
    with app.app_context():
        mail.send(msg)

def send_email(subject, sender, recipients, text_body, html_body):
    msg = Message(subject, sender=sender, recipients=recipients)
    msg.body = text_body
    msg.html = html_body
    Thread(target=send_async_email, args=(app, msg)).start()

Функция send_async_email теперь работает в фоновом потоке, поскольку вызывается через класс Thread() в последней строке send_email(). Отправка по электронной почте теперь будет выполняться в отдельном потоке, и когда процесс завершится, поток завершится и очистится. Если вы настроили и используете настоящий почтовый сервер, вы обязательно заметите улучшение скорости, когда вы нажмете кнопку «Отправить» в форме запроса сброса пароля.


Вероятно, вы ожидали, что в поток будет отправлен только аргумент msg, но, как видно в коде, я также отправляю экземпляр app. При работе с потоками есть важный аспект дизайна Flask, который нужно иметь в виду. Flask использует contexts, чтобы избежать необходимости передавать аргументы через функции. Я не собираюсь подробно останавливаться на этом, но знаю, что существуют два типа контекстов, контекст приложения и контекст запроса (application context и request context). В большинстве случаев эти контексты автоматически управляются инфраструктурой, но когда приложение запускает пользовательские потоки, контексты для этих потоков могут потребовать ввода вручную.


Существует множество расширений, для которых требуется, чтобы контекст приложения работал, потому что это позволяет им найти экземпляр приложения Flask без его передачи в качестве аргумента. Причина, по которой многие расширения должны знать экземпляр приложения, заключается в том, что они имеют конфигурацию, хранящуюся в объекте app.config. Это как раз ситуация с Flask-Mail. Метод mail.send() должен получить доступ к значениям конфигурации для почтового сервера, и это может быть сделано только зная, что такое app. Контекст приложения, созданный с вызовом with app.app_context(), делает экземпляр приложения доступным через переменную current_app из Flask.


<<< предыдущая следующая >>>