一個(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 的最基本的配置。
- 查看更多 自定義 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)用程序去做一些你不打算做的事情。