配套視頻教程
本章將學習應用程序如何向用戶發送電子郵件,以及如何在電子郵件支持下構建密碼修改功能。
目前在數據庫方面做得很好了,所以在本章將脫離該主題並添加大多數Web應用程序需要另一個重要部分,即 發送電子郵件。
為何需要向用戶發送電子郵件?原因很多,一個常見的原因是解決與身份驗證相關的問題。在本章中,將為忘記密碼的用戶添加密碼重置功能。當用戶請求重置密碼時,應用程序將發送包含特制鏈接的電子郵件。然后用戶需要單擊該鏈接以訪問用於設置密新密碼的表單。
Flask-Mail簡介
在發送電子郵件方面,Flask有一個流行擴展,名為Flask-Mail,可讓這個任務變得很簡單。安裝:pip install flask-mail,版本0.9.1
附帶安裝blinker,版本1.4,它提供一個快速的調度系統,允許任何數量的相關方訂閱事件,或“信號”。
(venv) D:\microblog>pip install flask-mail
Collecting flask-mail
Downloading https://files.pythonhosted.org/packages/05/2f/6a545452040c2556559779db87148d2a85e78a26f90326647b51dc5e81e9/Flask-Mail-0.9.1.tar.gz (45kB)
100% |████████████████████████████████| 51kB 41kB/s
Requirement already satisfied: Flask in d:\microblog\venv\lib\site-packages (from flask-mail)
Collecting blinker (from flask-mail)
Downloading https://files.pythonhosted.org/packages/1b/51/e2a9f3b757eb802f61dc1f2b09c8c99f6eb01cf06416c0671253536517b6/blinker-1.4.tar.gz (111kB)
100% |████████████████████████████████| 112kB 26kB/s
Requirement already satisfied: Werkzeug>=0.14 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: itsdangerous>=0.24 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: Jinja2>=2.10 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: click>=5.1 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: MarkupSafe>=0.23 in d:\microblog\venv\lib\site-packages (from Jinja2>=2.10->Flask->flask-mail)
Installing collected packages: blinker, flask-mail
Running setup.py install for blinker ... done
Running setup.py install for flask-mail ... done
Successfully installed blinker-1.4 flask-mail-0.9.1
密碼重置鏈接中將包含安全令牌。為生成這些令牌,得使用JSON Web令牌,它有一個流行的Python包 pyjwt:
(venv) D:\microblog>pip install pyjwt
Collecting pyjwt
Downloading https://files.pythonhosted.org/packages/93/d1/3378cc8184a6524dc92993090ee8b4c03847c567e298305d6cf86987e005/PyJWT-1.6.4-py2.py3-none-any.whl
Installing collected packages: pyjwt
Successfully installed pyjwt-1.6.4
Flask-Mail擴展是從app.config對象配置。
和大多數Flask擴展一樣,需要在創建Flask應用程序之后立即創建實例。下方是創建一個Mail類
對象:
#...
from flask_login import LoginManager
from flask_mail import Mail
app = Flask(__name__)
#...
login.login_view = 'login'
mail = Mail(app)
#...
Flask-Mail用法
為了解Flask-Mail是如何工作的,下方將展示在Python shell發送電子郵件,用flask shell
啟動Python,運行如下命令:
(venv) D:\microblog>flask shell
[2018-08-20 11:40:14,298] INFO in __init__: Microblog startup
Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
App: app [production]
Instance: D:\microblog\instance
>>> from flask_mail import Message
>>> from app import mail
>>> msg = Message('test subject',sender=app.config['ADMINS'][0],recipients=['jemwah96@gmail.com'])
>>> msg.body = 'text body'
>>> msg.html = '<h1>HTML body</h1>'
>>> mail.send(msg)
上述代碼將發送電子郵件到recipients參數
的電子郵件地址列表。將發送者作為第一個配置的管理員(即在第7章中添加的配置變量)。電子郵件包含純文本和HTML版本,因此根據電子郵件客戶端的配置方式,可能會看到其中一個。
如上所見,很簡單。現在將電子郵件集成到應用程序中。
簡單的電子郵件架構
首先,編寫一個發送電子郵件的輔助函數,它是上一節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文檔。
請求重置密碼
用戶可以選擇重置密碼。為此,在登錄頁面中添加一個鏈接: app/templates/login.html:登錄表單中的密碼重置鏈接
#...
<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 %}
當用戶單擊這個鏈接時,將出現一個新的Web表單,用於請求用戶的電子郵件地址作為啟動密碼重置過程的方法。這個表單類如下:
app/forms.py:重置密碼 表單
#...
class RegistrationForm(FlaskForm):
#...
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
#...
對應的HTML模板如下:
app/templates/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/routes.py:重置密碼請求的視圖函數
#...
from app.forms import ResetPasswordRequestForm
from app.email import send_password_reset_email
#...
def register():
#...
@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()
輔助函數,稍后展示。
發送電子郵件后,會閃爍一條消息,指示用戶查找電子郵件以獲取進一步說明,然后重定向回 /login
頁面。注意到,即使用戶提供的電子郵件未知,也會顯示閃爍消息。這樣的話,客戶端將無法使用這個表單來確定給定用戶是否為成員。
密碼重置令牌
在實現send_password_reset_email()
函數之前,我們需要有一種方法來生成密碼請求鏈接。這是提供電子郵件發送給用戶的鏈接。點擊鏈接時,將向用戶顯示可以設置新密碼的頁面。這個計划棘手的部分是 確保只有有效的重置鏈接才可以用來重置賬戶的密碼。
生成的鏈接中會包含令牌,它將在允許密碼變更之前被驗證,以證明請求重置密碼的用戶是通過訪問重置密碼郵件中的鏈接而來的。JSON Web Token(JWT)
是這類令牌處理的流行標准。它的好處是本身是自成一體的,不僅可以生成令牌,還可以提供對應的驗證方法。
JWT
是如何工作的?通過Python shell會話將很容易理解:
C:\Users\Administrator>d:
D:\>cd d:\microblog\venv\scripts
d:\microblog\venv\Scripts>activate
(venv) d:\microblog\venv\Scripts>cd d:\microblog
(venv) d:\microblog>python
Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> 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'}
>>> quit()
(venv) d:\microblog>
上述{'a':'b'}
字典是一個將要被寫入這個令牌的實例有效負載。為了令牌安全,需要提供一個密鑰用於創建加密簽名。對於這個例子,用了字符串'my-secret'
,但是在應用程序中將使用配置中的SECRET_KEY
。algorithm
參數指定令牌如何被生成,HS256
算法是最常用的。
如上所見,生成的令牌是一個長串字符。但不要認為這是一個加密令牌。令牌的內容,包括有效載荷,可被任何人輕松解碼(復制上述令牌,然后在JWT調試器中輸入它以查看其內容)。使令牌安全的是:有效載荷是簽名的。假如有人試圖在一個令牌中偽造或篡改有效載荷,那么這個簽名將無效,並且為了生成一個新簽名,需要密鑰。驗證令牌時,有效載荷的內容被解碼並返回給調用者。如果驗證了令牌的簽名,那么可以將有效載荷視為可信。
將用於密碼重置令牌的有效載荷格式為{'reset_password': user_id, 'exp': token_expiration}
。exp
字段是JWT的標准字段,如果存在,則表示令牌的到期時間。如果令牌具有有效簽名,但它已超過其到期時間戳,則它也將被視為無效。對於密碼重置功能,將給這些令牌提供10分鍾的有效期。
當用戶點擊通過電子郵件發送的鏈接時,這個令牌將作為URL的一部分發送會應用程序,處理這個URL的視圖函數首先要做的就是驗證它。如果簽名有效,則可以通過存儲在有效載荷中的ID來識別用戶。一旦知道了用戶的身份,應用程序就可以要求輸入新密碼並將其設置在用戶的賬戶上。
由於這些令牌屬於用戶,因此將在User模型
中編寫令牌生成和驗證的方法:
app/models.py:重置密碼令牌方法
#...
from time import time
import jwt
from app import app
#...
class User(UserMixin, db.Model):
# ...
def followed_posts(self):
#...
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()
是一個靜態方法,意味着它可以直接從類中調用。靜態方法類似於 類方法,唯一區別是靜態方法不接收類作為第一個參數。這個方法接受一個令牌並嘗試通過調用PyJWT的jwt.decode()
函數對其進行解碼。如果令牌無法驗證或過期,則會引發異常,在這種情況下,我們會捕獲它以防止錯誤,然后返回None
給調用者。如果令牌有效,則來自令牌的有效載荷的reset_password鍵
的值是用戶的ID,因此我能加載用戶並返回它。
發送密碼重置電子郵件
現在有了令牌,就可以生成密碼重置電子郵件。send_password_reset_email()
函數依賴send_mail()
方法(上述email.py模塊中寫的)。 app/email.py:發送密碼重置電子郵件函數
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['MAIL_USERNAME'],
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()函數從模板生成的。模板接收用戶和令牌作為參數,以便可以生成個性化電子郵件消息。以下是重置密碼電子郵件的文本模板:
app/templates/email/reset_password.txt:密碼重置電子郵件的文本
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:密碼重置電子郵件的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()
調用中引用的reset_password路由
還不存在,這將在下一節中添加。在兩個模板中調用url_for()
包含的_external=True
參數也是新的。默認情況下,由url_for()
生成的URL是相對URL,因此,例如url_for('user', username='susan')
調用將返回 /user/susan
。對於在web頁面中生成鏈接這通常足夠了,因為web瀏覽器從當前頁面中獲取URL的其余部分。但是,當通過電子郵件發送一個URL時,該上下文不存在,因此需要使用完全限定的URL。當_external=True
作為參數傳遞時,會生成完整的URL,因此前面的示例將返回http://localhost:5000/user/susan,或在域名上部署應用程序時的相應URL。
重置用戶密碼
當用戶點擊電子郵件鏈接時,將觸發與此功能關聯的第二個路由。這是密碼請求的視圖函數: app/routes.py:密碼重置的視圖函數
#...
from app.forms import ResetPasswordForm
#...
def reset_password_request():
#...
@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
。
如果令牌有效,那么我將向用戶顯示第二個表單,其中會請求新密碼。這個表單的處理方式與之前的表單類似,並且作為有效表單提交的結果,我調用User
的set_password()
方法去更改密碼,然后重定向到用戶現在可以登錄的登錄頁面。
下方是ResetPasswordForm
類:
app/forms.py:密碼重置表單
#...
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
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" %}
{% 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 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()
函數現在后台線程中運行,在send_email()
的最后一行通過Thread()類
調用。通過這個更改,電子郵件的發送將在線程中運行,並且當進程完成時,線程將結束並自行清理。如果配置了一個真實的電子郵件服務器,當按密碼重置請求表單上的提交按鈕時,會注意到速度的提高。
你可能希望只將msg參數
發送到線程,但是正如在代碼中看到的那樣,我也發送了應用程序實例 app
。使用線程時,需牢記Flask的一個重要設計方面。Flask使用上下文來 避免跨函數傳遞參數。在此不詳說,但要知道有兩種類型的上下文,即 應用程序上下文、請求上下文。在大多數情況下,這些上下文由框架自動管理,但當應用程序啟動自定義線程時,可能需要手動創建這些線程的上下文。
有許多擴展需要應用程序上下文才能工作,因為這允許它們找到Flask應用程序實例而不將其作為參數傳遞。許多擴展需要知道應用程序實例的原因是 因為它們的配置存儲在app.config
對象中。這正是Flask-Mail的情況。mail.send()
方法需要訪問電子郵件服務器的配置值,而這只能通過應用程序是什么來完成。with app.app_context()
調用創建的應用程序上下文 使得應用程序實例可以通過來自Flask的current_app
變量 可訪問。
接下來使用163郵箱進行測試。
這個flask-mail中有個MAIL_PASSWORD的配置屬性,這里不是讓填你的郵箱登陸密碼的,而是填寫我們這一步即將獲得的授權碼
進入准備作為發件人的郵箱,點擊【設置|客戶端授權密碼】,這里點擊開啟,會要先驗證手機號,然后設置一個新密碼並記住它!
安裝flask-dotenv
pip install flask-dotenv
1)、項目根目錄下添加microblog.env文件:
MAIL_SERVER=smtp.163.com
MAIL_PORT=25
MAIL_USE_TLS=True
MAIL_USE_SSL=False
MAIL_USERNAME=songboriceboy2@163.com
MAIL_PASSWORD=客戶端授權密碼
2)、修改config.py中的配置項:
microblog/config.py:
#...
import os
basedir = os.path.abspath(os.path.dirname(__file__)) # 獲取當前.py文件的絕對路徑
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, 'microblog.env'))
class Config:
#...
MAIL_SERVER = os.environ.get('MAIL_SERVER')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS')
MAIL_USE_SSL = os.environ.get('MAIL_USE_SSL', 'false').lower() in ['true', 'on', '1']
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')#客戶端授權密碼
#...
3)、flask run
運行程序,效果:
(venv) D:\microblog>flask run
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: off
[2018-08-22 12:07:18,392] INFO in __init__: Microblog startup
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [22/Aug/2018 12:07:24] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:07:24] "GET /favicon.ico HTTP/1.1" 404 -
127.0.0.1 - - [22/Aug/2018 12:07:28] "GET /reset_password_request HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:07:36] "POST /reset_password_request HTTP/1.1" 302 -
127.0.0.1 - - [22/Aug/2018 12:07:36] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:07:56] "GET /reset_password/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyZXNldF9wYXNzd29yZCI6NiwiZXhwIjoxNTM0OTExNDU2LjM1NDI0MDd9.AczmZ5WjKX1Lu6Iv6w3a0tL9LtHs7HbXETbSZ5nqJuY HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:09:26] "POST /reset_password/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyZXNldF9wYXNzd29yZCI6NiwiZXhwIjoxNTM0OTExNDU2LjM1NDI0MDd9.AczmZ5WjKX1Lu6Iv6w3a0tL9LtHs7HbXETbSZ5nqJuY HTTP/1.1" 302 -
127.0.0.1 - - [22/Aug/2018 12:09:26] "GET /login HTTP/1.1" 200 -
在/login
頁面點擊 Click to Reset It
按鈕,
點擊 Request Password Reset
提交按鈕,用戶注冊時的QQ電子郵箱將收到一封電子郵件,示例如下:
點擊 click here
鏈接,或把鏈接復制到瀏覽器:
重置密碼成功(用戶 oldiron,b123456(原密碼a123456))。
目前為止,項目結構:
microblog/
app/
templates/
email/
reset_password.html
reset_password.txt
_post.html
404.html
500.html
base.html
edit_profile.html
index.html
login.html
register.html
reset_password.html
reset_password_request.html
user.html
__init__.py
email.py
errors.py
forms.py
models.py
routes.py
logs/
microblog.log
migrations/
venv/
app.db
config.py
microblog.env
microblog.py
tests.py
參考:
https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-x-email-support