Python Flask 概念與實作(六) - 登入功能
前言
雖說這節要講登入功能(login/signin),但其實最重要的是註冊功能(register) 😂😂 ,畢竟要有user data才能登入啊~~
目前 Python Flask 概念與實作 大致規劃為
在開發過程中,Test(測試)其實也很重要,但礙於缺乏經驗,若之後有望的話再補上(專案打包、網站優化等等亦同)
Fornt-side
在使用 WTForm 快速建立表單 feet. flask檔案佈局 & bootstrap一節,我們已經可以使用bootstrap製作表單,這裡我們新增註冊與登入表單前端
- 新增
register.html1
2
3
4
5
6
7
8
9
10
11
12
13<!-- user/register.html -->
<!-- 略... -->
<h3 class="heading h3 mb-4">註冊</h3>
{% from 'bootstrap4/form.html' import render_field %}
<form method="POST" enctype="multipart/form-data">
{{ form.csrf_token() }}
{{ render_field(form.username, placeholder="請輸入手機號碼或使用者代號", class="form-control") }}
{{ render_field(form.email, placeholder="example@gmail.com", class="form-control", type="email") }}
{{ render_field(form.password, placeholder="請輸入密碼(6~20位英數混合)", class="form-control") }}
{{ render_field(form.confirm, placeholder="", class="form-control") }}
{{ render_field(form.submit, class="btn btn-dark px-4") }}
</form>
<!-- 略... -->❗️ Notice: 快速建立表單時使用的
render_form已包含method="POST" enctype="multipart/form-data"與{{ form.csrf_token() }}; 使用render_field則必須自行加入 - 新增
signin.html雖然可以使用1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<!-- users/sigin.html-->
<!-- 略... -->
<h3 class="heading text-white pt-2">登入</h3>
{% from 'bootstrap4/form.html' import render_field %}
<form class="form-dark" method="POST" enctype="multipart/form-data">
{{ form.csrf_token() }}
<div class="form-group">
<input type="text" class="form-control" name="username" id="username" placeholder="請輸入電話號碼或使用者代號">
</div>
<div class="form-group">
<input type="password" class="form-control" name="password" id="password" placeholder="請輸入密碼(6~20位元)">
</div>
<div class="text-right mt-4">
<a href="{{ url_for('users.register') }}" class="text-white">註冊</a> <a href="#" class="text-white">忘記密碼?</a></div>
<button type="submit" class="btn btn-block bg-white mt-4"><span class="text-datk">Sign in</span></button>
</form>
<!-- 略... -->wtf.quick_form(form)搭配model快速建立表單,但自己寫一個還是比較美觀 🤭 - 在
index.html新增登入或登出連接1
2
3
4
5
6
7<!-- index.html -->
<!-- 略... --->
{% if not current_user.is_authenticated %}
<a href="{{ url_for('users.signin') }}"><i class="fas fa-user"></i>Signin</a>
{% else %}
<a href="{{ url_for('users.logout') }}"><i class="fas fa-sign-out-alt text-tertiary"></i>登出</a>
{% endif %}💡 Notice: 若當前client已經登入,
current_user.is_authenticated返回True,否則返回False
CSRF, Cross Site Request Forgery
這裡稍微解釋一下為何<form>標籤內要包含{{ form.csrf_token() }}
起初client每次訪問server時,都需要做一次驗證,造成資源浪費,而cookie(明文的資訊,可自行修改)的出現讓client可以儲存自身的狀態。
但http並不是一個安全的傳輸協議,有一天惡意攻擊者在網路中獲得你的cookie時,他會使用這個cookie偽造你的身份,發送假的訊息給server(有點類似用你的集點卡去換獎品的概念),這就是CSRF(跨網站請求偽造)。
然而要防範這種狀況,server會在user訪問時給予一個session(加密的資訊),session也是cookie的一種,雖然儲存在user端,但只有server可解密。
當user傳送訊息給server時,必須包含此session,如此server可確保你的訊息不被惡意攻擊者偽造(因session包含username與時戳),而此session就是CSRF token。
1 | # routes.py |
Back-side
有了前端介面就可以來修改後端邏輯了!!Flask中有flask-login套件幫助我們管理client的狀態
Flask-Login
- 安裝
flask-login1
pip install flask-login
- 修改
models.py,定義lgoin1
2
3
4
5
6
7
8# models.py
from flask_login import LoginManager, UserMixin
login = LoginManager()
def load_user(user_id):
return Users.query.filter_by(id=user_id).first() - 修改
__init__.py,實體化login1
2
3
4
5
6
7
8
9
10
11# __init__.py
def create_app(configname=None):
# 略...
login.init_app(app)
# login_required 設定
login.login_view = "signin" # 訪問欲登入權限時跳轉頁面
login.login_message = "You must login to access this page." # 訪問欲登入頁面顯示訊息
login.login_message_category = "info" # 訊息警示種類(顏色)
return app - 在
models.py新增Users1
2
3
4
5
6
7
8
9
10
11# user/models.py
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class Users(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(20), nullable=False)
def __repr__(self):
return f'<Users {self.username}>'💡 Notice: 建議可以先在flask shell中測試
Usersmodel無問題再進行下一步 - 在
users/forms.py新增RegisterForm與SigninForm1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20from wtforms import StringField,SubmitField,PasswordField
from wtforms.validators import DataRequired, Length, \
ValidationError, Email, EqualTo
class RegisterForm(FlaskForm):
username = StringField("帳號", validators=[DataRequired(), Length(min=6, max=20)])
# 需安裝email_validator
email = StringField("Email", validators=[DataRequired(), Email()])
password = PasswordField("密碼", validators=[DataRequired(), Length(min=6, max=20)])
confirm = PasswordField("確認密碼", validators=[DataRequired(), EqualTo("password")])
submit = SubmitField("註冊")
class SigninForm(FlaskForm):
username = StringField("帳號", validators=[DataRequired(), Length(min=6, max=20)])
password = PasswordField("密碼", validators=[DataRequired(), Length(min=6, max=20)])
submit = SubmitField("登入")
def validate_name(self, username, password): # name 必須與上方的變數名相同
user = Users.query.filter_by(username=username.data).first()
if user :
raise ValidationError("帳號不存在或密碼錯誤")💡 Notice: email中的validators(檢查器),
Email()需額外安裝email_validator1
pip install email_validator
- 在
users/routes.py新增register()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24# user/routes.py
from flask import redirect, flash, render_template, url_for, session
from flask_login import current_user, login_user
from app.forms import *
def register():
form = RegisterForm()
if form.validate_on_submit():
username = form.username.data
email = form.email.data
password = form.password.data
if Users.query.filter_by(username=username).first():
flash('此帳號已被使用,請改使用其他帳號', category='warning')
return render_template('register.html', form=form)
if Users.query.filter_by(email=email).first():
flash('此信箱已被使用,請改使用其他信箱。', category='warning')
return render_template('register.html', form=form)
user = Users(username=username, email=email, password=password)
db.session.add(user)
db.session.commit()
flash("註冊成功!!", category="success")
return redirect(url_for("users.signin"))
return render_template("register.html", form=form) - 在
users/routes.py新增signin()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21# users/routes.py
def signin():
# 若已登入, 則導回index.html
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = SigninForm()
username = form.username.data
password = form.password.data
user = Users.query.filter_by(username=username).first()
if form.is_submitted(): # 當表單傳送時
if form.validate() and user and user.password == password:
# 表單格式合法, user是存在的, user's password相符
login_user(user)
session["username"] = user.username
flash("歡迎回來", category="success")
return redirect(url_for("main.index"))
print(form.errors)
flash("帳號或密碼錯誤,請重新輸入", category="danger")
return redirect(url_for("users.signin"))
return render_template("sign-in.html", form=form) - 在
users/routes.py新增logout()1
2
3
4
5
# 要求使用者必須登入後才能訪問
def logout():
logout_user()
return redirect(url_for("users.signin"))💡 Notice: 使用
@login_required可限制client必須登入才能訪問此頁面,否則回跳至__init__.py中設定的路徑,並且顯示提示訊息
至此,我們已經完成一個註冊與登入功能。
並且登入後,Flask會在session中紀錄我們的登入狀態 🤙🏻
Flask Bcrypt
雖說我們已經實現註冊與登入功能,但其實存在一個嚴重問題。
如果使用flask shell訪問user.password,
1 | from models import User |
使用者的密碼並沒有遮罩保存,衍生下列的問題
- 明文密碼儲存在資料庫,造成保管上的疑慮(雲端伺服器)
- 使用者在不同網站中的密碼大概率是相同的,即使當前的網站被破解對使用者無妨。但攻擊者仍可使用此密碼去攻擊其他網站
因此我們需要一個機制可以將密碼遮罩的同時,又可以驗證user輸入的密碼是否正確。
而我們可以使用一種Hash Function(雜湊函數)的技術,其特性為:
- 輸出的結果是隨機且不可逆的,無法使用hash value回推輸入
- 不同的長的字串丟入Hash Function中,都會輸出相同長度的hash value
- 不同的字串丟入Hash Function結果相異。即使非常相似,兩者hash value也各不相關
- 相同的字串丟入Hash Function結果相同。
💡 Notice: 當然也有不同輸入結果相同的狀況發生(Collision, 碰撞),但這不在本節的討論範圍中
Flask-Bcrypt讓我們可以快速使用這個技術
- 安裝
flask-bcrypt1
pip install flask-bcrypt
- 測試
flask-bcrypt1
2
3
4
5
6
7
8
9
10
11
12
13
14# flask shell
from flask-bcrypt import Bcrypt
pw = 'my_secret_password'
pw1 = 'my_secret_password'
### 丟入hash function(hash value + salt)
pw_hashed = bcrypt.generate_password_hash(pw)
# b'$2b$12$Uc6q9qQb2KAGU6HUOswcYOMSrI7o5MPD.jkPGQ3sNYO3riP1xhYEK'
pw1_hashed = bcrypt.generate_password_hash(pw1)
# b'$2b$12$DOIdHeZ9e0xJZlLhz5NA7u77xYTW10ZUSD9yhrHfJvC1tHSZkiKV.'
### 比較hash value, True
bcrypt.check_password_hash(pw_hashed, pw)💡 Notice: 相同輸入的hash value結果是相同的,
bcrypt在hash value加了salt(鹽),讓結果看起來不同,驗證時仍會加入相同的salt保證驗證成功 - 在
users/models.py修改password儲存長度並定義Flask-Bcrypt1
2
3
4
5
6
7
8# users/models.py
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
class Users(db.Model, UserMixin):
# 改儲存hash value, 擴充欄位大小(20->255)
password = db.Column(db.String(255), nullable=False)
# 略.... - 在
users/__init__.py實體化bcrypt1
2
3
4
5
6
7# users/__init__.py
from app.models import bcrypt
def create_app(configname=None):
app = Flask(__name__)
bcrypt.init_app(app)
# 略... - 在
users/routes.py修改register()1
2
3
4
5
6
7
8
9
10
11
12
13# users/routes.py
def register():
form = RegisterForm()
if form.validate_on_submit():
username = form.username.data
email = form.email.data
# 將表格輸入的password丟入hash function
password = bcrypt.generate_password_hash(form.password.data).decode('UTF-8')
# 略...
return redirect(url_for("users.signin"))
return render_template("register.html", form=form) - 在
users/routes.py修改register()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19# users/routes.py
def signin():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = SigninForm()
username = form.username.data
password = form.password.data
user = Users.query.filter_by(username=username).first()
if form.is_submitted():
# 比對hash function
if form.validate() and user and bcrypt.check_password_hash(user.password.encode('UTF-8'), password):
login_user(user)
session["username"] = user.username
flash("歡迎回來", category="success")
return redirect(url_for("main.index"))
flash("帳號或密碼錯誤,請重新輸入", category="danger")
return redirect(url_for("users.signin"))
return render_template("sign-in.html", form=form)
💡 Notice: 因儲存在MySQL時,MySQL會將hash value錯誤識別成二進制,因此在
register()中的password有使用decode('UTF-8')與signin中的encode('UTF-8'),將其強制改為字串型態
如此一來我們已經藉由Flask-Bcrypt將密碼儲存為hash value,並且在使用者登入時仍可正常驗證密碼 👍🏻
結論
- Flask-Login 限制client存取特定頁面
- Hash Fuction(雜湊函數)、概念
- Flask-Bcrypt 將密碼轉為hash value、比對輸入與hash vlaue
Reference
零基礎資安系列(一)-認識 CSRF(Cross Site Request Forgery)
Day14-Session與Cookie差別