本文轉載自: https://www.jianshu.com/p/cb5e8633e22e
這是Flask Mega-Tutorial系列的第五部分,我将告訴你如何建立一個使用者登入子系統。
你在
第三章 中學會了如何建立使用者登入表單,在 第四章中學會了運用資料庫。本章将教你如何結合這兩章的主題來建立一個簡單的使用者登入系統。
本章的GitHub連結為:
Browse , Zip Diff .密碼哈希
在
中,使用者模型設定了一個
password_hash
字段,到目前為止還沒有被使用到。 這個字段的目的是儲存使用者密碼的哈希值,并用于驗證使用者在登入過程中輸入的密碼。 密碼哈希的實作是一個複雜的話題,應該由安全專家來搞定,不過,已經有數個現成的簡單易用且功能完備加密庫存在了。
其中一個實作密碼哈希的包是
Werkzeug,當安裝Flask時,你可能會在pip的輸出中看到這個包,因為它是Flask的一個核心依賴項。 是以,Werkzeug已經安裝在你的虛拟環境中。 以下Python shell會話示範了如何哈希密碼:
>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'pbkdf2:sha256:50000$vT9fkZM8$04dfa35c6476acf7e788a1b5b3c35e217c78dc04539d295f011f01f18cd2175f'
在這個例子中,通過一系列已知沒有反向操作的加密操作,将密碼
foobar
轉換成一個長編碼字元串,這意味着獲得密碼哈希值的人将無法使用它逆推出原始密碼。 作為一個附加手段,多次哈希相同的密碼,你将得到不同的結果,是以這使得無法通過檢視它們的哈希值來确定兩個使用者是否具有相同的密碼。
驗證過程使用Werkzeug的第二個函數來完成,如下所示:
>>> from werkzeug.security import check_password_hash
>>> check_password_hash(hash, 'foobar')
True
>>> check_password_hash(hash, 'barfoo')
False
向驗證函數傳入之前生成的密碼哈希值以及使用者在登入時輸入的密碼,如果使用者提供的密碼執行哈希過程後與存儲的哈希值比對,則傳回
True
,否則傳回
False
。
整個密碼哈希邏輯可以在使用者模型中實作為兩個新的方法:
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)
使用這兩種方法,使用者對象現在可以在無需持久化存儲原始密碼的條件下執行安全的密碼驗證。 以下是這些新方法的示例用法:
>>> u = User(username='susan', email='[email protected]')
>>> u.set_password('mypassword')
>>> u.check_password('anotherpassword')
False
>>> u.check_password('mypassword')
True
Flask-Login簡介
在本章中,我将向你介紹一個非常受歡迎的Flask插件
Flask-Login。 該插件管理使用者登入狀态,以便使用者可以登入到應用,然後使用者在導航到該應用的其他頁面時,應用會“記得”該使用者已經登入。它還提供了“記住我”的功能,允許使用者在關閉浏覽器視窗後再次通路應用時保持登入狀态。可以先在你的虛拟環境中安裝Flask-Login來做好準備工作:
(venv) $ pip install flask-login
和其他插件一樣,Flask-Login需要在
app/__init__py
中的應用執行個體之後被建立和初始化。 該插件初始化代碼如下:
# ...
from flask_login import LoginManager
app = Flask(__name__)
# ...
login = LoginManager(app)
# ...
為Flask-Login準備使用者模型
Flask-Login插件需要在使用者模型上實作某些屬性和方法。這種做法很棒,因為隻要将這些必需項添加到模型中,Flask-Login就沒有其他依賴了,它就可以與基于任何資料庫系統的使用者模型一起工作。
必須的四項如下:
-
: 一個用來表示使用者是否通過登入認證的屬性,用is_authenticated
和True
表示。False
-
: 如果使用者賬戶是活躍的,那麼這個屬性是is_active
,否則就是True
(譯者注:活躍使用者的定義是該使用者的登入狀态是否通過使用者名密碼登入,通過“記住我”功能保持登入狀态的使用者是非活躍的)。False
-
: 正常使用者的該屬性是is_anonymous
,對特定的匿名使用者是False
True
-
: 傳回使用者的唯一id的方法,傳回值類型是字元串(Python 2下傳回unicode字元串).get_id()
我可以很容易地實作這四個屬性或方法,但是由于它們是相當通用的,是以Flask-Login提供了一個叫做
UserMixin
的mixin類來将它們歸納其中。 下面示範了如何将mixin類添加到模型中:
# ...
from flask_login import UserMixin
class User(UserMixin, db.Model):
# ...
使用者加載函數
使用者會話是Flask配置設定給每個連接配接到應用的使用者的存儲空間,Flask-Login通過在使用者會話中存儲其唯一辨別符來跟蹤登入使用者。每當已登入的使用者導航到新頁面時,Flask-Login将從會話中檢索使用者的ID,然後将該使用者執行個體加載到記憶體中。
因為資料庫對Flask-Login透明,是以需要應用來輔助加載使用者。 基于此,插件期望應用配置一個使用者加載函數,可以調用該函數來加載給定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
裝飾器來為使用者加載功能注冊函數。 Flask-Login将字元串類型的參數
id
傳入使用者加載函數,是以使用數字ID的資料庫需要如上所示地将字元串轉換為整數。
使用者登入
讓我們回顧一下登入視圖函數,它實作了一個模拟登入,隻發出一個
flash()
消息。 現在,應用可以通路使用者資料,并知道如何生成和驗證密碼哈希值,該視圖函數就可以完工了。
# ...
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()
函數中的前兩行處理一個非預期的情況:假設使用者已經登入,卻導航到應用的/login URL。 顯然這是一個不可能允許的錯誤場景。
current_user
變量來自Flask-Login,可以在處理過程中的任何時候調用以擷取使用者對象。 這個變量的值可以是資料庫中的一個使用者對象(Flask-Login通過我上面提供的使用者加載函數回調讀取),或者如果使用者還沒有登入,則是一個特殊的匿名使用者對象。 還記得那些Flask-Login必須的使用者對象屬性? 其中之一是
is_authenticated
,它可以友善地檢查使用者是否登入。 當使用者已經登入,我隻需要重定向到首頁。
相比之前的調用
flash()
顯示消息模拟登入,現在我可以真實地登入使用者。 第一步是從資料庫加載使用者。 利用表單送出的username,我可以查詢資料庫以找到使用者。 為此,我使用了SQLAlchemy查詢對象的
filter_by()
方法。
filter_by()
的結果是一個隻包含具有比對使用者名的對象的查詢結果集。 因為我知道查詢使用者的結果隻可能是有或者沒有,是以我通過調用
first()
來完成查詢,如果存在則傳回使用者對象;如果不存在則傳回None。 在
中,你已經看到當你在查詢中調用
all()
方法時, 将執行該查詢并獲得與該查詢比對的所有結果的清單。 當你隻需要一個結果時,通常使用
first()
如果使用提供的使用者名執行查詢并成功比對,我可以接下來通過調用上面定義的
check_password()
方法來檢查表單中随附的密碼是否有效。 密碼驗證時,将驗證存儲在資料庫中的密碼哈希值與表單中輸入的密碼的哈希值是否比對。 是以,現在我有兩個可能的錯誤情況:使用者名可能是無效的,或者使用者密碼是錯誤的。 在這兩種情況下,我都會閃現一條消息,然後重定向到登入頁面,以便使用者可以再次嘗試。
如果使用者名和密碼都是正确的,那麼我調用來自Flask-Login的
login_user()
函數。 該函數會将使用者登入狀态注冊為已登入,這意味着使用者導航到任何未來的頁面時,應用都會将使用者執行個體指派給
current_user
變量。
然後,隻需将新登入的使用者重定向到首頁,我就完成了整個登入過程。
使用者登出
提供一個使用者登出的途徑也是必須的,我将會通過Flask-Login的
logout_user()
函數來實作。其視圖函數代碼如下:
# ...
from flask_login import logout_user
# ...
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('index'))
為了給使用者暴露登對外連結接,我會在導航欄上實作當使用者登入之後,登入連結自動轉換成登對外連結接。修改base.html模闆的導航欄部分後,代碼如下:
<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
僅當使用者未登入時的值是
True
要求使用者登入
Flask-Login提供了一個非常有用的功能——強制使用者在檢視應用的特定頁面之前登入。 如果未登入的使用者嘗試檢視受保護的頁面,Flask-Login将自動将使用者重定向到登入表單,并且隻有在登入成功後才重定向到使用者想檢視的頁面。
為了實作這個功能,Flask-Login需要知道哪個視圖函數用于處理登入認證。在
app/__init__.py
中添加代碼如下:
# ...
login = LoginManager(app)
login.login_view = 'login'
上面的
'login'
值是登入視圖函數(endpoint)名,換句話說該名稱可用于
url_for()
函數的參數并傳回對應的URL。
Flask-Login使用名為
@login_required
的裝飾器來拒絕匿名使用者的通路以保護某個視圖函數。 當你将此裝飾器添加到位于
@app.route
裝飾器下面的視圖函數上時,該函數将受到保護,不允許未經身份驗證的使用者通路。 以下是該裝飾器如何應用于應用的首頁視圖函數的案例:
from flask_login import login_required
@app.route('/')
@app.route('/index')
@login_required
def index():
# ...
剩下的就是實作登入成功之後自定重定向回到使用者之前想要通路的頁面。 當一個沒有登入的使用者通路被
@login_required
裝飾器保護的視圖函數時,裝飾器将重定向到登入頁面,不過,它将在這個重定向中包含一些額外的資訊以便登入後的回轉。 例如,如果使用者導航到/index,那麼
@login_required
裝飾器将攔截請求并以重定向到/login來響應,但是它會添加一個查詢字元串參數來豐富這個URL,如/login?next=/index。 原始URL設定了
next
查詢字元串參數後,應用就可以在登入後使用它來重定向。
下面是一段代碼,展示了如何讀取和處理
next
查詢字元串參數:
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)
# ...
在使用者通過調用Flask-Login的
login_user()
函數登入後,應用擷取了
next
查詢字元串參數的值。 Flask提供一個
request
變量,其中包含用戶端随請求發送的所有資訊。 特别是
request.args
屬性,可用友好的字典格式暴露查詢字元串的内容。 實際上有三種可能的情況需要考慮,以确定成功登入後重定向的位置:
- 如果登入URL中不含
參數,那麼将會重定向到本應用的首頁。next
- 如果登入URL中包含
參數,其值是一個相對路徑(換句話說,該URL不含域名資訊),那麼将會重定向到本應用的這個相對路徑。next
-
參數,其值是一個包含域名的完整URL,那麼重定向到本應用的首頁。next
前兩種情況很好了解,第三種情況是為了使應用更安全。 攻擊者可以在
next
參數中插入一個指向惡意站點的URL,是以應用僅在重定向URL是相對路徑時才執行重定向,這可確定重定向與應用保持在同一站點中。 為了确定URL是相對的還是絕對的,我使用Werkzeug的
url_parse()
函數解析,然後檢查
netloc
屬性是否被設定。
在模闆中顯示已登入的使用者
你還記得在實作使用者子系統之前的
第二章中,我建立了一個模拟的使用者來幫助我設計首頁的事情嗎? 現在,應用實作了真正的使用者,我就可以删除模拟使用者了。
取而代之,我會在模闆中使用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 %}
并且我可以在視圖函數傳入渲染模闆函數的參數中删除
user
了:
@app.route('/')
@app.route('/index')
def index():
# ...
return render_template("index.html", title='Home Page', posts=posts)
這正是測試登入和登出功能運作機制的好時機。 由于仍然沒有使用者注冊功能,是以添加使用者到資料庫的唯一方法是通過Python shell執行,是以運作
flask shell
并輸入以下指令來注冊使用者:
>>> u = User(username='susan', email='[email protected]')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()
如果啟動應用并嘗試通路
http://localhost:5000/或
http://localhost:5000/index,會立即重定向到登入頁面。在使用之前添加到資料庫的憑據登入後,就會跳轉回到之前通路的頁面,并看到其中的個性化歡迎。
使用者注冊
本章要建構的最後一項功能是系統資料庫單,以便使用者可以通過Web表單進行注冊。 讓我們在app/forms.py中建立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.')
代碼中與驗證相關的幾處相當有趣。首先,對于
email
字段,我在
DataRequired
之後添加了第二個驗證器,名為
Email
。 這個來自WTForms的另一個驗證器将確定使用者在此字段中鍵入的内容與電子郵件位址的結構相比對。
由于這是一個系統資料庫單,習慣上要求使用者輸入密碼兩次,以減少輸入錯誤的風險。 出于這個原因,我提供了
password
password2
字段。 第二個password字段使用另一個名為
EqualTo
的驗證器,它将確定其值與第一個password字段的值相同。
我還為這個類添加了兩個方法,名為
validate_username()
validate_email()
。 當添加任何比對模式
validate_ <field_name>
的方法時,WTForms将這些方法作為自定義驗證器,并在已設定驗證器之後調用它們。 本處,我想確定使用者輸入的username和email不會與資料庫中已存在的資料沖突,是以這兩個方法執行資料庫查詢,并期望結果集為空。 否則,則通過
ValidationError
觸發驗證錯誤。 異常中作為參數的消息将會在對應字段旁邊顯示,以供使用者檢視。
我需要一個HTML模闆以便在網頁上顯示這個表單,我其存儲在app/templates/register.html檔案中。 這個模闆的構造與登入表單類似:
{% 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 %}
登入表單模闆需要在其表單之下添加一個連結來将未注冊的使用者引導到注冊頁面:
<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
最後,我來實作處理使用者注冊的視圖函數,存儲在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)
這個視圖函數的邏輯也是
一目了然,我首先確定調用這個路由的使用者沒有登入。表單的處理方式和登入的方式一樣。在
if validate_on_submit()
條件塊下,完成的邏輯如下:使用擷取自表單的username、email和password建立一個新使用者,将其寫入資料庫,然後重定向到登入頁面以便使用者登入。
精雕細琢之後,使用者已經能夠在此應用上注冊帳戶,并進行登入和登出。 請確定你嘗試了我在系統資料庫單中添加的所有驗證功能,以便更好地了解其工作原理。 我将在未來的章節中再次更新使用者認證子系統,以增加額外的功能,比如允許使用者在忘記密碼的情況下重置密碼。 不過對于目前的應用來講,這已經無礙于繼續建構了。