讓同一個人無論用 Apple/Google 登入都算同一個帳號 —— 為什麼沒有使用 Firebase 原生的 link(with:)

我個人開發的 Minecraft 伺服器監控應用程式「MineWatch」(官方網站、應用程式整體介紹請見這裡)使用 Firebase Authentication 進行登入,並支援 Apple/Google 登入。當我建立「同一個人即使分別用 Apple 版與 Google 版登入,也要視為同一個 MineWatch 帳號」的帳號連結功能時,我沒有採用 Firebase 原生的 link(with:),而是設計了後端獨立的資料表。這篇文章會說明這個決策的原因與實作方式。

TL;DR

  • 原先以為「Apple/Google 若是同一個電子郵件地址,就會自動合併」的想法是錯的。Apple 可能會隱藏電子郵件,而且也有一開始就用不同電子郵件註冊的情況。
  • Firebase 原生的 currentUser.link(with:) 在要連結的對象已經以另一個 Firebase 使用者存在時(credential-already-in-use),會需要先刪除還活著的 Firebase 使用者,再重新指派憑證,這是破壞性且多步驟的流程。若途中網路中斷,就可能變成兩邊憑證都無法登入的卡死狀態。
  • 取而代之,我們完全不碰 Firebase Authentication 端的狀態,而是用後端自訂的 auth_identities 資料表(1 User : N firebase_uid)來管理連結。破壞性的決策都限制在單一資料庫交易中,失敗時只要回滾即可。
  • 發生衝突時,讓使用者在「中止/合併/用目前資料覆蓋/用連結來源資料覆蓋」四種方式中選擇。

1. 「同一電子郵件就會自動合併」這個前提崩潰了

MineWatch 的認證設計最初把「同一電子郵件的 Apple/Google 帳號,可以透過 Firebase 標準的帳號連結功能合併」當作採用 Firebase 的優勢之一。

但實際上真正需要的是,不一定是相同電子郵件地址的連結。Apple 在登入時提供隱藏電子郵件的功能(Private Relay),而且也可能一開始就使用不同的電子郵件分別在 Apple 版與 Google 版註冊。這些情況都不屬於 Firebase「同一電子郵件就自動連結」的範圍。

2. 檢討 Firebase 原生的 link(with:) 後,最後放棄的原因

Firebase 的 currentUser.link(with:)(iOS)/ linkWithCredential(Web)本身即使電子郵件不一致也可以呼叫。問題出在當你要連結的識別資訊已經以另一個 Firebase 使用者存在時。此時會發生 auth/credential-already-in-use 錯誤,而要解決它,就必須「先刪除仍存在的 Firebase 使用者,再重新指派憑證」;這是一個破壞性、而且需要多步驟由用戶端協調的流程。

這種方式下,如果在刪除與重新指派之間網路斷線,或 App 當掉,理論上就可能出現兩邊憑證都無法登入的卡死狀態。在認證這種關鍵路徑上,我不想引入這種「做到一半卡住就完蛋」的流程。

3. 採用的設計:用 auth_identities 資料表管理 1 User : N firebase_uid

因此我改採不碰 Firebase Authentication 帳號狀態、而由後端自訂資料表管理連結的方式。

實際的模型定義如下。

"""與使用者綁定的 Firebase 識別子(auth_identities 資料表)。

讓 1 位使用者可以擁有多個 firebase_uid 的資料表。
Apple/Google 會依照不同提供者發出不同的 uid(即使電子郵件相同也不同值)
,因此若要實現「同一個人用多個提供者登入時仍視為同一個 MineWatch 帳號」
的帳號連結功能(POST /v1/me/link),就必須是 User:firebase_uid 的 1:1
而不是 1:N。
"""

class AuthIdentity(SQLModel, table=True):
    """與 User 綁定的 Firebase 識別子 1 筆。"""

    __tablename__ = "auth_identities"

    id: int | None = Field(default=None, primary_key=True)

    # 解除帳號連結或刪除時會一併刪除。
    user_id: int = Field(foreign_key="users.id", ondelete="CASCADE", index=True)

    # 證明本人身分的唯一鍵。Apple 可能會隱藏電子郵件,所以不能用電子郵件判定。
    firebase_uid: str = Field(max_length=128, unique=True, index=True)

users.firebase_uid(一開始建立時使用的識別子)本身則保留為舊欄位。這是遵循 Expand-Contract 方針中的 Expand 階段:欄位本身的刪除會等到確認正式環境穩定運作後再另行處理。本人辨識改為透過 auth_identities,因此一個 User 現在可以擁有多個 firebase_uid。

4. 發生衝突時的四選一,以及其實作

連結流程會先從沒有副作用的確認(POST /v1/me/link/preview)開始。

如果要連結的對象已經作為另一個帳號存在,系統就會讓使用者從四種方式中選擇。除了「中止」以外的三種策略,最後都會收斂成同一種結果:把對方的 AuthIdentity 轉移給目前登入的使用者,然後刪除對方的使用者列。

async def resolve_link_conflict(
    session: AsyncSession,
    current_user: User,
    other_user: User,
    strategy: LinkStrategy,
) -> None:
    """解決衝突,並將 other_user 合併到 current_user。

    - merge: 將雙方的 servers/devices 都合併到 current_user
      (不做重複檢查。保留雙方內容,對於「無法復原」的警告來說,
      這是較安全的處理方式)
    - keep_current: 不處理 other_user 的 servers/devices。
      在刪除 other_user 時,會透過 ON DELETE CASCADE 一併刪除
    - keep_source: 先刪除 current_user 既有的 servers/devices,
      再轉移 other_user 的資料

    所有步驟都限制在一次 commit 中(避免部分套用。對於會警告
    「無法復原」的操作,把中途狀態留在資料庫中是致命的)。
    """

merge 採取「保留雙方內容比較安全」的做法,因此忽略重複;keep_source 則是先刪除再轉移;而且最重要的是,所有處理都被包在一次 commit 裡,這正是為了避免第 2 節提到的「途中停止就卡死」問題。即使資料庫交易失敗,也只會回滾,不會留下半套狀態。

5. 一些細節上的設計判斷

在閱讀實作時,可以注意到幾個巧思:

  • ID token 由請求 body 傳入:一般的端點只會看 Authorization 標頭中的一個 token,但連結 API 除了「目前登入中的 session」之外,還需要同時驗證「想要連結的另一個提供者的本人身分」,因此目標端的 ID token 是放在 body 裡傳送,這種做法比較少見。
  • v1/v2 共用同一份處理本體:POST /me/link/preview、POST /me/link、DELETE /me/link/{provider} 的核心處理,都是由 v1/v2 兩邊的 router 呼叫同一個函式來共用,router 層只保留 APIRouter 的 decorator、Depends 解決與 docstring。這樣可以避免 router 層的程式碼重複(也會反映在 SonarQube 的 duplicated_lines 指標上)。
  • 不依賴 ORM 的延遲載入:不像 other_user.servers 這種透過關聯直接取值的寫法,而是先用明確的 select() 取出資料,再改寫 user_id。程式碼註解中提到:「因為這個 codebase 既有方針就是在非同步 session 下不使用延遲載入」。
  • 帳號刪除會刪掉連結後的所有識別子:在連結之後執行帳號刪除時,現在會把綁定的所有 firebase_uid 都從 Firebase 刪除。這是修正了在考慮連結功能之前的舊實作,當時只會刪除第一筆。

6. 額外:順手發現的無關既有 bug

這個功能的 PR(4,323 行、變更 36 個檔案)中,還順手修到了一個與實作無關的既有 bug。internal router 與 v1/servers router 的 list_servers operationId 重複,導致 iOS 端的 OpenAPI 型別產生壞掉。這個問題在 develop 分支單獨就能重現,是原本就存在的 bug,只是在實作這個功能的過程中剛好被發現並修掉。

總結

  • 一開始以為「同一電子郵件就會自動合併」的假設,和實際需求(Apple 隱藏電子郵件、可用不同電子郵件註冊)並不一致,這是整個問題的起點。
  • Firebase 原生的帳號連結在衝突解決時是破壞性且多步驟的,一旦中途失敗就有卡死風險;對於認證這種不容許失敗的路徑,我判斷不適合。
  • 透過自訂的 auth_identities 資料表管理 1 User : N firebase_uid,完全不碰 Firebase 端狀態,並把破壞性的決策限制在單一資料庫交易中,從原理上排除了卡死狀態。
  • 對於會提示「無法復原」的操作,設計上必須避免任何部分套用的可能;這次就是用「全部包在一次 commit 中」這種簡單方式來實現。

因為這是個人開發環境中的架構,如果你要直接套用到組織內部,請先確認帳號整併時的資料所有權與稽核紀錄需求。


JQIT 的工程師有 95% 以上都是從零經驗錄用的。
歡迎也來公司網站逛逛。

:sparkles:從零開始也能學習!一起挑戰吧!:sparkles:

我們也有 note 和 X ↓



原文出處:https://qiita.com/jqit-yukiono/items/0a68cd4ab997e8c8213b


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝59   💬2  
851
🥈
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登