使用 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 模擬器套件在本地進行。

取得原始碼

在此 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

您將用於執行測試的模擬器套件是 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 /{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. 更新已發佈的貼文:自訂函數和局部變數

更新已發布貼文的條件是:

  • 它只能由作者或版主完成,並且
  • 它必須包含所有必填欄位。

由於您已經編寫了成為作者或版主的條件,因此您可以複製並貼上這些條件,但隨著時間的推移,這些條件可能會變得難以閱讀和維護。相反,您將建立一個自訂函數來封裝作為作者或版主的邏輯。然後,您將從多個條件中呼叫它。

建立自訂函數

在草稿的匹配語句上方,建立一個名為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 進行基於模擬器的測試