跳至主要内容

會員系統

Flask 會員系統

在這一章中,我們將來試著做個簡單的會員系統,包括會員註冊、登入、登出等功能。不像 Django 這種比較功能完整、包山包海的框架,Flask 本身並沒有提供這麼多的功能,所以我們可能得自己來實作這些功能。

我會繼續用上一章的部落格系統的範例接著做,除了比較省事之外,後半段也要做到會員跟文章之間的關聯,讓每個會員只能編輯或刪除自己的文章等功能。

在上個章節我都是把程式碼寫在 app.py 檔案裡,隨著程式越寫越多,接下來的會員系統如果再加上後續還有其他追加其他功能的話,再繼續寫在同一個檔案的話,程式碼會變得又臭又長,不容易維護。所以在開始寫會員系統前,我想先稍微做點整理,把程式碼分成不同的目錄或檔案,讓專案看起來更有組織性。剛好,在 Flask 裡有個叫做 Blueprint 的設計可以讓我們做到這件事。

使用 Blueprint

Flask 的「藍圖(Blueprint)」可以讓我們把程式碼寫在不同的檔案裡,然後再把這些檔案「註冊」到 Flask 應用程式裡,這樣就可以不用把程式碼全部寫在同一個檔案裡了,如果只是一個小型的專案,可能不會覺得有什麼不方便,但如果專案越來越大,學著怎麼整理程式碼就越來越重要。我相信各位讀這本書,應該不會以後只想做個小專案而已。

使用藍圖整理專案的手法很多種,官方文件也沒有給出建議的做法,所以 10 個團隊可能就會有 10 種不同的整理方法。我也提供一個我自己的做法,如果各位有更好的做法,都可以自己試試看。

首先,我假設這個專案裡的應用程式可能會越來越多,所以我先在專案的根目錄下建立一個叫做 apps 的目錄,裡面準備放所有的應用程式。就以上一章介紹部落格的文章 CRUD 功能為例,我認為它是一組文章相關的功能,所以我在 apps 目錄裡再開一個 post 目錄,裡面新增一個檔案 views.py,現在的目錄結構看起來像這樣:

simple-flask-blog
├─ app.py
├─ apps
│  └─ post
│   └─ views.py # <-- 在這裡
├─ config
├─ db
├─ migrations
├─ models
├─ poetry.lock
├─ pyproject.toml
└─ templates

因為我想讓剛才新增的檔案決定使用者能「看到什麼」,所以我把它取名叫 views.py,這在之後介紹到的 Django 框架也有類似的概念。views.py 的內容要寫些什麼?我先回到 app.py 把原本負責首頁,也就是文章列表的程式碼剪下來,貼到 apps/post/views.py 裡,再稍微調整一下:

# 檔案 apps/post/views.py
from flask import Blueprint, render_template
from models import Post

post_bp = Blueprint("post", __name__)

@post_bp.route("/")
@post_bp.route("/posts")
def index():
posts = Post.query.order_by(-Post.id).all()
return render_template("posts/index.html.jinja", posts=posts)

首先,先透過 Blueprint 類別建立了一個叫做 post_bp 的藍圖物件,後面加上的 _bp 只是我為了讓這個物件看起來跟藍圖有關而已。Blueprint 類別裡的 "post" 參數是這個藍圖的名字,請先記得它,待會在 url_for() 的時候會用上。

接著把原本的裝飾器 @app.route 改寫成 @post_bp.route(),底下的 index() 函數的內容一個字都不用改,這樣前置做業就完成了。再來就是到 app.py 註冊剛才建立的藍圖物件:

# 檔案 app.py
from apps.post.views import post_bp

app = Flask(__name__)
app.register_blueprint(post_bp) # <-- 這裡

這樣就完成了。你可以現在再看看是不是功能一樣可以正常運作,如果有問題,可以先檢查一下程式碼有沒有打錯或是目錄、檔案放錯位置了。

首先搬完了,接下來我也把文章的 CRUD 文章的功能都搬過去,這不算複雜,只要把裝飾器名字一起改過來,然後該 import 的套件或函數也補上,基本上就沒問題了,因為程式碼有點長,可請大家檢視本次 Commit 的進度。

比較需要特別調整的,是之前如果用 url_for() 函數來產生 URL 的話,現在要加上藍圖的名字,例如原本是 url_for("show"),現在要改成 url_for("post.show"),這樣就可以正確產生 URL 了,這其實滿好的。所以搜尋一下專案裡用到的 url_for() 函數,然後把它們改成正確的名字。

另外,大家還記得原本在 app.py 還有一個專門處理 404 的傢伙嗎?以後也有可能會有更多處理像是 401 或 500 的情況,所以我也趁這次整理一併搬到 apps 目錄裡,建立一個 error 目錄,裡面新增檔案 handlers.py,然後把原本的 404 處理程式碼貼過去,用相同的手法調整一下,只有一個地方需要特別注意:

from flask import Blueprint, render_template

error_bp = Blueprint("error", __name__)

@error_bp.app_errorhandler(404) # <-- 這裡
def page_not_found(_):
return render_template("errors/404.html.jinja"), 404

原本的 errorhandler() 裝飾器是給 Flask 的,不是給 Blueprint 的,所以這裡需要改成 app_errorhandler(),這樣就完成了。最後別忘了把它註冊到 app.py 裡:

# 檔案 app.py
from apps.post.views import post_bp
from apps.error.handlers import error_bp

app = Flask(__name__)
app.register_blueprint(post_bp)
app.register_blueprint(error_bp)

現在整個專案的目錄結構看起來像這樣:

simple-flask-blog
├─ app.py
├─ apps
│  ├─ error
│  │  └─ handlers.py
│  └─ post
│   └─ views.py
├─ config
├─ db
├─ migrations
├─ models
├─ poetry.lock
├─ pyproject.toml
└─ templates

Blueprint 還有很多功能,例如可以把樣版、靜態檔案、錯誤處理等等都放在自己專屬的目錄裡,這樣可以更容易管理、組織專案,不過本章節的重點在於會員系統,所以到這裡先告一段落,有興趣的讀者可以再閱查閱官方文件。

目前進度

分支名稱 14-using-blueprint

會員系統

接下來我們要來實作會員系統,功能會有會員註冊、登入、登出這些功能等功能,至於會員基本資料更新、忘記密碼、變更密碼等功能我就留給大家了。我們就先從會員註冊開始...

新增會員

其實會員系統就跟文章系統一樣,說穿了也就只是個 CRUD,只不過 CRUD 的對象不是文章而是會員資料,所以首先我先在 models 目錄裡新增一個 user.py,準備建立會員資料的 Model:

class User(db.Model):
__tablename__ = "users"

id = mapped_column(Integer, primary_key=True)
username = mapped_column(String(50), nullable=False)
password = mapped_column(String(100), nullable=False)
created_at = mapped_column(DateTime, server_default=func.now())
updated_at = mapped_column(
DateTime, server_default=func.now(), server_onupdate=func.now()
)

def __repr__(self):
return f"{self.username}"

這裡沒什麼特別的,不過我發現 created_at 以及 updated_at 這兩個欄位我們之前文章的 Model 也出現過,之後其他 Model 應該也會用上,所以我打算把這兩個欄位抽出來變成一個類別,放在 models/mixins 目錄裡,檔名我就用 datetime.py

# 檔案 models/mixins/datetime.py
from sqlalchemy import DateTime
from sqlalchemy.orm import mapped_column
from sqlalchemy.sql import func

class TimeTrackable:
created_at = mapped_column(DateTime, server_default=func.now())
updated_at = mapped_column(
DateTime, server_default=func.now(), server_onupdate=func.now()
)

然後就讓 User 這個 Model 除了繼承應該繼承的之外,再另外繼承 TimeTrackable 類別:

# 檔案 models/user.py
from .mixins.datetime import TimeTrackable

class User(db.Model, TimeTrackable):
__tablename__ = "users"

id = mapped_column(Integer, primary_key=True)
username = mapped_column(String(50), nullable=False)
password = mapped_column(String(100), nullable=False)

def __repr__(self):
return f"{self.username}"

這樣以後要記錄建立時間以及更新時間的 Model 直接繼承 TimeTrackable 類別就行了,不用再重複寫一次。既然有了共用的模組,除了 User Model 之外,上個章節的 Post 別忘了也一起改寫。最後再修正一下 models/__init__.py

# 檔案 models/__init__.py
from .post import Post
from .user import User

然後就能執行 flask db migrate 產生 Migration 檔以及 flask db upgrade 更新資料庫了:

$ flask db migrate -m 'add users table'
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'user'
Generating /private/tmp/simple-flask-blog/migrations/versions/3487c42faba5_add_users_table.py ... done

$ flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade c6006c6206d2 -> 3487c42faba5, add users table

跟之前有一些些不一樣,這次我在做 flask db migrate 的時候額外加上了 -m 參數,主要是加上這次 Migration 想要做的事情是什麼,可以看到產生的 Migration 檔名除了原本的亂數外,還會加上這個訊息,這樣以後看檔案名字就能猜出這個 Migration 是做什麼的。

目前進度

分支名稱 15-add-user-model

註冊頁面

接下來實作會員註冊功能,首先先新增一個 Blueprint,因為功能跟使用者有關,所以我就在 apps 目錄裡新增一個 user 目錄,裡面同樣放一個 views.py

# 檔案 apps/user/views.py
from flask import Blueprint, render_template

user_bp = Blueprint("user", __name__)

@user_bp.route("/sign_up")
def new():
return render_template("users/new.html.jinja")

@user_bp.route("/create", methods=["POST"])
def create():
pass

這裡沒什麼特別的,就跟前面一樣的藍圖而已,這裡新增了一個看起來是 /sign_up 的路徑,另一個 /create 還沒什麼內容,先寫好準備著而已。接著到 app.py 註冊一下藍圖:

# 檔案 app.py
from apps.user.views import user_bp

app = Flask(__name__)
app.register_blueprint(post_bp)
app.register_blueprint(user_bp, url_prefix="/users") # <-- 這裡
app.register_blueprint(error_bp)

跟之前又有一些些不同的地方,這次註冊藍圖的時候多加了一個 url_prefix 參數,加這個參數可以避免跟其他藍圖的路徑衝突。想想看,如果兩個不同的藍圖都剛好設定了相同的路徑的時候該聽誰的?以上面的例子來說,加了 url_prefix 參數之後,在這個 user_bp 這個藍圖裡的所有路徑前面都會被冠上 /users 前綴,例如原本看起來是 /sign_up,現在就會變成 /users/sign_up。不只可以避免衝突,在各自的藍圖裡的路徑就能少寫幾個字,感覺好像也不錯,所以我也順手把文章的路徑改一下:

from apps.post.views import post_bp, index as root_view
from apps.user.views import user_bp
from apps.error.handlers import error_bp

app = Flask(__name__)
app.add_url_rule("/", view_func=root_view)
app.register_blueprint(post_bp, url_prefix="/posts")
app.register_blueprint(user_bp, url_prefix="/users")
app.register_blueprint(error_bp)

因為首頁的路徑是 /,所以我另外使用 .add_url_rule 方法來設定路徑,然後借用文章藍圖裡的 index() 函數來用,在文章藍圖裡的其他路徑也別忘了改。

接著,我調整一下原本導覽列的連結,加上註冊與登入的連結:

<nav class="flex items-center justify-between my-2">
<ul class="flex gap-4">
<li><a href="{{ url_for("root") }}" class="hover:underline">文章列表</a></li>
<li><a href="{{ url_for("post.new") }}" class="hover:underline">新增文章</a></li>
</ul>
<ul class="flex gap-4">
<li><a href="{{ url_for("user.new") }}" class="hover:underline">註冊</a></li>
<li><a href="#" class="hover:underline">登入</a></li>
<li></li>
</ul>
</nav>

再來是會員註冊表單 templates/users/new.html.jinja

{% extends "layout.html.jinja" %} {% block content %}
<h1 class="mb-2 text-2xl">會員註冊</h1>
<form
action="{{ url_for('user.create') }}"
method="POST"
class="flex flex-col gap-4"
onsubmit="return confirm('確認送出?')"
>
<div class="flex flex-col gap-2">
<label for="username">帳號</label>
<input type="text" id="username" name="username" class="..." placeholder="使用者帳號" />
</div>
<div class="flex flex-col gap-2">
<label for="password">密碼</label>
<input type="password" id="password" name="password" class="..." placeholder="請填寫密碼" />
</div>
<div class="flex flex-col gap-2">
<label for="password_confirm">密碼確認</label>
<input type="password" id="password_confirm" name="password_confirm" class="..." placeholder="請再次確認密碼" />
</div>
<div>
<button type="submit" class="p-2 text-lg text-white bg-blue-500 rounded-sm">註冊帳號</button>
</div>
</form>
{% endblock %}

這裡我稍微省略了一些 CSS,主要是為了讓程式碼看起來比較簡潔,不過這個表單的部分跟之前的新增文章表單差不多,只是多了一個密碼確認的欄位而已。再來就是要處理表單送出之後的事了。跟文章的 CRUD 的寫法差不多,只是我們在上個章節稍微偷懶了一下,沒有做表單的驗證,但會員註冊就不能這麼偷懶了,該填的沒有填,或是同一個帳號不能註冊兩欠,這些現在都得檢查,所以寫起來可能會像這樣:

# 檔案 apps/user/views.py
@user_bp.route("/create", methods=["POST"])
def create():
username = request.form.get("username")
password = request.form.get("password")
password_confirm = request.form.get("password_confirm")

if not username or not password:
flash("請填寫帳號及密碼")
return redirect(url_for("user.new"))

if password != password_confirm:
flash("密碼確認有誤")
return redirect(url_for("user.new"))

user_exists = User.query.filter_by(username=username).exists()

if user_exists:
flash("該帳號已註冊")
return redirect(url_for("user.new"))

# ....
# 未完,待續

隨著欄位越來越多,這樣的寫法會變得越來越難維護,所以我想借用另一個在 Flask 也常用到的套件 WTForms 來幫助我們搞定處理表單的驗證...

目前進度

分支名稱 16-user-signup-form

表單驗證

WTForms 是一個 Python 專門用來處理表單的套件,可以幫我們搞定處理表單的驗證之類的事,讓程式碼看起來更加簡潔。在 Flask 有人幫忙寫了擴充套件 Flask-WTF,在 Flask 裡使用 WTForms 更方便,先來安裝這個擴充套件:

$ poetry add flask-wtf
... 略 ...

Package operations: 2 installs, 0 updates, 0 removals

- Installing wtforms (3.1.2)
- Installing flask-wtf (1.2.1)

Writing lock file

雖然這個 WTF 的名字有點怪,但待會用起來的時候的手感還是很不錯的。接著我要來建立一個專門處理會員的表單類別,因為跟會員系統有關,我就把它放在 apps/user 目錄裡,檔名就取為 forms.py,內容如下:

# 檔案 apps/user/forms.py
from flask_wtf import Form
from wtforms import StringField, PasswordField
from wtforms.validators import InputRequired, Length, EqualTo

STYLES = "text-xl w-full md:w-1/2 px-3 py-2 border border-gray-900 rounded-sm"

class UserRegisterForm(Form):
username = StringField(
"帳號",
validators=[InputRequired(), Length(min=4)],
render_kw={"placeholder": "使用者帳號", "class": STYLES},
)
password = PasswordField(
"密碼",
validators=[InputRequired()],
render_kw={"placeholder": "請填寫登入密碼", "class": STYLES},
)
password_confirm = PasswordField(
"密碼確認",
validators=[InputRequired(), EqualTo("password", message="密碼確認不符")],
render_kw={"placeholder": "再次確認密碼", "class": STYLES},
)

乍看之下好像有點多東西,不過這裡的 STYLES 樣式以及 render_kw 參數可以先不用管它,這是因為我想待會讓它長出來的樣子有 CSS 樣式以及順便帶入一些屬性,這樣待會就不用在 HTML 裡面寫太多的樣式。

validators 則是用來定義這個欄位的驗證規則,這裡我用了 InputRequired 來確保這個欄位一定要填寫,Length 則是用來檢查帳號是至少有 4 個字,EqualTo 則是用來驗證是不是跟 password 欄位一樣的輸入結果。接著我們要來修改一下會員註冊的表單,讓它使用這個表單類別:

# 檔案 apps/user/views.py
from .forms import UserRegisterForm

@user_bp.route("/sign_up")
def new():
form = UserRegisterForm() # <-- 使用表單
return render_template("users/new.html.jinja", form=form)

這樣就可以準備在 HTML 樣版裡使用它了。使用的方法也挺簡單,回到 templates/users/new.html.jinja 樣版,原本的表單欄位這樣寫:

<div class="flex flex-col gap-2">
<label for="username">帳號</label>
<input
type="text"
id="username"
name="username"
class="w-full p-2 text-xl border border-gray-900 rounded-sm md:w-1/2"
placeholder="使用者帳號"
/>
</div>

使用表單元件後,可以改成這樣寫:

<div class="flex flex-col gap-2">{{ form.username.label }} {{ form.username }}</div>

有覺得比較簡單一點嗎?使用表單元件好像可以省一些手寫 HTML 標籤的時間,但其實 HTML 標籤也沒幾個字,所以這並不是重點,重點在於待會表單送出後的驗證。然後又因為這樣的東西可能會重複使用,所以我要把它抽出來變成一個...函數,通常我會稱這樣的東西叫做「小幫手(Helper)」,在 Jinja 稱這東西叫做「巨集(Macro)」。因為這個小幫手是用來幫助表單元件的,所以我就把它放在 templates/helpers 目錄裡,檔名取為 form_field.html.jinja,內容如下:

{% macro render_field(field) %}
<div class="flex flex-col gap-2">
{{ field.label(class="font-bold") }} {{ field(**kwargs)|safe }} {% if field.errors %}
<ul>
{% for error in field.errors %}
<li class="px-1 font-bold text-red-500">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}

這個不是什麼厲害的東西,其實這段也是從 WTForms 的官方文件上面借來的,這裡使用 Jinja 的 macro 語法建立一個名為 render_field() 的巨集,各位要把巨集當函數看也可以,裡面就是寫一些打算重複使用的 HTML 標籤,也就是每當執行這個巨集,就可以幫我產生這一大塊的 <div>...</div>。回到 templates/users/new.html.jinja 樣版來試用看看:

{% extends "layout.html.jinja" %} {% from "helpers/form_field.html.jinja" import render_field %} {% block content %}
<h1 class="mb-2 text-2xl">會員註冊</h1>
<form
action="{{ url_for('user.create') }}"
method="POST"
class="flex flex-col gap-4"
onsubmit="return confirm('確認送出?')"
>
{{ render_field(form.username) }} {{ render_field(form.password) }} {{ render_field(form.password_confirm) }}
<div>
<button type="submit" class="p-2 text-lg text-white bg-blue-500 rounded-sm">註冊帳號</button>
</div>
</form>
{% endblock %}

這裡看到的 {% from ... import ... %} 並不是 Python 的語法,雖然它看起來有點像,這同樣也是 Jinja 提供的,用途是引入其他檔案裡的巨集,接下來就可以在這個樣版裡使用它了。引入剛才寫的 render_field() 巨集,然後把表單元件傳給它,巨集就能幫我們產生相對應的 HTML 標籤。產生 HTML 標籤不是重點,重點是接下來送出表單之後的處理。回到 apps/user/views.py 檔案裡,我們要來處理一下送出來的表單:

# 檔案 apps/user/views.py
@user_bp.route("/create", methods=["POST"])
def create():
form = UserRegisterForm(request.form)

if form.validate():
user = User(username=form.username.data, password=form.password.data)
db.session.add(user)
db.session.commit()
flash("註冊成功!")
return redirect(url_for("root"))

return render_template("users/new.html.jinja", form=form)

把抓到的表單資料傳給 UserRegisterForm 表單類別,然後呼叫 .validate() 方法來驗證表單資料,驗證成功就把資料存進資料庫,這跟之前新增文章的做法差不多。沒有比較就沒有傷害,這應該比之前一個一個寫 if 判斷要簡單的多。

雖然使用內建的驗證器可以搞定大部分的驗證,像是會員帳號不能重複這件事就不是內建的驗證器能檢查的出來的,得自己另外寫個驗證器,這在 WTForms 還好寫的:

# 檔案 apps/user/forms.py
from wtforms.validators import InputRequired, Length, EqualTo, ValidationError
from models.user import User

# ... 略 ...

def unique_username(form, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError("該帳號已被申請")

class UserRegisterForm(Form):
username = StringField(
"帳號",
validators=[InputRequired(), Length(min=4), unique_username],
render_kw={"placeholder": "使用者帳號", "class": STYLES},
)
# ... 略 ...

這裡我寫了一個 unique_username() 函數,內容也很單純,就是去檢查資料表裡是否已經有相同的 username 存在,如果重複就會拋出 ValidationError 例外。接著把這個函數跟其他驗證器一樣掛在 validators 參數裡,這樣重複的帳號就會被擋下來了。

目前進度

分支名稱 17-form-validation

密碼看的見!

雖然現在可以註冊會員了,但有另一個有點嚴重的問題,你只要打開 users 資料表看一眼就會發現,所有會員的密碼都是直接存在資料表裡,一看就知道每個人的密碼是什麼。我不確定各位有沒有意識到這有什麼問題,早期我甚至遇過有開發者跟我說這樣很方便啊,要幫使用者查密碼只要進資料庫就查的到。

或許有人會認為,就算被看到密碼又怎麼樣,最多不就是這個網站的密碼被別人知道而已。大家可能不知道一般人其實沒辦法記憶這麼多組密碼組合,大部份的人的密碼可能只是用簡單的生日或數字組合,或是用同一組帳號密碼在不同的網站上註冊,如果這個網站的帳號密碼組合被看到,在其他網站上的帳號也可能會因此被盜用。不管是開發者監守自盜還是被駭客入侵而讓會員資料外流,只要發生都可能造成嚴重的問題。

所以,我們得想個辦法對密碼做一些處置,讓密碼不要這麼容易被看懂,或至少增加被識破的難度。密碼學這時候就能派上用場了,不過我們不是密碼學家,沒能力也不太需要自己實作密碼學演算法,只需要使用現成的加密或雜湊函數就好。在前面章節曾經介紹過什麼是雜湊,它是一種單向計算函數,可以把一段文字轉換成另一段固定長度的雜湊值,雜湊值基本上是不可逆的,就算要逆也得要花非常多的時間。

這裡需要特別提一下,「加密(Encrypt)」和「雜湊(Hash)」雖然看起來好像都是會產生亂數字串,但這兩個是不同的概念。只要有適當的金鑰或方法,加密過的資料是可以被解密的,但雜湊就是單向運算,是不可逆的。在這裡我們要用的是雜湊函數,對一般人來說看起來已經夠亂,所有些人可能會認為這是幫密碼「加密」,但就以專有名詞上來說還是不太一樣。

Python 有個叫做 Werkzeug 的套件,這個字是德文「工具」的意思,主要的功能是用來實作 WSGI 應用程式,事實上整個 Flask 框架就是建立在 Werkzeug 這個套件之上。Werkzeug 套件裡有滿好用的密碼學模組,包括很多常用的加密或雜湊函數。也有善心人士做了 Flask 的擴充套件 Flask-Bcrypt,讓我們可以更方便在 Flask 裡使用 Werkzeug 套件裡的密碼學模組,所以先來安裝一下:

$ poetry add flask-bcrypt
Using version ^1.0.1 for flask-bcrypt

... 略 ...

- Installing bcrypt (4.1.3)
- Installing flask-bcrypt (1.0.1)

Writing lock file

在開始使用之前,因為待會使用 Flask-Bcrypt 套件的時候會用到 app.py 裡的 app 物件,現在它放在 app.py 裡不太好拿出來用,所以我想把它拿出來另外找地方擺,其他程式如果需要用的時候再匯入就好,我就把它擺在 apps 目錄裡的 __init__.py 裡:

# 檔案 apps/__init__.py
import os
from dotenv import load_dotenv
from pathlib import Path
from flask import Flask

load_dotenv()

ROOT_PATH = Path().parent.absolute()
TEMPLATE_FOLDER = ROOT_PATH / "templates"
DB_PATH = ROOT_PATH / "db" / "blog.sqlite"

app = Flask(__name__, template_folder=TEMPLATE_FOLDER)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
app.secret_key = os.getenv("APP_SECRET_KEY")

如果在你的 apps 目錄裡沒有 __init__.py 檔案,手動新增一個即可。因為位置改了,所以像是 template_folder 以及 SQLite 資料庫檔案的路徑也要改一下,這裡我用 pathlib 來處理路徑,看起來比較清楚一點,接著回到根目錄的 app.py

# 檔案 app.py
# ... 略 ...
from apps import app # <-- 匯入 apps 資料夾裡的 app 物件

app.add_url_rule("/", view_func=root_view, endpoint="root")
app.register_blueprint(post_bp, url_prefix="/posts")
app.register_blueprint(user_bp, url_prefix="/users")
app.register_blueprint(error_bp)

db.init_app(app)
Migrate(app, db)

if __name__ == "__main__":
app.run(port=9527, debug=True)

這樣這個檔案看起來乾淨一點了。都整理完之後,回到原本要寫入會員資料的 create() 函數:

# 檔案 app/user/views.py
from flask_bcrypt import Bcrypt
from apps import app

user_bp = Blueprint("user", __name__)
bcrypt = Bcrypt(app)

# ... 略 ...
@user_bp.route("/create", methods=["POST"])
def create():
form = UserRegisterForm(request.form)

if form.validate():
hashed_password = bcrypt.generate_password_hash(form.password.data)
user = User(username=form.username.data, password=hashed_password)
db.session.add(user)
db.session.commit()
flash("註冊成功!")
return redirect(url_for("root"))

return render_template("users/new.html.jinja", form=form)

流程基本上是一樣的,不過在新增會員資料之前,我先使用了 .generate_password_hash() 方法對傳入的密碼進行雜湊運算並得到雜湊值,然後把雜湊值存進資料表。完成後你可再註冊一個新帳號,再到資料表看一下,現在的密碼應該就不是當時輸入的密碼了,這樣即使資料庫的資料不小心被看到,看到的也只是一串不知道意思的隨機字串。

目前進度

分支名稱 18-encrypt-password

會員登入表單

跟會員註冊比起來,會員登入的部分就簡單多了,我得準備一個登入表單,同樣也是使用跟註冊一樣的表單類別來實作,一樣寫在 apps/user/forms.py 裡,這個表單跟之前實作的註冊表單很像,只差了一個密碼確認的欄位,所以幾乎可以整個複製過來改幾個字就行了:

# 檔案 apps/user/forms.py
class UserLoginForm(Form):
username = StringField(
"帳號",
validators=[InputRequired(), Length(min=4)],
render_kw={"placeholder": "使用者帳號", "class": STYLES},
)
password = PasswordField(
"密碼",
validators=[InputRequired()],
render_kw={"placeholder": "登入密碼", "class": STYLES},
)

導覽列別忘了加上登入的連結。再來處理一下會員登入的頁面,檔案放在 templates/users/login.html.jinja

{% extends "layout.html.jinja" %} {% from "helpers/form_field.html.jinja" import render_field %} {% block content %}
<h1 class="mb-2 text-2xl">會員登入</h1>
<form action="{{ url_for('user.login') }}" method="POST" class="flex flex-col gap-4">
{{ render_field(form.username) }} {{ render_field(form.password) }}
<div>
<button type="submit" class="p-2 text-lg text-white bg-blue-500 rounded-sm">登入</button>
</div>
</form>
{% endblock %}

這裡沒什麼特別的新東西。接下來,登入的功能我就寫在 apps/user/views.py

# 檔案 apps/user/views.py
@user_bp.route("/login", methods=["GET", "POST"])
def login():
form = UserLoginForm(request.form)
if request.method == "POST" and form.validate():
# 待會這裡要檢查帳號密碼
pass
return render_template("users/login.html.jinja", form=form)

跟會員註冊的手法差不多,只是改用 UserLoginForm 表單類別,然後在 POST 方法裡面要檢查帳號密碼是否正確。問題是,我們現在不能直接比對密碼,因為我們的資料表裡的密碼都是經過雜湊運算的,已經不是比對密碼就比的出來的。既然現在的密碼是註冊的時候是透過 Bcrypt 算出來的,正所謂「解鈴還須繫鈴人」,所以也要請它幫我們檢查這組看起來像亂碼的雜湊值是不是正確的密碼:

# 檔案 apps/user/views.py
@user_bp.route("/login", methods=["GET", "POST"])
def login():
form = UserLoginForm(request.form)
if request.method == "POST" and form.validate():
user = User.query.filter_by(username=form.username.data).first()
if user and bcrypt.check_password_hash(user.password, form.password.data):
flash("登入成功!")
return redirect(url_for("root"))
else:
flash("帳號或密碼錯誤!")

return render_template("users/login.html.jinja", form=form)

先把使用者挑出來,再透過 .check_password_hash() 方法檢查使用者輸入的密碼是否正確,如果正確就顯示登入成功並轉到首頁,否則就顯示帳號或密碼錯誤的訊息。會員登入的流程差不多就這樣,但你以為這樣就是「登入」了嗎?並沒有,我們得先來看看什麼叫做「登入」...

目前進度

分支名稱 19-user-login-form

會員登入?

所謂的登入並不是表單送出、比對帳號密碼成功之後就叫做登入。

想像一下這個情境,如果你去買手搖杯飲料,你點了一杯珍珠奶茶,店員應該會給你一張號碼牌,然後店員手上也會有一張對應的訂單。你手上這張號碼牌只能在目前這家店領取飲料,如果拿去其他店,就算是連鎖店也不行。或是,如果你拿著這張號碼牌,過了一星期之後才去領取飲料,店員也會說這張號碼牌已經過期了,你得重新再點一杯飲料。

其實,網站並不知道你到底有沒有登入,你想要讓網站知道你是誰,你得主動提出證明才行,而這個證明就是瀏覽器裡要有特定的「號碼牌」,以專有名詞來說叫做「小餅乾(Cookie)」。

當表單送出、帳號密碼比對成功之後,伺服器可以發 Cookie 給你的瀏覽器,瀏覽器會把這個 Cookie 存起來。當下次再到這個網站的時候,瀏覽器會自動把 Cookie 傳給伺服器,伺服器發現你帶了 Cookie 來拜訪網站,而且經檢查之後認定是一個有效的 Cookie,這時候即使不用帳密密碼,伺服器也會認定你是有效的使用者而且會知道你是誰,然後給你看到應該看的資料。

伺服器是怎麼比對的?瀏覽器有 Cookie,而伺服器有 Session,Session 裡可能會記錄部份使用者的資訊。只要瀏覽器的 Cookie 跟伺服器的 Session 能對的起來,就認定這個使用者是有效的。

那什麼叫登出?一樣用飲料店的例子,什麼情況可能會讓你沒辦法用手上的號碼牌去領取飲料呢?可能是你把手上的號牌給撕掉了,或是號碼牌過期了,也可能是店員把手上的訂單給撕了,或是你根本就走錯分店了。反正只要沒辦法驗證你是否有買了指定的飲料,你就不能拿到飲料。在網站也是一樣的概念,如果使用者主動把瀏覽器的 Cookie 清掉,或是 Cookie 過期了,或是伺服器的 Session 被清掉了,都會造成伺服器不認識這個使用者,會視你為新的使用者,需要重新輸入帳號密碼再次取得號碼牌。

也因為伺服器只認 Cookie 不認人,所以如果我能造假 Cookie 或是想辦法偷到你的 Cookie 的話,我就能用你的身分登入網站,這就是為什麼在公共電腦上登入網站之後,一定要記得登出的原因。

雖然 Flask 本身就有 session 可以用,但有另一個更好用的套件叫做 Flask-Login,其實這個套件背後的原理就是把使用者的編號存在 session 裡,只是它又有提供更多便利的方法可以管理使用者的登入、登出狀態。同樣先安裝一下套件:

$ poetry add flask-login

Flask-Login 雖然用起來很方便,但有一些前置作業要處理,畢竟它不會聰明到知道你這個網站的會員資料是存在哪裡,又該如何進行驗證,這部份我們得手動設定一下。根據的文件,要先建立一個 LoginManager,並且要跟 app 物件綁定,所以我就直接寫在 apps/__init__.py 裡:

from flask_login import LoginManager
from models.user import User

# ... 略 ...

app = Flask(__name__, template_folder=TEMPLATE_FOLDER)

# ... 略 ...

# Login Manager
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "user.login"
login_manager.login_message = "請登入會員帳號"

@login_manager.user_loader
def load_user(id):
return User.query.get(id)

使用 LoginManager 類別建立物件應該不難理解,跟 app 綁定的時候原本應該要設定 SECRET_KEY,不過我們的 app 物件之前就設定過了,所以沒什麼問題。而 login_view 屬性是告訴 Flask-Login 如果使用者存取應該登入的頁面但還沒登入的時候,會被轉向哪個頁面。這裡我設定為 "user.login",這跟我們之前用過的 url_for() 寫法是一樣的。最後的 login_message 則是被轉向的頁面會顯示的快閃訊息。

接下來的 user_loader 裝飾器也很重要,主要是要告知 Flask-Login 套件如果要調用會員帳號的時候應該要怎麼取得資料,這裡我就直接透過 User model 配合 id 來取得使用者資料。

除了建立 LoginManager 之外,還需要在 User model 實作幾個方法,這些方法可能是我們自己主動呼叫,或是被 Flask-Login 呼叫。要把它直接寫在 User 裡,或是另外寫一個類別再繼承進來也行,我這裡就選擇後面的做法:

# models/user.py
class UserMixin:
def is_active(self):
return True

def is_authenticated(self):
return True

def get_id(self):
return str(self.id)

class User(db.Model, TimeTrackable, UserMixin): # <-- 繼承
__tablename__ = "users"
# ... 略 ...

這個類別實作的幾個方法:

  • is_authenticated 是用來判斷使用者是否已經登入,待會我們就會用到這個方法。
  • is_active 是用來判斷使用者是否是有效的使用者,例如希望使用者的年齡大於 18 歲或是完成認證流程才算有效使用者,就是在這裡實作。
  • get_id 是用來取得使用者識別的方法,只要足以用來識別使用者而且不會重複的值就可以,另外,文件上有說明這個方法的回傳值一定要是字串,所以這裡我用 str() 轉換型別。

雖然自己實作也不是多麻煩的事,但 Flask-Login 內建有一個 UserMixin 類別,裡面也有這幾個方法,而且預設實作都跟我們的差不多,所以如果沒有特別的流程或判斷,用現成的就行了:

# 檔案 models/user.py
from flask_login import UserMixin

class User(db.Model, TimeTrackable, UserMixin):
__tablename__ = "users"
# ... 略 ...

前置作業這樣就完成了。接著回到剛才登入成功的地方:

# 檔案 apps/user/views.py
from flask_login import login_user

@user_bp.route("/login", methods=["GET", "POST"])
def login():
form = UserLoginForm(request.form)
if request.method == "POST" and form.validate():
user = User.query.filter_by(username=form.username.data).first()
if user and bcrypt.check_password_hash(user.password, form.password.data):
login_user(user) # <-- 這裡
flash("登入成功")
return redirect(url_for("root"))
else:
flash("登入失敗,請確認後再重試一次")
return render_template("users/login.html.jinja", form=form)

在進行轉址之前使用 Flask-Login 套件提供的 login_user() 函數就能幫我們處理 Session/Cookie 相關的事,這樣比較算是完成登入流程了。我先調整一下導覽列:

<nav class="flex items-center justify-between my-2">
... 略 ...
<ul class="flex gap-4">
{% if current_user.is_authenticated %}
<li>{{ current_user }}</li>
<li>登出</li>
{% else %}
<li><a href="{{ url_for("user.new") }}" class="hover:underline">註冊</a></li>
<li><a href="{{ url_for("user.login") }}" class="hover:underline">登入</a></li>
{% endif %}
</ul>
</nav>

這裡用到的 current_user 是 Flask-Login 提供的方法,它背後就是呼叫我們剛才在前置作業寫的那個 load_user() 裝飾器所包裝的函數並透過這個函數取得使用者物件,在瀏覽列裡用到的 is_authenticated 其實就會呼叫剛才在 User model 裡實作的那幾個,用來判斷使用者是否已經登入。我這裡刻意設計成如果使用者已經登入就顯示使用者名稱,否則就顯示註冊及登入連結。

有了登入功能之後,我就可以在一些需要驗證的地方 login_required 裝飾器,例如新增文章等功能,不能讓隨隨便便的路人就使用:

# 檔案 apps/post/views.py
from flask_login import login_required

@post_bp.route("/new")
@login_required # <-- 這裡
def new():
return render_template("posts/new.html.jinja")

你可以試著開啟瀏覽器的無痕模式,去看看原本的新增文章頁面,應該就會被踢回登入頁面,而且會在快閃訊息提醒你得先登人才行。

目前進度

分支名稱 20-user-login

登出帳號

相對於登入,登出就簡單多了,雖然把瀏覽器的 Cookie 清掉就能造成登入失效,但總不能叫來看網站的人自己清 Cookie 吧。別擔心,Flask-Login 有更簡單的做法。首先,我先調整一下瀏覽列的登出連結:

<nav class="flex items-center justify-between my-2">
... 略 ...
<ul class="flex gap-4">
{% if current_user.is_authenticated %}
<li>{{ current_user }}</li>
<li>
<form action="{{ url_for("user.logout") }}" method="POST">
<button class="hover:underline">登出</button>
</form>
</li>
... 略 ...
</ul>
</nav>

這裡我用 <form> 標籤,而不是用一般的超連結,主要是因為我想要 POST 請求,而不是 GET 請求,這樣才能讓 Flask-Login 做登出的動作,而一般的超連結是沒辦法做 POST 請求的。並沒有規定登出一定要用 POST 連結,只是這樣比較符合 RESTful API 的設計原則,至於什麼是 RESTful API,我們後面會有更詳細的說明,這裡可暫時把它當做是一種網址的設計風格。

接著我們回到 apps/user/views.py 來處理一下登出的事:

@user_bp.route("/logout", methods=["POST"])
def logout():
logout_user()
flash("已登出!")
return redirect(url_for("root"))

只要呼叫 logout_user() 函數就行了,這樣就完成登出的效果。如果呼叫 logout_user() 函數的時候沒帶使用者參數給它的話就會登出目前登入的使用者,但如果你是系統管理員,想在後台管理系統設計一個可以登出指定的某位使用者的功能,把你想登出的使用者物件傳給它就能搞定。

雖然 Flask-Login 一開始需要花點時間做前置作業,但搞定之後剩下的功能就都簡單了。

目前進度

分支名稱 21-user-logout

資料關連

雖然現在已經有會員登入、登出系統,也有在新增、修改、刪除文章的地方加上登入檢查,但這其實不太夠,因為現在只有做到「認證(Authenication)」,並沒有做到「授權(Authorization)的功能。照理每個使用者只能修改或刪除自己的文章,但現在是只要有登入會員,大家都可以修改或刪除所有文章,這會出事的。

要做這這個功能,我們需要先了解一下資料表的關連性。目前我們有文章和會員這兩個資料表,但這彼此之間沒有任何關連。要做到讓每個會員只修改或刪除自己的文章,首先得知道每篇文章的作者是誰,也就是要建立資料表之間的關連性,而透過 ORM 要做到這件事還滿簡單的。

首先,因為每篇文章都會有一位作者,所以我要在存放文章的資料表中加入一個欄位,用來儲存作者的 id:

# 檔案 apps/post/models.py
from sqlalchemy import Integer, String, Text, ForeignKey

class Post(db.Model, TimeTrackable):
__tablename__ = "posts"

id = mapped_column(Integer, primary_key=True)
title = mapped_column(String, nullable=False)
content = mapped_column(Text)
user_id = mapped_column(
Integer, ForeignKey("users.id", name="fk_post_to_user_id"), nullable=False
) # <-- 這裡
# ... 略 ...

我在 posts 資料表新增一個叫做 user_id 的整數欄位,並且設定成「外部鍵(Foreign Key, FK)」,這個外部鍵會指向 users 資料表中的主鍵 id 欄位。至於欄位名稱是不是一定要叫做 user_id 並沒有強制規定,只是慣例上常會這樣命名而已。改了 Model 的結構後,記得要更新資料表的結構:

$ flask db migrate -m 'add user_id to posts'
$ flask db upgrade

是說,只要開一個整數欄位就能知道文章的作者是誰嗎?當然沒有這麼神奇。user_id 欄位就只是記錄 id 數字而已,想要會員資料還得自己手動拿這個 id 去查 users 表格才會知道這個編號所代表的使用者是誰。

使用 ORM 的好處之後,就是通常只要設定好相關的屬性或方法,都可以幫搞定原本需要手動再查一次的麻煩事:

from sqlalchemy.orm import mapped_column, relationship

class Post(db.Model, TimeTrackable):
__tablename__ = "posts"
id = mapped_column(Integer, primary_key=True)
title = mapped_column(String, nullable=False)
content = mapped_column(Text)
user_id = mapped_column(
Integer, ForeignKey("users.id", name="fk_post_to_user_id"), nullable=False
)
author = relationship("User") # <-- 這裡

新增的 author 屬性並不像其他 titlecontent 一樣是個實體資料表欄位,所以不需要執行 Migration。透過 relationship() 函數設定的屬性,待會就可以直接透過文章物件的 .author 屬性存取到使用者資料。relationship() 預設會順著這個 Model 的外部鍵找到對應的資料表,萬一在同一個 Model 裡有多個外部鍵的時候,可以在設定 relationship() 函數裡加上 foreign_keys 參數。

雖然通常在設定關連的時候,關連到 User Model 的名字通常會設定成 user,但我這裡刻意使用 author 是想讓大家知道這並沒有強制規定要叫什麼名字,同時也因為比較符合情境,畢竟文章的「作者」應該比文章的「使用者」更容易被理解。

另外,relationship() 函數常常會再加上 back_populates 參數,這是用來設定反向關係的,也就是說,如果你在 User Model 裡也設定了 posts 屬性,那麼這兩個屬性就會互相指向對方,這樣就能從使用者物件找到他的文章,也能從文章物件找到作者:

# 檔案 models/post.py
class Post(db.Model, TimeTrackable):
# ... 略 ...
author = relationship("User", back_populates="posts")

def __repr__(self):
return f"{self.title}"

另一邊的 User 也一起設定一下:

# 檔案 models/user.py
class User(db.Model, TimeTrackable, UserMixin):
# ... 略 ...
posts = relationship("Post", back_populates="author")

設定好關連之後,就可以回來調整新增文章的程式碼,讓文章和作者關聯起來:

# 檔案 apps/post/views.py
from flask_login import login_required, current_user

@post_bp.route("/create", methods=["POST"])
@login_required
def create():
title = request.form.get("title")
content = request.form.get("content")

post = Post(title=title, content=content)
post.author = current_user # <-- 這裡
db.session.add(post)
db.session.commit()
# ... 略 ...

current_user 是 Flask-Login 提供的函數,可以取得目前登入的使用者,所以只要把這個物件指定給文章的 author 屬性,也就是剛才設定的關連,就能把文章和作者關聯起來。這時候如果你去看看 posts 資料表的 user_id 欄位,就會看到這個欄位的值寫入了目前登入的使用者 id

不是只有新增文章的時候設定,編輯或刪除文章的時候也應該改一下:

# 檔案 apps/post/views.py
@post_bp.route("/<int:id>/edit")
@login_required
def edit(id):
post = Post.query.filter_by(id=id, author=current_user).first_or_404()
return render_template("posts/edit.html.jinja", post=post)

@post_bp.route("/<int:id>/update", methods=["POST"])
@login_required
def update(id):
post = Post.query.filter_by(id=id, author=current_user).first_or_404()
# ... 略 ...

@post_bp.route("/<int:id>/delete", methods=["POST"])
@login_required
def delete(id):
post = Post.query.filter_by(id=id, author=current_user).first_or_404()
# ... 略 ...

這樣才不會編輯或刪除到不屬於自己的文章。

目前進度

分支名稱 22-add-relationship

在最後收尾之前,我想把在上個章節關於文章的 CRUD 寫法改用表單物件來處理,基本上處理的手法都差不多,改完應該看起來會清爽多了。不過因為改的內容有點雜,我就直接放在 GitHub 上,有興趣的話可參考 Commit 的進度。

目前進度

分支名稱 23-post-form-object

資料表的關連有一對一、一對多、多對多等不同的形式,這裡只是介紹最基本的一對多關連,細節可再參考 SQLAlchemy 的文件說明。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!