配置在应用中如何向用户发送电子邮件,以及如何在电子邮件支持之上构建密码重置功能。
Flask-mail简介
用pip安装Flask-mail:
(microblog) D:\pythonProgram\PycharmProjects\microblog>pip install flask-mail
密码重置链接将包含有一个安全令牌。 为了生成这些令牌,使用JSON Web Tokens,它也有一个流行的Python包:
(microblog) D:\pythonProgram\PycharmProjects\microblog>pip install pyjwt
Flask-Mail插件是通过app.config对象来配置,配置参数在前期已配置过,配置如下:
config.py:
class Config(object):
#mail configure
MAIL_SERVER = "smtp@qq.com" #'smtp@qq.com'
MAIL_PORT = 587 #465不好用,用flask_mail测试不通过 587
MAIL_USE_TLS = True
MAIL_USERNAME = '********'
MAIL_PASSWORD = '********'
MAIL_DEFAULT_SENDER = ('Grey Li','********@qq.com')
ADMINS=['*******@dingtalk.com','*******@qq.com']
Flask应用创建之后创建一个邮件实例。 mail是类Mail的一个实例:app/init.py:
# ...
from flask_mail import Mail
app = Flask(__name__)
# ...
mail = Mail(app)
Flask-mail的使用
运行flask shell以激活Python,然后运行下面的命令:
(microblog) D:\pythonProgram\PycharmProjects\microblog>flask shell
[2021-07-18 09:54:53,892] INFO in __init__: Microblog startup
Python 3.8.5 (tags/v3.8.5:580fbb0, Jul 20 2020, 15:57:54) [MSC v.1924 64 bit (AMD64)] on win32
App: app [production]
Instance: D:\pythonProgram\PycharmProjects\microblog\instance
>>> from flask_mail import Message
>>> from app import mail
>>> msg=Message('test subject',recipients=app.config['ADMINS'],body='text body',html='<h1>HTML BODY</h1>')
>>> mail.send(msg)
现在已经可以接收到邮件了!
开始邮件验证
从编写一个邮件工具函数开始:定义在一个新的模块中app/mail.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)
在注册页面增加忘记密码链接:app/templates/auth/login.html:
<p>
Forgot Your Password?
<a href="{{ url_for('reset_password_request') }}">Click to Reset It</a>
</p>
点击链接后出现输入邮箱地址表单。需要增加一个表单类:app/forms/email.py:
from flask_wtf import FlaskForm
from wtforms import StringField,SubmitField
from wtforms.validators import DataRequired,Email
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
相应的html模板:app/templates/reset_password_request.html:
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Reset password</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %}
视图处理函数app/views/auth.py:
from app.forms import ResetPasswordRequestForm
from app.mail import send_password_reset_email
#....
@auth.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('reset_password_request.html',
title='Reset Password', form=form)
注意,在这里我们使用了一个帮助函数send_password_reset_email()。
使用邮件我们需要密码重置令牌的支持。
密码重置令牌
生成的链接中包含令牌,它将在允许密码变更之前被验证,以证明请求重置密码的用户是通过访问重置密码邮件中的链接而来的。。JSON Web Token(JWT)是这类令牌处理的流行标准。 JWTs的优点是它是自成一体的,不但可以生成令牌,还提供对应的验证方法。
作用pip安装PyJWT库:
(microblog) D:\pythonProgram\PycharmProjects\microblog>pip install PyJWT
Collecting PyJWT
Using cached PyJWT-2.1.0-py3-none-any.whl (16 kB)
Installing collected packages: PyJWT
Successfully installed PyJWT-2.1.0
通过Python shell来学习一下:
>>>token=jwt.encode({'a':'b'},'my-se',algorithm='HS256')
>>>token
>>>'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiJ9.ZgomC0K_NDDUJ9djFsM2r-2aqpRHUHsBH1sDGUyhkso'
>>>jwt.decode(token,'my-se',algorithms=['HS256'])
{'a': 'b'}
>>>token.encode('utf-8')
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiJ9.ZgomC0K_NDDUJ9djFsM2r-2aqpRHUHsBH1sDGUyhkso'
>>>from time import time
>>>time()
1627627978.6384175
>>>token=jwt.encode({'a':'b','exp':time()+30},'my-se',algorithm='HS256')
>>>token
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiIsImV4cCI6MTYyNzYyODAxMy4zMDY2MDY1fQ.dsbX8M_IXsfHOzykzKuY7JWvTg_mGElx6lgz5UXiLxA'
>>>jwt.decode(token,'my-se',algorithms=['HS256'])
Traceback (most recent call last):
File "<input>", line 1, in <module>
File "D:\PycharmProjects\flask\microblog\lib\site-packages\jwt\api_jwt.py", line 119, in decode
decoded = self.decode_complete(jwt, key, algorithms, options, **kwargs)
File "D:\PycharmProjects\flask\microblog\lib\site-packages\jwt\api_jwt.py", line 106, in decode_complete
self._validate_claims(payload, merged_options, **kwargs)
File "D:\PycharmProjects\flask\microblog\lib\site-packages\jwt\api_jwt.py", line 142, in _validate_claims
self._validate_exp(payload, now, leeway)
File "D:\PycharmProjects\flask\microblog\lib\site-packages\jwt\api_jwt.py", line 177, in _validate_exp
raise ExpiredSignatureError("Signature has expired")
jwt.exceptions.ExpiredSignatureError: Signature has expired
这些令牌属于用户,因此我将在User模型中编写令牌生成和验证的方法:app/models/user.py:
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').encode('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令牌。verify_reset_password_token()是一个静态方法,这意味着它可以直接从类中调用。这个方法需要一个令牌,并尝试通过调用PyJWT的jwt.decode()函数来解码它。
如果令牌不能被验证或已过期,将会引发异常,在这种情况下,我会捕获它以防止出现错误,然后将None返回给调用者。
如果令牌有效,那么来自令牌有效负载的reset_password的值就是用户的ID,所以我可以加载用户并返回它。
发送密码重置电子邮件
生成辅助函数send_password_reset_email(),依赖于send_email()函数。app/mail.py:
from flask_mail import Message
from flask import render_template
from app import mail,app
#....
#how to use mail
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',
user=user, token=token),
html_body=render_template('email/reset_password.html',
user=user, token=token))
电子邮件的文本和HTML内容是使用熟悉的render_template()函数从模板生成的。 模板接收用户和令牌作为参数,以便可以生成个性化的电子邮件消息。 以下是重置密码电子邮件的文本模板:app/templates/email/reset_password:
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
HTML版本:app/templates/email/reset_password.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>
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/views/auth.py:
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)
引用的ResetPasswordForm类:app/forms/email.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/reset_password.html:
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Reset your password</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %}
密码重置功能实现,现在可以试试了!
异步电子邮件
发送电子邮件功能要实现异步功能,不然你会失望群发邮件时的等待时间。现在看一下如何实现异步功能吧!使用pythin中的threading模块,在app/email.py中定义:
from flask_mail import Message
from flask import render_template
from app import mail,app
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,recipients=recipients)
msg.body=text_body
msg.html=html_body
Thread(target=send_async_email,args=(app,msg)).start()
#how to use mail
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',
user=user, token=token),
html_body=render_template('email/reset_password.html',
user=user, token=token))
send_async_email函数现在运行在后台线程中,它通过send_email()的最后一行中的Thread()类来调用。
Flask使用上下文来避免必须跨函数传递参数。有两种类型的上下文,即应用上下文和请求上下文。这些上下文由框架自动管理,但是当应用启动自定义线程时,可能需要手动创建这些线程的上下文。 许多Flask插件需要应用上下文才能工作,因为这使得他们可以在不传递参数的情况下找到Flask应用实例。这些插件需要知道应用实例的原因是因为它们的配置存储在app.config对象中,这正是Flask-Mail的情况。mail.send()方法需要访问电子邮件服务器的配置值,而这必须通过访问应用属性的方式来实现。 使用 with app.app_context()调用创建的应用上下文使得应用实例可以通过来自Flask的current_app变量来进行访问。