使用 Firebase 安全规则保护您的 Firestore 数据

1. 准备工作

Cloud Firestore、Cloud Storage for Firebase 和 Realtime Database 依赖您写入的配置文件来授予读写权限。该配置(称为安全规则)也可以充当应用的某种架构,是应用开发中最重要的部分之一。此 Codelab 将逐步引导您完成相关操作。

前提条件

  • 一个简单的编辑器,例如 Visual Studio Code、Atom 或 Sublime Text
  • Node.js 8.6.0 或更高版本(如需安装 Node.js,请使用 nvm;如需查看版本,请运行 node --version
  • Java 7 或更高版本(如需安装 Java,请按照这些说明安装;如需检查您的版本,请运行 java -version

您将执行的操作

在此 Codelab 中,您将保护一个基于 Firestore 构建的简单博客平台。您将使用 Firestore 模拟器针对安全规则运行单元测试,并确保规则允许和禁止您期望的访问。

您将了解如何:

  • 授予精细权限
  • 强制执行数据和类型验证
  • 实现基于属性的访问控制
  • 根据身份验证方法授予访问权限
  • 创建自定义函数
  • 创建基于时间的安全规则
  • 实现拒绝名单和软删除
  • 了解何时对数据进行反规范化以满足多种访问模式

2. 设置

这是一款博客应用。以下是对该应用功能的简要说明:

博文草稿

  • 用户可以创建博文草稿,将其保存在“drafts”集合中。
  • 在草稿可以发布之前,作者可以继续更新草稿。
  • 当数据可供发布时,系统会触发一个 Firebase 函数,该函数会在 published 集合中创建一个新文档。
  • 草稿可以由作者或网站管理员删除

已发布的博文

  • 用户无法创建已发布的帖子,只能通过函数创建。
  • 只能软删除它们,此操作会将 visible 属性更新为 false。

注释

  • 已发布的博文允许评论,评论是每个已发布博文的子集合。
  • 为减少滥用行为,用户必须拥有经过验证的电子邮件地址,且不是拒绝者,才能发表评论。
  • 评论只能在发布后的一小时内更新。
  • 评论的作者、原帖子的作者或管理员都可以删除评论。

除了访问规则外,您还将创建强制执行必填字段和数据验证的安全规则。

一切都将使用 Firebase Emulator Suite 在本地进行。

获取源代码

在此 Codelab 中,您首先要针对安全规则的测试开始测试,但规则本身要尽可能相似,因此您首先需要克隆源代码以运行测试:

$ git clone https://github.com/FirebaseExtended/codelab-rules.git

然后进入初始状态目录,您将在该目录中完成本 Codelab 的剩余部分:

$ cd codelab-rules/initial-state

现在,安装依赖项,以便运行测试。如果您的互联网连接速度较慢,此过程可能需要一两分钟的时间:

# Move into the functions directory, install dependencies, jump out.
$ cd functions && npm install && cd -

获取 Firebase CLI

您将用于运行测试的 Emulator Suite 是 Firebase CLI(命令行界面)的一部分,可以使用以下命令安装在您的计算机上:

$ npm install -g firebase-tools

接下来,确认您拥有最新版本的 CLI。此 Codelab 应当适用于 8.4.0 或更高版本,但后续版本包含更多 bug 修复。

$ firebase --version
9.10.2

3. 运行测试

在本部分中,您将在本地运行测试。这意味着该启动 Emulator Suite 了。

启动模拟器

您要使用的应用有三个主要的 Firestore 集合:drafts 包含正在处理的博文,published 集合包含已发布的博文,comments 是已发布博文的子集合。该代码库包含安全规则的单元测试,用于定义用户属性以及用户在 draftspublishedcomments 集合中创建、读取、更新和删除文档所需的其他条件。您将编写安全规则,以通过这些测试。

首先,您的数据库被锁定:对数据库的读写操作会被普遍拒绝,并且所有测试都会失败。在您编写安全规则时,测试将会通过。如需查看这些测试,请在编辑器中打开 functions/test.js

在命令行中,使用 emulators:exec 启动模拟器并运行测试:

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"

滚动到输出的顶部:

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
⚠  functions: Unable to fetch project Admin SDK configuration, Admin SDK behavior in Cloud Functions emulator may be incorrect.
i  firestore: Importing data from /Users/user/src/firebase/rules-codelab/initial-state/.seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
⚠  hosting: Authentication error when trying to fetch your current web app configuration, have you run firebase login?
⚠  hosting: Could not fetch web app configuration and there is no cached configuration on this machine. Check your internet connection and make sure you are authenticated. To continue, you must call firebase.initializeApp({...}) in your code before using Firebase.
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/user/src/firebase/rules-codelab/initial-state/functions" for Cloud Functions...
✔  functions[publishPost]: http function initialized (http://localhost:5001/codelab/us-central1/publishPost).
✔  functions[softDelete]: http function initialized (http://localhost:5001/codelab/us-central1/softDelete).
i  Running script: pushd functions; npm test
~/src/firebase/rules-codelab/initial-state/functions ~/src/firebase/rules-codelab/initial-state

> functions@ test /Users/user/src/firebase/rules-codelab/initial-state/functions
> mocha

(node:76619) ExperimentalWarning: Conditional exports is an experimental feature. This feature could change at any time


  Draft blog posts
    1) can be created with required fields by the author
    2) can be updated by author if immutable fields are unchanged
    3) can be read by the author and moderator

  Published blog posts
    4) can be read by everyone; created or deleted by no one
    5) can be updated by author or moderator

  Comments on published blog posts
    6) can be read by anyone with a permanent account
    7) can be created if email is verfied and not blocked
    8) can be updated by author for 1 hour after creation
    9) can be deleted by an author or moderator


  0 passing (848ms)
  9 failing

...

目前有 9 次失败。构建规则文件时,您可以通过观察更多测试通过情况来衡量进度。

4.创建博文草稿。

由于对博文草稿的访问权限与对已发布博文的访问权限截然不同,因此该博客应用会将博文草稿存储在单独的集合 /drafts 中。草稿只能由作者或审核人访问,并且有必填字段和不可变字段的验证。

打开 firestore.rules 文件,您会看到一个默认的规则文件:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

匹配语句 match /{document=**} 使用 ** 语法以递归方式应用于子集合中的所有文档。由于它位于顶层,因此目前相同的一揽子规则适用于所有请求,无论请求是谁,也无论他们尝试读取或写入哪些数据。

首先移除最内层的匹配语句,并将其替换为 match /drafts/{draftID}。(文档结构注释在规则中可能很有用,并且会包含在此 Codelab 中;注释始终是可选的。)

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional
    }
  }
}

您要为草稿编写的第一条规则将控制哪些人可以创建文档。在此应用中,草稿只能由列为作者的人员创建。检查发出请求的人员的 UID 是否与文档中列出的 UID 一致。

创建的第一个条件是:

request.resource.data.authorUID == request.auth.uid

接下来,只有包含 authorUIDcreatedAttitle 这三个必填字段的文档才能创建。(用户不提供 createdAt 字段;这会强制应用必须先添加该字段,然后才能尝试创建文档。)由于您只需检查属性是否正在创建中,因此可以检查 request.resource 是否具有所有这些键:

request.resource.data.keys().hasAll([
  "authorUID",
  "createdAt",
  "title"
])

创建博文的最终要求是标题不得超过 50 个字符:

request.resource.data.title.size() < 50

由于所有这些条件都必须为 true,因此请使用逻辑 AND 运算符 && 将这些条件连接在一起。第一条规则会变为:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

在终端中,重新运行测试并确认第一个测试通过。

5. 更新博文草稿。

接下来,当作者优化自己的博文草稿时,会修改草稿文档。创建规则,指定帖子可以更新的条件。首先,只有作者才能更新自己的草稿。请注意,此时您要检查已写入的 UID,resource.data.authorUID

resource.data.authorUID == request.auth.uid

更新的第二个要求是 authorUIDcreatedAt 这两个属性不应更改:

request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
]);

最后,标题不应超过 50 个字符:

request.resource.data.title.size() < 50;

由于必须满足以下所有条件,因此请使用 && 将它们串联在一起:

allow update: if
  // User is the author, and
  resource.data.authorUID == request.auth.uid &&
  // `authorUID` and `createdAt` are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
  ]) &&
  // Title must be < 50 characters long
  request.resource.data.title.size() < 50;

完整的规则如下所示:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

重新运行测试并确认其他测试会通过。

6. 删除和读取草稿:基于属性的访问权限控制

正如作者可以创建和更新草稿一样,他们也可以删除草稿。

resource.data.authorUID == request.auth.uid

此外,身份验证令牌上具有 isModerator 属性的作者可以删除草稿:

request.auth.token.isModerator == true

由于上述任一条件对于删除来说都足够,因此请使用逻辑 OR 运算符 || 将它们连接起来:

allow delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

读取也适用相同的条件,以便向规则添加权限:

allow read, delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

现在,完整规则如下所示:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }
  }
}

重新运行测试并确认其他测试现在会通过。

7. 读取、创建和删除已发布博文:针对不同的访问模式进行反规范化

由于已发布的博文和博文草稿的访问模式截然不同,因此该应用会将这些博文反规范化为单独的 draftpublished 集合。例如,任何人都可以阅读已发布的帖子,但不能硬删除的帖子;而草稿可以删除,但只有作者和管理员能够读取。在此应用中,当用户想要发布博文草稿时,系统会触发一个函数,用于创建新发布的博文。

接下来,您将编写发布帖子的规则。最简单的编写规则是:任何人都可以阅读已发布的帖子,而任何人都不能创建或删除这些帖子。添加这些规则:

match /published/{postID} {
  // `authorUID`: string, required
  // `content`: string, required
  // `publishedAt`: timestamp, required
  // `title`: string, < 50 characters, required
  // `url`: string, required
  // `visible`: boolean, required

  // Can be read by everyone
  allow read: if true;

  // Published posts are created only via functions, never by users
  // No hard deletes; soft deletes update `visible` field.
  allow create, delete: if false;
}

将这些规则添加到现有规则后,整个规则文件将变为:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }

    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;
    }
  }
}

重新运行测试,并确认其他测试会通过。

8. 更新已发布的帖子:自定义函数和局部变量

更新已发布帖子的条件如下:

  • 只能由作者或审核人执行
  • 其中必须包含所有必填字段

由于您已经编写了有关成为作者或管理员的条件,您可以复制并粘贴这些条件,但随着时间推移,可能难以阅读和维护这些条件。而是创建一个自定义函数,用于封装成为作者或管理员的逻辑。然后,从多个条件对其进行调用。

创建自定义函数

在草稿的 match 语句上方,创建一个名为 isAuthorOrModerator 的新函数,该函数以博文文档(这适用于草稿或已发布的博文)和用户的 auth 对象作为参数:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {

    }

    match /drafts/{postID} {
      allow create: ...
      allow update: ...
      ...
    }

    match /published/{postID} {
      allow read: ...
      allow create, delete: ...
    }
  }
}

使用局部变量

在该函数内,使用 let 关键字设置 isAuthorisModerator 变量。所有函数都必须以 return 语句结尾,我们的函数将返回一个布尔值,指示其中任一变量是否为 true:

function isAuthorOrModerator(post, auth) {
  let isAuthor = auth.uid == post.authorUID;
  let isModerator = auth.token.isModerator == true;
  return isAuthor || isModerator;
}

调用函数

现在,您将更新草稿规则以调用该函数,但要注意传入 resource.data 作为第一个参数:

  // Draft blog posts
  match /drafts/{draftID} {
    ...
    // Can be deleted by author or moderator
    allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
  }

现在,您可以编写一个也使用新函数来更新已发布帖子的条件:

allow update: if isAuthorOrModerator(resource.data, request.auth);

添加验证

已发布博文的某些字段不应更改,特别是 urlauthorUIDpublishedAt 字段是不可变的。更新后,另外两个字段 titlecontent 以及 visible 必须仍然存在。添加条件,以强制对已发布帖子进行更新:

// Immutable fields are unchanged
request.resource.data.diff(resource.data).unchangedKeys().hasAll([
  "authorUID",
  "publishedAt",
  "url"
]) &&
// Required fields are present
request.resource.data.keys().hasAll([
  "content",
  "title",
  "visible"
])

自行创建自定义函数

最后,添加一个条件,确保商品名长度不超过 50 个字符。由于这是重复使用的逻辑,因此您可以通过创建新函数 titleIsUnder50Chars 来实现这一点。使用新函数时,更新已发布博文的条件变为:

allow update: if
  isAuthorOrModerator(resource.data, request.auth) &&
  // Immutable fields are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "publishedAt",
    "url"
  ]) &&
  // Required fields are present
  request.resource.data.keys().hasAll([
    "content",
    "title",
    "visible"
  ]) &&
  titleIsUnder50Chars(request.resource.data);

完整的规则文件如下:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }
  }
}

重新运行测试。此时,您应该有 5 个通过的测试和 4 个失败的测试。

9. 备注:子集合和登录提供方权限

已发布的帖子允许发表评论,评论会存储在已发布帖子的子集合 (/published/{postID}/comments/{commentID}) 中。默认情况下,集合的规则不适用于子集合。您不想让应用于已发布博文的父文档的规则同样应用于评论,您需要创建不同的规则。

如需编写访问注释的规则,请先使用 match 语句:

match /published/{postID}/comments/{commentID} {
  // `authorUID`: string, required
  // `comment`: string, < 500 characters, required
  // `createdAt`: timestamp, required
  // `editedAt`: timestamp, optional

阅读评论:无法匿名

对于此应用,只有创建了永久账号的用户(而非匿名账号)才能查看评论。如需强制执行该规则,请查找每个 auth.token 对象上的 sign_in_provider 属性:

allow read: if request.auth.token.firebase.sign_in_provider != "anonymous";

重新运行测试,并确认再有一项测试通过。

创建评论:检查拒绝名单

创建评论有三个条件:

  • 用户必须有经过验证的电子邮件地址
  • 评论必须少于 500 个字符,并且
  • 不能位于被禁止的用户名单中,该列表存储在 Firestore 的 bannedUsers 集合中。一次满足一个条件:
request.auth.token.email_verified == true
request.resource.data.comment.size() < 500
!exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

创建评论的最后一条规则是:

allow create: if
  // User has verified email
  (request.auth.token.email_verified == true) &&
  // UID is not on bannedUsers list
  !(exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

整个规则文件现在如下所示:

For bottom of step 9
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));
    }
  }
}

重新运行测试,并确保再通过一次测试。

10. 更新评论:基于时间的规则

评论的业务逻辑是,评论作者可以在创建后一小时内对其进行修改。如需实现这一点,请使用 createdAt 时间戳。

首先,要确定用户是作者,请执行以下操作:

request.auth.uid == resource.data.authorUID

其次,该评论是在过去一小时内创建的:

(request.time - resource.data.createdAt) < duration.value(1, 'h');

将这些与逻辑 AND 运算符结合,更新注释的规则将变为:

allow update: if
  // is author
  request.auth.uid == resource.data.authorUID &&
  // within an hour of comment creation
  (request.time - resource.data.createdAt) < duration.value(1, 'h');

重新运行测试,并确保再通过一次测试。

11. 删除评论:检查父级所有权

评论的作者、管理员或博文的作者都可以删除评论。

首先,由于您先前添加的辅助函数会检查帖子或评论中可能存在的 authorUID 字段,因此您可以重复使用该辅助函数来检查用户是作者还是管理员:

isAuthorOrModerator(resource.data, request.auth)

如需检查用户是否为博文作者,请使用 get 在 Firestore 中查找该博文:

request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID

由于满足其中任何一个条件都足够,因此在它们之间使用逻辑 OR 运算符:

allow delete: if
  // is comment author or moderator
  isAuthorOrModerator(resource.data, request.auth) ||
  // is blog post author
  request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;

重新运行测试,并确保再通过一次测试。

整个规则文件如下所示:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

      allow update: if
        // is author
        request.auth.uid == resource.data.authorUID &&
        // within an hour of comment creation
        (request.time - resource.data.createdAt) < duration.value(1, 'h');

      allow delete: if
        // is comment author or moderator
        isAuthorOrModerator(resource.data, request.auth) ||
        // is blog post author
        request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;
    }
  }
}

12. 后续步骤

恭喜!您编写了安全规则,使所有测试都通过并保护了该应用!

以下是一些接下来需要深入探讨的相关主题:

  • 博文:如何对安全规则进行代码审核
  • Codelab:使用模拟器逐步完成本地开发
  • 视频:如何使用 GitHub Actions 为基于模拟器的测试设置 CI