แยกกุญแจตามหน้าที่ ระบบเดียวใช้ 4 แบบ หลุดดอกหนึ่ง อีกสามดอกยังปลอดภัย
เครื่องที่ส่งข้อมูล คนที่ล็อกอิน คนที่ดูหน้าสาธารณะ และคนที่ขอตามไม้ ต้องใช้กุญแจคนละแบบ ออกแบบ API key ผูกกับบัญชี session cookie และลิงก์สาธารณะ จากระบบที่รันจริง
- แยกกุญแจตามว่าใครใช้และใช้ทำอะไร กุญแจแต่ละดอกทำได้แค่งานของมัน หลุดดอกหนึ่งไม่เปิดอีกดอก
- API key ที่ดีมีคำนำหน้าให้จำได้ สุ่มด้วยตัวสุ่มที่ปลอดภัย และผูกกับสิ่งที่มันมีสิทธิ์เขียน
- cookie ของ session ต้องเป็น HttpOnly, Secure และ SameSite เพื่อลดผลจาก XSS และ CSRF
ระบบบันทึกผลเทรดของเรามีคนและเครื่องเข้ามาใช้สี่แบบ โปรแกรมเทรดที่ส่งข้อมูลทุกไม่กี่วินาที เจ้าของบัญชีที่ล็อกอินดูแดชบอร์ด คนทั่วไปที่เปิดลิงก์ดูผลงาน และผู้ตามที่ดึงไม้ไปเปิดตาม ถ้าใช้กุญแจแบบเดียวกันหมด กุญแจที่ฝังอยู่ในโปรแกรมบนเครื่องคนอื่นจะเปิดได้ทุกประตู บทความนี้เล่าวิธีแยกกุญแจทั้งสี่แบบ และเหตุผลของแต่ละการตัดสินใจ เขียนจากมุมคนออกแบบระบบให้ปลอดภัย
หลักการ: กุญแจหนึ่งดอก งานหนึ่งอย่าง
คิดแบบอาคารสำนักงาน พนักงานส่งของได้บัตรเข้าแค่ห้องรับของ แม่บ้านเข้าได้ทุกห้องแต่เปิดตู้เซฟไม่ได้ ผู้มาติดต่อนั่งได้แค่ล็อบบี้ บัตรหายหนึ่งใบ ก็ยกเลิกแค่ใบนั้น
หลักการ: แยกกุญแจตามว่า ใคร ใช้ และใช้ทำ อะไร กุญแจแต่ละดอกทำได้แค่งานของมัน
| ใคร | ใช้อะไร | ทำอะไรได้ | ทำอะไรไม่ได้ |
|---|---|---|---|
| โปรแกรมเทรด | API key ใน header X-Api-Key | ส่งข้อมูลของบัญชีเดียว | อ่านข้อมูล เข้าแดชบอร์ด |
| เจ้าของบัญชี | อีเมล + รหัสผ่าน → session cookie | ดูและจัดการบัญชีของตัวเอง | เขียนข้อมูลเทรดแทนโปรแกรม |
| คนทั่วไป | ลิงก์สาธารณะ /p/<slug> | ดูสถิติแบบอ่านอย่างเดียว | เห็นเลขบัญชีเต็ม แก้อะไรก็ได้ |
| ผู้ตามไม้ | token สำหรับแชร์ ใน X-Copy-Token | อ่านรายการไม้ที่ถืออยู่ | ส่งข้อมูล เข้าแดชบอร์ด |
กุญแจที่ 1: API key ของเครื่องที่ส่งข้อมูล
key ของเรามีหน้าตาแบบนี้
fxk_Q7mZp2Kx9vLr4Tn8Wc1Yh5Bd6Sa3
มีคำนำหน้า fxk_ ทำให้จำได้ทันทีเมื่อเห็นใน log ไฟล์ตั้งค่า หรือในโค้ดที่เผลอ commit ขึ้น git เครื่องมือสแกนหาความลับก็ตั้งกฎจับได้ง่าย
สุ่มด้วยตัวสุ่มที่ปลอดภัย ใช้ crypto/rand ของ Go ไม่ใช่ math/rand ซึ่งเดาค่าถัดไปได้ ส่วนสุ่มยาว 28 ตัวอักษร
func IngestKey() string { return "fxk_" + randString(28) } // randString ใช้ crypto/rand
ผูกกับบัญชีเทรดเดียว นี่คือส่วนที่สำคัญที่สุด ครั้งแรกที่โปรแกรมส่งข้อมูลเข้ามา ระบบจะผูก key กับเลขบัญชีเทรดในข้อมูลนั้น หลังจากนั้นถ้ามีข้อมูลเลขบัญชีอื่นส่งมาด้วย key เดียวกัน ระบบตอบ 409 Conflict และไม่บันทึก
if payloadLogin != 0 && payloadLogin != acct.Login {
writeError(w, http.StatusConflict,
"account login mismatch: this key belongs to a different MT account")
return false
}
ทำไมต้องผูก เพราะระบบนี้คือระบบ ยืนยันผลเทรด ถ้า key ของบัญชีหนึ่งส่งข้อมูลของอีกบัญชีมาปนได้ ผลที่แสดงก็เชื่อไม่ได้ ไม่ว่าจะเกิดจากคนตั้งใจโกง หรือแค่ก็อปไฟล์ตั้งค่าไปใช้กับอีกบัญชีโดยลืมเปลี่ยน key
กุญแจที่ 2: session ของคนที่ล็อกอิน
คนล็อกอินด้วยอีเมลกับรหัสผ่าน รหัสผ่านเก็บเป็น bcrypt ไม่เก็บตัวจริง
hash, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
ok := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
ล็อกอินผ่านแล้ว ระบบสร้าง session token สุ่ม 40 ตัวอักษร เก็บฝั่งเซิร์ฟเวอร์ และส่งให้เบราว์เซอร์เป็น cookie ที่ตั้งค่าแบบนี้
http.SetCookie(w, &http.Cookie{
Name: "fxsess", Value: token, Path: "/",
HttpOnly: true, // JavaScript อ่านไม่ได้
Secure: secure, // ส่งเฉพาะทาง HTTPS
SameSite: http.SameSiteLaxMode, // ไม่แนบไปกับคำขอจากเว็บอื่นแบบ POST
MaxAge: 30 * 24 * 60 * 60, // 30 วัน
})
- HttpOnly ถ้าวันหนึ่งหน้าเว็บมีช่องโหว่ XSS สคริปต์ที่ถูกฝังก็อ่าน cookie นี้ไม่ได้
- Secure cookie ไม่ถูกส่งผ่าน HTTP ธรรมดาที่ดักฟังได้
- SameSite=Lax เว็บอื่นส่งฟอร์มมาสั่งงานแทนผู้ใช้ไม่ได้ ลดความเสี่ยง CSRF
- ออกจากระบบ = ลบ session ฝั่งเซิร์ฟเวอร์ ไม่ใช่แค่ลบ cookie ในเบราว์เซอร์ token ที่ถูกขโมยไปก่อนหน้าจะใช้ต่อไม่ได้
กุญแจที่ 3: ลิงก์สาธารณะ
เจ้าของบัญชีเลือกเปิดหน้าสาธารณะได้ หน้านี้ใช้ slug สุ่ม 10 ตัวอักษร เช่น /p/k3Xm9QaZ2w ไม่ใช้เลขบัญชีหรือ id ที่เรียงลำดับ คนจึงไล่เดาหน้าของคนอื่นไม่ได้
ข้อมูลในหน้านี้อ่านอย่างเดียว และ ซ่อนเลขบัญชีเทรด เหลือแค่ 4 ตัวท้าย
func maskLogin(login int64) string {
s := strconv.FormatInt(login, 10)
return "••••" + s[len(s)-4:]
}
เลขบัญชีเต็มเป็นข้อมูลที่ไม่จำเป็นต่อการดูผลงาน และอาจถูกใช้ประกอบการหลอกลวงได้ จึงไม่แสดง
กุญแจที่ 4: token สำหรับแชร์ให้ผู้ตาม
ผู้ตามไม้ไม่ต้องมีบัญชีในระบบเลย ได้แค่ token ที่เจ้าของส่งให้ token นี้อ่านได้อย่างเดียว และเจ้าของ เปลี่ยน token ใหม่ ได้ทุกเมื่อ เพื่อตัดผู้ตามทั้งหมดในครั้งเดียว ระบบลบ cache ของ token เก่าทันที ไม่รอให้หมดอายุ (เล่าละเอียดในบทความเรื่อง cache ทางร้อน)
รายการตรวจของกุญแจที่ดี
- แยกตามหน้าที่ ไม่มีกุญแจไหนทำได้ทุกอย่าง
- มีคำนำหน้า จำได้เมื่อหลุดไปอยู่ผิดที่
- สุ่มด้วยตัวสุ่มที่ปลอดภัย และยาวพอที่จะเดาไม่ได้
- ผูกกับขอบเขต บัญชีเดียว สิทธิ์เดียว
- เปลี่ยนใหม่ได้ โดยไม่กระทบกุญแจอื่น
- เก็บแค่ hash สำหรับ key ที่ใช้ยืนยันตัว แสดงตัวเต็มครั้งเดียวตอนสร้าง
- ส่งใน header ไม่ใช่ใน URL เพราะ URL ไปโผล่ใน log ของเซิร์ฟเวอร์และ proxy
- จำกัดอัตราการลอง กันการเดา
ข้อผิดพลาดที่เจอบ่อย
- ใช้รหัสผ่านของคนฝังในโปรแกรมหรือสคริปต์อัตโนมัติ
- สุ่ม token ด้วย
Math.random()หรือmath/rand - cookie ไม่มี HttpOnly เพราะหน้าเว็บ “อยากอ่าน” token เอง
- ออกจากระบบแค่ลบ cookie ฝั่งเบราว์เซอร์ session ฝั่งเซิร์ฟเวอร์ยังใช้ได้
- ใช้ id เรียงลำดับเป็นลิงก์สาธารณะ ใครก็ไล่เปลี่ยนเลขดูของคนอื่นได้
คำถามที่พบบ่อย
ทำไมไม่ให้เครื่องส่งข้อมูลล็อกอินด้วยอีเมลกับรหัสผ่านเหมือนคน
เพราะรหัสผ่านต้องไปฝังอยู่ในโปรแกรมบนเครื่องอื่น ถ้าหลุด คนได้ไปจะเข้าหน้าจัดการทั้งหมดได้ API key แยกจำกัดสิทธิ์แค่ส่งข้อมูลของบัญชีเดียว และยกเลิกได้โดยไม่ต้องเปลี่ยนรหัสผ่าน
ควรเก็บ API key ในฐานข้อมูลยังไง
แนวทางที่ดีคือเก็บเฉพาะค่า hash ของ key แบบเดียวกับรหัสผ่าน แสดง key ตัวเต็มให้ผู้ใช้เห็นครั้งเดียวตอนสร้าง ถ้าฐานข้อมูลหลุด คนได้ไปจะไม่ได้ key ที่ใช้งานได้
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ