使用电话号码进行 Firebase 身份验证(Apple 平台)

使用 Firebase 身份验证,您可以向用户的电话发送短信协助其登录,用户使用短信中包含的一次性验证码即可登录。

如需向应用中添加电话号码登录方式,最简单的方法就是使用 FirebaseUI。该库中包含一个普适性登录 widget,可为电话号码登录、基于密码登录和联合登录实现登录流程。本文档介绍了如何使用 Firebase SDK 实现电话号码登录流程。

准备工作

使用 Swift Package Manager 安装和管理 Firebase 依赖项。

  1. 在 Xcode 中打开您的应用项目,依次点击 File(文件)> Add Packages(添加软件包)
  2. 出现提示时,添加 Firebase Apple 平台 SDK 代码库:
  3.   https://github.com/firebase/firebase-ios-sdk
  4. 选择 Firebase Authentication 库。
  5. 完成之后,Xcode 将会自动开始在后台解析和下载您的依赖项。
此外,请检查配置步骤:
  1. 如果您尚未将您的应用与 Firebase 项目关联,请在 Firebase 控制台中进行关联。

安全考量

与其他可用的方法相比,仅使用电话号码进行身份验证的方法虽然便捷,但安全性较低,因为电话号码的所有权可以很容易地在用户之间转移。此外,在具有多份用户个人资料的设备上,任何一位可以接收短信的用户都能使用该设备的电话号码登录帐号。

如果您选择在应用中使用电话号码登录方法,应同时提供更安全的登录方法,并将使用电话号码登录的安全隐患告知用户。

为 Firebase 项目启用电话号码登录方法

如需让用户能够通过短信登录,您必须先为 Firebase 项目启用电话号码登录方法,步骤如下:

  1. Firebase 控制台中,打开 Authentication 部分。
  2. Sign-in Method(登录方法)页面上,启用电话号码登录方法。

Firebase 的电话号码登录请求的配额非常充足,大多数应用都不会遇到配额问题。但是,如果有大量用户需要通过电话号码身份验证方法进行登录,您可能需要升级定价方案。请参阅价格页面。

启用应用验证

如需使用电话号码身份验证,Firebase 必须要能验证电话号码登录请求是否来自您的应用。Firebase Authentication 可通过以下两种方法完成此操作:

  • 静默 APN 通知:用户首次通过电话号码在设备上登录时,Firebase Authentication 会通过一条静默推送通知向设备发送令牌。如果您的应用成功收到 Firebase 的通知,就可以继续使用电话号码进行登录。

    对于 iOS 8.0 及更高版本,静默通知不需要用户明确同意,因此,即使用户拒绝在应用中接收 APNs 通知也不受影响。如此一来,在实现 Firebase 电话号码身份验证时,应用不需要请求用户授予权限即可接收推送通知。

  • reCAPTCHA 验证:如果无法发送或接收静默推送通知(例如,如果用户停用了应用的后台刷新,或者当您在 iOS 模拟器上测试应用时),Firebase Authentication 会使用 reCAPTCHA 验证来完成电话登录流程。reCAPTCHA 验证通常可以在不需要用户回答任何问题的情况下完成。

如果正确配置了静默推送通知,只有极少数的用户会经历 reCAPTCHA 流程。尽管如此,您应该确保无论静默推送通知是否可用,电话号码登录功能都可正常工作。

开始接收静默通知

如需启用 APNs 通知以用于 Firebase Authentication,请执行以下操作:

  1. 在 Xcode 中为您的项目启用推送通知
  2. 将您的 APNs 身份验证密钥上传到 Firebase。 如果您还没有 APNs 身份验证密钥,请务必在 Apple Developer Member Center 内创建一个。

    1. 在 Firebase 控制台中,在您的项目内依次选择齿轮图标 > 项目设置 > Cloud Messaging 标签页。

    2. iOS 应用配置下的 APNs 身份验证密钥中,点击上传按钮。

    3. 转到您保存密钥的位置,选择该密钥,然后点击打开。添加该密钥的 ID(可在 Apple Developer Member Center 中找到),然后点击上传

    如果您已有 APNs 证书,则可以改为上传证书。

设置 reCAPTCHA 验证

如需让 Firebase SDK 能使用 reCAPTCHA 验证,请执行以下操作:

  1. 向您的 Xcode 项目添加自定义网址方案 (URL scheme):
    1. 打开您的项目配置:在左侧的树状视图中双击项目名称。在目标部分中选择您的应用,然后选择信息标签页,并展开网址类型部分。
    2. 点击 + 按钮,然后将经过编码的应用 ID 添加为网址方案。您可以打开 Firebase 控制台的常规设置页面,在您的 iOS 应用部分找到经过编码的应用 ID。请将其他字段留空。

      完成上述操作后,您的配置应显示如下(但其中的值应替换为您的应用的值):

      Xcode 自定义网址方案设置界面的屏幕截图
  2. 可选:如果您希望自定义应用在向用户显示 reCAPTCHA 时如何呈现 SFSafariViewController,请创建一个符合 AuthUIDelegate 协议的自定义类,并将其传递给 verifyPhoneNumber(_:uiDelegate:completion:)

向用户的电话发送验证码

如需启动电话号码登录,请向用户显示一个提示其输入电话号码的界面,然后调用 verifyPhoneNumber(_:uiDelegate:completion:) 以请求 Firebase 通过短信向用户电话发送身份验证码:

  1. 获取用户的电话号码。

    虽然相关的法律要求可能不尽相同,但为了避免用户不满,最佳做法是告知用户,如果他们选择使用电话号码登录方式,则可能会收到一条验证短信,并需按标准费率支付短信费用。

  2. 调用 verifyPhoneNumber(_:uiDelegate:completion:),并向其传递用户的电话号码。

    Swift

    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
          // ...
      }

    Objective-C

    [[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 发送的短信进行本地化。

    Swift

     // Change language code to french.
     Auth.auth().languageCode = "fr";
    

    Objective-C

     // Change language code to french.
     [FIRAuth auth].languageCode = @"fr";
    
  3. 保存验证 ID,并在应用加载时将其还原。这样可确保即使您的应用在用户完成登录流程之前被终止(例如,用户切换到短信应用时),您仍然保留有效的验证 ID。

    您可以用任何方式留存验证 ID。一个简单的方法是使用 NSUserDefaults 对象保存验证 ID:

    Swift

    UserDefaults.standard.set(verificationID, forKey: "authVerificationID")
    

    Objective-C

    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    [defaults setObject:verificationID forKey:@"authVerificationID"];
    

    然后,您可以还原保存的值:

    Swift

    let verificationID = UserDefaults.standard.string(forKey: "authVerificationID")
    

    Objective-C

    NSString *verificationID = [defaults stringForKey:@"authVerificationID"];
    

如果成功调用 verifyPhoneNumber(_:uiDelegate:completion:),那么您可以在用户收到短信验证码时提示用户输入验证码。

使用验证码让用户登录

用户将短信中的验证码提供给您的应用之后,系统会根据验证码和验证 ID 创建一个 FIRPhoneAuthCredential 对象并将此对象传递给 signInWithCredential:completion:,以便让用户登录。

  1. 从用户处获取验证码。
  2. 根据验证码和验证 ID 创建 FIRPhoneAuthCredential 对象。

    Swift

    let credential = PhoneAuthProvider.provider().credential(
      withVerificationID: verificationID,
      verificationCode: verificationCode
    )

    Objective-C

    FIRAuthCredential *credential = [[FIRPhoneAuthProvider provider]
        credentialWithVerificationID:verificationID
                    verificationCode:userInput];
  3. 使用 FIRPhoneAuthCredential 对象让用户登录:

    Swift

    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
        // ...
    }

    Objective-C

    [[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 控制台设置虚构电话号码以用于开发。使用虚构的电话号码进行测试具有以下优势:

  • 测试电话号码身份验证时不会占用使用量配额。
  • 测试电话号码身份验证时无需发送实际的短信。
  • 可使用同一电话号码运行连续测试,而不会受到短信发送数量的限制。如果审核人员恰好使用相同的电话号码进行测试,这项优势会使在 App Store 审核过程中遭拒的风险降至最低。
  • 无需其他操作即可在开发环境中轻松进行测试,例如可以在 iOS 模拟器或没有 Google Play 服务的 Android 模拟器中进行开发。
  • 可编写集成测试,而不会被通常在生产环境中应用于真实电话号码的安全检查所屏蔽。

虚构的电话号码必须满足以下要求:

  1. 请确保使用确属虚构且不存在的电话号码。Firebase Authentication 不允许您将真实用户使用的现有电话号码设为测试号码。您可以使用以 555 为前缀的数字作为美国测试电话号码,例如:+1 650-555-3434
  2. 电话号码必须采用正确的格式,以符合长度要求和其他限制。这些电话号码仍将与真实用户的电话号码一样经过相同的验证。
  3. 您最多可以添加 10 个电话号码用于开发。
  4. 使用难以猜到的测试电话号码/验证码,并经常更换。

创建虚构的电话号码和验证码

  1. Firebase 控制台中,打开 Authentication 部分。
  2. Sign in method(登录方法)标签页中,启用电话号码提供方(如果您尚未启用)。
  3. 打开用于测试的电话号码折叠菜单。
  4. 提供您想要用于测试的电话号码,例如 +1 650-555-3434
  5. 为该特定号码提供 6 位验证码,例如 654321
  6. 添加该号码。如有需要,您可以删除电话号码及其验证码,只需将鼠标悬停在相应的行上并点击垃圾桶图标即可。

手动测试

您可以直接在应用中开始使用虚构的电话号码。这样一来,您就可以在开发阶段执行手动测试,而不会遇到配额问题或受到限制。您也可以直接通过 iOS 模拟器或未安装 Google Play 服务的 Android 模拟器进行测试。

当您提供虚构电话号码并发送验证码时,系统实际上不会发送短信。作为替代,您需要提供事先配置的验证码来完成登录。

完成登录后,系统会使用该电话号码创建一位 Firebase 用户。该用户的行为和属性与真实电话号码用户相同,并且可通过同样的方式使用 Realtime Database/Cloud Firestore 和其他服务。在此过程中生成的 ID 令牌与真实电话号码用户的令牌具有相同的签名。

您还可以利用自定义声明为此类用户设置测试角色,以将其作为虚构用户区分开来(如果您想进一步限制其访问权限)。

集成测试

除了手动测试外,Firebase Authentication 还提供了 API,帮助您编写用于进行电话号码身份验证测试的集成测试。这些 API 通过停用 reCAPTCHA 要求(在 Web 应用中)和静默推送通知(在 iOS 应用中)来停用应用验证。因此,您可以在这些流程中进行自动测试,并且实现起来更加容易。此外,借助这些 API,您还可以在 Android 上测试即时验证流程。

对于 iOS 应用,您必须在调用 verifyPhoneNumber 之前将 appVerificationDisabledForTesting 设置设为 TRUE。这样做不需要任何 APNs 令牌,也不需要在后台发送静默推送通知,因此更便于您在模拟器中进行测试。此外,这也会停用 reCAPTCHA 后备流程。

请注意,停用应用验证后,使用非虚构电话号码将无法完成登录。此 API 只能使用虚构的电话号码。

Swift

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
    }];
}];

Objective-C

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;
    }];
}];

附录:使用电话号码登录而不调配

Firebase Authentication 使用方法调配 (Method Swizzling) 自动获取您的应用的 APNs 令牌,处理 Firebase 发送给您的应用的静默推送通知,并自动拦截在验证期间来自 reCAPTCHA 验证页面的自定义架构重定向。

如果您不希望使用调配,则可以通过向应用的 Info.plist 文件添加 FirebaseAppDelegateProxyEnabled 标志并将其设置为 NO 来将其停用。请注意,将此标志设置为 NO 也会为其他 Firebase 产品(包括 Firebase 云消息传递)停用调配。

如果停用调配,您必须明确传递 APNs 设备令牌、推送通知和自定义架构重定向网址给 Firebase Authentication。

如果您是在构建 SwiftUI 应用,则也应明确传递 APNs 设备令牌、推送通知和自定义架构重定向网址给 Firebase Authentication。

要获取 APNs 设备令牌,请实现 application(_:didRegisterForRemoteNotificationsWithDeviceToken:) 方法,并在其中将设备令牌传递给 AuthsetAPNSToken(_:type:) 方法。

Swift

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  // Pass device token to auth
  Auth.auth().setAPNSToken(deviceToken, type: .prod)

  // Further handling of the device token if needed by the app
  // ...
}

Objective-C

- (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:): 方法中,通过调用 AuthcanHandleNotification(_:) 方法检查 Firebase 身份验证相关的通知。

Swift

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.
}

Objective-C

- (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:) 方法,并在其中将该网址传递给 AuthcanHandleURL(_:) 方法。

Swift

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.
}

Objective-C

- (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:) 方法,并在其中将该网址传递给 AuthcanHandleURL(_:) 方法。

Swift

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.
}

Objective-C

- (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 项目中,无论用户采用何种方式登录,您项目中的每个应用都可以使用此帐号来识别用户。

  • 在您的应用中,您可以从 User 对象获取用户的基本个人资料信息。请参阅管理用户

  • 在您的 Firebase Realtime Database 和 Cloud Storage 安全规则中,您可以从 auth 变量获取已登录用户的唯一用户 ID,然后利用此 ID 来控制用户可以访问哪些数据。

您可以将多个身份验证提供方凭据与一个现有用户帐号关联,让用户可以使用多个身份验证提供方登录您的应用。

如需将用户退出登录,请调用 signOut:

Swift

let firebaseAuth = Auth.auth()
do {
  try firebaseAuth.signOut()
} catch let signOutError as NSError {
  print("Error signing out: %@", signOutError)
}

Objective-C

NSError *signOutError;
BOOL status = [[FIRAuth auth] signOut:&signOutError];
if (!status) {
  NSLog(@"Error signing out: %@", signOutError);
  return;
}

您可能还需要为所有身份验证错误添加错误处理代码。请参阅处理错误