iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Vibe Coding

神隊友.swift:30 天 Vibe Coding 打造育兒 iOS App 系列 第 25 篇

[Day 25] 匿名帳號怎麼升級成 Google 登入:Firebase 與 Xcode 設定、用 link 保留 uid,再補一份對照讓換手機找得回家庭

  • 分享至 

  • xImage
  •  

Day 25

文章同步發表在我的個人 Blog

匿名帳號怎麼升級成 Google 登入:Firebase 與 Xcode 設定、用 link 保留 uid,再補一份對照讓換手機找得回家庭

登出之後,還找得回原本的身份嗎?

昨天對留言規則做完變異測試,預告今天要加登出與登入。目前這個 App 用 Firebase 匿名登入,每支手機一個匿名 uid,只存在那支手機的登入狀態裡。今天把匿名帳號綁定 Google,讓登出、換手機之後可以回到同一個 uid。

先講結果:匿名帳號用 link 綁定 Google,uid 不變,既有的規則不用改;登出後用同一個 Google 帳號登入,回到原本的家庭,這在兩支手機上都實測過。Apple 登入不做。另外綁定只解決一半,還要新增一份對照,讓換手機之後找得到家庭。

這篇照實作的順序寫:先講為什麼這樣設計,再依序是 Firebase 與 Xcode 的設定、綁定、登出與登入、找回家庭、測試和實測。

1. 匿名帳號登出就回不來了

匿名帳號沒有任何可以「再登入」的憑證,登出的那一刻,uid 就永久遺失,也等於退出家庭。所以設定頁的「登出」只在帳號已經綁定 Google 時才顯示,匿名帳號只看到「綁定 Google」。

這個限制不寫在畫面裡,而是用 AccountStatus 這個型別表達,畫面只讀它的屬性:

public enum AccountStatus: Equatable, Sendable {
    case anonymous
    case google(email: String?)

    public var canSignOut: Bool {
        if case .google = self { return true }
        return false
    }
    public var canLinkGoogle: Bool { !canSignOut }
}

實作端用 Firebase 使用者的 providerData 判斷有沒有綁定 Google:

func status() -> AccountStatus {
    guard let user = auth.currentUser,
          let google = user.providerData.first(where: { $0.providerID == "google.com" })
    else { return .anonymous }
    return .google(email: google.email ?? user.email)
}

2. 為什麼是 Google,不是 Apple

Firebase 文件裡 Sign in with Apple 的步驟,要在 Xcode 的 Signing & Capabilities 加上 Sign in with Apple。這個專案用免費的 Personal Team,不能加付費帳號才有的 capability,專案規則也明文禁止,所以不列入。

Google 登入不需要付費帳號,代價是要多一個第三方套件:GoogleSignIn-iOS(SPM,固定在 9.2.0)。專案規定新增相依套件之前要先有 ADR,所以第一步是寫 ADR-005-google-signin.md,記錄三個選項、決定和退路:

方案 優點 缺點
Google 登入綁定匿名帳號 不需要付費帳號;link 之後 uid 不變 多一個第三方套件;登入畫面是 Google 的,自動化測試操作不了
Email 連結登入(Firebase 內建) 不需要新套件 要處理寄信與深層連結,流程比較長
Sign in with Apple 原生體驗 需要付費帳號,專案規則禁止

退路是 Email 連結登入。登入已經包在 protocol 後面,換實作時,畫面和 ViewModel 都不用動。

3. Firebase 與 Xcode 設定

這一節是照著做就能接通的步驟,順序不能亂,因為 plist 要在啟用 Google 之後才下載得到需要的欄位。

步驟 1:在 Firebase Console 啟用 Google

Authentication → Sign-in method → 選 Google → 啟用,填「專案支援電子郵件」,儲存。

步驟 2:重新下載 GoogleService-Info.plist

專案設定裡找到 iOS App,重新下載 plist,覆蓋原本的檔案。啟用 Google 之前下載的版本沒有 CLIENT_ID 和 REVERSED_CLIENT_ID,用不了。這個專案把正式設定放在 Config/Firebase/,這個檔案不進版控。

步驟 3:把 REVERSED_CLIENT_ID 設成 URL scheme

Google 登入完成之後,是靠這個自訂網址跳回 App。先用 PlistBuddy 取出值,只印這一個欄位,不會把其他金鑰印出來:

/usr/libexec/PlistBuddy -c 'Print :REVERSED_CLIENT_ID' Config/Firebase/GoogleService-Info.plist

輸出的格式是 com.googleusercontent.apps.<數字>-<字串>。這個專案用 XcodeGen 管專案檔,所以不直接改 Info.plist,而是放在不進版控的 Config/Local.xcconfig:

GOOGLE_REVERSED_CLIENT_ID = com.googleusercontent.apps.<你的值>

project.yml 的 URL types 再引用這個變數:

CFBundleURLTypes:
  - CFBundleURLName: google-signin
    CFBundleURLSchemes:
      - $(GOOGLE_REVERSED_CLIENT_ID)

進版控的 Config/Base.xcconfig 給一個預設值 com.googleusercontent.apps.not-configured,沒設定的環境照樣建得起來,只是 Google 登入完成後回不到 App。建置後可以檢查 Info.plist 的 CFBundleURLTypes 有沒有帶進正確的值。

步驟 4:加入套件

packages:
  GoogleSignIn:
    url: https://github.com/google/GoogleSignIn-iOS
    exactVersion: 9.2.0
# Teammate target 的 dependencies
- package: GoogleSignIn
  product: GoogleSignIn

改完執行 xcodegen generate。固定版本是為了建置可以重現。9.2.0 是 9.x 的最新版;10.0.0 已經發布,但文章裡的 API 是在 9.x 上實測的,所以沒有採用。

步驟 5:把回呼網址交給 SDK

SwiftUI 的 onOpenURL 會收到所有自訂網址,包含這個 App 原本的 teammate:// 深層連結。先問 Google 的 SDK 要不要處理,不是它的再往下交:

.onOpenURL { url in
    if !FirebaseAccountLinker.handleCallback(url) {
        model.open(url)
    }
}

handleCallback 裡只有一行 GIDSignIn.sharedInstance.handle(url)。這樣 GoogleSignIn 這個 import 只留在 Teammate/Persistence/。

步驟 6:發佈規則

本篇第 6 節會新增一個集合的規則,需要發佈到線上專案:

firebase deploy --only firestore:rules --project <專案 id> --config firebase/firebase.json

沒發佈之前,補寫對照會被規則拒絕,但不影響其他功能,所以順序晚一點也不會壞掉。

4. 綁定:走完 Google 登入,但不呼叫 signIn

Firebase 文件有一段「Convert an anonymous account to a permanent account」。做法是走完 Google 登入、拿到 credential 為止,不呼叫任何 signIn,改把 credential 交給目前使用者的 link。

第一步是取得 credential。GIDConfiguration 的 client id 直接從 Firebase 的設定讀,不另外存一份;使用者取消登入畫面時,SDK 丟的是一個特定的錯誤,轉成 nil:

@MainActor
private func googleCredential() async throws -> AuthCredential? {
    guard let clientID = app.options.clientID else { throw LinkerError.missingClientID }
    guard let presenter = Self.topViewController() else { throw LinkerError.noPresenter }
    GIDSignIn.sharedInstance.configuration = GIDConfiguration(clientID: clientID)
    do {
        let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: presenter)
        guard let idToken = result.user.idToken?.tokenString else { throw LinkerError.missingToken }
        return GoogleAuthProvider.credential(
            withIDToken: idToken, accessToken: result.user.accessToken.tokenString
        )
    } catch let error as NSError where error.domain == kGIDSignInErrorDomain
        && error.code == GIDSignInError.canceled.rawValue {
        return nil
    }
}

第二步是 link,並把「這個 Google 帳號已經屬於別的帳號」轉成明確的結果:

@MainActor
private func performLink() async throws -> LinkOutcome {
    guard let user = auth.currentUser else { throw LinkerError.notSignedIn }
    guard let credential = try await googleCredential() else { return .cancelled }
    do {
        let result = try await user.link(with: credential)
        let google = result.user.providerData.first(where: { $0.providerID == "google.com" })
        return .linked(email: google?.email ?? result.user.email)
    } catch let error as NSError where error.domain == AuthErrorDomain
        && (error.code == AuthErrorCode.credentialAlreadyInUse.rawValue
            || error.code == AuthErrorCode.emailAlreadyInUse.rawValue) {
        return .alreadyInUse
    }
}

文件說 link 成功之後,新帳號可以存取原本匿名帳號的資料,也就是 uid 不變。我另外搜尋過規則檔和 Persistence 程式,沒有任何地方檢查「是不是匿名」,所以既有的規則完全不用動。

三種結果在 AccountLinking 這個 protocol 裡用回傳值表達,畫面只依結果決定顯示什麼:

情況 回傳 畫面
綁定成功 .linked(email:) 帳號區改成已綁定,顯示 email
使用者取消 .cancelled 維持原狀,不顯示錯誤
Google 帳號已屬於別的帳號 .alreadyInUse 提示「這個 Google 帳號已經用在另一個帳號上」

第三種,文件說 link 會失敗,合併要自己處理。第一版選擇不合併:直接拒絕、不搬任何資料。要合併就得跨 uid 搬家庭與卡片,規則也不允許。

5. 登出與用 Google 登入

登出只做三件事:結束 Firebase 的登入、結束 Google SDK 的登入、清掉這支手機記住的家庭 id。

func signOut() throws {
    try auth.signOut()
    GIDSignIn.sharedInstance.signOut()
    defaults.removeObject(forKey: householdKey)
}

家庭 id 是跟著登入的帳號走的,所以登出時要清掉,下一個登入的帳號再從對照找自己的家庭。

登出之後,AppModel 停掉目前的資料監聽,畫面回到首次引導:

func didSignOut() {
    if case .ready(let session) = phase {
        session.stop()
    }
    phase = .onboarding
}

引導畫面多一個「已經有帳號?用 Google 登入」。用 Google 登入的實作是同樣的 credential,但呼叫 signIn:

@MainActor
private func performSignIn() async throws -> SignInOutcome {
    guard let credential = try await googleCredential() else { return .cancelled }
    let result = try await auth.signIn(with: credential)
    return .signedIn(MemberID(rawValue: result.user.uid))
}

登入後的流程在 GoogleSignInModel:取消就留在引導畫面;登入成功就去找回家庭,找得到就進入首頁,找不到也留在引導畫面,讓使用者照原來的流程建立或加入家庭。

6. 只綁定還不夠:uid 找回來了,家庭在哪?

登出後再用 Google 登入,uid 回來了,但 App 還是不知道這個 uid 屬於哪個家庭。原因有兩個:

  • 家庭 id 只存在這支手機的 UserDefaults,換手機或登出時就沒了。
  • households 的規則寫了 allow list: if false,所以不能用「memberUids 包含我」去查。

解法是新增一份小對照:userHouseholds/{uid},只有一個欄位 householdId。規則只有幾行(節錄):

function isValidUserDoc() {
  let data = request.resource.data;
  return data.keys().hasOnly(['householdId'])
    && data.householdId is string
    && data.householdId.matches('^[A-Za-z0-9_-]{1,128}$')
    && isMember(data.householdId);
}

match /userHouseholds/{uid} {
  allow get: if signedIn() && request.auth.uid == uid;
  allow create, update: if signedIn() && request.auth.uid == uid && isValidUserDoc();
  allow list, delete: if false;
}

isMember(data.householdId) 讓這份對照只能指向自己已經是成員的家庭,所以不能偷指別人的家庭,也不會洩漏任何家庭資料。

有兩個實作上的細節:

  • 寫入要放在建立家庭的 batch 之後。 isMember 看的是寫入之前的狀態,同一個 batch 裡成員文件還不存在,會被拒絕。所以建立或加入家庭成功後,另外再寫一次,失敗不影響主流程。
  • 已經有家庭的帳號要補寫。 App 進入主畫面後,背景檢查一次,沒有或不一致就補寫,失敗就下次啟動再試。

找回家庭的流程是三步:讀自己的對照、直接 get 家庭、確認自己在 memberUids 才進入並記回手機。

func restoreHousehold() async throws -> Household? {
    guard let uid = auth.currentUser?.uid else { return nil }
    let lookup = try await lookupRef(uid).getDocument()
    guard lookup.exists, let householdID = lookup.get("householdId") as? String else { return nil }
    let snapshot: DocumentSnapshot
    do {
        snapshot = try await db.collection("households").document(householdID).getDocument()
    } catch let error as NSError where error.domain == FirestoreErrorDomain
        && error.code == FirestoreErrorCode.permissionDenied.rawValue {
        return nil   // stale lookup
    }
    guard let household = HouseholdDocument.household(from: snapshot),
          household.memberIDs.contains(MemberID(rawValue: uid)) else { return nil }
    defaults.set(householdID, forKey: householdKey)
    return household
}

對照過期(指向的家庭不認得這個帳號)就當作沒有,不顯示錯誤。

集合叫 userHouseholds,不叫 users,是因為既有的規則測試把 users/uid-alice 當作「不該被開放的路徑」來驗證。開放它會讓那個測試失敗,而既有測試不能為了讓新功能通過而修改。

7. Google 的登入畫面沒辦法自動測

GIDSignIn 的登入畫面是 Google 的,自動化測試操作不了。所以登入包在 AccountLinking protocol 後面,Firebase 與 GoogleSignIn 只出現在 Teammate/Persistence/,ViewModel 與測試只認 protocol,用假的實作指定每一種結果:

let linking = FakeAccountLinking(linkResult: .success(.alreadyInUse))
let model = AccountModel(linking: linking, onSignedOut: {})
await model.linkGoogle()
XCTAssertEqual(model.status, .anonymous)
XCTAssertEqual(model.notice, .alreadyInUse)
測試 數量 驗證什麼
AccountModelTests 10 綁定成功、取消、帳號已被使用、其他失敗可重試、登出、匿名不能登出、引導畫面登入
HouseholdLookupTests(Emulator) 5 建立與加入時寫入對照、補寫、找回家庭、沒有對照、對照過期
userHouseholds.test.js 4 本人可讀寫、別人被拒、指向別人的家庭被拒、多餘欄位與 list、delete 被拒
AccountUITests 5 匿名只有綁定鈕、已綁定只有登出鈕、登出回到引導

HouseholdLookupTests 裡的「對照過期」要寫出一份規則不允許的文件。測試用 Emulator 的 REST 介面,帶 Authorization: Bearer owner 略過規則直接寫入,這只能用在本機 Emulator。另外還有一個小坑:Section 上設 accessibilityIdentifier,會蓋掉裡面按鈕自己的 identifier,UI 測試就找不到按鈕,改放在內容容器上。

真實的登入畫面只能在實機上驗證。

8. 手機上實測

在我的手機上,設定頁按「綁定 Google」,跳出 Google 的授權畫面。畫面只要求兩項資訊:名稱與個人資料相片、電子郵件地址。授權畫面上的應用程式名稱,顯示的是 Google Cloud 專案的預設名稱,這部分我還沒調整。

按「繼續」之後,帳號區改成已綁定,家庭和卡片都還在。接著登出,回到引導畫面,按「已經有帳號?用 Google 登入」,用同一個 Google 帳號登入,直接回到原本的首頁,不需要邀請碼。登出時手機上記住的家庭 id 已經清掉,所以這一步確實是靠 userHouseholds/{uid} 找回來的。

另一支手機也用同樣的步驟實測,結果一樣可以。這也確認了 GoogleSignIn 在免費 Personal Team 的實機上可以正常使用。

如果你照做,建議用兩個不同的 Google 帳號各綁一支手機。兩支手機綁同一個 Google 帳號,第二支預期會得到「這個 Google 帳號已經用在另一個帳號上」,這就是 .alreadyInUse 的情況。

9. 目前的狀態

項目 結果
匿名帳號綁定 Google 兩支手機實測成功,uid 與資料不變
登出再登入 兩支手機實測成功,回到原本的家庭
Apple 登入 不做(免費帳號)
帳號合併、解除綁定、刪除帳號 刻意不做
授權畫面的應用程式名稱 還沒調整
verify.sh VERIFY: PASS(TeammateKit 59、規則 99、App 194)

明天預告

Day 26 在簽章到期前後各檢查一次,重新安裝之後確認兩支手機的登入狀態都還在。

參考資源

官方文件

套件


上一篇
[Day 24] 規則的每個條件都有測試守著嗎:對留言規則做變異測試,11 個條件拿掉後 9 個會讓測試失敗,找出一個真的缺口
下一篇
[Day 26] 雙十國慶日,簽章到期之後重新安裝:覆蓋安裝為什麼沒續期、Xcode 帳號掉了,以及 Google 授權畫面上的 OAuth
系列文
神隊友.swift:30 天 Vibe Coding 打造育兒 iOS App 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言