Skip to main content

การยืนยันตัวตนด้วย API Key (3 Header)

ภาพรวม​

webhook-go ไม่ใช้ JWT เลย ซึ่งต่างจาก cms-api และ client-api ระบบยืนยันตัวตนที่นี่มีเพียงรูปแบบเดียว คือ api-key แบบ 3 header สำหรับ integration ประเภท B2B

Headerความหมาย
x-client-idรหัสระบุ client อ้างอิงตาราง api_client
x-client-secretsecret ของ client เปรียบเทียบแบบ plaintext ตรง ๆ ใน SQL
x-api-keykey ที่ระบุขอบเขตการเข้าถึง อ้างอิงตาราง api_key ซึ่งผูกกับ line_oa_id และ organization_id

แนวคิดคือการแยก "คุณคือใคร" (client id และ secret) ออกจาก "คุณเข้าถึงอะไรได้" (api key) client หนึ่งรายสามารถถือ api_key ได้หลายใบ โดยแต่ละใบผูกกับ LINE OA คนละตัว

route ที่ถูกป้องกันด้วยกลไกนี้มีเพียง /api/audience และ /api/line-oa/verify รวม 4 route ส่วน route ที่รับ event จาก LINE และ Chatwoot ทั้งหมดเปิดสาธารณะโดยเจตนา

Business Flow​

ขั้น middleware (APIKeyAuth)​

  1. อ่านค่าจาก 3 header หากขาดตัวใดตัวหนึ่งจะตอบ 401 "Missing credentials in headers" และหยุดทันที
  2. หาก repository ไม่ถูก wire (กรณี boot โดยไม่มีฐานข้อมูล) จะตอบ 500 เป็นการ fail closed ไม่ปล่อยผ่าน
  3. เรียก ValidateClient ด้วย SELECT ... FROM api_client WHERE client_id=$1 AND client_secret=$2 AND status='active'
    • error จริงจากฐานข้อมูลต้องตอบ 500 ห้าม collapse เป็น 401 ตามกฎ "ห้ามกลืน error"
    • ไม่พบ row จะตอบ 401 "Invalid client credentials"
  4. เมื่อสำเร็จ จะเก็บค่าลง gin context ได้แก่ apiClientId (เป็น int64 จาก api_client.id) และ apiKey (สตริงดิบจาก header) ซึ่งทำหน้าที่แทน ClsService ของ NestJS เดิม
  5. เรียก c.Next() เพื่อส่งต่อให้ handler

ขั้น service (resolve scope)​

  1. handler ดึงค่าออกจาก context ด้วย middleware.ApiClientID(c) และ middleware.APIKey(c) หากไม่มีค่าแสดงว่า guard ถูก bypass จะตอบ 401
  2. service เรียก apikey.Repository.GetByClientAndKey ด้วย SELECT ... FROM api_key WHERE api_client_id=$1 AND key=$2 AND status='active'
  3. ผลลัพธ์ที่ได้คือ {id, lineOaId, organizationId} ซึ่งจะถูกใช้เป็น scope ของทุก query ต่อจากนี้

หากไม่พบ row ในขั้นตอนนี้ แต่ละ endpoint จัดการต่างกัน ซึ่งเป็น parity ตามต้นฉบับที่ไม่สม่ำเสมอ GET /audience ตอบ 200 null, PUT ตอบ 500, DELETE ตอบ 400 "Step 1: ..." ส่วน verify จะเดินหน้าต่อแล้วไปจบที่ 404

ข้อสังเกตด้านความปลอดภัย​

  • client_secret ถูกเก็บและเปรียบเทียบเป็น plaintext ในฐานข้อมูล ซึ่งเป็นการทำตามต้นฉบับ โดยเจตนา (ไฟล์ internal/middleware/apikey.go มี comment กำกับว่า "plaintext compare — match source; no hashing") แพ็กเกจ internal/middleware/apikey_test.go และ helper bcrypt ของ template ยังคงอยู่ แต่ไม่ได้ถูกใช้ในเส้นทางจริง
  • ไม่มี rate limit เฉพาะสำหรับกรณี auth ล้มเหลว มีเพียง rate limit รวมต่อ IP
  • ไม่มีกลไก expiry ของ api key ในโค้ดชุดนี้ มีเพียงคอลัมน์ status

ไฟล์และฟังก์ชันหลัก​

ไฟล์ของสำคัญ
internal/middleware/apikey.goAPIKeyAuth(v ClientValidator) gin.HandlerFunc, ApiClientID(c), APIKey(c), const CtxKeyAPIClientID / CtxKeyAPIKey, interface ClientValidator
internal/server/server.go(*Deps).APIKeyAuth() — ผูก middleware เข้ากับ deps.APIClient
internal/apiclient/repository.goValidateClient(ctx, clientID, clientSecret)
internal/apiclient/entity.gostruct ApiClient
internal/apikey/repository.goGetByClientAndKey(ctx, apiClientID, key)
internal/apikey/entity.gostruct ApiKey (ID, LineOaID, OrganizationID และอื่น ๆ), const StatusActive
cmd/api/main.goสร้าง repository ทั้งสองตัวเมื่อ a.SQL != nil
cmd/api/wiring_test.goยืนยันว่า 4 route ถูก guard และ 3 route เปิดสาธารณะ

สรุปการป้องกันรายเส้นทาง​

RouteAuth
GET /api, GET /api/healthไม่มี
POST /api/line/:id, POST /api/Line/:idไม่มี
POST /api/trackingไม่มี
POST /api/mbox/callback/:oaHashไม่มี
GET /api/audienceapi-key
PUT /api/audience/:idapi-key
DELETE /api/audience/:idapi-key
POST /api/line-oa/verifyapi-key

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