iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

重寫一套比我還老的系統:21 歲的校園文字廣播系統系列 第 11 篇

Day 11|來場酣暢淋漓的 Code Review吧,雖然不是我在 Code Review

  • 分享至 

  • xImage
  •  

昨天我們完成了 auth 路由,但我總感覺怪怪的 hhh。

讓 GPT 來做個 Code Review 好了。

https://ithelp.ithome.com.tw/upload/images/20260925/20182031NmdggRlAVe.png

## Code Review 簡要報告

**審查者:OpenAI Codex**  
**審查版本:`c3af90e`**

這次檢查了專案目前的登入、Session 續期、個人資料路由、資料庫 Service 與相關測試。

整體而言,前一版的幾項主要問題已經獲得改善。專案目前使用非同步 SQLAlchemy;Argon2 密碼運算也透過 Thread 執行,不會直接阻塞 Event Loop。Session Token 由安全亂數產生,資料庫只保存 Token 的 SHA-256 雜湊,並且已經支援閒置期限、滑動續期、強制期限與主動撤銷。

不過,目前仍有幾項需要優先處理的問題:

1. **尚未使用 HttpOnly Cookie**

   現在仍由登入 API 將 Session Token 放在 JSON 中交給前端,再透過 `Authorization` Header 傳送。它雖然是伺服器端 Session,但若前端把 Token 存進 Web Storage,仍可能被 XSS 直接讀取,尚未達成原先規劃的 HttpOnly Cookie 防護。

2. **修改密碼與撤銷 Session 並非同一筆交易**

   系統會先提交新密碼,再撤銷所有 Session。若第二步發生資料庫錯誤,就可能出現「密碼已修改,但舊 Session 仍然有效」的狀況。這兩項操作應放進同一筆 Transaction,一起成功或一起回滾。

3. **登入端點缺少暴力嘗試防護**

   目前沒有登入頻率限制,而且不存在的帳號會直接返回,存在的帳號則需要執行耗時的 Argon2 驗證。除了可能被用來大量消耗伺服器資源,也可能透過回應時間差推測帳號是否存在。

4. **每次驗證 Session 都會寫入資料庫**

   每個已登入請求都會更新 `last_used_at` 並提交 Transaction,即使 Session 還沒進入續期窗口也是如此。功能上沒有錯,但日後請求量增加時,可能形成不必要的資料庫寫入負擔。

5. **主要整合測試仍依賴外部 PostgreSQL**

   本次測試結果為 `4 passed, 6 skipped`。被跳過的部分包含登入、Session、Migration 與權限合併等核心流程,因此目前的測試綠燈還不能完整代表這些功能已經通過驗證。

總體而言,這版的 Session 核心設計已經成立,沒有發現 Token 原文入庫、過期 Session 仍可使用,或密碼雜湊阻塞 Event Loop等嚴重錯誤。

接下來建議優先完成 HttpOnly Cookie、交易原子性與登入節流。處理完這三項後,這套認證流程就會從「功能可以運作」,進一步接近「能夠放心交給瀏覽器使用」的狀態。

果然,看到 Bearer 就有不好的預感,總之,丟回 Agent 讓他自己修正,然後我們來聊聊吧。

把 Review 給 Agent 不就好了,為啥我們要聊聊?

我們不聊聊我文章要寫什麼?

有道理。

那要聊啥?

先從第一條開始吧。

HttpOnly Cookie?這不是前幾天才講過?

對,所以我看到這個 Bearer Token 的時候才覺得哪裡怪怪的。

Session 不能用 Bearer Token 嗎?

不是不可以,但在這次不行。

還記得昨天我們聊了 HttpOnly Cookie 嗎?

目的是什麼?

讓 JS 讀不到 Cookie?

嗯哼。

那現在問題來了,如果我們不用 Cookie,而是登入後把 Session Token 回傳給前端:

{
  "session_token": "1145141919810..."
}

前端要把這串東西保存起來,之後每次 Request 再自己塞進 Header:

Authorization: Bearer 1145141919810...

好像也沒什麼問題?

確實能用。

問題是,Token 既然要由 JavaScript 自己送出去,JavaScript 就得先拿得到它。

如果我們把它存在 localStorage 之類的地方,那只要哪天又冒出一個 XSS,惡意 Script 一樣可以把 Token 讀走。

……那我們前幾天研究 HttpOnly 是研究心酸的?

差不多。

所以問題從來不是「Session 能不能搭 Bearer Token」。

當然可以。

Session 解決的是「登入狀態放在哪裡、由誰管理」;Cookie 和 Authorization Header 則是在處理「憑證怎麼從 Client 送到 Server」。

它們其實是兩件不同的事情。

只是我們前面既然已經決定:

不要讓 JavaScript 碰到 Session Token。

那這次自然就應該讓瀏覽器透過 HttpOnly Cookie 自動帶上它,而不是再把 Token 交回 JavaScript 手上。

所以這個 Bearer Token 不是不能跑。

而是——

能跑,但把我們前幾天做的資安設計繞過去了。

修改密碼與撤銷 Session 並非同一筆交易?

這兩件事有什麼關係?

先想一下,今天如果你修改了密碼,我們通常會希望原本登入中的 Session 一起失效。

畢竟如果是因為帳號疑似被盜才改密碼,結果密碼改完了,攻擊者手上的 Session 還能繼續用,那這個密碼多少改得有點心酸。

所以現在的流程大概長這樣:

修改密碼
    ↓
撤銷所有 Session

看起來很合理。

但我們把它拆開一點看。

假設修改密碼的 Service 做完自己的工作之後,直接:

await db.commit()

接著才輪到撤銷 Session:

await change_password(...)

await revoke_all_sessions(...)

不就照順序執行嗎?有什麼問題?

正常情況下,確實沒有問題。

問題通常都出在不正常的時候。

假設今天事情變成這樣:

修改密碼
    ↓
 COMMIT ✓
    ↓
撤銷所有 Session
    ↓
   BOOM

恭喜。

你的密碼成功修改了。

然後你的舊 Session 也成功活下來了。

……喔。

這就是 Code Review 第二條抓到的問題。

對我們來說,「修改密碼」和「撤銷舊 Session」其實不是兩件互不相干的事情。

我們真正想完成的是:

修改密碼,並讓原本的 Session 全部失效。

這才是一個完整的操作。

但是如果修改密碼完成後就先 commit(),對資料庫來說,前半段的事情已經確定了。

後面的 Session 就算撤銷失敗,也不能跑回去跟資料庫說:

「欸不好意思,剛剛那個密碼當我沒改。」

所以不能先 commit()?

至少不能在這裡先 commit()。

我們真正想要的應該是:

修改密碼 ✓
撤銷 Session ✓
──────────────
   COMMIT

兩件事情都成功,再一起 commit()。

如果中間任何一步失敗,只要這些操作還在同一筆尚未 Commit 的 Transaction 裡:

修改密碼 ✓
撤銷 Session ✗
──────────────
  ROLLBACK

那就全部當作沒發生。

這種「要嘛全部成功,要嘛全部失敗」的特性叫做 Atomicity(原子性),也是資料庫 Transaction 很重要的一項特性。

等等。

所以把兩個 commit() 刪掉,最後再補一個不就好了?

方向對了。

但這時候問題就變得更有趣了。

這個 commit() 到底該由誰負責?

假設我們的 Service 長這樣:

async def change_password(...):
    # 修改密碼
    ...
    await db.commit()

async def revoke_all_sessions(...):
    # 撤銷 Session
    ...
    await db.commit()

單獨看好像完全合理。

change_password() 負責修改密碼,做完就 commit()。

revoke_all_sessions() 負責撤銷 Session,做完也 commit()。

大家各做各的,井水不犯河水。

直到有一天,我們需要:

await change_password(...)
await revoke_all_sessions(...)

第一個 Function 已經 commit() 了。

對。

Service 太熱心了。

作業第一頁寫完就直接交出去,等寫第二頁的時候才發現——

幹,老師已經把第一頁收走了。

這就是所謂的 Transaction Boundary。

Transaction 的邊界不一定應該跟著 Function 畫,也不一定每呼叫一次 Service 就該 commit() 一次。

真正要看的,是:

哪些操作在業務邏輯上必須一起成功、一起失敗?

在我們這個例子裡,真正完整的操作不是:

change_password()

也不是:

revoke_all_sessions()

而是:

修改密碼
+
撤銷所有 Session

所以這兩個操作應該被包在同一個 Transaction 裡。

所以 Service 就都不要 commit()?

也不能這麼武斷。

但至少這次 Code Review 提醒了我們一件事:

不要因為一個 Function 做完了,就理所當然地認為 Transaction 也該結束了。

Function 的邊界,是我們怎麼拆程式。

Transaction 的邊界,則是我們怎麼定義「一件事情」。

兩者剛好一樣的時候很方便。

但它們從來就不是同一件事。

好,最麻煩的兩個問題處理完了。

等等,Review 不是有五條嗎?

你還有三條欸。

我知道。

不過剩下三條就沒有前面兩個那麼複雜了,我們快速看過去。

先看第三條:

登入端點缺少暴力嘗試防護。

就是有人一直試密碼?

對。

而且我們現在用的是 Argon2。

還記得前面為什麼選它嗎?

密碼雜湊本來就不希望算得太快。

如果一個 Hash 能在短時間內被大量計算,那攻擊者猜密碼的速度也會跟著起飛。

所以 Argon2 會故意消耗一定的運算資源,增加暴力破解的成本。

但事情都有兩面。

既然每次驗證密碼都需要付出成本,那有人一直狂戳登入 API:

POST /login
POST /login
POST /login
POST /login
POST /login
...

伺服器也得一直陪他算。

好欸,免費壓力測試。

我不是很想要這種免費。

所以登入端點之後還需要加入適當的 Rate Limit,限制短時間內大量的登入嘗試。

另外,現在還有另一個小問題。

如果帳號不存在,我們可以很快發現:

if account is None:
    return

但如果帳號存在,就得真的跑一次 Argon2 驗證。

兩條路需要的時間不同,理論上就可能透過回應時間的差異,洩漏「這個帳號到底存不存在」的資訊。

所以這個也要修?

要。

但這比較屬於登入端點的 Security Hardening,不影響我們現在 Session 的核心設計。

先記帳,等等一起交給 Agent。

接著第四條:

每次驗證 Session 都會寫入資料庫。

這個就更單純了。

現在每次使用者帶著 Session 發出 Request,我們都會更新:

last_used_at

於是原本可能只是一次讀取:

SELECT session

最後又多了一次寫入。

如果哪天 Request 數量很多,這些 Update 就可能變成額外的資料庫負擔。

那現在要改嗎?

……

你覺得我們這個校園文字廣播系統現在有多少流量?

……

對。

這確實是一個可以改善的地方,例如不必每次 Request 都更新 last_used_at,隔一段時間再更新一次就好。

但在目前這個階段,我不打算為還不存在的流量問題大改架構。

先知道它在這裡。

真的有需要,再回來處理。

最後一條:

主要整合測試仍依賴外部 PostgreSQL。

目前測試結果:

4 passed, 6 skipped

四個 Passed!

所以測試過了?

有考的部分過了。

那另外六個?

沒考。

……

只要不考試,就不會不及格。

你最好是。

而且偏偏被 Skip 的還不是什麼無關緊要的東西。

登入、Session、Migration、權限合併……

基本上都是我們最近正在寫的核心功能。

所以這個綠燈目前只能證明:

跑起來的四個 Test 通過了。

不能證明整套 Auth 已經經過完整測試。

這部分之後還是得把測試用 PostgreSQL 環境補起來,讓這些 Integration Test 真的跑過一次。

不過它同樣不是今天 Auth 設計本身出了什麼大問題,而是我們目前的測試環境還不完整。

所以五個問題看完,其實真正值得我們今天停下來聊的,還是前面兩個。

第一個提醒我們:

能跑的實作,不一定符合原本的安全設計。

第二個則提醒我們:

Function 的邊界,不一定就是 Transaction 的邊界。

至於剩下三個——

Rate Limit 要補。

Session 寫入可以優化。

Integration Test 得真的跑。

所以……

Agent 修完了沒?

https://ithelp.ithome.com.tw/upload/images/20260925/20182031xFcApsKNO6.png

歐,他比我們聊得快超多 hhh。

我們聊一下的功夫它就做完了,完全是它在等我們。

那就讓 GPT 再 Review 一次吧。

## Code Review 複查報告

**審查者:OpenAI Codex**  
**審查版本:`780c993`**

本次重新檢查了修正後的登入、Cookie、Session 續期與密碼修改流程。

上一版發現的大部分問題都已獲得改善。登入憑證現在透過具備 `HttpOnly`、`Secure` 與 `SameSite=Lax` 的 Cookie 傳送,不再直接交給前端 JavaScript;不存在或停用的帳號也會執行 Dummy Argon2 驗證,縮小登入回應的時間差。

另外,密碼更新與 Session 撤銷已經放進同一筆 Transaction,能夠一起成功或一起回滾;`last_used_at` 也改為每五分鐘才更新一次,避免每個請求都造成資料庫寫入。新密碼的長度限制亦提高到 15~64 個字元。

不過,目前仍有一項會影響實際行為的問題:

1. **Cookie 沒有跟著 Session 一起續期**

   伺服器雖然會延長資料庫中的 Session 期限,但瀏覽器 Cookie 的 `Max-Age` 只在登入時設定一次。即使使用者持續操作、Session 已在伺服器端成功續期,瀏覽器仍會在最初的 24 小時後刪除 Cookie。

   換句話說,資料庫認為使用者還能繼續登入,瀏覽器卻已經把登入憑證丟掉,使滑動續期無法真正發揮作用。

除此之外,登入端點仍需在 Nginx 或應用層加入頻率限制;修改密碼並撤銷所有 Session 後,也建議同步刪除瀏覽器中的舊 Cookie。

整體而言,這次修改已經解決了上一輪的主要安全與交易問題。Session 認證的核心設計已經成立,目前最需要優先處理的,就是讓 Cookie 的生命週期與伺服器端的滑動續期保持一致。

看起來問題不大了。

剩下最主要的問題,是伺服器端的 Session 雖然成功續命了,瀏覽器手上的 Cookie 卻還不知道這件事。

結果就會變成:

Server:放心,我幫你續命了。

Browser:24 小時到了,掰。

資料庫裡的 Session 活得好好的,唯一能證明「我是誰」的 Cookie 卻被瀏覽器丟進垃圾桶。

多少有點尷尬。

除此之外,Rate Limit 還沒補上,修改密碼後也還可以順手把瀏覽器裡已經失效的 Cookie 清掉。

不過跟第一次 Review 比起來:

5 個問題
    ↓
Agent 修正
    ↓
1 個主要問題 + 幾個待辦

至少已經從「總感覺哪裡怪怪的」,變成「知道哪裡還怪怪的」了。

今天就先到這裡吧。

至於 Cookie 到底要怎麼跟著 Session 一起續命——

明天再來慢慢處理。

畢竟今天已經 Review 兩次了。

所以明天要幹嘛?

修 Cookie。

然後?

再 Review。

……

習慣就好 hhh。


上一篇
Day 10|你走在路上最好是放心一點!我記住你了。
下一篇
Day 12|奇怪了,.env 不是有寫嗎?我去,沒用 dotenv……
系列文
重寫一套比我還老的系統:21 歲的校園文字廣播系統 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言