Bảo vệ dữ liệu Firestore của bạn bằng Quy tắc bảo mật của Firebase

1. Trước khi bắt đầu

Cloud Firestore, Cloud Storage cho Firebase và Cơ sở dữ liệu theo thời gian thực dựa vào các tệp cấu hình mà bạn ghi để cấp quyền đọc và ghi. Cấu hình đó (gọi là Quy tắc bảo mật) cũng có thể đóng vai trò như một loại giản đồ cho ứng dụng của bạn. Đây là một trong những phần quan trọng nhất khi phát triển ứng dụng. Và lớp học lập trình này sẽ hướng dẫn bạn thực hiện việc đó.

Điều kiện tiên quyết

  • Một trình chỉnh sửa đơn giản như Visual Studio Code, Atom hoặc Sublime Text
  • Node.js 8.6.0 trở lên (để cài đặt Node.js, hãy sử dụng nvm; để kiểm tra phiên bản, hãy chạy node --version)
  • Java 7 trở lên (để cài đặt Java, hãy làm theo các hướng dẫn này; để kiểm tra phiên bản, hãy chạy java -version)

Những việc bạn sẽ làm

Trong lớp học lập trình này, bạn sẽ bảo mật một nền tảng blog đơn giản được xây dựng trên Firestore. Bạn sẽ sử dụng trình mô phỏng Firestore để chạy các kiểm thử đơn vị dựa trên Quy tắc bảo mật và đảm bảo rằng các quy tắc cho phép và không cho phép quyền truy cập mà bạn mong muốn.

Bạn sẽ tìm hiểu cách:

  • Cấp quyền ở cấp độ chi tiết
  • Thực thi xác thực dữ liệu và loại
  • Triển khai chế độ Kiểm soát quyền truy cập dựa trên thuộc tính
  • Cấp quyền truy cập dựa trên phương thức xác thực
  • Tạo hàm tuỳ chỉnh
  • Tạo quy tắc bảo mật dựa trên thời gian
  • Triển khai danh sách từ chối và xoá mềm
  • Hiểu rõ thời điểm cần chuẩn hoá dữ liệu để đáp ứng nhiều mẫu truy cập

2. Thiết lập

Đây là một ứng dụng viết blog. Sau đây là nội dung tóm tắt ở cấp độ cao về chức năng của ứng dụng:

Bài đăng trên blog ở dạng bản nháp:

  • Người dùng có thể tạo bài đăng nháp trên blog trong bộ sưu tập drafts.
  • Tác giả có thể tiếp tục cập nhật bản nháp cho đến khi bản nháp đó sẵn sàng được xuất bản.
  • Khi sẵn sàng xuất bản, một Hàm Firebase sẽ được kích hoạt để tạo một tài liệu mới trong bộ sưu tập published.
  • Tác giả hoặc người kiểm duyệt trang web có thể xoá bản nháp

Bài đăng trên blog đã xuất bản:

  • Người dùng không thể tạo bài đăng đã xuất bản, mà chỉ có thể tạo thông qua một hàm.
  • Bạn chỉ có thể xoá tạm thời các mục này, thao tác này sẽ cập nhật thuộc tính visible thành false.

Bình luận

  • Bài đăng đã xuất bản cho phép bình luận, đây là một bộ sưu tập con trên mỗi bài đăng đã xuất bản.
  • Để giảm hành vi sai trái, người dùng phải có địa chỉ email đã xác minh và không nằm trong danh sách từ chối để có thể để lại bình luận.
  • Bạn chỉ có thể cập nhật bình luận trong vòng một giờ sau khi đăng.
  • Người viết bình luận, tác giả của bài đăng ban đầu hoặc người kiểm duyệt đều có thể xoá bình luận.

Ngoài các quy tắc truy cập, bạn sẽ tạo Quy tắc bảo mật để thực thi các trường bắt buộc và xác thực dữ liệu.

Mọi thứ sẽ diễn ra trên thiết bị, bằng cách sử dụng Bộ công cụ mô phỏng Firebase.

Lấy mã nguồn

Trong lớp học lập trình này, bạn sẽ bắt đầu với các bài kiểm thử cho Quy tắc bảo mật, nhưng bản thân Quy tắc bảo mật là tối thiểu, vì vậy, việc đầu tiên bạn cần làm là sao chép nguồn để chạy các bài kiểm thử:

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

Sau đó, chuyển đến thư mục initial-state, nơi bạn sẽ làm việc trong phần còn lại của lớp học lập trình này:

$ cd codelab-rules/initial-state

Bây giờ, hãy cài đặt các phần phụ thuộc để bạn có thể chạy các kiểm thử. Nếu bạn đang sử dụng kết nối Internet chậm, quá trình này có thể mất một vài phút:

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

Tải Giao diện dòng lệnh (CLI) của Firebase

Emulator Suite mà bạn sẽ dùng để chạy các quy trình kiểm thử là một phần của Firebase CLI (giao diện dòng lệnh). Bạn có thể cài đặt giao diện này trên máy bằng lệnh sau:

$ npm install -g firebase-tools

Tiếp theo, hãy xác nhận rằng bạn có phiên bản mới nhất của CLI. Lớp học lập trình này sẽ hoạt động với phiên bản 8.4.0 trở lên, nhưng các phiên bản sau này có nhiều bản sửa lỗi hơn.

$ firebase --version
9.10.2

3. Chạy kiểm thử

Trong phần này, bạn sẽ chạy các kiểm thử cục bộ. Điều này có nghĩa là đã đến lúc khởi động Emulator Suite.

Khởi động Trình mô phỏng

Ứng dụng mà bạn sẽ làm việc có 3 bộ sưu tập Firestore chính: drafts chứa các bài đăng trên blog đang được thực hiện, bộ sưu tập published chứa các bài đăng trên blog đã được xuất bản và comments là một bộ sưu tập con trên các bài đăng đã xuất bản. Kho lưu trữ này đi kèm với các kiểm thử đơn vị cho Quy tắc bảo mật. Các quy tắc này xác định thuộc tính người dùng và các điều kiện khác mà người dùng cần đáp ứng để tạo, đọc, cập nhật và xoá tài liệu trong các tập hợp drafts, publishedcomments. Bạn sẽ viết Quy tắc bảo mật để vượt qua các bài kiểm thử đó.

Để bắt đầu, cơ sở dữ liệu của bạn sẽ bị khoá: mọi hoạt động đọc và ghi vào cơ sở dữ liệu đều bị từ chối trên toàn cầu và tất cả các kiểm thử đều thất bại. Khi bạn viết Quy tắc bảo mật, các bài kiểm thử sẽ đạt. Để xem các kiểm thử, hãy mở functions/test.js trong trình chỉnh sửa.

Trên dòng lệnh, hãy khởi động trình mô phỏng bằng cách sử dụng emulators:exec và chạy các kiểm thử:

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

Di chuyển lên đầu đầu ra:

$ 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

...

Hiện tại có 9 lỗi. Khi tạo tệp quy tắc, bạn có thể đo lường tiến trình bằng cách theo dõi thêm các bài kiểm thử đã vượt qua.

4. Tạo bản nháp bài đăng trên blog.

Vì quyền truy cập vào bài đăng trên blog nháp khác với quyền truy cập vào bài đăng trên blog đã xuất bản, nên ứng dụng viết blog này lưu trữ bài đăng trên blog nháp trong một bộ sưu tập riêng, /drafts. Chỉ tác giả hoặc người kiểm duyệt mới có thể truy cập vào bản nháp và bản nháp có các quy trình xác thực cho các trường bắt buộc và không thay đổi.

Khi mở tệp firestore.rules, bạn sẽ thấy một tệp quy tắc mặc định:

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

Câu lệnh so khớp, match /{document=**}, đang sử dụng cú pháp ** để áp dụng đệ quy cho tất cả tài liệu trong các bộ sưu tập con. Và vì nó ở cấp cao nhất, nên hiện tại, cùng một quy tắc chung áp dụng cho tất cả các yêu cầu, bất kể ai đang đưa ra yêu cầu hoặc dữ liệu mà họ đang cố gắng đọc hoặc ghi.

Bắt đầu bằng cách xoá câu lệnh khớp trong cùng và thay thế bằng match /drafts/{draftID}. (Nhận xét về cấu trúc của tài liệu có thể hữu ích trong các quy tắc và sẽ được đưa vào lớp học lập trình này; các nhận xét này luôn là không bắt buộc.)

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
    }
  }
}

Quy tắc đầu tiên mà bạn sẽ viết cho bản nháp sẽ kiểm soát những người có thể tạo tài liệu. Trong ứng dụng này, chỉ người được liệt kê là tác giả mới có thể tạo bản nháp. Kiểm tra để đảm bảo rằng UID của người gửi yêu cầu trùng với UID có trong giấy tờ.

Điều kiện đầu tiên để tạo sẽ là:

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

Tiếp theo, bạn chỉ có thể tạo tài liệu nếu tài liệu đó có 3 trường bắt buộc là authorUID, createdAttitle. (Người dùng không cung cấp trường createdAt; điều này buộc ứng dụng phải thêm trường này trước khi cố gắng tạo tài liệu.) Vì bạn chỉ cần kiểm tra xem các thuộc tính có đang được tạo hay không, nên bạn có thể kiểm tra xem request.resource có tất cả các khoá đó hay không:

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

Yêu cầu cuối cùng để tạo bài đăng trên blog là tiêu đề không được dài quá 50 ký tự:

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

Vì tất cả các điều kiện này đều phải đúng, nên hãy nối các điều kiện này với nhau bằng toán tử logic AND, &&. Quy tắc đầu tiên sẽ trở thành:

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;
    }
  }
}

Trong thiết bị đầu cuối, hãy chạy lại các bài kiểm thử và xác nhận rằng bài kiểm thử đầu tiên đạt.

5. Cập nhật bản nháp của bài đăng trên blog.

Tiếp theo, khi tinh chỉnh bản nháp bài đăng trên blog, tác giả sẽ chỉnh sửa tài liệu nháp. Tạo một quy tắc cho các điều kiện khi một bài đăng có thể được cập nhật. Trước tiên, chỉ tác giả mới có thể cập nhật bản nháp của mình. Xin lưu ý rằng ở đây, bạn kiểm tra UID đã được ghi,resource.data.authorUID:

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

Yêu cầu thứ hai đối với bản cập nhật là hai thuộc tính authorUIDcreatedAt không được thay đổi:

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

Cuối cùng, tiêu đề không được dài quá 50 ký tự:

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

Vì bạn cần đáp ứng tất cả các điều kiện này, hãy nối các điều kiện lại với nhau bằng &&:

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;

Các quy tắc hoàn chỉnh sẽ là:

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;
    }
  }
}

Chạy lại các kiểm thử và xác nhận rằng một kiểm thử khác đã đạt.

6. Xoá và đọc bản nháp: Kiểm soát quyền truy cập dựa trên thuộc tính

Tương tự như việc tạo và cập nhật bản nháp, tác giả cũng có thể xoá bản nháp.

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

Ngoài ra, những tác giả có thuộc tính isModerator trên mã thông báo xác thực của họ được phép xoá bản nháp:

request.auth.token.isModerator == true

Vì một trong hai điều kiện này là đủ để xoá, hãy nối các điều kiện đó bằng toán tử logic OR, ||:

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

Các điều kiện tương tự áp dụng cho hoạt động đọc, vì vậy, bạn có thể thêm quyền vào quy tắc:

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

Toàn bộ quy tắc hiện là:

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;
    }
  }
}

Chạy lại các kiểm thử và xác nhận rằng một kiểm thử khác hiện đã đạt.

7. Đọc, tạo và xoá đối với các bài đăng đã xuất bản: chuẩn hoá cho các mẫu truy cập khác nhau

Vì các mẫu truy cập cho bài đăng đã xuất bản và bài đăng nháp rất khác nhau, nên ứng dụng này sẽ chuẩn hoá các bài đăng thành các bộ sưu tập draftpublished riêng biệt. Ví dụ: mọi người đều có thể đọc bài đăng đã xuất bản nhưng không thể xoá vĩnh viễn, còn bản nháp có thể bị xoá nhưng chỉ tác giả và người kiểm duyệt mới có thể đọc. Trong ứng dụng này, khi người dùng muốn xuất bản một bài đăng nháp trên blog, một hàm sẽ được kích hoạt để tạo bài đăng mới đã xuất bản.

Tiếp theo, bạn sẽ viết các quy tắc cho bài đăng đã xuất bản. Quy tắc đơn giản nhất để viết là mọi người đều có thể đọc bài đăng đã xuất bản, nhưng không ai có thể tạo hoặc xoá bài đăng. Thêm các quy tắc sau:

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;
}

Khi thêm các quy tắc này vào các quy tắc hiện có, toàn bộ tệp quy tắc sẽ trở thành:

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;
    }
  }
}

Chạy lại các kiểm thử và xác nhận rằng một kiểm thử khác đạt.

8. Cập nhật bài đăng đã xuất bản: Hàm tuỳ chỉnh và biến cục bộ

Các điều kiện để cập nhật một bài đăng đã xuất bản là:

  • chỉ tác giả hoặc người kiểm duyệt mới có thể thực hiện việc này, và
  • nguồn cấp dữ liệu đó phải chứa tất cả các trường bắt buộc.

Vì đã viết các điều kiện để trở thành tác giả hoặc người kiểm duyệt, nên bạn có thể sao chép và dán các điều kiện đó. Tuy nhiên, theo thời gian, việc này có thể khiến mã trở nên khó đọc và khó duy trì. Thay vào đó, bạn sẽ tạo một hàm tuỳ chỉnh bao bọc logic để trở thành tác giả hoặc người kiểm duyệt. Sau đó, bạn sẽ gọi hàm này từ nhiều điều kiện.

Tạo hàm tuỳ chỉnh

Phía trên câu lệnh so khớp cho bản nháp, hãy tạo một hàm mới có tên là isAuthorOrModerator. Hàm này sẽ lấy một tài liệu bài đăng (hàm này sẽ hoạt động cho cả bản nháp hoặc bài đăng đã xuất bản) và đối tượng uỷ quyền của người dùng làm đối số:

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: ...
    }
  }
}

Sử dụng biến cục bộ

Bên trong hàm, hãy dùng từ khoá let để đặt các biến isAuthorisModerator. Tất cả các hàm phải kết thúc bằng một câu lệnh trả về và hàm của chúng ta sẽ trả về một giá trị boolean cho biết liệu một trong hai biến có đúng hay không:

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

Gọi hàm

Bây giờ, bạn sẽ cập nhật quy tắc cho bản nháp để gọi hàm đó, nhớ truyền resource.data làm đối số đầu tiên:

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

Giờ đây, bạn có thể viết một điều kiện để cập nhật các bài đăng đã xuất bản bằng cách sử dụng hàm mới:

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

Thêm quy trình xác thực

Bạn không nên thay đổi một số trường của bài đăng đã xuất bản, cụ thể là các trường url, authorUIDpublishedAt là bất biến. Hai trường còn lại là titlecontent, cũng như visible vẫn phải xuất hiện sau khi cập nhật. Thêm điều kiện để thực thi các yêu cầu này đối với nội dung cập nhật cho bài đăng đã xuất bản:

// 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"
])

Tự tạo hàm tuỳ chỉnh

Cuối cùng, hãy thêm một điều kiện là tiêu đề phải có ít hơn 50 ký tự. Vì đây là logic được dùng lại, nên bạn có thể thực hiện việc này bằng cách tạo một hàm mới, titleIsUnder50Chars. Với hàm mới này, điều kiện để cập nhật một bài đăng đã xuất bản sẽ là:

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);

Và tệp quy tắc hoàn chỉnh là:

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);
    }
  }
}

Chạy lại các bài kiểm thử. Tại thời điểm này, bạn sẽ có 5 bài kiểm thử thành công và 4 bài kiểm thử không thành công.

9. Nhận xét: Tập hợp con và quyền của nhà cung cấp dịch vụ đăng nhập

Các bài đăng đã xuất bản cho phép bình luận và các bình luận được lưu trữ trong một bộ sưu tập con của bài đăng đã xuất bản (/published/{postID}/comments/{commentID}). Theo mặc định, các quy tắc của một bộ sưu tập không áp dụng cho các bộ sưu tập con. Bạn không muốn các quy tắc áp dụng cho tài liệu mẹ của bài đăng đã xuất bản áp dụng cho bình luận; bạn sẽ tạo các quy tắc khác.

Để viết quy tắc truy cập vào bình luận, hãy bắt đầu bằng câu lệnh match:

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

Đọc bình luận: Không thể ẩn danh

Đối với ứng dụng này, chỉ những người dùng đã tạo tài khoản vĩnh viễn (không phải tài khoản ẩn danh) mới có thể đọc bình luận. Để thực thi quy tắc đó, hãy tra cứu thuộc tính sign_in_provider có trên mỗi đối tượng auth.token:

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

Chạy lại các kiểm thử và xác nhận rằng có thêm một kiểm thử đạt.

Tạo bình luận: Kiểm tra danh sách từ chối

Có 3 điều kiện để tạo bình luận:

  • người dùng phải có email đã xác minh
  • bình luận không được vượt quá 500 ký tự và
  • họ không thể có trong danh sách người dùng bị cấm, được lưu trữ trong Firestore trong bộ sưu tập bannedUsers. Hãy xem xét từng điều kiện một:
request.auth.token.email_verified == true
request.resource.data.comment.size() < 500
!exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

Quy tắc cuối cùng để viết bình luận là:

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));

Toàn bộ tệp quy tắc hiện là:

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));
    }
  }
}

Chạy lại các bài kiểm thử và đảm bảo có thêm một bài kiểm thử đạt.

10. Cập nhật bình luận: Quy tắc dựa trên thời gian

Theo logic nghiệp vụ, tác giả có thể chỉnh sửa bình luận trong vòng một giờ sau khi tạo. Để triển khai việc này, hãy sử dụng dấu thời gian createdAt.

Trước tiên, để xác định rằng người dùng là tác giả:

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

Tiếp theo, nhận xét được tạo trong vòng một giờ qua:

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

Khi kết hợp các điều kiện này với toán tử AND logic, quy tắc cập nhật bình luận sẽ là:

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');

Chạy lại các bài kiểm thử và đảm bảo có thêm một bài kiểm thử đạt.

11. Xoá bình luận: kiểm tra quyền sở hữu của cha mẹ

Bình luận có thể bị xoá bởi tác giả bình luận, người kiểm duyệt hoặc tác giả bài đăng trên blog.

Trước tiên, vì hàm trợ giúp mà bạn đã thêm trước đó sẽ kiểm tra trường authorUID có thể tồn tại trên bài đăng hoặc bình luận, nên bạn có thể dùng lại hàm trợ giúp này để kiểm tra xem người dùng có phải là tác giả hay người kiểm duyệt hay không:

isAuthorOrModerator(resource.data, request.auth)

Để kiểm tra xem người dùng có phải là tác giả của bài đăng trên blog hay không, hãy sử dụng get để tra cứu bài đăng trong Firestore:

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

Vì bất kỳ điều kiện nào trong số này đều đủ, hãy sử dụng toán tử logic OR giữa các điều kiện:

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;

Chạy lại các bài kiểm thử và đảm bảo có thêm một bài kiểm thử đạt.

Và toàn bộ tệp quy tắc là:

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. Các bước tiếp theo

Xin chúc mừng! Bạn đã viết Quy tắc bảo mật giúp tất cả các bài kiểm thử đều đạt và bảo mật ứng dụng!

Sau đây là một số chủ đề liên quan mà bạn nên tìm hiểu thêm:

  • Bài đăng trên blog: Cách xem xét mã của Quy tắc bảo mật
  • Lớp học lập trình: hướng dẫn quy trình phát triển ưu tiên cục bộ bằng Trình mô phỏng
  • Video: Cách thiết lập CI cho các kiểm thử dựa trên trình mô phỏng bằng cách sử dụng Thao tác GitHub