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 đó (được 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 trong quá trình phát triển ứng dụng của bạn. Lớp học lập trình này sẽ hướng dẫn bạn từng bướ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 của bạn, hãy chạy node --version)
  • Java 7 trở lên (để cài đặt Java, hãy sử dụng các hướng dẫn này; để kiểm tra phiên bản của bạn, hãy chạy java -version)

Bạn sẽ thực hiện

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 kiểm thử đơn vị theo Quy tắc bảo mật và đảm bảo rằng các quy tắc này cho phép và không cho phép quyền truy cập như bạn mong muốn.

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

  • Cấp các quyền chi tiết
  • Thực thi xác thực dữ liệu và loại
  • Triển khai tính năng 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 pháp 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á tạm thời
  • Tìm hiểu thời điểm nên huỷ chuẩn hoá dữ liệu để đáp ứng nhiều kiểu truy cập

2. Thiết lập

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

Bài đăng nháp trên blog:

  • Người dùng có thể tạo các bài đăng nháp trên blog, nằm 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 xuất bản.
  • Khi tài liệu đã 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 tập hợp published.
  • Tác giả hoặc người kiểm duyệt trang web có thể xoá bản nháp

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

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

Bình luận

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

Ngoài 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à quy trình xác thực dữ liệu.

Mọi thứ sẽ diễn ra trên thiết bị bằng Bộ 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 bằng 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 nhỏ. Vì vậy, việc đầu tiên bạn cần làm là sao chép nguồn để chạy kiểm thử:

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

Sau đó, hãy chuyển sang thư mục trạng thái ban đầu để xử lý 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 để có thể chạy kiểm thử. Nếu bạn đang sử dụng kết nối Internet chậm hơn, quá trình này có thể mất một hoặc hai 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

Bộ mô phỏng mà bạn dùng để chạy kiểm thử là một phần của Firebase CLI (giao diện dòng lệnh) có thể được cài đặt trên máy của bạn 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 CLI mới nhất. Lớp học lập trình này 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 sẽ sửa nhiều 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 bài kiểm thử cục bộ. Điều này có nghĩa là đã đến lúc khởi động Bộ mô phỏng.

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

Ứng dụng bạn sẽ làm việc có ba bộ sưu tập Firestore chính: drafts chứa các bài đăng trên blog đang diễn ra, 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 bài kiểm thử đơn vị cho Quy tắc bảo mật xác định thuộc tính người dùng và các điều kiện cần thiết khác để người dùng tạo, đọc, cập nhật và xoá tài liệu trong bộ sưu tập drafts, publishedcomments. Bạn sẽ viết Quy tắc bảo mật để vượt qua các lượt kiểm thử đó.

Để bắt đầu, cơ sở dữ liệu của bạn sẽ bị khoá: hoạt động đọc và ghi vào cơ sở dữ liệu bị từ chối trên toàn cầu và tất cả các lượt kiểm thử đều không đạt. Khi bạn viết Quy tắc bảo mật, các kiểm thử sẽ thành công. Để 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 emulators:exec rồi chạy chương trình kiểm thử:

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

Di chuyển lên đầu kết quả:

$ 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 xem có nhiều lượt kiểm thử đạt hơn.

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

Vì quyền truy cập vào bài đăng nháp trên blog khác với quyền truy cập vào các bài đăng đã xuất bản trên blog, nên ứng dụng viết blog này lưu trữ các bài đăng trên blog nháp trong một tập hợp riêng biệt là /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, đồng thời có quy trình xác thực cho các trường bắt buộc và không thể 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 tập hợp con. Và vì đây là yêu cầu ở cấp cao nhất, nên bây giờ, 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 hay họ đang cố gắng đọc hoặc ghi dữ liệu nào.

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}. (Chú thích 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ó trong lớp học lập trình này (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 bạn 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. Hãy kiểm tra để đảm bảo UID của người đưa ra yêu cầu giống với UID được nêu trong tài liệu.

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

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

Tiếp theo, tài liệu chỉ có thể được tạo nếu bao gồm ba trường bắt buộc là authorUID,createdAttitle. (Người dùng không cung cấp trường createdAt; trường hợp này thực thi việc ứng dụng phải thêm trường này thì mới có thể tạo tài liệu.) Bạn chỉ cần kiểm tra để đảm bảo các thuộc tính đang được tạo, nên bạn có thể kiểm tra để đảm bảo rằng request.resource có tất cả các khoá đó:

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

Yêu cầu cuối cùng để tạo một 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, hãy nối các điều kiện này với toán tử logic AND, &&. Quy tắc đầu tiên 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 cửa sổ dòng lệnh, hãy chạy lại quy trình kiểm thử và xác nhận rằng lần kiểm thử đầu tiên thành công.

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

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

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

Yêu cầu thứ hai để cập nhật là 2 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ì tất cả các điều kiện này đều cần được đáp ứng, hãy nối các điều kiện đó với &&:

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

Chạy lại chương trình kiểm thử và xác nhận rằng một lượt kiểm thử khác đã thành công.

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ác giả có thể tạo và cập nhật bản nháp, họ cũng có thể xoá bản nháp.

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

Ngoài ra, tác giả có thuộc tính isModerator trên mã xác thực của họ cũng đượ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 đó với 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ự cũng áp dụng cho lượt đọc để 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 tại 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 chương trình kiểm thử và xác nhận rằng một lượt kiểm thử khác hiện đã thành công.

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

Do mô hình 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 huỷ chuẩn hoá các bài đăng thành các bộ sưu tập draftpublished riêng biệt. Ví dụ: bất kỳ ai cũng có thể đọc bài đăng đã xuất bản nhưng không được xoá vĩnh viễn bài đăng, trong khi có thể xoá bản nháp 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à bất kỳ ai cũng có thể đọc bài đăng đã xuất bản và không ai có thể tạo hoặc xóa. 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;
}

Thêm các quy tắc này vào quy tắc hiện tại, 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 lượt kiểm thử và xác nhận rằng một lượt kiểm thử khác đã thành công.

8. Cập nhật các 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 và
  • trường phải chứa tất cả các trường bắt buộc.

Vì bạn đã viết điều kiện để trở thành tác giả hoặc người kiểm duyệt, bạn có thể sao chép và dán các điều kiện, nhưng theo thời gian, điều này có thể trở nên khó đọc và duy trì. Thay vào đó, bạn sẽ tạo một hàm tùy chỉnh đóng gói logic để trở thành tác giả hoặc người kiểm duyệt. Sau đó, bạn sẽ gọi phương thức 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 làm đối số cho tài liệu bài đăng (điều này sẽ hoạt động với bản nháp hoặc bài đăng đã xuất bản) và đối tượng xác thực của người dùng:

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 các biến cục bộ

Bên trong hàm này, hãy dùng từ khoá let để đặt các biến isAuthorisModerator. Tất cả các hàm đều 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 một trong hai biến đó có giá trị true 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 đó, hãy cẩn thận 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 bài đăng đã xuất bản cũng 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à không thể thay đổi. Hai trường còn lại (title, contentvisible) vẫn phải xuất hiện sau khi cập nhật. Thêm điều kiện để thực thi những yêu cầu sau đâ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 một hàm tuỳ chỉnh

Cuối cùng, hãy thêm một điều kiện là tiêu đề phải dưới 50 ký tự. Vì đây là logic sử 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 là titleIsUnder50Chars. Với hàm mới này, điều kiện để cập nhật bài đăng đã xuất bản sẽ trở thành:

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 lượt kiểm thử đạt và 4 lượt kiểm thử không đạt.

9. Nhận xét: Quyền của nhà cung cấp dịch vụ đăng nhập và bộ sưu tập phụ

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

Để viết các quy tắc truy cập nhận xét, hãy bắt đầu với câu lệnh khớp:

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 chứ 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 trên từng đối tượng auth.token:

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

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

Tạo nhận xét: Kiểm tra danh sách từ chối

Có ba điều kiện để tạo nhận xét:

  • người dùng phải có địa chỉ email đã được xác minh
  • nhận xét phải ít hơn 500 ký tự và
  • họ không thể có trong danh sách người dùng bị cấm. Danh sách này được lưu trữ tại firestore trong bộ sưu tập bannedUsers. Thực hiện lần lượt từng điều kiện sau:
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 để tạo nhận xét 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));

Bây giờ, toàn bộ tệp quy tắc sẽ 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 lượt kiểm thử và đảm bảo có thêm một lượt kiểm thử đạt.

10. Đang cập nhật nhận xét: Quy tắc dựa trên thời gian

Logic kinh doanh của nhận xét là tác giả có thể chỉnh sửa nhận xét trong 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ận 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 thông tin này với toán tử logic AND, quy tắc cập nhật nhận xét sẽ trở thành:

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 lượt kiểm thử và đảm bảo có thêm một lượt kiểm thử đạt.

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

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

Thứ nhất, vì hàm trợ giúp mà bạn đã thêm trước đó để kiểm tra một trường authorUID có thể tồn tại trên bài đăng hoặc nhận xét, nên bạn có thể sử dụng lại hàm trợ giúp để 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ả 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

Do bất kỳ điều kiện nào trong số này là đủ, 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 lượt kiểm thử và đảm bảo có thêm một lượt 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ả kiểm thử đạt và bảo mật ứng dụng!

Sau đây là một số chủ đề có liên quan để bạn tìm hiểu tiếp theo:

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