1. 程式人生 > >《Flask 入門教程》第 8 章:使用者認證

《Flask 入門教程》第 8 章:使用者認證

目前為止,雖然程式的功能大部分已經實現,但還缺少一個非常重要的部分——使用者認證保護。頁面上的編輯和刪除按鈕是公開的,所有人都可以看到。假如我們現在把程式部署到網路上,那麼任何人都可以執行編輯和刪除條目的操作,這顯然是不合理的。

這一章我們會為程式新增使用者認證功能,這會把使用者分成兩類,一類是管理員,通過使用者名稱和密碼登入程式,可以執行資料相關的操作;另一個是訪客,只能瀏覽頁面。在此之前,我們先來看看密碼應該如何安全的儲存到資料庫中。

安全儲存密碼

把密碼明文儲存在資料庫中是極其危險的,假如攻擊者竊取了你的資料庫,那麼使用者的賬號和密碼就會被直接洩露。更保險的方式是對每個密碼進行計算生成獨一無二的密碼雜湊值,這樣即使攻擊者拿到了雜湊值,也幾乎無法逆向獲取到密碼。

Flask 的依賴 Werkzeug 內建了用於生成和驗證密碼雜湊值的函式,werkzeug.security.generate_password_hash() 用來為給定的密碼生成密碼雜湊值,而 werkzeug.security.check_password_hash() 則用來檢查給定的雜湊值和密碼是否對應。使用示例如下所示:

>>> from werkzeug.security import generate_password_hash, check_password_hash
>>> pw_hash = generate_password_hash('dog')  # 為密碼 dog 生成密碼雜湊值
>>> pw_hash  # 檢視密碼雜湊值
'pbkdf2:sha256:50000$mm9UPTRI$ee68ebc71434a4405a28d34ae3f170757fb424663dc0ca15198cb881edc0978f'
>>> check_password_hash(pw_hash, 'dog')  # 檢查雜湊值是否對應密碼 dog
True
>>> check_password_hash(pw_hash, 'cat')  # 檢查雜湊值是否對應密碼 cat
False複製程式碼

我們在儲存使用者資訊的 User 模型類新增 username 欄位和 password_hash 欄位,分別用來儲存登入所需的使用者名稱和密碼雜湊值,同時新增兩個方法來實現設定密碼和驗證密碼的功能:

from werkzeug.security import generate_password_hash, check_password_hash

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(20))
    username = db.Column(db.String(20))  # 使用者名稱
    password_hash = db.Column(db.String(128))  # 密碼雜湊值

    def set_password(self, password):  # 用來設定密碼的方法,接受密碼作為引數
        self.password_hash = generate_password_hash(password)  # 將生成的密碼保持到對應欄位

    def validate_password(self, password):  # 用於驗證密碼的方法,接受密碼作為引數
        return check_password_hash(self.password_hash, password)  # 返回布林值複製程式碼

因為模型(表結構)發生變化,我們需要重新生成資料庫(這會清空資料):

$ flask initdb --drop複製程式碼

生成管理員賬戶

因為程式只允許一個人使用,沒有必要編寫一個註冊頁面。我們可以編寫一個命令來建立管理員賬戶,下面是實現這個功能的 admin() 函式:

import click

@app.cli.command()
@click.option('--username', prompt=True, help='The username used to login.')
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='The password used to login.')
def admin(username, password):
    """Create user."""
    db.create_all()

    user = User.query.first()
    if user is not None:
        click.echo('Updating user...')
        user.username = username
        user.set_password(password)  # 設定密碼
    else:
        click.echo('Creating user...')
        user = User(username=username, name='Admin')
        user.set_password(password)  # 設定密碼
        db.session.add(user)

    db.session.commit()  # 提交資料庫會話
    click.echo('Done.')複製程式碼

使用 click.option() 裝飾器設定的兩個選項分別用來接受輸入使用者名稱和密碼。執行 flask admin 命令,輸入使用者名稱和密碼後,即可建立管理員賬戶。如果執行這個命令時賬戶已存在,則更新相關資訊:

$ flask admin
Username: greyli
Password: 123  # hide_input=True 會讓密碼輸入隱藏
Repeat for confirmation: 123  # confirmation_prompt=True 會要求二次確認輸入
Updating user...
Done.複製程式碼

使用 Flask-Login 實現使用者認證

擴充套件 Flask-Login 提供了實現使用者認證需要的各類功能函式,我們將使用它來實現程式的使用者認證,首先使用 Pipenv 安裝它:

$ pipenv install flask-login複製程式碼

這個擴充套件的初始化步驟稍微有些不同,除了例項化擴充套件類之外,我們還要實現一個“使用者載入回撥函式”,具體程式碼如下所示:

app.py:初始化 Flask-Login

from flask_login import LoginManager

login_manager = LoginManager(app)  # 例項化擴充套件類


@login_manager.user_loader
def load_user(user_id):  # 建立使用者載入回撥函式,接受使用者 ID 作為引數
    user = User.query.get(int(user_id))  # 用 ID 作為 User 模型的主鍵查詢對應的使用者
    return user  # 返回使用者物件複製程式碼

Flask-Login 提供了一個 current_user 變數,註冊這個函式的目的是,當程式執行後,如果使用者已登入, current_user 變數的值會是當前使用者的使用者模型類記錄。

另一個步驟是讓儲存使用者的 User 模型類繼承 Flask-Login 提供的 UserMixin 類:

from flask_login import UserMixin

class User(db.Model, UserMixin):
    # ...複製程式碼

繼承這個類會讓 User 類擁有幾個用於判斷認證狀態的屬性和方法,其中最常用的是 is_authenticated 屬性:如果當前使用者已經登入,那麼 current_user.is_authenticated 會返回 True, 否則返回 False。有了 current_user 變數和這幾個驗證方法和屬性,我們可以很輕鬆的判斷當前使用者的認證狀態。

登入

登入使用者使用 Flask-Login 提供的 login_user() 函式實現,需要傳入使用者模型類物件作為引數。下面是用於顯示登入頁面和處理登入表單提交請求的檢視函式:

app.py:使用者登入

from flask_login import login_user

# ...

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']

        if not username or not password:
            flash('Invalid input.')
            return redirect(url_for('login'))
        
        user = User.query.first()
        # 驗證使用者名稱和密碼是否一致
        if username == user.username and user.validate_password(password):
            login_user(user)  # 登入使用者
            flash('Login success.')
            return redirect(url_for('index'))  # 重定向到主頁

        flash('Invalid username or password.')  # 如果驗證失敗,顯示錯誤訊息
        return redirect(url_for('login'))  # 重定向回登入頁面
    
    return render_template('login.html')複製程式碼

下面是包含登入表單的登入頁面模板:

templates/login.html:登入頁面

{% extends 'base.html' %}

{% block content %}
<h3>Login</h3>
<form method="post">
    Username<br>
    <input type="text" name="username" required><br><br>
    Password<br>
    <!-- 密碼輸入框的 type 屬性使用 password,會將輸入值顯示為圓點 -->
    <input type="password" name="password" required><br><br>
    <input class="btn" type="submit" name="submit" value="Submit">
</form>
{% endblock %}複製程式碼

登出

和登入相對,登出操作則需要呼叫 logout_user() 函式,使用下面的檢視函式實現:

from flask_login import login_required, logout_user

# ...

@app.route('/logout')
@login_required  # 用於檢視保護,後面會詳細介紹
def logout():
    logout_user()  # 登出使用者
    flash('Goodbye.')
    return redirect(url_for('index'))  # 重定向回首頁複製程式碼

實現了登入和登出後,我們先來看看認證保護,最後再把對應這兩個檢視函式的登入/登出連結放到導航欄上。

認證保護

在 Web 程式中,有些頁面或 URL 不允許未登入的使用者訪問,而頁面上有些內容則需要對未登陸的使用者隱藏,這就是認證保護。

檢視保護

在檢視保護層面來說,未登入使用者不能執行下面的操作:

  • 訪問編輯頁面
  • 訪問設定頁面
  • 執行登出操作
  • 執行刪除操作
  • 執行新增新條目操作

對於不允許未登入使用者訪問的檢視,只需要為檢視函式附加一個 login_required 裝飾器就可以將未登入使用者拒之門外。以刪除條目檢視為例:

@app.route('/movie/delete/<int:movie_id>', methods=['POST'])
@login_required  # 登入保護
def delete(movie_id):
    movie = Movie.query.get_or_404(movie_id)
    db.session.delete(movie)
    db.session.commit()
    flash('Item deleted.')
    return redirect(url_for('index'))複製程式碼

添加了這個裝飾器後,如果未登入的使用者訪問對應的 URL,Flask-Login 會把使用者重定向到登入頁面,並顯示一個錯誤提示。為了讓這個重定向操作正確執行,我們還需要把 login_manager.login_view 的值設為我們程式的登入檢視端點(函式名):

login_manager.login_view = 'login'複製程式碼

提示 如果你需要的話,可以通過設定 login_manager.login_message 來自定義錯誤提示訊息。

編輯檢視同樣需要附加這個裝飾器:

@app.route('/movie/edit/<int:movie_id>', methods=['GET', 'POST'])
@login_required
def edit(movie_id):
    # ...複製程式碼

建立新條目的操作稍微有些不同,因為對應的檢視同時處理顯示頁面的 GET 請求和建立新條目的 POST 請求,我們僅需要禁止未登入使用者建立新條目,因此不能使用 login_required,而是在函式內部的 POST 請求處理程式碼前進行過濾:

from flask_login import login_required, current_user

# ...

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        if not current_user.is_authenticated:  # 如果當前使用者未認證
            return redirect(url_for('index'))  # 重定向到主頁
        # ...複製程式碼

最後,我們為程式新增一個設定頁面,支援修改使用者的名字:

app.py:支援設定使用者名稱字

from flask_login import login_required, current_user

# ...

@app.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
    if request.method == 'POST':
        name = request.form['name']
        
        if not name or len(name) > 20:
            flash('Invalid input.')
            return redirect(url_for('settings'))
        
        current_user.name = name
        # current_user 會返回當前登入使用者的資料庫記錄物件
        # 等同於下面的用法
        # user = User.query.first()
        # user.name = name
        db.session.commit()
        flash('Settings updated.')
        return redirect(url_for('index'))
    
    return render_template('settings.html')複製程式碼

下面是對應的模板:

templates/settings.html:設定頁面模板

{% extends 'base.html' %}

{% block content %}
<h3>Settings</h3>
<form method="post">
    Your Name <input type="text" name="name" autocomplete="off" required value="{{ current_user.name }}">
    <input class="btn" type="submit" name="submit" value="Save">
</form>
{% endblock %}複製程式碼

模板內容保護

認證保護的另一形式是頁面模板內容的保護。比如,不能對未登入使用者顯示下列內容:

  • 建立新條目表單
  • 編輯按鈕
  • 刪除按鈕

這幾個元素的定義都在首頁模板(index.html)中,以建立新條目表單為例,我們在表單外部新增一個 if 判斷:

<!-- 在模板中可以直接使用 current_user 變數 -->
{% if current_user.is_authenticated %}
<form method="post">
    Name <input type="text" name="title" autocomplete="off" required>
    Year <input type="text" name="year" autocomplete="off" required>
    <input class="btn" type="submit" name="submit" value="Add">
</form>
{% endif %}複製程式碼

在模板渲染時,會先判斷當前使用者的登入狀態(current_user.is_authenticated)。如果使用者沒有登入(current_user.is_authenticated 返回 False),就不會渲染表單部分的 HTML 程式碼,即上面程式碼塊中 {% if ... %}{% endif %} 之間的程式碼。類似的還有編輯和刪除按鈕:

{% if current_user.is_authenticated %}
	<a class="btn" href="{{ url_for('edit', movie_id=movie.id) }}">Edit</a>
	<form class="inline-form" method="post" action="{{ url_for('.delete', movie_id=movie.id) }}">
		<input class="btn" type="submit" name="delete" value="Delete" onclick="return confirm('Are you sure?')">
	</form>
{% endif %}複製程式碼

有些地方則需要根據登入狀態分別顯示不同的內容,比如基模板(base.html)中的導航欄。如果使用者已經登入,就顯示設定和登出連結,否則顯示登入連結:

{% if current_user.is_authenticated %}
	<li><a href="{{ url_for('settings') }}">Settings</a></li>
	<li><a href="{{ url_for('logout') }}">Logout</a></li>
{% else %}
	<li><a href="{{ url_for('login') }}">Login</a></li>
{% endif %}複製程式碼

現在的程式中,未登入使用者看到的主頁如下所示:



在登入頁面,輸入使用者名稱和密碼登入:



登入後看到的主頁如下所示:



本章小結

新增使用者認證後,在功能層面,我們的程式基本算是完成了。結束前,讓我們提交程式碼:

$ git add .
$ git commit -m "User authentication with Flask-Login"
$ git push複製程式碼

提示 你可以在 GitHub 上檢視本書示例程式的對應 commit:3944088

進階提示