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

使用集合让一切井井有条 根据您的偏好保存内容并对其进行分类。

1. 开始之前

Cloud Firestore、Cloud Storage for Firebase 和实时数据库依赖于您编写的配置文件来授予读写权限。该配置称为安全规则,也可以作为您的应用程序的一种模式。它是开发应用程序的最重要部分之一。这个 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 或更高版本,但更高版本包含更多错误修复。

$ firebase --version
9.10.2

3. 运行测试

在本节中,您将在本地运行测试。这意味着是时候启动模拟器套件了。

启动模拟器

您将使用的应用程序具有三个主要的 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 语句match /{document=**}使用**语法递归地应用于子集合中的所有文档。而且因为它处于顶层,所以现在相同的一揽子规则适用于所有请求,无论是谁发出请求或他们试图读取或写入什么数据。

首先删除最里面的 match 语句并将其替换为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

由于所有这些条件都必须为真,因此将它们与逻辑 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 语句结尾,我们的函数将返回一个布尔值,指示任一变量是否为真:

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字段是不可变的。其他两个字段titlecontentvisible必须在更新后仍然存在。添加条件以强制执行这些更新已发布帖子的要求:

// 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 个字符,并且
  • 他们不能出现在被禁止用户列表中,该列表存储在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 charachters
        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 charachters
        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 进行基于模拟器的测试
,

1. 开始之前

Cloud Firestore、Cloud Storage for Firebase 和实时数据库依赖于您编写的配置文件来授予读写权限。该配置称为安全规则,也可以作为您的应用程序的一种模式。它是开发应用程序的最重要部分之一。这个 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 或更高版本,但更高版本包含更多错误修复。

$ firebase --version
9.10.2

3. 运行测试

在本节中,您将在本地运行测试。这意味着是时候启动模拟器套件了。

启动模拟器

您将使用的应用程序具有三个主要的 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 语句match /{document=**}使用**语法递归地应用于子集合中的所有文档。而且因为它处于顶层,所以现在相同的一揽子规则适用于所有请求,无论是谁发出请求或他们试图读取或写入什么数据。

首先删除最里面的 match 语句并将其替换为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

由于所有这些条件都必须为真,因此将它们与逻辑 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 语句结尾,我们的函数将返回一个布尔值,指示任一变量是否为真:

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字段是不可变的。其他两个字段titlecontentvisible必须在更新后仍然存在。添加条件以强制执行这些更新已发布帖子的要求:

// 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 个字符,并且
  • 他们不能出现在被禁止用户列表中,该列表存储在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 charachters
        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 charachters
        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 进行基于模拟器的测试