Firebase邮箱2FA实现:需强制每次登录触发邮件验证
实现Next.js + Firebase每次登录强制触发邮箱2FA验证
Firebase自带的emailVerified字段是用于标记邮箱是否完成所有权验证,一旦验证通过就永久保持true,无法满足每次登录都触发2FA验证的需求。要实现这个功能,需要放弃依赖该字段,改用自定义的临时验证机制。
核心思路
- 用户登录成功后,生成一次性验证令牌并存储到Firestore用户文档中(附带过期时间)。
- 发送包含该令牌的验证链接到用户邮箱。
- 用户点击链接后,验证令牌有效性并清除临时验证标记,允许进入系统。
- 前端登录后直接进入验证等待状态,轮询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
相关产品推荐
相关产品推荐

