PAYATHON 2026

Flutter Firebase Auth:用自定义 email OTP 登录 Apple 账户

排障赵师傅

结论

仅靠 Flutter 客户端和 Firebase Console 配置,无法把自定义 OTP 的验证结果转换为现有 Apple 账户的 Firebase 登录态。

自定义 OTP 不是 Firebase Auth 支持的登录凭据。即使应用已经验证邮箱,Firebase Auth 也不会允许客户端指定某个 uid,或直接登录使用同一邮箱的账户。

如果既要保留自定义 OTP,又要用它登录已有的 Apple 账户,就需要由可信服务端验证 OTP、查找目标用户,再调用 Admin SDK 生成 custom token。完全无法增加服务端时,可以考虑以下方案:

  • 让老用户重新完成一次 Apple Sign-In,再通过 linkWithCredential() 关联新的登录方式。
  • 改用 Firebase Authentication 原生支持的 email link 等登录方式。
  • 让自定义 OTP 和已有 Apple 账户保持为两套独立身份,但这样通常会产生数据归属问题。

现有流程为什么无法自动合并账户

signInWithEmailAndPassword() 验证的是 Email/Password provider 的密码凭据。Apple 创建的账户如果没有关联 password provider,就没有可供验证的密码,登录自然会失败。

createUserWithEmailAndPassword() 只能创建新用户,无法把密码凭据附加到另一个尚未登录的账户。Firebase 发现邮箱已被使用时,会返回 email-already-in-use。

所以下面的处理方式在逻辑上有问题:

try {
  cred = await FirebaseAuth.instance.signInWithEmailAndPassword(
    email: email,
    password: derivedPassword,
  );
} on FirebaseAuthException catch (e) {
  if (e.code == 'user-not-found' || e.code == 'invalid-credential') {
    cred = await FirebaseAuth.instance.createUserWithEmailAndPassword(
      email: email,
      password: derivedPassword,
    );
  } else {
    rethrow;
  }
}

invalid-credential 不等于“用户不存在”。启用 Email enumeration protection 后,Firebase 还可能故意返回较模糊的错误,防止客户端判断某个邮箱是否已经注册。收到这个错误后直接创建用户,无法解决 provider 冲突,还可能掩盖密码错误等其他情况。

客户端更合适的做法是停止自动创建账户,引导用户改用原来的登录方式:

try {
  final credential =
      await FirebaseAuth.instance.signInWithEmailAndPassword(
    email: email,
    password: derivedPassword,
  );

  return credential;
} on FirebaseAuthException catch (e) {
  if (e.code == 'invalid-credential' ||
      e.code == 'email-already-in-use') {
    throw StateError(
      '该邮箱可能已绑定其他登录方式,请先使用 Apple 或 Google 登录。',
    );
  }

  rethrow;
}

Console 中的账户关联设置为何无效

“Link accounts that use the same email”只对 Firebase 能够识别并验证的登录 provider 生效。应用自行生成的 6 位验证码不会因此成为 Firebase credential。

Firebase 客户端 SDK 也没有“邮箱已由应用验证,请登录这个 uid”之类的接口。如果允许客户端这样操作,任何修改客户端代码或伪造请求的人都能声称自己验证了任意邮箱。

账户关联通常需要满足以下任一条件:

  1. 用户已经登录,然后调用 linkWithCredential()。
  2. 用户通过 Firebase 支持的 provider 完成登录,由 Firebase 按账户关联规则处理冲突。
  3. 可信服务端确认用户身份,并为指定 uid 签发 custom token。

自定义 SMTP OTP 不满足前两个条件。

可行的服务端流程

服务端需要负责:

  1. 生成、保存和发送 OTP。
  2. 校验 OTP 的有效期、使用次数与尝试次数。
  3. 使用规范化后的邮箱查找 Firebase 用户。
  4. 如果找到已有的 Apple 用户,使用该用户的 uid 生成 custom token。
  5. 由客户端调用 signInWithCustomToken()。

下面的 Node.js 代码只包含主要流程。verifyOtpOrThrow()、限流和 OTP 存储仍需自行实现:

import admin from 'firebase-admin';

admin.initializeApp();

async function completeEmailOtp(email, otp) {
  const normalizedEmail = email.trim().toLowerCase();

  await verifyOtpOrThrow(normalizedEmail, otp);

  let user;

  try {
    user = await admin.auth().getUserByEmail(normalizedEmail);
  } catch (error) {
    if (error.code !== 'auth/user-not-found') {
      throw error;
    }

    user = await admin.auth().createUser({
      email: normalizedEmail,
      emailVerified: true,
    });
  }

  return admin.auth().createCustomToken(user.uid);
}

Flutter 客户端拿到 token 后即可登录:

final result = await FirebaseAuth.instance.signInWithCustomToken(
  customToken,
);

final user = result.user;

custom token 必须由 Admin SDK 在可信环境中生成。Service Account 私钥、SMTP 密码和 Admin SDK 权限都不能放进 Flutter 应用。

暂时无法增加服务端时怎么办

可以让用户再次完成 Apple Sign-In,然后在已有登录态中关联 Email/Password credential:

final user = FirebaseAuth.instance.currentUser;

if (user == null) {
  throw StateError('User must sign in with Apple first.');
}

final emailCredential = EmailAuthProvider.credential(
  email: email,
  password: derivedPassword,
);

await user.linkWithCredential(emailCredential);

这种方式适用于之后愿意重新使用 Apple 或 Google 登录的用户,但无法迁移只愿意使用自定义 OTP 的存量账户。客户端在未登录状态下,无权把 credential 关联到指定用户。

如果产品要求这些存量用户无需再次使用 Apple 登录,就必须增加可信服务端,或者改用 Firebase 原生支持的无密码登录方式。

不要依赖 fetchSignInMethodsForEmail()

fetchSignInMethodsForEmail() 不适合用来判断是否应该创建账户。启用 Email enumeration protection 后,Firebase 会限制客户端查询账户是否存在及其登录方式。即使返回空列表,也不能证明用户不存在。该 API 已被弃用,也不适合作为账户迁移流程的基础。

账户是否存在、对应哪个 uid,应由有权限的服务端查询。客户端不应执行“登录失败后立即创建账户”的逻辑。

Apple 邮箱的特殊情况

Apple 用户可能启用了 Hide My Email。Firebase 中保存的是 privaterelay.appleid.com 中继地址,而用户在 OTP 页面输入的可能是真实邮箱。由于两个地址不同,服务端通过 getUserByEmail() 也无法自动判断它们是否属于同一个人。

设计账户体系时,最好保存并使用稳定的 Firebase uid 和 Apple provider 标识,不要把邮箱视为唯一且永久不变的身份标识。Apple 只会在有限的时机返回邮箱,应用需要在首次授权时保存所需的账户映射。

固定派生密码的安全问题

按照固定规则生成 derivedPassword,实际是在模拟 Email/Password 登录,OTP 并不会因此成为 Firebase 的认证因子。如果攻击者能从安装包中提取派生算法或客户端密钥,就可能绕过 OTP,直接计算密码并调用 Firebase Auth。

自定义 OTP 验证成功后,Firebase 用户的 emailVerified 也不会自动更新。如果安全规则或业务逻辑依赖这个字段,需要由可信服务端在验证完成后明确更新。

如果保留自定义 OTP,建议让服务端直接通过 custom token 建立登录态,不再把固定派生密码作为中间凭据。

备注:内容仅供参考。