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 信息。

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