在第十章,我们将讨论应用如何向你的用户发送电子邮件,以及如何在电子邮件支持之上构建密码重置功能。
现在,应用在数据库方面做得相当不错,所以在本章中,我想抛开这个主题,开始添加发送电子邮件的功能,这是大多数 Web 应用必需的另一个重要部分。
为什么应用需要发送电子邮件给用户? 原因很多,但其中一个常见的原因是解决与认证相关的问题。在本章中,我将为忘记密码的用户添加密码重置功能。当用户请求重置密码时,应用将发送包含特制链接的电子邮件。用户然后需要点击该链接才能访问设置新密码的表单。
Flask-Mail 简介
Flask 有一个名为 Flask-Mail
的流行插件,可以帮助我们很容易地实现邮件功能。 和往常一样,该插件是用 pip
安装的:
(venv) $ pip install flask-mail
密码重置链接将包含有一个安全令牌。为了生成这些令牌,我将使用 JSON Web Tokens
,它也有一个流行的 Python 包:
(venv) $ pip install pyjwt
完成安装后我们在 app\__init__.py
内实例化 flask-mail
,并与工厂函数绑定:
# app\__init__.py
from flask import Flask
from config import Config
from flask_mail import Mail
mail = Mail()
def create_app(config):
app = Flask(__name__)
app.config.from_object(config)
mail.init_app(app)
return app
接下来我们需要设置邮件模块的相关配置,需要配置 MAIL_SERVER
、MAIL_PORT
、MAIL_USE_TLS
、MAIL_USERNAME
和 MAIL_PASSWORD
几项。
配置可以写在 config.py
里:
# config.py
class Config(object):
# ...
MAIL_SERVER = 'smtp.qq.com'
MAIL_PORT = 465
MAIL_USE_SSL = True
MAIL_USE_TLS = False
MAIL_USERNAME = 'xxxxxx@qq.com'
MAIL_PASSWORD = 'xxxxxxxxxx' # 授权码,非邮箱密码
MAIL_SERVER
是使用的邮件服务,因为这里使用 QQ 邮箱作为测试,所以设定为 'smtp.qq.com'
,如使用其他邮箱请换成对应的配置。要使用第三方发送邮件功能需要先在邮箱设置里开启了 “POP3/SMTP 服务”和 “IMAP/SMTP 服务”,并获取了授权码。
以 QQ 邮箱为例,这样开始相关服务并获取授权码:
MAIL_PORT
是发送邮件端口;MAIL_USERNAME
是发送邮件使用的邮箱;MAIL_PASSWORD
为邮箱的授权码,并非邮箱的登陆密码。
在这里我们会发现 MAIL_USERNAME
和 MAIL_PASSWORD
两项可能会涉及和个人信息相关的敏感资料,如果把它直接写在 config.py
文件中,一把小心把该文件上次到 github 上了,就有个人资料外泄的风险。所以这两项我们在环境变量中来设置。
$ set MAIL_USERNAME=xxxxxxxxxx@qq.com
$ set MAIL_PASSWORD=xxxxxxxxxx
在配置文件里,我们通过读取环境变量来获得上述配置:
# config.py
class Config(object):
# ...
MAIL_SERVER = 'smtp.qq.com'
MAIL_PORT = 465
MAIL_USE_SSL = True
MAIL_USE_TLS = False
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
Flask-Mail 的使用
为了学习 Flask-Mail
如何工作,我将向你展示如何用 Python shell
发送电子邮件。那么,运行 flask shell
以激活 Python,然后运行下面的命令:
>>> from flask_mail import Message
>>> from app import mail
>>> msg = Message('test subject', sender='xxx@qq.com', recipients=['yyy@126.com'])
>>> msg.body = 'text body'
>>> mail.send(msg)
上面的代码片段将发送一个电子邮件到你在 recipients
参数中设置的电子邮件地址列表。 该电子邮件将具有纯文本和 HTML 版本。
如果想发送 HTML 版本,可以加上这样的代码:
>>> msg.html = '<h1>HTML body</h1>'
简单的电子邮件框架
现在我们把上一节中 shell 里的命令整合成一个函数。 我将把这个函数放在一个名为 app/email.py
的新模块中:
# app/email.py
from flask_mail import Message
from app import mail
def send_email(subject, sender, recipients, body):
msg = Message(
subject=subject,
body=body,
sender=sender,
recipients=recipients
)
mail.send(msg)
Flask-Mail
支持一些我不在这里使用的功能,如抄送和密件抄送列表。 如果你对这些选项感兴趣,请查阅 Flask-Mail 文档。
请求重置密码
我们先在登录页面提供一个忘记密码的链接:
# app\templates\auth\login.html
<p>
Forgot Your Password?
<a href="{{ url_for('auth.reset_password_request') }}">Click to Reset It</a>
</p>
我们期待这个链接有这些功能:当用户点击链接时,会出现一个 Web 表单,要求用户输入注册的电子邮件地址,以启动密码重置过程。 先来定义是表单类:
# app\auth\forms.py
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
定义相应的 HTML 模板:
# app\templates\auth\reset_password_request.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 %}
当然也需要一个视图函数来处理表单:
# app\auth\routes.py
from app.auth.forms import ResetPasswordForm
from app.email import send_password_reset_email
@auth_routes.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
if current_user.is_authenticated:
return redirect(url_for('main.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('auth.login'))
return render_template(
'auth/reset_password_request.html',
title='Reset Password',
form=form
)
该视图函数与其他的表单处理视图函数非常相似。我们先确保用户没有登录,如果用户登录,那么使用密码重置功能就没有意义,所以我重定向到主页。
当表格被提交并验证通过,我使用表格中的用户提供的电子邮件来查找用户。 如果我找到用户,就发送一封密码重置电子邮件。这里使用send_password_reset_email()
函数来执行发送邮件的操作,该函数我们在下一节来定义。
电子邮件发送后,我会闪现一条消息,指示用户查看电子邮件以获取进一步说明,然后重定向回登录页面。 你可能会注意到,即使用户提供的电子邮件不存在,也会显示闪现该消息,因为我不想他人通过这个方法来知道某个电邮地址是否已经注册。
密码重置令牌
在实现 send_password_reset_email()
函数之前,我需要一种方法来生成密码重置链接,它将被通过电子邮件发送给用户。当链接被点击时,将为用户展现设置新密码的页面。这里我们就需要解决一系列问题,首先我们需要确保打开这个重置链接的跟刚才申请重置密码的是同一用户,而且这个链接还需要验证时间的有效性,比如只能在生成后的 10 分钟内才能有效。
生成的链接中会包含令牌,它将在允许密码变更之前验证用户,以证明请求重置密码的用户是通过访问重置密码邮件中的链接而来的。JSON Web Token(JWT)是这类令牌处理的流行标准。 JWTs 的优点是它是自成一体的,不但可以生成令牌,还提供对应的验证方法。
如何运行 JWTs?让我们通过 Python shell 来学习一下:
>>> import jwt
>>> token = jwt.encode({'a': 'b'}, 'my-secret', algorithm='HS256')
>>> token
'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
字段是 JWTs 的标准,如果它存在,则表示令牌的到期时间。如果一个令牌有一个有效的签名,但是它已经过期,那么它也将被认为是无效的。对于密码重置功能,我会给这些令牌 10 分钟的有效期。
以下是一个重置密码链接的例子:
http://127.0.0.1:5000/reset_password/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1Nxxxx
可见当用户点击电子邮件链接时,令牌将被作为 URL 的一部分发送回应用,处理这个 URL 的视图函数首先要做的就是验证它。如果签名是有效的,则可以通过存储在有效载荷中的 ID 来识别用户。 一旦得知用户的身份,应用可以重置一个新的密码。
在 User
模型中编写令牌生成和验证的方法:
# app\models.py
from time import time
import jwt
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},
current_app.config['SECRET_KEY'],
algorithm='HS256'
)
@staticmethod
def verify_reset_password_token(token):
try:
id = jwt.decode(
token,
current_app.config['SECRET_KEY'],
algorithms=['HS256'])['reset_password']
except:
return
return User.query.get(id)
get_reset_password_token()
函数以字符串形式生成一个 JWT 令牌。
verify_reset_password_token()
是一个静态方法,可以直接从类中调用。 静态方法与类方法类似,唯一的区别是静态方法不会接收类作为第一个参数。
这个方法需要一个令牌,并尝试通过调用 PyJWT
的 jwt.decode()
函数来解码它。 如果令牌不能被验证或已过期,将会引发异常,在这种情况下,我会捕获它以防止出现错误,然后将 None
返回给调用者。 如果令牌有效,那么来自令牌有效负载的 reset_password
的值就是用户的 ID
,所以我可以加载用户并返回它。
发送密码重置电子邮件
现在有了令牌,可以编写生成密码重置电子邮件的send_password_reset_email()
函数。
# app\email.py
from flask_mail import Message
from flask import render_template, current_app
from app import mail
def send_email(subject, sender, recipients, body):
msg = Message(
subject=subject,
body=body,
sender=sender,
recipients=recipients
)
mail.send(msg)
def send_password_reset_email(user):
token = user.get_reset_password_token()
send_email(
subject = '[Microblog] Reset Your Password',
sender = current_app.config['MAIL_USERNAME'],
recipients = [user.email],
body = render_template('email/reset_password.txt', user=user, token=token)
)
这个函数中有趣的部分是电子邮件的文本使用熟悉的 render_template()
函数从模板生成的,而模板文件是 txt
格式的文本,因为 Jinja2 语法在 txt
中也能有效运行。这样我们就可以生成个性化的电子邮件消息。以下是重置密码电子邮件的文本模板:
# app\templates\email\reset_password.txt
Dear {{ user.username }},
To reset your password click on the following link:
{{ url_for('auth.reset_password', token=token, _external=True) }}
If you have not requested a password reset simply ignore this message.
Sincerely,
The Microblog Team
这里的 url_for
的参数 'auth.reset_password'
所代表的视图函数暂时还未编写,我们在下一节来完成。
在这个模板中,url_for()函数中的 _external=True
参数我们第一次见到。不带这个参数的情况下,url_for()
函数生成的是相对路径。例如 url_for('user', username='susan')
生成 /user/susan
。这样的路径在本站的 Web 页面中使用是完全足够的,因为其余的协议、主机、端口部分,会沿用本站的当前值。一旦通过邮件发送时,就脱离了这个上下文,这时候就需要 URL 的完全路径了。一旦传入 _external=True
参数给 url_for()
函数,就会生成一个 URL 的完全路径。本处示例为 http://localhost:5000/user/susan
。如果应用被部署到一个域名下,则协议、主机名和端口会发生对应的变化。
重置用户密码
当用户点击电子邮件链接时,会触发与此功能相关的第二个路由。 这是密码重置视图函数:
# app\auth\routes.py
from app.auth.forms import ResetPasswordForm
@auth_routes.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
user = User.verify_reset_password_token(token)
if not user:
return redirect(url_for('main.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('auth.login'))
return render_template('auth/reset_password.html', form=form)
在这个视图函数中,我首先确保用户没有登录,然后通过调用 User
类的令牌验证方法来确定用户是谁。如果令牌有效,则此方法返回用户;如果不是,则返回 None
,并将重定向到主页。
如果令牌是有效的,那么我向用户呈现第二个表单,需要用户其中输入新密码。这个表单的处理方式与以前的表单类似,表单提交验证通过后,我调用 User
类的 set_password()
方法来更改密码,然后重定向到登录页面,以便用户登录。
这是 ResetPasswordForm
表单类,我们用两次输入密码来确保用户输入密码的正确性:
# app\auth\forms.py
class ResetPasswordForm(FlaskForm):
password = PasswordField('Password', validators=[DataRequired()])
password2 = PasswordField(
'Repeat Password',
validators=[DataRequired(),
EqualTo('password')]
)
submit = SubmitField('Request Password Reset')
这是相应的 HTML 模板:
# app\templates\auth\reset_password.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
模块都可以做到这一点。为发送电子邮件启动一个后台线程,比开始一个全新的进程需要的资源少得多,所以我打算采用这种方法:
# app\email.py
from flask_mail import Message
from flask import render_template, current_app
from app import mail
from threading import Thread
def send_async_email(app, msg):
with app.app_context():
mail.send(msg)
def send_password_reset_email(user):
token = user.get_reset_password_token()
msg = Message(
subject = '[Microblog] Reset Your Password',
body = render_template('email/reset_password.txt', user=user, token=token),
sender = current_app.config['MAIL_USERNAME'],
recipients = [user.email]
)
mail.send(msg)
Thread(
target=send_async_email,
args=(current_app._get_current_object(), msg)
).start()
send_async_email
函数现在运行在后台线程中,它通过 send_email()
的最后一行中的 Thread()
类来调用。 有了这个改变,电子邮件的发送将在线程中运行,并且当进程完成时,线程将结束并自行清理。 如果你已经配置了一个真正的电子邮件服务器,当你按下密码重置请求表单上的提交按钮时,肯定会注意到访问速度的提升。
你可能预期只有 msg
参数会被发送到线程,但正如你在代码中所看到的那样,我也传入了应用实例。 使用线程时,需要牢记 Flask 的一个重要设计方面。 Flask 使用上下文来避免必须跨函数传递参数。我不打算详细讨论这个问题,但是需要知道的是,有两种类型的上下文,即应用上下文和请求上下文。 在大多数情况下,这些上下文由框架自动管理,但是当应用启动自定义线程时,可能需要手动创建这些线程的上下文。
许多 Flask 插件需要应用上下文才能工作,因为这使得他们可以在不传递参数的情况下找到 Flask 应用实例。这些插件需要知道应用实例的原因是因为它们的配置存储在 app.config
对象中,这正是 Flask-Mail
的情况。
mail.send()
方法需要访问电子邮件服务器的配置值,而这必须通过访问应用属性的方式来实现。 使用 with app.app_context()
调用创建的应用上下文使得应用实例可以通过来自 Flask 的 current_app
变量来进行访问。
本文源码:https://github.com/SingleDiego/Flask-Tutorial-Source-Code/tree/SingleDiego-patch-10