昨天我們完成了 auth 路由,但我總感覺怪怪的 hhh。
讓 GPT 來做個 Code Review 好了。

## 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 修完了沒?

歐,他比我們聊得快超多 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。