Flask教程第十章:郵件支援

天降攻城獅發表於2019-02-22

本文轉載自:https://www.jianshu.com/p/277c981e5c94

這是Flask Mega-Tutorial系列的第十部分,在其中我將告訴你,應用如何向你的使用者傳送電子郵件,以及如何在電子郵件支援之上構建密碼重置功能。

現在,應用在資料庫方面做得相當不錯,所以在本章中,我想拋開這個主題,開始新增傳送電子郵件的功能,這是大多數Web應用必需的另一個重要部分。

為什麼應用需要傳送電子郵件給使用者? 原因很多,但其中一個常見的原因是解決與認證相關的問題。 在本章中,我將為忘記密碼的使用者新增密碼重置功能。 當使用者請求重置密碼時,應用將傳送包含特製連結的電子郵件。 使用者然後需要點選該連結才能訪問設定新密碼的表單。

本章的GitHub連結為:BrowseZipDiff.

Flask-Mail簡介

就實際的郵件傳送而言,Flask有一個名為Flask-Mail的流行外掛,可以使任務變得非常簡單。 和往常一樣,該外掛是用pip安裝的:

(venv) $ pip install flask-mail

密碼重置連結將包含有一個安全令牌。 為了生成這些令牌,我將使用JSON Web Tokens,它也有一個流行的Python包:

(venv) $ pip install pyjwt

Flask-Mail外掛是通過app.config物件來配置的。還記得在第七章中,我新增了用於在生產環境中發生錯誤時傳送電子郵件的配置項? 當時我沒有告訴你,不過,我選擇的配置變數都是Flask-Mail的需求的,所以不需要任何額外的工作,配置的活已經完工。

像大多數Flask外掛一樣,你需要在Flask應用建立之後建立一個郵件例項。 本處,mail是類Mail的一個例項:

# ...
from flask_mail import Mail

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

第七章中我提到過,測試傳送電子郵件的方式有兩種。 如果你想使用一個模擬的電子郵件伺服器,Python提供了一個非常好用的方法,你可以使用下面的命令在第二個終端中啟動它:

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

要配置此伺服器,需要設定兩個環境變數:

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

如果你希望真實地傳送電子郵件,則需要使用真實的電子郵件伺服器。 那麼你只需要為它設定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>

如果你使用的是Microsoft Windows,則需要在上面的每個export語句中將export替換為set

Gmail帳戶中的安全功能可能會阻止應用通過它傳送電子郵件,除非你明確允許“安全性較低的應用程式”訪問你的Gmail帳戶。 可以閱讀此處來了解具體情況,如果你擔心帳戶的安全性,可以建立一個輔助郵箱帳戶,配置它來僅用於測試電子郵件功能,或者你可以暫時啟用允許不太安全的應用程式來執行此測試,完成後恢復為預設值。

Flask-Mail的使用

為了學習Flask-Mail如何工作,我將向你展示如何用Python shell傳送電子郵件。那麼,執行flask shell以啟用Python,然後執行下面的命令:

>>> 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引數中設定的電子郵件地址列表。發件人配置項我在第七章中已經配置過了,是ADMINS。 該電子郵件將具有純文字和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支援一些我不在這裡使用的功能,如抄送和密件抄送列表。 如果你對這些選項感興趣,務必查閱Flask-Mail文件

請求重置密碼

我上面提到過,使用者有權利重置密碼。因此我將在登入頁面提供一個連結:

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

當使用者點選連結時,會出現一個新的Web表單,要求使用者輸入註冊的電子郵件地址,以啟動密碼重置過程。 這裡是表單類:

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

當然也需要一個檢視函式來處理表單:

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)

該檢視函式與其他的表單處理檢視函式非常相似。 我從確保使用者沒有登入開始,如果使用者登入,那麼使用密碼重置功能就沒有意義,所以我重定向到主頁。

當表格被提交併驗證通過,我使用表格中的使用者提供的電子郵件來查詢使用者。 如果我找到使用者,就傳送一封密碼重置電子郵件。 我執行此操作使用的send_password_reset_email()輔助函式,將在下面向你展示。

電子郵件傳送後,我會閃現一條訊息,指示使用者檢視電子郵件以獲取進一步說明,然後重定向回登入頁面。 你可能會注意到,即使使用者提供的電子郵件不存在,也會顯示閃現的訊息,這樣的話,客戶端就不能用這個表單來判斷一個給定的使用者是否已註冊。

密碼重置令牌

在實現send_password_reset_email()函式之前,我需要一種方法來生成密碼重置連結,它將被通過電子郵件傳送給使用者。 當連結被點選時,將為使用者展現設定新密碼的頁面。 這個計劃中棘手的部分是確保只有有效的重置連結可以用來重置帳戶的密碼。

生成的連結中會包含令牌,它將在允許密碼變更之前被驗證,以證明請求重置密碼的使用者是通過訪問重置密碼郵件中的連結而來的。JSON Web Token(JWT)是這類令牌處理的流行標準。 JWTs的優點是它是自成一體的,不但可以生成令牌,還提供對應的驗證方法。

如何執行JWTs?讓我們通過Python shell來學習一下:

>>> 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_KEYalgorithm引數指定使用什麼演算法來生成令牌,而HS256是應用最廣泛的演算法。

如你所見,得到的令牌是一長串字元。 但是不要認為這是一個加密的令牌。 令牌的內容,包括有效載荷,可以被任何人輕易解碼(不相信我?複製上面的令牌,然後貼上在JWT偵錯程式上就可以看到它的內容)。 使令牌安全的是,有效載荷是被簽名的。 如果有人試圖偽造或篡改令牌中的有效載荷,則簽名將會無效,並且生成新的簽名依賴祕密金鑰。 令牌驗證通過時,有效負載的內容將被解碼並返回給呼叫者。 如果令牌的簽名驗證通過,有效載荷才可以被認為是可信的。

我要用於密碼重置令牌的有效載荷格式為{`reset_password`:user_id,`exp`:token_expiration}。 exp欄位是JWTs的標準,如果它存在,則表示令牌的到期時間。 如果一個令牌有一個有效的簽名,但是它已經過期,那麼它也將被認為是無效的。 對於密碼重置功能,我會給這些令牌10分鐘的有效期。

當使用者點選電子郵件連結時,令牌將被作為URL的一部分傳送回應用,處理這個URL的檢視函式首先要做的就是驗證它。 如果簽名是有效的,則可以通過儲存在有效載荷中的ID來識別使用者。 一旦得知使用者的身份,應用可以要求一個新的密碼,並將其設定在使用者的帳戶上。

由於這些令牌屬於使用者,因此我將在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()是一個靜態方法,這意味著它可以直接從類中呼叫。 靜態方法與類方法類似,唯一的區別是靜態方法不會接收類作為第一個引數。 這個方法需要一個令牌,並嘗試通過呼叫PyJWT的jwt.decode()函式來解碼它。 如果令牌不能被驗證或已過期,將會引發異常,在這種情況下,我會捕獲它以防止出現錯誤,然後將None返回給呼叫者。 如果令牌有效,那麼來自令牌有效負載的reset_password的值就是使用者的ID,所以我可以載入使用者並返回它。

傳送密碼重置電子郵件

現在我有了令牌,可以生成密碼重置電子郵件。 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()函式從模板生成的。 模板接收使用者和令牌作為引數,以便可以生成個性化的電子郵件訊息。 以下是重置密碼電子郵件的文字模板:

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版本:

<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路由尚不存在,這將在下一節中新增。

重置使用者密碼

當使用者點選電子郵件連結時,會觸發與此功能相關的第二個路由。 這是密碼重置檢視函式:

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,並將重定向到主頁。

如果令牌是有效的,那麼我向使用者呈現第二個表單,需要使用者其中輸入新密碼。 這個表單的處理方式與以前的表單類似,表單提交驗證通過後,我呼叫User類的set_password()方法來更改密碼,然後重定向到登入頁面,以便使用者登入。

這是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實際上有多種方式支援執行非同步任務,threadingmultiprocessing模組都可以做到這一點。 為傳送電子郵件啟動一個後臺執行緒,比開始一個全新的程式需要的資源少得多,所以我打算採用這種方法:

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引數會被髮送到執行緒,但正如你在程式碼中所看到的那樣,我也傳入了應用例項。 使用執行緒時,需要牢記Flask的一個重要設計方面。 Flask使用上下文來避免必須跨函式傳遞引數。 我不打算詳細討論這個問題,但是需要知道的是,有兩種型別的上下文,即應用上下文請求上下文。 在大多數情況下,這些上下文由框架自動管理,但是當應用啟動自定義執行緒時,可能需要手動建立這些執行緒的上下文。

許多Flask外掛需要應用上下文才能工作,因為這使得他們可以在不傳遞引數的情況下找到Flask應用例項。這些外掛需要知道應用例項的原因是因為它們的配置儲存在app.config物件中,這正是Flask-Mail的情況。mail.send()方法需要訪問電子郵件伺服器的配置值,而這必須通過訪問應用屬性的方式來實現。 使用with app.app_context()呼叫建立的應用上下文使得應用例項可以通過來自Flask的current_app變數來進行訪問。


相關文章