รู้จัก Security Rules ใน Firebase Realtime Database

แน่นอนว่าการที่เราทำงานกับ database บน server ทั้ง Android, iOS และ Web ก็ต้องเจอเรื่องการ read และ write โดยหากต้องการข้อมูลที่ถูกต้องก็จะต้องมีเรื่อง validation เข้ามา ซึ่งเราควรจะทำทั้งฝั่ง client และ server วันนี้เราจะมารู้จัก Security Rules ของ Firebase Realtime Database ใน Firebase server กัน

Security Rules ใน Firebase Realtime Database จะทำงานร่วมกับ Firebase Authentication เพื่อกำหนดการเข้าถึงข้อมูลของผู้ใช้ได้ทั้งการ read และ write, ตรวจสอบข้อมูลที่เข้ามาเพื่อให้ถูกต้องตามโครงสร้างที่ออกแบบไว้ได้ และการจัดทำ indexes เพื่อการจัดเรียง และการ query ที่รวดเร็ว โดย rules ทั้งหมดจะอยู่บน Firebase server ซึ่งเราสามารถปรับเปลี่ยนได้ตลอดเวลาที่ Firebase Console


การกำหนดค่า Security Rules

เราสามารถกำหนดค่า rules ในแต่ละ path หรือทั้งหมด ทั้งแบบ read และ write ผ่าน Firebase Console โดยเลือกเข้าไปที่เมนู Database และเลือก tab RULES

และเราสามารถทดสอบ security rules ก่อน publish ขึ้น production ได้ด้วยการกดปุ่ม SIMULATOR ทางด้านบนขวา จากนั้นเลือกประเภทว่าจะเป็น read หรือ write, เลือก path ที่จะทำการทดสอบ และเลือกได้ว่าจะ authen หรือไม่

Simulator สำหรับการทดสอบ Security Rulesใน Firebase Realtime Database

นอกจากนั้นเรายังสามารถดูรายละเอียดได้ว่า input ที่เราใส่ไปนั้น ผ่าน หรือ ไม่ผ่าน เพราะอะไร

รายละเอียดการทดสอบ Security Rules ใน Firebase Realtime Database

การจำกัดการเข้าถึง

การจำกัดการเข้าถึงข้อมูลใน Firebase Realtime Database นั้นจะใช้ Firebase Authentication เป็นตัวกำหนดสิทธิ์เท่านั้น ทั้งการ read และ write

ตัวอย่างการจำกัดการเข้าถึงแบบต่าง

1. DEFAULT เป็นแบบที่ผู้ใช้จะต้องทำการ authen ก่อน จึงจะ read และ write ได้

{
"rules": {
".read": "auth != null",
".write": "auth != null"
}
}

2. PUBLIC เป็นแบบที่ read และ write ข้อมูลโดยใครก็ได้ (ไม่ต้อง authen)

{
"rules": {
".read": true,
".write": true
}
}

3. USER เป็นแบบที่ให้ผู้ใช้สามารถเข้าถึงข้อมูลส่วนตัวเท่านั้น

{
"rules": {
"users": {
"$uid": {
".read": "$uid === auth.uid",
".write": "$uid === auth.uid"
}
}
}
}

4. PRIVATE เป็นแบบที่ผู้ใช้ไม่สามารถ read และ write ได้เลย (เข้าถึงจาก Firebase Console เท่านั้น)

{
"rules": {
".read": false,
".write": false
}
}

ประเภทของ Rules

จากตัวอย่างการจำกัดการเข้าถึงแบบต่างๆด้านบน Firebase Realtime Database Rules จะใช้ JavaScript-like syntax ในรูปแบบของ JSON เพื่อกำหนด rules ต่างๆ ซึ่งจะประกอบไปด้วย 4 ประเภท

.read ใช้กำหนดสิทธิ์การอ่านข้อมูลของผู้ใช้

".read": "auth != null && auth.provider == 'facebook'"

.write ใช้กำหนดสิทธิ์การเขียนข้อมูลของผู้ใช้

".write": "auth != null && auth.isAdmin == true"

.validate ใช้ตรวจสอบรูปแบบของข้อมูลที่ผู้ใช้ส่งเข้ามา

".validate": "newData.hasChildren(['name', 'age'])"

.indexOn ใช้ระบุ child ที่ต้องการทำ index

{
"rules": {
"dinosaurs": {
".indexOn": ["height", "length"]
}
}
}
* หมายเหตุ .read, .write และ .validate จะถือเป็น security rules นะจ๊ะ

ตัวแปรที่ฟ้ากำหนดมา (Predefined Variables)

  • now อ้างถึงเวลาปัจจุบันหน่วยเป็น milliseconds ใช้เพื่อตรวจสอบ timestamps
  • root อ้างอิงถึง root path ของ database
  • newData อ้างถึงข้อมูลใหม่ที่จะถูกเขียน
  • data อ้างถึงข้อมูลที่มีอยู่ใน database
  • $variables อ้างถึง wildcard path ของ dynamic child keys เช่น $uid
  • auth อ้างถึงข้อมูลของผู้ใช้ ที่ได้จากการ authen ผ่าน Firebase Authentication
    * auth.provider ตัวอย่าง password, anonymous, facebook, google, twitter
    * auth.uid
    * auth.token ตัวอย่าง auth.token.email และ auth.token.name เป็นต้น

โครงสร้างของ Rules (Structuring Your Rules)

โครงสร้างของ rules ที่เราจะสร้างจะต้องสร้างล้อตามโครงสร้างของข้อมูลใน Firebase Realtime Database

ตัวอย่างข้อมูลใน Firebase Realtime Database

{
"messages": {
"message0": {
"content": "Hello",
"timestamp": 1405704370369
},
"message1": {
"content": "Goodbye",
"timestamp": 1405704395231
}
}
}

ตัวอย่างของ rules ที่ล้อตามโครงสร้างด้านบน

{
"rules": {
"messages": {
"$message": {
//only data from the last ten minutes can be read
".read": "data.child('timestamp').val() > (now - 600000)",
".write": "auth != null",
//new data must have a string content and a number timestamp
".validate": "newData.hasChildren(['content', 'timestamp'])
&& newData.child('content').isString()
&& newData.child('content').val().length < 100
&& newData.child('timestamp').isNumber()"
}
}
}
}

การอ้างถึงข้อมูลใน path อื่นๆ (Referencing Data in other Paths)

ข้อมูลในทุกๆ path ของ Firebase Realtime Database สามารถอ้างถึงเพื่อการกำหนด rules ได้ ด้วยตัวแปร root, data และ newData ทั้งก่อนและหลังการ write

ตัวอย่างจะให้สิทธิ์ write ข้อมูลได้ต่อเมื่อค่าของ child จาก root ชื่อ allow_writes เป็น true และ parent ไม่มี child ชื่อ readOnly และมี child ชื่อ foo ในข้อมูลที่จะเขียนขึ้นมาใหม่

".write": "root.child('allow_writes').val() === true &&
!data.parent().child('readOnly').exists() &&
newData.child('foo').exists()"

การสืบทอดคุณสมบัติ Read และ Write (Read and Write Rules Cascade)

สิทธิ์ของ .read และ .write จะยึดตาม path ชั้นนอกสุดที่ประกาศเป็น true เป็นหลัก หมายความว่า path ที่อยู่ภายใต้ทั้งหมด จะถูก grant ไปโดยอัตโนมัติ เช่นเราให้สิทธิ์ ที่ /games/ เป็น “.read”: true แล้ว /games/gameContent/ ก็จะได้สิทธิ์ read ไปด้วย ถึงแม้ใน /games/gameContent/ จะประกาศ “.read”: false ก็ตาม


การตรวจสอบข้อมูล (Validating Data)

การตรวจสอบข้อมูลเราจะใช้ .validate ทั้งรูปแบบและประเภทของข้อมูลที่เข้ามา เราสามารถใช้ regular expression ได้ด้วยนะเออ นอกจากนั้นก็จะมีสิ่งที่ควรรู้ดังนี้

  • .vaildate จะทำงานต่อเมื่อ rule ของการ .write ถูก grant ผ่านมาแล้ว และหากข้อมูลที่เข้ามาไม่ผ่านเงื่อนไขใน .validate การ write นั้นก็จะไม่เป็นผล
  • .validate เป็น rule ประเภทเดียวใน security rules ที่จะไม่มีการสืบทอดคุณสมบัติ
  • .validate จะไม่ทำงานในกรณีข้อมูลที่ส่งมาเป็น null (ถ้าอ่านเรื่อง Firebase Realtime Database มาแล้ว การส่งค่าเป็น null มานั่นก็คือการลบนั่นเอง)

ตัวอย่างแรก

{
"rules": {
// ให้สิทธิ์ทุกคน อ่าน, เขียน และลบได้ (ทุก path)
".read": true,
".write": true,
// ค่าของ birthdate จะต้องอยู่ในรูปแบบ YYYY-MM-DD ตั้งแต่ปี 1900–2099
"birthdate": {
".validate": "newData.isString() &&
newData.val().matches(/^(19|20)[0-9][0-9][-\\/. ]
(0[1-9]|1[012])[-\\/. ]
(0[1-9]|[12][0-9]|3[01])$/)"
}
}
}

จาก rules ด้านบน เรามาทดสอบตัวอย่างแรกกัน

ปีเกิน 2099 ก็ไม่ผ่านนะจ๊ะ

ตัวอย่างที่สอง ใช้ .validate กำกับตาม path

{
"rules": {
// ต้องผ่านการ authen มาก่อนจึงจะ อ่าน, เขียน และลบได้ (ทุก path)
".read": "auth != null",
".write": "auth != null",
"widget": {
// จะต้องมี attributes "color" และ "size"
".validate": "newData.hasChildren(['color', 'size'])",
"size": {
// ค่าของ "size" จะต้องเป็นเลขที่มีค่าตั้งแต่ 0 ถึง 99
".validate": "newData.isNumber() &&
newData.val() >= 0 &&
newData.val() <= 99"
},
"color": {
// ค่าของ "color" มีใน child "/valid_colors/"
".validate": "root.child('valid_colors/' +
newData.val()).exists()"
}
}
}
}

จาก rules ด้านบน เรามาทดสอบตัวอย่างที่สองกัน

ไม่ผ่านเพราะติด .validate ชุดที่สองที่ระบุว่าค่าสีจะต้องมีใน valiad_colors (ถึงแม้จะผ่าน .validate ชุดแรกก็ตาม)
ไม่ผ่านเพราะติด .validate ชุดแรกที่บอกว่าข้อมูลจะต้องมี 2 attributes คือ color และ size
ต้องผ่าน .validate ครบทุกตัว การ write จึงจะสำเร็จ (มีสี blue ใน node ชื่อ valid_colors แล้ว)

ตัวอย่างสุดท้าย ใช้ .write ลูกเดียว ไม่มี .validate

{
"rules": {
// จะลบ widget ไม่ได้นะ เพราะไม่ได้ประกาศ .write ไว้ที่ root
"widget": {
// จะต้องมี attributes "color" และ "size"
".write": "newData.hasChildren(['color', 'size'])",
"size": {
// ค่าของ "size" จะต้องเป็นเลขที่มีค่าตั้งแต่ 0 ถึง 99
// เฉพาะการเขียนที่เจาะจงมาที่ size จึงจะทำงาน
".write": "newData.isNumber() &&
newData.val() >= 0 && newData.val() <= 99"
},
"color": {
// ค่าของ "color" มีใน child "/valid_colors/"
// เฉพาะการเขียนที่เจาะจงมาที่ color จึงจะทำงาน
".write": "root.child('valid_colors/' +
newData.val()).exists()"
}
}
}
}

จาก rules ด้านบน เรามาทดสอบตัวอย่างสุดท้ายกัน

ผ่านฉลุยซะงั้นถึงแม้จะไม่ระบุ color และ size เกิน 99 นั่นก็เพราะ .write มันสืบทอดคุณสมบัติจาก parent ที่บอกว่าแค่มี attributes 2 ตัว คือ color และ size ก็ผ่าน
ระบุไปที่ path ชื่อ size ตรงๆเลย แล้วใส่ 99 ปรากฏว่า write ได้นะจ๊ะ เพราะเงื่อนไขของการ write ถูกคิดจาก path ตรงที่ชี้ไป

จากตัวอย่างที่ 2 และ 3 ก็น่าจะทำให้เราเข้าใจในความแตกต่างของการเขียน rules ใน .validate กับ .write ได้ละ


การสร้าง rulesให้กับ path ที่เป็น dynamic (wildcard)

เราสามารถใช้ $ นำหน้าตัวแปรของ path ที่เป็น dynamic ได้ เช่น key หรือ timestamp ตัวอย่าง

{
"rules": {
"rooms": {
// id ของห้องทุกห้อง
"$room_id": {
"topic": {
// ชื่อห้องจะสามารถเปลี่ยนได้ถ้า id ห้องมีคำว่า "public"
".write": "$room_id.contains('public')"
}
}
}
}
}

อีกตัวอย่างหนึ่ง หากเราต้องการข้อมูลที่มี 2 attributes เท่านั้นในข้อมูล (ส่งมาเกินจะไม่ผ่าน) สามารถใช้ wildcard แบบนี้

{
"rules": {
"widget": {
// ข้อมูลต้องมี attribute ชื่อ title และ color
"title": { ".validate": true },
"color": { ".validate": true },

// attribute ตัวอื่นเข้ามา ไม่ผ่านนะ
"$other": { ".validate": false }
}
}
}

การทำ Index

เมื่อข้อมูลใน database ของคุณมีมาก index ถือเป็นสิ่งสำคัญที่จะช่วยให้การ query และ order ข้อมูลนั้นรวดเร็ว สำหรับ Firebase Realtime Database Rules เราจะใช้ .indexOn ในการสร้าง index ซึ่งจะทำที่ลำดับชั้นไหนก็ได้ โดยจะแบ่งออกเป็น 2 กรณีด้วยกัน

1. การ index กับ orderByChild

database ของ dinosaurs

จากข้อมูลตัวอย่าง ถ้าแอพเรามีเมนูเรียงลำดับ ชื่อ, ส่วนสูง และ น้ำหนัก เราสามารถใช้ .indexOn กับ field เหล่านั้นเพื่อให้เวลา query นั้นเร็วขึ้น และสำหรับ node ที่เป็น key ต่างๆใน Firebase Realtime Database จะถูก index ให้อัตโนมัติ ดังนั้นที่เหลือก็ลุยเลย

{
"rules": {
"dinosaurs": {
".indexOn": ["height", "length"]
}
}
}

2. การ index กับ orderByValue

{
"scores": {
"ManUtd" : 60,
"Liverpool" : 48,
"Arsenal" : 54,
"Chelsea" : 45,
"ManCity" : 57,
"Everton" : 51
}
}

จากข้อมูลตัวอย่าง ถ้าแอพเรามีหน้าแสดงลำดับคะแนนของทีมฟุตบอล และเราต้องการเพิ่มประสิทธิภาพในการเรียงลำดับคะแนน ก็สามารถทำได้โดย

{
"rules": {
"scores": {
".indexOn": ".value"
}
}
}

หวังว่าทุกคนที่ได้อ่านบทความเรื่อง Security Rules ซึ่งเป็นภาคต่อของ Firebase Realtime Database แล้วไปประยุกต์ใช้ ไม่ว่าจะเป็น Android, iOS และ Web จะสามารถเก็บข้อมูลลง database ได้ถูกต้องและเป็นไปตามโครงสร้างที่ออกแบบไว้ ที่อยากแนะนำคือ เราควรตรวจสอบข้อมูลจากฝั่ง client ก่อนด้วยจะดีที่สุด สำหรับบทความของ Firebase ที่เป็น Fundamental ก็ยังเหลืออีก 4 ตัว แต่ตอนนี้แอบ draft บทความที่เป็นการรวมกันของ product บางตัว เพื่อก่อเกิดพลังอันมหาศาลไว้อยู่ ยังไงก็รอติดตามตอนต่อไปนะครับ สำหรับวันนี้ขอตัวก่อน…ราตรีสวัสดิ์ พี่น้องชาวไทย