1. 程式人生 > 實用技巧 >10.flask部落格專案實戰五之使用者登入功能

10.flask部落格專案實戰五之使用者登入功能

配套視訊教程

本文B站配套視訊教程

密碼雜湊

使用者模型有一個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='[email protected]')
>>> 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個必需專案:

  1. is_authenticated:一個屬性,如果使用者具有有效憑據則是True,否則是False。
  2. is_active:屬性,如果使用者的賬戶處於活動狀態則是True;其他狀態下是False。
  3. is_anonymous:屬性,普通使用者則是False;匿名使用者則是True。
  4. get_id():一個方法,以字串形式返回使用者的唯一識別符號。
    我們可輕鬆地實現上述4個,但由於實現相當通用,Flask-Login提供了一個名為UserMixinmixin類,它包含適用於大多數使用者模型類的通用實現。以下將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導航到/loginURL。顯然這是錯誤的,不允許這麼做。current_user變數來自Flask-Login,可在處理過程中隨時使用,以表示請求客戶端的使用者物件。此變數的值可以是資料庫中的使用者的物件(Flask-Login通過上述提供的使用者載入器回撥讀取的),如果使用者尚未登入,則可以是特殊的匿名使用者物件。回想一下使用者物件需要Flask-Login的那4個專案(3個屬性,1個方法),其中一個是is_authenticated,它可以方便地檢查使用者是否登入。當用戶已經登入時,只需重定向到/index頁面。

代替之前的flash(),現在我們可以將使用者登入為真實的。首先,從資料庫載入使用者。使用者名稱來自於表單提交,因此我們可以使用查詢來查詢資料庫以查詢使用者。為此,使用SQLAlchemyfilter_by()方法查詢物件。得到的查詢結果是隻包含具有匹配使用者名稱的物件。因為我們知道1個或0個結果,所以通過呼叫first()完成查詢,如果存在則返回使用者物件,否則返回None。呼叫all()將執行查詢,得到查詢匹配的所有結果的列表。當我們只需要一個結果時,通過使用first()方法執行查詢。

如果我們得到了所提供使用者名稱的匹配項,接下來則可以檢查該表單附帶的密碼是否有效。這將通過呼叫check_password()方法完成。它將獲取與使用者一起儲存的密碼雜湊值,並確定在表單輸入的密碼是否與雜湊值匹配。因此,現在有兩個可能的錯誤條件:使用者名稱可能無效;或使用者密碼可能不正確。在任一情況下,都將flash一條訊息,從重定向到登入頁面,以便使用者可以再次嘗試。

如果使用者名稱、密碼都正確,那麼將呼叫來自Flask-Login的login_user()函式。這個函式將在登入時註冊使用者,這意味著使用者導航的任何未來頁面都將current_user變數設定為該使用者。

最後,要完成這個登入過程,只需將新登入的使用者重定向到/index頁面。

使用者退出

為使用者提供退出應用程式的選項。這得使用Flask-Loginlogout_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=/indexnext查詢字串引數設定為原始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-Loginlogin_user()函式登入後,獲取next查詢字串引數的值。這裡寫程式碼片Flask提供了一個request變數,它包含客戶端隨 請求 傳送的所有資訊。特別的是,request.args屬性以友好的字典格式公開查詢字串的內容。實際上,在成功登入後,確實需要考慮三種可能的情況來確定重定向的位置:

  1. 如果登入URL沒有next引數,則將頁面重定向到/index頁面。
  2. 如果登入URL包含next設定為相對路徑的引數(即沒有域部分的URL),則將使用者重定向到該URL。
  3. 如果登入URL包含next設定為包含域名的完整URL的引數,則將使用者重定向到/index頁面。

上述第1、2種情況很明顯。第3種情況是為讓應用程式更安全。攻擊者可在next引數中插入惡意站點的URL,因此應用程式僅在URL為相對時重定向,這可確保重定向與應用程式保持在同一站點內。要確定URL是相對的、還是絕對的,要使用Werkzeugurl_parse()函式解析它,然後檢查netloc元件是否已設定。

在模板中顯示登入使用者

以前,建立過“假”使用者來設計應用程式主頁,因為那時沒有使用者系統。現在我們可以有真正的使用者了,就可以刪除“假”使用者了,用真實使用者了。修改index.html模板程式碼,使用Flask-Logincurrent_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='[email protected]')
>>> 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 validatorEqualTo,它將確保其值與第一個密碼欄位的值相同。

還為這個類新增兩個方法: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,[email protected],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 [email protected], 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 [email protected], 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