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 อื่น