照着做: 给 Next.js 加 FastAPI 的 App 接上 Firebase 的 Google 登录¶
🌐 AI ReaderRead this document in English or ask questions: Open in Claude, ChatGPT, or Gemini ↗
这篇和 用 Firebase Authentication 接第三方登录: 后端只需要验证一种 token 讲的是同一个 App: 前端 Next.js, 后端 Python FastAPI, 都部署在 Vercel 上. 那篇讲为什么用 Firebase, 这篇是我照着配了一遍的记录, 以后要再配一次, 从头照着做就行. 当时的代码在我的一个私有仓库里, 下面的代码都是从那里整理出来的, 只有按 email 关联老账号那一段, 我直接写成了修好之后的版本 (原因见 按 email 关联老账号之前, 先确认这个邮箱真是他的).
两个关键决定先说在前面. 第一, 邮箱密码登录我自己管, 用的是 fastapi-users 加自己的用户表, Firebase 只负责 Google 这类第三方登录. 第二, 后端验证完 Firebase ID Token 之后, 换发我自己的 JWT, 之后的请求只认自己的 JWT, 不再碰 Firebase 的 token. 这两个决定定了, 后面每一步为什么这么做就都顺了.
整体做法: 邮箱密码自己管, Firebase 只接第三方, 后端换发自己的 JWT¶
接 Google 登录之前, 这个 App 已经有一整套邮箱密码的登录体系: fastapi-users 负责注册, 登录, 发验证邮件, 重置密码; 登录成功后发一个短命的 JWT access token, 再加一个放在 HttpOnly cookie 里的 refresh token. 这套东西已经能用了, 我不想为了加一个 Google 登录把它拆掉重来.
所以 Firebase 在这里只干一件事: 帮我完成 Google 那边的 OAuth, 交给我一个能证明 "这个人是谁" 的 Firebase ID Token. 后端拿到它, 验证, 在自己的用户表里找到或建出这个用户, 然后发一套和密码登录一模一样的 JWT 加 refresh token. 说白了, Firebase ID Token 只用一次, 是一张换票凭证, 换到的是我自己系统的门票:
sequenceDiagram
participant U as 用户
participant FE as Next.js 前端
participant FA as Firebase Auth
participant BE as FastAPI 后端
U->>FE: 点 "Sign in with Google"
FE->>FA: signInWithPopup (Google)
FA-->>FE: Firebase ID Token
FE->>BE: POST /api/auth/firebase {id_token}
BE->>BE: 验 Firebase ID Token
BE->>BE: 按 firebase_uid 或 email 找用户, 找不到就建
BE-->>FE: 自己的 JWT access token + refresh token cookie
FE->>BE: 之后的请求都带自己的 JWT
这么做的好处是, 不管用户是用密码登录还是用 Google 登录, 进了门之后就是同一种身份, 同一套过期和刷新逻辑, 业务接口完全不用区分. 代价也很明确: 同一个人现在可能有两个来源 (密码和 Google), 两者要不要合并成一个账号, 得我的后端自己判断. 这一步做错了就有安全问题, 后面单独一节讲.
在 Firebase Console 点五步, 拿到前端配置和服务账号¶
这五步我都是在 Firebase Console 里亲手点的. 菜单名以 2026-11 时的英文界面为准, 以后界面改了, 照着意思找就行.
第一步, 建项目. 点 "Create a project", 输入项目名. 项目名只是给人看的, 系统会据此生成一个项目 ID, 项目 ID 才是唯一标识, 建好之后不能改, 前后端都要用到它. Google Analytics 开不开都不影响登录.
第二步, 启用 Google 登录. 左边栏 "Build" → "Authentication", 点 "Get started", 进 "Sign-in method", 选 "Google", 打开 "Enable". 这里要填两样: 项目的公开名称 (用户在 Google 授权页上看到的应用名) 和支持邮箱. 保存. 这一步之所以这么简单, 是因为 Firebase 在背后替你建好了 Google 的 OAuth 客户端, 回调地址指向 Firebase 自己的服务器, 你不用去 Google Cloud Console 自己配.
第三步, 注册 Web 应用, 拿前端配置. 回到 "Project Overview", 点 </> 图标加一个 Web 应用, 随便起个昵称. 页面上会出现一个 firebaseConfig 对象, 里面是 apiKey, authDomain, projectId, storageBucket, messagingSenderId, appId. 把这几个值记下来, 前端要用. "同时设置 Firebase Hosting" 那个勾我没勾, 因为前端部署在 Vercel.
apiKey 看着像密钥, 其实不是, 放在前端代码里是正常的. 它只用来标识你的 Firebase 项目, 真正挡住别人冒用的是第五步的授权域名.
第四步, 下载服务账号私钥. 点左上角齿轮进 "Project settings", 切到 "Service accounts", 选中 "Firebase Admin SDK", 点 "Generate new private key", 会下载一个 JSON 文件. 后端用它初始化 Firebase Admin SDK. 这个文件里有私钥, 绝对不能进 Git, 我是怎么存的见下一节.
第五步, 配授权域名. 在 "Authentication" → "Settings" → "Authorized domains" 里, Firebase 默认已经放了 localhost 和 <project-id>.firebaseapp.com. 你要把生产域名加进去, 比如 Vercel 给的 <app>.vercel.app 和你自己绑的域名, 只填域名, 不带 https://. 别人就算抄走了你的 firebaseConfig, 只要他的网站域名不在这张表里, Firebase 就拒绝在他的页面上弹登录窗口.
服务账号私钥放进 AWS SSM Parameter Store, 启动时取出来¶
那个 JSON 文件有三种放法: 跟着代码走 (放进仓库或部署包), 放进 Vercel 的环境变量, 或者放进专门的密钥存储. 第一种直接排除, 私钥进了 Git 就等于公开. 我的项目本来就在用 AWS, 其他配置也放在 SSM Parameter Store 里, 所以私钥也放进去, 存成 SecureString (加密存储), 参数名是项目配置参数名后面加 -firebase.
把 JSON 文件内容整个存进去, 用 AWS CLI 一条命令就行:
aws ssm put-parameter \
--name "<your-parameter-name>-firebase" \
--type SecureString \
--value file://firebase-service-account.json
存好之后, 本地那个 JSON 文件就可以删了. 后端启动时从 SSM 取出来, 解析成 dict, 交给 credentials.Certificate. 下面是去掉了项目里配置封装之后的写法:
# -*- coding: utf-8 -*-
import json
import boto3
import firebase_admin
from firebase_admin import credentials
_firebase_app: firebase_admin.App | None = None
def get_firebase_cert(parameter_name: str) -> dict:
ssm = boto3.client("ssm")
res = ssm.get_parameter(Name=parameter_name, WithDecryption=True)
return json.loads(res["Parameter"]["Value"])
def init_firebase(parameter_name: str) -> bool:
"""幂等: 已经初始化过就直接返回."""
global _firebase_app
if _firebase_app is not None:
return True
try:
cred = credentials.Certificate(get_firebase_cert(parameter_name))
_firebase_app = firebase_admin.initialize_app(cred)
return True
except Exception as e:
print(f"Failed to initialize Firebase: {e}")
return False
def is_firebase_initialized() -> bool:
return _firebase_app is not None
init_firebase 在 FastAPI 的 lifespan 里调一次. 写成幂等是因为 firebase_admin.initialize_app 对同一个默认 app 调第二次会报错, 而 serverless 环境里启动逻辑跑几次不好说. 初始化失败时我选择只记日志, 不让整个服务起不来: 邮箱密码登录照常能用, 只是 Google 登录接口会返回 503.
备注
Vercel 上的函数要读 SSM, 得先拿到 AWS 凭证, 这部分是项目里通用的 AWS 会话管理在做, 和 Firebase 无关, 这里不展开. 给它的权限能读这个参数就够了. 如果你的项目没在用 AWS, 把 JSON 整段放进 Vercel 的环境变量 (标成 Sensitive) 也行, 只是 get_firebase_cert 改成从环境变量里读.
后端: 一个验 token 的模块, 一个换 JWT 的接口¶
先装依赖. 项目用 uv 管理:
uv add "firebase-admin>=6.0.0,<7.0.0"
我当时锁在 6.x. 到 2026-11, firebase-admin 最新已经是 7.x, 新项目直接装最新版就行, 下面用到的 auth.verify_id_token 和几个异常类没有变.
验 token 的模块¶
这个模块只做两件事: 验证 Firebase ID Token, 把验证后的声明整理成一个固定结构. 自定义两个异常, 是为了让接口层能按错误类型返回不同的状态码:
# -*- coding: utf-8 -*-
import dataclasses
from firebase_admin import auth
from firebase_admin.exceptions import FirebaseError
from .firebase_init import is_firebase_initialized
class FirebaseTokenInvalidError(Exception):
"""token 无效, 过期, 或被撤销."""
class FirebaseNotInitializedError(Exception):
"""启动时 init_firebase 没成功."""
def verify_firebase_token(id_token: str) -> dict:
if not is_firebase_initialized():
raise FirebaseNotInitializedError("Firebase is not initialized.")
try:
return auth.verify_id_token(id_token)
except auth.ExpiredIdTokenError:
raise FirebaseTokenInvalidError("Firebase token has expired.")
except auth.RevokedIdTokenError:
raise FirebaseTokenInvalidError("Firebase token has been revoked.")
except auth.InvalidIdTokenError:
raise FirebaseTokenInvalidError("Firebase token is invalid.")
except FirebaseError as e:
raise FirebaseTokenInvalidError(f"Firebase authentication error: {e}")
@dataclasses.dataclass
class FirebaseUserInfo:
firebase_uid: str
email: str | None
email_verified: bool
name: str | None
picture: str | None
provider: str # "google.com", "apple.com", ...
def get_user_info_from_token(decoded_token: dict) -> FirebaseUserInfo:
firebase_claims = decoded_token.get("firebase", {})
return FirebaseUserInfo(
firebase_uid=decoded_token["uid"],
email=decoded_token.get("email"),
email_verified=decoded_token.get("email_verified", False),
name=decoded_token.get("name"),
picture=decoded_token.get("picture"),
provider=firebase_claims.get("sign_in_provider", "unknown"),
)
email, name, picture 都可能是 None: Google 一般三样都给, Apple 可能把真实邮箱藏起来, 名字和头像也常常没有. email_verified 一定要留着, 后面关联老账号全靠它.
用户表加一个 firebase_uid¶
用户模型是 fastapi-users 的 SQLAlchemyBaseUserTableUUID, 自带 email, hashed_password, is_active, is_verified. 我加了两个字段:
# fmt: off
firebase_uid: orm.Mapped[str | None] = orm.mapped_column(sa.String(128), unique=True, nullable=True, index=True)
has_set_password: orm.Mapped[bool] = orm.mapped_column(sa.Boolean, default=False, server_default=sa.text("false"))
# fmt: on
firebase_uid 可以为空, 因为只用密码登录的用户没有它; 唯一, 因为一个 Firebase 用户只能对应一个本地账号; 加索引, 因为每次 Google 登录都要按它查. has_set_password 用来区分 "这个账号的密码是用户自己设的" 和 "只用 Google 注册, 密码是系统随机生成的", 前端据此显示 "修改密码" 还是 "设置密码". 建表用的是 Base.metadata.create_all, 项目里没有用 Alembic 做迁移.
换 JWT 的接口¶
请求体只有一个字段 id_token. 响应里除了 access_token, 还有 is_new_user (前端用来显示 "账号已创建" 还是 "登录成功") 和 email:
class FirebaseLoginRequest(BaseModel):
id_token: str = Field(..., description="Firebase ID token from frontend")
class FirebaseLoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
is_new_user: bool = False
email: str
接口本身分四步: 验 token, 找或建用户, 发自己的 JWT, 把 refresh token 写进 cookie. 第二步里 "按 email 找到老账号怎么办" 那一段是 link_existing_user, 下一节单独讲. 这里的 fastapi_users, get_jwt_strategy, create_refresh_token, get_refresh_token_cookie_settings 是项目里密码登录本来就有的东西, Google 登录直接复用:
@router.post("/api/auth/firebase", response_model=FirebaseLoginResponse)
@limiter.limit(rate_limit_login)
async def firebase_login(
request: Request,
data: FirebaseLoginRequest,
session: AsyncSession = Depends(get_async_session),
):
if not firebase_enabled:
raise HTTPException(status_code=503, detail="FIREBASE_AUTH_DISABLED")
# 1. 验 Firebase ID Token
try:
user_info = get_user_info_from_token(verify_firebase_token(data.id_token))
except FirebaseNotInitializedError:
raise HTTPException(status_code=503, detail="FIREBASE_NOT_INITIALIZED")
except FirebaseTokenInvalidError:
raise HTTPException(status_code=401, detail="FIREBASE_TOKEN_INVALID")
if not user_info.email:
raise HTTPException(status_code=400, detail="FIREBASE_EMAIL_REQUIRED")
# 2. 找或建用户
is_new_user = False
result = await session.execute(
select(User).where(User.firebase_uid == user_info.firebase_uid)
)
user = result.scalar_one_or_none()
if user is None:
result = await session.execute(select(User).where(User.email == user_info.email))
user = result.scalar_one_or_none()
if user is not None:
await link_existing_user(session, user, user_info) # 见下一节
else:
user = User(
email=user_info.email,
# 随机密码, 用户不知道也用不到, 以后可以自己 "设置密码"
hashed_password=PasswordHelper().hash(secrets.token_urlsafe(32)),
is_active=True,
is_verified=user_info.email_verified,
is_superuser=False,
firebase_uid=user_info.firebase_uid,
has_set_password=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
is_new_user = True
# 3. 发自己的 JWT, 和密码登录用同一个策略
access_token = await get_jwt_strategy().write_token(user)
# 4. refresh token 写进 HttpOnly cookie
refresh_token = await create_refresh_token(session, user.id, refresh_token_lifetime)
response = JSONResponse(
content={
"access_token": access_token,
"token_type": "bearer",
"is_new_user": is_new_user,
"email": user_info.email,
}
)
response.set_cookie(value=refresh_token, **get_refresh_token_cookie_settings(refresh_token_lifetime))
return response
这里有几处是故意这么写的:
先按
firebase_uid找, 再按email找.firebase_uid是 Firebase 给的, 用户改了 Google 邮箱它也不变, 是最可靠的身份;email只在第一次用 Google 登录, 需要和老的密码账号对上时才用.新用户的
is_verified取 token 里的email_verified, 不是无脑设成True. Google 登录的email_verified基本都是True, 但换成别的登录方式就不一定了, 不要替 Firebase 做担保.这个接口和密码登录一样挂了限流 (
@limiter.limit), 防止有人拿一堆假 token 来刷.业务接口用的是 fastapi-users 的
current_user(active=True, verified=True), 只认自己的 JWT, 而且要求邮箱已验证. Google 登录换来的 JWT 和密码登录的完全一样, 业务代码不用关心用户是怎么登进来的.
按 email 关联老账号之前, 先确认这个邮箱真是他的¶
这一节对应上面的 link_existing_user. 场景是: 有人先用邮箱密码注册了 alice@gmail.com, 后来又用同一个 gmail 点了 Google 登录. 两个来源, 同一个邮箱, 合不合并?
合并是对的, 不然同一个人会有两个账号. 问题是怎么合. 我当时最初的写法很直接: 按 email 找到老账号, 把 firebase_uid 挂上去, 完事. 这样写有一个洞, 就是 别人抢先用你的邮箱注册, 也偷不走你的账号 那一节讲的抢注后劫持, 而且在我这种 "邮箱密码自己管" 的架构下, 洞全在我自己的后端里, Firebase 帮不上忙:
警告
最初的写法有三个条件叠在一起: 密码登录不要求邮箱已验证 (fastapi-users 的登录路由默认不要求), 按 email 关联时不看 token 的 email_verified, 关联之后也不改老账号的状态. 于是攻击路径是: 黑客先用 alice 的邮箱加他自己的密码注册一个账号, 没验证邮箱, 但能登录; alice 后来用 Google 登录, 被挂进了这个账号; alice 一旦完成邮箱验证, 黑客就能拿他的密码登进一个已经验证过的, 属于 alice 的账号. 业务接口要求邮箱已验证, 所以在 alice 验证之前黑客拿不到数据, 但验证之后就挡不住了.
修法的思路是: Google 登录这一下, 恰好证明了 "现在点按钮的这个人, 才是这个邮箱的主人". 所以在 token 里 email_verified 为真的前提下, 老账号如果还没验证过邮箱, 那它的密码就是一个不知道是谁设的密码, 应该作废; 同时把账号标成已验证, 因为 Google 已经替我们验证过了.
async def link_existing_user(
session: AsyncSession,
user: User,
user_info: FirebaseUserInfo,
) -> None:
# 1. 邮箱没被登录方 (Google 等) 验证过, 就不能拿 email 认人
if not user_info.email_verified:
raise HTTPException(status_code=403, detail="FIREBASE_EMAIL_NOT_VERIFIED")
# 2. 老账号自己没验证过邮箱: 它的密码是谁设的不知道, 作废掉
if not user.is_verified:
user.hashed_password = PasswordHelper().hash(secrets.token_urlsafe(32))
user.has_set_password = False
user.is_verified = True
await revoke_all_user_refresh_tokens(session, user.id)
# 3. 挂上 firebase_uid
user.firebase_uid = user_info.firebase_uid
session.add(user)
await session.commit()
逐条说一下:
第 1 条挡的是 "登录方没验证过邮箱" 的情况. Google 给的 gmail 一定是验证过的, 但以后接别的登录方式时, 有的 provider 允许用户填一个没验证的邮箱, 那这个邮箱就不能拿来认人.
第 2 条是关键. 老账号如果自己已经验证过邮箱, 说明设密码的人能收到这个邮箱的信, 就是本人, 直接关联, 密码保留. 没验证过的, 密码作废,
has_set_password改回False, 用户之后想用密码登录, 走 "设置密码" 重新设一个. 顺手把这个账号所有的 refresh token 都撤销掉, 黑客之前登录拿到的会话就续不上了.黑客手里已经签发的 JWT access token 撤不掉, 因为 JWT 是无状态的, 要等它自然过期 (我设的是 1 小时). 这段时间里他的账号状态还是未验证, 业务接口本来就进不去.
修完之后, 抢注的人最多是让 alice 的账号多了一个他设过又被作废的密码, 什么也拿不到.
前端: lib/firebase.ts 和登录按钮的处理¶
前端用 npm 上的 firebase 包 (模块化 API). 配置全部从 NEXT_PUBLIC_FIREBASE_* 环境变量里读, 本地放 .env.local, 线上放 Vercel 的环境变量. 前缀必须是 NEXT_PUBLIC_, 否则 Next.js 不会把它打进浏览器端的代码:
// lib/firebase.ts
import { getApps, initializeApp, type FirebaseApp } from "firebase/app";
import { getAuth, GoogleAuthProvider, signInWithPopup, type Auth } from "firebase/auth";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
let app: FirebaseApp | undefined;
let auth: Auth | undefined;
function getFirebaseAuth(): Auth {
if (!auth) {
app = getApps()[0] ?? initializeApp(firebaseConfig);
auth = getAuth(app);
}
return auth;
}
/** 弹出 Google 登录窗口, 返回要发给后端的 Firebase ID Token. */
export async function signInWithGoogle(): Promise<string> {
const provider = new GoogleAuthProvider();
provider.addScope("email");
provider.addScope("profile");
const result = await signInWithPopup(getFirebaseAuth(), provider);
return result.user.getIdToken();
}
export async function signOutFromFirebase(): Promise<void> {
await getFirebaseAuth().signOut();
}
登录页上 "Sign in with Google" 按钮的处理函数. 这里的 setToken, setUserEmail, showToast, getErrorMessage 是项目里密码登录本来就有的工具函数:
const handleGoogleSignIn = async () => {
setGoogleLoading(true);
try {
const idToken = await signInWithGoogle();
const response = await fetch("/api/auth/firebase", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id_token: idToken }),
credentials: "include", // 让浏览器收下后端设置的 refresh token cookie
});
const data = await response.json();
if (response.ok) {
setUserEmail(data.email);
setToken(data.access_token);
showToast(data.is_new_user ? "Account created successfully!" : "Login successful!", "success");
router.push("/dashboard");
} else {
// 后端的 detail 是错误码, 比如 FIREBASE_TOKEN_INVALID
showToast(getErrorMessage(data.detail), "error");
}
} catch (error: unknown) {
const code = (error as { code?: string }).code;
if (code === "auth/popup-closed-by-user") {
showToast("Sign in cancelled.", "info");
} else {
showToast("Google sign in failed. Please try again.", "error");
}
} finally {
setGoogleLoading(false);
}
};
两个要注意的地方. credentials: "include" 不能漏, 不然浏览器不会保存后端写的 refresh token cookie, 用户一小时后就会被踢出去. 后端返回的 detail 是固定的错误码 (FIREBASE_AUTH_DISABLED, FIREBASE_NOT_INITIALIZED, FIREBASE_TOKEN_INVALID, FIREBASE_EMAIL_REQUIRED, FIREBASE_EMAIL_NOT_VERIFIED), 前端用一张表把它们翻译成给用户看的话, 这样后端改措辞不用动前端.
我用的是 signInWithPopup, 没用 signInWithRedirect. 原因在 用 Firebase Authentication 接第三方登录: 后端只需要验证一种 token 的结尾讲过: 前端在 Vercel 上, 浏览器挡第三方存储之后, redirect 方式默认会坏.
怎么确认接通了, 以及接不通时查哪里¶
我是手动测的, 三个场景, 每次都用无痕窗口:
新用户. 用一个从没在 App 里出现过的 Google 账号点登录. 应该弹出 Google 授权窗口, 授权后跳到 dashboard, 提示 "Account created successfully". 数据库里多一条用户,
firebase_uid有值,is_verified为真,has_set_password为假.老的 Google 用户. 同一个 Google 账号再登录一次. 应该直接登录, 提示 "Login successful", 数据库里没有多出新用户.
先密码后 Google. 先用某个 gmail 走邮箱密码注册, 然后用同一个 gmail 点 Google 登录. 要分两种情况测: 如果先完成了邮箱验证, 应该登录成功,
firebase_uid被挂上, 原来的密码照样能用; 如果没验证邮箱就点 Google, 也应该登录成功,is_verified变成真, 但原来的密码失效了, 要走 "设置密码" 重设.
接不通时, 按报错查:
现象 |
多半是哪里 |
怎么查 |
|---|---|---|
弹窗出现后马上报 |
当前域名不在授权域名里 |
Console 的 "Authorized domains", 把当前访问的域名加进去. 用局域网 IP 访问时也会这样 |
点按钮没反应, 或报 |
浏览器挡了弹窗 |
允许本站弹窗. 登录必须由用户点击直接触发, 不要在 |
后端返回 503 |
启动时没取到私钥 |
看启动日志里 |
后端返回 401 |
token 和后端不是同一个 Firebase 项目 |
前端的 |
后端返回 400 |
token 里没有邮箱 |
检查是否请求了 |
登录成功, 一小时后被登出 |
refresh token cookie 没存下来 |
前端 |
以后加 Apple 等登录方式: 只改 Console 和前端, 以及还没做的事¶
这套做法最值的地方, 是以后加登录方式基本不碰后端. 以 Apple 为例: 在 Console 的 "Sign-in method" 里启用 Apple, 按它的引导去 Apple Developer 后台建 Service ID, 上传密钥; 前端加一个按钮, 把 GoogleAuthProvider 换成 OAuthProvider("apple.com"). 后端的 /api/auth/firebase 不用改, 因为 Apple 登录换来的也是同一种 Firebase ID Token. 唯一要留意的是 Apple 用户可能不给邮箱, 后端会返回 FIREBASE_EMAIL_REQUIRED, 到时候要决定是放宽这条, 还是提示用户必须共享邮箱. 这部分我还没做过, 具体的 Apple 配置以 Firebase 官方文档 为准.
还没做的事, 也记在这里:
只用了
signInWithPopup. 手机浏览器上弹窗体验差, 要换成 redirect 方式, 得先按官方方案处理 Vercel 上的第三方存储问题.Google 登录接口没有自动化测试, 上面的三个场景都是手动测的. 补测试时, 要 mock 掉
verify_firebase_token, 重点测link_existing_user的三条分支.写这篇时 App 还没上生产. 真实用户里同邮箱撞号有多常见, 弹窗被拦的比例有多高, 我现在都不知道.