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

Firebase邮箱2FA实现:需强制每次登录触发邮件验证

实现Next.js + Firebase每次登录强制触发邮箱2FA验证

Firebase自带的emailVerified字段是用于标记邮箱是否完成所有权验证,一旦验证通过就永久保持true,无法满足每次登录都触发2FA验证的需求。要实现这个功能,需要放弃依赖该字段,改用自定义的临时验证机制。

核心思路

  1. 用户登录成功后,生成一次性验证令牌并存储到Firestore用户文档中(附带过期时间)。
  2. 发送包含该令牌的验证链接到用户邮箱。
  3. 用户点击链接后,验证令牌有效性并清除临时验证标记,允许进入系统。
  4. 前端登录后直接进入验证等待状态,轮询Firestore检查验证标记是否已清除。

修改后的代码实现

1. 登录逻辑修改(handleLogin)

import { v4 as uuidv4 } from 'uuid'; // 需先安装:npm install uuid @types/uuid

const handleLogin = async (e: React.FormEvent) => {
  e.preventDefault();
  setLoading(true);

  try {
    const userCredential = await signInWithEmailAndPassword(auth, email, password);
    const user = userCredential.user;
    
    // 生成15分钟有效期的一次性验证令牌
    const verificationToken = uuidv4();
    const expiresAt = new Date(Date.now() + 15 * 60 * 1000);

    // 将验证信息合并存入Firestore用户文档
    await setDoc(
      doc(db, 'users', user.uid),
      {
        loginVerification: {
          token: verificationToken,
          expiresAt: expiresAt
        }
      },
      { merge: true }
    );

    // 构造验证链接,指向自定义验证页面
    const verificationUrl = `${window.location.origin}/verify-login?token=${verificationToken}&uid=${user.uid}`;
    await sendEmailVerification(user, { url: verificationUrl });

    setVerificationSent(true);
    setToast('请查收邮箱中的登录验证链接');
    startVerificationCheck(user.uid);
    return;

  } catch (error) {
    console.error('登录失败:', error);
    setToast('登录失败!请检查账号密码是否正确。');
  } finally {
    setLoading(false);
  }
};

2. 验证状态轮询修改(startVerificationCheck)

const startVerificationCheck = (uid: string) => {
  const intervalId = setInterval(async () => {
    setVerificationChecks((prev) => prev + 1);
    
    const docRef = doc(db, 'users', uid);
    const docSnap = await getDoc(docRef);
    
    if (docSnap.exists()) {
      const data = docSnap.data();
      // 验证标记已清除,说明用户完成验证
      if (!data.loginVerification) {
        clearInterval(intervalId);
        setVerificationSent(false);
        const currentUser = auth.currentUser;
        if (currentUser) await fetchUserDetails(currentUser.uid, currentUser);
        return;
      }
      // 验证链接过期
      if (new Date(data.loginVerification.expiresAt) < new Date()) {
        clearInterval(intervalId);
        setVerificationSent(false);
        setToast('验证链接已过期,请重新登录。');
        await auth.signOut();
        return;
      }
    }

    // 超过5分钟停止轮询
    if (verificationChecks >= 60) {
      clearInterval(intervalId);
      setVerificationSent(false);
      setToast('验证超时,请重新登录。');
      await auth.signOut();
    }
  }, 5000);
};

3. 自定义验证页面(/verify-login)

创建Next.js客户端组件处理验证请求:

// app/verify-login/page.tsx
'use client';

import { useSearchParams, redirect } from 'next/navigation';
import { auth, db } from '@/firebase/config';
import { doc, getDoc, updateDoc } from 'firebase/firestore';
import { useEffect, useState } from 'react';

export default function VerifyLoginPage() {
  const searchParams = useSearchParams();
  const token = searchParams.get('token');
  const uid = searchParams.get('uid');
  const [status, setStatus] = useState<string>('验证中...');

  useEffect(() => {
    const verifyLogin = async () => {
      if (!token || !uid) {
        setStatus('无效的验证链接');
        return;
      }

      try {
        const docRef = doc(db, 'users', uid);
        const docSnap = await getDoc(docRef);

        if (!docSnap.exists()) {
          setStatus('用户不存在');
          return;
        }

        const data = docSnap.data();
        const loginVerification = data.loginVerification;
        if (!loginVerification || loginVerification.token !== token) {
          setStatus('无效或已过期的验证链接');
          return;
        }

        if (new Date(loginVerification.expiresAt) < new Date()) {
          setStatus('验证链接已过期');
          return;
        }

        // 清除临时验证标记
        await updateDoc(docRef, { loginVerification: null });

        setStatus('验证成功!正在跳转...');
        setTimeout(() => redirect('/'), 1000);
      } catch (error) {
        console.error('验证失败:', error);
        setStatus('验证失败,请重试。');
      }
    };

    verifyLogin();
  }, [token, uid]);

  return (
    <div className="flex flex-col items-center justify-center min-h-screen p-4">
      <h1 className="text-2xl font-bold mb-4">{status}</h1>
    </div>
  );
}

关键说明

  • 不再依赖Firebase的emailVerified字段,完全通过自定义的loginVerification临时字段控制登录验证流程。
  • 验证令牌设置15分钟有效期,避免长期有效带来的安全风险。
  • 验证页面直接操作Firestore清除标记,确保只有完成验证的用户才能进入系统。

内容的提问来源于stack exchange,提问作者Anshu Sharma

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 06:24:57