第二部分 Blog例子
第八章 用戶驗證
大部分程序需要追蹤用戶身份。當(dāng)用戶連接到程序,通過一系列步驟使自己的身份被識別,這就是用戶驗證茫孔。一旦程序知道這個用戶是誰,它就能提供定制化的體驗被芳。
大部分驗證方法要求用戶提供一個身份標(biāo)識(用戶名稱或者郵件地址)和一個密碼缰贝。本章將創(chuàng)建一個完整的身份認(rèn)證系統(tǒng)。
Flask認(rèn)證擴展
優(yōu)秀的Python認(rèn)證包有很多畔濒,但它們都沒有單蹦完成所有的任務(wù)的剩晴。本章的用戶認(rèn)證解決方案聯(lián)合使用了多個包共同完成這一任務(wù)。下面是我們要用的包清單:
- Flask-Login: 登陸后用戶會話管理
- Werkzeug: 密碼加密和密碼驗證
- itsdangerous: 加密安全令牌的生成和驗證
除了特定的認(rèn)證包之外侵状,下面是我們要用到的常規(guī)目的的一些擴展赞弥。
- Flask-Mail: 發(fā)送驗證郵件
- Flask-Bootstrap:HTML模板
- Flask-WTF: web表單
密碼加密
在設(shè)計開發(fā)web程序過程中,存儲在數(shù)據(jù)庫的用戶信息的安全性往往被忽視趣兄。如果攻擊者黑進服務(wù)器绽左,訪問了你的用戶數(shù)據(jù)庫,你就有用戶安全的風(fēng)險艇潭。并且這種風(fēng)險要比你想象的要大得多拼窥。這是眾所周知的一個事實:大部分用戶在多個網(wǎng)站上使用同樣的密碼戏蔑,所以即使你沒有存儲任何敏感信息,只需獲得你數(shù)據(jù)庫里的用戶密碼鲁纠,攻擊者就可以訪問其他網(wǎng)站上你這些用戶的帳戶了侈百。(譯注:奇葩如此悯周。當(dāng)年CSDN這樣的大網(wǎng)站竟然都是明文存儲密碼塘辅。在2011年其密碼數(shù)據(jù)庫泄露后民泵,眾多網(wǎng)站紛紛中招——用戶們都是一個密碼|甚至一套名碼走天下。
)
安全存儲用戶密碼的關(guān)鍵就是不要存儲明文密碼捍壤,你應(yīng)該存儲它的hash(譯注:哈希|散列
)值骤视。密碼哈希函數(shù)獲取輸入的明文密碼后對其一次或多次加密轉(zhuǎn)換,其結(jié)果是一個新的字符串序列白群,跟原文密碼毫不相似尚胞。哈希密碼可以與明文密碼(譯注:用戶輸入
)進行校驗硬霍,因為哈希函數(shù)是可重復(fù)的:同樣的輸入一定會得到同樣的結(jié)果輸出帜慢。
密碼哈希是一個復(fù)雜的任務(wù),很難確保沒有疏漏唯卖。我不建議你自己實現(xiàn)加密驗證這套方案粱玲,應(yīng)該采用社區(qū)廣泛認(rèn)可的庫。如果有興趣學(xué)習(xí)加密驗證拜轨,你可以去讀讀這些文章:Salted Password Hashing-Doing it Right
使用Werkzeug哈希密碼
Werkzeug的安全模塊通過兩個函數(shù)方便地實現(xiàn)了密碼哈希加密抽减,分別用于注冊和驗證階段:
- generate_password_hash(password, method=pbkdf2:sha1, salt_length=8) :這個函數(shù)接收純文本密碼并返回經(jīng)過哈希加密的一個字符串,該返回值可以放心的存入數(shù)據(jù)庫橄碾。method和salt_length兩個參數(shù)的默認(rèn)值基本可以滿足大部分用例需要了卵沉。(
譯注:當(dāng)然,這兩個參數(shù)可以指定別的值法牲,從而帶來更高的安全性史汗。總的來說隨著cpu技術(shù)進步拒垃,暴力破解<窮盡所有組合進行逐一猜測>所需要的時間也在相應(yīng)縮短——理論上所有密碼都可以被猜出來停撞,當(dāng)然所需要的時間可能無法接受——所以很對定長的用戶密碼,提高加密的復(fù)雜性/更長的salt/更多次的hash悼瓮,使破解需要的時間盡可能長戈毒,是另一種安全保護考慮。
) - check_password_hash(hash, password) : 這個函數(shù)分別從數(shù)據(jù)庫獲得散列后的密碼横堡,從用戶獲取輸入的明文密碼埋市,進行比較。一旦這兩個密碼一致就返回True命贴。
例子8-1展示了第五章編寫的User模型中密碼加密部分的變更:
Example 8-1. app/models.py: Password hashing in User model
from werkzeug.security import generate_password_hash, check_password_hash
class User(db.Model):
# ...
password_hash = db.Column(db.String(128))
@property
def password(self):
raise AttributeError('password is not a readable attribute')
@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
密碼哈希函數(shù)通過User類的password只寫屬性實現(xiàn)的道宅。如果試圖讀取password屬性腊满,將返回一個錯誤。設(shè)置該屬性時培己,setter方法會呼叫werkzeug的generate_password_hash()函數(shù)并把返回結(jié)果寫到類字段password_hash碳蛋。原始密碼 一旦哈希就無法恢復(fù)(<small>譯注:你只能比對哈希值而不能從哈希值還原原始密碼</small>)
verify_password方法獲取用戶輸入的密碼,并傳遞給werkzeug的check_pasword_hash()函數(shù)來驗證是否與保存在User模型中的已經(jīng)哈希過的密碼一致省咨。如果這個方法返回True肃弟,密碼就是正確的。
密碼哈希功能已經(jīng)完成零蓉,你可以在shell中進行測試:
(venv) $ python manage.py shell
>>> u = User()
>>> u.password = 'cat'
>>> u.password_hash
'pbkdf2:sha1:1000$duxMk0OF$4735b293e397d6eeaf650aaf490fd9091f928bed'
>>> u.verify_password('cat')
True
>>> u.verify_password('dog')
False
>>> u2 = User()
>>> u2.password = 'cat'
>>> u2.password_hash
'pbkdf2:sha1:1000$UjvnGeTP$875e28eb0874f44101d6b332442218f66975ee89'
注意笤受,雖然使用了同一個密碼,用戶u和u2的哈希密碼值完全不同敌蜂。為了確認(rèn)這一功能將來也能正常工作箩兽,我們把上面的測試寫成一個單元測試以便于能重復(fù)測試。例子8-2展示了tests包中的一個新模塊章喉,它有三個新測試來檢測User模型的最近更改:
Example 8-2. tests/test_user_model.py: Password hashing tests
import unittest
from app.models import User
class UserModelTestCase(unittest.TestCase):
def test_password_setter(self):
u = User(password = 'cat')
self.assertTrue(u.password_hash is not None)
def test_no_password_getter(self):
u = User(password = 'cat')
with self.assertRaises(AttributeError):
u.password
def test_password_verification(self):
u = User(password = 'cat')
self.assertTrue(u.verify_password('cat'))
self.assertFalse(u.verify_password('dog'))
def test_password_salts_are_random(self):
u = User(password='cat')
u2 = User(password='cat')
self.assertTrue(u.password_hash != u2.password_hash)
創(chuàng)建身份認(rèn)證藍(lán)圖
我們在第七章介紹了藍(lán)圖:把程序創(chuàng)建移動到工廠函數(shù)中后汗贫,定義全局路由。用戶驗證相關(guān)的路由可以添加到auth藍(lán)圖秸脱。要保持良好的代碼結(jié)構(gòu)落包,針對不同的程序功能集合設(shè)置不同的藍(lán)圖就是很好的辦法。
auth藍(lán)圖保存在同名的python包里摊唇。藍(lán)圖包構(gòu)造函數(shù)創(chuàng)建藍(lán)圖對象并從一個views.py模塊中導(dǎo)入路由咐蝇。請看來例子8-3:
Example 8-3. app/auth/__init__.py: Blueprint creation
from flask import Blueprint
auth = Blueprint('auth', __name__)
from . import views
app/auth/views.py
模塊,如例子8-4所示巷查,導(dǎo)入了藍(lán)圖和驗證相關(guān)的的路由定義(譯注:使用裝飾器@auth.route()
)∮行颍現(xiàn)在已經(jīng)添加了一個'/login'路由,來顯示同名的占位符模板岛请。
Example 8-4. app/auth/views.py: Blueprint routes and view functions
from flask import render_template
from . import auth
@auth.route('/login')
def login():
return render_template('auth/login.html')
傳給render_template()的模板文件被保存在auth文件夾旭寿。這個文件夾必須在app/templates
中創(chuàng)建,因為Flask默認(rèn)所有模板都在這里髓需。把藍(lán)圖模板保存在各自同名文件夾许师,避免了和main了藍(lán)圖或未來其他藍(lán)圖的模板名稱沖突。
提醒:經(jīng)過配置僚匆,藍(lán)圖也可以擁有自己獨立的模板文件夾(<small>譯注:不在app/templates中?</small>)微渠。當(dāng)配置了多個模板文件夾之后,render_template()函數(shù)將先搜索程序指定的模板文件夾咧擂,再搜索藍(lán)圖指定的模板文件夾逞盆。
auth藍(lán)圖需要添加到create_app()工廠函數(shù)中的程序,如例子8-5:
Example 8-5. app/__init__.py: Blueprint attachment
def create_app(config_name):
# ...
from .auth import auth as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth')
return app
藍(lán)圖注冊中的url_prefix參數(shù)是可選的松申。如果啟用云芦,在這個藍(lán)圖中定義的路由都會使用這個前綴(此處是/auth)注冊俯逾。例如,/login路由會被注冊成/auth/login舅逸,在開發(fā)服務(wù)器上的完整路徑就是http://localhost:5000/auth/login
使用Flask-login實現(xiàn)用戶驗證
當(dāng)用戶登錄進入程序桌肴,必須記錄下他們的驗證授權(quán)狀態(tài),這樣即使轉(zhuǎn)到別的頁面也可以記住已登錄的用戶琉历。Flask-Login是個小巧但非常有用的擴展坠七,它并沒有綁定某個特定的驗證方式,只是限于用戶驗證管理這方面(譯注:不限制于某種驗證方法(賬號密碼方式旗笔,openID彪置,或其他),只負(fù)責(zé)管理用戶是否可以驗證通過蝇恶,以及記住用戶會話拳魁,退出系統(tǒng)?撮弧?
)潘懊。
開始之前,我們需要在虛擬環(huán)境中安裝該擴展:
(venv) $ pip install flask-login
準(zhǔn)備登錄用的用戶模型
為了配合User模型一起工作想虎,F(xiàn)lask-Login擴展需要模型實現(xiàn)幾個方法卦尊。如表8-1:
Table 8-1. Flask-Login user methods
Method Description
is_authenticated() Must return True if the user has login credentials or False otherwise.
is_active() Must return True if the user is allowed to log in or False otherwise. A False return value can be used for disabled accounts.
is_anonymous() Must always return False for regular users.
get_id() Must return a unique identifier for the user, encoded as a Unicode string.
這四個方法可以在模型類中直接以方法的形式實現(xiàn)叛拷,但我們有個更簡單的備選——Flask-Login提供了一個UserMixin類舌厨,它已經(jīng)默認(rèn)實現(xiàn)了這些方法足以滿足大部分應(yīng)用場景。更新后的用戶模型如里子8-6所示:
Example 8-6. app/models.py: Updates to the User model to support user logins
from flask.ext.login import UserMixin
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key = True)
email = db.Column(db.String(64), unique=True, index=True)
username = db.Column(db.String(64), unique=True, index=True)
password_hash = db.Column(db.String(128))
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
注意忿薇,email字段也被添加上了裙椭。在這個程序里,用戶使用email來登錄系統(tǒng)署浩,相比較用戶名揉燃,一般很少有人會忘了自己郵件地址。
Flask-Login在工廠函數(shù)中進行初始化筋栋,如例子8-7所示:
Example 8-7. app/__init__.py: Flask-Login initialization
from flask.ext.login import LoginManager
login_manager = LoginManager()
login_manager.session_protection = 'strong'
login_manager.login_view = 'auth.login'
def create_app(config_name):
# ...
login_manager.init_app(app)
# ...
Login_Manager對象的屬性session_protection可以被設(shè)置成None炊汤,basic或者strong,來提供不同的安全等級對付用戶會話攻擊弊攘。如果設(shè)置為strong抢腐,F(xiàn)lask-Login將持續(xù)追蹤客戶端ip地址和瀏覽器標(biāo)識,一旦發(fā)現(xiàn)變動就會要求重新登錄襟交。 login_view屬性設(shè)置了登錄頁面的端點('auth.login')迈倍。你可能記著,login路由是在藍(lán)圖里的捣域,這就需要添加藍(lán)圖名為前綴啼染。
最終宴合,F(xiàn)lask-Login要求程序設(shè)置一個回調(diào)函數(shù),用來載入用戶迹鹅,作為身份識別卦洽。如例子8-8所示:
Example 8-8. app/models.py: User loader callback function
from . import login_manager
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
載入用戶的回調(diào)函數(shù)接收一個Unicode字符串格式的用戶標(biāo)識id。如果該標(biāo)識有效斜棚,回調(diào)函數(shù)的返回值則肯定是一個用戶對象逐样,否則返回None而不會報錯。
保護路由
為了保護路由只允許已登錄用戶訪問打肝,F(xiàn)lask-Login提供了login_required裝飾器脂新。用例如下:
from flask.ext.login import login_required
@app.route('/secret')
@login_required
def secret():
return 'Only authenticated users are allowed!'
如果一個未經(jīng)驗證的用戶試圖訪問該路由,F(xiàn)lask-Login就會中斷請求粗梭,把用戶轉(zhuǎn)到登錄頁面争便。
添加登錄表單
將要顯示給用戶的登錄表單頁面,包含一個文本字段用來輸入email地址,一個密碼字段,一個"remember me"選框和一個提交按鈕。例子8-9顯示了Flask-WTF類:
Example 8-9. app/auth/forms.py: Login form
from flask.ext.wtf import Form
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import Required, Email
class LoginForm(Form):
email = StringField('Email', validators=[Required(), Length(1, 64),Email()])
password = PasswordField('Password', validators=[Required()])
remember_me = BooleanField('Keep me logged in')
submit = SubmitField('Log In')
email字段利用了WTFForms提供的Length(),Email()驗證器断医。PassowrdField類將顯示為type="passowrd"的一個<input>元素滞乙。BooleanField類則顯示為一個選擇框。
與登錄頁面關(guān)聯(lián)的模板是auth/login.html鉴嗤。這個模板只需要使用Flask-Bootstrap的wtf.quick_form()宏來顯示表單即可斩启。圖8-1顯示了瀏覽器中的登錄表單:
在base.html模板中的導(dǎo)航條使用了jinja2的條件語句,根據(jù)當(dāng)前用戶的登錄狀態(tài)來顯示“登錄”或“退出”鏈接醉锅。這個條件語句顯示在例子8-10:
Example 8-10. app/templates/base.html: Sign In and Sign Out navigation bar links
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_authenticated() %}
<li><a href="{{ url_for('auth.logout') }}">Sign Out</a></li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
{% endif %}
</ul>
條件語句中的current_user變量由Flask-login定義兔簇,在視圖函數(shù)和模板中自動生效。這個變量包含了當(dāng)前已登錄的用戶或者一個匿名用戶對象——如果未登錄的話硬耍。匿名用戶對象給is_authenticated()方法的響應(yīng)是False垄琐,所以很方便就能知道當(dāng)前用戶是否已經(jīng)登錄。
用戶登錄
視圖函數(shù)login()的實現(xiàn)如例子8-11:
Example 8-11. app/auth/views.py: Sign In route
from flask import render_template, redirect, request, url_for, flash
from flask.ext.login import login_user
from . import auth
from ..models import User
from .forms import LoginForm
@auth.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is not None and user.verify_password(form.password.data):
login_user(user, form.remember_me.data)
return redirect(request.args.get('next') or url_for('main.index'))
flash('Invalid username or password.')
return render_template('auth/login.html', form=form)
視圖函數(shù)創(chuàng)建了一個LoginForm對象经柴,并像第四章中那樣使用了這個簡單表單狸窘。當(dāng)這個請求的類型是GET,視圖函數(shù)就只渲染這個模板——依次顯示表單坯认。如果這個表單被提交——請求類型是POST翻擒,F(xiàn)lask-WTF的validate_on_submit()函數(shù)就驗證表單變量,然后試圖登錄用戶牛哺。
為了登錄用戶陋气,這個函數(shù)開始利用表單提供的email從數(shù)據(jù)庫中加載用戶。如果指定email的用戶存在荆隘,就傳遞表單的password值給verify_password方法調(diào)用之恩伺。如果密碼有效,F(xiàn)lask-login的login_user()函數(shù)就把用戶登入系統(tǒng)椰拒,并察看表單的“remember me”的布爾值晶渠。如果該值為False凰荚,用戶會話將在瀏覽器關(guān)閉的時候過期失效,用戶下次就必須重新登錄褒脯。如果值為True便瑟,就會給瀏覽器設(shè)置一個長期的cookie,用以恢復(fù)用戶會話番川。(譯注:由于cookie被加密存儲在客戶端到涂,這樣下次用戶打開網(wǎng)站時瀏覽器就會讀取cookie自動把用戶登錄進系統(tǒng)。
)
根據(jù)我們在第四章討論過的POST/Redirect/GET模式颁督,POST請求提交登錄信息并以重定向為結(jié)束践啄,但也有兩個可能的URL跳轉(zhuǎn)方向。如果是阻止為驗證用戶訪問某URL而顯示登錄表單沉御,F(xiàn)lask_login會把這一URL保存在“next”查詢字符串參數(shù)中屿讽,你可以從request.args字典中訪問。如果next查詢字符串參數(shù)無不可用吠裆,就以重定向到home頁面取代之伐谈。(譯注:即,如果登錄頁面有歷史url--你在登錄前試圖訪問的地址试疙,成功登錄后就自動轉(zhuǎn)向該url诵棵;反之,跳轉(zhuǎn)到首頁祝旷。
)如果用戶提供的email或密碼無效履澳,就會設(shè)置閃現(xiàn)消息并重新向用戶顯示登錄表單。
提醒:在生產(chǎn)服務(wù)器上缓屠,登錄路由必須設(shè)置為基于安全http(
譯注:https??
)奇昙,保證提交給服務(wù)器的表單數(shù)據(jù)經(jīng)過加密傳輸。沒有安全http的話敌完,登錄信息在傳輸時會被攔截——這樣在服務(wù)器上加密密碼的所有努力統(tǒng)統(tǒng)白費了。
登錄模板需要更新來顯示表單羊初。如例子8-12所示:
Example 8-12. app/templates/auth/login.html: Render login form
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Login{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Login</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
退出登錄
退出路由的實現(xiàn) 如例子8-13所示:
Example 8-13. app/auth/views.py: Sign Out route
from flask.ext.login import logout_user, login_required
@auth.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.')
return redirect(url_for('main.index'))
Flask-login調(diào)用了logout_user()函數(shù)來移除并重設(shè)用戶會話 滨溉,來退出登錄狀態(tài)。退出操作 設(shè)置了一個閃現(xiàn)消息確認(rèn)完成长赞,并重定向到首頁晦攒。
測試登錄
為了測試登錄函數(shù)正常工作,應(yīng)該更新首頁代碼來顯示已登錄的用戶名歡迎信息得哆。模板中的歡迎信息段如例子8-14:
Example 8-14. app/templates/index.html: Greet the logged-in user
Hello,
{% if current_user.is_authenticated() %}
{{ current_user.username }}
{% else %}
Stranger
{% endif %}!
在這個模板里脯颜,我們再次使用了current_user.is_authenticated()來判斷用戶是否登錄成功。
由于我們還沒有創(chuàng)建用戶注冊功能贩据,現(xiàn)在只能先從shell中添加一個新用戶:
(venv) $ python manage.py shell
>>> u = User(email='john@example.com', username='john', password='cat')
>>> db.session.add(u)
>>> db.session.commit()
上面注冊的用戶登錄后栋操,將在首頁顯示對他的歡迎信息闸餐,如圖8-2所示
用戶注冊
要成為程序的一個正式用戶,必須向系統(tǒng)注冊自己信息才能登錄矾芙。在登錄頁面上添加一個鏈接舍沙,供用戶跳轉(zhuǎn)到注冊頁面,來輸入email地址剔宪,用戶名和密碼來實現(xiàn)注冊拂铡。
添加注冊表單
注冊表單用于注冊頁面,來供用戶輸入email地址葱绒,用戶名和密碼感帅。如例子8-15:
Example 8-15. app/auth/forms.py: User registration form
from flask.ext.wtf import Form
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import Required, Length, Email, Regexp, EqualTo
from wtforms import ValidationError
from ..models import User
class RegistrationForm(Form):
email = StringField('Email', validators=[Required(), Length(1, 64),Email()])
username = StringField('Username', validators=[Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,'Usernames must have only letters, numbers, dots or underscores')])
password = PasswordField('Password',validators=[Required(), EqualTo('password2', message='Passwords must match.')])
password2 = PasswordField('Confirm password', validators=[Required()])
submit = SubmitField('Register')
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already in use.')
這個表單使用了WTForms內(nèi)置驗證器Regexp來確保username字段只包含字母、數(shù)字地淀、下劃線和點號留瞳。這個驗證器在表達式后面的兩個參數(shù)分別是正則表達式標(biāo)志位(原文the regular expression flags
<small> 不明白。</small>查看WTForm文檔:*flags* – The regexp flags to use, for example re.IGNORECASE. Ignored if regex is not a string.意思是像re.ignorecase<忽略大小寫>的話骚秦,如果regex是非字符串就忽略她倘,不執(zhí)行驗證?作箍?硬梁?更迷糊了
)和供驗證失敗顯示的錯誤信息。
為了安全胞得,要求輸入兩次密碼荧止。因此要保證這兩次輸入的內(nèi)容一致,就得使用WTForms的另外一個驗證器EqualTo進行驗證阶剑。這個驗證器被添加到密碼字段跃巡,把另外一個字段名作為參數(shù)。
表單還有兩個自定義的驗證器(作為表單的方法實現(xiàn))牧愁。一旦表單定義了一個方法素邪,其格式是前綴validate_+字段名,那么除了常規(guī)驗證器之外這個方法也會被調(diào)用猪半。本例中兔朦,自定義的email和username驗證器可以確保這兩個值不會與數(shù)據(jù)庫中已有值重復(fù)。如驗證失敗磨确,自定義驗證器將拋出一個帶有文本錯誤信息為參數(shù)的ValidationError錯誤沽甥。
顯示模板就是/tmplates/auth/register.html
。跟登錄(login)模板一樣乏奥,它也通過wft.quick_form()
來顯示表單摆舟。注冊頁面如圖8-3:
注冊頁面需要從登錄頁面鏈接過來,以便于沒有系統(tǒng)帳戶的用戶能找到注冊地方。更改如例子8-16所示:
Example 8-16. app/templates/auth/login.html: Link to the registration page
<p>
New user?
<a href="{{ url_for('auth.register') }}">Click here to register
</a>
</p>
注冊新用戶
對用戶注冊的處理沒有什么奇特之處恨诱。當(dāng)提交注冊表并驗證通過后媳瞪,一個新帳戶就被添加到數(shù)據(jù)庫里。完成這一任務(wù)的視圖函數(shù)如例子8-17所示:
Example 8-17. app/auth/views.py: User registration route
@auth.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
user = User(email=form.email.data,username=form.username.data,password=form.password.data)
db.session.add(user)
flash('You can now login.')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
帳戶確認(rèn)
對于某些類型的程序來說胡野,確認(rèn)用戶信息真實有效非常重要材失。一個最常見的要求是可以通過用戶的郵件聯(lián)系該用戶。
要驗證email地址硫豆,程序只需在用戶注冊完成后立即向用戶的email發(fā)送一封確認(rèn)郵件龙巨。用戶的新帳戶被初始化為未確認(rèn),直到根據(jù)郵件中的指示操作完成才會正式啟用熊响。帳戶確認(rèn)過程一般是點擊一個特定的包含確認(rèn)令牌的URL鏈接旨别。
使用itsdangerous生成確認(rèn)令牌
最簡單的帳戶確認(rèn)鏈接就是一個包含在email中的http://www.example.com/auth/confirm/<id>
格式的URL,此處的id就是用戶在數(shù)據(jù)庫中的主鍵id的數(shù)值汗茄。當(dāng)用戶點擊鏈接秸弛,視圖函數(shù)就會處理該路由,把id作為確認(rèn)參數(shù)來更新用戶的確認(rèn)狀態(tài)洪碳。
但這絕對不是一個安全的處理递览,任何用戶只要能識別出確認(rèn)鏈接的格式就能通過URL來發(fā)送隨機數(shù)字確認(rèn)任意帳戶。所以瞳腌,用一個包含同樣但加密了的信息的令牌來替換url中的id就更安全些绞铃。
如可能你還記得我們在第四章用戶會話中的那些討論,F(xiàn)lask使用了加密簽名的cookies來保護用戶會話不受攻擊嫂侍。這些安全cookie都是使用itsdangerouse包來加密的儿捧。同樣,我們可以把這一思路用于確認(rèn)令牌挑宠。
下面是一個簡短的shell會話菲盾,展示了itsdangerous如何生成包含用戶id的安全令牌:
(venv) $ python manage.py shell
>>> from manage import app
>>> from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
>>> s = Serializer(app.config['SECRET_KEY'], expires_in = 3600)
>>> token = s.dumps({ 'confirm': 23 })
>>> token
'eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4MTcxODU1OCwiaWF0IjoxMzgxNzE0OTU4fQ.ey ...'
>>> data = s.loads(token)
>>> data
{u'confirm': 23}
itsdangerous提供了多種令牌生成方式。其中各淀,TimedJSONWebSignatureSerializer類會生成帶有超時限制的JSON Web簽名(JWS懒鉴,包含了時間戳。)揪阿。這個類的構(gòu)造函數(shù)需要一個加密密匙(key)作為參數(shù)疗我,你可以使用Flask程序配置中的SECRET_KEY。
發(fā)送確認(rèn)郵件
當(dāng)前/register路由在完成添加用戶之后就會重定向到/index南捂。在重定向之前,這個路由需要發(fā)送確認(rèn)郵件旧找。變更如例子8-19所示:
Example 8-19. app/auth/views.py: Registration route with confirmation email
from ..email import send_email
@auth.route('/register', methods = ['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
# ...
db.session.add(user)
db.session.commit()
token = user.generate_confirmation_token()
send_email(user.email, 'Confirm Your Account','auth/email/confirm', user=user, token=token)
flash('A confirmation email has been sent to you by email.')
return redirect(url_for('main.index'))
return render_template('auth/register.html', form=form)
注意溺健,即使程序配置中設(shè)置了請求結(jié)束時自動提交,也必須顯式調(diào)用db.session.commit()
。這是因為只有提交到數(shù)據(jù)庫后鞭缭,新用戶才會取得id剖膳,這樣生成確認(rèn)令牌時才有id可用——不能延遲提交(譯注:程序配置是在請求結(jié)束后再提交,在這里明顯是晚了:Config類中 SQLALCHEMY_COMMIT_ON_TEARDOWN = True
)岭辣。
認(rèn)證藍(lán)圖使用的email模板存儲在templates/auth/email
文件夾吱晒,與其他模板分離。就行第六章討論的那樣沦童,每個email有兩個正文模板——純文本和富文本格式仑濒。例子8-20展示了確認(rèn)郵件模板的純文本格式,你可以在我的Github庫里找到HTML版本的模板偷遗。
Example 8-20. app/auth/templates/auth/email/confirm.txt: Text body of confirmation email
Dear {{ user.username }},
Welcome to Flasky!
To confirm your account please click on the following link:
{{ url_for('auth.confirm', token=token, _external=True) }}
Sincerely,
The Flasky Team
Note: replies to this email address are not monitored.
默認(rèn)情況下墩瞳,url_for()會生成一個相對路徑的url,所以氏豌,像url_for('auth.conrim',token='abc' )將返回字符串'/auth/confirm/abc'喉酌。這個對于通過郵件來發(fā)送的地址來說當(dāng)然是無效的——在程序內(nèi)部使用相對地址沒有問題,是因為瀏覽器會利用當(dāng)前頁面的主機名和端口把它轉(zhuǎn)換成絕對地址泵喘。但通過email發(fā)送出的url可是沒有轉(zhuǎn)換需要的上下文的泪电。所以_external=True
參數(shù)就用上了,這樣url_for()就可以生成一個包含協(xié)議名稱(http://或https://
)以及主機名纪铺,端口信息的完整url(絕對URL地址
)相速。
確認(rèn)帳戶的視圖函數(shù)如例子8-21所示:
Example 8-21. app/auth/views.py: Confirm a user account
from flask.ext.login import current_user
@auth.route('/confirm/<token>')
@login_required
def confirm(token):
if current_user.confirmed:
return redirect(url_for('main.index'))
if current_user.confirm(token):
flash('You have confirmed your account. Thanks!')
else:
flash('The confirmation link is invalid or has expired.')
return redirect(url_for('main.index'))
這個路由有裝飾器login_required保護,所以用戶點擊鏈接訪問這個視圖函數(shù)之前需要登錄進入系統(tǒng)才行霹陡。這個函數(shù)首先檢查登錄用戶是否已經(jīng)確認(rèn)和蚪,要是已經(jīng)確認(rèn)就會直接跳轉(zhuǎn)到首頁。這主要是避免用戶多次|誤點確認(rèn)鏈接導(dǎo)致不必要的工作烹棉。
因為實際的令牌校驗工作是在User模型中完成攒霹,所以視圖函數(shù)要做的也就是調(diào)用confirm方法,然后根據(jù)結(jié)果閃現(xiàn)消息浆洗。如果確認(rèn)成功催束,User模型的confirmed屬性就會改變并被添加到會話中,在請求結(jié)束的時候會被提交到數(shù)據(jù)庫更新伏社。
程序可以自行決定未經(jīng)確認(rèn)的用戶在確認(rèn)帳戶之前可以做什么抠刺。一個可能是允許未確認(rèn)用戶登錄,但是只顯示一個要求進行帳戶確認(rèn)的頁面摘昌。
這一步可以使用Flask的before_request鉤子速妖,在第二章中有過描述。在藍(lán)圖中聪黎,before_request鉤子只會響應(yīng)本藍(lán)圖的請求罕容。要想在藍(lán)圖中使用整個程序范圍的鉤子,就必須使用before_app_request裝飾器了。例子8-22說明了如何實現(xiàn)這一處理器:
Example 8-22. app/auth/views.py: Filter unconfirmed accounts in before_app_request handler
@auth.before_app_request
def before_request():
if current_user.is_authenticated() and not current_user.confirmed and request.endpoint[:5] != 'auth.':
return redirect(url_for('auth.unconfirmed'))
@auth.route('/unconfirmed')
def unconfirmed():
if current_user.is_anonymous() or current_user.confirmed:
return redirect('main.index')
return render_template('auth/unconfirmed.html')
如果三個條件為真锦秒,before_app_request處理器將中斷一個請求:
- 用戶已登錄(current_user.is_authenticated()的返回為True)
- 該用戶帳戶未經(jīng)確認(rèn)
- 請求的端點|結(jié)束點(可寫作request.endpoint露泊,
譯注:也就是路由名稱
)超出了認(rèn)證藍(lán)圖(auth)的范圍。要訪問的這個認(rèn)證藍(lán)圖的路由需要授權(quán)旅择,比如那些允許用戶確認(rèn)帳戶或執(zhí)行其他帳戶管理的函數(shù)(譯注:迷糊了:Access to the authentication routes needs to be granted, as those are the routes that will enable the user to confirm the account or perform other account management functions.
)
如果這三個條件都符合惭笑,那個就會觸發(fā)一個重定向,轉(zhuǎn)到/auth/unconfirmed
路由生真,顯示一個關(guān)于帳戶確認(rèn)信息的頁面沉噩。
警告:before_request或before_app_request回調(diào)返回一個響應(yīng)或者一個重定向,F(xiàn)lask將把它發(fā)送給客戶端汇歹,而不會再調(diào)用與這一請求相關(guān)的視圖函數(shù)屁擅。這樣,回調(diào)就能在必要的時候中斷請求产弹。
顯示的確認(rèn)信息頁面(圖8-4)只是給未確認(rèn)用戶顯示一個模板派歌,介紹如何確認(rèn)其帳戶,并附加一個鏈接用戶重新請求確認(rèn)郵件——如原始確認(rèn)郵件丟失痰哨。例子8-23路由將再次發(fā)送一個確認(rèn)郵件:
Example 8-23. app/auth/views.py: Resend account confirmation email
@auth.route('/confirm')
@login_required
def resend_confirmation():
token = current_user.generate_confirmation_token()
send_email('auth/email/confirm','Confirm Your Account', user, token=token)
flash('A new confirmation email has been sent to you by email.')
return redirect(url_for('main.index'))
這個路由使用current_user(已登錄用戶)作為目標(biāo)用戶胶果,再次重復(fù)了注冊路由的工作(給用戶發(fā)送確認(rèn)郵件)。這個路由其也是被login_required裝飾器保護的斤斧,這樣確保訪問路由的發(fā)送這一請求用戶已登錄早抠。圖8-4
帳戶管理
程序的用戶可能不時的需要更改自己的帳戶信息,我們可以根據(jù)本章介紹的技術(shù)來給認(rèn)證藍(lán)圖添加一些功能:
-
密碼更改
具有安全意識的用戶可能不定期更改自己的密碼撬讽。這個功能很容易實現(xiàn)蕊连,因為一旦用戶登錄系統(tǒng),我們可以比較安全的詢問其舊密碼和輸入新密碼游昼,通過表單進行新舊替換即可甘苍。 -
密碼重置
為了防止合法用戶遺忘密碼后被擋在系統(tǒng)之外,我們需要提供一個密碼重置的選項烘豌。為了安全實現(xiàn)密碼重試载庭,需要像進行帳戶確認(rèn)那樣使用一個加密令牌。這樣廊佩,一旦用戶請求密碼重設(shè)囚聚,習(xí)題集就給注冊的Emailizhi發(fā)送一個重設(shè)令牌。用戶點擊郵件中的鏈接标锄,驗證令牌通過后顽铸,通過表單輸入新的密碼即可完成重置。 -
變更Email地址
用戶可能也需要更改其注冊的email地址料皇,同樣需要通過一封確認(rèn)郵件來驗證這一新email地址跋破。用戶在表單中輸入新地址然后系統(tǒng)就給新地址發(fā)送令牌簸淀。一旦服務(wù)器回收到令牌瓶蝴,就可以驗證并更新用戶對象了毒返。在服務(wù)器等待回收令牌期間,他會把新地址保存到一個數(shù)據(jù)庫新字段中(附加email地址)舷手,或者可以把心郵件地址和id一起保存到令牌中拧簸。
注:以上三個功能還是參照注冊,確認(rèn)帳戶兩步男窟,利用current_user盆赤,分別生成token后進行更新即可
在下一章,我們將擴展用戶子系統(tǒng)歉眷,加入用戶角色牺六。