如何编写仅应用可访问的Firebase云函数及无认证访问计数安全方案
我来帮你拆解这两个Firebase Cloud Functions的常见安全问题,都是实际开发中经常遇到的场景:
要实现这个,核心是验证请求确实来自你的合法应用,下面是几种靠谱的方案,按推荐程度排序:
Firebase App Check(最推荐)
App Check是Firebase官方提供的验证请求来源的工具,能确保请求来自你注册的iOS/Android/Web应用,甚至是Cloud Functions本身。你需要先在客户端集成App Check SDK,然后在Cloud Function里验证令牌:const { getAppCheck } = require("firebase-admin/app-check"); exports.yourRestrictedFunction = functions.https.onRequest(async (req, res) => { // 从请求头获取App Check令牌 const appCheckToken = req.headers["x-firebase-appcheck"]; if (!appCheckToken) { res.status(403).send("未提供合法的应用验证令牌"); return; } try { // 验证令牌有效性 await getAppCheck().verifyToken(appCheckToken); } catch (err) { res.status(403).send("应用验证失败,请求来源不合法"); return; } // 这里写你的函数核心逻辑 res.status(200).send("请求已通过验证,执行成功"); });这种方式能有效拦截第三方恶意调用,Firebase会帮你处理大部分验证细节,省心又安全。
Firebase Auth用户验证(适合需要登录的应用)
如果你的应用所有功能都要求用户登录,那可以在函数里验证用户的ID令牌,确保请求来自你的应用的认证用户:const { getAuth } = require("firebase-admin/auth"); exports.yourAuthProtectedFunction = functions.https.onRequest(async (req, res) => { // 从请求头获取Bearer令牌 const idToken = req.headers.authorization?.split("Bearer ")[1]; if (!idToken) { res.status(401).send("未提供身份验证令牌"); return; } try { const decodedToken = await getAuth().verifyIdToken(idToken); // 可选:额外验证用户是否属于你的应用(比如检查email域名) if (!decodedToken.email?.endsWith("@yourdomain.com")) { res.status(403).send("用户无访问权限"); return; } } catch (err) { res.status(403).send("身份验证失败"); return; } // 函数核心逻辑 res.status(200).send("认证通过,执行成功"); });自定义API密钥(谨慎使用)
你可以在客户端请求时带上自定义API密钥,在函数里验证密钥是否匹配。但注意:Web应用的API密钥容易被逆向获取,所以这种方式只适合非Web客户端,或作为辅助验证手段。记得把密钥存在Cloud Functions环境变量里,不要硬编码:exports.yourApiKeyProtectedFunction = functions.https.onRequest((req, res) => { const apiKey = req.headers["x-custom-api-key"]; if (apiKey !== process.env.YOUR_SECRET_API_KEY) { res.status(403).send("无效的API密钥"); return; } // 函数核心逻辑 res.status(200).send("密钥验证通过,执行成功"); });
这个场景的核心挑战是防止恶意刷量,同时因为用户未认证,不能依赖Auth令牌。以下是几个可行的方案:
方案一:中间跳转页+App Check验证(最推荐)
不要直接让用户点击外部链接,而是先跳转到你的Cloud Function(或网站中间页),完成计数后再重定向到目标网站。这样可以:
- 用App Check验证请求来自你的合法网站,阻止第三方脚本恶意调用;
- 避免直接暴露计数接口。
函数代码示例:
const { getAppCheck } = require("firebase-admin/app-check"); const { getFirestore } = require("firebase-admin/firestore"); exports.trackVisitAndRedirect = functions.https.onRequest(async (req, res) => { // 1. 验证App Check令牌 const appCheckToken = req.headers["x-firebase-appcheck"]; if (!appCheckToken) { res.status(403).send("请求来源不合法"); return; } try { await getAppCheck().verifyToken(appCheckToken); } catch (err) { res.status(403).send("请求来源不合法"); return; } // 2. 获取目标资源ID(从查询参数中获取) const resourceId = req.query.resourceId; if (!resourceId) { res.status(400).send("缺少资源ID"); return; } // 3. 原子递增访问计数(避免并发更新丢失) const db = getFirestore(); await db.collection("resources").doc(resourceId).update({ visits: admin.firestore.FieldValue.increment(1) }); // 4. 重定向到目标网站 const resourceDoc = await db.collection("resources").doc(resourceId).get(); if (resourceDoc.exists) { res.redirect(resourceDoc.data().url); } else { res.status(404).send("资源不存在"); } });
然后在你的网站上,把外部链接改成指向这个函数的地址,比如:/track-visit?resourceId=xxx
方案二:IP限流+原子计数
如果不想用中间跳转页,可以直接提供计数接口,但要加上IP限流,防止同一IP短时间内多次调用:
const rateLimit = require("express-rate-limit"); const express = require("express"); const app = express(); app.use(express.json()); // 配置限流:每个IP每分钟最多调用10次 const limiter = rateLimit({ windowMs: 60 * 1000, // 1分钟窗口 max: 10, message: "请求过于频繁,请稍后再试" }); app.use(limiter); app.post("/increment-visits", async (req, res) => { const resourceId = req.body.resourceId; if (!resourceId) { res.status(400).send("缺少资源ID"); return; } const db = getFirestore(); try { await db.collection("resources").doc(resourceId).update({ visits: admin.firestore.FieldValue.increment(1) }); res.status(200).send("计数更新成功"); } catch (err) { res.status(500).send("更新失败,请重试"); } }); exports.incrementVisits = functions.https.onRequest(app);
这种方式的缺点是可能误杀共享IP的正常用户,但对于一般场景已经足够。
额外注意事项
- 必须使用原子操作:不管哪种方案,都要用
FieldValue.increment(1)更新计数,不要先读取再写入,否则并发请求会导致计数丢失; - 保持数据库只读权限:继续让
/resources端点保持公开只读,所有计数更新都通过Cloud Function完成; - 可选:会话验证:如果你的网站有匿名会话,可以在请求时带上会话ID,在函数里验证会话有效性,进一步防止刷量。
内容的提问来源于stack exchange,提问作者Geoffrey H

