ตรวจสอบการเชื่อมบัญชี LINE
ภาพรวม
หน้า /verify-line-login เป็นหน้าแบบ standalone ที่ใช้ตรวจสอบว่าบัญชี LINE
ที่กำลังเข้าสู่ระบบอยู่ ถูกผูกเข้ากับ OA ที่ระบุไว้แล้วหรือยัง โดยระบุ OA ผ่าน
พารามิเตอร์ webhook key ที่แนบมาใน URL
หน้านี้เป็นฟีเจอร์รุ่นเก่ากว่าส่วนอื่นในโปรเจกต์อย่างชัดเจน จุดที่ต่างจากหน้าอื่นคือ:
- ระบุ OA ด้วย webhook key แทนที่จะใช้ hash แบบเส้นทาง
[hash]ที่ฟีเจอร์อื่นใช้ - เรียก API ผ่าน base URL คนละชุด (ฝั่ง admin และฝั่ง line) ไม่ใช่ base URL ของ client API เหมือนหน้าอื่น
เหมาะสำหรับใช้เป็นเครื่องมือตรวจสอบสถานะการเชื่อมบัญชี ไม่ใช่หน้าที่ผู้ใช้ทั่วไป เข้าถึงจาก flow ปกติ
Business Flow
- เปิด
/verify-line-loginพร้อมแนบ webhook key มาใน query string - หน้าเก็บ URL ปัจจุบันแบบเต็มไว้เป็นปลายทางย้อนกลับ เพื่อให้กลับมาที่หน้าเดิม ได้หลังผ่านการเข้าสู่ระบบ LINE
- เรียก API ฝั่ง admin เพื่อแลก webhook key เป็น LIFF ID โดยตั้งอายุ cache ไว้สั้นเพียง 10 วินาที
- ตรวจว่า webhook key ในหน้าตรงกับที่เก็บไว้ใน store หรือไม่ — ถ้าไม่ตรง ระบบจะออกจากระบบก่อนแล้วจึงเก็บ key ใหม่ เพื่อป้องกันข้อมูลโปรไฟล์ของ OA เดิมค้างอยู่และทำให้ผลการตรวจผิด
- เก็บ LIFF ID ลง store แล้วเข้าสู่ระบบผ่าน LIFF เพื่อให้ได้รหัสผู้ใช้ LINE
- เรียก API ฝั่ง line เพื่อตรวจว่ารหัสผู้ใช้นั้นถูกผูกกับ OA นี้แล้วหรือยัง โดย endpoint ตอบกลับเป็นค่าจริงหรือเท็จ — คำขอนี้ไม่ถูก cache เลย เพื่อให้ได้ผลปัจจุบันทุกครั้ง
- แสดงผลลัพธ์บนหน้าจอ โดยถือว่าผ่านเมื่อผลตรวจเป็นจริงและไม่มีข้อผิดพลาด ตอนแลก LIFF ID
หน้าจอและองค์ประกอบหลัก
- หน้าหลัก (
src/app/verify-line-login/page.tsx) — อ่าน webhook key จาก query string แล้วส่งต่อให้ตัวควบคุม - ตัวควบคุม (
src/components/verify-line-login/verify-line-login.container.tsx) — ประสานลำดับการทำงานทั้งหมดและเรนเดอร์ผลลัพธ์ - hook แลก LIFF ID (
hooks/use-fetch-liff-id.ts) — แลก webhook key เป็น LIFF ID โดยตั้งอายุ cache สั้น - hook ตรวจการเชื่อมบัญชี (
hooks/use-verify-line-login.ts) — ตรวจผลการเชื่อม โดยไม่ cache ผลลัพธ์ - service (
src/service/verify-line-login.service.ts) — รวมการเรียก API ทั้งสองฝั่งไว้ที่เดียว
Endpoint ที่ใช้
| Method | Base URL | Path |
|---|---|---|
| GET | admin API | /line/liff-id?webhook-key= |
| GET | line API | /integration/verify-line-login?webhook-key=&line-user-id= |
จุดเชื่อมต่อกับฟีเจอร์อื่น
- การยืนยันตัวตนผ่าน LIFF — ใช้ hook กลางของโปรเจกต์โดยตรง (ดู liff-authentication)
- Store ของแอป — หน้านี้เป็นที่เดียวในโปรเจกต์ที่ใช้ field เก็บ webhook key ซึ่งเป็นร่องรอยว่าฟีเจอร์นี้มาก่อนแนวทางที่ใช้ hash
- ไม่ได้อยู่ใต้เส้นทาง
[hash]และไม่ได้ใช้ base URL ของ client API เหมือนฟีเจอร์อื่น จึงควรพิจารณาปรับให้เข้ากับแนวทางปัจจุบันหากจะพัฒนาต่อ
รายละเอียดฝั่ง Backend (Client API)
หน้านี้เรียก API ฝั่ง admin และ line ไม่ได้เรียก client-api โดยตรง แต่สิ่งที่หน้านี้กำลังตรวจ — "บัญชี LINE นี้ผูกกับ OA นี้แล้วหรือยัง" — คือคำถามเดียวกับที่ client-api ต้องตอบให้ได้ ในทุก request ส่วนนี้จึงอธิบายว่า client-api ตัดสินเรื่องนี้อย่างไร ซึ่งเป็นบริบทที่ช่วยให้เข้าใจว่า ทำไมผลการเชื่อมบัญชีถึงสำคัญ
client-api ไม่มี session เป็นของตัวเอง
client-api ไม่มี JWT ของตัวเอง ไม่มี session cookie และไม่มี Authorization header
การยืนยันตัวตนทั้งหมดมาจาก LIFF token ที่หน้าเว็บแนบมาเป็น header x-liff-token
(LINE ID token) และ/หรือ x-liff-access-token (LINE access token) แล้ว service เป็นผู้ไป
ตรวจกับ LINE Platform เอง
channel binding — หัวใจของความปลอดภัย
token ที่ valid เพียงอย่างเดียวพิสูจน์แค่ว่า "มีบัญชี LINE บัญชีหนึ่งอยู่" ไม่ได้พิสูจน์ว่า
"บัญชีนี้เป็นของ tenant นี้" ใครก็สร้าง LINE Login channel ของตัวเองแล้ว mint token ได้
ดังนั้น flow ที่ปลอดภัยต้องผูก token กับ LINE Login channel ของ OA ที่ resolve จาก
:hash ในเส้นทางเสียก่อน แล้วจึงยอมให้ token นั้นมีความหมาย
นี่คือเหตุผลเดียวกับที่หน้านี้ ล้างเซสชันเมื่อ webhook key เปลี่ยน — ตัวตนที่ค้างจาก OA ก่อนหน้าไม่มีความหมายกับ OA ใหม่ และการเอามาใช้ต่อจะให้ผลการตรวจที่ผิด
3 เส้นทางการ verify ที่มีอยู่จริงใน client-api
| เส้นทาง | ใครใช้ | ผูก channel ไหม |
|---|---|---|
VerifyAndGetLineUser | เมนูสาธารณะ, เนื้อหาสาธารณะ, ลิงก์ติดตาม, ระบบฟอร์ม | ผูก (verify ID token กับ channel ของ OA) |
| ladder แบบ bind channel | บอร์ดประกาศ, บัตรสะสมแต้ม | ผูกเข้มที่สุด |
| verify access token ตรงๆ | จองนัดหมาย, ติดตามการเพิ่มเพื่อน | ไม่ผูก (คงพฤติกรรมของระบบเดิม) |
- เส้นทางแรก verify กับ primary channel ก่อน แล้วจึง retry ด้วย form channel ถ้า OA
ตั้งค่า LIFF ของฟอร์มไว้เป็นคนละ channel; ไม่พบ OA ที่ active → 401
"Invalid channel"; OA ที่ยังไม่ได้ตั้ง LINE Login → 401 พร้อมข้อความบอกว่ายังไม่ได้ตั้งค่า - เส้นทาง ladder ไล่ verify ID token ทีละ channel ใน binding ก่อน ถ้ายังไม่ได้จึงลองเส้นทาง access token โดย introspect ก่อนเสมอ แล้วบังคับว่า client id ที่ได้ต้องอยู่ใน binding ถ้าไม่อยู่ก็ปฏิเสธทันที ไม่ยอมเรียกโปรไฟล์ต่อ — และ client id ที่ว่างไม่ถือว่า "ไม่มีข้อจำกัด" คือไม่เคยผ่าน
- ทุกความล้มเหลวของ ladder คืน 401 ข้อความเดียวกัน คือ token ไม่ถูกต้องหรือหมดอายุ แยกไม่ออกว่า "token ผิด" หรือ "channel ผิด"
การสร้างผู้ใช้อัตโนมัติ (auto-provision guest)
เมื่อ verify ผ่านแต่ยังไม่มีแถว line_user สำหรับคู่ (ผู้ใช้, OA) นั้น ระบบจะ สร้างแถว guest
ให้ทันที โดยตั้งชื่อจาก claim ในโปรไฟล์ (ไม่มีก็ใช้ค่า Guest), ภาษาเป็นไทย, สถานะการติดตาม
เป็น "เป็นเพื่อนแล้ว" และชนิดผู้ใช้เป็น guest — นี่คือเหตุผลที่หลายฟีเจอร์ทำงานได้ทันทีกับคนที่
เพิ่งเปิด LIFF ครั้งแรก โดยไม่ต้องมีขั้นตอนลงทะเบียน
การตรวจสิทธิ์เข้าถึงเนื้อหาที่จำกัด audience
สำหรับเนื้อหาที่จำกัดกลุ่มผู้ชม มีฟังก์ชันแยกที่ต่างจากด้านบน 2 จุดโดยเจตนา: ไม่มี form-channel fallback (ใช้ primary channel เท่านั้น) และตอบ 403 ไม่ใช่ 401 เพราะเป็นเรื่องสิทธิ์ ไม่ใช่เรื่องตัวตน กฎการตรวจคือ:
- เนื้อหาที่ไม่ระบุ audience → ทุกคนผ่าน (public)
- เนื้อหาที่ระบุ audience แต่ผู้ใช้ไม่มี audience เลย → 403
- มีทั้งคู่ → ผ่านถ้ามี audience ทับกันอย่างน้อย 1 ตัว
audience_idsของผู้ใช้รับได้ทั้งรูปแบบรายการตัวเลขและรายการอ็อบเจ็กต์ที่มี keyid; element ที่เป็นอ็อบเจ็กต์แต่ไม่มี key นั้นถูก ทิ้ง ไม่ถูก coerce เป็น 0 ซึ่งจะไปทับ audience id 0 โดยบังเอิญ
การ degrade อย่างนุ่มนวล
บาง endpoint ตั้งใจ "กลืน" ความล้มเหลวของการ verify แทนที่จะตอบ error: เมนูสาธารณะและ รายการลิงก์เนื้อหาจะแสดงเฉพาะรายการที่เป็น public, ลิงก์ติดตามการคลิกบันทึกเป็นแบบไม่ระบุตัวตน และ endpoint ที่ถามว่า "เคยส่งฟอร์มนี้ไหม" ตอบว่ายังไม่เคย — ทั้งหมดนี้เป็นการเลือกโดยเจตนา ว่าหน้าจอต้องไม่พังเพราะการระบุตัวตนไม่สำเร็จ