
文章同步發表在我的個人 Blog
登出之後,還找得回原本的身份嗎?
昨天對留言規則做完變異測試,預告今天要加登出與登入。目前這個 App 用 Firebase 匿名登入,每支手機一個匿名 uid,只存在那支手機的登入狀態裡。今天把匿名帳號綁定 Google,讓登出、換手機之後可以回到同一個 uid。
先講結果:匿名帳號用 link 綁定 Google,uid 不變,既有的規則不用改;登出後用同一個 Google 帳號登入,回到原本的家庭,這在兩支手機上都實測過。Apple 登入不做。另外綁定只解決一半,還要新增一份對照,讓換手機之後找得到家庭。
這篇照實作的順序寫:先講為什麼這樣設計,再依序是 Firebase 與 Xcode 的設定、綁定、登出與登入、找回家庭、測試和實測。
匿名帳號沒有任何可以「再登入」的憑證,登出的那一刻,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)
}
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 都不用動。
這一節是照著做就能接通的步驟,順序不能亂,因為 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
沒發佈之前,補寫對照會被規則拒絕,但不影響其他功能,所以順序晚一點也不會壞掉。
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 搬家庭與卡片,規則也不允許。
登出只做三件事:結束 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:取消就留在引導畫面;登入成功就去找回家庭,找得到就進入首頁,找不到也留在引導畫面,讓使用者照原來的流程建立或加入家庭。
登出後再用 Google 登入,uid 回來了,但 App 還是不知道這個 uid 屬於哪個家庭。原因有兩個:
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) 讓這份對照只能指向自己已經是成員的家庭,所以不能偷指別人的家庭,也不會洩漏任何家庭資料。
有兩個實作上的細節:
isMember 看的是寫入之前的狀態,同一個 batch 裡成員文件還不存在,會被拒絕。所以建立或加入家庭成功後,另外再寫一次,失敗不影響主流程。找回家庭的流程是三步:讀自己的對照、直接 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 當作「不該被開放的路徑」來驗證。開放它會讓那個測試失敗,而既有測試不能為了讓新功能通過而修改。
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 測試就找不到按鈕,改放在內容容器上。
真實的登入畫面只能在實機上驗證。
在我的手機上,設定頁按「綁定 Google」,跳出 Google 的授權畫面。畫面只要求兩項資訊:名稱與個人資料相片、電子郵件地址。授權畫面上的應用程式名稱,顯示的是 Google Cloud 專案的預設名稱,這部分我還沒調整。
按「繼續」之後,帳號區改成已綁定,家庭和卡片都還在。接著登出,回到引導畫面,按「已經有帳號?用 Google 登入」,用同一個 Google 帳號登入,直接回到原本的首頁,不需要邀請碼。登出時手機上記住的家庭 id 已經清掉,所以這一步確實是靠 userHouseholds/{uid} 找回來的。
另一支手機也用同樣的步驟實測,結果一樣可以。這也確認了 GoogleSignIn 在免費 Personal Team 的實機上可以正常使用。
如果你照做,建議用兩個不同的 Google 帳號各綁一支手機。兩支手機綁同一個 Google 帳號,第二支預期會得到「這個 Google 帳號已經用在另一個帳號上」,這就是 .alreadyInUse 的情況。
| 項目 | 結果 |
|---|---|
| 匿名帳號綁定 Google | 兩支手機實測成功,uid 與資料不變 |
| 登出再登入 | 兩支手機實測成功,回到原本的家庭 |
| Apple 登入 | 不做(免費帳號) |
| 帳號合併、解除綁定、刪除帳號 | 刻意不做 |
| 授權畫面的應用程式名稱 | 還沒調整 |
verify.sh |
VERIFY: PASS(TeammateKit 59、規則 99、App 194) |
Day 26 在簽章到期前後各檢查一次,重新安裝之後確認兩支手機的登入狀態都還在。
官方文件
link 會失敗,合併要自己處理套件