Private Docs

BE 3 — API Key: กันไม่ให้ใครก็ได้เรียก endpoint ระหว่าง service

งานเล็กที่สุดในชุดนี้แต่พลาดกันมากที่สุด — สำรวจจริงพบว่า 10 จาก 11 service ต่อ mechanism ไว้แล้ว แต่มีเพียง 1 service ที่ประตูใช้งานได้จริง · 3 ชั้นที่ต้องครบ และกับดักที่ทำให้ 'ต่อแล้วแต่ไม่ทำงาน'

อัปเดต: 2026-08-23

ใช้กับ service เรียก service เท่านั้น · ห้ามใช้กับ browser — อะไรที่ browser ส่งได้ ผู้ใช้ก็อ่านได้ นั่นไม่ใช่ความลับอีกต่อไป

1. บทเรียนสำคัญที่สุดของหัวข้อนี้

จากการสำรวจจริงทั้ง 11 service ที่ deploy อยู่: 10 service เรียก AddApiKeyAuth(...) แล้ว แต่มีแค่ 1 service ที่ประตูทำงานได้จริง

สาเหตุคือมันมี 3 ชั้น ที่ต้องครบ และการทำครบ 1-2 ชั้นให้ความรู้สึกว่า “มีระบบป้องกันแล้ว” ทั้งที่ยังไม่มี:

ชั้นทำอะไรถ้าขาดชั้นนี้
(ก) AddApiKeyAuth(...) ใน DIลงทะเบียนตัวตรวจไม่มีอะไรตรวจเลย
(ข) แขวน ApiKeyAuthFilter เข้า pipelineทำให้ตัวตรวจถูกเรียกจริงชั้น (ก) เป็นแค่ของประดับ — ไม่มี error ไม่มี log
(ค) มี [RequireApiKey] บน endpoint และ มีคีย์ที่ตั้งค่าไว้จริงทำให้ประตูมีทั้งกลอนและกุญแจถ้าไม่มี attribute = ประตูเปิด · ถ้ามี attribute แต่ ชุดคีย์ว่าง = ประตูปิดตาย ปฏิเสธทุกคน 100%

เคสจริงที่เจอ: 2 service มีประตูที่ทำงานอยู่ แต่ปฏิเสธทุกคำขอ เพราะชุดคีย์ว่างเปล่าในทุกไฟล์ config ที่ตรวจได้ — และไม่มีใครรู้ เพราะไม่เคยมีใครเรียกสำเร็จมาก่อนอยู่แล้ว

2. ต่อสาย 3 ขั้น

// (ก) DI — อ่านจาก config แล้วสลับทิศ dictionary (ดู §3 กับดักที่ 1)
var cfg = configuration.GetSection(ApiKeyAuthConfig.SectionName).Get<ApiKeyAuthConfig>()
          ?? new ApiKeyAuthConfig();

services.AddApiKeyAuth(options =>
{
    if (!string.IsNullOrEmpty(cfg.HeaderName))
        options.HeaderName = cfg.HeaderName;

    // config เก็บเป็น { ชื่อผู้เรียก : คีย์ } แต่ตัวตรวจต้องการ { คีย์ : ชื่อผู้เรียก } → สลับ
    options.ApiKeys = cfg.ApiKeys
        .Where(kvp => !string.IsNullOrEmpty(kvp.Value))
        .ToDictionary(kvp => kvp.Value, kvp => kvp.Key);
});
// (ข) Program.cs — ใน AddControllers(...) แขวน filter ให้ทำงานจริง
options.Filters.Add<ApiKeyAuthFilter>();
// (ค) endpoint ที่ต้องการกุญแจ
[RequireApiKey]
[HttpPost("warm-cache")]
public async Task<IActionResult> WarmCache(...) { ... }

// ถ้าต้องยกเว้นบาง action ภายใต้ controller ที่ประกาศ [RequireApiKey] ไว้ทั้งชุด
[SkipApiKey]
[HttpGet("health")]
public IActionResult Health() => Ok();

filter จะข้ามการตรวจเมื่อ ไม่มี [RequireApiKey] ⇒ การแขวน filter แบบ global จึงปลอดภัย ไม่กระทบเส้นอื่น

3. กับดัก 3 ข้อที่เจอจริง

  1. ทิศของ dictionary — ⚠️ ไม่เหมือนกันทุก service ตรวจของเดิมก่อนใช้ snippet ข้างบน
    • ทิศตามที่ library กำหนด คือ { คีย์ : ชื่อผู้เรียก }ชื่อ property คือตัวคีย์เอง (เป็นความลับ)
    • บาง service (รวมถึงตัวที่ snippet นี้คัดมา) กลับทิศ เป็น { ชื่อผู้เรียก : คีย์ } เพื่อให้อ่านง่าย แล้วสลับกลับตอนต่อสาย
    • เปิดดู config เดิมของ service คุณและของใน KeyVault ก่อน ว่าใช้ทิศไหน แล้วเลือกว่าจะสลับหรือไม่สลับ · ใส่ snippet ผิดทิศ = ปฏิเสธทุกคำขอ โดยไม่มี error ให้เห็น
  2. ชื่อ header ไม่ตรงกัน — ฝั่งส่งกับฝั่งรับตั้งชื่อ header คนละชื่อ (default คือ X-API-Key) เป็นสาเหตุจริงของ service หนึ่งที่ประตูเปิดไม่ได้เลย ยืนยันชื่อ header จากโค้ดทั้ง 2 ฝั่ง อย่าเชื่อเอกสาร
  3. [RequireApiKey] ที่ถูก comment ทิ้ง — พบในโค้ดจริง: endpoint ที่ตั้งใจให้ป้องกัน แต่ attribute ถูกปิดไว้ตอน debug แล้วไม่มีใครเปิดกลับ ให้ grep หา // [RequireApiKey] ด้วย

4. ทำไมต้องเทียบคีย์แบบ “เวลาเท่ากันเสมอ”

== ธรรมดาจะหยุดเทียบทันทีที่เจอตัวอักษรต่างตัวแรก ⇒ เวลาที่ใช้ตอบกลับ บอกใบ้ว่าคีย์ที่เดามาถูกไปกี่ตัว คนร้ายเดาทีละตัวอักษรได้ library จึงเทียบกับ ทุกคีย์ที่ตั้งไว้เสมอ และใช้ฟังก์ชันเทียบแบบเวลาคงที่:

var isValid = false;
foreach (var knownKey in _apiKeys.Keys)
{
    // ไม่ break กลางคัน และไม่ใช้ || แบบ short-circuit — ตั้งใจให้เวลาเท่ากันทุกกรณี
    isValid |= CryptographicOperations.FixedTimeEquals(candidateBytes, knownBytes);
}

ห้ามเขียนเทียบเอง — ใช้ของใน library

5. Config + ที่เก็บค่า

// appsettings.json — โครงเท่านั้น ห้ามใส่ค่าคีย์จริงลง repo
"ApiKeyAuth": {
  "HeaderName": "X-API-Key",
  "ApiKeys": {}
}
keyเก็บที่ไหนหมายเหตุ
ApiKeyAuth:HeaderNameappsettings + IaC ทุก envต้องตรงกับฝั่งผู้เรียกทุกตัว
ApiKeyAuth:ApiKeys:*🔴 KeyVault เท่านั้น (mount เป็น env var)ค่าคีย์คือความลับ · ห้ามอยู่ใน repo หรือใน IaC เป็น plaintext
⚠️ ถ้า service ใช้ทิศตาม library ({คีย์ : ชื่อผู้เรียก}) ชื่อ key ใน config ก็เป็นความลับด้วย — ห้ามเขียนลงเอกสาร/ticket/PR description

🔴 กุญแจดอกเดียวที่หลายคนใช้ร่วมกัน = ถอนสิทธิ์ทีละคนไม่ได้ — พบเคสจริงที่ 3 service ใช้ secret ก้อนเดียวกันภายใต้ชื่อ config คนละชื่อ ผลคือฝั่งรับแยกไม่ออกว่าใครเรียกมา และการหมุนกุญแจ 1 ครั้งกระทบทั้ง 3 service พร้อมกัน ตั้งกุญแจแยกต่อผู้เรียกตั้งแต่แรก

ค่าที่มาจาก KeyVault จะ ทับ appsettings ทุกชั้น — ถ้าแก้ appsettings แล้ว “ไม่มีผล” ให้ไปดูที่ KeyVault ก่อนเป็นที่แรก

6. Acceptance test

[Theory]
[InlineData(null)]              // ไม่ส่ง header
[InlineData("")]                // ส่งแต่ว่าง
[InlineData("wrong-key-value")] // ส่งผิด
public async Task ProtectedEndpoint_WithoutValidKey_Returns401(string? key)
{
    var response = await CallWithApiKey(key);
    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task ProtectedEndpoint_WithValidKey_Succeeds()   // เคสบวก — ต้องมีเสมอ
{
    var response = await CallWithApiKey(TestKeys.Valid);
    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

🔴 ต้องมีเคสบวกเสมอ — ชุดทดสอบที่มีแต่เคสลบจะเขียวสนิทแม้ประตูจะปิดตายปฏิเสธทุกคน ซึ่งเป็นอาการที่เจอจริงมาแล้ว 2 service

7. Checklist

  • ครบทั้ง 3 ชั้น (ก)(ข)(ค) — ตรวจชั้น (ข) ด้วยตาว่า filter ถูกแขวนจริง
  • สลับทิศ dictionary ตอนต่อสาย
  • ชื่อ header ตรงกันทั้งฝั่งส่งและฝั่งรับ (ยืนยันจากโค้ด 2 ฝั่ง)
  • grep หา [RequireApiKey] ที่ถูก comment ทิ้ง
  • ค่าคีย์อยู่ใน KeyVault ไม่ใช่ repo · HeaderName อยู่ใน IaC ครบทุก env
  • มี test ทั้งเคสบวกและเคสลบ