Python個人博客程序開發(fā)實例用戶驗證功能
在Python個人博客程序開發(fā)實例框架設計中,我們已經(jīng)完成了 數(shù)據(jù)庫設計、數(shù)據(jù)準備、模板架構、表單設計、視圖函數(shù)設計、電子郵件支持 等總體設計的內(nèi)容。
在Python個人博客程序開發(fā)實例信息顯示中,我們一起實現(xiàn)了 顯示文章列表、博客信息、文章內(nèi)容和評論 等功能。
那么,本篇文章將會介紹如何 初始化博客、利用 Flask-Login 管理用戶認證、使用 CSRFProtect 實現(xiàn) CSRF 保護。
1.安全存儲密碼
創(chuàng)建管理員用戶需要存儲用戶名和密碼,密碼的存儲需要特別注意。密碼不能直接以明文的形式存儲在數(shù)據(jù)庫中,因為一旦數(shù)據(jù)庫被竊取或是被攻擊者使用暴 力破解或字典法破解,用戶的賬戶、密碼將被直接泄露。如果發(fā)生泄漏,常常會導致用戶在其他網(wǎng)站上的賬戶處于危險狀態(tài),因為通常用戶會在多個網(wǎng)站使用同一個密碼。一般的做法是不存儲密碼本身,而是存儲通過密碼生成的散列值(hash)。每一個密碼對應著獨一無二的散列值,從而避免明文存儲密碼。
如果只是簡單地計算散列值,攻擊者可以使用彩虹表的方式逆向破解密碼。這時我們需要加鹽計算散列值。加鹽后,散列值的隨機性會顯著提高。但僅僅把鹽和散列值連接在一起可能還不夠,我們還需要使用 HMAC(hash-based message authentication code)
來重復計算很多次(比如 5000 次)最終獲得派生密鑰,這會增大攻擊者暴 力破解密碼的難度,這種方式被稱為 密鑰擴展(key stretching
)。
在密碼學中,鹽(salt)是一串隨機生成的字符,用來增加散列值計算的隨機性。經(jīng)過這一系列處理后,即使攻擊者獲取到了密碼的散列值,也無法逆向獲取真實的密碼值。在生產(chǎn)環(huán)節(jié)中,盡管對密碼加密存儲安全性很強,仍然需要使用安全的 HTTP 以加密傳輸數(shù)據(jù),避免密碼在傳輸過程中被截獲。
Werkzeug 在 security
模塊中提供了一個 generate_password_hash(password,method=‘pbkdf2:sha256’,salt_length=8)
函數(shù)用于為給定的密碼生成散列值,參數(shù) method
用來指定計算散列值的方法,salt_length
參數(shù)用來指定鹽(salt)的長度。security
模塊中的 check_password_hash(pwhash,password)
函數(shù)接收散列值(pwhash)和密碼(password)作為參數(shù),用于檢查密碼散列值與密碼是否對應。
>>> from werkzeug.security import generate_password_hash, check_password_hash >>> password_hash = generate_password_hash('cat') >>> password_hash 'pbkdf2:sha256:50000$mIeMzTvb$ba3c0a274c6b53fda2ab39f864254dfb0a929848b7ec99f81e3bf721d8860fdc' >>> check_password_hash(password_hash, 'dog') False >>> check_password_hash(password_hash, 'cat') True >>> password_hash = generate_password_hash('cat') >>> password_hash 'pbkdf2:sha256:150000$AITKk6jv$5c0b732535cae83677fdf2e666153f82b5db30e6f40ec7a625678ad2b5f4ad25'
generate_password_hash()
函數(shù)生成的密碼散列值的格式如下:
method$salt$hash
因為在計算散列值時會加鹽,而鹽是隨機生成的,所以即使兩個用戶的密碼相同,最終獲得的密碼散列值也是不同的。我們沒法從密碼散列值逆向獲取密碼,但是如果密碼、計算方法、鹽相同,最終獲得的散列值結果也會是相同的,所以 check_password_hash()
函數(shù)會根據(jù)密碼散列值中的方法、鹽重新對傳入的密碼進行散列值計算,然后對比散列值。
from werkzeug.security import generate_password_hash, check_password_hash class User(db.Model): ... 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)
set_password()
方法用來設置密碼,它接收密碼的原始值作為參數(shù),將密碼的散列值設為 password_hash
的值。validate_password()
方法用于驗證密碼是否和對應的散列值相符,返回布爾值。
2.使用Flask-Login管理用戶認證
博客程序需要根據(jù)用戶的身份開放不同的功能,對于程序使用者——管理員來說,他可以撰寫文章、管理博客;而普通的用戶(匿名用戶)則只能閱讀文章、發(fā)表評論。為了讓程序識別出用戶的身份,我們需要添加用戶認證功能。具體來說,使用用戶名和密碼登入博客程序的用戶被視為管理員,而未登錄的用戶則被視為匿名用戶。
擴展 Flask-Login 為 Flask 提供了用戶會話管理功能,使用它可以輕松的處理用戶登錄、登出等操作。
在 extensions.py
腳本中實例化擴展提供的 LoginManager
類,創(chuàng)建一個 login_manager
或 login
對象。
from flask_login import LoginManager ... login_manager = LoginManager(app)
然后在程序包的工廠函數(shù)中對 login
對象調(diào)用 init_app()
方法進行初始化擴展
login_manager.init_app(app)
Flask-Login 要求表示用戶的類必須實現(xiàn)下表中所示的這幾個屬性和方法,以便用來判斷用戶的認證狀態(tài)。
通過對用戶對象調(diào)用各種方法和屬性即可判斷用戶的狀態(tài),比如是否登錄等。方便的做法是讓用戶類繼承 Flask-Login 提供的 UserMixin
類,它包含了這些方法和屬性的默認實現(xiàn)。
from flask_login import UserMixin class Admin(db.Model, UserMixin): ...
UserMinxin
表示通過認證的用戶,所以 is_authenticated
和 is_active
屬性會返回 True
,而 is_anonymous
則返回 False
。get_id()
默認會查找用戶對象的 id
屬性值作為 id
,而這正是我們的 Admin
類中的主鍵字段。
使用 Flask-Login 登入/登出某個用戶非常簡單,只需要在視圖函數(shù)中調(diào)用 Flask-Login 提供的 login_user()
或 logout_user()
函數(shù),并傳入要登入/登出的用戶類對象。在這兩個函數(shù)背后,F(xiàn)lask-Login 使用 Flask 的 session
對象將用戶的 id
值存儲到用戶瀏覽器的 cookie
中(名為 user_id
),這時表示用戶被登入。相對來說,登出則意味著在用戶瀏覽器的 cookie
中刪除這個值。默認情況下,關閉瀏覽器時,通過 Flask 的 session
對象存儲在客戶端的 session cookie
會被刪除,所以用戶會登出。
另外,F(xiàn)lask-Login 還支持記住登錄狀態(tài),通過在 login_user()
中將 remember
參數(shù)設為 True
即可實現(xiàn)。這時 Flask-Login 會在用戶瀏覽器中創(chuàng)建一個名為 remember_token
的 cookie
,當通過 session
設置的 user_id cookie
因為用戶關閉瀏覽器而失效時,它會重新恢復 user_id cookie
的值。
為了防止破壞 Flask-Login 提供的認證功能,我們在視圖函數(shù)中操作 session
時要避免使用 user_id
和 remember_token
作為鍵。remember_token cookie
的默認過期時間為 365 天。你可以通過配置變量 REMEMBER_COOKIE_DURATION
進行設置,設為 datetime.timedelta
對象即可。
2.1 獲取當前用戶
那么我們?nèi)绾闻袛嘤脩舻恼J證狀態(tài)呢?答案是使用 Flask-Login 提供的 current_user
對象。它是一個和 current_app
類似的代理對象(Proxy),表示當前用戶。調(diào)用時會返回與當前用戶對應的用戶模型類對象。因為 session
中只會存儲登錄用戶的 id
,所以為了讓它返回對應的用戶對象,我們還需要設置一個用戶加載函數(shù)。這個函數(shù)需要使用 login_manager.user_loader
裝飾器,它接收用戶 id
作為參數(shù),返回對應的用戶對象。
@login_manager.user_loader def load_user(user_id): from bluelog.models import Admin user = Admin.query.get(int(user_id)) return user
現(xiàn)在,當我們調(diào)用 current_user
時,F(xiàn)lask-Login 會調(diào)用用戶加載函數(shù)并返回對應的用戶對象。如果當前用戶已經(jīng)登錄,會返回 Admin
類實例;如果用戶未登錄,current_user
默認會返回 Flask-Login 內(nèi)置的 AnonymousUserMixin
類對象,它的 is_authenticated
和 is_active
屬性會返回 False
,而 is_anonymous
屬性則返回 True
。
current_user
存儲在請求上下文堆棧上,所以只有激活請求上下文程序的情況下才可以使用,比如在視圖函數(shù)中或是模板中調(diào)用。
最終,我們可以通過對 current_user
對象調(diào)用 is_authenticated
等屬性來判斷當前用戶的認證狀態(tài)。它也和我們自定義的模板全局變量一樣注入到了模板上下文中,可以在所有模板中使用,所以我們可以在模板中根據(jù)用戶狀態(tài)渲染不同的內(nèi)容
2.2 登入用戶
個人博客的登錄鏈接可以放在次要的位置,因為只有博客作者才會真正用到它。我們把它放到頁腳,并根據(jù)用戶的狀態(tài)來選擇渲染出不同的鏈接。
<small> {% if current_user.is_authenticated %} <!-- 如果用戶已經(jīng)登錄,顯示下面的“登出”鏈接--> <a href="{{ url_for('auth.logout', next=request.full_path) }}" rel="external nofollow" >Logout</a> {% else %} <!-- 如果沒有登錄,則顯示下面的“登錄”按鈕 --> <a href="{{ url_for('auth.login', next=request.full_path) }}" rel="external nofollow" >Login</a> {% endif %} </small>
通過 current_user
的 is_authenticated
值判斷用戶是否登錄,如果用戶已登錄(is_authenticated
為 True
)就渲染注銷按鈕,否則就渲染登錄按鈕。按鈕中的 URL 分別指向用于登錄和登出的 login
和 logout
視圖,url_for()
函數(shù)中加入的 next
參數(shù)用來存儲當前頁面的路徑,以便在執(zhí)行登錄或登出操作后將用戶重定向回上一個頁面。
from flask_login import login_user from bluelog.forms import LoginForm from bluelog.models import Admin from bluelog.utils import redirect_back ... @auth_bp.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('blog.index')) form = LoginForm() if form.validate_on_submit(): username = form.username.data password = form.password.data remember = form.remember.data admin = Admin.query.first() if admin: # 驗證用戶名和密碼 if username == admin.username and admin.validate_password(password): login_user(admin, remember) # 登入用戶 flash('Welcome back.', 'info') return redirect_back() # 返回上一個頁面 flash('Invalid username or password.', 'warning') else: flash('No account.', 'warning') return render_template('auth/login.html', form=form)
登錄視圖負責渲染 login.html
模板和驗證登錄表單。在函數(shù)一開始,為了避免已經(jīng)登錄的用戶不小心訪問這個視圖,我們添加一個if判斷將已經(jīng)登錄的用戶重定向到首頁。
與其他表單處理流程相同,當用戶提交表單且數(shù)據(jù)通過驗證后,我們分別從表單中獲取用戶名(username)、密碼(password)和 “記住我”(remember)字段的數(shù)據(jù)。接著,從數(shù)據(jù)庫中查詢出 Admin
對象,判斷 username
的值,并使用 Admin
類中的 validate_password()
方法驗證密碼。如果通過驗證就調(diào)用 login_user()
方法登錄用戶,傳入用戶對象和 remember
字段的值作為參數(shù),最后使用 redirect_back()
函數(shù)重定向回上一個頁面;如果用戶名和密碼驗證出錯就發(fā)送錯誤提示,并渲染模板。另外,如果 Admin
對象不存在,就發(fā)送一個提示消息,然后重新渲染表單。
登錄表單 LoginForm
在新創(chuàng)建的 login.html
模板中使用 Bootstrap-Flask 提供的 render_form()
宏渲染。為了編寫一個更簡單的登錄頁面,我們打算不在登錄頁面顯示頁腳,因為我們在基模板中為頁腳的代碼定義了 footer
塊,所以在登錄頁面模板只需要定義這個塊并留空就可以覆蓋基模板中的對應內(nèi)容。
{% extends 'base.html' %} {% from 'bootstrap/form.html' import render_form %} {% block title %}Login{% endblock %} {% block content %} <div class="container h-100"> <div class="row h-100 page-header justify-content-center align-items-center"> <h1>Log in</h1> </div> <div class="row h-100 justify-content-center align-items-center"> {{ render_form(form, extra_classes='col-6') }} </div> </div> {% endblock %} {% block footer %}{% endblock %}
2.3 登出用戶
注銷登錄比登錄還要簡單,只需要調(diào)用 Flask-Login 提供的 logout_user()
函數(shù)即可。這會登出用戶并清除 session
中存儲的用戶 id
和 “記住我” 的值。
from flask_login import logout_user ... @auth_bp.route('/logout') @login_required def logout(): logout_user() flash('Logout success.', 'info') return redirect_back()
2.4 視圖保護
程序中的許多操作要求用戶登錄后才能進行,因此我們要把這些需要登錄才能訪問的視圖 “保護” 起來。如果用戶訪問了某個需要認證才能訪問的資源,我們不會返回對應的響應,而是把程序重定向到登錄頁面。
視圖保護可以使用 Flask-Login 提供的 login_required
裝飾器實現(xiàn)。在需要登錄才能訪問的視圖前附加這個裝飾器,比如博客設置頁面。
當為視圖函數(shù)附加多個裝飾器時,route()
裝飾器應該置于最外層。
from flask_login import login_required @admin_bp.route('/settings') @login_required def settings(): ... return render_template('admin/settings.html')
當未登錄的用戶訪問使用了 login_required
裝飾器的視圖時,程序會自動重定向到登錄視圖,并閃現(xiàn)一個消息提示。在此之前,我們還需要在 extension.py
腳本中使用 login_manager
對象的 login_view
屬性設置登錄視圖的端點值(包含藍本名的完整形式)。
login_manager = LoginManager(app) ... login_manager.login_view = 'auth.login' login_manager.login_message_category = 'warning'
使用可選的 login_message_category
屬性可以設置消息的類別,默認類別為 “message”。另外,使用可選的 login_message
屬性設置提示消息的內(nèi)容,默認消息內(nèi)容為“Please log in to access this page.”
當用戶訪問某個被保護的 URL 時,在重定向后的登錄 URL 中,F(xiàn)lask-Login 會自動附加一個包含上一個頁面 URL 的 next
參數(shù),所以我們只需要使用 redirect_back()
函數(shù)就可以將登錄成功后的用戶重定向回上一個頁面。
當在未登錄狀態(tài)下訪問設置頁面 http://localhost:5000/admin/settings 時,程序會重定向到登錄頁面,并顯示提示消息,URL 中包含上一個頁面的 next
參數(shù)。
仔細觀察地址欄,你會看到附加的 next
參數(shù)包含上一個頁面的地址,我們經(jīng)常在上網(wǎng)時在地址欄發(fā)現(xiàn)類似的參數(shù),比如 ReturnUrl
、RedirectUrl
等。當我們登錄后,程序會重定向回我們想要訪問的設置頁面。
有些時候,你會希望為整個藍本添加登錄保護。比如,管理后臺的所有頁面都需要登錄后才能訪問,也就是說,我們需要為所有 admin
藍本中的視圖函數(shù)附加 login_required
裝飾器。有一個小技巧可以避免這些重復:為 admin
藍本注冊一個 before_request
處理函數(shù),然后為這個函數(shù)附加 login_required
裝飾器。因為使用 before_request
鉤子注冊的函數(shù)會在每一個請求前運行,所以這樣就可以為該藍本下所有的視圖函數(shù)添加保護,函數(shù)內(nèi)容可以為空。
@admin_bp.before_request @login_required def login_protect(): pass
- 雖然這個技巧很方便,但是為了避免在書中單獨給出視圖函數(shù)代碼時造成誤解,Bluelog程序中并沒有使用這個技巧。
- 如果沒有使用這個技巧,那么
admin
藍本下的所有視圖都需要添加login_required
裝飾器,否則會導致博客資源被匿名用戶修改。
3.使用CSRFProtect實現(xiàn)CSRF保護
CSRF攻擊,全稱為 Cross-site request forgery
,中文名為 跨站請求偽造,也被稱為 One Click Attack
或者 Session Riding
,通常縮寫為 CSRF
或者 XSRF
,是一種對網(wǎng)站的惡意利用。XSS 主要是利用站點內(nèi)的信任用戶,而 CSRF 則通過偽裝來自受信任用戶的請求,來利用受信任的網(wǎng)站。與 XSS 相比,CSRF 更具危險性。攻擊者盜用用戶身份,發(fā)送惡意請求。比如:模擬用戶發(fā)送郵件,發(fā)消息,以及支付、轉賬等。
博客管理后臺會涉及對資源的局部更新和刪除操作,這時我們就要考慮到 CSRF 保護問題。為了應對 CSRF 攻擊,當需要創(chuàng)建、修改和刪除數(shù)據(jù)時,我們需要將這類請求通過 POST 方法提交,同時在提交請求的表單中添加 CSRF 令牌。對于刪除和某些修改操作來說,單獨創(chuàng)建表單類的流程太過煩瑣,我們可以使用 Flask-WTF 內(nèi)置的 CSRFProtect
擴展為這類操作實現(xiàn)更簡單和完善的 CSRF 保護。
CSRFProtect
是 Flask-WTF 內(nèi)置的擴展,也是 Flask-WTF 內(nèi)部使用的 CSRF 組件,單獨使用可以實現(xiàn)對程序的全局 CSRF 保護。它主要提供了生成和驗證 CSRF 令牌的函數(shù),方便在不使用 WTForms 表單類的情況下實現(xiàn) CSRF 保護。因為我們已經(jīng)安裝了 Flask-WTF,所以可以直接使用它。首先在 extensions.py
腳本中實例化 Flask-WTF
提供的 CSRFProtect
類。
from flask_wtf.csrf import CSRFProtect ... csrf = CSRFProtect() ...
在程序包的構造文件中初始化擴展 CSRFProtect
:
from bluelog.extensions import csrf def create_app(config_name=None): ... register_extensions(app) return app def register_extensions(app): ... csrf.init_app(app)
CSRFProtect
在模板中提供了一個 csrf_token()
函數(shù),用來生成 CSRF 令牌值,我們直接在表單中創(chuàng)建這個隱藏字段,將這個字段的 name
值設為 csrf_token
。下面是用來刪除文章的表單示例:
<form method="post" action="{{ url_for('.delete_post', post_id=post.id) }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="submit" value="Delete Post"/> </form>
在對應的 delete_post
視圖中,我們直接執(zhí)行相關刪除操作,CSRFProtect
會自動獲取并驗證 CSRF 令牌。注意,在 app.route()
裝飾器中使用 methods 參數(shù)限制僅監(jiān)聽 POST 請求。
@app.route('/post/delete/<id>', methods=['POST']) def delete_post(id): post = Post.query.get(id) post.delete() return redirect(url_for('index'))
默認情況下,當令牌驗證出錯或過期時,程序會返回 400 錯誤,和 Werkzeug 內(nèi)置的其他 HTTP 異常類一樣,CSRFError
將錯誤描述保存在異常對象的 description
屬性中。
如果你想將與 CSRF 相關的錯誤描述顯示在模板中,那么你可以在 400 錯誤處理函數(shù)中將異常對象的 description
屬性傳入模板,也可以單獨創(chuàng)建一個錯誤處理函數(shù)捕捉令牌出錯時拋出的 CSRFError
異常。
from flask_wtf.csrf import CSRFError def register_errors(app): ... @app.errorhandler(CSRFError) def handle_csrf_error(e): return render_template('400.html', description=e.description), 400
這個錯誤處理函數(shù)仍然使用 app.errorhandler
裝飾器注冊,傳入 flask_wtf.csrf
模塊中的 CSRFError
類。這個錯誤處理函數(shù)返回 400 錯誤響應,通過異常對象的 description
屬性獲取內(nèi)置的錯誤消息(英文),傳入模板 400.html
中。在模板中,我們渲染這個錯誤消息,并為常規(guī) 400 錯誤設置一個默認值。
<p>{<!--{cke_protected}{C}%3C!%2D%2D%20%2D%2D%3E-->{ description|default('Bad Request') }}</p>
在實際應用中,除了使用內(nèi)置的錯誤描述,更合適的方法是自己編寫錯誤描述信息。默認的錯誤描述為 “Invalid CSRF token.” 和 “The CSRF token is missing.” 因為包含太多術語,不容易理解,所以在實際的程序中,我們應該使用更簡單的錯誤提示,比如 “會話過期或失效,請返回上一頁面重試”。
到此這篇關于Python個人博客程序開發(fā)實例用戶驗證功能的文章就介紹到這了,更多相關Python個人博客內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!