The Flask Mega-Tutorial 之 Chapter 10: Email Support

小引

  • 很多网站都有给用户发 email 的设置,很常规的一个目的是解决 authentication 相关的问题。
  • 本节将添加 email support,基于此添加 password reset feature。
  • 当 user 忘掉 password 时,可以选择 reset,app 据此会给 user 发送一封带有 crafted link 的 email,user 点击链接,转到 reset 页面,进行 reset。

Introduction to Flask-Mail

1、引入两个 extension,分别是 Flask-Mailpyjwt

  • Flask-Mail, 用于发送邮件
  • pyjwt, JSON Web Tokens 的一个package,用于生成 secure token,此 token 将被添加到邮件中的 password reset link 中

(venv) $ pip install flask-mail
(venv) $ pip install pyjwt


2、配置 Flask-Mail

和其他 Flask extensions 一样,需要在 flask application instance 后,创建对应的 instance:
app/__init__.py: Flask-Mail instance.

# ...
from flask_mail import Mail

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


3、发送邮件的两种方式

  • Emulated Email Server (常用于testing 验证)

在第一个 terminal 运行 flask run,在第二个 terminal 运行如下命令:

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


注:运行之前,须完成 environment variable 的设置

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


  • Real Email Server

config.py 中已在chapter 7 Error Handling 完成设置环境变量:
MAIL_SERVERMAIL_PORTMAIL_USE_TLSMAIL_USERNAMEMAIL_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>

注:Gmail 会阻止第三方应用通过它的服务器发送邮件,需要 explicitly allow “less secure apps” access to your Gmail account. 设置见此


Flask-Mail Usage

启动 flask shell,运行以下命令:

>>> from flask_mail import Message
>>> from app import mail
>>> msg = Message('test subject', sender=app.config['ADMINS'][0], recipients=['your-email'])
>>> msg.body = 'text body'
>>> msg.html = '<h1>HTML body</h1>'
>>> mail.send(msg)
  • 自 flask_mail 引入 Message,用于封装邮件信息
  • 自 app 引入 mail(即 mail = Mail(app)
  • Message(subject, sender, recipients)中,注意 sender 为 app.config[‘ADMINS’] 邮件列表的第一项
  • Message(subject, sender, recipients)中,注意 recipients 为 list
  • 信息类似属性方式,绑定到 msg上



A Simple Email Framework

封装用于发送邮件的 helper function

app / email.py: email sending wrapper function.

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 另有 features,如 Cc and Bcc lists 见此


Requesting a Password Reset

1、请求密码重置,将链接入口设置在登录页 login.html 底部

app / templates / login.html: password reset link in login

<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
    <p>
        Forgot Your Password?
        <a href="{{ url_for('reset_password_request') }}">Click to Reset It</a>
    </p>
{% endblock %}

login 页面出现密码重置链接(如下)
这里写图片描述


2、点击上述 login 页面的密码重置链接,user 会收到 new web form 用于填写自己的 email,以便 as a way to initiate the password reset process

app / forms.py: reset password request form.

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


3、 Reset password request 模板

{% 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 %}

这里写图片描述


4、创建路由 reset_password_request

app/routes.py: reset password request view function.

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)


  • 如为 logged user,则定向至 index 页
  • 如server 收到初次 GET 请求(或提交表单未通过验证), 则向 browser 发送空的 ‘reset_password_request.html’
  • 提交的表单验证通过,根据填写的 email 查询 db 中的 user。如果有对应的 user,则通过 send_password_reset_email() 向其发送 email;如没有,则不发送。
  • 无论提交的表单中的 email 在 db 中存在与否,都会 flash信息 ,后重定向至 登录页 login。

This is so that clients cannot use this form to figure out if a given user is a member or not.

这里写图片描述


Password Reset Tokens

  • 在使用 send_password_reset_email() 之前,我们需要找到生成 password reset link (即邮件中用于重置密码的 link) 的方法。当 user 点击此 link 后,可跳转到 password reset 页面,进行重置。
  • 为保证这个 password reset link 的有效性,我们要在发送邮件时,加上 token。

    The tricky part of this plan is to make sure that only valid reset links can be used to reset an account’s password.

  • 这类 password reset links 会携带 token,必须 token 被验证后方能获得重置密码的权限。token 被验证,则证明 user 有 reset password request 时提交的 email 的账号权限,然后才能点击带有 token 的reset password link。

    The links are going to be provisioned with a token, and this token will be validated before allowing the password change, as proof that the user that requested the email has access to the email address on the account.

  • 常用的一种 token 是 JSON Web Token。用 JWT 的妙处是 self-contained,即可以完成自我验证,

    You can send a token to a user in an email, and when the user clicks the link that feeds the token back into the application, it can be verified on its own.

JWT 的用法如下:

>>> 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'}
  • 生成 token = jwt.encode(payload_dict, secret_key, algorithm),生成的是 bytes sequence
  • 解码 jwt.decode(token, secret_key, algorithms_dict)

1、 为 User 类添加 token 生成及验证 methods

app / models.py: reset password token methods.

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()payload_dict 中,利用 time() 设置 expiration 参数; 另一个元素为 ‘reset_password’: self.id
  • secret_key,采用 app.config[‘SECRET_KEY’]
  • jwt.encode()bytes sequence,用 decode('utf-8') 变成 string。
  • verify_reset_password_token()static method,第一个参数不为 clsself,像普通函数一样传参。
  • verify_reset_password_token() 通过 jwt.decode() 获取 token 的 payload_dict,然后调取其中的 [‘reset_password’] 元素,即 self.id
  • 如果 id 不存在,则返回 None;如存在,则调取对应的 user。



Sending a Password Reset Email

token 的生成方法已构建,现在基于 send_mail() 封装发送 password reset email 的函数 send_password_reset_email()


1、构建路由 send_password_reset_email()

app / email.py: send_password_reset_email function.

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))
  • 添加 token 的生成(利用 user 中的方法 get_reset_password_token()
  • 注意 recipients_dict 中 为 user.email, 即 reset_password_request() 中 根据 form 提交的 email 查到的 user (如有)的 email
@app.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
    #...
    form = ResetPasswordRequestForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user:
            send_password_reset_email(user)
    # ...
  • text_bodyhtml_body, 均采用了 render_template,注意 template 的位置为app/template/email/,并传入参数 usertoken


2、构建模板 reset_password.txtreset_password.html

2.1 app / templates / email / reset_password.txt: text for password reset email.

Dear {{ user.username }},

To reset your password click on the following link:

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

If you have not requested a password reset simply ignore this message.

Sincerely,

The Microblog Team

注: when _external=True is passed as argument, complete URLs are generated.

url_for('user', username='susan') 生成 /user/susan

url_for('user', username='susan', _external=True) 生成 http://localhost:5000/user/susan

2.2 app / templates / email / reset_password.html: HTML for password reset email.

<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>


这里写图片描述


Resetting a User Password

收到邮件后,点击重置链接,跳转至 reset_password 页面,进行 重置。

1、app / routes.py: password reset view function.

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)
  • 利用 Userstaticmethod verify_reset_password_token() 验证 token。
  • 如 token 为 False,证明 links 非真(或expires失效),user 为 None,则直接定向至 index;如 True,则 user 存在(即为发送 request 时提交的 email 对应的 user)。
  • 初次 GET 请求,或表单填写有误,则向 browser 发送 空白 form。
  • 利用表单,重置 password,写入 db,最终定向至 login 页面,用于 user 用新密码登录。

点击链接,返回密码重置页面
这里写图片描述


密码重置成功,返回 login 页面(如下),
这里写图片描述


用重置密码登录
这里写图片描述

2、表单构建 ResetPasswordForm

app / forms.py: password reset form.

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


3、模板构建 reset_password.html (注:与 app/templates/email 中的 模板同名)

app / templates / reset_password.html: password reset form template.

{% 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 %}



Asynchronous Emails

send_email() 为同步函数,导致封装后的 send_password_reset_email() 也为同步。
为提高处理速度,采用并发处理,并发可以用 multiprocessing 或者 threading。
考虑到 multiprocessing 的 more resource intensive,采用 threading。

app/email.py: Send emails asynchronously.

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() 利用 app.app_context(),生成 application context (另一类为 request contenxt)。
  • application context 可以利用 Flask 的变量 current_app 使得 application instance 变得 accessible。
  • send_email() 最后创建了 一个 Thread,其中 target 即是 send_async_email,传入的 args 即为 target func 需要的 (app, msg),最后启动线程 Thread().start()


1、当我们调用 send_password_reset_email() 时,会调用封装其中的 send_email(),进而启动一个Thread,最终调用的 send_async_email 就会在这个 background thread 中运行。

2、注意,Thread 中传递的参数不止 msg,还有 app

3、在 Flask 中涉及 threads 时,必须记住,Flask 用 contexts 来避免在跨函数传参。
有两类 contexts,即 application context 和 request context。
通常情况下,这些 contexts 由 framework 自动管理;但是,当 application 启动 自定义 threads 时,这类threads 所需的 contexts 必须手动创建。

When working with threads there is an important design aspect of Flask that needs to be kept in mind. Flask uses contexts to avoid having to pass arguments across functions. I’m not going to go into a lot of detail on this, but know that there are two types of contexts, the application context and the request context. In most cases, these contexts are automatically managed by the framework, but when the application starts custom threads, contexts for those threads may need to be manually created.

4、很多 extensions 需要 application instance,因为它们的配置多放在 app.config 中。Flask-Mail 正属于此种情形,send_email() 中的 mail.send() 必须 access 到 app.config 中的 mail server,方能发送邮件,所以必须知道 application instance。

5、通过 with app.app_context(),可创建 application context; 这个 application context 即可使 application instance 变得 accessible(通过 Flask 的变量 current_app)。这样,send_async_email() 中的 mail.send() 即可access 到 application instance,进而拿到 app.config 中的 mail server 信息。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章