🧰 ของที่ใช้ในบทนี้ (เวอร์ชันที่รันจริง)
go version go1.26.5 darwin/arm64
github.com/redis/go-redis/v9 v9.22.0
redis:8-alpine # redis_version:8.10.1 os:Linux 6.8.0-117-generic aarch64
quay.io/keycloak/keycloak:26.7 # Keycloak 26.7.2 on JVM (powered by Quarkus 3.33.3.1)
Docker client 29.6.2 / server 29.5.2 (colima ไม่ใช่ Docker Desktop)
Google Chrome 151.0.7922.170 # ใช้ตอนวัดเรื่อง Referer ในหัวข้อ 5
คำเตือนเรื่องพอร์ต: ในหน้านี้ผมรัน Keycloak ไว้ที่ localhost:18081
และ Redis ที่ 127.0.0.1:16379 เพราะเครื่องผมมีของอื่นจองพอร์ตปกติไว้อยู่
ถ้าคุณรันตามสไลด์จะเป็น 8081 กับ 6379 — ตัวเลขพอร์ตในผลรันจึงต่างจากของคุณได้
แต่พฤติกรรมเหมือนกันหมด
1 · ตัวละครห้าตัวใน diagram นี้
ก่อนดูเส้น ต้องรู้ก่อนว่าใครเป็นใคร เพราะ diagram นี้มีจุดที่คนอ่านสลับกันบ่อยมาก คือ Browser กับ Frontend JS ซึ่งอยู่ในหน้าจอเดียวกันแต่ไม่ใช่ตัวเดียวกัน:
| ตัวละคร | คืออะไร | ในบทนี้ทำหน้าที่อะไร |
|---|---|---|
| Browser | ตัวโปรแกรมเบราว์เซอร์เอง — คนที่เดินตาม redirect เปลี่ยน URL บนแถบที่อยู่ และเก็บ history | เป็นคนวิ่งไปมาระหว่าง wongnok API กับ Keycloak ในขั้น 1–2 โดย JavaScript ไม่ได้สั่ง |
| Frontend JS | โค้ด JavaScript ในหน้าเว็บ (React/Vue) ที่เรียก API ด้วย axios / fetch |
โผล่มาตอนขั้น 3–4 เท่านั้น — ขั้น 1–2 มันยังไม่ได้ทำงานเลยด้วยซ้ำ |
| wongnok API | backend Go ของเรา (Gin) ที่ถือ client_secret ไว้ |
เป็นตัวกลางระหว่างผู้ใช้กับ Keycloak · เป็นที่เดียวที่รู้ client_secret |
| Redis | ที่เก็บ key-value ในหน่วยความจำ ที่ตั้งวันหมดอายุ (TTL) ให้แต่ละ key ได้ (บทเรียน 32) | เก็บของสองอย่างที่อายุสั้นมาก: oauth_state:xxx กับ auth_ticket:yyy |
| Keycloak | Identity Provider — เจ้าของหน้า login และเจ้าของ token | ตรวจรหัสผ่าน · ออก access_token / refresh_token · ปิด session ตอน logout |
เส้นในขั้น 1 กับ 2 ออกจาก Browser ไม่ใช่ออกจาก Frontend JS
แปลว่าตอนนั้น axios ไม่ได้ยิงอะไรเลย — เป็นเบราว์เซอร์ที่เปลี่ยนหน้าไปเรื่อย ๆ ตาม
Location: ที่แต่ละฝั่งตอบกลับมา
พอมาถึงขั้น 3 เส้นถึงย้ายมาออกจาก Frontend JS — และนั่นคือจุดที่โค้ด JavaScript
ของเราเพิ่งได้ทำงานครั้งแรกของทั้ง flow
2 · ภาพรวมทั้งเส้น
นี่คือ diagram เดียวกับสไลด์หน้า 44–47 ทั้งสี่หน้ารวมกัน วาดใหม่เป็นตัวอักษรให้อ่านทีละบรรทัดได้
ลูกศร ─▶ คือ request ที่วิ่งออกไป · ◀─ คือ response ที่ตอบกลับมา
Browser Frontend JS wongnok API Redis Keycloak
│ │ │ │ │
│ │ │ │ │
═══ STEP 1 /login window.location (NOT ajax) ════════════════════════════════════════
│ │ │ │ │
│ GET /api/v1/auth/login │ │ │
│───────────────────────────────────▶ │ │
│ │ │ │ │
│ │ │ SET oauth_state:xxx EX 300 │
│ │ │────────────────▶ │
│ │ │ │ │
│ 302 Location: KEYCLOAK/auth?...&state=xxx │ │
◀───────────────────────────────────│ │ │
│ │ │ │ │
│ GET /auth?client_id&redirect_uri&state=xxx │ │
│────────────────────────────────────────────────────────────────────▶
│ │ │ │ │
│ 200 Keycloak login page │ │ │
◀────────────────────────────────────────────────────────────────────│
│ │ │ │ │
│ POST username + password │ │ │
│────────────────────────────────────────────────────────────────────▶
│ │ │ │ │
│ │ │ │ │
═══ STEP 2 /callback browser is still following redirects ═══════════════════════════════
│ │ │ │ │
│ 302 Location: /api/v1/auth/callback?code&state │ │
◀────────────────────────────────────────────────────────────────────│
│ │ │ │ │
│ GET /api/v1/auth/callback?code=...&state=xxx │ │
│───────────────────────────────────▶ │ │
│ │ │ │ │
│ │ │ GETDEL oauth_state:xxx (check + delete)
│ │ │────────────────▶ │
│ │ │ │ │
│ │ │ POST /token code + client_secret
│ │ │────────────────────────────────▶
│ │ │ │ │
│ │ │ 200 {access_token, refresh_token, ...}
│ │ ◀────────────────────────────────│
│ │ │ │ │
│ │ │ SET auth_ticket:yyy EX 30 = credential
│ │ │────────────────▶ │
│ │ │ │ │
│ 302 Location: FRONTEND_URL/auth/success?ticket=yyy │
◀───────────────────────────────────│ │ │
│ │ │ │ │
│ │ │ │ │
═══ STEP 3 /exchange axios (ajax) ══════════════════════════════════════════════════════
│ │ │ │ │
│ page loads -> JS reads ticket from URL │ │
│────────────────▶ │ │ │
│ │ │ │ │
│ │ POST /api/v1/auth/exchange body {ticket: yyy} │
│ │──────────────────▶ │ │
│ │ │ │ │
│ │ │ GETDEL auth_ticket:yyy │
│ │ │────────────────▶ │
│ │ │ │ │
│ │ 200 {accessToken, refreshToken, bearerType, expiresAt}
│ ◀──────────────────│ │ │
│ │ │ │ │
│ │ │ │ │
═══ STEP 4 /logout axios (ajax) ══════════════════════════════════════════════════════
│ │ │ │ │
│ │ POST /api/v1/auth/logout body {refreshToken} │
│ │──────────────────▶ │ │
│ │ │ │ │
│ │ │ POST /logout refresh_token + client_secret
│ │ │────────────────────────────────▶
│ │ │ │ │
│ │ │ 204 No Content │
│ │ ◀────────────────────────────────│
│ │ │ │ │
│ │ 204 No Content │ │ │
│ ◀──────────────────│ │ │
│ │ │ │ │
Browser Frontend JS wongnok API Redis Keycloak
🧭 วิธีอ่าน diagram นี้ให้เร็ว — ดูที่เส้นแนวตั้งซ้ายสุด (Browser) ก่อน
ตราบใดที่ยังมีลูกศรออกจากเส้นนั้น แปลว่ายังอยู่ในโลกของ redirect —
ผู้ใช้ยังเห็นหน้าจอกระพริบ URL เปลี่ยนไปเรื่อย ๆ
พอลูกศรย้ายไปออกจากเส้น Frontend JS แปลว่าเข้าโลกของ AJAX แล้ว —
URL บนแถบที่อยู่หยุดนิ่ง ทุกอย่างเกิดเบื้องหลัง
ไปติดต่อราชการ มีสองแบบที่ต่างกันคนละเรื่อง:
· แบบเดินไปเอง — เจ้าหน้าที่บอกว่า “เรื่องนี้ต้องไปเซ็นที่ตึก B”
คุณต้องลุกเดินไปยืนที่ตึก B จริง ๆ เห็นป้ายชื่อตึกด้วยตาตัวเอง แล้วเซ็นตรงนั้น
· แบบนั่งรอ — คุณยื่นเรื่องแล้วนั่งอยู่ที่เดิม เจ้าหน้าที่เดินไปคุยกับตึก B ให้
คุณไม่ได้ขยับไปไหนเลย
ขั้น 1–2 (/login · /callback) คือแบบเดินไปเอง —
เบราว์เซอร์ย้ายทั้งหน้าไปที่ Keycloak จริง URL บนแถบที่อยู่เปลี่ยน
เพราะผู้ใช้กำลังจะพิมพ์รหัสผ่าน เขาต้องเห็นชื่อโดเมนของเจ้าของหน้า login ด้วยตาตัวเอง
ขั้น 3–4 (/exchange · /logout) คือแบบนั่งรอ —
axios วิ่งไปคุยให้ ผู้ใช้ยังอยู่หน้าเดิม
ที่ไม่เหมือนกัน: ที่ราชการคุณเลือกได้ว่าจะเดินไปหรือไม่เดิน
แต่บนเว็บไม่มีใครถามคุณ — พอเซิร์ฟเวอร์ตอบ 302 พร้อม Location:
เบราว์เซอร์ก็เดินตามให้ทันทีโดยที่ผู้ใช้ไม่ได้กดอะไรเลย
และในขั้น 1–2 โค้ด JavaScript ของเรายังไม่ได้ทำงานแม้แต่บรรทัดเดียว —
มันเพิ่งได้ทำงานครั้งแรกตอนขั้น 3
- redirect
- การที่เซิร์ฟเวอร์ตอบว่า “ไม่ใช่ที่นี่ ไปที่นี่แทน” แล้วเบราว์เซอร์ย้ายหน้าตามให้เอง · พูดกับ senior ว่า “endpoint นี้ตอบ 302 redirect ใช่ไหมครับ ผมเลยเรียกด้วย axios ไม่ได้”
- Location header
- บรรทัดใน response ที่บอกว่าให้ไปต่อที่ URL ไหน · เป็นตัวที่ทำให้ redirect เกิดขึ้นจริง · ถาม AI ว่า “ช่วยดู response header ให้หน่อย ทำไม Location ชี้ผิด path”
- AJAX
- การที่ JavaScript ยิง request เองเบื้องหลังโดยหน้าจอไม่เปลี่ยน (
axios/fetchทำงานแบบนี้) · ประโยคที่ใช้ได้จริงตอนอ่าน diagram: “ขั้นนี้เป็น AJAX หรือเป็น full page redirect ครับ” - user agent
- ศัพท์ทางการที่มาตรฐานใช้เรียก “ตัวเบราว์เซอร์” · เจอคำนี้ในเอกสารเมื่อไหร่
ให้อ่านว่า เบราว์เซอร์ของผู้ใช้ — เดี๋ยวเจออีกทีตอนท้ายบท ตอนที่ RFC 6749 พูดถึง
state
3 · ขั้นที่ 1 — /login (สไลด์หน้า 44)
Browser Frontend JS wongnok API Redis Keycloak
│ │ │ │ │
│ GET /api/v1/auth/login <- window.location, NOT ajax │
│───────────────────────────────────▶ │ │
│ │ │ │ │
│ │ │ SET oauth_state:xxx EX 300 │
│ │ │────────────────▶ │
│ │ │ │ │
│ 302 Location: KEYCLOAK/auth?...&state=xxx │ │
◀───────────────────────────────────│ │ │
│ │ │ │ │
│ GET /auth?client_id&redirect_uri&state=xxx │ │
│────────────────────────────────────────────────────────────────────▶
│ │ │ │ │
│ 200 Keycloak login page │ │ │
◀────────────────────────────────────────────────────────────────────│
│ │ │ │ │
│ POST username + password │ │ │
│────────────────────────────────────────────────────────────────────▶
│ │ │ │ │
Browser Frontend JS wongnok API Redis Keycloak
ต้องใช้ window.location เท่านั้น ห้ามใช้ AJAX
จุดนี้สไลด์เขียนไว้ตัวเล็ก ๆ ว่า "window.location เท่านั้น" แต่มันเป็นเรื่องคอขาดบาดตาย
ก่อนอื่นนิยามสองคำ:
| คำ | หมายความว่า |
|---|---|
window.location = "..." |
สั่งเบราว์เซอร์ให้ย้ายทั้งหน้าไปที่ URL นั้น — หน้าปัจจุบันถูกทิ้ง แถบที่อยู่เปลี่ยน และ redirect ต่อ ๆ ไปเบราว์เซอร์เดินตามให้เอง |
AJAX (axios.get(...)) |
ยิง request เบื้องหลังโดยไม่เปลี่ยนหน้า — JavaScript ได้ response กลับมาเป็นข้อมูลก้อนหนึ่ง หน้าจอไม่ขยับ |
ทีนี้ดูว่า /api/v1/auth/login ตอบอะไรกลับมา: มันตอบ 302 พร้อม header
Location: ที่ชี้ไปหน้า login ของ Keycloak
ถ้าใช้ AJAX จะพังสองชั้นซ้อน:
- ชั้นที่ 1 — หน้าจอไม่ไปไหน ต่อให้
axiosเดินตาม redirect จนได้ HTML หน้า login ของ Keycloak มา มันก็ได้มาเป็นสตริงอยู่ในตัวแปร JavaScript ผู้ใช้ไม่เห็นอะไรเลย ยังนั่งดูหน้าเดิม - ชั้นที่ 2 — ผู้ใช้ต้องได้อยู่บนโดเมนของ Keycloak จริง ๆ เพราะเขากำลังจะพิมพ์รหัสผ่าน
เขาต้องมองเห็นชื่อโดเมนบนแถบที่อยู่ว่าเป็นของผู้ให้บริการ login ไม่ใช่ของเว็บเรา
ถ้าเว็บเราเอา HTML ของหน้า login มาแปะเองได้ นั่นคือหน้าตาของการฟิชชิงพอดี — และนั่นคือเหตุผลที่ Keycloak ไม่ยอมให้ทำอยู่แล้ว
Keycloak ตอบหน้า login เป็น HTML จริง ๆ — ผม curl ไปที่ authorization_endpoint
แล้วนับขนาดที่ได้:
$ curl -s -c cookies.txt "$KC/realms/wongnok/protocol/openid-connect/auth?client_id=wongnok-api&response_type=code&scope=openid&redirect_uri=$RU&state=$STATE" | wc -c
6882 # หน้า HTML ล้วน ๆ ไม่ใช่ JSON — เอาไปให้ axios ก็ทำอะไรต่อไม่ได้
state เกิดตรงนี้ แล้วถูกฝากไว้ที่ Redis
ก่อนจะ 302 ออกไป wongnok API สุ่มค่าขึ้นมาหนึ่งค่า เรียกว่า state
แล้วทำสองอย่างพร้อมกัน: เก็บลง Redis และ แนบไปกับ URL ของ Keycloak
ผมรัน Redis จริงให้ดูว่าคำสั่งนั้นเกิดอะไรขึ้น (EX 300 คือ 300 วินาที = 5 นาที ตามสไลด์):
SET oauth_state:9f2b7c1e "1" EX 300 -> OK
TTL oauth_state:9f2b7c1e -> 300
EXISTS oauth_state:9f2b7c1e -> 1
เหตุผลที่ต้องเก็บฝั่งเรา: เดี๋ยวตอนขั้น 2 Keycloak จะส่ง state ค่านี้กลับมาให้เรา
และเราต้องพิสูจน์ให้ได้ว่ามันเป็นค่าที่เราเป็นคนออกให้เอง ไม่ใช่ค่าที่ใครก็ไม่รู้แต่งมา
— รายละเอียดว่ามันกันอะไรได้ อยู่ในหัวข้อ 9
4 · ขั้นที่ 2 — /callback (สไลด์หน้า 45)
Browser Frontend JS wongnok API Redis Keycloak
│ │ │ │ │
│ 302 Location: /api/v1/auth/callback?code&state │ │
◀────────────────────────────────────────────────────────────────────│
│ │ │ │ │
│ GET /api/v1/auth/callback?code=...&state=xxx │ │
│───────────────────────────────────▶ │ │
│ │ │ │ │
│ │ │ GETDEL oauth_state:xxx (check + delete)
│ │ │────────────────▶ │
│ │ │ │ │
│ │ │ POST /token code + client_secret
│ │ │────────────────────────────────▶
│ │ │ │ │
│ │ │ 200 {access_token, refresh_token, ...}
│ │ ◀────────────────────────────────│
│ │ │ │ │
│ │ │ SET auth_ticket:yyy EX 30 = credential
│ │ │────────────────▶ │
│ │ │ │ │
│ 302 Location: FRONTEND_URL/auth/success?ticket=yyy │
◀───────────────────────────────────│ │ │
│ │ │ │ │
Browser Frontend JS wongnok API Redis Keycloak
ขั้นนี้เริ่มตอนผู้ใช้กด "เข้าสู่ระบบ" ที่หน้า Keycloak แล้ว Keycloak ตอบ 302
เด้งเบราว์เซอร์กลับมาที่ redirect_uri ที่เราลงทะเบียนไว้
ผมรันของจริง: สร้าง realm wongnok + client wongnok-api แบบ confidential
(มี client_secret) + user chef1 แล้วเดินฟอร์ม login ด้วย curl
นี่คือ header ที่ Keycloak ตอบกลับมาจริง ๆ:
HTTP/1.1 302 Found
Location: http://localhost:8080/api/v1/auth/callback?state=9f2b7c1e-state-from-redis&session_state=afOr4xGE8XiarnPyxNrzqiyn&iss=http%3A%2F%2Flocalhost%3A18081%2Frealms%2Fwongnok&code=10f0f184-2410-7b36-ab64-b85d03545adf.afOr4xGE8XiarnPyxNrzqiyn.5a65a5e5-7360-4b21-ae33-8b765128d89f
แกะ query string ออกมาทีละตัว:
path = /api/v1/auth/callback
state = 9f2b7c1e-state-from-redis (ยาว 25)
session_state = afOr4xGE8XiarnPyxNrzqiyn (ยาว 24)
iss = http://localhost:18081/realms/wongnok (ยาว 37)
code = 10f0f184-2410-7b36-ab64-b85d03545adf.afOr4xGE8XiarnPyxNrzqiy... (ยาว 98)
สังเกต state ที่กลับมา: 9f2b7c1e-state-from-redis —
เป็นค่าเดียวกับที่เราส่งไปเป๊ะ ๆ
Keycloak ไม่ได้ทำอะไรกับมันเลย มันแค่คืนกลับมาให้เหมือนเดิม
ซึ่งเป็นทั้งหมดที่ระบบ state ต้องการ
เช็ค state แล้วลบทิ้งในคำสั่งเดียว
สไลด์เขียนว่า "เช็ค + ลบ oauth_state:xxx" — คำสั่ง Redis ที่ทำสองอย่างนี้พร้อมกันชื่อ
GETDEL (อ่านค่าคืนมา แล้วลบ key นั้นทิ้ง เป็นคำสั่งเดียวกัน)
ผมรันจริง รวมถึงเคสที่คนพยายามใช้ state เดิมซ้ำ และเคสที่แต่ง state ขึ้นมาเอง:
GETDEL oauth_state:9f2b7c1e -> 1 # เลข 1 ตรงนี้คือ "ค่าที่เก็บไว้" เพราะเรา SET เป็นสตริง "1" ไม่ใช่รหัสว่าสำเร็จ
-- ยิงซ้ำด้วย state เดิม (replay) --
GETDEL oauth_state:9f2b7c1e -> (ว่าง)
EXISTS oauth_state:9f2b7c1e -> 0 # ส่วนเลข 0/1 ของ EXISTS คือ "ไม่มี/มี" คนละความหมายกับบรรทัดบน
-- state ที่ผู้โจมตีแต่งขึ้นเอง ไม่เคยมีใน Redis --
GETDEL oauth_state:attacker-made-it -> (ว่าง)
สองเคสล่างให้ผลเหมือนกันเป๊ะ — ทั้งการยิงซ้ำและการแต่งค่าเอง ได้ค่าว่างเหมือนกัน
แปลว่าโค้ดฝั่งเราเขียนเงื่อนไขเดียวจบ: ถ้า GETDEL ไม่คืนค่า ให้ปฏิเสธ
แลก code เป็น token ที่ฝั่งเซิร์ฟเวอร์
ตอนนี้ wongnok API ถือ code อยู่ในมือ มันจะ POST ไปที่ token_endpoint
ของ Keycloak พร้อม client_secret
ขั้นนี้เกิดที่เซิร์ฟเวอร์ ไม่ผ่านเบราว์เซอร์เลย — client_secret จึงไม่เคยออกจากเครื่องเรา
ผลรันจริง:
$ curl -s -X POST $KC/realms/wongnok/protocol/openid-connect/token \
-d client_id=wongnok-api -d client_secret=super-secret-123 \
-d grant_type=authorization_code -d redirect_uri=$RU \
--data-urlencode code@code.txt
HTTP 200
access_token = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkI... (ยาว 1378)
expires_in = 300
refresh_expires_in = 1800
refresh_token = eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkI... (ยาว 697)
token_type = Bearer
id_token = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkI... (ยาว 1069)
not-before-policy = 0
session_state = afOr4xGE8XiarnPyxNrzqiyn
scope = openid profile email
📌 สองตัวเลขนี้จำไว้ เพราะเป็นค่า default จริงของ Keycloak 26.7:
expires_in = 300 (access token อยู่ได้ 5 นาที) ·
refresh_expires_in = 1800 (refresh token 30 นาที)
หลายคนจำว่าเป็น 1 ชั่วโมง — ไม่ใช่
ส่วนตัวเลข "ยาว xxx" อย่าเอาไปเทียบว่าตรงกันเป๊ะ — ความยาวของ access_token
ขึ้นกับว่า realm ของคุณใส่ role กับ client scope อะไรให้ user บ้าง
ผมลองรันซ้ำบน realm เปล่า ๆ ที่มีแค่ client กับ user หนึ่งคนได้ access_token ยาว 1083 กับ
refresh_token ยาว 673 ขณะที่ id_token ยังยาว 1069 เท่าเดิม
สิ่งที่ต้องตรงคือ ชื่อ field กับตัวเลขวินาที ไม่ใช่จำนวนตัวอักษร
แล้ว code ใช้ซ้ำได้ไหม? ผมเอาตัวเดิมไปแลกอีกรอบทันที:
{"error":"invalid_grant","error_description":"Code not valid"}
HTTP 400
code เป็นของใช้ครั้งเดียว เหมือนกัน — และนี่เป็นแบบที่ Keycloak บังคับให้เอง
เราไม่ต้องเขียนอะไรเพิ่ม
เก็บ credential ไว้ใต้ ticket อายุ 30 วินาที
ตอนนี้ wongnok API ถือ token ครบแล้ว แต่ส่งกลับหาผู้ใช้ตรง ๆ ไม่ได้
เพราะช่องทางที่เหลืออยู่ตอนนี้คือ 302 ซึ่งส่งได้แค่ URL
วิธีแก้คือเก็บ credential ไว้ใน Redis ใต้ key ที่สุ่มขึ้นมาใหม่ชื่อ auth_ticket:yyy
แล้วส่งกลับไปแค่ชื่อ key ผลรันจริงผ่าน go-redis:
PING -> PONG
Set(auth_ticket, 30s) err = <nil>
TTL -> 30s
แล้วเบราว์เซอร์ก็โดนเด้งไปหน้า frontend พร้อม ticket ติดไปใน URL:
302 Location: FRONTEND_URL/auth/success?ticket=yyy
5 · ขั้นที่ 3 — /exchange และหัวใจของทั้งบท (สไลด์หน้า 46)
Browser Frontend JS wongnok API Redis Keycloak
│ │ │ │ │
│ page loads -> JS reads ticket from URL │ │
│────────────────▶ │ │ │
│ │ │ │ │
│ │ POST /api/v1/auth/exchange body {ticket: yyy} <- axios
│ │──────────────────▶ │ │
│ │ │ │ │
│ │ │ GETDEL auth_ticket:yyy │
│ │ │────────────────▶ │
│ │ │ │ │
│ │ 200 {accessToken, refreshToken, bearerType, expiresAt}
│ ◀──────────────────│ │ │
│ │ │ │ │
Browser Frontend JS wongnok API Redis Keycloak
มาถึงคำถามที่บทนี้เกิดมาเพื่อตอบ:
access_token ไปเลย จะได้ไม่ต้องมีขั้น /exchange
เขียนเป็น 302 Location: FRONTEND_URL/auth/success?access_token=eyJhbGci... ก็จบแล้วไม่ใช่เหรอ
คำตอบคือ อะไรก็ตามที่อยู่ใน URL จะถูกบันทึกไว้หลายที่โดยที่เราไม่ได้สั่ง —
และนั่นคือความต่างระหว่าง query string กับ body ที่
บทเรียน 35 ปูไว้
หัวข้อนี้ผมรันวัดให้ดูทีละที่
ที่ที่ 1 — access log ของเซิร์ฟเวอร์
access log คือบันทึกที่เว็บเซิร์ฟเวอร์เขียนไว้ทุกครั้งที่มี request เข้ามา
บรรทัดหนึ่งต่อหนึ่ง request โดยปกติจะบันทึก method กับ URL
ทั้ง gin.Logger(), nginx, และ load balancer ของ cloud ทุกเจ้าทำแบบนี้เป็น default
ผมเขียน middleware Go ที่พิมพ์แบบเดียวกัน แล้วยิงสองแบบเทียบ — แบบแรกส่ง ticket ไปใน query string
แบบที่สองส่งไปใน body ของ POST:
=== แบบที่ 1 — ส่ง ticket ไปกับ query string (GET) ===
[access log] GET /api/v1/auth/success-bad?ticket=T-9f2b7c1e
=== แบบที่ 2 — ส่ง ticket ไปกับ body ของ POST ===
[access log] POST /api/v1/auth/exchange
(handler เห็น body = {"ticket":"T-9f2b7c1e"})
(client ได้ status=200 body={"accessToken":"eyJhbGciOiJSUzI1NiJ9...","refreshToken":"eyJhbGciOiJIUzUxMiJ9...","bearerType":"Bearer","expiresAt":1787368745})
บรรทัด access log ของแบบที่สองไม่มี ticket อยู่เลย —
ทั้งที่ handler เห็น body เต็ม ๆ และตอบ token กลับไปครบ
เหตุผลง่ายมาก: body ไม่ได้เป็นส่วนหนึ่งของ request line ที่ log เขียน —
ต้องตั้งใจเขียนโค้ดให้ log body ถึงจะโผล่ (ซึ่งไม่มีใครทำ เพราะมันหนักและมีของลับ)
ที่ที่ 2 — history ของเบราว์เซอร์
URL ที่ถูกโหลดจะไปอยู่ในประวัติการเข้าเว็บของเครื่องนั้น พร้อม query string ครบ ๆ
ผมเปิดหน้าจริงในเบราว์เซอร์แล้วถามมันว่าตอนนี้ URL คืออะไร:
> location.href
"http://127.0.0.1:19080/auth/success?ticket=SECRET-TICKET-abc123"
ค่านี้คือสิ่งเดียวกับที่เข้าไปอยู่ใน history — และมันอยู่ในเครื่องผู้ใช้ต่อไป แม้ปิดแท็บไปแล้ว ค้นเจอได้จากช่องค้นหาของเบราว์เซอร์
ที่ที่ 3 — header Referer
Referer (สะกดผิดมาตั้งแต่ปี 1996 และไม่มีใครแก้) คือ header
ที่เบราว์เซอร์แนบไปบอกปลายทางว่า "ฉันมาจากหน้าไหน"
มันถูกแนบไปกับทุกไฟล์ย่อยที่หน้านั้นโหลด — รูป, ไฟล์ CSS, สคริปต์ analytics
ผมทำหน้าทดสอบที่โหลดรูปสองรูป: รูปหนึ่งจากเซิร์ฟเวอร์เดียวกัน อีกรูปจากเซิร์ฟเวอร์ภายนอก (คนละพอร์ต = คนละ origin)
แล้วให้ทั้งสองฝั่งพิมพ์ Referer ที่ได้รับ นี่คือผลจริงจาก Google Chrome 151:
[frontend :19080] GET /auth/success?ticket=SECRET-TICKET-abc123 Referer=""
[frontend :19080] GET /logo.gif Referer="http://127.0.0.1:19080/auth/success?ticket=SECRET-TICKET-abc123"
[third-party :19081] GET /tracker.gif Referer="http://127.0.0.1:19080/"
[frontend :19080] GET /favicon.ico Referer="http://127.0.0.1:19080/auth/success?ticket=SECRET-TICKET-abc123"
ผลนี้ละเอียดกว่าที่สไลด์บอก และต้องอ่านให้ครบทั้งสามข้อ:
- ไฟล์จาก origin เดียวกัน (
/logo.gif) ได้ URL เต็ม พร้อม?ticket=— แปลว่าสคริปต์ทุกตัวที่โหลดจากเว็บเราเอง เห็น ticket หมด - ไฟล์จากภายนอก (
/tracker.gif) ได้แค่http://127.0.0.1:19080/— เบราว์เซอร์ยุคนี้ตัด path กับ query ทิ้งก่อนส่งข้ามเว็บให้เอง - แม้แต่
/favicon.icoที่หน้าเว็บไม่ได้สั่งโหลดเองก็ได้ URL เต็มพร้อม?ticket=— เบราว์เซอร์เป็นคนขอไอคอนนี้เองเพื่อเอาไปแปะบนแท็บ แต่มันก็ยังแนบRefererของหน้าที่กำลังเปิดอยู่ไปด้วย
แปลว่า คุณไม่ต้องเขียนโค้ดอะไรผิดเลย ticket ก็เข้า log ไปแล้วหนึ่งรอบ
เหตุผลของข้อที่สองคือค่าปริยายของ Referrer-Policy ในเบราว์เซอร์สมัยใหม่คือ
strict-origin-when-cross-origin ซึ่งแปลว่า "ข้ามเว็บเมื่อไหร่ ส่งแค่ชื่อโดเมน"
ดังนั้นถ้าใครบอกว่า "token ใน URL จะรั่วไปให้เว็บภายนอกทาง Referer" — ทุกวันนี้ไม่จริงแล้ว
แต่มันยังรั่วเข้า access log ของทุกตัวที่ request วิ่งผ่าน (proxy, load balancer, CDN)
และยังรั่วให้สคริปต์ทุกตัวใน origin เดียวกัน ซึ่งพอแล้วที่จะไม่ทำ
Referrer-Policy คุมได้แค่ Referer ซึ่งเป็น header ที่เบราว์เซอร์เป็นคนใส่ให้
แต่สคริปต์ analytics ไม่ได้พึ่ง header ตัวนั้นเลย — มันอ่าน document.location เองด้วย JavaScript
แล้วส่ง URL เต็มไปกับ request ของตัวเอง (ใน GA4 คือ field page_location)
ผมจำลองให้ดูด้วยสคริปต์ที่ทำแบบเดียวกับ gtag เป๊ะ ๆ คือโหลดจากโดเมนภายนอก (:19081) แล้วยิงกลับไปที่โดเมนตัวเอง:
[analytics :19081] GET /gtag.js Referer="http://127.0.0.1:19080/"
[analytics :19081] GET /collect?page_location=http%3A%2F%2F127.0.0.1%3A19080%2Fauth%2Fsuccess%3Fticket%3DSECRET-TICKET-abc123 Referer="http://127.0.0.1:19080/"
สังเกตสองบรรทัดนี้ให้ดี: Referer ของทั้งคู่ถูกตัดเหลือแค่โดเมนตามที่หัวข้อนี้บอกไว้จริง
แต่ ticket เดินทางไปถึงเซิร์ฟเวอร์ภายนอกจนได้ — ผ่าน query string ที่สคริปต์เป็นคนสร้างเอง
นี่คือเหตุผลที่ทางแก้ต้องเป็น "ทำให้ของใน URL ไร้ค่า" ไม่ใช่ "ตั้ง Referrer-Policy ให้แน่นขึ้น"
เพราะฉะนั้น ticket จึงต้องเป็นของที่หลุดแล้วไม่มีค่า
ทางแก้ที่ Wongnok เลือกไม่ใช่ "ทำให้ URL ไม่รั่ว" (ทำไม่ได้) แต่คือ ยอมให้ของใน URL รั่ว แล้วทำให้ของชิ้นนั้นไร้ค่าตั้งแต่แรก
| เทียบสองสิ่ง | ticket (อยู่ใน URL) | accessToken (อยู่ใน body ของ POST) |
|---|---|---|
| เดินทางยังไง | query string ของ 302 → แถบที่อยู่ | response body ของ POST /exchange |
| เข้า access log ไหม | เข้า | ไม่เข้า |
| เข้า browser history ไหม | เข้า | ไม่เข้า |
โผล่ใน Referer ไหม | โผล่ (สำหรับ origin เดียวกัน) | ไม่โผล่ |
| อายุ | 30 วินาที | 300 วินาที (5 นาที) ตามค่า Keycloak |
| ใช้ได้กี่ครั้ง | ครั้งเดียว — ถูก GETDEL ทันทีที่แลก | ใช้ซ้ำได้จนหมดอายุ |
| ถ้าหลุดแล้วเกิดอะไร | ผู้เก็บได้ต้องชิงใช้ให้ทันภายใน 30 วินาที และต้องชิงก่อนเจ้าของด้วย — ซึ่งเจ้าของแลกไปแล้วตั้งแต่วินาทีแรก | เข้าถึง API ในนามผู้ใช้ได้จนกว่าจะหมดอายุ |
เอาผ้าไปซัก ร้านไม่ได้ยัดผ้ากลับใส่มือคุณตอนนั้น เขาให้ใบรับของมาแผ่นเดียว
ใบนั้นไม่ใช่ผ้า — ทำหล่นกลางถนน คนเก็บได้ก็ยังไม่มีผ้าอยู่ในมือ
ต้องเดินไปที่ร้านแล้วยื่นให้พนักงานถึงจะได้ผ้า
ticket ที่ติดมาใน URL คือใบรับของ ·
accessToken ที่ได้จาก POST /exchange คือผ้า
เว็บยอมให้ใบรับของไปนอนอยู่ทั้งสามที่ที่ผมวัดมาข้างบน (access log · history ·
Referer) เพราะของที่รั่วออกไปนั้นเอาไปทำอะไรเองไม่ได้
ที่ไม่เหมือนกัน: ใบรับของจริงมักใช้ได้เรื่อย ๆ จนกว่าจะมีคนมารับผ้า
ใครหยิบไปก่อนก็ได้ผ้าไป
แต่ ticket ถูกบีบสองชั้นจนแทบไม่เหลือค่า: อยู่ได้ 30 วินาที
และ ใช้ได้ครั้งเดียว — เจ้าของตัวจริงแลกไปตั้งแต่วินาทีแรกที่หน้าเว็บโหลดเสร็จ
คนที่เก็บใบได้ทีหลังจึงเจอแค่ใบเปล่า
- bearer token
- token ประเภท “ใครถือก็ใช้ได้” ไม่ถามว่าเป็นใคร —
accessTokenในบทนี้เป็นแบบนี้ จึงห้ามให้มันไปโผล่ในที่ที่ถูกบันทึกไว้ · “ตัวนี้เป็น bearer token ถ้าหลุดคือใช้ได้เลย เราไม่ควรส่งผ่าน query string นะครับ” - one-time token / single-use
- ของที่ออกแบบให้แลกได้ครั้งเดียวแล้วตาย ·
ticket·state·codeเป็นแบบนี้ทั้งหมด · ถาม AI ได้ว่า “ช่วยรีวิวหน่อยว่า flow นี้ทำให้ ticket เป็น one-time token จริงไหม” - access log
- บันทึกที่เซิร์ฟเวอร์เขียนให้ทุก request โดยอัตโนมัติ (method + URL) · ประโยคที่ใช้บ่อยตอนรีวิวโค้ด: “อย่าใส่ค่านี้ใน query string เดี๋ยวมันเข้า access log”
- credential
- คำรวมของ “ของที่ใช้ยืนยันตัวตน” — ในบทนี้คือก้อน
{accessToken, refreshToken, bearerType, expiresAt}ที่เราเก็บไว้ใน Redis · เจอตัวแปรชื่อcredในโค้ดคนอื่นเมื่อไหร่ ให้เดาไว้ก่อนว่าเป็นของชิ้นนี้
GETDEL ทำให้ ticket ใช้ได้ครั้งเดียวจริงไหม
รันจริงผ่าน go-redis v9.22.0:
GetDel #1 -> v={"accessToken":"eyJhbGciOiJSUzI1NiJ9...","refreshToken":"eyJhbGciOiJIUzUxMiJ9...","bearerType":"Bearer","expiresAt":1787368745} err=<nil>
GetDel #2 -> v="" err=redis: nil
errors.Is(err2, redis.Nil) = true
ก่อนดู handler ต้องมี struct ที่ใช้แกะ JSON ก้อนนั้นก่อน — นี่คือที่มาของชื่อ field ใน response ข้างบน
(ถ้าข้ามบล็อกนี้ไป ตอนคอมไพล์จะเจอ undefined: Credential ทันที)
สองบล็อกถัดจากนี้เป็นสองไฟล์จริงใน package auth เดียวกัน —
ก๊อปไปวางทั้งคู่แล้ว go build ./... ผ่าน ไม่ต้องเติมอะไรเอง
(ทดสอบบน Go 1.26.5 · gin v1.12.0 · go-redis v9.22.0):
// internal/auth/credential.go
package auth
// ก้อนที่เราเก็บลง Redis ตอนขั้น 2 และคายกลับให้เบราว์เซอร์ตอนขั้น 3
type Credential struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
BearerType string `json:"bearerType"`
ExpiresAt int64 `json:"expiresAt"`
}
📌 bearerType เป็นชื่อที่ API ของเราตั้งเอง ไม่ใช่ชื่อมาตรฐานของ OAuth —
Keycloak ตอบกลับมาว่า token_type = Bearer (ดูผลรัน token endpoint ข้างบน)
แล้วเราเปลี่ยนเป็น camelCase ให้เข้าชุดกับ field อื่นใน response ของเรา
ถ้าไปหาคำว่า bearerType ใน RFC 6749 จะไม่เจอ เพราะมันไม่ได้อยู่ในนั้น
หน้าตาของ handler /exchange ที่ถูกต้องจึงสั้นมาก — และบรรทัดที่สำคัญที่สุดคือบรรทัดเดียว
ที่ใช้ GetDel แทน Get:
// internal/auth/exchange.go — อยู่ package เดียวกับ credential.go จึงเห็น Credential
package auth
import (
"encoding/json"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
func exchangeHandler(rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct{ Ticket string `json:"ticket" binding:"required"` }
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
// GetDel = อ่านแล้วลบในคำสั่งเดียว -> ticket ใช้ได้ครั้งเดียวเท่านั้น
raw, err := rdb.GetDel(c, "auth_ticket:"+req.Ticket).Result()
switch {
case errors.Is(err, redis.Nil): // ไม่มี ticket นี้: หมดอายุ / ใช้ไปแล้ว / แต่งมาเอง
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid ticket"})
return
case err != nil: // Redis มีปัญหาจริง ๆ คนละเรื่องกัน
c.JSON(http.StatusInternalServerError, gin.H{"error": "storage error"})
return
}
var cred Credential
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "corrupt ticket"})
return
}
c.JSON(http.StatusOK, cred) // token เดินทางใน response body ของ POST เท่านั้น
}
}
📌 สังเกตว่า switch แยกสอง error ออกจากกันคนละเคส —
redis.Nil ตอบ 401 Unauthorized (ผู้ใช้ผิด) ส่วน error ตัวอื่นตอบ
500 (เราผิด)
ถ้าเขียนรวมเป็น if err != nil { 401 } ก้อนเดียว เวลาที่ Redis ล่มจริง ๆ
ผู้ใช้ทุกคนจะเห็นข้อความว่า "ticket ไม่ถูกต้อง" แล้วคุณจะไล่หาสาเหตุผิดทางทั้งวัน
ครั้งที่สองได้ redis: nil ซึ่งเป็น sentinel error ตัวเดียวกับที่เจอใน
บทเรียน 32 —
แปลว่า "ไม่มี key นี้" ไม่ใช่ "Redis พัง" · สอง error นี้ต้องแยกให้ออกในโค้ด
ทายก่อนรัน — สมมติมีคนเขียน /exchange ด้วย GET เฉย ๆ แล้วลืมบรรทัดที่ลบ ticket ทิ้ง:
// เศษโค้ดสองบรรทัด (ไม่ใช่ไฟล์เต็ม) — เวอร์ชันที่มีบั๊ก: อ่านอย่างเดียว ไม่ลบ
raw, err := rdb.Get(ctx, "auth_ticket:"+ticket).Result()
ทายว่าถ้าเอา ticket ตัวเดิมมายิงซ้ำสามครั้ง จะได้อะไรบ้าง
และคำถามที่สำคัญกว่า: ผู้ใช้คนนั้นจะสังเกตเห็นอะไรผิดปกติไหม?
ลองคิดต่อด้วยว่า ticket ตัวนั้นไปนอนอยู่ที่ไหนบ้างแล้ว (ย้อนดูหัวข้อ 5 สามที่ที่ผมวัดมา)
ผลรันจริงจาก Redis 8.10.1 — ตั้ง TTL 30 วินาทีเหมือนเดิมทุกอย่าง ต่างแค่ใช้ GET แทน GETDEL:
GET auth_ticket:bug999 ครั้งที่ 1 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 2 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 3 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
EXISTS auth_ticket:bug999 -> 1
คืนค่าเดิมทุกครั้ง และ key ยังอยู่ — เทียบกับเวอร์ชันที่ถูก ที่ครั้งที่สองได้ redis: nil ไปแล้ว
คำตอบของคำถามที่สอง: ผู้ใช้จะไม่เห็นอะไรผิดปกติเลย —
เขาล็อกอินสำเร็จ ใช้งานเว็บได้ตามปกติทุกอย่าง เพราะครั้งแรกที่เขาแลก ก็ได้ token ถูกต้อง
ช่องโหว่อยู่ที่ ticket ตัวเดิมยังใช้ได้อีก 30 วินาที และตอนนี้มันไปนอนอยู่ใน
access log ของเซิร์ฟเวอร์ · history ของเบราว์เซอร์ · และ Referer ที่ส่งให้ทุกไฟล์ใน origin เดียวกัน
ใครก็ตามที่อ่าน log ได้ — คนในทีมที่เปิด dashboard ดู, ระบบรวม log, หรือคนที่เจาะเข้าเครื่อง log —
หยิบไปยิงซ้ำได้ token ชุดเดียวกับผู้ใช้จริง
และเรื่องที่แสบที่สุดคือมันตรวจไม่เจอ: ไม่มี error ไม่มี log เตือน ผู้ใช้ไม่ร้องเรียน
เทสก็ผ่าน เพราะเทสส่วนใหญ่แลก ticket แค่ครั้งเดียวแล้วเช็คว่าได้ token
เทสที่จับบั๊กนี้ได้ต้องเป็นเทสที่ยิงสองครั้งแล้ว assert ว่าครั้งที่สองต้องล้มเหลว —
ซึ่งเป็นเทสที่คนไม่ค่อยเขียน เพราะมันเช็ค "สิ่งที่ต้องไม่เกิด"
6 · ขั้นที่ 4 — /logout (สไลด์หน้า 47)
Browser Frontend JS wongnok API Redis Keycloak
│ │ │ │ │
│ │ POST /api/v1/auth/logout body {refreshToken} <- axios
│ │──────────────────▶ │ │
│ │ │ │ │
│ │ │ POST /logout refresh_token + client_secret
│ │ │────────────────────────────────▶
│ │ │ │ │
│ │ │ 204 No Content │
│ │ ◀────────────────────────────────│
│ │ │ │ │
│ │ 204 No Content │ │ │
│ ◀──────────────────│ │ │
│ │ │ │ │
Browser Frontend JS wongnok API Redis Keycloak
ขั้นนี้สั้นที่สุด และเป็น AJAX ล้วน ๆ ไม่มี redirect เลย
สิ่งที่ต้องเข้าใจคือ การลบ token ทิ้งจากฝั่ง frontend ไม่ใช่การ logout —
token ที่ผู้ใช้เคยได้ไปยังใช้ได้อยู่จนหมดอายุ ถ้ามีใครก๊อปไว้ก่อน
การ logout จริงคือบอก Keycloak ให้ปิด session นั้นทิ้ง ซึ่งต้องส่ง refresh_token
ไปพร้อม client_secret
ผมยิงของจริงไปที่ end_session_endpoint ของ Keycloak 26.7.2 สามรอบ:
=== 1) logout ครั้งแรก ===
$ curl -X POST $KC/realms/wongnok/protocol/openid-connect/logout \
-d client_id=wongnok-api -d client_secret=super-secret-123 \
--data-urlencode refresh_token@rt.txt
HTTP 204
response body ยาว 0
=== 2) ยิง logout ซ้ำด้วย refresh_token ตัวเดิม ===
HTTP 204
(body ว่าง)
=== 3) หลัง logout แล้วเอา refresh_token เดิมไปขอ token ใหม่ ===
{"error":"invalid_grant","error_description":"Session not active"}
HTTP 400
📌 อ่านผลนี้ให้ครบสามชั้น
· 204 No Content คือสถานะ "สำเร็จ แต่ไม่มีอะไรจะตอบ" — ไม่มี body เลยจริง ๆ
(ยาว 0 ไบต์) ตรงกับที่ บทเรียน 34 ปูไว้
· ยิงซ้ำก็ยังได้ 204 ไม่ใช่ 400 — เพราะ logout เป็น idempotent
(สั่งซ้ำกี่ครั้งผลลัพธ์เท่าเดิม) ดังนั้นอย่าเขียนโค้ดที่แปล 204 ครั้งที่สองว่า "logout ไม่สำเร็จ"
· ชั้นที่พิสูจน์ว่ามันทำงานจริงคือชั้นที่สาม: refresh_token ตัวนั้นตายแล้ว
ได้ Session not active กลับมา
Keycloak ปิด session และฆ่า refresh_token ให้ — แต่ access_token
ที่ผู้ใช้ถืออยู่เป็น JWT ที่ตรวจได้ด้วยลายเซ็นอย่างเดียว ไม่ได้ถาม Keycloak ทุกครั้ง
แปลว่ามันยังใช้ได้ต่อจนกว่าจะครบ expires_in ซึ่งค่า default คือ
300 วินาที
นี่คือราคาที่ต้องจ่ายของการใช้ JWT แบบไม่เช็คทุก request — และเป็นเหตุผลที่ Keycloak
ตั้ง access token ไว้แค่ 5 นาที
ตึกออฟฟิศหลายที่ ยามหน้าประตูไม่ได้โทรเช็คทะเบียนพนักงานทุกครั้งที่มีคนเดินเข้า —
เขาแค่ดูว่าบัตรเป็นของจริงไหม และวันหมดอายุที่พิมพ์อยู่บนบัตรยังไม่ถึงหรือเปล่า
วันที่ HR ลบชื่อคุณออกจากระบบ ทะเบียนกลางเปลี่ยนทันที
แต่บัตรที่ยังอยู่ในกระเป๋าคุณหน้าตาถูกต้องทุกอย่าง ยามจึงยังปล่อยผ่าน
จนกว่าจะถึงวันที่พิมพ์ไว้บนบัตร
access_token แบบ JWT คือบัตรใบนั้น — API ตรวจแค่ลายเซ็น
กับเวลาหมดอายุที่ติดมาในตัว token เอง ไม่ได้ถาม Keycloak ทุก request
POST /logout จึงทำได้แค่ปิดทะเบียนกลาง (session ตาย · refresh_token ใช้ไม่ได้แล้ว)
ส่วนบัตรที่แจกออกไปแล้วยังใช้ได้จนครบ expires_in = 300 วินาที ตามที่รันให้ดูข้างบน
ที่ไม่เหมือนกัน: บัตรพนักงานยังยึดคืนได้ถ้าคุณเดินมาส่ง
แต่ access_token ถูกก๊อปเก็บไว้กี่ชุดก็ได้และเรียกคืนไม่ได้เลย —
นี่คือเหตุผลที่ทางแก้ไม่ใช่ “ทำให้ยึดคืนได้” แต่คือตั้งอายุให้สั้นจนขโมยไปแล้วไม่คุ้ม
- JWT (JSON Web Token)
- token ที่พกข้อมูลกับลายเซ็นไว้ในตัวเอง ผู้รับตรวจได้เองโดยไม่ต้องถามใคร · “access token เราเป็น JWT ใช่ไหมครับ แปลว่าสั่งให้ตายกลางคันไม่ได้ใช่หรือเปล่า”
- revoke
- “ยกเลิกของที่แจกไปแล้ว” ·
refresh_tokenrevoke ได้เพราะต้องกลับไปถาม Keycloak ทุกครั้ง ส่วน JWT ที่ตรวจด้วยลายเซ็นอย่างเดียว revoke ไม่ได้ · ถาม AI ว่า “ถ้าต้อง revoke access token ให้ตายทันที มีทางเลือกอะไรบ้าง แลกกับอะไร” - idempotent
- “สั่งซ้ำกี่ครั้งผลลัพธ์ก็เท่าเดิม” —
/logoutเป็นแบบนี้ ยิงสองครั้งได้204ทั้งคู่ · “endpoint นี้ idempotent ไหมครับ ถ้า client retry แล้วจะพังไหม” - session
- “รอบการใช้งานหนึ่งรอบของผู้ใช้” ที่ฝั่ง Keycloak จำไว้ · logout คือการปิด session นั้นทิ้ง · ระวังอย่าสับสนกับ session ของเว็บเราเอง — flow ในบทนี้ฝั่งเราไม่มี session เลย มีแต่ Redis ที่เก็บของอายุสั้น
7 · ตารางสรุปทั้งสี่ endpoint
| endpoint | ใครเป็นคนเรียก | เรียกยังไง | แตะ Redis ยังไง | ตอบอะไรกลับ |
|---|---|---|---|---|
GET /api/v1/auth/login |
Browser | window.locationห้าม AJAX |
SET oauth_state:xxxTTL 300 วินาที |
302 + Location: ไปหน้า login ของ Keycloak |
GET /api/v1/auth/callback |
Browser (เดินตาม 302 ของ Keycloak) |
ไม่มีใครสั่ง — เบราว์เซอร์เดินเองตาม redirect | GETDEL oauth_state:xxx แล้วSET auth_ticket:yyy TTL 30 วินาที |
302 + Location: ไป FRONTEND_URL/auth/success?ticket=yyy |
POST /api/v1/auth/exchange |
Frontend JS | axios.post (AJAX)body {ticket} |
GETDEL auth_ticket:yyy |
200 + body {accessToken, refreshToken, bearerType, expiresAt} |
POST /api/v1/auth/logout |
Frontend JS | axios.post (AJAX)body {refreshToken} |
ไม่แตะเลย | 204 No Content (body ว่าง) |
อ่านตารางนี้ตามแนวตั้งของคอลัมน์ "เรียกยังไง" แล้วจะเห็นเส้นแบ่งชัด ๆ
สองแถวบน = โลกของ redirect — เบราว์เซอร์เป็นคนเดิน · ส่งข้อมูลได้แค่ทาง URL · ทุกอย่างถูก log
สองแถวล่าง = โลกของ AJAX — JavaScript เป็นคนเรียก · ส่งข้อมูลทาง body ได้ · ไม่ถูก log
ทั้ง flow นี้ออกแบบมาเพื่อข้ามจากโลกบนไปโลกล่างให้เร็วที่สุด โดยมี ticket เป็นสะพาน
8 · Redis อยู่ตรงนี้ทำไม
คำถามที่ควรถามคือ ทำไมไม่เก็บใส่ตัวแปรใน Go ไปเลย? — คำตอบมีสามข้อ และข้อสุดท้ายสำคัญที่สุด
ข้อ 1 — ของสองชิ้นนี้ต้อง "หายเอง"
ทั้ง oauth_state และ auth_ticket เป็นของที่ถ้าไม่มีใครมาเอา ต้องหายไปเอง
ผู้ใช้กด login แล้วปิดแท็บทิ้ง = ไม่มีใครมาแลก state นั้นตลอดกาล ·
ผู้ใช้เจอเน็ตหลุดตอนหน้า success = ไม่มีใครมาแลก ticket
ถ้าเก็บใน map ของ Go ของพวกนี้จะค้างอยู่จนกว่าจะ restart — หน่วยความจำรั่ว
และต้องเขียนโค้ดกวาดขยะเอง
Redis ตั้ง TTL แล้วลืมได้เลย ผมรันให้ดูด้วย TTL 3 วินาที (ย่อจาก 30 เพื่อให้ดูทัน):
t=0s TTL -> 3
t=2s TTL -> 1
t=4s TTL -> -2
t=4s GET -> (ว่าง)
t=4s EXISTS -> 0
📌 TTL คืนเลขติดลบสองค่าที่ความหมายต่างกัน อย่าจำสลับ
ผมรันเทียบให้ดู:
TTL forgot_ttl (ตั้ง SET โดยไม่ใส่ EX) -> -1 # key มีอยู่ แต่ไม่มีวันหมดอายุ = อยู่ตลอดไป
TTL ของคีย์ที่ไม่มีอยู่จริง -> -2 # ไม่มี key นี้แล้ว
-1 คือสัญญาณอันตรายในบทนี้: ถ้าลืมใส่ EX ตอน SET auth_ticket
ticket ตัวนั้นจะอยู่ตลอดไป ไม่ใช่ 30 วินาที
ข้อ 2 — ต้องอ่านแล้วลบให้ได้ในจังหวะเดียว
ทั้งสองจุดใช้ GETDEL ซึ่ง Redis รับประกันว่าเป็นคำสั่งเดียว —
ไม่มีช่องว่างระหว่าง "อ่าน" กับ "ลบ" ให้ใครแทรก
ถ้าเขียนเองเป็น Get() แล้วค่อย Del() สองบรรทัด
จะมีช่วงเสี้ยววินาทีที่ request สองอันอ่านค่าเดียวกันได้พร้อมกัน — ซึ่งพังทั้งความเป็นของใช้ครั้งเดียว
ซื้อตั๋วเที่ยวเดียวไว้ พอถึงปลายทางคุณยื่นมันเข้าประตูขาออก —
ประตูอ่านค่าแล้วกลืนตั๋วไปเลยในจังหวะเดียวกัน ไม่มีคืน
ลองนึกภาพประตูอีกแบบ: อ่านค่าแล้วคืนตั๋วให้คุณ โดยบอกว่า “เดี๋ยวมีคนมาเก็บทีหลัง” —
ระหว่างนั้นใครหยิบตั๋วใบนั้นไปยื่นซ้ำก็ออกได้ฟรี
GETDEL คือประตูแบบแรก · เขียน Get แล้วค่อย Del คนละบรรทัด
คือประตูแบบหลัง
ช่องว่างระหว่างสองบรรทัดนั้นสั้นมากจนตอนเทสในเครื่องตัวเองไม่มีทางเห็น
แต่บนของจริงที่มี request วิ่งเข้ามาพร้อมกันหลายอัน มันกว้างพอให้สองคนอ่านค่าเดียวกันได้
ที่ไม่เหมือนกัน: ประตูรถไฟฟ้ากลืนตั๋วตอนคุณออก คือจบการเดินทางพอดี
แต่ GETDEL เกิดตอนเริ่มใช้งาน — พอ ticket ตายไปแล้ว
สิ่งที่คุณเพิ่งได้มาคือ access_token ที่ยังใช้ต่อได้จนครบอายุ 300 วินาทีของมัน
ข้อ 3 — แอปของจริงรันหลายก้อนพร้อมกัน
ตอน deploy จริงเราไม่ได้รัน wongnok API ตัวเดียว แต่รันสำเนาหลายตัวหลัง load balancer
ทีนี้ดูเส้นทางในหัวข้อ 4 อีกรอบ: /login กับ /callback เป็นคนละ request
— ไม่มีอะไรรับประกันว่าสองอันจะวิ่งเข้าเครื่องเดียวกัน
ถ้า state ถูกเก็บไว้ใน RAM ของเครื่องที่รับ /login
แล้ว /callback ดันวิ่งเข้าอีกเครื่อง ผู้ใช้จะ login ไม่ผ่านแบบสุ่ม —
บั๊กที่หาสาเหตุยากที่สุดประเภทหนึ่ง เพราะบนเครื่อง dev ที่รันก้อนเดียวมันไม่เคยเกิด
Redis เป็นของกลางที่ทุกก้อนเห็นชุดเดียวกัน — เหตุผลเดียวกับที่
บทเรียน 32 อธิบายเรื่อง cache ไว้
- atomic
- “ทำทั้งชุดจบในจังหวะเดียว ไม่มีใครแทรกกลางได้” —
GETDELเป็นคำสั่ง atomic · ประโยคที่ใช้ได้จริงตอนรีวิวโค้ด: “ตรงนี้ต้อง atomic นะ ไม่งั้นสอง request อ่านค่าเดียวกันได้” - race condition
- บั๊กที่เกิดเพราะสองงานวิ่งชนกันในจังหวะที่พอดีเป๊ะ · เจอยากมากตอนเทส เพราะต้องบังเอิญชนกันพอดี · ถาม AI ว่า “โค้ดนี้มี race condition ตรงไหนไหม ถ้ามี request เข้ามาพร้อมกันสองอัน”
- TTL (time to live)
- อายุที่ตั้งให้ key ใน Redis · เลขบวก = เหลืออีกกี่วินาที ·
-1= ไม่มีวันหมดอายุ ·-2= ไม่มี key นี้แล้ว · “key นี้ TTL เท่าไหร่ครับ เผลอลืมใส่ EX ตอน SET รึเปล่า” - stateless
- “ตัวแอปไม่จำอะไรไว้ในตัวเอง” — เหตุผลที่
stateกับticketต้องไปอยู่ใน Redis ไม่ใช่ตัวแปรใน Go เพราะของจริงรันหลายก้อนหลัง load balancer · “service เราเป็น stateless ใช่ไหม ถ้าใช่ ของแบบนี้ต้องไปอยู่ Redis”
9 · state กัน CSRF ยังไง และทำไมต้องลบทิ้ง
CSRF ย่อจาก Cross-Site Request Forgery — การที่เว็บของคนอื่น
หลอกให้เบราว์เซอร์ของคุณยิง request ไปที่เว็บเรา โดยคุณไม่รู้ตัว
ในบริบท OAuth มันมีหน้าตาเฉพาะตัว ที่เรียกว่า login CSRF — ไล่ทีละขั้น:
ถ้า /callback ไม่เช็ค state:
1. ผู้โจมตี login เข้า Wongnok ด้วย "บัญชีของตัวเอง" ตามปกติ
2. แต่พอ Keycloak เด้ง 302 กลับมาที่ /callback?code=AAA เขา "ไม่กดต่อ"
-> เขาคัดลอก URL นั้นเก็บไว้ ตอนนี้ code=AAA ยังไม่ถูกใช้
3. เขาหลอกให้เหยื่อคลิก URL นั้น (ส่งทางแชท / ฝังเป็นรูปในหน้าเว็บ)
4. เบราว์เซอร์ของเหยื่อยิง GET /api/v1/auth/callback?code=AAA
5. wongnok API แลก code=AAA -> ได้ token ของ "บัญชีผู้โจมตี"
-> เหยื่อถูกล็อกอินเข้าบัญชีของผู้โจมตี โดยคิดว่าเป็นบัญชีตัวเอง
6. เหยื่อกดบันทึกสูตรอาหาร / ผูกบัตร / อัปโหลดรูป
-> ทุกอย่างไปอยู่ในบัญชีที่ผู้โจมตีเข้าถึงได้
state เข้ามาตัดวงจรนี้ที่ขั้น 4 — จุดที่ request วิ่งเข้ามาถึงเรา
เพราะเราไม่รับ /callback ที่ไม่มี state ซึ่งเราเป็นคนออกให้
URL ที่ผู้โจมตีจะใช้ได้จึงต้องเป็น URL ที่มี state จริง ไม่ใช่ค่าที่เขาแต่งขึ้นมาเอง
ผลรันในหัวข้อ 4 แสดงให้เห็นแล้วว่าค่าที่แต่งเองได้อะไรกลับมา:
GETDEL oauth_state:attacker-made-it -> (ว่าง) # ไม่มีใน Redis = ปฏิเสธ
สมมติเราเขียนแค่ EXISTS oauth_state:xxx แล้วปล่อย key ไว้ 5 นาทีตาม TTL
ผู้โจมตีจะได้ช่องว่างยาว 5 นาทีในการยิง URL เดิมซ้ำกี่ครั้งก็ได้ —
และถ้าโค้ดฝั่งเรามีจุดไหนที่ทำงานไม่สำเร็จแล้วให้ลองใหม่ได้ เขาก็ยิงรัวได้ฟรี ๆ
พอเปลี่ยนเป็น GETDEL ช่องว่างนั้นหดเหลือครั้งเดียว
ผมรันพิสูจน์ไว้แล้วในหัวข้อ 4:
GETDEL oauth_state:9f2b7c1e -> 1 # ครั้งแรก ผ่าน
GETDEL oauth_state:9f2b7c1e -> (ว่าง) # ครั้งที่สอง ไม่มีแล้ว
EXISTS oauth_state:9f2b7c1e -> 0
ผลข้างเคียงที่ดีอีกอย่าง: เคส "ยิงซ้ำ" กับเคส "แต่ง state เอง" ให้ผลเหมือนกันเป๊ะ
(ค่าว่างทั้งคู่) — โค้ดจึงเขียนเงื่อนไขเดียวจบ ไม่ต้องแยกสองกรณี และไม่บอกใบ้ผู้โจมตีว่า
state ที่เขาเดามาถูกหรือผิด
state ต้องผูกกับเบราว์เซอร์คนนั้น ไม่ใช่แค่ "เราเคยออกให้"
ตรงนี้เป็นจุดที่ diagram ในสไลด์บอกไม่ครบ และเป็นเรื่องที่ควรรู้ก่อนเอาไปเขียนของจริง
ลองไล่การโจมตีข้างบนอีกรอบ แต่คราวนี้ผู้โจมตีไม่แต่ง state เอง —
เขาไปกด /login ที่เว็บเราตามปกติ เพื่อให้ระบบออก state ให้เขาจริง ๆ
แล้วค่อยเอา URL /callback?code&state ของเขาไปหลอกเหยื่อ
ตอนเหยื่อคลิก เราจะ GETDEL เจอ เพราะ key นั้นมีอยู่จริง — ด่านนี้ผ่านฉลุย
สาเหตุคือการเก็บแค่ oauth_state:xxx = "1" ตอบได้แค่คำถามว่า
"ค่านี้เราเคยออกให้ใครสักคนไหม"
แต่คำถามที่ต้องตอบจริง ๆ คือ "ค่านี้เราออกให้เบราว์เซอร์ตัวที่กำลังยิงเข้ามาอยู่นี้หรือเปล่า"
ทางแก้คือผูก state เข้ากับเบราว์เซอร์: ตอน /login
ให้ตั้ง cookie ที่สุ่มขึ้นมาคู่กันไปด้วย (HttpOnly · SameSite=Lax) แล้วเก็บลง Redis เป็น
oauth_state:xxx = <ค่าใน cookie> แทนที่จะเก็บเป็น "1"
ตอน /callback ก็เทียบค่าที่ GETDEL ได้ กับ cookie ที่ติดมากับ request
— ไม่ตรงกันเมื่อไหร่คือคนละเบราว์เซอร์ ให้ปฏิเสธ
นี่คือสิ่งที่ RFC 6749 §10.12
หมายถึงตอนที่มันเขียนว่า state ต้องผูกกับ session ของ user agent
🧭 สรุปกฎเดียวที่ครอบทั้งบท —
ของทุกชิ้นในบทนี้ที่ เดินทางผ่าน URL ถูกออกแบบให้เป็นของใช้ครั้งเดียว อายุสั้น:
· state — TTL 5 นาที · GETDEL ทิ้งเมื่อใช้ (และควรผูกกับ cookie ตามกล่องข้างบน)
· code — Keycloak บังคับเอง (ยิงซ้ำได้ Code not valid HTTP 400)
· ticket — TTL 30 วินาที · GETDEL ทิ้งเมื่อแลก
ส่วนของที่มีค่าจริง (access_token, refresh_token, client_secret)
ไม่เคยผ่าน URL แม้แต่ครั้งเดียวตลอดทั้ง flow
ทดสอบความเข้าใจ
1. ทำไม /api/v1/auth/login ต้องเรียกด้วย window.location เรียกด้วย axios.get() ไม่ได้?
ปัญหาไม่ได้อยู่ที่ axios ตามไม่ได้ — มันตามได้ แต่ผลที่ได้กลับมาเป็น
สตริง HTML ในตัวแปร JavaScript หน้าจอผู้ใช้ไม่ขยับ
ผม curl ไปที่ authorization_endpoint จริง ได้ HTML กลับมา 6882 ไบต์
— เอาไปให้ axios ก็ทำอะไรต่อไม่ได้
และเหตุผลที่หนักกว่านั้นคือเรื่องความไว้ใจ: ผู้ใช้กำลังจะพิมพ์รหัสผ่าน
เขาต้องเห็นชื่อโดเมนของ Keycloak บนแถบที่อยู่ ถ้าเว็บเราแสดงหน้า login แทนได้ นั่นคือหน้าตาของฟิชชิง
ตัวเลือกที่ 2 ผิดเพราะ endpoint นี้ไม่ได้ตอบ JSON เลย · ตัวเลือกที่ 1 ผิดเพราะไม่เกี่ยวกับ header
ตัวเลือกที่ 4 หลอกดีเพราะฟังดูสมเหตุสมผล แต่ axios ในเบราว์เซอร์เดินตาม redirect ให้อัตโนมัติอยู่แล้ว
2. ผมยิง ticket ไปสองแบบแล้วดู access log แบบเดียวกับ gin.Logger() — ผลจริงเป็นยังไง?
ผลรันจริงสองบรรทัดนี้คือหัวใจของทั้งบท:
[access log] GET /api/v1/auth/success-bad?ticket=T-9f2b7c1e
[access log] POST /api/v1/auth/exchange
ทั้งที่ handler ของแบบ POST เห็น body เต็ม ๆ ({"ticket":"T-9f2b7c1e"}) และตอบ token กลับไปครบ
เหตุผล: log เขียนจาก request line ซึ่งประกอบด้วย method กับ URI เท่านั้น —
body ไม่ได้อยู่ในนั้น ต้องตั้งใจเขียนโค้ด log body ถึงจะโผล่
ตัวเลือกที่ 2 ผิดชัด: ไม่มีเซิร์ฟเวอร์ตัวไหนเดาได้ว่าพารามิเตอร์ไหนเป็นความลับ
ตัวเลือกที่ 4 ผิดกลับด้าน และ "query string ถูกเข้ารหัส" ก็ไม่จริง — HTTPS เข้ารหัสระหว่างทาง
แต่เซิร์ฟเวอร์ปลายทางถอดออกมาเขียน log อยู่ดี
3. โค้ด /exchange ใช้ Get() แทน GetDel() คือลืมลบ ticket ทิ้ง — จะเกิดอะไร และใครจะสังเกตเห็น?
ผลรันจริงกับ Redis 8.10.1 — GET คีย์เดิมสามครั้งได้ค่าเดิมทั้งสามครั้ง และ EXISTS
ยังคืน 1 · เทียบกับ GETDEL ที่ครั้งที่สองได้ redis: nil ไปแล้ว
ตัวเลือกที่ 3 คือคำตอบที่คนเลือกเยอะที่สุดและผิดที่สุด: TTL ไม่ได้ทำให้ใช้ครั้งเดียว
มันแค่ทำให้หมดอายุ — ระหว่าง 30 วินาทีนั้นใช้ได้ไม่จำกัดครั้ง
และ 30 วินาทีนั้นเริ่มนับตั้งแต่ /callback เขียน key ลง Redis
ซึ่งเป็นวินาทีเดียวกับที่ ticket โผล่ใน access log แล้ว — ผู้อ่าน log มีเวลาเหลือเฟือ
ตัวเลือกที่ 1 กับ 2 ผิดเรื่องพฤติกรรมของ Redis: GET อ่านซ้ำได้ไม่จำกัด และใช้กับ key ที่มี TTL ได้ปกติ
สิ่งที่ทำให้บั๊กนี้อันตรายคือมันไม่มีอาการ: ไม่ error ไม่มี log เตือน ผู้ใช้ไม่ร้องเรียน
เทสที่จะจับได้ต้องยิงสองครั้งแล้ว assert ว่าครั้งที่สองต้องล้มเหลว
4. ยิง POST /logout ไป Keycloak สำเร็จแล้วได้ 204 — ตอนนี้อะไรตายแล้ว อะไรยังไม่ตาย?
ผมรันครบสามรอบ: logout ครั้งแรกได้ HTTP 204 body ยาว 0 ไบต์ ·
ยิงซ้ำได้ 204 อีกครั้ง (idempotent) ·
แล้วเอา refresh_token เดิมไปขอ token ใหม่ได้
{"error":"invalid_grant","error_description":"Session not active"} พร้อม HTTP 400
ส่วน access_token ยังไม่ตาย เพราะมันเป็น JWT ที่ตรวจด้วยลายเซ็น —
API ที่รับมันไม่ได้ถาม Keycloak ทุกครั้งว่า session ยังอยู่ไหม
ตัวเลือกที่ 4 กลับด้าน · ตัวเลือกที่ 3 เข้าใจ 204 ผิด: 204 No Content แปลว่า
สำเร็จแล้ว แต่ไม่มีอะไรจะตอบ ไม่ใช่ "รับเข้าคิว" (นั่นคือ 202 Accepted)
ผลในทางปฏิบัติ: ช่วง 300 วินาทีนั้นคือหน้าต่างที่ token ที่ถูกขโมยไปก่อนหน้ายังใช้ได้อยู่
— เป็นราคาที่ต้องจ่ายของการใช้ JWT แบบไม่เช็คทุก request และเป็นเหตุผลที่ Keycloak ตั้งอายุไว้แค่ 5 นาที
เป้าหมายไม่ใช่ "ทำให้ login ผ่าน" — เป้าหมายคือให้คุณเห็นด้วยตาตัวเองว่า
อะไรที่อยู่ใน URL มันไปโผล่ที่ไหน แล้วค่อยเข้าใจว่าทำไมต้องมี /exchange
- ตั้ง Redis ขึ้นมาก่อน:
docker run -d --rm --name lab-redis -p 6379:6379 redis:8-alpine
เช็คว่ารันแล้วด้วยdocker exec lab-redis redis-cli PINGต้องได้PONG - เล่นกับ TTL ให้เข้าใจสามสถานะ: ใช้
redis-cliตรง ๆSET k1 v EX 3แล้วสั่งTTL k1รัว ๆ จนได้-2·SET k2 v(ไม่ใส่EX) แล้วTTL k2
ตอบให้ได้ว่า-1กับ-2ต่างกันยังไง และข้อไหนคือบั๊กถ้าเจอกับauth_ticket: - พิสูจน์
GETDEL:SET t1 "credential" EX 30แล้วสั่งGETDEL t1สองครั้ง · จากนั้นทำซ้ำด้วยGETสามครั้ง
จดผลทั้งสองแบบมาเทียบ — นี่คือความต่างระหว่างโค้ดที่ถูกกับโค้ดที่มีบั๊ก - ทำ access log ของตัวเอง: เขียน Go server เล็ก ๆ ที่มี middleware พิมพ์
r.Methodกับr.URL.RequestURI()ทุก request
ยิงเข้าไปสองแบบ:curl "localhost:PORT/a?ticket=SECRET"และcurl -X POST localhost:PORT/b -d '{"ticket":"SECRET"}'
ดูว่าบรรทัดไหนมีคำว่า SECRET - วัด
Refererด้วยตัวเอง: ให้ server เดิมเสิร์ฟหน้า HTML ที่/page?ticket=SECRETซึ่งข้างในมี<img src="/pixel.gif">แล้วเพิ่มr.Header.Get("Referer")เข้าไปใน log
เปิดหน้านั้นด้วยเบราว์เซอร์จริง แล้วดูว่า Referer ของ/pixel.gifมี ticket ติดมาไหม - ท้าทาย — วัดฝั่งข้ามเว็บ: เปิด server ตัวที่สองคนละพอร์ต แล้วให้หน้าเดิมโหลดรูปจากพอร์ตนั้นด้วย
เทียบRefererที่สองฝั่งได้รับ แล้วอธิบายว่าทำไมไม่เหมือนกัน (คำใบ้: ค้นคำว่าstrict-origin-when-cross-origin) - ตั้ง Keycloak แล้วเดิน flow จริง: ตามสไลด์หน้า 39 · สร้าง realm + client แบบ confidential
+ user · แล้วเปิด
authorization_endpointในเบราว์เซอร์พร้อมstateที่คุณตั้งเอง
ดู URL ที่มันเด้งกลับมา แล้วนับว่ามีพารามิเตอร์กี่ตัว อะไรบ้าง - ยืนยันว่า
codeใช้ครั้งเดียว: เอาcodeจากข้อที่แล้วไปแลกที่token_endpointด้วยcurlสองครั้งติดกัน
จดข้อความ error ของครั้งที่สองมาให้ตรงตัวอักษร
ข้อ 2 — สามสถานะของ TTL (ผลรันจริงบน Redis 8.10.1):
t=0s TTL -> 3 # เลขบวก = เหลืออีกกี่วินาที
t=2s TTL -> 1
t=4s TTL -> -2 # ไม่มี key นี้แล้ว (หมดอายุไปแล้ว หรือไม่เคยมี)
t=4s EXISTS -> 0
TTL forgot_ttl (SET โดยไม่ใส่ EX) -> -1 # key อยู่ แต่ไม่มีวันหมดอายุ
-1 คือข้อที่เป็นบั๊ก ถ้าเจอกับ auth_ticket: —
แปลว่าลืมใส่ EX ตอน SET และ ticket ตัวนั้นจะอยู่ตลอดไป
ทั้งที่มันควรตายใน 30 วินาที
ข้อ 3 — GETDEL เทียบกับ GET:
-- แบบที่ถูก --
GETDEL auth_ticket:a1b2c3 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GETDEL auth_ticket:a1b2c3 -> (ว่าง)
EXISTS auth_ticket:a1b2c3 -> 0
-- แบบที่มีบั๊ก --
GET auth_ticket:bug999 ครั้งที่ 1 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 2 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 3 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
EXISTS auth_ticket:bug999 -> 1
ถ้าเขียนใน Go ด้วย go-redis ความต่างจะโผล่ที่ error: GetDel ครั้งที่สองได้
redis: nil ซึ่ง errors.Is(err, redis.Nil) เป็น true
อย่าลืมว่า redis.Nil แปลว่า "ไม่มี key" ไม่ใช่ "Redis พัง" —
สอง error นี้ต้องแยกกันเสมอ ตามที่ บทเรียน 32 ย้ำไว้
💬 ข้อ 5 กับ 6 คือข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ —
เพราะการเห็น Referer="http://.../page?ticket=SECRET" โผล่ในเทอร์มินัลของตัวเอง
มันคนละเรื่องกับการอ่านว่า "อย่าใส่ token ใน URL"
และถ้าคุณทำข้อ 6 จนเห็นว่าฝั่งข้ามเว็บได้แค่ชื่อโดเมน คุณจะเถียงเก่งขึ้นด้วย —
เพราะรู้ว่าคำเตือนแบบเหมารวมข้อไหนล้าสมัยไปแล้ว และข้อไหนยังจริงอยู่
ผลรันทุกบรรทัดในหน้านี้วัดบน Redis 8.10.1 · Keycloak 26.7.2 · Go 1.26.5 darwin/arm64 ·
Google Chrome 151.0.7922.170 ถ้าของคุณออกมาไม่ตรง ส่งมาได้เลย
อ้างอิง
- RFC 6749 §10.12 — Cross-Site Request Forgery — ต้นทางของ
stateอ่านประโยคที่บอกว่ามันต้องผูกกับ session ของผู้ใช้ ซึ่งคือเหตุผลที่เราเก็บลง Redis - RFC 9700 — OAuth 2.0 Security Best Current Practice — เอกสารที่บอกตรง ๆ ว่าห้ามส่ง token ผ่าน query string และ
codeต้องใช้ครั้งเดียว - Redis —
GETDEL· TTL · SET — อ่านตรงที่TTLอธิบาย-1กับ-2ซึ่งเป็นสองค่าที่จำสลับกันบ่อยที่สุด - go-redis v9 —
GetDel· redis.Nil — sentinel error ที่ต้องแยกจาก error จริง - MDN —
Referer· Referrer-Policy — อ่านว่าstrict-origin-when-cross-originทำอะไร เพื่ออธิบายผลรันในหัวข้อ 5 ให้ครบ - Keycloak — OIDC endpoints — ที่มาของ
token_endpointและend_session_endpointที่ใช้ในบทนี้ - บทเรียน 35 — query / body / header · บทเรียน 34 — status code · บทเรียน 32 — Redis กับ TTL — สามบทที่บทนี้ยืนอยู่บน