iT邦幫忙

0

[BFF_Security] Day 5:新舊系統之間的夾心餅乾——Evolution、Bridge、SIA 與雙寫一致性

  • 分享至 

  • xImage
  •  

大部分產品工程師都面臨著新舊系統並存的情境,BFF 架構可以讓新舊服務被存取,但有些設計需要作到位,不然 BFF 也是弱點。

週四清晨,機房深處瀰漫著一股老舊電子零件過熱的焦糊味。
運維老哥指著機櫃最底層一台發出拖拉機般轟鳴聲的古老伺服器,對我們發出嚴肅警告:

「這台機器跑的是公司 2012 年創立時寫的單體 ERP 系統。
裡面的代碼是用早已沒人維護的框架寫的,沒有單元測試、沒有文檔、核心資料庫甚至還跑在老舊的 MySQL 5.5 上。全公司沒人敢重啟它,連按鈕上的漆都掉光了。
但今天,老闆下達了死命令:本季必須上線全新訂閱會員功能!新功能放在全新的微服務裡,但核心用戶的姓名與 Email 還在這台古董裡。前端只准看見一個乾淨的現代化 JSON API!」

前端小杰看著那台老古董,臉上的肌肉抽搐著:
「這……這根本是把一台蒸汽機車跟特斯拉綁在一起跑啊!我們該怎麼接?!」

架構師老陳拍了拍機櫃上的灰塵:
「這就是每個資深工程師都逃不掉的宿命——當新舊系統之間的夾心餅乾。
在 bff-patterns.com 中,專門為這種處境量身定制了四大模式:
Evolution、Single Integration Architecture(SIA)、Coordination 與 Transformation Bridge!」

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


第一幕:Evolution——為什麼老陳堅決反對建造防腐層(ACL)?

小杰翻開教科書,試圖尋找正統解答:
「老陳,軟體工程教科書上說,面對這種遠古舊系統,標準做法是建造一個厚重的 防腐層(Anti-Corruption Layer,ACL):蓋一個獨立的中繼微服務,把老古董的怪異格式包裝成乾淨的領域模型,隔離毒素!」

「那是教科書騙你的童話!」老陳毫不客氣地反駁道:
「建造一個獨立的 ACL 服務,你要為它申請伺服器、寫 Dockerfile、配置 CI/CD、監控健康狀態。對於一個兩年內預計除役淘汰的老系統,花費團隊三個月精力去養一個用完即丟的過渡服務,純屬浪費公司生命!」

bff-patterns.com 倡導的 API Evolution 走了一條無比務實的捷徑:
不建 ACL,直接在 BFF 內部撰寫 Split Operations!

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

讀取時的靈魂拷問:誰是權威來源(Source of Truth)?

在專案目錄 day5/coordination/service.go 中,我們實作了分裂讀取邏輯:

func (s *Service) Get(ctx context.Context, u User, id string) (Profile, error) {
	// 1. 舊系統目前仍是「姓名與 Email」的權威來源:它若掛了,整個請求視為失敗!
	c, err := s.in.Legacy.Get(ctx, id)
	if err != nil { return Profile{}, fmt.Errorf("legacy: %w", err) }

	p := Profile{Customer: c, PlanStatus: "ok"}

	// 2. 新微服務只負責「會員方案」等附加新功能:它若掛了,優雅降級!
	nc, err := s.in.New.Get(ctx, id)
	if err != nil {
		p.PlanStatus = "unavailable" // 標註為不可用,絕不拖垮個人首頁
		return p, nil
	}
	p.Plan = nc.Plan
	return p, nil
}

💡 架構鐵律:
誰是權威來源,誰就是必要步驟!
核心個資目前還握在老古董手中,老古董斷線,個人頁面就無法成立;新微服務只是增強功能,新微服務抖動,首頁依然能開,只是標籤顯示暫時無法載入。


第二幕:API Coordination——雙寫翻車的「六大血淚陷阱」

讀取搞定了,到了下午兩點,真正的災難在**寫入操作(更新 Email)**時徹底爆發。
使用者改了 Email,舊系統要改,新系統也要改。
在沒有分散式事務(2PC)的環境下,我們採用了 Coordination 的三部曲:Map ➔ Sync ➔ Verify。

然而不到半小時,測試環境就傳來慘叫:
「資料庫分裂了(Data Drift)!同一個使用者的 Email,在舊系統裡是 alice@new.com,在新系統裡卻依然是 alice@old.com!兩邊對不上了!」

老張和我一頭扎進代碼,揪出了讓所有分散式系統工程師聞風喪膽的六大暗坑:

https://ithelp.ithome.com.tw/upload/images/20261018/201049305r2k1mkcKV.png


🚨 實戰六大翻車陷阱深度復盤

陷阱 1:死人無法救人——補償絕對不能沿用已被 Cancel 的 Context!

小杰寫的第一版回滾代碼:

// 💥 致命 Bug:直接用傳入的 ctx 去回滾
s.in.Legacy.Update(ctx, id, prev)

「你知道為什麼新系統超時後,回滾一定失敗嗎?」我指著螢幕:
「因為使用者等得不耐煩按了取消,或是 HTTP 逾時,原本的 ctx 已經是 Canceled 狀態了! 你拿一個死掉的 ctx 去打舊系統,舊系統的 HTTP 客戶端直接回傳 context canceled,補償根本沒送出!」

正確解法:在 Go 1.21+ 中,必須使用脫鉤的全新的 Context:

// 脫鉤 Context,確保無論前方如何超時,回滾都能有獨立的 5 秒生命週期!
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
s.in.Legacy.Update(rctx, id, prev)

陷阱 2:同一客戶的並發寫入必須排隊(Per-Customer Lock)

如果使用者狂點了兩次按鈕:
請求 A 寫入 a@x.com,請求 B 寫入 b@x.com。
若沒有排隊,可能發生交錯寫入:舊系統先執行了 B,新系統卻先執行了 A。
結果:舊系統是 B,新系統是 A,兩邊永久不一致,而且全程綠燈沒報錯!
我們在代碼中引入了客戶維度的鎖,同一客戶的更新請求強制排隊序列化。

陷阱 3:重試必須冪等,且冪等鍵必須鎖死範圍!

前端網路超時後一定會重試。我們實作了 Idempotency-Key。
但在審查時,老張發現了一個極其隱蔽的漏洞:阿呆原本把冪等鍵做成了全域 Key。
黑客只要隨便在 Header 猜測 Key: 12345,就能探測出「別人的這個鍵曾發生過衝突」,進而推斷業務狀態。
修正:冪等鍵的儲存範圍必須綁定 Actor + CustomerID + Key,徹底消除跨租戶探測!

陷阱 4:補償失敗(ErrDrift)必須拉響最高警報!

普通同步失敗(ErrSyncFailed)代表舊系統已經復原,前端大可以提示「網路繁忙,請重試」;
但如果連回滾都失敗了,這叫 Data Drift(資料分裂)!
此時絕對不准假裝沒事,必須立刻將事件推入 Repair Queue,在稽核日誌標註 DRIFT,並直接把工程師的 PagerDuty 叫醒!


第三幕:舊系統認車不認人,BFF 是唯一的代償門神!

下午四點,老張把我們叫到了投影機前,展示了一個令人毛骨悚然的測試案例:

// 詳見 day5/coordination/service_test.go
func TestAuthz_LegacyItselfWouldHaveAllowedIt(t *testing.T) {
	l := newLegacy()
	// 直接以服務帳號呼叫舊系統,要求將客戶 c2 的資料改成駭客的信箱
	l.Update(ctx, "c2", Customer{"c2", "Hacked", "evil@x.example"})
	// 💥 舊系統毫無反抗地照辦了!
}

「看見了嗎?」老張說:
「2012 年的老古董只認一條 Master API Key。在它的眼裡,根本沒有『林小姐』或『王先生』,它只知道『是擁有最高權限的 BFF 在向我下指令』!
如果黑客在前端抓包,把網址裡的 customer_id 改成受害者的 ID:
一旦 BFF 盲目轉發,老古董就會毫不猶豫地幫黑客覆寫全站任何人的個資!」

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

兩條不可妥協的代償鐵律:

  1. 被拒絕的越權請求,後端呼叫次數必須是 0:在碰到老古董的一根毫毛之前,BFF 必須將其當場斬首。
  2. 稽核記錄的人必須是「真人」,絕不准寫服務帳號:如果日誌裡寫「服務帳號改了資料」,出事時根本找不到內鬼是誰。BFF 必須將真人的 Actor: u.ID 牢牢刻進審計記錄。

第四幕:Transformation Bridge——異質 Webhook 的 7 道安檢與防投毒

到了傍晚,最後一個挑戰降臨:
外部老牌金流供應商會發送扣款成功的 Webhook 通知。
但那家供應商極度古老,送過來的居然是 application/x-www-form-urlencoded 表單格式;而我們內部的新微服務只吃現代化的 JSON 格式 + HMAC 簽名!

bff-patterns.com 提出的解法叫 API Transformation Bridge(轉換橋):在中間放一座橋樑,把 Form 轉成 JSON 並重簽轉發。

在 day5/webhook-bridge/ 中,我們用 Rust 打造了一座高強度的轉換橋,老張特別糾正了業界最常見的兩大致命錯誤:

⚠️ 雙金鑰隔離原則(重要資安常識)

規格書中提到「轉換橋使用相同的憑證轉發」,這在資安上是徹頭徹尾的死罪!
入站金鑰(金流商 ➔ 橋)絕對不能等於出站金鑰(橋 ➔ 內部微服務)!
如果兩邊共用金鑰,一旦金流商那邊的密鑰洩漏,黑客就能直接繞過轉換橋,偽造內部請求打爛我們的微服務!


嚴防快取投毒阻斷攻擊(Cache Poisoning DoS)

在實作驗證順序時,阿呆一開始把順序寫反了:先登記 Event ID 防重放,再去算 HMAC 簽章。

老張一眼看穿了其中的殺機:
「如果黑客發送一個簽名是假的請求,但把 Event ID 設成 evt-1001(猜測下一個合法事件的編號)。
你的橋先在快取裡記下了 evt-1001,隨後比對簽章失敗回傳 401。
幾秒鐘後,金流商真正合法的 evt-1001 扣款通知抵達了——
你的橋卻判定:『evt-1001 已經處理過了,視為重放攻擊拒絕!』
黑客一毛錢不花,就成功癱瘓了全公司的金流扣款!」

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

在 Rust 單元測試 a_forged_request_cannot_poison_the_replay_cache 中,我們親自驗證了這個順序的重要性:未通過簽名校驗的垃圾請求,在系統內絕不准留下任何蹤跡!


尾聲:週四深夜的勝利會師

深夜十一點半,雙語言的所有測試在終端機中全數綻放出漂亮的綠色:

cd day5/coordination && go test -race -v -count=1 ./...
cd ../webhook-bridge && cargo test
# 17 項 Go 雙寫一致性與授權測試 + 12 項 Rust 轉換橋與防投毒測試全部通過!

看著那台發出低沉嗡嗡聲的老古董伺服器,小杰擦了擦額頭上的冷汗:
「今天真是開了眼界……原來新舊系統並存時,有這麼多致命的暗礁:Context 脫鉤回滾、排隊防交錯、老系統代償授權、還有 Webhook 快取防投毒……」

老陳笑著拍了拍小杰的肩膀:
「恭喜你,你已經挺過了最泥濘不堪的一場硬仗。
但明天,我們將迎來全系列的大收官之戰:

  • 當前端打 API 時,我們能不能徹底剝奪它傳送任意路徑與參數的權力,改用嚴格的 API Facade(外觀白名單)?
  • 當一個使用者的 Token 轉發給下游微服務時,如何防止某個次要微服務被黑客打穿後,引發跨服務的『橫向移動浩劫』?
  • 還有……BFF 幫使用者代管 Google、Stripe 的第三方 Token,如何用 AES-256-GCM 打造一個連內部工程師都無法監守自盜的鋼鐵金庫?」

小杰握緊了拳頭:
「明天就是最後一天了,讓我們把這座 BFF 堡壘徹底打造成終極形態!」

(未完待續,請見 Day 6:【第五夜:IPO 前夕的通牒】授權與金鑰的終局之戰——API Facade、Token 縮小換發與第三方金庫(Token Handler))


📚 本回參考文獻


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

尚未有邦友留言

立即登入留言