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

1. 事前準備

Cloud Firestore、Cloud Storage for Firebase 和即時資料庫依賴您寫入的設定檔來授予讀取和寫入權限。這項設定稱為安全性規則,也可以做為應用程式的一種結構定義。這是開發應用程式最重要的部分之一。本程式碼研究室將引導您完成這些步驟。

必要條件

  • 簡易編輯器,例如 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 Emulator 套件。

取得原始碼

在本程式碼研究室中,您將開始進行安全性規則的測試,但首先要介紹一些不同的安全性規則,因此首先需要複製來源以執行測試:

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

然後進入初始狀態目錄,您將在本程式碼研究室的其餘部分執行:

$ 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. 執行測試

在本節中,您將在本機執行測試。這表示可以啟動模擬器套件了。

啟動模擬器

您要使用的應用程式有三個主要的 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 變數。所有函式都必須以回傳敘述結尾,我們的 會傳回布林值,指出任一變數是否為 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 欄位無法變更。另外兩個欄位 (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 /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 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. 後續步驟

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

請參閱下列相關主題:

  • 網誌文章:如何程式碼審查安全性規則
  • 程式碼研究室:使用 Android Emulator 逐步進行本機首次開發作業
  • 影片:如何使用 GitHub Actions 為以模擬器為基礎的測試設定 CI