您可以使用 Firebase Authentication 向使用者傳送簡訊,讓使用者登入。使用者使用簡訊訊息中包含的一次性代碼登入。
在應用程式中加入電話號碼登入功能最簡單的方法,就是使用 FirebaseUI,其中包含置入式登入小工具,可實作電話號碼登入的登入流程,以及密碼和聯合登入。本文說明如何使用 Firebase SDK 導入電話號碼登入流程。
事前準備
- 如果您尚未將應用程式連結至 Firebase 專案,請透過 Firebase 主控台連結。
-
使用 Swift Package Manager 安裝及管理 Firebase 依附元件。
- 在 Xcode 中保持開啟應用程式專案,然後依序點選「File」>「Add Packages」。
- 系統提示時,請新增 Firebase Apple 平台 SDK 存放區:
- 選擇 Firebase Authentication 程式庫。
- 將
-ObjC
標記新增至目標的建構設定「Other Linker Flags」部分。 - 完成後,Xcode 就會自動開始在背景中解析並下載依附元件。
https://github.com/firebase/firebase-ios-sdk.git
安全疑慮
雖然只使用電話號碼進行驗證很方便,但比起其他可用方法,安全性較低,因為電話號碼很容易在使用者之間轉移。此外,在有多個使用者設定檔的裝置上,任何可接收簡訊的使用者都可以使用裝置的電話號碼登入帳戶。
如果您在應用程式中使用電話號碼登入功能,應同時提供更安全的登入方式,並告知使用者使用電話號碼登入功能的安全性取捨。
為 Firebase 專案啟用電話號碼登入功能
如要透過簡訊登入使用者,您必須先為 Firebase 專案啟用電話號碼登入方法:
- 在 Firebase 主控台中,開啟「驗證」部分。
- 在「登入方式」頁面中,啟用「電話號碼」登入方式。
啟用驗證應用程式功能
如要使用電話號碼驗證功能,Firebase 必須能夠驗證電話號碼登入要求是否來自您的應用程式。Firebase Authentication 有兩種方式可達成這項要求:
- 靜默 APN 通知:當您在裝置上首次使用電話號碼登入使用者時,Firebase Authentication 會透過靜默推播通知將權杖傳送至裝置。如果應用程式成功收到 Firebase 的通知,即可繼續進行電話號碼登入程序。
針對 iOS 8.0 以上版本,靜默通知不需要明確的使用者同意聲明,因此不會受到使用者拒絕接收應用程式中的 APN 通知影響。因此,在導入 Firebase 電話號碼驗證功能時,應用程式不需要要求使用者授權才能接收推播通知。
- reCAPTCHA 驗證:如果無法傳送或接收靜默推播通知 (例如使用者已停用應用程式的背景重新整理功能,或是在 iOS 模擬器上測試應用程式時),Firebase Authentication 會使用 reCAPTCHA 驗證來完成手機登入流程。使用者通常不必回答任何問題,就能完成 reCAPTCHA 驗證。
在正確設定靜默推播通知後,只有極少數使用者會遇到 reCAPTCHA 流程。不過,無論是否提供靜默推播通知,您都應確保電話號碼登入功能正常運作。
開始接收靜音通知
如要啟用 APN 通知,以便與 Firebase Authentication 搭配使用,請按照下列步驟操作:
- 在 Xcode 中, 為專案啟用推播通知。
-
將 APNs 驗證金鑰上傳至 Firebase。如果您尚未取得 APN 驗證金鑰,請務必前往 Apple Developer Member Center 建立金鑰。
-
在 Firebase 控制台的專案中,依序選取齒輪圖示、「Project Settings」(專案設定),然後選取「Cloud Messaging」分頁。
-
在「iOS 應用程式設定」下方的「APNs 驗證金鑰」中,按一下「上傳」按鈕。
-
瀏覽至儲存金鑰的位置,選取金鑰,然後按一下「Open」。新增金鑰的金鑰 ID (可在 Apple Developer Member Center 中找到),然後按一下「上傳」。
如果您已擁有 APN 憑證,可以改為上傳憑證。
-
- 在 Xcode 中, 為專案啟用背景模式功能,然後選取「Background fetch」和「Remote notifications」模式的核取方塊。
設定 reCAPTCHA 驗證
如要讓 Firebase SDK 使用 reCAPTCHA 驗證功能,請按照下列步驟操作:
- 在 Xcode 專案中新增自訂網址配置:
- 選用:如果您想自訂應用程式向使用者顯示 reCAPTCHA 時呈現
SFSafariViewController
的方式,請建立符合AuthUIDelegate
通訊協定的自訂類別,並將其傳遞至verifyPhoneNumber(_:uiDelegate:completion:)
。
將驗證碼傳送至使用者的手機
如要啟動電話號碼登入功能,請向使用者顯示介面,提示他們提供電話號碼,然後呼叫 verifyPhoneNumber(_:uiDelegate:completion:)
,要求 Firebase 透過簡訊將驗證碼傳送至使用者的手機:
-
取得使用者的電話號碼。
法律規定各有不同,但為了提供最佳做法並為使用者設定預期,您應告知使用者,如果他們使用電話號碼登入,可能會收到驗證簡訊,並須支付一般簡訊費用。
- 呼叫
verifyPhoneNumber(_:uiDelegate:completion:)
,並將使用者的電話號碼傳遞給verifyPhoneNumber(_:uiDelegate:completion:)
。PhoneAuthProvider.provider() .verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationID, error in if let error = error { self.showMessagePrompt(error.localizedDescription) return } // Sign in using the verificationID and the code sent to the user // ... }
[[FIRPhoneAuthProvider provider] verifyPhoneNumber:userInput UIDelegate:nil completion:^(NSString * _Nullable verificationID, NSError * _Nullable error) { if (error) { [self showMessagePrompt:error.localizedDescription]; return; } // Sign in using the verificationID and the code sent to the user // ... }];
verifyPhoneNumber
方法是可重入的:如果您多次呼叫此方法 (例如在檢視畫面的onAppear
方法中),除非原始要求已逾時,否則verifyPhoneNumber
方法不會傳送第二次簡訊。當您呼叫
verifyPhoneNumber(_:uiDelegate:completion:)
時,Firebase 會向您的應用程式傳送靜默推送通知,或向使用者發出 reCAPTCHA 挑戰。應用程式收到通知或使用者完成 reCAPTCHA 挑戰後,Firebase 會傳送含有驗證碼的簡訊至指定的電話號碼,並將驗證 ID 傳遞至完成函式。您需要驗證碼和驗證 ID 才能讓使用者登入。您也可以透過 Auth 例項的
languageCode
屬性,指定 Firebase 傳送的簡訊語言,以便進行本地化。// Change language code to french. Auth.auth().languageCode = "fr";
// Change language code to french. [FIRAuth auth].languageCode = @"fr";
-
請儲存驗證 ID,並在應用程式載入時還原。這樣一來,如果應用程式在使用者完成登入流程前終止 (例如切換至 SMS 應用程式),您仍可確保擁有有效的驗證 ID。
您可以隨意保留驗證 ID。最簡單的方法是使用
NSUserDefaults
物件儲存驗證 ID:UserDefaults.standard.set(verificationID, forKey: "authVerificationID")
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; [defaults setObject:verificationID forKey:@"authVerificationID"];
接著,您可以還原已儲存的值:
let verificationID = UserDefaults.standard.string(forKey: "authVerificationID")
NSString *verificationID = [defaults stringForKey:@"authVerificationID"];
如果呼叫 verifyPhoneNumber(_:uiDelegate:completion:)
成功,您可以提示使用者在收到簡訊驗證碼時輸入驗證碼。
使用驗證碼登入使用者
使用者將簡訊中的驗證碼提供給應用程式後,請使用驗證碼和驗證 ID 建立 FIRPhoneAuthCredential
物件,然後將該物件傳遞至 signInWithCredential:completion:
,以便讓使用者登入。
- 向使用者取得驗證碼。
- 使用驗證碼和驗證 ID 建立
FIRPhoneAuthCredential
物件。let credential = PhoneAuthProvider.provider().credential( withVerificationID: verificationID, verificationCode: verificationCode )
FIRAuthCredential *credential = [[FIRPhoneAuthProvider provider] credentialWithVerificationID:verificationID verificationCode:userInput];
- 使用
FIRPhoneAuthCredential
物件登入使用者:Auth.auth().signIn(with: credential) { authResult, error in if let error = error { let authError = error as NSError if isMFAEnabled, authError.code == AuthErrorCode.secondFactorRequired.rawValue { // The user is a multi-factor user. Second factor challenge is required. let resolver = authError .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as! MultiFactorResolver var displayNameString = "" for tmpFactorInfo in resolver.hints { displayNameString += tmpFactorInfo.displayName ?? "" displayNameString += " " } self.showTextInputPrompt( withMessage: "Select factor to sign in\n\(displayNameString)", completionBlock: { userPressedOK, displayName in var selectedHint: PhoneMultiFactorInfo? for tmpFactorInfo in resolver.hints { if displayName == tmpFactorInfo.displayName { selectedHint = tmpFactorInfo as? PhoneMultiFactorInfo } } PhoneAuthProvider.provider() .verifyPhoneNumber(with: selectedHint!, uiDelegate: nil, multiFactorSession: resolver .session) { verificationID, error in if error != nil { print( "Multi factor start sign in failed. Error: \(error.debugDescription)" ) } else { self.showTextInputPrompt( withMessage: "Verification code for \(selectedHint?.displayName ?? "")", completionBlock: { userPressedOK, verificationCode in let credential: PhoneAuthCredential? = PhoneAuthProvider.provider() .credential(withVerificationID: verificationID!, verificationCode: verificationCode!) let assertion: MultiFactorAssertion? = PhoneMultiFactorGenerator .assertion(with: credential!) resolver.resolveSignIn(with: assertion!) { authResult, error in if error != nil { print( "Multi factor finanlize sign in failed. Error: \(error.debugDescription)" ) } else { self.navigationController?.popViewController(animated: true) } } } ) } } } ) } else { self.showMessagePrompt(error.localizedDescription) return } // ... return } // User is signed in // ... }
[[FIRAuth auth] signInWithCredential:credential completion:^(FIRAuthDataResult * _Nullable authResult, NSError * _Nullable error) { if (isMFAEnabled && error && error.code == FIRAuthErrorCodeSecondFactorRequired) { FIRMultiFactorResolver *resolver = error.userInfo[FIRAuthErrorUserInfoMultiFactorResolverKey]; NSMutableString *displayNameString = [NSMutableString string]; for (FIRMultiFactorInfo *tmpFactorInfo in resolver.hints) { [displayNameString appendString:tmpFactorInfo.displayName]; [displayNameString appendString:@" "]; } [self showTextInputPromptWithMessage:[NSString stringWithFormat:@"Select factor to sign in\n%@", displayNameString] completionBlock:^(BOOL userPressedOK, NSString *_Nullable displayName) { FIRPhoneMultiFactorInfo* selectedHint; for (FIRMultiFactorInfo *tmpFactorInfo in resolver.hints) { if ([displayName isEqualToString:tmpFactorInfo.displayName]) { selectedHint = (FIRPhoneMultiFactorInfo *)tmpFactorInfo; } } [FIRPhoneAuthProvider.provider verifyPhoneNumberWithMultiFactorInfo:selectedHint UIDelegate:nil multiFactorSession:resolver.session completion:^(NSString * _Nullable verificationID, NSError * _Nullable error) { if (error) { [self showMessagePrompt:error.localizedDescription]; } else { [self showTextInputPromptWithMessage:[NSString stringWithFormat:@"Verification code for %@", selectedHint.displayName] completionBlock:^(BOOL userPressedOK, NSString *_Nullable verificationCode) { FIRPhoneAuthCredential *credential = [[FIRPhoneAuthProvider provider] credentialWithVerificationID:verificationID verificationCode:verificationCode]; FIRMultiFactorAssertion *assertion = [FIRPhoneMultiFactorGenerator assertionWithCredential:credential]; [resolver resolveSignInWithAssertion:assertion completion:^(FIRAuthDataResult * _Nullable authResult, NSError * _Nullable error) { if (error) { [self showMessagePrompt:error.localizedDescription]; } else { NSLog(@"Multi factor finanlize sign in succeeded."); } }]; }]; } }]; }]; } else if (error) { // ... return; } // User successfully signed in. Get user data from the FIRUser object if (authResult == nil) { return; } FIRUser *user = authResult.user; // ... }];
使用虛構的電話號碼進行測試
您可以透過 Firebase 控制台設定虛構的電話號碼,用於開發作業。使用虛構的電話號碼進行測試可帶來以下好處:
- 在不消耗使用配額的情況下測試電話號碼驗證。
- 在不傳送實際簡訊的情況下,測試電話號碼驗證機制。
- 使用相同的電話號碼連續執行測試,且不會受到頻寬限制。這樣一來,如果審查人員剛好使用相同的電話號碼進行測試,就不會在應用程式商店審查程序中遭到拒絕。
- 無需額外付出任何努力,即可在開發環境中輕鬆測試,例如在 iOS 模擬器或 Android 模擬器中開發,而無需 Google Play 服務。
- 編寫整合測試時,不會受到實際電話號碼在實際環境中通常會受到的安全檢查阻擋。
虛構的電話號碼必須符合下列規定:
- 請務必使用虛構的電話號碼,且該號碼不應已存在。Firebase Authentication 不允許您將真實使用者使用的現有電話號碼設為測試號碼。您可以使用開頭為 555 的號碼做為美國測試電話號碼,例如: +1 650-555-3434
- 電話號碼的長度和其他限制條件必須符合正確的格式。這類號碼仍須通過與真實使用者電話號碼相同的驗證程序。
- 您最多可以新增 10 個開發用電話號碼。
- 請使用不易猜測且經常變更的測試電話號碼/驗證碼。
建立虛構的電話號碼和驗證碼
- 在 Firebase 主控台中,開啟「驗證」部分。
- 在「登入方式」分頁中,啟用電話服務供應商 (如果尚未啟用的話)。
- 開啟「測試用電話號碼」摺疊式選單。
- 提供要測試的電話號碼,例如:+1 650-555-3434。
- 請提供該特定號碼的 6 位數驗證碼,例如:654321。
- 新增電話號碼。如有需要,您可以將滑鼠游標懸停在對應的資料列上,然後按一下垃圾桶圖示,即可刪除電話號碼和代碼。
手動測試
您可以在應用程式中直接開始使用虛構的電話號碼。這樣一來,您就能在開發階段執行手動測試,而不必擔心會發生配額問題或頻寬限制。您也可以直接透過未安裝 Google Play 服務的 iOS 模擬器或 Android 模擬器進行測試。
當您提供虛構的電話號碼並傳送驗證碼時,系統不會傳送實際的簡訊。您必須提供先前設定的驗證碼,才能完成登入程序。
登入完成後,系統會使用該電話號碼建立 Firebase 使用者。使用者具有與實際電話號碼使用者相同的行為和屬性,且可以相同方式存取 Realtime Database/Cloud Firestore 和其他服務。這個程序中產生的 ID 權杖,其簽章與真實電話號碼使用者相同。
如果您想進一步限制存取權,另一個做法是透過自訂聲明設定測試角色,將這些使用者視為假使用者。
整合測試
除了手動測試之外,Firebase Authentication 也提供 API,協助您為電話驗證測試編寫整合測試。這些 API 會停用網頁版 reCAPTCHA 和 iOS 版靜默推播通知的規定,進而停用應用程式驗證功能。這樣一來,您就能在這些流程中進行自動化測試,並更輕鬆地實作。此外,這些測試還可協助您在 Android 上測試即時驗證流程。
在 iOS 上,必須先將 appVerificationDisabledForTesting
設定設為 TRUE
,才能呼叫 verifyPhoneNumber
。這項作業不需要任何 APN 權杖,也不需要在背景傳送靜默推播通知,因此更容易在模擬器中進行測試。這麼做也會停用 reCAPTCHA 備用流程。
請注意,如果停用應用程式驗證功能,使用非虛構的電話號碼就無法完成登入程序。這個 API 只能搭配虛構的電話號碼使用。
let phoneNumber = "+16505554567" // This test verification code is specified for the given test phone number in the developer console. let testVerificationCode = "123456" Auth.auth().settings.isAppVerificationDisabledForTesting = TRUE PhoneAuthProvider.provider().verifyPhoneNumber(phoneNumber, uiDelegate:nil) { verificationID, error in if (error) { // Handles error self.handleError(error) return } let credential = PhoneAuthProvider.provider().credential(withVerificationID: verificationID ?? "", verificationCode: testVerificationCode) Auth.auth().signInAndRetrieveData(with: credential) { authData, error in if (error) { // Handles error self.handleError(error) return } _user = authData.user }]; }];
NSString *phoneNumber = @"+16505554567"; // This test verification code is specified for the given test phone number in the developer console. NSString *testVerificationCode = @"123456"; [FIRAuth auth].settings.appVerificationDisabledForTesting = YES; [[FIRPhoneAuthProvider provider] verifyPhoneNumber:phoneNumber completion:^(NSString *_Nullable verificationID, NSError *_Nullable error) { if (error) { // Handles error [self handleError:error]; return; } FIRAuthCredential *credential = [FIRPhoneAuthProvider credentialWithVerificationID:verificationID verificationCode:testVerificationCode]; [FIRAuth auth] signInWithAndRetrieveDataWithCredential:credential completion:^(FIRUser *_Nullable user, NSError *_Nullable error) { if (error) { // Handles error [self handleError:error]; return; } _user = user; }]; }];
附錄:不使用 swizzling 的手機登入功能
Firebase Authentication 會使用方法混合功能自動取得應用程式的 APN 權杖,以便處理 Firebase 傳送至應用程式的靜默推播通知,並在驗證期間自動攔截來自 reCAPTCHA 驗證頁面的自訂配置轉送。
如果您不想使用 swizzling,請將標記 FirebaseAppDelegateProxyEnabled
新增至應用程式的 Info.plist 檔案,並將其設為 NO
,即可停用 swizzling。請注意,將這個標記設為 NO
也會停用其他 Firebase 產品 (包括 Firebase Cloud Messaging) 的 swizzling 功能。
如果停用 swizzling,您必須明確將 APNs 裝置驗證碼、推播通知和自訂配置文件重新導向網址傳遞至 Firebase Authentication。
如果您要建構 SwiftUI 應用程式,也應明確將 APNs 裝置 Token、推播通知和自訂配置重新導向網址傳送至 Firebase Authentication。
如要取得 APN 裝置符記,請實作 application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
方法,並在其中將裝置符記傳遞至 Auth
的 setAPNSToken(_:type:)
方法。
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { // Pass device token to auth Auth.auth().setAPNSToken(deviceToken, type: .unknown) // Further handling of the device token if needed by the app // ... }
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { // Pass device token to auth. [[FIRAuth auth] setAPNSToken:deviceToken type:FIRAuthAPNSTokenTypeProd]; // Further handling of the device token if needed by the app. }
如要處理推播通知,請在 application(_:didReceiveRemoteNotification:fetchCompletionHandler:):
方法中呼叫 Auth
的 canHandleNotification(_:)
方法,檢查 Firebase 驗證相關通知。
func application(_ application: UIApplication, didReceiveRemoteNotification notification: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { if Auth.auth().canHandleNotification(notification) { completionHandler(.noData) return } // This notification is not auth related; it should be handled separately. }
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)notification fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler { // Pass notification to auth and check if they can handle it. if ([[FIRAuth auth] canHandleNotification:notification]) { completionHandler(UIBackgroundFetchResultNoData); return; } // This notification is not auth related; it should be handled separately. }
如要處理自訂配置重新導向網址,請實作 application(_:open:options:)
方法,並在其中將網址傳遞至 Auth
的 canHandleURL(_:)
方法。
func application(_ application: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any]) -> Bool { if Auth.auth().canHandle(url) { return true } // URL not auth related; it should be handled separately. }
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { if ([[FIRAuth auth] canHandleURL:url]) { return YES; } // URL not auth related; it should be handled separately. }
如果您使用的是 SwiftUI 或 UISceneDelegate
,請實作 scene(_:openURLContexts:)
方法,並在其中將網址傳遞至 Auth
的 canHandleURL(_:)
方法。
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { for urlContext in URLContexts { let url = urlContext.url _ = Auth.auth().canHandle(url) } // URL not auth related; it should be handled separately. }
- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts { for (UIOpenURLContext *urlContext in URLContexts) { [FIRAuth.auth canHandleURL:urlContext.url]; // URL not auth related; it should be handled separately. } }
後續步驟
使用者首次登入後,系統會建立新使用者帳戶,並連結至使用者登入時所用的憑證 (即使用者名稱和密碼、電話號碼或驗證服務提供者資訊)。這個新帳戶會儲存在 Firebase 專案中,無論使用者如何登入,都可以用於在專案中的每個應用程式中識別使用者。
在 Firebase Realtime Database 和 Cloud Storage 安全性規則中,您可以從
auth
變數取得已登入使用者的專屬使用者 ID,並利用該 ID 控管使用者可存取的資料。
您可以將驗證服務供應商憑證連結至現有使用者帳戶,讓使用者使用多個驗證服務供應商登入應用程式。
如要讓使用者登出,請呼叫
signOut:
。
let firebaseAuth = Auth.auth() do { try firebaseAuth.signOut() } catch let signOutError as NSError { print("Error signing out: %@", signOutError) }
NSError *signOutError; BOOL status = [[FIRAuth auth] signOut:&signOutError]; if (!status) { NSLog(@"Error signing out: %@", signOutError); return; }
您可能還想為各種驗證錯誤新增錯誤處理程式碼。請參閱「處理錯誤」。