與用戶有關(guān)的進(jìn)階技能

一個(gè)現(xiàn)代 web 應(yīng)用程序需要做的最常見的事情就是處理用戶。擁有基本賬號(hào)功能的一個(gè)應(yīng)用程序需要處理很多的事
情,像注冊(cè),確認(rèn)電子郵箱,安全地存儲(chǔ)密碼,安全地重置密碼,認(rèn)證等等。因?yàn)樵谔幚碛脩舻臅r(shí)候存在很多安全的
問題,通常最佳的方式就是堅(jiān)持在這個(gè)領(lǐng)域中的標(biāo)準(zhǔn)模式。

確認(rèn)電子郵箱

當(dāng)一個(gè)新用戶提供我們他們的郵箱,我們通常要確認(rèn)他們提供給我們的郵箱是否是正確的。一旦我們已經(jīng)通過郵箱驗(yàn)
證,我們可以安心地給我們用戶發(fā)送密碼重置鏈接以及其它敏感的信息,而無需擔(dān)心是誰在接收這些內(nèi)容。

確認(rèn)郵箱最常見的模式之一就是發(fā)送一個(gè) URL 唯一的密碼重置鏈接,當(dāng)訪問它的時(shí)候,證實(shí)了用戶的郵箱地址。例
如,john@gmail.com 注冊(cè)了我們的應(yīng)用程序。我們把他的用戶數(shù)據(jù)插入到數(shù)據(jù)庫(kù)中,該條用戶數(shù)據(jù)的
email_confirmed 字段被設(shè)置成 False 并且發(fā)送了一封攜帶唯一的 URL 的郵件到 john@gmail.com
上。這個(gè) URL 通常包含一個(gè)唯一的令牌,例如,
http://myapp.com/accounts/confirm-/Q2hhZCBDYXRsZXR0IHJvY2tzIG15IHNvY2tz。當(dāng) John
收到這封郵件的時(shí)候,他點(diǎn)擊鏈接。我們的應(yīng)用程序會(huì)檢查令牌,知道誰在確認(rèn)郵箱并且設(shè)置 John 的
email_confirmed 字段為 True。

我們是如何知道 URL 中的令牌是對(duì)應(yīng)哪個(gè)用戶?一種方式就是在令牌被創(chuàng)建的時(shí)候存儲(chǔ)到數(shù)據(jù)庫(kù)中,當(dāng)我們收到
確認(rèn)請(qǐng)求的時(shí)候到數(shù)據(jù)庫(kù)中檢查對(duì)比。這是一個(gè)很大的開銷,幸運(yùn)地是,我們不必這么做。

我們會(huì)把郵箱地址編碼成令牌。并且令牌也包含一個(gè)時(shí)間戳,該時(shí)間戳是讓我們?cè)O(shè)置一個(gè)令牌在什么時(shí)間內(nèi)有效的時(shí)
間限制。為了完成這些,我們使用 itsdangerous 包。這個(gè)包提供我們一個(gè)用來發(fā)送敏感數(shù)據(jù)到一個(gè)不可信的環(huán)
境的工具(像發(fā)送一封郵件確認(rèn)令牌到一個(gè)未確認(rèn)的郵箱)。在本例中,我們將會(huì)使用
URLSafeTimedSerializer 類的一個(gè)實(shí)例。

# ourapp/util/security.py

from itsdangerous import URLSafeTimedSerializer

from .. import app

ts = URLSafeTimedSerializer(app.config["SECRET_KEY"])

當(dāng)一個(gè)用戶給我們他們的郵箱地址的時(shí)候,我們可以使用它序列化來生成一個(gè)確認(rèn)令牌。我們實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的賬號(hào)
創(chuàng)建過程,里面就使用了這種方法。

# ourapp/views.py

from flask import redirect, render_template, url_for

from . import app, db
from .forms import EmailPasswordForm
from .util import ts, send_email

@app.route('/accounts/create', methods=["GET", "POST"])
def create_account():
    form = EmailPasswordForm()
    if form.validate_on_submit():
        user = User(
            email = form.email.data,
            password = form.password.data
        )
        db.session.add(user)
        db.session.commit()

        # Now we'll send the email confirmation link
        subject = "Confirm your email"

        token = ts.dumps(self.email, salt='email-confirm-key')

        confirm_url = url_for(
            'confirm_email',
            token=token,
            _external=True)

        html = render_template(
            'email/activate.html',
            confirm_url=confirm_url)

        # We'll assume that send_email has been defined in myapp/util.py
        send_email(user.email, subject, html)

        return redirect(url_for("index"))

    return render_template("accounts/create.html", form=form)

我們上面定義的視圖處理用戶的創(chuàng)建以及發(fā)送一封郵件到指定的郵箱地址。你可能注意到我們使用了一個(gè)模板用來
生成郵件內(nèi)容的 HTML 形式。

{# ourapp/templates/email/activate.html #}

Your account was successfully created. Please click the link below<br>
to confirm your email address and activate your account:

<p>
<a href="{% raw %}{{ confirm_url }}{% endraw %}">{% raw %}{{ confirm_url }}{% endraw %}</a>
</p>

<p>
--<br>
Questions? Comments? Email hello@myapp.com.
</p>

好了,現(xiàn)在我們只需要實(shí)現(xiàn)一個(gè)處理郵件中確認(rèn)鏈接的視圖。

# ourapp/views.py

@app.route('/confirm/{% raw %}<token>{% endraw %}')
def confirm_email(token):
    try:
        email = ts.loads(token, salt="email-confirm-key", max_age=86400)
    except:
        abort(404)

    user = User.query.filter_by(email=email).first_or_404()

    user.email_confirmed = True

    db.session.add(user)
    db.session.commit()

    return redirect(url_for('signin'))

這個(gè)視圖是一個(gè)簡(jiǎn)單的表單視圖。我們只在開始的時(shí)候添加了 try ... except 來檢查令牌是否有效。令牌
中包含了一個(gè)時(shí)間戳,因此我們能夠告訴 ts.loads() 引發(fā)一個(gè)異常如果它大于 max_age 的話。在本例中,
我們?cè)O(shè)置 max_age 為 86400 秒,即:24小時(shí)。

  • 你可以使用非常相似的方法來實(shí)現(xiàn)更新郵箱地址的功能。只要發(fā)送一封攜帶令牌的郵件到新的郵箱,該令牌包含
    舊的以及新的郵箱地址。如果令牌是有效的,用新的郵箱更新舊的郵箱。

存儲(chǔ)密碼

處理用戶的首要規(guī)則就是在存儲(chǔ)密碼之前用 Bcrypt(或者 scrypt,這里我們使用 Bcrypt)散列密碼。我們
絕不能明文存儲(chǔ)密碼。這是一個(gè)巨大的安全問題并且對(duì)于我們用戶來說是不公平的。所有的這些辛勤工作都已經(jīng)有
人完成并且抽象出來給我們使用,所以沒有理由不在這里遵循最佳實(shí)踐。

  • OWASP 是關(guān)于 Web 應(yīng)用程序安全性的信息的業(yè)界最值得信賴的來源之一??纯匆恍┧麄?br> 關(guān)于安全編碼的建議。

我們將繼續(xù)并且使用 Flask-Bcrypt 擴(kuò)展在我們的應(yīng)用中實(shí)現(xiàn) bcrypt 包。這個(gè)包基本上是對(duì) py-bcrypt
包的封裝,但是為我們做了一些很煩人的事情(像在比較散列之前檢查字符編碼等等)。

# ourapp/__init__.py

from flask.ext.bcrypt import Bcrypt

bcrypt = Bcrypt(app)

Bcrypt 算法強(qiáng)烈地被推薦的原因之一就是”未來的適應(yīng)性“。這就意味著隨著時(shí)間的推移,當(dāng)計(jì)算能力變得越來越便宜的
時(shí)候,我們可以把它變得越來越困難地被暴力方式來破解,這種暴力方式就是上百萬次的猜測(cè)密碼。我們使用越多
的”循環(huán)“來散列密碼,將會(huì)花費(fèi)越多的時(shí)間來猜測(cè)。如果我們?cè)诖鎯?chǔ)密碼之前使用算法散列密碼 20 次的話,
攻擊者必須散列每一個(gè)它們的猜測(cè) 20 次。

請(qǐng)記住如果我們散列密碼超過 20 次的話,我們的應(yīng)用程序需要花費(fèi)很長(zhǎng)的一段時(shí)間來返回響應(yīng),具體要取決于什
么時(shí)候處理完成。這就意味著當(dāng)選擇使用的”循環(huán)數(shù)“的時(shí)候,我們必須平衡安全和可用性。我們可以在給定時(shí)間內(nèi)
計(jì)算完成的”循環(huán)“取決于提供我們應(yīng)用程序的計(jì)算資源。在 0.25 到 0.5 秒之間的時(shí)間內(nèi)散列密碼是一個(gè)很好
的體驗(yàn)。我們應(yīng)該嘗試使用的”循環(huán)“至少為 12。

為了測(cè)試散列密碼花費(fèi)的時(shí)間,我們可以編寫一個(gè)簡(jiǎn)單且快速的散列密碼的 Python 腳本。

# benchmark.py

from flask.ext.bcrypt import generate_password_hash

# Change the number of rounds (second argument) until it takes between
# 0.25 and 0.5 seconds to run.
generate_password_hash('password1', 12)

現(xiàn)在我們可以使用 UNIX 的 time 工具來記錄時(shí)間的消耗數(shù)。

$ time python test.py

real    0m0.496s
user    0m0.464s
sys     0m0.024s

我做了一個(gè)快速的基準(zhǔn)測(cè)試在一個(gè)小型的服務(wù)器上,12 ”循環(huán)“(rounds)是一個(gè)很合適的值,因此我們使用它來
配置我的示例。

# config.py

BCRYPT_LOG_ROUNDS = 12

現(xiàn)在 Flask-Bcrypt 已經(jīng)配置好了,是時(shí)候開始散列密碼。我們可以在接收來自注冊(cè)表單的請(qǐng)求的視圖中手動(dòng)
去散列密碼,但是我們必須在密碼重置以及密碼修改的視圖中再次重復(fù)這樣做。相反,我們要做的就是如何抽象散
列,以便我們的應(yīng)用程序無需我們考慮就能自己完成。這里我們會(huì)使用一個(gè) setter,這樣的話當(dāng)我們?cè)O(shè)置
user.password = 'password1' 的話,在存儲(chǔ)之前就會(huì)自動(dòng)地使用 Bcrypt 散列密碼。

# ourapp/models.py

from sqlalchemy.ext.hybrid import hybrid_property

from . import bcrypt, db

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username = db.Column(db.String(64), unique=True)
    _password = db.Column(db.String(128))

    @hybrid_property
    def password(self):
        return self._password

    @password.setter
    def _set_password(self, plaintext):
        self._password = bcrypt.generate_password_hash(plaintext)

我們使用了 SQLAlchemy 的 hybrid 擴(kuò)展來定義一個(gè)屬性,這個(gè)屬性從相同接口調(diào)用的時(shí)候擁有不同的功能。
當(dāng)我們?yōu)?user.password 屬性賦值的時(shí)候,我們的 setter 就被調(diào)用。在它里面,我們散列一個(gè)明文的密
碼并且存儲(chǔ)在用戶表的 _password 字段中。因?yàn)槲覀兪褂?hybrid 屬性,我們可以通過 user.password
屬性來訪問散列的密碼。

現(xiàn)在我們使用上面的模型為應(yīng)用程序?qū)崿F(xiàn)一個(gè)注冊(cè)視圖。

# ourapp/views.py

from . import app, db
from .forms import EmailPasswordForm
from .models import User

@app.route('/signup', methods=["GET", "POST"])
def signup():
    form = EmailPasswordForm()
    if form.validate_on_submit():
        user = User(username=form.username.data, password=form.password.data)
        db.session.add(user)
        db.session.commit()
        return redirect(url_for('index'))

    return render_template('signup.html', form=form)

認(rèn)證

既然我們?cè)跀?shù)據(jù)庫(kù)中有用戶了,我們可以實(shí)現(xiàn)認(rèn)證。我們要一個(gè)用戶提交攜帶他們的用戶名和密碼的表單(盡管對(duì)
一些應(yīng)用來說這可能是郵箱和密碼),接著確保他們是否提供了正確的密碼。如果所有的都驗(yàn)證通過了,我們通過
在他們的瀏覽器上設(shè)置一個(gè) cookie 來標(biāo)記他們已經(jīng)通過認(rèn)證。下一次他們?cè)龠^來請(qǐng)求的時(shí)候我們通過查找
cookie 知道他們已經(jīng)登錄。

讓我們開始用 WTForms 定義一個(gè) UsernamePassword 表單。

# ourapp/forms.py

from flask_wtf import Form
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired


class UsernamePasswordForm(Form):
    username = StringField('Username', validators=[DataRequired()])
    password = PasswordField('Password', validators=[DataRequired()])

下一步我們?cè)谖覀兊挠脩裟P椭刑砑右粋€(gè)方法,該方法用來比較一個(gè)字符串和用戶存儲(chǔ)的散列密碼。

# ourapp/models.py

from . import db

class User(db.Model):

    # [...] columns and properties

    def is_correct_password(self, plaintext)
        return bcrypt.check_password_hash(self._password, plaintext)

Flask-Login

我們下一目標(biāo)就是定義一個(gè)登錄的視圖,該視圖用來服務(wù)和接收我們的表單。如果用戶輸入正確的憑證的話,我們
將使用 Flask-Login 擴(kuò)展來認(rèn)證他們。這個(gè)擴(kuò)展簡(jiǎn)化了處理用戶會(huì)話和認(rèn)證的過程。

我們需要的就是對(duì) Flask-Login 進(jìn)行一些小小的配置。

在 \\init\\.py 中,我們將定義 Flask-Login 的 login\\_manager。

# ourapp/__init__.py

from flask.ext.login import LoginManager

# Create and configure app
# [...]

from .models import User

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view =  "signin"

@login_manager.user_loader
def load_user(userid):
    return User.query.filter(User.id==userid).first()

這里我們創(chuàng)建了一個(gè) LoginManager 示例,并且用我們的 app 對(duì)象初始化它,定義登錄視圖并且告訴
它如何用一個(gè)的用戶的 id 得到用戶對(duì)象。這是我們使用 Flask-Login 的最基本的配置。

現(xiàn)在我們可以定義處理登錄的 signin 視圖。

# ourapp/views.py

from flask import redirect, url_for

from flask.ext.login import login_user

from . import app
from .forms import UsernamePasswordForm()

@app.route('signin', methods=["GET", "POST"])
def signin():
    form = UsernamePasswordForm()

    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first_or_404()
        if user.is_correct_password(form.password.data):
            login_user(user)

            return redirect(url_for('index'))
        else:
            return redirect(url_for('signin'))
    return render_template('signin.html', form=form)

我們簡(jiǎn)單地從 Flask-Login 中導(dǎo)入 login_user 函數(shù),檢查用戶登錄憑證并且調(diào)用
login_user(user)。你可以使用 logout_user() 實(shí)現(xiàn)用戶的退出操作。

# ourapp/views.py

from flask import redirect, url_for
from flask.ext.login import logout_user

from . import app

@app.route('/signout')
def signout():
    logout_user()

    return redirect(url_for('index'))

忘記密碼

我們通常要實(shí)現(xiàn)一個(gè)”忘記你的密碼“的功能,允許一個(gè)用戶通過郵箱找回自己的賬號(hào)。這個(gè)地方也會(huì)有很多潛在的
風(fēng)險(xiǎn),因?yàn)殛P(guān)鍵是讓一個(gè)未認(rèn)證的用戶接管一個(gè)賬號(hào)。我們這里實(shí)現(xiàn)密碼重置采用了我們?cè)卩]箱確認(rèn)的時(shí)候一些同
樣的技術(shù)。

我們需要一個(gè)表單用來申請(qǐng)為某個(gè)賬號(hào)的郵箱重置密碼,并且需要一個(gè)表單來讓用戶輸入新的密碼,一旦我們已經(jīng)
確認(rèn)了未經(jīng)認(rèn)證的用戶能夠訪問某個(gè)賬號(hào)的郵箱。在本節(jié)的代碼假設(shè)我們的用戶模型有一個(gè)郵箱和密碼,并且密碼
是我們之前創(chuàng)建的具有 hybrid 屬性。

  • 不要發(fā)送密碼重置鏈接到一個(gè)未經(jīng)證實(shí)的電子郵件地址!你要確保你正在發(fā)送鏈接給合適的人。

我們將需要兩個(gè)表單。一個(gè)是用于申請(qǐng)重置密碼的鏈接,一個(gè)是用于一旦郵件被認(rèn)證用于更改密碼。

# ourapp/forms.py

from flask_wtf import Form
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Email

class EmailForm(Form):
    email = TextField('Email', validators=[DataRequired(), Email()])

class PasswordForm(Form):
    password = PasswordField('Email', validators=[DataRequired()])

上面的代碼假設(shè)我們的密碼重置的表單只需要一個(gè)密碼字段(只需要輸入一次新密碼)。許多應(yīng)用程序需要用戶輸
入新的密碼兩次以確保他們沒有輸錯(cuò)。要做到這一點(diǎn)的話,我們可以簡(jiǎn)單地添加另一個(gè) PasswordField 字段,
并且添加 WTForms 的驗(yàn)證器:EqualTo。

  • 用戶體驗(yàn)社區(qū)(UX)有很多關(guān)于處理注冊(cè)表單的最佳方式的有趣的討論。我個(gè)人十分喜歡 Stack Exchange
    用戶(Roger Attrill)的想法,他這樣說的:
  • ”我們不應(yīng)該要求用戶輸入密碼兩次 - 我們只需要用戶輸入一次并且確保‘忘記密碼’的功能要完美和無縫的?!?/li>

現(xiàn)在我們實(shí)現(xiàn)第一個(gè)視圖,用戶可以申請(qǐng)發(fā)送密碼重置鏈接到一個(gè)指定的郵箱地址。

# ourapp/views.py

from flask import redirect, url_for, render_template

from . import app
from .forms import EmailForm
from .models import User
from .util import send_email, ts

@app.route('/reset', methods=["GET", "POST"])
def reset():
    form = EmailForm()
    if form.validate_on_submit()
        user = User.query.filter_by(email=form.email.data).first_or_404()

        subject = "Password reset requested"

        # Here we use the URLSafeTimedSerializer we created in `util` at the
        # beginning of the chapter
        token = ts.dumps(user.email, salt='recover-key')

        recover_url = url_for(
            'reset_with_token',
            token=token,
            _external=True)

        html = render_template(
            'email/recover.html',
            recover_url=recover_url)

        # Let's assume that send_email was defined in myapp/util.py
        send_email(user.email, subject, html)

        return redirect(url_for('index'))
    return render_template('reset.html', form=form)

當(dāng)表單接收到一個(gè)郵箱地址,我們獲取與該郵箱地址有關(guān)的用戶,生成一個(gè)重置的令牌并且發(fā)送他們一個(gè)密碼重置
的 URL。這個(gè) URL 將他們路由到一個(gè)視圖,該視圖驗(yàn)證令牌并且讓他們重置密碼。

# ourapp/views.py

from flask import redirect, url_for, render_template

from . import app, db
from .forms import PasswordForm
from .models import User
from .util import ts

@app.route('/reset/<token>', methods=["GET", "POST"])
def reset_with_token(token):
    try:
        email = ts.loads(token, salt="recover-key", max_age=86400)
    except:
        abort(404)

    form = PasswordForm()

    if form.validate_on_submit():
        user = User.query.filter_by(email=email).first_or_404()

        user.password = form.password.data

        db.session.add(user)
        db.session.commit()

        return redirect(url_for('signin'))

    return render_template('reset_with_token.html', form=form, token=token)

我們使用了和驗(yàn)證用戶的郵箱地址一樣的令牌驗(yàn)證方法。視圖把從 URL 中獲取的令牌傳入到模板中。接著模板使
用令牌提交表單到正確的 URL。讓我們看看模板可能的樣子。

{# ourapp/templates/reset_with_token.html #}

{% extends "layout.html" %}

{% block body %}
{% endraw %}
<form action="{{ url_for('reset_with_token', token=token) }}" method="POST">
    {% raw %}
    {{ form.password.label }}: {{ form.password }}<br>
    {{ form.csrf_token }}
    {% endraw %}
    <input type="submit" value="Change my password" />
</form>
{% raw %}
{% endblock %}

摘要

  • 使用 itsdangerous 包來創(chuàng)建和驗(yàn)證發(fā)送到郵箱地址的令牌。
  • 當(dāng)一個(gè)用戶創(chuàng)建賬號(hào),更改郵箱或者忘記密碼的時(shí)候,你可以使用這些令牌來驗(yàn)證郵件。
  • 使用 Flask-Login 擴(kuò)展來認(rèn)證用戶可以避免自己處理一大堆麻煩的會(huì)話管理。
  • 要經(jīng)常思考一個(gè)惡意的用戶如何濫用你的應(yīng)用程序去做一些你不打算做的事情。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 22年12月更新:個(gè)人網(wǎng)站關(guān)停,如果仍舊對(duì)舊教程有興趣參考 Github 的markdown內(nèi)容[https://...
    tangyefei閱讀 35,401評(píng)論 22 257
  • 大多數(shù)程序都需要進(jìn)行用戶跟蹤。用戶鏈接程序時(shí)需要進(jìn)行身份認(rèn)證,通過這一過程,讓程序知道自己的身份。程序知道用戶是誰...
    藕絲空間閱讀 1,098評(píng)論 0 0
  • 4 創(chuàng)建一個(gè)社交網(wǎng)站 在上一章中,你學(xué)習(xí)了如何創(chuàng)建站點(diǎn)地圖和訂閱,并且為博客應(yīng)用構(gòu)建了一個(gè)搜索引擎。在這一章中,你...
    lakerszhy閱讀 2,258評(píng)論 0 7
  • 第二部分 Blog例子 第八章 用戶驗(yàn)證 大部分程序需要追蹤用戶身份。當(dāng)用戶連接到程序,通過一系列步驟使自己的身份...
    易木成華閱讀 1,412評(píng)論 0 4
  • 最近在學(xué)習(xí)flask,用到flask-login,發(fā)現(xiàn)網(wǎng)上只有0.1版本的中文文檔,看了官方已經(jīng)0.4了,并且添加...
    ZZES_ZCDC閱讀 6,131評(píng)論 3 24

友情鏈接更多精彩內(nèi)容