flask學習:用戶登錄

密碼哈希
在前一章中,用戶模型設置了一個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。
  • get_id(): 返回用戶的唯一id的方法,返回值類型是字符串(Python 2下返回unicode字符串).

我可以很容易地實現這四個屬性或方法,但是由於它們是相當通用的,因此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中包含next參數,其值是一個相對路徑(換句話說,該URL不含域名信息),那麼將會重定向到本應用的這個相對路徑。
  • 如果登錄URL中包含next參數,其值是一個包含域名的完整URL,那麼重定向到本應用的主頁。

前兩種情況很好理解,第三種情況是爲了使應用更安全。 攻擊者可以在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創建一個新用戶,將其寫入數據庫,然後重定向到登錄頁面以便用戶登錄。

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

發佈了22 篇原創文章 · 獲贊 11 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章