Skip to main content

การตรวจสิทธิ์ 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)

  1. โหลด line_oa ที่มีเงื่อนไข status='active' AND deleted_date IS NULL หากไม่พบแถวจะตอบ 401 พร้อมข้อความ Invalid channel
  2. หากไม่มี line_login_info.channelId จะตอบ 401 LINE Login not configured for this channel
  3. verify ID token กับ primary channel ก่อน หากไม่ผ่านและมี formLiffId ที่ให้ channel ต่างจาก primary (โดยตัด substring ก่อนเครื่องหมาย - ตัวแรก) จึงจะ retry ด้วย form channel แต่ถ้าไม่มีก็คืน error ของ primary
  4. ค้นหา line_user ตามคู่ (user_id, line_oa_id) หากไม่มีจะ auto-provision guest ทันที โดยตั้งค่า display_name จาก claim name หรือใช้ Guest User, language='th', follow='yes', user_type='guest' และ last_activity_status='active'
  5. คืนค่าเป็น {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 และไม่มี key id จะถูกทิ้งไป ไม่ถูก coerce เป็น 0

เส้นทางที่ 2 — ladder แบบ bind channel

ใช้โดย bulletin และ loyalty ผ่าน resolveUserID(ctx, verifier, channelBinding, liffToken, accessToken) ซึ่งอยู่ใน bulletin/accessctx.go และ loyalty/auth.go โดยจงใจเขียนซ้ำกันเพื่อไม่ให้ import ข้ามโดเมน

  1. ไม่มี token ทั้งสองตัว จะตอบ 401 No authentication token provided
  2. OA ไม่มี primary channel จะตอบ 401 (fail closed เพราะไม่มีอะไรให้ผูก)
  3. หากมี x-liff-token จะเรียก VerifyIDToken ไล่ทีละ channel ใน binding จาก primary ไป form
  4. หากยังไม่ผ่านจะลอง access-token path โดยลองกับ accessToken แล้วตามด้วย liffToken (เพราะ client บางตัวส่ง access token มาใน header x-liff-token) ขั้นตอนคือ introspect ก่อน ผ่าน GET /oauth2/v2.1/verify?access_token=... แล้วบังคับว่า client_id ต้องอยู่ใน binding ถ้าไม่อยู่จะปฏิเสธทันที เมื่อผ่านแล้วจึงเรียก /v2/profile
  5. ทุกกรณีที่ล้มเหลวคืน 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.goNew(verifier, oaRepo, userRepo), VerifyAndGetLineUser, VerifyContentAccess, CheckAudienceAccess, resolveOrCreateUser, formChannelID
internal/linehttp/linehttp.goVerifyIDToken(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.goresolveUserID, ChannelBinding, channelBindingOf, verifyIDTokenBound, verifyAccessTokenBound
internal/loyalty/auth.goสำเนาของ ladder เดียวกันสำหรับโดเมน loyalty
internal/lineoa/repository.goFindActiveByID, FindByHash
internal/lineuser/repository.goFindByUserIDAndOA, InsertGuest, UpdateProfileByUserID

Header ที่เกี่ยวข้อง: x-liff-token และ x-liff-access-token

จุดเชื่อมต่อกับ Service อื่น

  • ตาราง line_oa (คอลัมน์ jsonb line_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