返回

Firebase权限不足:JS SDK创建文档前检查的坑

javascript

权限不足问题:在JS模块化Web SDK中创建文档前检查文档存在性

使用Firebase JS模块化 Web SDK (版本11.0.1及之前版本)时,在执行setDoc操作前先进行文档存在性检查,可能会遭遇 “缺少或权限不足” 的错误。 本文将深入探讨该问题,分析潜在原因,并提供几种有效的解决方案。

问题现象

当尝试使用 getDoc 检测文档是否存在,之后使用 setDoc 创建文档时,即便规则看似允许创建操作,仍然会抛出权限不足的异常。但如果移除 getDoc 的存在性检查,setDoc 则能顺利执行, 这暗示问题并非出在setDoc本身,而可能与读取操作有关。

以下代码片段说明了该问题:

代码片段 A(存在问题):

export async function setYouTubePublication(publication) {
    console.log("setYouTubePublication", publication);  
    const db = getFirestore();
    const ref = doc(db, "youtube_publications", publication.youtubeID);
    const snap = await getDoc(ref); // 检测文档是否存在
    if (snap.exists()) {
        throw new Error(`YouTube publication with ID ${publication.youtubeID} already exists`);
    }
    return setDoc(ref, publication); // 创建文档
}

代码片段 B(正常工作):

export async function setYouTubePublication(publication) {
    console.log("setYouTubePublication", publication);  
    const db = getFirestore();
    const ref = doc(db, "youtube_publications", publication.youtubeID);
    return setDoc(ref, publication); // 直接创建文档
}

Firebase 安全规则定义如下:

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /youtube_publications/{document} {
            allow read: if request.auth != null && resource.data.owner == request.auth.token.dcid;
            allow create: if request.auth != null;
        }
    }
}

原因分析

问题根源在于 Firebase 安全规则中 readcreate 规则的不同。在代码片段 A 中,虽然创建规则 (create: if request.auth != null) 是满足的,但是, getDoc 执行了一个 read 操作。如果此时该文档实际不存在 ,由于read规则需要resource.data.owner存在(但当文档不存在的时候resource.data也是不存在的),且需要request.auth.token.dcid一致才能通过验证,这个read检查就会失败,直接抛出权限不足错误,阻止后续的setDoc。 这说明在安全规则校验时,存在对空文档或不存在文档的处理差异,即使没有resource也进行了解析尝试。而代码片段 B 则跳过了这个文档不存在时的检查环节。

解决方案

以下列举了几种可行的方案。

方案一:修改读取规则

原理: 调整 read 规则,使得在文档不存在时也能允许读取操作。

代码示例:

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /youtube_publications/{document} {
             allow read: if request.auth != null && (resource == null || resource.data.owner == request.auth.token.dcid);
             allow create: if request.auth != null;
        }
    }
}

操作步骤:

  1. 打开 Firebase 控制台。
  2. 导航到 Firestore 的 “规则” 部分。
  3. read 规则修改为上述示例中的规则,利用resource == null 或者条件检查来处理文档不存在的情况。
  4. 保存修改并部署新的规则。

解释: 这个修改后的 read 规则使用 resource == null 或 条件检查允许在文档不存在时跳过对 resource.data.owner 的校验。这样,当文档不存在时,getDoc 操作也能成功,后续的 setDoc 操作也会按照预期进行。这种方案的优点在于它直接解决了安全规则造成的限制。

方案二:使用 setDocmerge 选项

原理: 使用 setDocmerge: true 选项进行创建,它只会修改指定文档已有的属性,或者创建一个新的文档,并且能够避免执行读取操作,从而避开由于文档不存在导致的规则限制。

代码示例:

export async function setYouTubePublication(publication) {
    console.log("setYouTubePublication", publication);
    const db = getFirestore();
    const ref = doc(db, "youtube_publications", publication.youtubeID);
    return setDoc(ref, publication, { merge: true });
}

操作步骤:

  1. 修改代码中调用setDoc的地方,添加第三个参数 {merge: true}
  2. 测试程序是否能成功执行创建文档操作。

解释: 这种方案本质上回避了先 getDocsetDoc 的模式,而使用 Firestore 自带的创建或更新机制。通过 merge:true 参数,如果文档不存在,它会创建一个新的,如果存在,它会更新该文档。 由于该操作不需要先执行文档读取验证,避免了上述权限不足问题。 此方案简化了逻辑,但也失去了预先检测存在的功能。

方案三:利用事务(Transactions)

原理: 利用事务的原子性操作,结合自定义服务端逻辑确保检查和创建同步进行。
代码示例:
首先编写一个服务器端云函数(Firebase Functions):

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();
exports.createYouTubePublication = functions.https.onCall(async (data, context) => {
  if (!context.auth) {
      throw new functions.https.HttpsError('unauthenticated', 'User must be authenticated to create publications');
  }

  const { youtubeID, ...publicationData } = data;
  const db = admin.firestore();
  const publicationRef = db.collection("youtube_publications").doc(youtubeID);
  try {
    await db.runTransaction(async (transaction) => {
      const snap = await transaction.get(publicationRef);
      if (snap.exists) {
          throw new functions.https.HttpsError('already-exists', `Publication with ID ${youtubeID} already exists`);
      }
     
     await transaction.set(publicationRef, {...publicationData, owner: context.auth.uid} );
    });
    return {message:`YouTube publication with ID ${youtubeID} has been created`}

  } catch (error){
    throw new functions.https.HttpsError('internal', 'Failed to process request',error);
  }

});

然后在客户端调用:

import {getFunctions,httpsCallable } from 'firebase/functions';

export async function setYouTubePublication(publication) : Promise<void> {
    console.log("setYouTubePublication", publication);
    const functions = getFunctions();
    const createPublicationCallable = httpsCallable(functions,"createYouTubePublication");
    try {
         await createPublicationCallable(publication);
         
    } catch (error){
        console.error("Error calling function",error);
    }


}

操作步骤:

  1. 创建一个云函数(或部署其他后端)用来原子化的执行读取验证和创建操作。
  2. 修改客户端代码,调用该云函数而不是直接操作 Firestore。
  3. 根据错误类型,决定抛出错误或是提示用户。

解释: 这种方式通过云函数调用,把对权限的验证和操作统一放入服务器执行。它不仅安全,也能够提供更加复杂的操作, 是一种较稳妥的方式。由于读取和写入都在事务中执行,不存在规则限制,同时也保障数据的一致性。

安全建议

在配置 Firebase 安全规则时,应该深入理解每个规则的含义,并注意不同操作的差异。仔细检查规则的逻辑,尤其是读取操作和不存在文档的规则, 可以帮助规避此类错误。

总结

理解 getDocsetDoc 操作之间的微妙差异以及它们在安全规则中的行为方式是解决此类问题的关键。通过调整读取规则、使用 setDocmerge 选项 或事务机制,能够克服权限不足的限制。 选择哪种解决方案取决于项目具体需求、对复杂性的容忍度以及对性能和安全性的权衡。