การตรวจสิทธิ์ LIFF Token
ภาพรวม
client-api ไม่มี JWT ของตัวเอง ไม่มี session cookie และไม่ใช้ Authorization header การยืนยันตัวตนทั้งหมดมาจาก LIFF token ที่ client-web แนบมาในรูปของ header x-liff-token (LINE ID token) และ/หรือ x-liff-access-token (LINE access token) โดย service นี้เป็นผู้ตรวจสอบ token กับ LINE Platform ด้วยตัวเอง
หัวใจของความปลอดภัยคือ channel binding: token ที่ valid เพียงอย่างเดียวพิสูจน์ได้แค่ว่ามีบัญชี LINE บัญชีหนึ่งอยู่จริง แต่ไม่ได้พิสูจน์ว่าบัญชีนั้นเป็นของ tenant ที่กำลังเรียกใช้งาน เพราะใครก็สามารถสร้าง LINE Login channel ของตัวเองแล้ว mint token ออกมาได้ ดังนั้นทุก flow จึงต้องผูก token เข้ากับ LINE Login channel ของ OA ที่ resolve จาก :hash หรือ :token ในเส้นทางเสียก่อน
Business Flow
เส้นทางที่ 1 — liff.Service
ใช้โดย menu-builder, public-content, tracking-redirect และฟอร์มต่าง ๆ ผ่าน VerifyAndGetLineUser(liffToken, lineOaID)
- โหลด
line_oaที่มีเงื่อนไขstatus='active' AND deleted_date IS NULLหากไม่พบแถวจะตอบ 401 พร้อมข้อความInvalid channel - หากไม่มี
line_login_info.channelIdจะตอบ 401LINE Login not configured for this channel - verify ID token กับ primary channel ก่อน หากไม่ผ่านและมี
formLiffIdที่ให้ channel ต่างจาก primary (โดยตัด substring ก่อนเครื่องหมาย-ตัวแรก) จึงจะ retry ด้วย form channel แต่ถ้าไม่มีก็คืน error ของ primary - ค้นหา
line_userตามคู่(user_id, line_oa_id)หากไม่มีจะ auto-provision guest ทันที โดยตั้งค่าdisplay_nameจาก claimnameหรือใช้Guest User,language='th',follow='yes',user_type='guest'และlast_activity_status='active' - คืนค่าเป็น
{userId, lineUser}
ส่วน VerifyContentAccess(liffToken, contentLineOaID, contentAudienceIDs) ใช้กับเนื้อหาที่จำกัด audience ต่างจากเส้นทางด้านบน 2 จุดโดยเจตนา คือ ไม่มี form-channel fallback (ใช้ primary เท่านั้น) และ error เป็น 403 ไม่ใช่ 401 จากนั้นจะตรวจ audience overlap ด้วย CheckAudienceAccess ตามกฎ:
- content ที่ไม่มี audience ถือเป็น public ผ่านได้ทุกคน
- content มี audience แต่ user ไม่มี จะตอบ 403
You do not have access to this content - มีทั้งคู่จะผ่านเมื่อมี id ซ้อนกันอย่างน้อย 1 ตัว (OR overlap)
line_user.audience_idsรองรับได้ทั้งรูปแบบ[1,2]และ[{"id":1}]โดย element ที่เป็น object และไม่มี keyidจะถูกทิ้งไป ไม่ถูก coerce เป็น 0
เส้นทางที่ 2 — ladder แบบ bind channel
ใช้โดย bulletin และ loyalty ผ่าน resolveUserID(ctx, verifier, channelBinding, liffToken, accessToken) ซึ่งอยู่ใน bulletin/accessctx.go และ loyalty/auth.go โดยจงใจเขียนซ้ำกันเพื่อไม่ให้ import ข้ามโดเมน
- ไม่มี token ทั้งสองตัว จะตอบ 401
No authentication token provided - OA ไม่มี primary channel จะตอบ 401 (fail closed เพราะไม่มีอะไรให้ผูก)
- หากมี
x-liff-tokenจะเรียกVerifyIDTokenไล่ทีละ channel ใน binding จาก primary ไป form - หากยังไม่ผ่านจะลอง access-token path โดยลองกับ
accessTokenแล้วตามด้วยliffToken(เพราะ client บางตัวส่ง access token มาใน headerx-liff-token) ขั้นตอนคือ introspect ก่อน ผ่านGET /oauth2/v2.1/verify?access_token=...แล้วบังคับว่าclient_idต้องอยู่ใน binding ถ้าไม่อยู่จะปฏิเสธทันที เมื่อผ่านแล้วจึงเรียก/v2/profile - ทุกกรณีที่ล้มเหลวคืน 401 ด้วยข้อความเดียวกันคือ
Invalid or expired LIFF tokenเพื่อไม่ให้แยกออกว่าเป็นเพราะ token ผิดหรือ channel ผิด
ทั้งนี้ client_id ที่ว่างเปล่าไม่ถือว่าเป็น "ไม่มีข้อจำกัด" และจะไม่ผ่านเด็ดขาด
เส้นทางที่ 3 — verify access token โดยตรง
- appointment: ถ้ามี
liffTokenจะเรียกverifyAccessTokenด้วยaccessTokenหรือliffTokenแล้ว catch เป็นverifyAccessToken(liffToken)ถ้ามีเฉพาะaccessTokenก็เรียกด้วยค่านั้น นอกนั้นตอบ 401 โดยเส้นทางนี้ ไม่มี channel binding เพื่อ parity กับ source เดิม - friend-track: ถ้ามี
x-liff-tokenจะเรียกVerifyIDTokenกับ primary channel ของ OA โดยตรง (ต้องมี channel ไม่เช่นนั้นตอบ 401) ถ้าไม่มีจึงใช้x-liff-access-tokenผ่านVerifyAccessToken
การ degrade อย่างนุ่มนวล
บาง endpoint ตั้งใจกลืนความล้มเหลวของการ verify แทนที่จะโยน error ได้แก่ menu-builder และ content-link listing ที่จะแสดงเฉพาะ item ซึ่งเป็น public, tracking-redirect ที่บันทึกคลิกแบบ anonymous และ form-builder/:hash/is-submitted ที่คืนค่า false
ไฟล์และฟังก์ชันหลัก
feature นี้ไม่มี route ของตัวเอง แต่เป็น service กลางที่ทุกโดเมนเรียกใช้
| ไฟล์ | ฟังก์ชันหลัก |
|---|---|
internal/liff/liff.go | New(verifier, oaRepo, userRepo), VerifyAndGetLineUser, VerifyContentAccess, CheckAudienceAccess, resolveOrCreateUser, formChannelID |
internal/linehttp/linehttp.go | VerifyIDToken(idToken, channelID) เรียก POST /oauth2/v2.1/verify, VerifyAccessToken เรียก GET https://api.line.me/v2/profile (URL hardcode ตาม source), VerifyAccessTokenChannel เรียก GET /oauth2/v2.1/verify?access_token= |
internal/bulletin/accessctx.go | resolveUserID, ChannelBinding, channelBindingOf, verifyIDTokenBound, verifyAccessTokenBound |
internal/loyalty/auth.go | สำเนาของ ladder เดียวกันสำหรับโดเมน loyalty |
internal/lineoa/repository.go | FindActiveByID, FindByHash |
internal/lineuser/repository.go | FindByUserIDAndOA, InsertGuest, UpdateProfileByUserID |
Header ที่เกี่ยวข้อง: x-liff-token และ x-liff-access-token
จุดเชื่อมต่อกับ Service อื่น
- ตาราง
line_oa(คอลัมน์ jsonbline_login_infoที่มีchannelId,lineLiffId,formLiffId) และตารางline_user(audience_ids,user_type,status,custom_attribute) - LINE Platform ได้แก่
/oauth2/v2.1/verify(ทั้ง POST เพื่อ verify id_token และ GET เพื่อ introspect access_token) และ/v2/profileโดย timeout มาจากLINE_API_TIMEOUTและ base URL จากLINE_API_ENDPOINT_URL - ถูกใช้โดยแทบทุก feature เช่น โหลดโครงฟอร์ม, ส่งคำตอบฟอร์ม, menu-builder, content viewer, tracking-redirect, bulletin, loyalty, appointment และ friend-track
- ฝั่ง client-web ที่เกี่ยวข้องคือ feature
liff-authentication(เว็บทำliff.init()และ login แล้วส่ง token มาให้) รวมถึงverify-line-login