🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน
go version go1.26.5 darwin/arm64
github.com/golang-jwt/jwt/v5 v5.3.1
openssl / base64 ของ macOS # ใช้ตอนถอด token ด้วยมือ
swag v1.16.4 # ใช้เฉพาะหัวข้อ 9 (Swagger)
ถ้าจะพิมพ์ตาม: สร้างโฟลเดอร์เปล่าใหม่ · cd เข้าไป · go mod init jwtcheck
· go get github.com/golang-jwt/jwt/v5
หัวข้อ 2–4 (Basic Auth) ใช้แค่ standard library ไม่ต้อง go get อะไรเลย
ในบทนี้มีโปรแกรมทดสอบหกตัว อยู่คนละโฟลเดอร์ใต้ cmd/ —
อย่าสับสนว่าเป็นตัวเดียวกัน:
· cmd/basic — เซิร์ฟเวอร์ Basic Auth ปลอม ๆ ที่พิมพ์ header ที่ได้รับออกมา
· cmd/mine — ถอด token ของสไลด์ · สร้าง JWT เอง · เคส error สามแบบ
· cmd/algnone — การโจมตี alg=none
· cmd/confusion — การโจมตี algorithm confusion (RS256 → HS256)
· cmd/extra — เคส exp / nbf / aud / iss / token พังรูปแบบ
· cmd/size — วัดขนาด JWT เทียบกับ session id (ใช้ในหัวข้อ 10)
เรื่องเลขในผลรัน: ตัวเลขที่โปรแกรมพิมพ์นำหน้าแต่ละบล็อก
(=== 1) … --- 4.1 ---) เป็นเลขลำดับของโปรแกรม
ไม่ใช่เลขหัวข้อในบทเรียน — ถ้าเห็น --- 4.1 --- อยู่ใต้หัวข้อ 7.1 ก็ไม่ได้พิมพ์ผิด
1 · Authentication ต่างจาก Authorization ยังไง
สองคำนี้ขึ้นต้นด้วย Auth เหมือนกัน ย่อเหมือนกันว่า "auth" และคนใช้ปนกันตลอด
แต่มันตอบคนละคำถาม และเกิดคนละเวลา:
| Authentication (authn) | Authorization (authz) | |
|---|---|---|
| ถามว่าอะไร | "คุณเป็นใคร" | "คุณทำอะไรได้บ้าง" |
| ภาษาไทย | การพิสูจน์ตัวตน | การให้สิทธิ์ |
| เทียบกับชีวิตจริง | ยามดูบัตรประชาชนคุณที่ประตู — ยืนยันว่าคุณคือคนนี้จริง | บัตรพนักงานของคุณรูดเข้าชั้น 3 ได้ แต่รูดเข้าห้องเซิร์ฟเวอร์ไม่ได้ |
| เกิดเมื่อไร | ก่อน — ต้องรู้ก่อนว่าเป็นใคร | หลัง — พอรู้ว่าเป็นใครแล้วค่อยเช็คสิทธิ์ |
| HTTP status ที่ตอบกลับ (ทบทวนได้ที่ บทเรียน 34) |
401 (ยังไม่รู้ว่าคุณเป็นใคร) |
403 (รู้ว่าคุณเป็นใคร แต่ห้ามเข้า) |
| ตัวอย่างในโปรเจกต์ Wongnok | ล็อกอินด้วย username/password แล้วได้ token | เจ้าของสูตรอาหารแก้สูตรตัวเองได้ · คนอื่นแก้ไม่ได้ |
401 ชื่อว่า "Unauthorized" แต่มันคือเรื่อง authentication
ชื่อนี้ตั้งผิดมาตั้งแต่ HTTP/1.0 (RFC 1945 · พฤษภาคม 1996 · หัวข้อ 9.4) และแก้ไม่ได้แล้วเพราะโลกทั้งใบใช้ไปแล้ว
วิธีจำที่ไม่พลาด: ดูที่ header ที่มันตอบกลับมาคู่กัน —
401 ตอบ WWW-Authenticate มาด้วย ซึ่งแปลว่า
"ยังไม่ได้พิสูจน์ตัวตน กรุณาล็อกอินด้วยวิธีนี้"
เดี๋ยวหัวข้อ 2 คุณจะเห็น header ตัวนี้ในผลรันจริง
ส่วน 403 ไม่มี header อะไรเพิ่ม เพราะไม่มีอะไรให้คุณทำ — คุณคือคุณ และคุณเข้าไม่ได้
ทั้งบทนี้อยู่ฝั่ง authentication ล้วน ๆ —
เรากำลังหาวิธีที่ดีที่สุดที่เซิร์ฟเวอร์จะรู้ว่า request ที่วิ่งเข้ามาเป็นของใคร
ส่วน authorization (เช็คว่า role นี้ทำอะไรได้) เป็นเรื่องของบทถัดไป
แต่มันจะโผล่มาแวบหนึ่งในหัวข้อ 5 ตอนที่เราเห็นคำว่า "admin":true อยู่ใน token
2 · Basic Auth ทำงานยังไง
Basic Auth คือวิธีล็อกอินที่เก่าที่สุดของ HTTP กติกาสั้นมาก:
ทุก request ที่ยิงไปหาเซิร์ฟเวอร์ ให้แนบ header ชื่อ Authorization ที่มีหน้าตาแบบนี้
Authorization: Basic <base64 ของสตริง "username:password">
คำว่า "ทุก request" ในประโยคข้างบนคือหัวใจของทั้งบท —
ไม่ใช่แค่ตอนล็อกอินครั้งแรก แต่คือทุกครั้ง ที่คุยกับเซิร์ฟเวอร์
ผมเขียนเซิร์ฟเวอร์เล็ก ๆ ด้วย httptest
(เซิร์ฟเวอร์จำลองที่รันอยู่ในหน่วยความจำ ไม่ต้องเปิดพอร์ตจริง — รายละเอียดอยู่ในบทเรียน 40)
ที่พิมพ์ header ทุกอันที่ได้รับ
แล้วให้ client ยิงไปสามครั้งด้วย req.SetBasicAuth("somchai", "p@ssw0rd"):
req, _ := http.NewRequest("GET", url+"/foods", nil)
req.SetBasicAuth("somchai", "p@ssw0rd") // ฝั่ง client — ใส่ header ให้
res, err := http.DefaultClient.Do(req)
// ---- ฝั่ง server ----
user, pass, ok := r.BasicAuth() // แกะ header กลับมาเป็น user/pass
ผลรันจริง — ดูค่า Authorization ของทั้งสาม request ให้ดี:
$ go run ./cmd/basic
>>> request #1
[server] GET /foods Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true
[client] status=200 body=hello somchai
>>> request #2
[server] GET /foods Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true
[client] status=200 body=hello somchai
>>> request #3
[server] GET /foods Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true
[client] status=200 body=hello somchai
สตริงเดิมเป๊ะ ๆ ถูกส่งซ้ำทั้งสามครั้ง —
ไม่มี session ไม่มี cookie ไม่มีอะไรจำ เซิร์ฟเวอร์ไม่รู้จักคุณเลยจนกว่าคุณจะบอกใหม่ทุกครั้ง
แปลว่ารหัสผ่านของคุณเดินทางข้ามเน็ตเวิร์กทุก request ตลอดอายุการใช้งานแอป
ไม่ใช่แค่ตอนล็อกอิน
เวลาไปติดต่อตึกออฟฟิศ คุณยื่นบัตรประชาชนให้ รปภ. ที่ล็อบบี้ครั้งเดียว
แล้วได้บัตรผู้มาติดต่อมาห้อยคอ
หลังจากนั้นจะขึ้นลิฟต์ · เข้าห้องประชุม · เดินกลับออกมา ก็แค่แตะบัตรใบที่ห้อยคออยู่
ไม่มีใครขอดูบัตรประชาชนซ้ำทุกประตู
Basic Auth ทำตรงข้ามกับเรื่องนี้ทั้งหมด — มันยื่น "บัตรประชาชน" (username + password ตัวจริง)
ให้ใหม่ทุกประตู ทุกครั้งที่ขยับ
ผลรันข้างบนคือหลักฐาน: สตริง Basic c29tY2hhaTpwQHNzdzByZA== ก้อนเดิมเป๊ะ ๆ
วิ่งซ้ำครบทั้งสามรอบ ไม่มีการแลกเป็นบัตรอะไรมาถือแทนเลย
ที่ไม่เหมือนกัน: ที่ตึกจริง คุณยื่นบัตรให้คนที่ยืนตรงหน้าคุณ มองเห็นด้วยตาว่าใครดูบ้าง
แต่ header ตัวนี้ต้องเดินผ่านเราเตอร์ Wi-Fi ร้านกาแฟ · เสาสัญญาณ · เครื่องของผู้ให้บริการเน็ต
อีกหลายทอดกว่าจะถึงเซิร์ฟเวอร์ — และถ้าเว็บเป็น http:// ธรรมดา ทุกทอดที่ผ่านอ่านได้หมด
หัวข้อ 3 จะพิสูจน์ให้ดูว่า "อ่านได้" ที่ว่านี่ ง่ายแค่ไหน
และเวลารหัสผิด เซิร์ฟเวอร์ตอบแบบนี้ — สังเกต header WWW-Authenticate ที่พูดถึงในหัวข้อ 1:
>>> request รหัสผิด
[server] GET /foods Authorization: "Basic c29tY2hhaTp3cm9uZw=="
[server] r.BasicAuth() -> user="somchai" pass="wrong" ok=true
[client] status=401 WWW-Authenticate="Basic realm=\"wongnok\"" body=unauthorized
ok=true ทั้งที่รหัสผ่านผิด
อ่านบรรทัดกลางอีกรอบ: รหัสผ่านคือ "wrong" ซึ่งผิด แต่ ok เป็น true
เพราะ ok ของ r.BasicAuth() ไม่ได้แปลว่า "รหัสถูก" —
มันแปลว่า "แกะ header สำเร็จ" เท่านั้น
คือมี header Authorization · ขึ้นต้นด้วย Basic · และ base64 ข้างหลังถอดได้
ถ้าใครเขียนโค้ดแบบนี้ ระบบจะเปิดให้ใครก็ได้ที่ใส่รหัสอะไรก็ได้เข้ามา:
user, pass, ok := r.BasicAuth()
if ok { // ❌ ช่องโหว่เต็ม ๆ — ok ไม่ได้แปลว่ารหัสถูก
// ให้ผ่าน
}
ที่ถูกต้องคือต้องเทียบ user กับ pass เองเสมอ และควรเทียบด้วย
subtle.ConstantTimeCompare (เทียบครบทุกไบต์เสมอ ไม่หยุดกลางคัน)
เพื่อไม่ให้ผู้โจมตีเดารหัสจากเวลาที่ใช้ตอบ:
user, pass, ok := r.BasicAuth()
okUser := subtle.ConstantTimeCompare([]byte(user), []byte("somchai")) == 1
okPass := subtle.ConstantTimeCompare([]byte(pass), []byte("p@ssw0rd")) == 1
if !ok || !okUser || !okPass {
w.Header().Set("WWW-Authenticate", `Basic realm="wongnok"`)
w.WriteHeader(http.StatusUnauthorized) // 401
return
}
ห้าคำนี้คือคำที่ dev เขาใช้เรียกของที่คุณเพิ่งเห็นในผลรัน — ใช้คำให้ตรง จะได้คำตอบที่ตรง
- authentication (authn)
- "คุณเป็นใคร" — ด่านแรก ตอบด้วย
401เมื่อไม่ผ่าน · เวลาคุยกับ senior คนทำ auth เขาย่อว่า authn (อ่านว่า ออธ-เอ็น) ประโยคที่เอาไปใช้ได้เลย: "ตรงนี้มันพังที่ authn หรือ authz ครับ" ถามแบบนี้คำตอบจะแคบลงทันที ต่างจากถามว่า "ล็อกอินไม่ได้" - authorization (authz)
- "คุณทำอะไรได้บ้าง" — ด่านหลัง ตอบด้วย
403เมื่อไม่ผ่าน · ใช้คู่กับ authn เสมอ · "เคสนี้ authn ผ่านแล้วนะ ติดที่ authz" แปลว่า "รู้แล้วว่าเป็นใคร แต่สิทธิ์ไม่พอ" - credential
- คำรวมของ "ของที่ใช้ยืนยันตัวตน" — คู่ username/password · token · API key นับหมด · ใช้เวลาไม่อยากเจาะจงว่าเป็นแบบไหน: "credential มันหลุดไปโผล่ใน log รึเปล่า" ครอบคลุมกว่าถามว่า "รหัสผ่านหลุดไหม"
- realm
- ป้ายชื่อขอบเขตที่ต้องล็อกอิน — คือคำว่า
wongnokในWWW-Authenticate: Basic realm="wongnok"ที่คุณเพิ่งเห็นในผลรัน · มันบอกว่า credential ชุดนี้ใช้กับพื้นที่ไหนของเว็บ ไม่ได้มีผลต่อการตรวจรหัสเลย - constant-time comparison
- การเทียบค่าที่ใช้เวลาเท่ากันเสมอ ไม่ว่าจะตรงตั้งแต่ตัวแรกหรือตรงหมดทุกตัว —
ใน Go คือ
subtle.ConstantTimeCompare· "ตรงที่เทียบ secret เนี่ย ใช้ constant-time compare รึยังครับ" เป็นคำถามที่ reviewer สาย security ถามเป็นคำถามแรก ๆ
3 · base64 ไม่ใช่การเข้ารหัส
สไลด์เขียนไว้ตรง ๆ ว่า "Base64 ไม่ใช่การเข้ารหัส — ถอดกลับได้ง่ายมาก"
แต่พูดเฉย ๆ ไม่พอ ต้องเห็นด้วยตา
ก่อนอื่นนิยามให้ชัด: base64 คือวิธีเขียนข้อมูลใหม่ให้เหลือแค่ตัวอักษร 64 ตัว
(A–Z a–z 0–9 + /)
เกิดมาเพื่อให้ข้อมูลที่มีอักขระแปลก ๆ เดินทางผ่าน HTTP header ได้ปลอดภัย
มันไม่ต้องใช้กุญแจ ไม่มีความลับอะไรในตัวมันเลย — ใครก็แปลงกลับได้
ทายก่อนรัน — นี่คือค่าที่ผมดักได้จาก header ในหัวข้อ 2 ตรง ๆ
Authorization: Basic c29tY2hhaTpwQHNzdzByZA==
ถ้าผมเอาส่วนที่เป็นตัวหนาไปสั่ง base64 -d โดยไม่มีกุญแจอะไรเลย —
จะได้อะไรออกมา?
แล้วคำถามที่สำคัญกว่า: ถ้ามีคนนั่งดักดูทราฟฟิกอยู่ที่ Wi-Fi ร้านกาแฟ เขาได้อะไรไป?
$ printf '%s' 'somchai:p@ssw0rd' | base64
c29tY2hhaTpwQHNzdzByZA==
$ echo 'c29tY2hhaTpwQHNzdzByZA==' | base64 -d
somchai:p@ssw0rd
ได้รหัสผ่านกลับมาเต็ม ๆ ในคำสั่งเดียว ไม่ต้องมีกุญแจ ไม่ต้องเดา ไม่ต้องแกะอะไร
ผมยืนยันอีกทางด้วยโปรแกรม Go ที่วัดขนาด header ทั้งอันด้วย:
Authorization: Basic c29tY2hhaTpwQHNzdzByZA== (45 byte ทั้ง header)
decode กลับได้ทันทีโดยไม่ต้องมี key: somchai:p@ssw0rd
คนที่ดักได้ ได้ไปทั้ง username และ password ตัวจริง —
ไม่ใช่แค่ "สิทธิ์เข้าระบบนี้" แต่คือรหัสผ่านของคน ๆ นั้น
ซึ่งถ้าเขาใช้รหัสเดียวกันกับอีเมลหรือธนาคาร คุณเพิ่งทำให้เขาซวยทั้งชีวิตดิจิทัล
นี่คือเหตุผลที่สไลด์เขียนว่า "ต้องใช้คู่กับ HTTPS เท่านั้น" —
HTTPS ต่างหากที่เข้ารหัส ตัว Basic Auth ไม่ได้เข้ารหัสอะไรเลยสักนิดเดียว
เบอร์ 0812345678 ถ้าเขียนใหม่เป็น ๐๘๑๒๓๔๕๖๗๘ หน้าตามันเปลี่ยนไปเลย
คนที่ไม่คุ้นอาจอ่านช้าลงหน่อย
แต่ไม่มีใครเรียกสิ่งนี้ว่า "เข้ารหัส" เพราะ ไม่มีกุญแจ ไม่มีความลับอะไรอยู่ในนั้น —
ตารางแปลง ๐=0 ๑=1 อยู่ในหัวคนไทยทุกคน ใครก็อ่านกลับได้
base64 คือเรื่องเดียวกันเป๊ะ — มันเปลี่ยนวิธีเขียน ไม่ได้เปลี่ยนความลับ
ตารางแปลงของมันเป็นมาตรฐานสาธารณะ และคำสั่งที่ใช้อ่านกลับ (base64 -d)
ติดมากับ macOS/Linux ทุกเครื่องอยู่แล้ว ไม่ต้องลงอะไรเพิ่มด้วยซ้ำ
ที่ไม่เหมือนกัน: สามข้อ —
(1) เลขไทยยังต้องมีคนนั่งอ่าน แต่ base64 มีคำสั่งสำเร็จรูปรออยู่
เครื่องเดียวถอดเป็นล้านชุดต่อนาทีได้สบาย ๆ ·
(2) เลขไทยเขียนแล้วยาวเท่าเดิม แต่ base64 ทำให้ข้อมูลใหญ่ขึ้นราว 4/3 เท่า —
somchai:p@ssw0rd ที่ยาว 16 byte กลายเป็น
c29tY2hhaTpwQHNzdzByZA== ที่ยาว 24 ตัวอักษร ·
(3) การเข้ารหัสของจริงต้องมีกุญแจ ไม่มีกุญแจก็ถอดไม่ได้เลย
แต่ในนิยามของ base64 ไม่มีคำว่ากุญแจอยู่แม้แต่คำเดียว
สองคำแรกคือคำที่คนสับสนกันมากที่สุดในเรื่องนี้ ถ้าแยกได้ คุณจะคุยเรื่อง security ได้คนละระดับเลย
- encoding
- "เปลี่ยนวิธีเขียนข้อมูล" — ไม่มีกุญแจ ใครก็ย้อนกลับได้ ·
base64 · URL-encoding (
%20) · UTF-8 อยู่ในกลุ่มนี้หมด - encryption
- "ล็อกข้อมูลด้วยกุญแจ" — ไม่มีกุญแจก็ถอดไม่ได้ · ประโยคที่แยกสองคำนี้ออกจากกันได้ในทีเดียว: "อันนี้มัน encode หรือ encrypt ครับ" ถ้าเขาตอบว่า encode แปลว่าถือเป็นข้อมูลเปิด ห้ามเอาความลับใส่ลงไป
- base64url
- base64 รุ่นที่เปลี่ยน
+เป็น-·/เป็น_และตัด=ท้ายทิ้ง เพื่อให้แปะใน URL ได้โดยไม่ต้อง escape · JWT ใช้ตัวนี้ ไม่ใช่ base64 ธรรมดา — เดี๋ยวหัวข้อ 5 จะเจอผลของการตัด=ทิ้งด้วยตัวเอง - plaintext (cleartext)
- ข้อความดิบที่อ่านออกตรง ๆ ยังไม่ถูกล็อกด้วยกุญแจ · credential ของ Basic Auth ถือเป็น plaintext เต็มตัว เพราะ base64 ถอดกลับได้ในคำสั่งเดียว · "อันนี้เราส่ง password เป็น plaintext อยู่รึเปล่าครับ"
- in transit
- "ตอนข้อมูลกำลังเดินทาง" (คู่กับ at rest = ตอนนอนอยู่ในดิสก์) ·
สิ่งที่ปกป้อง in transit คือ TLS ซึ่งก็คือตัว
sในhttps· "ตรงนี้เราป้องกันตอน in transit ด้วยอะไรครับ TLS อย่างเดียวใช่ไหม"
4 · ทำไม Basic Auth ไม่เหมาะกับระบบจริง
สไลด์ลิสต์ไว้สี่ข้อ ผมจะอธิบายทีละข้อว่าทำไมมันเป็นปัญหาจริง ไม่ใช่แค่ข้อเสียบนกระดาษ
และทั้งสี่ข้อมีต้นเหตุเดียวกัน ซึ่งเห็นได้จากสิ่งที่เรา decode มาแล้วในหัวข้อ 3:
Basic: somchai:p@ssw0rd // ทั้งหมดที่ header ตัวนี้บอกได้ มีแค่ 2 field
1 · ไม่มีวันหมดอายุ
ดูสตริงข้างบนอีกรอบ — ไม่มีที่ไหนให้ใส่วันหมดอายุเลย
มันมีแค่ชื่อกับรหัส คั่นด้วย : จบ
ผลคือ header ที่หลุดออกไปวันนี้ ยังใช้ได้พรุ่งนี้ · สัปดาห์หน้า · ปีหน้า
ตราบใดที่รหัสผ่านยังไม่ถูกเปลี่ยน
เทียบกับบัตรเข้าออฟฟิศแบบชั่วคราวที่หมดอายุเอง 6 โมงเย็น — Basic Auth ไม่มีกลไกแบบนั้นให้ใช้
2 · Revoke ยาก
Revoke แปลว่า "ยกเลิกสิทธิ์ที่ให้ไปแล้ว" เช่นพนักงานลาออก
หรือรู้ตัวว่ารหัสหลุด แล้วอยากตัดการเข้าถึงทันที
ปัญหาคือสิ่งเดียวที่ใช้ยืนยันตัวตนได้คือรหัสผ่าน
ดังนั้นวิธีเดียวที่จะยกเลิกคือ เปลี่ยนรหัสผ่าน
ซึ่งลากทุกอย่างของคน ๆ นั้นไปด้วยหมด: มือถือ · แล็ปท็อป · script ที่ตั้งไว้ · CI ·
ตัดขาดทุกเครื่องพร้อมกัน ทั้งที่คุณอยากตัดแค่เครื่องที่หาย
ในระบบที่ออกแบบมาดี คุณควรจะ "เตะเครื่องนี้ออกเครื่องเดียว" ได้ — Basic Auth ทำไม่ได้เลย
3 · ไม่มี claims
Claim (อ่านว่า เคลม) แปลตรงตัวว่า "ข้อกล่าวอ้าง" —
ในโลก auth หมายถึงข้อมูลชิ้นหนึ่งที่ token กล่าวอ้างเกี่ยวกับผู้ถือ
เช่น "คนนี้ id 42" · "คนนี้เป็น admin" · "ใบนี้หมดอายุ 6 โมงเย็น"
ลองเทียบสิ่งที่ Basic Auth บอกได้ กับสิ่งที่ JWT บอกได้ (ทั้งสองบรรทัดนี้ผม decode มาจากของจริง):
Basic: somchai:p@ssw0rd // มีแค่ 2 field ไม่มี exp ไม่มี role
JWT : {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
บรรทัดล่างบอกได้ว่า เป็นใคร · ชื่ออะไร · เป็น admin ไหม · ออกให้เมื่อไร
ทั้งหมดนี้อยู่ในตัว token เอง
บรรทัดบนบอกได้แค่ชื่อกับรหัส — เซิร์ฟเวอร์จึงไม่มีทางรู้อะไรมากกว่านั้น
นอกจากไปถามฐานข้อมูล ซึ่งพาเราไปสู่ข้อสุดท้ายพอดี
4 · ต้องเช็ค DB ทุก request
เพราะ header ไม่มีข้อมูลอะไรที่เชื่อถือได้เลย ทุก request เซิร์ฟเวอร์จึงต้อง:
1. เอา username ไปหาแถวในตาราง users →
2. เอา password ที่ได้มาเข้าฟังก์ชัน hash แล้วเทียบกับที่เก็บไว้ →
3. ค่อยตัดสินใจ
ข้อ 2 คือตัวปัญหา — ฟังก์ชัน hash รหัสผ่านที่ใช้กันจริง (เช่น bcrypt)
ถูกออกแบบมาให้ช้าโดยตั้งใจ เพื่อกันคนไล่เดารหัส
ปกติจึงจงใจตั้งให้ใช้เวลาระดับหลักสิบถึงร้อยมิลลิวินาทีต่อครั้ง
ซึ่งดีมากตอนล็อกอินวันละครั้ง แต่ถ้าต้องทำทุก request คุณเพิ่งแปะเวลานั้นลงบนทุกการกดปุ่มของผู้ใช้
และ CPU ของเซิร์ฟเวอร์ก็หมดไปกับการ hash แทนที่จะไปทำงานจริง
สรุปสี่ข้อให้เหลือประโยคเดียว: ปัญหาของ Basic Auth คือ
ตัวมันเองไม่มีข้อมูลอะไรเลย — มันเป็นแค่รหัสผ่านที่แปะซ้ำไปเรื่อย ๆ
ทางแก้จึงชัด: ต้องมี "ใบผ่าน" ที่มีข้อมูลอยู่ในตัว ตรวจได้เองโดยไม่ต้องถาม DB
และหมดอายุเองได้
ใบผ่านแบบนั้นมีชื่อว่า JWT
5 · JWT คืออะไร — ถอดของจริงในสไลด์ให้ดู
JWT ย่อมาจาก JSON Web Token (คนส่วนใหญ่อ่านว่า "จ็อต") คือสตริงยาว ๆ ก้อนหนึ่งที่ประกอบด้วย สามส่วนคั่นด้วยจุด:
header . payload . signature
| ส่วน | ข้างในคืออะไร | ใครอ่านได้ |
|---|---|---|
| header | JSON บอกว่าใช้อัลกอริทึมอะไรเซ็น เช่น {"alg":"HS256","typ":"JWT"} |
ทุกคน |
| payload | JSON ของ claims ทั้งหมด — id ผู้ใช้ · role · วันหมดอายุ | ทุกคน ← จำข้อนี้ให้ดี |
| signature | ลายเซ็นที่คำนวณจาก header+payload+secret | อ่านได้ แต่สร้างเองไม่ได้ถ้าไม่มี secret |
สองส่วนแรกเป็นแค่ JSON ที่เอาไปเขียนด้วย base64url
(base64 รุ่นที่เปลี่ยน + เป็น - และ / เป็น _
เพื่อให้ใส่ใน URL ได้ และมักตัด = ท้ายทิ้ง)
ย้ำอีกครั้งจากหัวข้อ 3: base64 ไม่ใช่การเข้ารหัส —
ซึ่งแปลว่าเรื่องที่กำลังจะเกิดขึ้นข้างล่างนี้เป็นเรื่องปกติ ไม่ใช่การแฮ็ก
ทายก่อนรัน — นี่คือ token ตัวที่อยู่ในสไลด์หน้า 32 ตัวเต็ม ผมแบ่งบรรทัดตามจุดให้ดูง่าย
// ส่วนที่ 1 — header
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// ส่วนที่ 2 — payload
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0
// ส่วนที่ 3 — signature
KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
ถ้าเอาส่วนที่ 2 (payload) ไป base64 decode จะเห็นอะไร?
ผมไม่มี secret ของ token นี้ · ไม่มีกุญแจอะไรเลย · มีแค่สตริงที่คุณเห็นข้างบนนี้
ลองทายสามข้อ: (ก) มันจะ decode ออกมาได้ไหม ·
(ข) ถ้าได้ ข้างในมีอะไร · (ค) แล้วส่วนที่ 3 (signature) มีไว้กันอะไรกันแน่?
ถอดด้วย shell ล้วน ๆ ไม่มี Go ไม่มี library:
$ echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | tr '_-' '/+' | base64 -d
{"alg":"HS256","typ":"JWT"}
$ echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0=' \
| tr '_-' '/+' | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
(ก) ได้ · (ข) เห็นทุก claim รวมทั้ง "admin":true
ยืนยันซ้ำด้วย Go โดยใช้ ParseUnverified ซึ่งเป็นฟังก์ชันที่ไม่รับ secret เข้าไปเลย:
claims := jwt.MapClaims{}
tok, parts, err := jwt.NewParser().ParseUnverified(demo, claims)
=== 1) ParseUnverified (ไม่ส่ง secret เข้าไปเลย) ===
err=<nil>
alg=HS256 typ=JWT
claims=map[admin:true iat:1.516239022e+09 name:John Doe sub:1234567890]
จำนวนส่วนของ token=3
err=<nil> ทั้งที่ไม่มี secret แม้แต่ตัวเดียว — และเห็น admin:true ด้วย
ส่วน iat:1516239022 คือเวลาที่ออก token นับเป็นวินาทีตั้งแต่ปี 1970
แปลงกลับด้วย date -r 1516239022 -u ได้ Thu Jan 18 01:30:22 UTC 2018
(ค) signature กันการแก้ไข ไม่ได้กันการอ่าน
ใครก็อ่าน payload ได้ แต่ถ้าแก้ตัวเลขในนั้นแล้วส่งกลับมา signature จะไม่ตรงทันที (เดี๋ยวหัวข้อ 7 พิสูจน์)
กฎที่ตามมาจึงเป็นกฎเหล็ก: ห้ามใส่รหัสผ่าน · เลขบัตรประชาชน · เลขบัตรเครดิต
หรือความลับใด ๆ ลงใน JWT เด็ดขาด เพราะมันเท่ากับเขียนไว้บนกระดาษที่ทุกคนอ่านได้
base64 -d ตัดตัวท้ายทิ้งเงียบ ๆ แล้วบอกว่าสำเร็จ
สังเกตว่าในคำสั่งข้างบนผมเติม = ต่อท้าย payload ก่อน decode
เพราะ base64url ของ JWT ตัด = ท้ายทิ้ง แต่ base64 -d ต้องการมัน
ถ้าไม่เติม จะเกิดแบบนี้ — และมันไม่ฟ้อง error:
$ echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' | tr '_-' '/+' | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true,"iat":151623902 ← หายไป 2 ตัว!
$ echo $?
0 ← exit code บอกว่าสำเร็จ
JSON ปิดวงเล็บไม่ครบ เลขผิดไปหนึ่งหลัก แต่ exit code เป็น 0
วิธีเช็คก่อน: ความยาวของแต่ละส่วนต้องหารด้วย 4 ลงตัว —
${#P} ของ payload นี้ได้ 91 ซึ่ง 91 % 4 = 3
แปลว่าต้องเติม = อีก 1 ตัวให้ครบ 92
(ใน Go ไม่ต้องคิดเรื่องนี้ เพราะ base64.RawURLEncoding จัดการให้แล้ว)
ตั๋วหนังใบหนึ่งพิมพ์ไว้ครบตั้งแต่แรก: ชื่อเรื่อง · โรง 5 · รอบ 19:30 · ที่นั่ง H12
ใครหยิบขึ้นมาดูก็อ่านได้หมด ไม่ต้องขอกุญแจใคร ไม่ต้องถามพนักงาน
— เพราะมันไม่เคยถูกออกแบบมาให้เป็นความลับ
ตรงนี้คือ payload ของ JWT เป๊ะ ๆ — เราเพิ่งอ่าน
{"sub":"1234567890","name":"John Doe","admin":true,…}
ออกมาได้โดยไม่มี secret เลยสักตัว
ทีนี้ลองเอาปากกาแก้ H12 เป็น A1 (ที่นั่งดีกว่า) แล้วยื่นให้พนักงานหน้าโรง —
พอสแกนแล้วมันไม่ตรง คุณก็ไม่ได้เข้า
นั่นคือหน้าที่ทั้งหมดของ signature: มันกันการแก้ ไม่ได้กันการอ่าน
ที่ไม่เหมือนกัน: สองข้อ และทั้งสองข้อสำคัญกว่าตัวการเปรียบเทียบเสียอีก —
(1) บาร์โค้ดบนตั๋วเป็นแค่การเขียนข้อมูลเดิมซ้ำอีกรอบ ใครมีเครื่องพิมพ์ก็ทำเลียนแบบได้ ·
แต่ signature ของ JWT คำนวณจาก secret ที่มีแต่เซิร์ฟเวอร์รู้ ไม่มี secret ก็สร้างไม่ได้เลย
(หัวข้อ 7.2 จะพิสูจน์ให้ดู: แก้ไปตัวเดียวจาก 185 ตัวก็พังทันที) ·
(2) พนักงานหน้าโรงยังยิงเข้าระบบเพื่อเช็คว่าตั๋วใบนี้ถูกใช้ไปหรือยัง ·
แต่ JWT ตั้งใจให้ตรวจจบในตัว ไม่ถามฐานข้อมูลเลย —
ซึ่งแปลว่าใบที่ถูกขโมยไปก็ยังใช้ได้จนกว่าจะหมดอายุ เรื่องนี้จะกลับมาหลอกเราในหัวข้อ 10
ห้าคำนี้จะโผล่ในทุกบทที่เหลือของ Day 9 และในทุกหน้า doc ของ auth ที่คุณจะอ่านต่อจากนี้
- claim
- "ข้อมูลหนึ่งชิ้นที่ token กล่าวอ้างเกี่ยวกับผู้ถือ" —
sub·exp·admin:trueแต่ละอันคือหนึ่ง claim · "เราจะเอา role ใส่เป็น claim ในตัว token เลย หรือให้ไปอ่านจาก DB ดีครับ" เป็นคำถามออกแบบที่ senior จะเข้าใจทันทีว่าคุณกำลังชั่งอะไรอยู่ - payload
- ส่วนที่ 2 ของ token — ก้อน JSON ที่รวม claim ทั้งหมด · ถือว่าเป็นข้อความสาธารณะเสมอ · "claim นี้อยู่ใน payload นะ แปลว่า client อ่านได้หมด โอเคไหม"
- signature
- ส่วนที่ 3 — ค่าที่คำนวณจาก header + payload + secret · ตอบคำถามเดียวคือ "ก้อนนี้ถูกแก้ระหว่างทางไหม" ไม่ได้ตอบว่า "ใครอ่านได้บ้าง"
- HS256 (HMAC-SHA256)
- วิธีเซ็นแบบกุญแจก้อนเดียว — คนเซ็นกับคนตรวจต้องถือ secret ตัวเดียวกัน ·
คู่เทียบคือ
RS256ที่เซ็นด้วย private key แล้วตรวจด้วย public key · "ระบบเราเซ็นด้วย HS256 หรือ RS256 ครับ" เป็นคำถามแรกเวลาต้องต่อ auth กับทีมอื่น - bearer token
- "ใครถือใบนี้ = คนนั้นคือเจ้าของสิทธิ์" ไม่มีการเช็คซ้ำว่าคนถือเป็นคนเดียวกับที่ออกให้ ·
นี่คือที่มาของคำว่า
Bearerใน headerAuthorization: Bearer <token>ที่จะเจอในหัวข้อ 9 · "token เราเป็น bearer token ใช่ไหมครับ ถ้าหลุดคือจบเลย"
secret ของ token ตัวนี้ไม่ใช่ค่าที่คนส่วนใหญ่เขียนกัน
token ตัวข้างบนเป็นตัวอย่าง default ของเว็บ jwt.io ซึ่งคนก๊อปไปใช้กันทั้งโลก
และตำราเก่าเกือบทุกเล่มบอกว่า secret ของมันคือ "your-256-bit-secret"
ผมลองแล้วไม่ผ่าน — ผมคำนวณ HMAC ด้วย openssl ตรง ๆ เทียบกับ signature ในสไลด์:
$ SIGNING=<header>.<payload>
$ printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$s" -binary \
| base64 | tr '+/' '-_' | tr -d '='
your-256-bit-secret reGQzG3OKdoIMWLDKOZ4TICJit3EW69cQE72E2CfzRE
a-string-secret-at-least-256-bits-long KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
target: KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
ยืนยันซ้ำด้วย jwt.Parse ของ golang-jwt/jwt/v5 v5.3.1:
=== 2) ลอง secret หลายตัวกับ token ของ jwt.io ===
secret="your-256-bit-secret" -> FAIL: token signature is invalid: signature is invalid
secret="secret" -> FAIL: token signature is invalid: signature is invalid
secret="a-string-secret-at-least-256-bits-long" -> OK (signature ผ่าน)
📌 jwt.io เปลี่ยนค่า default ไปแล้ว ตำราที่เขียนไว้ก่อนหน้านั้นจึงล้าสมัย —
ถ้าคุณลองตามตำราเก่าแล้วขึ้น signature is invalid คุณไม่ได้ทำผิด secret ต่างหากที่คนละตัว
อีกเรื่องที่ควรรู้ไว้ก่อนงงเอง: กล่องเล็ก ๆ ในสไลด์หน้า 32 เขียนว่า "alg":"RS256"
แต่ token ตัวจริงที่อยู่หน้าเดียวกัน decode ออกมาได้ {"alg":"HS256","typ":"JWT"}
สองอันนี้มาจากคนละรูป ไม่ใช่ของคู่กัน — ให้เชื่อผลที่ decode ออกมา
(HS256 = เซ็นด้วย secret ก้อนเดียวที่ทั้งคนเซ็นและคนตรวจต้องมีเหมือนกัน ·
RS256 = เซ็นด้วย private key แล้วตรวจด้วย public key — คนละแบบกันคนละเรื่อง)
6 · สร้างและ verify JWT ด้วย golang-jwt/jwt/v5
สไลด์หน้า 33 บอกแค่บรรทัดเดียวว่าให้ลง library ตัวไหน:
$ go get github.com/golang-jwt/jwt/v5
go: added github.com/golang-jwt/jwt/v5 v5.3.1
โค้ดสร้าง token มีแค่สองบรรทัดจริง ๆ — ที่เหลือคือการเตรียม claims
ข้างล่างนี้คือ cmd/mine/main.go ฉบับย่อ (ตัดเหลือเฉพาะส่วนที่ 3 กับ 3b)
แต่เป็นไฟล์เต็มที่ก๊อปทั้งก้อนไปวางแล้ว go run ได้เลย —
มีทั้งบรรทัด package และ import ครบ ไม่ต้องเติมอะไรเพิ่ม:
// cmd/mine/main.go
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("a-string-secret-at-least-256-bits-long")
func keyfunc(t *jwt.Token) (any, error) { return secret, nil } // ใช้ตอน parse ในหัวข้อถัดไป
func main() {
now := time.Now()
mine := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42", // subject = ผู้ใช้คนนี้เป็นใคร
"name": "somchai",
"role": "admin",
"iat": now.Unix(), // issued at = ออกให้เมื่อไร
"exp": now.Add(time.Hour).Unix(), // expires = หมดอายุเมื่อไร
})
s, err := mine.SignedString(secret) // secret เป็น []byte
fmt.Println("=== 3) สร้าง JWT เอง (HS256) ===")
fmt.Println("SignedString err =", err)
fmt.Println("token =", s)
fmt.Println("len =", len(s))
}
ผลรันจริง:
=== 3) สร้าง JWT เอง (HS256) ===
SignedString err = <nil>
token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODczNzI0MjksImlhdCI6MTc4NzM2ODgyOSwibmFtZSI6InNvbWNoYWkiLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.1JpaiixbPQRhCTZEcNwFMJbEZSJVVw6xej3cSqg_nSc
len = 185 # 185 ตัวอักษร = 185 byte เพราะ JWT เป็น ASCII ล้วน
ฝั่งตรวจ ใช้ jwt.Parse ซึ่งขอสองอย่าง: ฟังก์ชันที่คืน key (เรียกว่า keyfunc) และ รายชื่อ alg ที่เรายอมรับ
keyfunc เราประกาศไว้ในไฟล์ข้างบนแล้ว เหลือแค่เติมบรรทัดพวกนี้ต่อท้าย main()
(ก้อนนี้เป็นส่วนที่เติมเข้าไป ไม่ใช่ไฟล์เดี่ยว ๆ — ไม่ต้องใส่ package/import ซ้ำ):
back, err := jwt.Parse(s, keyfunc, jwt.WithValidMethods([]string{"HS256"}))
fmt.Println("\n--- 3b) parse กลับ + verify ---")
if err != nil {
fmt.Println("FAIL", err)
return
}
c := back.Claims.(jwt.MapClaims)
fmt.Printf("OK valid=%v claims=%v\n", back.Valid, c)
fmt.Printf("ชนิดจริงของ c[\"exp\"] = %T\n", c["exp"])
fmt.Printf("ชนิดจริงของ c[\"sub\"] = %T\n", c["sub"])
--- 3b) parse กลับ + verify ---
OK valid=true claims=map[exp:1.787372429e+09 iat:1.787368829e+09 name:somchai role:admin sub:42]
ชนิดจริงของ c["exp"] = float64
ชนิดจริงของ c["sub"] = string
MapClaims แปลงตัวเลขเป็น float64 เสมอ — เขียน .(int) เมื่อไหร่ panic เมื่อนั้น
สังเกต exp:1.787372429e+09 ในผลรัน — มันโชว์เป็นทศนิยมเพราะมันเป็น float64 จริง ๆ
สาเหตุคือ MapClaims เป็น map[string]any ที่ decode มาจาก JSON
และตัวถอด JSON มาตรฐานของ Go แปลงตัวเลขทุกตัวเป็น float64 ไม่มีข้อยกเว้น
c := back.Claims.(jwt.MapClaims)
// สองบรรทัดนี้เป็น "ตัวเลือก" ให้เทียบกัน — เอาไปใช้จริงให้เลือกบรรทัดเดียว
exp := c["exp"].(int) // ❌ panic: interface conversion: interface {} is float64, not int
exp := int64(c["exp"].(float64)) // ✅ ถูกต้อง
ส่วน sub ในตัวอย่างนี้เป็น string เพราะเราใส่เข้าไปเป็น "42" (มีเครื่องหมายคำพูด)
ทางที่ปลอดภัยกว่าคือเลิกใช้ MapClaims แล้วประกาศ struct ของตัวเองที่ฝัง
jwt.RegisteredClaims — แบบนั้นชนิดจะถูกบังคับตั้งแต่ตอน compile ไม่ต้องมานั่ง cast เอง
7 · สามเคสที่ token ใช้ไม่ได้ — พร้อม error จริง
ทั้งสามเคสข้างล่างผมรันด้วยฟังก์ชันตัวเดียวกัน คือ
jwt.Parse(tokenStr, keyfunc, jwt.WithValidMethods([]string{"HS256"}))
แล้วพิมพ์ทั้งข้อความ error และผลของ errors.Is สามตัวออกมาเทียบกัน
7.1 · token หมดอายุ
--- 4.1 token หมดอายุ (exp ผ่านไป 1 ชม.) ---
err = token has invalid claims: token is expired
errors.Is(err, jwt.ErrTokenExpired) = true
errors.Is(err, jwt.ErrSignatureInvalid) = false
errors.Is(err, jwt.ErrTokenSignatureInvalid) = false
valid= false
7.2 · signature ผิด (แก้ token ไปหนึ่งตัวอักษร)
(แก้ตัวอักษรสุดท้าย "c" -> "Q")
--- 4.2 signature ผิด ---
err = token signature is invalid: signature is invalid
errors.Is(err, jwt.ErrTokenExpired) = false
errors.Is(err, jwt.ErrSignatureInvalid) = true
errors.Is(err, jwt.ErrTokenSignatureInvalid) = true
valid= false
แก้แค่ตัวอักษรเดียวจาก 185 ตัวก็พังทันที — นี่คือหลักฐานว่า signature ทำหน้าที่ของมันจริง: มันกันการแก้ไข (แต่ยังไม่ได้กันการอ่าน ตามที่พิสูจน์ไปในหัวข้อ 5)
7.3 · alg ไม่ตรงกับที่เรายอมรับ
--- 4.3 alg ไม่ตรง (token=HS256 แต่ยอมรับแค่ RS256) ---
err = token signature is invalid: signing method HS256 is invalid
errors.Is(err, jwt.ErrTokenExpired) = false
errors.Is(err, jwt.ErrSignatureInvalid) = false
errors.Is(err, jwt.ErrTokenSignatureInvalid) = true
ErrSignatureInvalid กับ ErrTokenSignatureInvalid ไม่ใช่ตัวเดียวกัน
ชื่อต่างกันแค่คำว่า Token แต่ผลรันบอกว่าคนละตัวจริง ๆ:
· เคส 7.2 (signature ผิด) — errors.Is เป็น true ทั้งคู่
· เคส 7.3 (alg ไม่ตรง) — ErrSignatureInvalid เป็น false
แต่ ErrTokenSignatureInvalid เป็น true
แปลว่าถ้าโค้ดคุณเช็คด้วย ErrSignatureInvalid อย่างเดียว
คุณจะไม่จับเคส alg ไม่ตรง
เหตุผลอยู่ที่คนละไฟล์กัน: ErrTokenSignatureInvalid อยู่ใน errors.go
เป็น error กลางของตัว parser · ส่วน ErrSignatureInvalid อยู่ใน hmac.go
เป็นของ SigningMethodHMAC โดยเฉพาะ
เคส 7.3 ถูกปฏิเสธตั้งแต่ตอนเช็ครายชื่อ alg — ยังไม่ทันเดินไปถึงตัวตรวจ HMAC เลย
error ของ HMAC จึงไม่มีทางโผล่ (ถ้าวันหนึ่งคุณย้ายไป RS256 ก็จะไม่เจอ
ErrSignatureInvalid อีกเลยด้วยเหตุผลเดียวกัน)
สรุปให้จำ: ใช้ jwt.ErrTokenSignatureInvalid เพราะครอบคลุมกว่า
7.4 · เคสที่สไลด์ไม่ได้พูด แต่เจอบ่อยกว่า
ผมรันเพิ่มอีกชุด เพราะสามเคสข้างบนยังไม่ครบกับที่เจอจริงตอน debug:
$ go run ./cmd/extra
nbf อยู่อนาคต (ยังใช้ไม่ได้) err = token has invalid claims: token is not valid yet
aud ไม่ตรง (คาด mobile-app) err = token has invalid claims: token has invalid audience
iss ไม่ตรง (token มี iss=evil, คาด wongnok) err = token has invalid claims: token has invalid issuer
iss ไม่มีเลย + WithIssuer() err = token has invalid claims: token is missing required claim: iss claim is required
token มีแค่ 2 ส่วน (ตัด signature ทิ้ง) err = token is malformed: token contains an invalid number of segments
token เป็นข้อความมั่ว err = token is malformed: token contains an invalid number of segments
token ว่าง err = token is malformed: token contains an invalid number of segments
| claim | ย่อมาจาก | แปลว่า |
|---|---|---|
sub | subject | token นี้พูดถึงใคร — ปกติใส่ id ผู้ใช้ |
iat | issued at | ออก token ตอนไหน |
exp | expiration time | หมดอายุตอนไหน — หลังเวลานี้ห้ามใช้ |
nbf | not before | ก่อนเวลานี้ห้ามใช้ — ใช้กับ token ที่ออกล่วงหน้า |
aud | audience | token นี้ออกให้ระบบไหนใช้ — กัน token ของแอปหนึ่งไปใช้กับอีกแอป |
iss | issuer | ใครเป็นคนออก token นี้ |
สังเกตสองอย่างจากผลรัน: token ว่าง · token มั่ว · token ขาดส่วน ได้ error เดียวกันหมด
คือ invalid number of segments —
ดังนั้นถ้าเจอ error นี้อย่าไปไล่หา secret ให้เสียเวลา ปัญหาอยู่ที่ตัว token ไม่ครบสามส่วน
(สาเหตุยอดฮิต: ลืมตัดคำว่า "Bearer " ออกจาก header ก่อนส่งเข้า jwt.Parse)
อีกอย่าง: สังเกตสองบรรทัด iss ให้ดี — jwt.WithIssuer("wongnok")
ทำงานสองหน้าที่ในตัวเดียว และให้ error คนละตัวกัน:
· ถ้า token มี iss แต่ค่าไม่ตรง → token has invalid issuer
(jwt.ErrTokenInvalidIssuer)
· ถ้า token ไม่มี iss เลย → token is missing required claim: iss claim is required
(jwt.ErrTokenRequiredClaimMissing) เพราะการใส่ WithIssuer()
ทำให้ iss กลายเป็น claim ที่บังคับต้องมีไปด้วยเลย
ถ้าคุณเจอ invalid issuer แปลว่า token มี iss อยู่แล้ว — ไปไล่ดูว่าใครออก token ให้
ไม่ใช่ไปไล่ดูว่าลืมใส่ claim
exp ผ่านฉลุย
เราบอกไปในหัวข้อ 4 ว่า "JWT ดีกว่า Basic Auth เพราะมีวันหมดอายุ"
ประโยคนั้นจริงแค่ครึ่งเดียว — JWT รองรับ วันหมดอายุ แต่ไม่ได้บังคับ
ผมสร้าง token ที่มีแค่ sub อย่างเดียว ไม่มี exp เลย แล้ว parse ดู:
token ไม่มี exp เลย (default) err = <nil>
token ไม่มี exp + WithExpirationRequired() err = token has invalid claims: token is missing required claim: exp claim is required
err = <nil> แปลว่า token ตลอดชีพ — หลุดเมื่อไหร่คือหลุดตลอดไป
ซึ่งแย่กว่า Basic Auth ด้วยซ้ำ เพราะ Basic Auth ยังเปลี่ยนรหัสผ่านตัดได้
ต้องทำสองอย่างคู่กันเสมอ: ใส่ exp เองตอน sign
และ ใส่ jwt.WithExpirationRequired() ตอน parse
📌 เรื่อง leeway (ค่าเผื่อนาฬิกาคลาดเคลื่อน) —
เซิร์ฟเวอร์คนละเครื่องมีเวลาไม่ตรงกันเป๊ะได้ ทำให้ token ที่เพิ่งออกมาโดนตีว่าหมดอายุแล้ว
golang-jwt มี jwt.WithLeeway() ให้เผื่อได้ ค่า default คือ 0 วินาที (ไม่เผื่อเลย):
หมดอายุ 20 วิ (default ไม่มี leeway) err = token has invalid claims: token is expired
หมดอายุ 20 วิ + WithLeeway(60s) err = <nil>
เป็นดาบสองคม: มันทำให้ token ที่หมดอายุไปแล้วจริง ๆ ยังใช้ได้ต่ออีก 60 วินาที
ใส่เมื่อจำเป็นเท่านั้น และใส่ให้น้อยที่สุดเท่าที่ระบบยอมได้
8 · การโจมตี alg=none และเหตุผลที่ต้องระบุ alg ตอน parse
ย้อนกลับไปดู header ของ JWT: {"alg":"HS256","typ":"JWT"}
ช่องนี้อยู่ในตัว token ซึ่งแปลว่าผู้โจมตีเป็นคนเขียนเองได้ —
แล้วถ้าเขาเขียนว่า "alg":"none" ล่ะ?
มาตรฐาน JWT มีค่า none อยู่จริง หมายถึง "token นี้ไม่มีลายเซ็น"
ซึ่งถ้าเซิร์ฟเวอร์เชื่อตามนั้น ก็เท่ากับให้ผู้โจมตีพิมพ์บัตรผ่านของตัวเองได้เลย
สร้าง token ปลอมแบบนี้ง่ายมาก ไม่ต้องมี library ด้วยซ้ำ — แค่ base64 สองก้อนแล้วปิดท้ายด้วยจุดเปล่า:
evil := b64(`{"alg":"none","typ":"JWT"}`) + "." +
b64(`{"role":"admin","sub":"42"}`) + "." // ← ส่วนที่ 3 ว่างเปล่า
$ go run ./cmd/algnone
=== 5) alg=none attack ===
evil token = eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.
A) มี WithValidMethods([HS256]) -> err = token signature is invalid: signing method none is invalid
B) ไม่มี WithValidMethods -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed
C) คืน UnsafeAllowNoneSignatureType -> err = <nil>
ข่าวดี: golang-jwt/jwt/v5 v5.3.1 บล็อกให้ตั้งแต่ default อยู่แล้ว —
ดูเคส B ที่ไม่ได้ใส่ WithValidMethods เลย มันก็ยังไม่ยอมให้ผ่าน
ทางเดียวที่ token alg=none จะทะลุเข้ามาได้ คือเคส C:
คนเขียนโค้ดเองไปคืน jwt.UnsafeAllowNoneSignatureType ใน keyfunc
(ชื่อมันขึ้นต้นด้วยคำว่า Unsafe อยู่แล้ว)
🧭 ตรงนี้ต้องพูดให้ตรง อย่าท่องตามกันมา —
หลายที่สอนว่า "ถ้าไม่ใส่ WithValidMethods แล้ว alg=none จะทะลุ"
ในไลบรารีตัวนี้เวอร์ชันนี้ ไม่จริง (ดูเคส B)
แต่คำแนะนำให้ใส่ WithValidMethods ยังถูกอยู่ ด้วยเหตุผลอื่นสองข้อ:
1. error ชัดกว่ามาก — signing method none is invalid
อ่านแล้วรู้เลยว่าใครกำลังทำอะไร เทียบกับข้อความยาว ๆ ของ default
2. มันกันการโจมตีอีกแบบที่อันตรายกว่า ซึ่งอยู่ข้างล่างนี้
algorithm confusion — เอา public key มาเซ็นแบบ HS256
Algorithm confusion คือการที่ผู้โจมตีสลับชนิดของอัลกอริทึมเพื่อหลอกให้เซิร์ฟเวอร์
เอาของที่เปิดเผยได้ มาใช้เป็นความลับ
ไอเดียคือ: ถ้าเซิร์ฟเวอร์ตั้งใจใช้ RS256 (private key เซ็น / public key ตรวจ)
แล้ว public key เป็นของสาธารณะ ใครก็โหลดได้
ผู้โจมตีก็เอา public key นั้นมาใช้เป็น "secret" ของ HS256 แล้วเซ็น token เอง —
ถ้าเซิร์ฟเวอร์ไม่ได้ล็อก alg ไว้ มันจะหยิบ public key ตัวเดียวกันมาตรวจ แล้วก็ตรงกันพอดี
จุดที่ต้องเห็นให้ชัดคือ "เอา public key รูปแบบไหนมาใช้เป็น secret" —
ในโปรแกรมนี้ผมใช้ PEM ทั้งก้อน (ตัวเดียวกับที่เซิร์ฟเวอร์เอาไปแปะไว้ให้โหลด)
ฝั่งผู้โจมตีมีแค่สามบรรทัด:
(โปรแกรมสุ่ม RSA key ใหม่ทุกครั้งที่รัน ตัว token ที่คุณได้เลยไม่เหมือนข้างล่าง —
แต่สองบรรทัด error จะออกมาเหมือนกันเป๊ะ)
der, _ := x509.MarshalPKIXPublicKey(pub) // public key → DER
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) // DER → PEM
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(pubPEM)
$ go run ./cmd/confusion
attacker เซ็น HS256 ด้วย public key: err=<nil>
token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.BTBZcWv8rupZ27-xkP_c6coRiTpy4nT6fwb23UrkOo4
A) ไม่มี WithValidMethods -> err = token signature is invalid: key is of invalid type: HMAC verify expects []byte
B) มี WithValidMethods([RS256]) -> err = token signature is invalid: signing method HS256 is invalid
เคส A รอดเพราะ keyfunc คืน *rsa.PublicKey ส่วนตัวตรวจ HMAC ต้องการ []byte
ชนิดไม่ตรงกันมันเลยพัง — ไม่ได้แปลว่ามันเช็คว่า alg ผิด
แปลว่าถ้าวันหนึ่งมีคนแก้ keyfunc ให้คืน []byte (เช่นอ่าน PEM มาเป็น byte แล้วส่งดิบ ๆ)
ช่องโหว่นี้เปิดทันทีโดยไม่มีอะไรเตือน
ส่วนเคส B ปฏิเสธเพราะ alg ไม่อยู่ในรายชื่อที่เรายอมรับ ตรง ๆ ซึ่งไม่ขึ้นกับชนิดของ key เลย
กฎที่ควรถือ: ใส่ jwt.WithValidMethods() ทุกครั้ง
เพราะเราอยากกันโดยตั้งใจ ไม่ใช่กันโดยบังเอิญ
9 · บอก Swagger ว่า endpoint นี้ต้องใช้ JWT
สไลด์หน้า 34 เขียนสั้น ๆ ว่า "เพิ่ม Swagger annotation เกี่ยวกับ Security Definition เพื่อระบุการใช้งาน JWT"
ของชิ้นนี้เราทำไปแล้วใน บทเรียน 41
ตอนนี้แค่มาดูอีกครั้งว่ามันเชื่อมกับ JWT ตรงไหน — มันทำงานเป็นคู่ สองจุด ขาดจุดใดจุดหนึ่งก็ไม่เกิดผล
จุดที่ 1 — ประกาศ "วิธีล็อกอิน" ไว้เหนือ func main() มีที่เดียวในทั้งโปรเจกต์:
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description ใส่ค่าเป็น "Bearer {token}"
func main() { ... }
จุดที่ 2 — ติดป้ายบน handler ที่ต้องล็อกอิน (ใส่เฉพาะตัวที่ต้องการ):
// @Security BearerAuth
// @Router /users/{id} [get]
func (handler *Handler) FindByID(ctx *gin.Context) { ... }
สั่ง swag init (v1.16.4) แล้วเปิด docs/swagger.json ดู จะได้สองก้อนนี้คนละที่:
// ระดับเอกสาร มาจากจุดที่ 1
"securityDefinitions": {
"BearerAuth": {
"description": "ใส่ค่าเป็น \"Bearer {token}\"",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
// ระดับ endpoint มาจากจุดที่ 2 (อยู่ใต้ paths./users/{id}.get)
"security": [ { "BearerAuth": [] } ]
ผลที่เห็นบนหน้าจอ: มีปุ่ม Authorize มุมขวาบน · endpoint ที่ต้องล็อกอินมีรูปกุญแจ · ใส่ token ครั้งเดียวแล้วกด Try it out ได้ทุกอันโดยไม่ต้องเปิด Postman
apikey ทั้งที่มันคือ Bearer JWT — และทำไมต้องพิมพ์คำว่า "Bearer " เอง
เพราะ swag v1 ออกไฟล์เป็น Swagger 2.0 (ดูค่า "swagger": "2.0" ในไฟล์)
ซึ่งเป็นสเปกที่ยังไม่มี type: http / scheme: bearer —
ของพวกนั้นเพิ่งมาใน OpenAPI 3
ท่าที่ทำได้ในสเปก 2.0 จึงมีแค่บอกว่า "มี header ชื่อ Authorization" ซึ่งก็คือ apiKey
ผลข้างเคียงที่ต้องบอกทีมทุกครั้ง: ในช่อง Authorize ต้องพิมพ์คำว่า Bearer
(มีเว้นวรรค) นำหน้า token เอง เพราะ Swagger UI ส่งค่าที่พิมพ์ไปตรง ๆ ไม่ได้เติมให้
นั่นแหละคือเหตุผลที่บรรทัด @description เขียนว่า ใส่ค่าเป็น "Bearer {token}" —
มันคือคำเตือนที่จะไปโผล่ใต้ช่องกรอกให้คนใช้เห็น
10 · JWT ไม่ได้ดีกว่าทุกเรื่อง
ก่อนจบบท ต้องบอกฝั่งที่แลกมาด้วย ไม่งั้นคุณจะเอา JWT ไปใช้ผิดที่
อย่างแรกคือขนาด — JWT พก claims ไว้ในตัว แปลว่ามันใหญ่กว่า session id มาก
และมันเดินทางไปกับทุก request เหมือน Basic Auth เป๊ะ ผมวัดมาให้ดู
สามก้อนที่เอามาวัดคือ claims พวกนี้ (cmd/size เซ็นทั้งสามก้อนด้วย HS256 เหมือนกันหมด):
minimal := jwt.MapClaims{"sub": "42", "exp": exp}
typical := jwt.MapClaims{
"sub": "42", "name": "somchai", "role": "admin",
"iss": "wongnok", "iat": now.Unix(), "exp": exp,
}
fat := jwt.MapClaims{
"sub": "42", "name": "สมชาย ใจดี", "iss": "wongnok",
"roles": []string{"admin", "editor", "reviewer"},
"perms": []string{"food:read", "food:write", "food:delete"},
"iat": now.Unix(), "exp": exp,
}
$ go run ./cmd/size
แบบ byte ตัวอย่าง
JWT minimal (sub+exp) 120 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODczNzcxNDIsInN1YiI6IjQyIn0.XJ3SsW28hIV-mK16uXjbmZJUApKMs1X11wvM6_OM2W4
JWT typical (6 claims) 207 (ตัด token ออก)
JWT fat (roles+perms+ชื่อไทย) 331 (ตัด token ออก)
session id 128-bit hex 32 954aa83869ba92dfb8c586b374c221e3
session id 256-bit b64url 43 syv3gevNPL5wqDBV7Z-J0zGCYrBegP_3_W101-LKLGE
header ที่ส่งจริง Authorization: Bearer <typical> = 229 byte
header ที่ส่งจริง Cookie: sid=<hex> = 44 byte
typical JWT ใหญ่กว่า session hex = 6.5 เท่า
fat JWT ใหญ่กว่า session hex = 10.3 เท่า
ถ้าคุณรันเอง exp/iat กับ session id จะเป็นค่าอื่น
แต่ตัวเลข byte จะเท่ากันเป๊ะ เพราะ unix timestamp ยาว 10 หลักเท่ากันทุกครั้ง
และ signature ของ HS256 ยาว 43 ตัวอักษรเสมอ
ตัวที่บวมที่สุดคือ token ที่ยัด roles + perms + ชื่อภาษาไทย
เหตุผลเรื่องภาษาไทยคือจำนวนไบต์ ไม่ใช่การ escape —
encoding/json ของ Go ไม่แปลงอักษรไทยเป็น \uXXXX
(มัน escape แค่ < > & และตัวคั่นบรรทัดพิเศษ U+2028/U+2029)
payload จึงเก็บภาษาไทยเป็น UTF-8 ดิบ ๆ อ่านออกได้ตรง ๆ ตอน decode
แต่อักษรไทยหนึ่งตัวกิน 3 ไบต์ ใน UTF-8 (ASCII กินตัวละ 1 ไบต์)
แล้ว base64 ยังขยายอีก 4/3 เท่า → ชื่อไทยหนึ่งตัวกลายเป็น 4 ตัวอักษร ใน token
ขณะที่ตัวอักษรอังกฤษสามตัวถึงจะกินที่เท่ากัน
ส่วนอย่างที่สองที่หนักกว่าเรื่องขนาด: JWT ที่ออกไปแล้ว เรียกคืนไม่ได้
เพราะจุดขายทั้งหมดของมันคือ "เซิร์ฟเวอร์ตรวจได้เองโดยไม่ต้องถาม DB" —
ซึ่งแปลว่าเซิร์ฟเวอร์ก็ไม่รู้เหมือนกันว่ามีใครถูกแบนไปแล้ว
ถ้าผู้ใช้ถูกไล่ออกตอน 10 โมง แต่ token ของเขาหมดอายุ 11 โมง เขายังใช้งานได้อีกชั่วโมงเต็ม
| Basic Auth | Session id + DB | JWT | |
|---|---|---|---|
| ส่งอะไรไปทุก request | รหัสผ่านตัวจริง | id สุ่ม 32 byte | ตัว token 120–331 byte |
| มีวันหมดอายุในตัว | ไม่มี | เซิร์ฟเวอร์คุมเอง | มี — ถ้าใส่ exp เอง |
| เช็ค DB ทุก request | ต้อง (hash ด้วย) | ต้อง (แต่แค่หา key) | ไม่ต้อง |
| ยกเลิกทันทีได้ไหม | ต้องเปลี่ยนรหัสผ่าน | ได้ — ลบแถวเดียว | ไม่ได้ ต้องรอหมดอายุ |
| พก claims (role ฯลฯ) | ไม่ได้ | ได้ (เก็บฝั่งเซิร์ฟเวอร์) | ได้ — อยู่ในตัว token |
ท่ามาตรฐานที่ระบบจริงใช้กัน คือรวมข้อดีสองฝั่ง:
ออก access token (JWT) ที่อายุสั้นมาก เช่น 15 นาที เอาไว้ยิง API —
คู่กับ refresh token ที่อายุยาวและเก็บใน DB จริง เอาไว้ขอ access token ใบใหม่
เวลาจะแบนใคร ก็ลบ refresh token ของเขาทิ้ง — เขาใช้ได้อีกไม่เกิน 15 นาทีก็จบ
คุณยังไม่ต้องทำท่านี้ตอนนี้ แค่รู้ไว้ว่า "อายุ token สั้น" ไม่ใช่ความจู้จี้ แต่เป็น
คำตอบเดียวที่ JWT มีให้กับปัญหา revoke
ทดสอบความเข้าใจ
1. ผู้ใช้ล็อกอินสำเร็จแล้ว แล้วกดปุ่มลบสูตรอาหารของคนอื่น — เซิร์ฟเวอร์ควรตอบ status อะไร และนั่นเป็นเรื่องของอะไร?
ลำดับของสองเรื่องนี้คือกุญแจ: authentication เกิดก่อน (คุณเป็นใคร)
แล้ว authorization เกิดทีหลัง (คุณทำอะไรได้)
โจทย์บอกชัดว่า "ล็อกอินสำเร็จแล้ว" แปลว่าด่าน authentication ผ่านไปแล้ว
เซิร์ฟเวอร์รู้ว่าเขาเป็นใคร — ที่ติดคือเรื่องสิทธิ์ล้วน ๆ ซึ่งคือ 403
ตัวเลือกที่ 1 กับ 2 ผิดเพราะ 401 แปลว่า "ยังไม่รู้ว่าคุณเป็นใคร" ซึ่งขัดกับโจทย์
โดยเฉพาะข้อ 2 ที่ฟังดูดี — WWW-Authenticate คือ header ที่มาคู่กับ 401
เพื่อบอกวิธีพิสูจน์ตัวตน (ในผลรันหัวข้อ 2 เราเห็นค่าจริงคือ
WWW-Authenticate="Basic realm=\"wongnok\"") — มันไม่ใช่เครื่องมือสำหรับบอกว่า "สิทธิ์ไม่พอ"
ตัวเลือกที่ 4 ผิดเพราะ request ถูกต้องทุกอย่าง ไม่มีข้อมูลไหนพัง
จุดที่ต้องจำ: ชื่อ 401 Unauthorized ตั้งผิดมาตั้งแต่ต้น —
มันหมายถึง unauthenticated ส่วนตัวที่แปลว่า "ไม่มีสิทธิ์" จริง ๆ คือ 403 Forbidden
2. โค้ดเซิร์ฟเวอร์เขียนว่า user, pass, ok := r.BasicAuth() แล้วตามด้วย if ok { ให้ผ่าน } — เกิดอะไรขึ้น?
ผลรันจริงที่ยิงด้วยรหัสผิดตั้งใจ (req.SetBasicAuth("somchai", "wrong")):
[server] r.BasicAuth() -> user="somchai" pass="wrong" ok=true
รหัสผิดชัด ๆ แต่ ok ยังเป็น true
เพราะ ok ตอบคำถามว่า "แกะ header สำเร็จไหม" —
คือมี header Authorization · ขึ้นต้นด้วย Basic · base64 ข้างหลังถอดได้ · มี : คั่น
Go ไม่มีทางรู้เลยว่ารหัสที่ถูกต้องของระบบคุณคืออะไร มันจึงเทียบให้ไม่ได้ตั้งแต่ต้น
ตัวเลือกที่ 3 ผิดเพราะ r.BasicAuth() ไม่คืน error และไม่ panic — คืนแค่ bool
ตัวเลือกที่ 4 ผิดเพราะมันไม่ได้เช็ค username ด้วยซ้ำ แค่แกะออกมาเฉย ๆ
วิธีที่ถูก: เทียบเองทั้ง user และ pass ด้วย
subtle.ConstantTimeCompare แล้วตอบ 401 พร้อม WWW-Authenticate เมื่อไม่ตรง
3. เพื่อนส่ง JWT มาให้ดูแล้วบอกว่า "ไม่เป็นไรหรอก ข้างในใส่รหัสผ่านไว้ แต่มันเข้ารหัสอยู่" — ตอบเขายังไง?
ผมพิสูจน์สองทางในหัวข้อ 5 ด้วย token จากสไลด์:
ทางที่ 1 — shell ล้วน ๆ: echo '<payload>=' | tr '_-' '/+' | base64 -d
ได้ {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
ทางที่ 2 — Go ด้วย ParseUnverified ที่ไม่รับ secret เข้าไปเลย:
ได้ err=<nil> และเห็น admin:true ครบ
ตัวเลือกที่ 1 ผิดเพราะ JWT มาตรฐาน (JWS) ไม่เข้ารหัส payload เลย — แค่ base64
ตัวเลือกที่ 2 ผิดเพราะ alg ไม่เกี่ยวกับการอ่าน payload เลย มันเกี่ยวกับการตรวจ signature อย่างเดียว
ตัวเลือกที่ 3 คือกับดักที่หลอกที่สุด — มันเข้าใจหน้าที่ของ signature ถูก แต่ตอบผิดโจทย์:
signature กันการแก้ไข ไม่ได้กันการอ่าน
และปัญหาของเพื่อนคือรหัสผ่านถูกอ่านไปแล้ว ไม่ใช่กลัวคนแก้
จุดที่ต้องจำ: ถือว่า payload ของ JWT เป็นข้อความสาธารณะเสมอ —
ห้ามใส่รหัสผ่าน เลขบัตร หรือความลับใด ๆ ลงไป
4. โค้ดเขียน jwt.Parse(s, keyfunc) เฉย ๆ ไม่ได้ใส่ jwt.WithValidMethods() — ข้อไหนอธิบายความเสี่ยงได้ตรงกับผลรันจริงที่สุด?
ผลรันของ cmd/algnone ปฏิเสธตัวเลือกที่ 1 ตรง ๆ:
B) ไม่มี WithValidMethods -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed
ทางเดียวที่ alg=none ทะลุได้คือคนเขียนโค้ดคืน jwt.UnsafeAllowNoneSignatureType เอง (เคส C ได้ err=<nil>)
ส่วนของจริงที่น่ากลัวอยู่ในผลรันของ cmd/confusion:
A) ไม่มี WithValidMethods -> err = token signature is invalid: key is of invalid type: HMAC verify expects []byte
ข้อความนี้บอกเราว่ามันรอดเพราะ keyfunc คืน *rsa.PublicKey แล้วชนิดไม่ตรงกับที่ HMAC ต้องการ
ไม่ใช่เพราะมันเช็คว่า alg ผิด — วันไหน keyfunc คืน []byte ช่องโหว่เปิดทันที
เทียบกับตอนใส่: B) มี WithValidMethods([RS256]) -> signing method HS256 is invalid ซึ่งปฏิเสธเพราะ alg ตรง ๆ
ตัวเลือกที่ 3 ผิดเพราะมองข้าม algorithm confusion · ตัวเลือกที่ 4 ผิดชัดเพราะ signature ยังถูกตรวจอยู่เสมอ
จุดที่ต้องจำ: ใส่ WithValidMethods ทุกครั้ง เพราะเราต้องการกันโดยตั้งใจ
ไม่ใช่กันโดยบังเอิญ
เป้าหมายไม่ใช่ "ทำให้ JWT ใช้ได้" — เป้าหมายคือ ทำให้คุณเห็นด้วยตาตัวเองว่าอะไรที่ token กันได้ และอะไรที่มันกันไม่ได้
- ถอดด้วยมือก่อน: ก๊อป token ตัวในหัวข้อ 5 มา ตัดด้วย
cut -d. -f1แล้ว-f2แล้ว decode ทั้งสองส่วน
ห้ามใช้เว็บ jwt.io — พิมพ์คำสั่งเองจะได้รู้ว่ามันไม่มีอะไรมากไปกว่า base64
อย่าลืมเติม=ให้ครบ 4 ตัว ไม่งั้นเลขท้ายจะหายไปเงียบ ๆ - ตั้งโปรเจกต์:
go mod init jwtcheck·go get github.com/golang-jwt/jwt/v5
เขียนโปรแกรมที่ sign token ด้วยHS256โดยใส่sub·role·iat·exp(ตั้ง 1 ชั่วโมง) แล้วพิมพ์ token กับlen()ออกมา - parse กลับ: เขียนฝั่ง verify ด้วย
jwt.Parse+jwt.WithValidMethods([]string{"HS256"})
พิมพ์claimsออกมา แล้วลองเขียนc["exp"].(int)ดูสักครั้ง เพื่อให้เห็น panic ด้วยตาตัวเอง ก่อนจะแก้เป็น.(float64) - สร้างสามเคส error: ตั้ง
expเป็นอดีต · แก้ตัวอักษรสุดท้ายของ token ไปหนึ่งตัว · parse ด้วยWithValidMethods([]string{"RS256"})
จดข้อความ error ทั้งสามอันไว้ แล้วเทียบกับที่อยู่ในหัวข้อ 7 ว่าตรงกันไหม - ทดสอบ
errors.Is: ในทั้งสามเคสข้างบน พิมพ์ผลของerrors.Is(err, jwt.ErrSignatureInvalid)กับerrors.Is(err, jwt.ErrTokenSignatureInvalid)
ตอบให้ได้ว่ามีเคสไหนที่สองอันนี้ให้คำตอบไม่เหมือนกัน และควรใช้ตัวไหน - ลองโจมตีตัวเอง: สร้าง token
alg=noneด้วยมือ —base64urlของ{"alg":"none","typ":"JWT"}+.+base64urlของ{"role":"admin"}+.(ปิดท้ายด้วยจุดเปล่า)
ยิงเข้า parser ของคุณสามแบบ: มีWithValidMethods· ไม่มี · และให้ keyfunc คืนjwt.UnsafeAllowNoneSignatureType
แบบไหนที่ทะลุได้จริง? - กับดักตลอดชีพ: sign token ที่ไม่มี
expเลย แล้ว parse ดู — จะได้err=<nil>
จากนั้นเติมjwt.WithExpirationRequired()แล้วรันซ้ำ
ตอบว่าทำไม default ถึงเป็นแบบนี้ และคุณจะกันไม่ให้เกิดในทีมยังไง - ท้าทาย: เปลี่ยนจาก
jwt.MapClaimsไปเป็น struct ของตัวเองที่ฝังjwt.RegisteredClaimsข้างใน แล้วใส่ fieldRole stringเพิ่ม
แล้วตอบว่า ปัญหาfloat64ในข้อ 3 หายไปไหม และทำไม
ผลรันจริงของทั้งสามแบบ:
evil token = eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.
A) มี WithValidMethods([HS256]) -> err = token signature is invalid: signing method none is invalid
B) ไม่มี WithValidMethods -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed
C) คืน UnsafeAllowNoneSignatureType -> err = <nil>
มีแบบ C แบบเดียวที่ทะลุ — และมันทะลุเพราะคนเขียนโค้ดอนุญาตเอง
ไม่ใช่เพราะไลบรารีหละหลวม
ถ้าคุณเคยได้ยินว่า "ไม่ใส่ WithValidMethods แล้ว alg=none ทะลุ"
— กับ golang-jwt/jwt/v5 v5.3.1 ไม่จริง ดูเคส B
แต่อย่าเพิ่งสรุปว่าไม่ต้องใส่ ลองข้อ 6 ต่ออีกนิด: เขียนโปรแกรมที่ keyfunc คืน
*rsa.PublicKey แล้วให้ "ผู้โจมตี" เอา public key นั้นไปเซ็นแบบ HS256
คุณจะได้ error ว่า key is of invalid type: HMAC verify expects []byte —
ซึ่งแปลว่ามันรอดเพราะชนิดของ key บังเอิญไม่ตรง ไม่ใช่เพราะตรวจ alg
นั่นคือเหตุผลจริงที่ต้องใส่ WithValidMethods ทุกครั้ง
💬 ข้อ 1 กับข้อ 6 คือสองข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ —
ข้อ 1 ทำให้คุณเลิกกลัว JWT (มันคือ base64 กับ JSON เท่านั้นเอง)
ส่วนข้อ 6 ทำให้คุณเลิกเชื่อว่ามันปลอดภัยเองโดยอัตโนมัติ
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย —
ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 กับ
golang-jwt/jwt/v5 v5.3.1
จุดที่มีโอกาสไม่ตรงมากที่สุดคือ ตัว token ที่คุณ sign เอง —
มันจะไม่เหมือนของผมแน่นอน เพราะ iat กับ exp เป็นเวลาตอนที่รัน
ดูที่ข้อความ error กับโครงสร้างแทน อย่าดูที่ตัวอักษรของ token
อ้างอิง
- pkg.go.dev —
http.Request.BasicAuth— สังเกตว่า doc ไม่ได้อธิบายความหมายของokไว้เลยสักคำ — มันเขียนแค่ว่าคืน username/password ที่ request "provides" มาใน header เท่านั้น · นั่นแหละคือกับดักในหัวข้อ 2:okบอกว่าแกะ header ได้ ไม่ได้บอกว่ารหัสผ่านถูก - pkg.go.dev —
subtle.ConstantTimeCompare— ทำไมการเทียบรหัสผ่านด้วย==ธรรมดาถึงรั่วข้อมูลออกทางเวลาที่ใช้ตอบ - RFC 7519 — JSON Web Token — อ่านหัวข้อ 4.1 ซึ่งเป็นรายชื่อ registered claims ทั้งเจ็ด (
isssubaudexpnbfiatjti) พร้อมนิยามทีละตัว - RFC 7515 §4.1.1 — header
alg· RFC 8725 — JWT Best Current Practices — RFC 8725 คือฉบับที่ควรอ่านที่สุดในลิสต์นี้ เขียนตรง ๆ ว่าต้องล็อกรายชื่อ alg ที่ยอมรับ และห้ามเชื่อalgที่มาจากตัว token - pkg.go.dev —
golang-jwt/jwt/v5— รายชื่อParserOptionทั้งหมด (WithValidMethods·WithExpirationRequired·WithLeeway·WithAudience·WithIssuer) และค่า default ของแต่ละตัว - golang-jwt —
errors.go— ในไฟล์นี้มีแค่ErrTokenSignatureInvalid(บรรทัด 15 — ไฟล์นี้ประกาศ error ไว้ 16 ตัวรวด บรรทัด 10–25 เช่นErrTokenInvalidIssuerที่เจอในหัวข้อ 7.4) · ส่วนErrSignatureInvalidไม่ได้อยู่ที่นี่ — มันอยู่ในhmac.goบรรทัด 21 เพราะเป็น error เฉพาะของSigningMethodHMAC· นี่คือเหตุผลจริงที่หัวข้อ 7.3 ได้false: เคส alg ไม่ตรงถูกปฏิเสธก่อนถึงตัวตรวจ HMAC จึงไม่มีทางได้ error ตัวนี้เลย - MDN — 401 Unauthorized · 403 Forbidden — หน้า 401 เขียนไว้เองว่าชื่อนี้หมายถึง "unauthenticated" ตามที่บทนี้บอกในหัวข้อ 1
- บทเรียน 41 — Swagger & swaggo — ที่มาของ annotation ทั้งชุดในหัวข้อ 9 · Swagger 2.0 Specification — ดูว่า
securityDefinitionsรองรับtypeอะไรบ้าง (และทำไมไม่มีhttp)