You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Firebase如何不创建新用户发送邮箱验证实现安全退订

Firebase 无新建用户场景下的邮箱验证退订实现方案

Firebase 原生的sendEmailVerification接口仅支持给当前已登录的认证用户发送验证邮件,无法直接给任意已存在用户发验证邮件且不触发新用户创建,要实现退订邮箱校验、无越权删号的需求,需要组合云函数、Firestore临时存储、邮件发送能力实现,完全绕开原生验证的限制。

整体实现流程

  • 用户在退订弹窗输入目标邮箱,前端发起验证申请
  • 后端(云函数)先校验输入的邮箱确实绑定了Firebase Auth账号,不存在则直接返回提示
  • 后端生成密码学安全的随机校验token,将token和对应邮箱、用户ID、过期时间(建议15分钟)存入Firestore临时集合
  • 后端调用邮件发送能力,把带token的退订验证链接发到用户输入的邮箱,全程不需要创建新用户,也不改动现有用户的认证状态
  • 用户点击邮件内的验证链接,跳转到前端退订确认页,页面提取URL中的token传给后端做校验
  • 后端校验token有效、未过期、未被使用后,先标记token为已失效防止重放攻击,再通过Admin SDK删除对应用户账号,清理关联业务数据
  • 前端收到删除成功响应后,清理本地登录态,展示退订成功提示

代码改造参考

原有代码的核心问题是直接在前端取当前登录用户执行删除,既没有做邮箱归属校验,还把敏感操作暴露在前端,存在严重越权风险,改造后代码如下:

前端代码

import { auth, db, functions } from '../../../firebase-config'
import { httpsCallable } from "firebase/functions";
import { useState } from "react";

export default function SubscribePopup(props) {
  const [unsubscribeEmail, setUnsubscribeEmail] = useState("");
  const [verifyStep, setVerifyStep] = useState("input"); // 三个状态:输入邮箱/已发邮件/退订成功

  // 申请发送退订验证邮件
  const sendUnsubscribeVerify = async () => {
    if (!unsubscribeEmail.trim()) return alert("请输入要退订的绑定邮箱");
    const sendVerifyReq = httpsCallable(functions, "sendUnsubscribeVerifyEmail");
    try {
      await sendVerifyReq({ email: unsubscribeEmail.trim() });
      setVerifyStep("sent");
    } catch (err) {
      alert(err.message);
    }
  };

  // 从验证链接跳回时执行校验和删号
  const handleVerifyToken = async (token) => {
    const confirmUnsubReq = httpsCallable(functions, "confirmUnsubscribe");
    try {
      await confirmUnsubReq({ token });
      // 清理本地登录状态
      await auth.signOut();
      localStorage.setItem('signedIn', false);
      setVerifyStep("success");
      setUnsubscribePop(false);
    } catch (err) {
      alert("验证链接无效或已过期,请重新申请退订");
    }
  };

  if (props.subscribe){
    // 原有订阅相关逻辑
  }
  else{
    return (
      <div className="unsubscribe-popup">
        {verifyStep === "input" && (
          <>
            <p>请输入账号绑定的邮箱,我们将发送验证邮件确认退订操作</p>
            <input 
              type="email" 
              value={unsubscribeEmail}
              onChange={(e) => setUnsubscribeEmail(e.target.value)}
              placeholder="请输入绑定邮箱"
            />
            <button onClick={sendUnsubscribeVerify}>发送验证邮件</button>
          </>
        )}
        {verifyStep === "sent" && <p>验证邮件已发送,请点击邮件内的链接完成退订,链接15分钟内有效</p>}
        {verifyStep === "success" && <p>账号已成功退订</p>}
      </div>
    )
  }
}

云函数(后端)代码

所有敏感操作必须部署在云函数这类受信任的服务端环境,不能在前端直接操作,需要依赖firebase-admin SDK:

const functions = require("firebase-functions");
const admin = require("firebase-admin");
const crypto = require("crypto");
admin.initializeApp();

const db = admin.firestore();
const auth = admin.auth();

// 发送退订验证邮件接口
exports.sendUnsubscribeVerifyEmail = functions.https.onCall(async (data) => {
  const email = data.email?.trim().toLowerCase();
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    throw new functions.https.HttpsError("invalid-argument", "邮箱格式不正确");
  }
  // 校验邮箱是否对应存在的用户
  let targetUser;
  try {
    targetUser = await auth.getUserByEmail(email);
  } catch (err) {
    throw new functions.https.HttpsError("not-found", "该邮箱未绑定任何平台账号");
  }
  // 生成32位密码学安全随机token
  const verifyToken = crypto.randomBytes(32).toString("hex");
  // token存入Firestore,设置15分钟自动过期
  await db.collection("unsubscribeTokens").doc(verifyToken).set({
    email: email,
    targetUid: targetUser.uid,
    createTime: admin.firestore.FieldValue.serverTimestamp(),
    used: false
  });
  // 拼接你的前端退订确认页地址
  const verifyLink = `https://你的站点域名/unsubscribe-confirm?token=${verifyToken}`;
  // 调用邮件服务发信,可直接使用Firebase官方Trigger Email扩展
  await db.collection("mail").add({
    to: email,
    message: {
      subject: "账号退订验证",
      html: `<p>你正在申请平台账号退订,请点击下方链接确认操作,链接15分钟内有效:<br/><a href="${verifyLink}">${verifyLink}</a></p><p>如果不是你本人发起的操作,请直接忽略这封邮件即可。</p>`
    }
  });
  return { code: 0 };
});

// 校验token、执行删号接口
exports.confirmUnsubscribe = functions.https.onCall(async (data) => {
  const token = data.token;
  if (!token || typeof token !== "string") {
    throw new functions.https.HttpsError("invalid-argument", "验证参数无效");
  }
  const tokenDocRef = db.collection("unsubscribeTokens").doc(token);
  const tokenSnap = await tokenDocRef.get();
  if (!tokenSnap.exists) {
    throw new functions.https.HttpsError("not-found", "验证链接不存在或已过期");
  }
  const tokenInfo = tokenSnap.data();
  if (tokenInfo.used) {
    throw new functions.https.HttpsError("permission-denied", "该验证链接已被使用");
  }
  // 先标记token为已使用,防止重放攻击
  await tokenDocRef.update({ used: true });
  // 执行账号删除
  await auth.deleteUser(tokenInfo.targetUid);
  // 可在此处添加关联业务数据清理逻辑,比如删除用户的业务文档、存储文件等
  return { code: 0 };
});

关键安全注意事项

  • 绝对不要把删除用户、查询全量用户这类敏感操作放在前端执行,哪怕加了前端校验也能被抓包绕过,所有敏感逻辑必须放在受信任的服务端
  • 验证token必须使用密码学安全的随机数生成,长度不低于32位,必须配置过期时间,使用后立刻标记失效,避免被暴力枚举、重放攻击
  • 发验证邮件前必须校验目标邮箱存在对应用户,同时给接口加频率限制:单个IP、单个邮箱1分钟内最多允许发1次验证邮件,防止被恶意利用做邮件轰炸
  • 验证邮件里必须明确提示非本人操作可直接忽略,降低恶意操作风险

内容的提问来源于stack exchange,提问作者Thavas Antonio

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 02:39:16