【百度云搜索,搜各種資料:http://bdy.lqkweb.com】
【搜網(wǎng)盤,搜各種資料:http://www.swpan.cn】
本文翻譯自The Flask Mega-Tutorial Part V: User Logins
這是Flask Mega-Tutorial系列的第五部分,我將告訴你如何創(chuàng)建一個用戶登錄子系統(tǒng)。
你在第三章中學(xué)會了如何創(chuàng)建用戶登錄表單,在第四章中學(xué)會了運用數(shù)據(jù)庫。本章將教你如何結(jié)合這兩章的主題來創(chuàng)建一個簡單的用戶登錄系統(tǒng)。
本章的GitHub鏈接為:Browse, Zip, Diff.
密碼哈希
在第四章中,用戶模型設(shè)置了一個password_hash字段,到目前為止還沒有被使用到。 這個字段的目的是保存用戶密碼的哈希值,并用于驗證用戶在登錄過程中輸入的密碼。 密碼哈希的實現(xiàn)是一個復(fù)雜的話題,應(yīng)該由安全專家來搞定,不過,已經(jīng)有數(shù)個現(xiàn)成的簡單易用且功能完備加密庫存在了。
其中一個實現(xiàn)密碼哈希的包是Werkzeug,當(dāng)安裝Flask時,你可能會在pip的輸出中看到這個包,因為它是Flask的一個核心依賴項。 所以,Werkzeug已經(jīng)安裝在你的虛擬環(huán)境中。 以下Python shell會話演示了如何哈希密碼:
>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'pbkdf2:sha256:50000$vT9fkZM8$04dfa35c6476acf7e788a1b5b3c35e217c78dc04539d295f011f01f18cd2175f'
在這個例子中,通過一系列已知沒有反向操作的加密操作,將密碼foobar轉(zhuǎn)換成一個長編碼字符串,這意味著獲得密碼哈希值的人將無法使用它逆推出原始密碼。 作為一個附加手段,多次哈希相同的密碼,你將得到不同的結(jié)果,所以這使得無法通過查看它們的哈希值來確定兩個用戶是否具有相同的密碼。
驗證過程使用Werkzeug的第二個函數(shù)來完成,如下所示:
>>> from werkzeug.security import check_password_hash
>>> check_password_hash(hash, 'foobar')
True
>>> check_password_hash(hash, 'barfoo')
False
向驗證函數(shù)傳入之前生成的密碼哈希值以及用戶在登錄時輸入的密碼,如果用戶提供的密碼執(zhí)行哈希過程后與存儲的哈希值匹配,則返回True,否則返回False。
整個密碼哈希邏輯可以在用戶模型中實現(xiàn)為兩個新的方法:
from werkzeug.security import generate_password_hash, check_password_hash
# ...
class User(db.Model):
# ...
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
使用這兩種方法,用戶對象現(xiàn)在可以在無需持久化存儲原始密碼的條件下執(zhí)行安全的密碼驗證。 以下是這些新方法的示例用法:
>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('mypassword')
>>> u.check_password('anotherpassword')
False
>>> u.check_password('mypassword')
True
Flask-Login簡介
在本章中,我將向你介紹一個非常受歡迎的Flask插件Flask-Login。 該插件管理用戶登錄狀態(tài),以便用戶可以登錄到應(yīng)用,然后用戶在導(dǎo)航到該應(yīng)用的其他頁面時,應(yīng)用會“記得”該用戶已經(jīng)登錄。它還提供了“記住我”的功能,允許用戶在關(guān)閉瀏覽器窗口后再次訪問應(yīng)用時保持登錄狀態(tài)??梢韵仍谀愕奶摂M環(huán)境中安裝Flask-Login來做好準備工作:
(venv) $ pip install flask-login
和其他插件一樣,F(xiàn)lask-Login需要在app/__init__py中的應(yīng)用實例之后被創(chuàng)建和初始化。 該插件初始化代碼如下:
# ...
from flask_login import LoginManager
app = Flask(__name__)
# ...
login = LoginManager(app)
# ...
為Flask-Login準備用戶模型
Flask-Login插件需要在用戶模型上實現(xiàn)某些屬性和方法。這種做法很棒,因為只要將這些必需項添加到模型中,F(xiàn)lask-Login就沒有其他依賴了,它就可以與基于任何數(shù)據(jù)庫系統(tǒng)的用戶模型一起工作。
必須的四項如下:
-
is_authenticated: 一個用來表示用戶是否通過登錄認證的屬性,用True和False表示。 -
is_active: 如果用戶賬戶是活躍的,那么這個屬性是True,否則就是False(譯者注:活躍用戶的定義是該用戶的登錄狀態(tài)是否通過用戶名密碼登錄,通過“記住我”功能保持登錄狀態(tài)的用戶是非活躍的)。 -
is_anonymous: 常規(guī)用戶的該屬性是False,對特定的匿名用戶是True。 -
get_id(): 返回用戶的唯一id的方法,返回值類型是字符串(Python 2下返回unicode字符串).
我可以很容易地實現(xiàn)這四個屬性或方法,但是由于它們是相當(dāng)通用的,因此Flask-Login提供了一個叫做UserMixin的mixin類來將它們歸納其中。 下面演示了如何將mixin類添加到模型中:
# ...
from flask_login import UserMixin
class User(UserMixin, db.Model):
# ...
用戶加載函數(shù)
用戶會話是Flask分配給每個連接到應(yīng)用的用戶的存儲空間,F(xiàn)lask-Login通過在用戶會話中存儲其唯一標識符來跟蹤登錄用戶。每當(dāng)已登錄的用戶導(dǎo)航到新頁面時,F(xiàn)lask-Login將從會話中檢索用戶的ID,然后將該用戶實例加載到內(nèi)存中。
因為數(shù)據(jù)庫對Flask-Login透明,所以需要應(yīng)用來輔助加載用戶。 基于此,插件期望應(yīng)用配置一個用戶加載函數(shù),可以調(diào)用該函數(shù)來加載給定ID的用戶。 該功能可以添加到app/models.py模塊中:
from app import login
# ...
@login.user_loader
def load_user(id):
return User.query.get(int(id))
使用Flask-Login的@login.user_loader裝飾器來為用戶加載功能注冊函數(shù)。 Flask-Login將字符串類型的參數(shù)id傳入用戶加載函數(shù),因此使用數(shù)字ID的數(shù)據(jù)庫需要如上所示地將字符串轉(zhuǎn)換為整數(shù)。
用戶登入
讓我們回顧一下登錄視圖函數(shù),它實現(xiàn)了一個模擬登錄,只發(fā)出一個flash()消息。 現(xiàn)在,應(yīng)用可以訪問用戶數(shù)據(jù),并知道如何生成和驗證密碼哈希值,該視圖函數(shù)就可以完工了。
# ...
from flask_login import current_user, login_user
from app.models import User
# ...
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
return redirect(url_for('index'))
return render_template('login.html', title='Sign In', form=form)
login()函數(shù)中的前兩行處理一個非預(yù)期的情況:假設(shè)用戶已經(jīng)登錄,卻導(dǎo)航到應(yīng)用的/login URL。 顯然這是一個不可能允許的錯誤場景。 current_user變量來自Flask-Login,可以在處理過程中的任何時候調(diào)用以獲取用戶對象。 這個變量的值可以是數(shù)據(jù)庫中的一個用戶對象(Flask-Login通過我上面提供的用戶加載函數(shù)回調(diào)讀取),或者如果用戶還沒有登錄,則是一個特殊的匿名用戶對象。 還記得那些Flask-Login必須的用戶對象屬性? 其中之一是is_authenticated,它可以方便地檢查用戶是否登錄。 當(dāng)用戶已經(jīng)登錄,我只需要重定向到主頁。
相比之前的調(diào)用flash()顯示消息模擬登錄,現(xiàn)在我可以真實地登錄用戶。 第一步是從數(shù)據(jù)庫加載用戶。 利用表單提交的username,我可以查詢數(shù)據(jù)庫以找到用戶。 為此,我使用了SQLAlchemy查詢對象的filter_by()方法。 filter_by()的結(jié)果是一個只包含具有匹配用戶名的對象的查詢結(jié)果集。 因為我知道查詢用戶的結(jié)果只可能是有或者沒有,所以我通過調(diào)用first()來完成查詢,如果存在則返回用戶對象;如果不存在則返回None。 在第四章中,你已經(jīng)看到當(dāng)你在查詢中調(diào)用all()方法時, 將執(zhí)行該查詢并獲得與該查詢匹配的所有結(jié)果的列表。 當(dāng)你只需要一個結(jié)果時,通常使用first()方法。
如果使用提供的用戶名執(zhí)行查詢并成功匹配,我可以接下來通過調(diào)用上面定義的check_password()方法來檢查表單中隨附的密碼是否有效。 密碼驗證時,將驗證存儲在數(shù)據(jù)庫中的密碼哈希值與表單中輸入的密碼的哈希值是否匹配。 所以,現(xiàn)在我有兩個可能的錯誤情況:用戶名可能是無效的,或者用戶密碼是錯誤的。 在這兩種情況下,我都會閃現(xiàn)一條消息,然后重定向到登錄頁面,以便用戶可以再次嘗試。
如果用戶名和密碼都是正確的,那么我調(diào)用來自Flask-Login的login_user()函數(shù)。 該函數(shù)會將用戶登錄狀態(tài)注冊為已登錄,這意味著用戶導(dǎo)航到任何未來的頁面時,應(yīng)用都會將用戶實例賦值給current_user變量。
然后,只需將新登錄的用戶重定向到主頁,我就完成了整個登錄過程。
用戶登出
提供一個用戶登出的途徑也是必須的,我將會通過Flask-Login的logout_user()函數(shù)來實現(xiàn)。其視圖函數(shù)代碼如下:
# ...
from flask_login import logout_user
# ...
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('index'))
為了給用戶暴露登出鏈接,我會在導(dǎo)航欄上實現(xiàn)當(dāng)用戶登錄之后,登錄鏈接自動轉(zhuǎn)換成登出鏈接。修改base.html模板的導(dǎo)航欄部分后,代碼如下:
<div>
Microblog:
<a href="{{ url_for('index') }}">Home</a>
{% if current_user.is_anonymous %}
<a href="{{ url_for('login') }}">Login</a>
{% else %}
<a href="{{ url_for('logout') }}">Logout</a>
{% endif %}
</div>
用戶實例的is_anonymous屬性是在其模型繼承UserMixin類后Flask-Login添加的,表達式current_user.is_anonymous僅當(dāng)用戶未登錄時的值是True。
要求用戶登錄
Flask-Login提供了一個非常有用的功能——強制用戶在查看應(yīng)用的特定頁面之前登錄。 如果未登錄的用戶嘗試查看受保護的頁面,F(xiàn)lask-Login將自動將用戶重定向到登錄表單,并且只有在登錄成功后才重定向到用戶想查看的頁面。
為了實現(xiàn)這個功能,F(xiàn)lask-Login需要知道哪個視圖函數(shù)用于處理登錄認證。在app/__init__.py中添加代碼如下:
# ...
login = LoginManager(app)
login.login_view = 'login'
上面的'login'值是登錄視圖函數(shù)(endpoint)名,換句話說該名稱可用于url_for()函數(shù)的參數(shù)并返回對應(yīng)的URL。
Flask-Login使用名為@login_required的裝飾器來拒絕匿名用戶的訪問以保護某個視圖函數(shù)。 當(dāng)你將此裝飾器添加到位于@app.route裝飾器下面的視圖函數(shù)上時,該函數(shù)將受到保護,不允許未經(jīng)身份驗證的用戶訪問。 以下是該裝飾器如何應(yīng)用于應(yīng)用的主頁視圖函數(shù)的案例:
from flask_login import login_required
@app.route('/')
@app.route('/index')
@login_required
def index():
# ...
剩下的就是實現(xiàn)登錄成功之后自定重定向回到用戶之前想要訪問的頁面。 當(dāng)一個沒有登錄的用戶訪問被@login_required裝飾器保護的視圖函數(shù)時,裝飾器將重定向到登錄頁面,不過,它將在這個重定向中包含一些額外的信息以便登錄后的回轉(zhuǎn)。 例如,如果用戶導(dǎo)航到/index,那么@login_required裝飾器將攔截請求并以重定向到/login來響應(yīng),但是它會添加一個查詢字符串參數(shù)來豐富這個URL,如/login?next=/index。 原始URL設(shè)置了next查詢字符串參數(shù)后,應(yīng)用就可以在登錄后使用它來重定向。
下面是一段代碼,展示了如何讀取和處理next查詢字符串參數(shù):
from flask import request
from werkzeug.urls import url_parse
@app.route('/login', methods=['GET', 'POST'])
def login():
# ...
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('index')
return redirect(next_page)
# ...
在用戶通過調(diào)用Flask-Login的login_user()函數(shù)登錄后,應(yīng)用獲取了next查詢字符串參數(shù)的值。 Flask提供一個request變量,其中包含客戶端隨請求發(fā)送的所有信息。 特別是request.args屬性,可用友好的字典格式暴露查詢字符串的內(nèi)容。 實際上有三種可能的情況需要考慮,以確定成功登錄后重定向的位置:
- 如果登錄URL中不含
next參數(shù),那么將會重定向到本應(yīng)用的主頁。 - 如果登錄URL中包含
next參數(shù),其值是一個相對路徑(換句話說,該URL不含域名信息),那么將會重定向到本應(yīng)用的這個相對路徑。 - 如果登錄URL中包含
next參數(shù),其值是一個包含域名的完整URL,那么重定向到本應(yīng)用的主頁。
前兩種情況很好理解,第三種情況是為了使應(yīng)用更安全。 攻擊者可以在next參數(shù)中插入一個指向惡意站點的URL,因此應(yīng)用僅在重定向URL是相對路徑時才執(zhí)行重定向,這可確保重定向與應(yīng)用保持在同一站點中。 為了確定URL是相對的還是絕對的,我使用Werkzeug的url_parse()函數(shù)解析,然后檢查netloc屬性是否被設(shè)置。
在模板中顯示已登錄的用戶
你還記得在實現(xiàn)用戶子系統(tǒng)之前的第二章中,我創(chuàng)建了一個模擬的用戶來幫助我設(shè)計主頁的事情嗎? 現(xiàn)在,應(yīng)用實現(xiàn)了真正的用戶,我就可以刪除模擬用戶了。 取而代之,我會在模板中使用Flask-Login的current_user:
{% extends "base.html" %}
{% block content %}
<h1>Hi, {{ current_user.username }}!</h1>
{% for post in posts %}
<div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
{% endfor %}
{% endblock %}
并且我可以在視圖函數(shù)傳入渲染模板函數(shù)的參數(shù)中刪除user了:
@app.route('/')
@app.route('/index')
def index():
# ...
return render_template("index.html", title='Home Page', posts=posts)
這正是測試登錄和注銷功能運作機制的好時機。 由于仍然沒有用戶注冊功能,所以添加用戶到數(shù)據(jù)庫的唯一方法是通過Python shell執(zhí)行,所以運行flask shell并輸入以下命令來注冊用戶:
>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()
如果啟動應(yīng)用并嘗試訪問http://localhost:5000/或http://localhost:5000/index,會立即重定向到登錄頁面。在使用之前添加到數(shù)據(jù)庫的憑據(jù)登錄后,就會跳轉(zhuǎn)回到之前訪問的頁面,并看到其中的個性化歡迎。
用戶注冊
本章要構(gòu)建的最后一項功能是注冊表單,以便用戶可以通過Web表單進行注冊。 讓我們在app/forms.py中創(chuàng)建Web表單類來開始吧:
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.models import User
# ...
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
password2 = PasswordField(
'Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError('Please use a different username.')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Please use a different email address.')
代碼中與驗證相關(guān)的幾處相當(dāng)有趣。首先,對于email字段,我在DataRequired之后添加了第二個驗證器,名為Email。 這個來自WTForms的另一個驗證器將確保用戶在此字段中鍵入的內(nèi)容與電子郵件地址的結(jié)構(gòu)相匹配。
由于這是一個注冊表單,習(xí)慣上要求用戶輸入密碼兩次,以減少輸入錯誤的風(fēng)險。 出于這個原因,我提供了password和password2字段。 第二個password字段使用另一個名為EqualTo的驗證器,它將確保其值與第一個password字段的值相同。
我還為這個類添加了兩個方法,名為validate_username()和validate_email()。 當(dāng)添加任何匹配模式validate_ <field_name>的方法時,WTForms將這些方法作為自定義驗證器,并在已設(shè)置驗證器之后調(diào)用它們。 本處,我想確保用戶輸入的username和email不會與數(shù)據(jù)庫中已存在的數(shù)據(jù)沖突,所以這兩個方法執(zhí)行數(shù)據(jù)庫查詢,并期望結(jié)果集為空。 否則,則通過ValidationError觸發(fā)驗證錯誤。 異常中作為參數(shù)的消息將會在對應(yīng)字段旁邊顯示,以供用戶查看。
我需要一個HTML模板以便在網(wǎng)頁上顯示這個表單,我其存儲在app/templates/register.html文件中。 這個模板的構(gòu)造與登錄表單類似:
{% extends "base.html" %}
{% block content %}
<h1>Register</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}<br>
{% for error in form.username.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<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.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 %}
登錄表單模板需要在其表單之下添加一個鏈接來將未注冊的用戶引導(dǎo)到注冊頁面:
<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
最后,我來實現(xiàn)處理用戶注冊的視圖函數(shù),存儲在app/routes.py中,代碼如下:
from app import db
from app.forms import RegistrationForm
# ...
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Congratulations, you are now a registered user!')
return redirect(url_for('login'))
return render_template('register.html', title='Register', form=form)
這個視圖函數(shù)的邏輯也是一目了然,我首先確保調(diào)用這個路由的用戶沒有登錄。表單的處理方式和登錄的方式一樣。在if validate_on_submit()條件塊下,完成的邏輯如下:使用獲取自表單的username、email和password創(chuàng)建一個新用戶,將其寫入數(shù)據(jù)庫,然后重定向到登錄頁面以便用戶登錄。

精雕細琢之后,用戶已經(jīng)能夠在此應(yīng)用上注冊帳戶,并進行登錄和注銷。 請確保你嘗試了我在注冊表單中添加的所有驗證功能,以便更好地了解其工作原理。 我將在未來的章節(jié)中再次更新用戶認證子系統(tǒng),以增加額外的功能,比如允許用戶在忘記密碼的情況下重置密碼。 不過對于目前的應(yīng)用來講,這已經(jīng)無礙于繼續(xù)構(gòu)建了。