← 所有文章

工程 · 2026.10.02 · 4 min · 更新於 2026.10.26

JWT 到期邊界的 race、2FA 文案與密碼封裝:帳號安全上線後的三個修正

放貸平台碰得到使用者的交易所 API key,上線後又修了三件帳號安全的事:安全文案先對過後端,JWT 只解析一次避開到期 race,密碼在瀏覽器封裝後才送出。

閱讀設定
主題
字級
寬度

9 月 10 號,PROD 出現一次未處理的 ExpiredJwtException,那個 token 只過期了 1 毫秒,剛好卡在驗證和取欄位的兩次 parse 之間。

這是我的放貸平台帳號安全上線後修的三件事之一,每件各自帶一個教訓:

  • 安全文案寫之前,先查後端實際的 gate,不要承諾沒做的保護。
  • 從 JWT 取多個欄位時,只 parse 一次。
  • 判斷密碼有沒有明文傳送,要看 TLS 在哪裡終結,不是看 DevTools,所以密碼改成在瀏覽器裡封裝後才送出。

背景是使用者會把 Bitfinex API 金鑰交給平台。金鑰加密存放,但帳號一旦被拿走,攻擊者就可能透過平台去動那把金鑰。

所以我的原則是:密碼、session、單一個 API 動作,都不應該是唯一的一道防線。

2FA 文案先對過後端

2FA 用標準的 RFC 6238 TOTP(驗證器 app 上定時更換的一次性驗證碼)。

啟用分兩階段:secret 先放在 Redis 10 分鐘,輸入正確的驗證碼才正式啟用。掃了 QR code 卻沒設定成功的人,不會把自己鎖在門外。

停用要同時輸入密碼和驗證碼,只偷到 session cookie 的人關不掉保護。這些檢查都放在後端,前端的限制只是方便。

儀表板改版之後,設定頁的 2FA 提示變成「登入、改金鑰、刪帳號都會再驗證一次」。

這句話有問題。產品根本沒有自助刪帳號的功能,金鑰相關的操作,後端也不是每一種都會要驗證碼。

修法是先去後端查一般使用者在哪些地方會被要求驗證碼。答案是登入,以及已開 2FA 的人新增 API 金鑰。文案改成「登入和新增 API 金鑰時,都要輸入驗證碼。」

公開站的文案本來就有一條規則:不寫產品沒有的功能。這句是 app 內漏掉的。

現在寫任何安全相關的文案之前,我都先去後端看實際的 gate。

JWT 只 parse 一次

那次 ExpiredJwtException 只發生一次,沒有使用者受影響,但自動分診開了 issue,判定要修。

問題在 JwtAuthenticationFilter 的寫法。它先呼叫 validateToken() 驗證,這一步有 catch。通過之後,再逐欄呼叫 getEmailFromToken 這類 getter 取值,每一個都重新 parse 一次 JWT,而且都沒有 catch。

token 如果剛好在兩次 parse 之間到期,驗證時還沒過期、取值時已經過期,例外就從 filter 丟出去。refresh token 的流程也是同樣寫法。

修法是 parse 一次,把結果包成一個物件,所有人都從這一份取值:

Java
public Optional<VerifiedJwt> verifyToken(String token) {
    Claims claims;
    try {
        claims = parseClaims(token);
    } catch (JwtException | IllegalArgumentException e) {
        return Optional.empty();
    }
    // 從同一份 claims 取出 email、userId、type、iat、impersonatorId
    ...
}

過期、簽章不符、格式錯誤一律回 empty,走未登入的分支。filter 和 refresh 都改吃這一份結果,過期的 refresh token 回業務錯誤,不再變成 500。

示意圖:修正前先用 validateToken() 驗證,再用 getEmailFromToken() 這類 getter 逐欄重新 parse,token 剛好在兩次 parse 之間到期時,ExpiredJwtException 會丟出 filter;修正後 verifyToken() 只 parse 一次,包成 VerifiedJwt,之後的欄位都從這一份取。

原本那些逐欄 getter 和 validateToken 全部刪掉。主程式已經沒有地方用它們,留著的話,下一個寫 auth 的人很可能又拿來組出同一個 race。

自動分診建議在外層加一個 catch JwtException,我沒有採用。那只是把症狀吞掉,容易誤用的 API 都還在。

用不到、又容易被誤用的 API,修完就刪掉。

明文要看 TLS 終結點

這件事的起點是一個誤會。我原本以為 Bitfinex 的 API 回應在 DevTools 看不到,想照做。

回頭查截圖才發現,那個 "Failed to load response data" 是點到被擋掉的 Google Analytics 請求,本來就沒有 body。

再往下想也不合理:瀏覽器要把資料畫在畫面上,就一定拿得到資料,在前端解密只是混淆。

DevTools 的 Payload 分頁顯示的是瀏覽器加密前的內容,任何網站都看得到自己送出了什麼。判斷有沒有明文傳送,要看 TLS 在哪裡終結。

用這個角度看,密碼確實有問題。登入、註冊、重設密碼、停用 2FA 這四個請求,密碼原本是明文欄位放在 request body。

HTTPS 只保護到 TLS 終結點,Cloudflare 在邊緣就解開 TLS。Cloudflare 的文件講的就是這一段:

These certificates secure the encrypted connection between your visitors and Cloudflare.

出自 Cloudflare 的 Edge certificates 文件。所以 Cloudflare 和任何會記錄 body 的中間層,都看得到密碼。

很多人的密碼會在別的網站重複使用,這裡外洩,影響會延伸出去。

用 WebCrypto 封裝密碼

現在前端送出前,會先跟後端拿一把公鑰和後端的時間,用瀏覽器內建的 WebCrypto 把密碼封裝起來(ECDH P-256、HKDF-SHA256、AES-256-GCM),不用新增任何相依套件。

後端解開封裝後重跑一次欄位驗證,後面的流程不變,DB 和密碼 hash 都不用動。

流程對照:HTTPS 只保護瀏覽器到 Cloudflare 邊緣這段,TLS 在邊緣解開。修正前密碼以明文送出,Cloudflare 和會記錄 body 的中間層都看得到;現在瀏覽器先用 WebCrypto 封裝,中間只看得到封裝,後端解開後重跑驗證。

幾個取捨:

  • 後端金鑰每次啟動重新產生,不放進 env。就算之後後端的機密外洩,也解不開以前截到的封裝。
  • 封裝綁定用途,5 分鐘過期,時間用後端自己給的,從 log 撿到的舊封裝不能拿來重送登入。
  • 沒有做成單次使用。能即時攔下封裝重送的人,也拿得到回應裡的 session cookie,多一次 Redis 寫入換不到什麼。

後端測試裡放了一組前端程式實際產出的封裝,釘住兩邊的格式和金鑰推導,任一邊改了演算法,測試就會紅。

加一道機制前,先想清楚它多擋住了誰,擋不到新對象的就不做。

#2FA#WebCrypto#JWT#資安

留言

載入留言中…

下一篇

工程

Next.js 網站 Lighthouse 每次 timeout?主因是一個沒讀的 response body

首頁手機版 PageSpeed 只有 46 分,Lighthouse 每次都 timeout。主因是匿名訪客的 refresh 請求拿到 400 後沒讀 response body;修完再處理 WebGL、LCP 圖和字型,分數到 61。