運用 Firebase 安全性規則保護 Firestore 資料

1. 事前準備

Cloud Firestore、Cloud Storage for Firebase 和 Realtime Database 依賴您編寫的設定檔授予讀取和寫入權限。這項設定稱為「安全規則」,也可以做為應用程式的結構定義。這是開發應用程式時最重要的環節之一。本程式碼研究室將逐步說明如何完成這項作業。

必要條件

  • 簡單的編輯器,例如 Visual Studio Code、Atom 或 Sublime Text
  • Node.js 8.6.0 以上版本 (如要安裝 Node.js,請使用 nvm;如要檢查版本,請執行 node --version)
  • Java 7 以上版本 (如要安裝 Java,請按照這些指示操作;如要查看版本,請執行 java -version)

執行步驟

在本程式碼研究室中,您將保護以 Firestore 建構的簡易網誌平台。您將使用 Firestore 模擬器對安全性規則執行單元測試,確保規則允許和禁止的存取權符合預期。

內容如下:

  • 授予精細權限
  • 強制執行資料和類型驗證
  • 實作屬性型存取權控管
  • 根據驗證方法授予存取權
  • 建立自訂函式
  • 建立以時間為準的安全性規則
  • 實作拒絕清單和虛刪除功能
  • 瞭解何時應將資料去正規化,以符合多種存取模式

2. 設定

這是部落格應用程式。以下是應用程式功能的大致摘要:

網誌文章草稿:

  • 使用者可以建立網誌文章草稿,這些草稿會儲存在「drafts」集合中。
  • 作者可以繼續更新草稿,直到準備好發布為止。
  • 準備好發布後,系統會觸發 Firebase 函式,在 published 集合中建立新文件。
  • 草稿可由作者或網站管理員刪除

已發布的網誌文章:

  • 使用者無法建立已發布的貼文,只能透過函式建立。
  • 只能虛刪除,也就是將 visible 屬性更新為 false。

註解

  • 發布的貼文允許留言,這些留言是每個發布貼文的子集合。
  • 為減少濫用情形,使用者必須有已驗證的電子郵件地址,且不在拒絕清單中,才能留言。
  • 留言發布後,只能在一小時內更新。
  • 留言者、原始貼文作者或管理員可以刪除留言。

除了存取規則,您還會建立安全性規則,強制執行必填欄位和資料驗證。

所有作業都會在本機進行,並使用 Firebase 模擬器套件。

取得原始碼

在本程式碼研究室中,您會先測試安全性規則,但安全性規則本身很少,因此您需要做的第一件事是複製來源來執行測試:

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

然後移至 initial-state 目錄,您將在本程式碼研究室的其餘部分使用這個目錄:

$ 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。這個程式碼研究室應適用於 8.4.0 以上版本,但後續版本包含更多錯誤修正。

$ 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}。(文件結構的註解有助於制定規則,因此會納入本程式碼研究室;註解一律為選用項目)。

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. 更新已發布的貼文:自訂函式和區域變數

更新已發布貼文的條件如下:

  • 只有作者或管理員可以執行這項操作。
  • 必須包含所有必填欄位。

由於您已為作者或版主撰寫條件,因此可以複製並貼上條件,但隨著時間推移,這可能會變得難以閱讀和維護。而是要建立自訂函式,封裝作者或版主身分的邏輯。然後從多個條件呼叫該函式。

建立自訂函式

在草稿的相符陳述式上方,建立名為 isAuthorOrModerator 的新函式,並將文章文件 (適用於草稿或已發布的文章) 和使用者的驗證物件做為引數:

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 /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. 後續步驟

恭喜!您已編寫安全性規則,讓所有測試都通過,並確保應用程式安全無虞!

以下是幾個相關主題,可供您深入瞭解:

  • 網誌文章:如何檢查安全性規則程式碼
  • 程式碼研究室:逐步瞭解如何使用模擬器進行本機優先開發
  • 影片:如何使用 GitHub Actions 設定 CI,以進行模擬器測試