配套視頻教程
密碼哈希
用戶模型有一個password_hash
字段,到目前為止尚未使用。它是用於保存用戶密碼的哈希值,密碼用於驗證用戶在登錄過程中輸入的密碼。密碼散列是一個復雜的主題,應交給安全專家,但有幾個易於使用的庫以一種簡單地從應用程序調用的方式實現所有邏輯。
其中一個實現密碼散列的包是Werkzeug,在安裝Flask,它已自動安裝上了(虛擬環境中),因為是核心依賴之一。以下Python shell會話將演示如何散列密碼:
(venv) d:\microblog>python
Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'pbkdf2:sha256:50000$G6lpo6G5$017b9bc06a89d2886a0cf255cb0db7ab34242cfcf7eb45900eade8cffe63f059'
PS:退出python shell有兩種方法:1)exit()或quit(),回車;2)Ctrl+Z后,回車。
上述示例中,密碼 foobar
經過一系列沒有已知的反向操作的加密操作,轉換為長編碼的字符串,這意味着獲得散列密碼的人無法用它來得到原始密碼。作為一項額外措施,如果多次散列相同的密碼,那么將得到不同的結果。因此,使得無法通過查看其哈希值來確定兩個用戶是否具有相同的密碼。
驗證過程得使用Werkzeug的第二個功能來完成,如下:
>>> from werkzeug.security import check_password_hash
>>> check_password_hash(hash, 'foobar')
True
>>> check_password_hash(hash, 'barfoo')
False
驗證函數check_password_hash()
采用先前生成的密碼哈希值和用戶在登錄時輸入的密碼。True
表示用戶輸入的密碼與哈希值匹配,否則返回False
。
整個密碼哈希邏輯在用戶模型可作為兩個新方法實現,更新代碼:
app/models.py:密碼哈希、驗證
from app import db
from datetime import datetime
from werkzeug.security import generate_password_hash,check_password_hash
class User(db.Model):
# ...
def __repr__(self):
return '<User {}>'.format(self.username)
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)
# ...
有了上述這倆個方法,一個用戶對象現在就可以進行安全密碼驗證,而無需存儲原始密碼。以下是上述新方法的示例:
(venv) d:\microblog>flask shell
Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
App: app [production]
Instance: d:\microblog\instance
>>> 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-Login是非常流行Flask擴展。用於管理用戶登錄狀態,以便做到諸如用戶可登錄到應用程序,然后在應用程序“記住”用戶登錄並導航到不同頁面。它還提供“記住我”功能,即使是在關閉瀏覽器窗口后,用戶也可保持登錄狀態。在虛擬環境中安裝Flask-Login:版本0.4.1
(venv) d:\microblog>pip install flask-login
Collecting flask-login
Using cached https://files.pythonhosted.org/packages/c1/ff/bd9a4d2d81bf0c07d9e53e8cd3d675c56553719bbefd372df69bf1b3c1e4/Flask-Login-0.4.1.tar.gz
Requirement already satisfied: Flask in .\venv\lib\site-packages (from flask-login)
Requirement already satisfied: Werkzeug>=0.14 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: click>=5.1 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: Jinja2>=2.10 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: itsdangerous>=0.24 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: MarkupSafe>=0.23 in .\venv\lib\site-packages (from Jinja2>=2.10->Flask->flask-login)
Installing collected packages: flask-login
Running setup.py install for flask-login ... done
Successfully installed flask-login-0.4.1
和其他擴展一樣,需要在app/init.py中的應用程序實例之后立即創建和初始化Flask-Login。 app/init.py:Flask-Login初始化
# ...
from flask_login import LoginManager
app = Flask(__name__)
# ...
login = LoginManager(app)
# ...
為Flask-Login准備用戶模型
Flask-Login擴展與應用程序的用戶模型一起使用,並期望在其中實現某些屬性和方法。這種做法很好,因為只要將將這些必需的項添加到模型中,Flask-Login就沒有任何其他要求。因此,例如,它可以與基於任何數據系統的用戶模型一起使用。
以下列出4個必需項目:
is_authenticated
:一個屬性,如果用戶具有有效憑據則是True,否則是False。is_active
:屬性,如果用戶的賬戶處於活動狀態則是True;其他狀態下是False。is_anonymous
:屬性,普通用戶則是False;匿名用戶則是True。get_id()
:一個方法,以字符串形式返回用戶的唯一標識符。
我們可輕松地實現上述4個,但由於實現相當通用,Flask-Login提供了一個名為UserMixin
的mixin類,它包含適用於大多數用戶模型類的通用實現。以下將mixin類添加到模型中:
app/models.py:添加Flask-Login用戶mixin類
#...
from flask_login import UserMixin
class User(UserMixin, db.Model):
#...
用戶加載器功能
Flask-Login通過在Flask的用戶會話中存儲其唯一的標識符來跟蹤登錄用戶,這個用戶會話是分配給連接到應用程序的每個用戶的存儲空間。每次登錄用戶導航到新頁面時,Flask-Login都會從會話中檢索用戶的ID,然后將用戶加載到內存中。
因為Flask-Login對數據庫一無所知,所以在加載用戶時需要應用程序的幫助。因此,擴展期望應用程序配置一個用戶加載函數,它可以被調用去加載給定ID的用戶。這個函數添加到app/models.py模塊中:
app/models.py:Flask-Login用戶加載函數
from app import login
# ...
@login.user_loader
def load_user(id):
return User.query.get(int(id))
# ...
使用@login.user_loader裝飾器
向Flask-Login注冊用戶加載函數。Flask-Login傳遞給函數的id作為一個參數將是一個字符串,所以需要將字符串類型轉換為int型以供數據庫使用數字ID。
用戶登錄
這兒重新訪問 登錄視圖 函數,那時現實了發出flash()
消息的虛假登錄。既然應用程序可訪問用戶數據庫,並且知道如何生存、驗證密碼哈希,那么就可以完成視圖功能。
app/routes.py:實現登錄視圖函數的邏輯
# ...
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()函數的前兩行處理了一個奇怪情況:想象一下,有一個登錄用戶,Ta導航到/login
URL。顯然這是錯誤的,不允許這么做。current_user變量來自Flask-Login,可在處理過程中隨時使用,以表示請求客戶端的用戶對象。此變量的值可以是數據庫中的用戶的對象(Flask-Login通過上述提供的用戶加載器回調讀取的),如果用戶尚未登錄,則可以是特殊的匿名用戶對象。回想一下用戶對象需要Flask-Login的那4個項目(3個屬性,1個方法),其中一個是 is_authenticated
,它可以方便地檢查用戶是否登錄。當用戶已經登錄時,只需重定向到/index
頁面。
代替之前的flash()
,現在我們可以將用戶登錄為真實的。首先,從數據庫加載用戶。用戶名來自於表單提交,因此我們可以使用查詢來查找數據庫以查找用戶。為此,使用SQLAlchemy的filter_by()
方法查詢對象。得到的查詢結果是只包含具有匹配用戶名的對象。因為我們知道1個或0個結果,所以通過調用first()
完成查詢,如果存在則返回用戶對象,否則返回None
。調用all()
將執行查詢,得到查詢匹配的所有結果的列表。當我們只需要一個結果時,通過使用first()
方法執行查詢。
如果我們得到了所提供用戶名的匹配項,接下來則可以檢查該表單附帶的密碼是否有效。這將通過調用check_password()
方法完成。它將獲取與用戶一起存儲的密碼哈希值,並確定在表單輸入的密碼是否與哈希值匹配。因此,現在有兩個可能的錯誤條件:用戶名可能無效;或用戶密碼可能不正確。在任一情況下,都將flash一條消息,從重定向到登錄頁面,以便用戶可以再次嘗試。
如果用戶名、密碼都正確,那么將調用來自Flask-Login的login_user()
函數。這個函數將在登錄時注冊用戶,這意味着用戶導航的任何未來頁面都將current_user
變量設置為該用戶。
最后,要完成這個登錄過程,只需將新登錄的用戶重定向到/index
頁面。
用戶退出
為用戶提供退出應用程序的選項。這得使用Flask-Login的logout_user()
函數完成,即退出視圖函數:
app/routes.py:退出視圖函數
# ...
from flask_login import logout_user
#...
# ...
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('index'))
要想用戶公開此鏈接,可在用戶登錄后使用導航欄的“登錄”鏈接自動切換到“退出”鏈接。通過base.html模板中的條件來完成,更新代碼:
app/templates/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
屬性是Flask-Login通過UserMixin類添加到用戶對象的屬性之一。current_user.is_anonymous
表達式將只在用戶沒有登錄時為True
。
要求用戶登錄
Flask-Login還提供了一個非常有用的功能:強制用戶在查看應用程序的某些頁面之前必須登錄。如果未登錄用戶嘗試查看受保護的頁面,Flask-Login將自動將用戶重定向到登錄表單,並且僅在登錄過程完成后重定向回用戶想要查看的頁面。
要實現上述功能,Flask-Login需要知道處理登錄的視圖函數是什么。這可在app/init.py中添加:
# ...
login = LoginManager(app)
login.login_view = 'login'
#...
上述‘login’
的值是登錄視圖的函數(或端點)名稱。也就是:在url_for()
調用中使用的名稱來獲取URL。
Flask-Login為匿名用戶保護視圖函數的方式是 使用一個名為@login_required
的裝飾器。當將這個裝飾器添加到來自Flask的@app.route
的裝飾器下方時,這個函數將被收到保護,並且不允許未經過身份驗證的用戶。下方是裝飾器如何用於應用程序的index
視圖函數:
app/routes.py:添加@login_required裝飾器
#...
from flask_login import login_required
#...
@app.route('/')
@app.route('/index')
@login_required
def index():
# ...
剩下的是實現:從成功登陸 到 用戶想要訪問的頁面的重定向。當未登錄用戶訪問受@login_required
裝飾器保護的視圖函數時,裝飾器將重定向到登錄頁面,但它將在此重定向中包含一些額外信息,以便應用程序可返回到第一個頁。例如,如果用戶到/index
,@login_required
裝飾器將攔截請求,並使用重定向響應/login
,但它會向此URL添加一個查詢字符串參數,從而形成完成的重定向URL /login?next=/index
。next
查詢字符串參數設置為原始URL,因此應用程序可使用這個參數在登錄后重定向。
下方代碼將展示如何讀取、處理 next
查詢字符串參數:
app/routes.py:重定向到 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 頁面
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)
return render_template('login.html',title='Sign In',form=form)
# ...
在用戶通過調用Flask-Login的login_user()
函數登錄后,獲取next
查詢字符串參數的值。這里寫代碼片
Flask提供了一個request
變量,它包含客戶端隨 請求 發送的所有信息。特別的是,request.args
屬性以友好的字典格式公開查詢字符串的內容。實際上,在成功登錄后,確實需要考慮三種可能的情況來確定重定向的位置:
- 如果登錄URL沒有
next
參數,則將頁面重定向到/index
頁面。 - 如果登錄URL包含
next
設置為相對路徑的參數(即沒有域部分的URL),則將用戶重定向到該URL。 - 如果登錄URL包含
next
設置為包含域名的完整URL的參數,則將用戶重定向到/index
頁面。
上述第1、2種情況很明顯。第3種情況是為讓應用程序更安全。攻擊者可在next
參數中插入惡意站點的URL,因此應用程序僅在URL為相對時重定向,這可確保重定向與應用程序保持在同一站點內。要確定URL是相對的、還是絕對的,要使用Werkzeug的url_parse()
函數解析它,然后檢查netloc
組件是否已設置。
在模板中顯示登錄用戶
以前,創建過“假”用戶來設計應用程序主頁,因為那時沒有用戶系統。現在我們可以有真正的用戶了,就可以刪除“假”用戶了,用真實用戶了。修改index.html模板
代碼,使用Flask-Login的current_user
替換“假”用戶:
app/templates/index.html:將當前用戶傳遞給模板
{% extends "base.html" %}
{% block content %}
<h1>Hello,{{ current_user.username }}!</h1>
{% for post in posts %}
<div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
{% endfor %}
{% endblock %}
並在視圖函數中刪除這個user模板參數:修改代碼
app/routes.py:不再將用戶傳遞給模板
#...
@app.route('/')
@app.route('/index')
@login_required
def index():
posts = [
{
'author': {'username': 'John'},
'body': 'Beautiful day in Portland!'
},
{
'author': {'username': 'Susan'},
'body': 'The Avengers movie was so cool!'
}
]
return render_template('index.html', title='Home', posts=posts)
#...
目前為止,就能測試:登錄、退出功能了。不過暫無用戶注冊功能,得通過將用戶添加到數據庫,即用Python shell操作,運行flask shell命令,並輸入以下命令來向數據庫添加用戶:
C:\Users\Administrator>d:
D:\>cd D:\microblog\venv\Scripts
D:\microblog\venv\Scripts>activate
(venv) D:\microblog\venv\Scripts>cd D:\microblog
(venv) D:\microblog>set FLASK_APP=microblog.py
(venv) D:\microblog>flask shell
Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
App: app [production]
Instance: D:\microblog\instance
>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()
先退出上述Python shell,運行程序:
>>> quit()
(venv) D:\microblog>flask run
* Serving Flask app "microblog.py"
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
...
瀏覽器訪問http://localhost:5000/
或http://localhost:5000 /index
,都將立即重定向到 /login
登錄頁面。在使用剛才添加到數據庫中的用戶名、密碼登錄后,將返回到原始頁面,並將看到個性化問候語。效果:
登錄后
點擊“Logout”
按鈕,即可退出用戶登錄,重定向到登錄頁面。
用戶注冊
構建本章最后一項功能:用戶注冊表單。以便用戶可以通過Web表單進行注冊。首先,在app/forms.py中創建Web表單類:
app/forms.py
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附帶的另一個stock validator,它將確保用戶在此字段中鍵入的內容與電子郵件地址的結構相匹配(省了正則去匹配這是否為一個郵箱地址)。
因為這是個注冊表單,因此通常都會要求用戶輸入密碼兩次以減少拼寫錯誤的風險。因此,用了password、password2
兩個字段。第二個字段 用了另一個stock validator EqualTo
,它將確保其值與第一個密碼字段的值相同。
還為這個類添加兩個方法:validate_username()
、validate_email()
。當添加與模式匹配任何 validate_字段名
方法時,WTForms會將這些方法作為自定義驗證器,並在stock validator之外調用它們。在這種情況下,確定用戶輸入的用戶名、電子郵件地址是否在數據庫中,因此這倆個方法會發出數據庫查詢。如果存在查詢結果,則通過觸發驗證錯誤ValidationError
。將在字段傍邊顯示包含此異常的消息讓用戶查看。
要在網頁上顯示這個Web表單,還需一個HTML模板,存於app/templates/register.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.error %}
<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 %}
登錄表單模板需要一個鏈接,用於發送新用戶到注冊表單,位於登錄表單下方:
app/templates/login.html:鏈接到注冊頁面
#...
<p>{{ form.submit() }}</p>
</form>
<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
{% endblock %}
最后,在app/routes.py中編寫處理用戶注冊的視圖函數:
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()
判斷內完成以下邏輯:創建一個新用戶,其中提供用戶名、電子郵件地址、密碼,將其寫入數據庫,最后重定向到登錄頁面,以便用戶登錄。運行程序,效果:
(venv) D:\microblog>flask run
* Serving Flask app "microblog.py"
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
注冊信息如:belen,belen@example.com,Abc123456
點擊“Register”按鈕,頁面轉向登錄頁面:
點擊“Sign In”按鈕,頁面轉向/index
頁面。
至此,應用程序有了:創建賬戶、登錄、退出的功能。在接下來的章節中,將重新訪問用戶身份驗證子系統,以添加其他功能,如允許用戶在忘記密碼時重置密碼。
僅修改app/models.py中User類的__repr__()
代碼,以便打印出數據庫中 所有用戶的信息:
#...
posts = db.relationship('Post', backref='author', lazy='dynamic')
def __repr__(self):
#return '<User {}>'.format(self.username)
return '<User {}, Email {}, Password_Hash {}, Posts {}'.format(self.username, self.email, self.password_hash, self.posts)
#...
運行flask shell
命令后,就可看到剛才注冊的用戶 belen。
(venv) D:\microblog>flask shell
Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
App: app [production]
Instance: D:\microblog\instance
>>> u = User.query.all()
>>> u
[<User susan, Email susan@example.com, Password_Hash pbkdf2:sha256:50000$OONOkVyy$8d008c6647ab95a5793cf60bf57eaa3bb1123d6e5b3135c5cc5e42e02eddae32, Posts SELECT post.id AS post_id, post.body AS post_body, post.timestamp AS post_timestamp, post.user_id AS post_user_id
FROM post
WHERE ? = post.user_id, <User belen, Email belen@example.com, Password_Hash pbkdf2:sha256:50000$PEDt5NxS$cf6c958c97b6ad28d9495d138cb5a310f6f2389534b0cafa3002dd3cec9af9d1, Posts SELECT post.id AS post_id, post.body AS post_body, post.timestamp AS post_timestamp, post.user_id AS post_user_id
FROM post
WHERE ? = post.user_id]
目前為止,項目結構:
microblog/
app/
templates/
base.html
index.html
login.html
register.html
__init__.py
forms.py
models.py
routes.py
migrations/
venv/
app.db
config.py
microblog.py
參考
https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-v-user-logins