Python Flask 概念與實作(六) - 登入功能

前言

雖說這節要講登入功能(login/signin),但其實最重要的是註冊功能(register) 😂😂 ,畢竟要有user data才能登入啊~~


目前 Python Flask 概念與實作 大致規劃為

  1. 佈置環境
  2. Flask model運用(一)
  3. Web server概念(二)
  4. 專案檔案分佈(三)
  5. 連接資料庫(四)
  6. 渲染模板(五)
  7. 登入功能(六)
  8. Blog功能(七)

在開發過程中,Test(測試)其實也很重要,但礙於缺乏經驗,若之後有望的話再補上(專案打包、網站優化等等亦同)


Fornt-side

使用 WTForm 快速建立表單 feet. flask檔案佈局 & bootstrap一節,我們已經可以使用bootstrap製作表單,這裡我們新增註冊與登入表單前端

  1. 新增register.html
    1
    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則必須自行加入

  2. 新增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快速建立表單,但自己寫一個還是比較美觀 🤭
  3. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# routes.py
def signin():
form = SigninForm()

if form.is_submitted(): # 若form傳送成功
print("submitted")

if form.validate(): # 若form驗證器驗證成功
print("valid")

print(form.errors) # 顯示form返回的錯誤訊息
# 當前端無使用{{ form.csrf_token }}時,
# 'csrf_token': ['The CSRF token is missing.']

if form.validate_on_submit():
print("form.validate_on_submit success")
return render_template("sign-in.html", form=form)

Back-side

有了前端介面就可以來修改後端邏輯了!!Flask中有flask-login套件幫助我們管理client的狀態

Flask-Login

  1. 安裝flask-login
    1
    pip install flask-login
  2. 修改models.py,定義lgoin
    1
    2
    3
    4
    5
    6
    7
    8
    # models.py
    from flask_login import LoginManager, UserMixin

    login = LoginManager()

    @login.user_loader
    def load_user(user_id):
    return Users.query.filter_by(id=user_id).first()
  3. 修改__init__.py,實體化login
    1
    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
  4. models.py新增Users
    1
    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中測試Users model無問題再進行下一步

  5. users/forms.py新增RegisterFormSigninForm
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    from 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_validator

    1
    pip install email_validator
  6. 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 *

    @users_bp.route("/register", methods=["GET", "POST"])
    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)
  7. 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
    @users_bp.route("/signin", methods=["GET", "POST"])
    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)
  8. users/routes.py新增logout()
    1
    2
    3
    4
    5
    @users_bp.route("/logout")
    @login_required # 要求使用者必須登入後才能訪問
    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
2
3
4
from models import User
u = Users.query.all()[0]
u # <Users test1234>
u.password # 12345678

使用者的密碼並沒有遮罩保存,衍生下列的問題

  1. 明文密碼儲存在資料庫,造成保管上的疑慮(雲端伺服器)
  2. 使用者在不同網站中的密碼大概率是相同的,即使當前的網站被破解對使用者無妨。但攻擊者仍可使用此密碼去攻擊其他網站

因此我們需要一個機制可以將密碼遮罩的同時,又可以驗證user輸入的密碼是否正確。

而我們可以使用一種Hash Function(雜湊函數)的技術,其特性為:

  1. 輸出的結果是隨機且不可逆的,無法使用hash value回推輸入
  2. 不同的長的字串丟入Hash Function中,都會輸出相同長度的hash value
  3. 不同的字串丟入Hash Function結果相異。即使非常相似,兩者hash value也各不相關
  4. 相同的字串丟入Hash Function結果相同。

💡 Notice: 當然也有不同輸入結果相同的狀況發生(Collision, 碰撞),但這不在本節的討論範圍中

Flask-Bcrypt讓我們可以快速使用這個技術

  1. 安裝flask-bcrypt
    1
    pip install flask-bcrypt
  2. 測試flask-bcrypt
    1
    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保證驗證成功

  3. users/models.py修改password儲存長度並定義Flask-Bcrypt
    1
    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)
    # 略....
  4. users/__init__.py實體化bcrypt
    1
    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)
    # 略...
  5. users/routes.py修改register()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # users/routes.py
    @users_bp.route("/register", methods=["GET", "POST"])
    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)
  6. 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
    @users_bp.route("/signin", methods=["GET", "POST"])
    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差別