iT邦幫忙

0

[BFF_Security] Day 2:剛把 Token 藏好,用戶怎麼全變成別人了?

  • 分享至 

  • xImage
  •  

Day1 講了一些知識與概念,今天分享一些實做與資安相關的實作部份。

週一早上九點,辦公室裡洋溢著大功告成的快活空氣。
前端負責人小杰捧著咖啡,得意洋洋地展示週末加班的成果:

「搞定!照著 Day 1 的方案,我們把 OAuth 換 Token 的流程全部搬進了 BFF 後端!現在前端完全碰不到任何 Token,只拿一張乾乾淨淨的 HttpOnly Cookie,這下總該高枕無憂了吧?」

話音未落,客服部的電話突然像炸彈一樣連環引爆。
客服主管阿美慌慌張張地衝進會議室,聲音都在發抖:

「研發團隊!出大事了!剛剛有幾十位使用者回報,他們用自己的帳密點擊登入後,赫然發現自己登入成了『隔壁老王』的帳號!
其中一位林小姐甚至在購物車裡看到了老王的住家地址,還差點直接刷了老王綁定的信用卡!現在各大論壇都在瘋傳我們的系統鬧鬼了!」

小杰手裡的咖啡杯差點摔在地上:「怎麼可能?!每個人都是自己輸入帳密的,怎麼可能登入成別人?!」

資安長老張慢條斯理地推門進來,在白板上重重寫下四個大字:
「Login CSRF(登入劫持)」。


第一幕:魔鬼藏在細節裡——為什麼駭客要讓你登入「他的帳號」?

「小杰,你是不是覺得 CSRF 攻擊只能用來『盜刷使用者的錢』?」老張面無表情地問。

小杰愣愣地點頭:「對啊……黑客讓受害者登入黑客自己的帳號,那不是把帳號白白送給受害者用嗎?黑客圖什麼?」

「他圖你的信用卡、地址、還有你上傳的私密檔案!」老張在白板上飛快地畫出一張時序圖:

[!NOTE]
名詞小科普:我們在 Day 1 提到的 Authorization Server,在實務與身分認證(OIDC)情境下常被統稱為 IdP(Identity Provider,身分識別提供者),例如 Google 登入、Keycloak、Auth0、Azure AD 等。Day 2 起我們會統一稱呼它為 IdP。

https://ithelp.ithome.com.tw/upload/images/20261018/20104930EIs8IGddWN.png

全場工程師倒抽了一口涼氣。
原來,黑客先用自己的身分換到一個授權碼 code_A,然後設局騙受害者去點那個回跳網址。
如果後端沒有查驗憑證,受害者就會在不知情的情況下,登入成黑客的帳號,並在上面留下一連串個人的信用卡與機密個資!

「看懂了吧?」老張冷冷地說:「state 不是可有可無的裝飾品,它是用來證明:『現在送回來的這張考卷,真的是眼前這個瀏覽器剛才領取的!』」


第二幕:OAuth 機密客戶端的五大「防身保鏢」

看著滿頭大汗的小杰,架構師老陳拍了拍他的肩膀:
「別慌。把 BFF 變成真正的 OAuth 機密客戶端(Confidential Client),就像給系統配備五名貼身保鏢。任何一個保鏢擅離職守,都會引發連環車禍。」

https://ithelp.ithome.com.tw/upload/images/20261018/20104930dKolxuMRG0.png

老陳隨後帶著我們逐一理清這五個零件在程式架構裡的真實分工:

  1. client_secret:證明「我是真正的後端」。這是前端 SPA 永遠做不到的事,也是 BFF 最值錢的特權。
  2. state:防偷樑換柱(Login CSRF)。每次登入產生一個隨機亂數,存進暫存狀態,回調時一字不差才給過。
  3. nonce:ID Token 防偽浮水印。把隨機值塞進 ID Token 的 Payload 裡,防止攻擊者拿半年前截獲的他次合法 Token 來重放登入。
  4. PKCE(code_challenge / verifier):暗號驗證。發起時留下雜湊題目,換 Token 時出示原始謎底。即便 code 在前台瀏覽器網址被外掛窺探,沒有 verifier 的黑客依然一無所獲。
  5. Exact redirect_uri:鎖死回跳網址。嚴禁使用萬用字元(Wildcard),防止黑客誘導 IdP 把 code 送去 evil.com。

第三幕:第二場災難——「全體使用者登入完都顯示未登入?!」

中午十二點,小杰把 state 驗證加上去並發布了更新。
然而不到十分鐘,辦公室再次響起慘叫:

「小杰!這次換全體 Android 與 iOS 使用者登入失敗了!每次在 Google 頁面點完同意跳回首頁,畫面都顯示『未登入』,使用者必須手動重新整理一次才會正常!」

小杰抓著頭髮快瘋了:「我明明把 Session Cookie 設得最安全啊!HttpOnly=true、Secure=true、SameSite=Strict,為什麼跳回來就掉了?!」

老陳笑了:「這就是新人工程師都會踩到的『SameSite 轉址坑』!」

https://ithelp.ithome.com.tw/upload/images/20261018/20104930znN1bwkYRp.png

「看明白了嗎?」老陳在程式碼中圈出兩行:
「系統裡必須有『兩顆 Cookie,兩種 SameSite』!」

  • login 暫存 Cookie 必須是 SameSite=Lax:因為使用者是從外部的 IdP 網域(如 accounts.google.com)跳轉回我們的 /callback。如果設為 Strict,瀏覽器在跳轉那一瞬間會無情地把 Cookie 丟掉,導致 BFF 找不到剛才生成的 state,登入自然宣告破局!
  • sid 正式 Session Cookie 才能設為 SameSite=Strict:等使用者正式進入站內,才享受最高等級的防禦。

第四幕:程式碼巡禮——帶走就銷毀的 Pending State

在專案目錄 day2/bff/bff.go 中,我們親手重構了這套防禦體系。

在 /login 發起時,伺服器生成機密考卷,只發給瀏覽器一個隨機 ID:

// 詳見 day2/bff/bff.go
func (b *BFF) login(w http.ResponseWriter, r *http.Request) {
	p := pending{state: randToken(), nonce: randToken(), verifier: randToken(), expires: b.now().Add(10 * time.Minute)}
	loginID := randToken()
	b.mu.Lock()
	b.pending[loginID] = p
	b.mu.Unlock()

	// 關鍵:這顆暫存 cookie 必須是 Lax,否則跳轉回調會掉狀態!
	b.setCookie(w, "login", loginID, http.SameSiteLaxMode, 600)
    ...
}

而在 /callback 接收請求的第一時間,執行**「取出即銷毀(Consume-on-read)」**:

// 取出立刻刪除:登入狀態是一次性的,防範回調重放!
b.mu.Lock()
p, ok := b.pending[c.Value]
delete(b.pending, c.Value)
b.mu.Unlock()

「這樣一來,」我對小杰說:「同一條 Callback 連結,黑客就算攔截到了想重新點擊第二次,也因為資料早就被刪除而無法重放!」


第五幕:ID Token 六大安檢門與登出的三個致命疏忽

更換回 Token 後,老張盯著我們寫的 ID Token 驗證流水線(day2/bff/jwt.go):
「少檢查一項,就是一個零日漏洞。」

https://ithelp.ithome.com.tw/upload/images/20261018/20104930s4fa8oh0Pv.png

在測試 TestIDTokenChecks 中,我們故意將 Token 改成 alg: none(無簽名)或是 alg: HS256(利用公鑰對稱加密的著名 CVE 漏洞),BFF 毫無懸念地全部封殺。

登出的三個黃金步驟(缺一不可)

傍晚五點,小杰準備下班前,寫了一行 /logout 代碼:deleteCookie("sid")。
老張一把按住他的手:「你這叫假登出!」

小杰一臉委屈:「老張,我們 Day 1 不是才說『Token 全鎖在 BFF,瀏覽器只有 sid Cookie』嗎?那我把瀏覽器的 sid 拔掉,再把 BFF 伺服器記憶體裡的 Session 刪掉,黑客根本拿不到任何東西,為什麼非得大費周章去敲 IdP 的門?」

「因為你只丟掉了自己手裡的『鑰匙指針』,卻把那把能無限開門的真正金鑰,完好無損地留在了 IdP 伺服器裡!」老張在白板上畫出了完整的登出防線:

https://ithelp.ithome.com.tw/upload/images/20261018/20104930i0qWmiRKtf.png

老張指著「步驟三」為小杰補上了最關鍵的三塊拼圖:

  1. 「丟掉指標」不等於「金鑰作廢」(防禦歷史外洩憑證):
    BFF 的 delete(b.sessions, sid) 只是我們自己的系統不再理會這個使用者。但 IdP 核發的這把 Refresh Token 在 IdP 的資料庫裡狀態依然是 active!
    如果這把長效 Token 過去曾意外印入 Application Log、APM 監控、Redis RDB 備份檔,或被 SSRF 漏洞拖走——黑客根本不需要透過你的 BFF,他只要拿著偷來的 Refresh Token 與 Client 憑證直接向 IdP 的 /token 端點請求,IdP 就會源源不絕地給他簽發全新、合法的 Access Token!只有主動呼叫 RFC 7009 /revoke,IdP 才會從根源註銷這張長期通行證。
  2. 終結 IdP 端的 SSO Session(避免幽靈回魂):
    在許多現代 IdP(例如 Keycloak、Auth0)中,一個 Refresh Token 往往緊密綁定著使用者在 IdP 上的那次 SSO 登入態。當 BFF 向 /revoke 撤銷 Refresh Token 時,IdP 會一併終止該次 SSO 授權;若不撤銷,IdP 端會殘留大量幽靈 Session,甚至在多系統單點登入環境下造成身分狀態不同步。
  3. 撤銷授權是使用者的法定義務(RFC 7009 / RFC 9700):
    使用者點擊「登出」,在安全語意上不僅是「關閉這個分頁」,而是「撤回對此客戶端的存取授權」。Refresh Token 的效期往往長達數週甚至數月,不及時撤銷就是給系統平添潛在的攻擊面。

[!TIP]
那已發出的 Access Token 呢?
很多人會問:「向 IdP revoke 了 Refresh Token,那之前發出去的 Access Token 呢?」
答案是:由於 Access Token 通常是無狀態的 JWT,在它的短期壽命(如 5~15 分鐘)結束前,內網微服務若未做即時黑名單查驗,它技術上依然可用。這正是為什麼我們在架構上必須堅持:

  • Access Token 壽命必須極短(縮減窗口)。
  • Refresh Token 必須妥善保管且在登出時強制 /revoke(掐斷續杯源頭)。
  • 若需要連使用者在 IdP 端的 SSO 瀏覽器登入狀態都全域清除,則需進一步搭配 OIDC RP-Initiated Logout 將瀏覽器導向 IdP 的登出端點。

尾聲:深夜的 PagerDuty 預警

晚上十點,專案目錄下的 9 個單元測試全數綠燈亮起:

cd day2/bff && go test -v -count=1 ./...
# PASS: State 驗證、Cookie 標誌、ID Token 六大安檢、Session Fixation 全數通過!

小杰癱坐在椅子上,長舒了一口氣:「太不容易了……原來一個登入背後藏著這麼多要人命的暗器。這下我們總能安心代理使用者的 API 請求了吧?」

老陳端著茶走過來,淡淡一笑:
「小杰,登入完成只是拿到了通行證。真正的修羅場,是當 BFF 開始當『代理人(Proxy)』幫前端跑腿打 API 的那一刻。
明天一早,你馬上就會面對四個更恐怖的破口:

  • 為什麼 Cookie 明明是 SameSite,行銷部的一個外掛部落格被黑,全站依然能被 CSRF 洗劫?
  • 微服務的 URL 如果讓前端指定,黑客是怎麼用 %2e%2e 穿越進內網管理員後台的?
  • 還有……使用者開著 5 個分頁同時刷新,為什麼全站用戶會在同一秒被集體踢登出?」

小杰臉上的笑容瞬間凝固。
夜深了,但我們知道,明天的戰鬥只會更加慘烈……

(未完待續,請見 Day 3:【第二夜:行銷部的豬隊友】登入完成之後——BFF 代理請求時的四個致命破口)


📚 本回參考文獻


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言