Security Uplift · A — Body signature
ติดตั้งการตรวจลายเซ็นของผู้ใช้ในทุก request: DI, pipeline, appsettings, การเลือกเส้นด้วย [RequireClientSignature] และวิธีตรวจว่าติดตั้งสำเร็จ
อัปเดต: 2026-09-07
📘 แนวคิดเบื้องหลังกลไกนี้ → Body Signature 101 📕 สิ่งที่พังได้ในแต่ละขั้น → กับดัก A 📗 ภาพรวมทั้ง 6 กลไก · กฎ opt-in · การเปิดเป็นราย env → ติดตั้งใน service ของคุณ
A. Body signature — ติดตั้ง 5 ขั้น
ทุก service ต้องทำ · ไม่ต้องแตะ KeyVault
ทั้งเส้นทำงานยังไง
sequenceDiagram
autonumber
participant B as Browser (auth-sdk)
participant G as APIM facade
participant M as UseClientSignatureVerification
participant R as Redis
participant H as Handler
B->>B: ประกอบ stringToSign = v1 + timestamp + METHOD + path + query + base64(sha256(body))
B->>G: request พร้อม X-Signature และ X-Timestamp
G->>G: cookie ผ่าน resolve-session เป็น Bearer แล้วตัด route prefix ทิ้ง
G->>M: forward — path ที่ service เห็นคือ path ที่ต้องถูกเซ็น
Note over M: UseAuthentication · UseRedisUserInfo · UseAuthorization รันไปก่อนหน้านี้แล้ว
alt ClientSignature Enabled = false
M->>H: ผ่านทันที ไม่ตรวจอะไรเลย
else endpoint ไม่อยู่ใน Coverage
M->>H: ผ่าน — OptIn และไม่ได้แปะ RequireClientSignature
else endpoint อยู่ใน scope
M->>M: ไม่มี X-Signature หรือ X-Timestamp — SIG_MISSING_HEADER
M->>M: timestamp parse ไม่ได้ หรือ skew เกิน 300 วินาที — SIG_TIMESTAMP_INVALID
M->>M: ลายเซ็น parse ไม่ได้ — SIG_MALFORMED
M->>M: ไม่มี oid ใน ClaimsPrincipal — SIG_KEY_NOT_FOUND
M->>R: อ่าน public key ของ oid ที่ prefix จาก KeyRegistryPrefix
alt Redis ตอบไม่ได้
R--xM: SIG_KEY_STORE_UNAVAILABLE
else ไม่มีกุญแจของผู้ใช้คนนี้
R-->>M: null — SIG_KEY_NOT_FOUND
else มีกุญแจ
R-->>M: public key ECDSA P-256
M->>M: body ต้องอ่านซ้ำได้ ไม่งั้น SIG_BODY_UNREADABLE
M->>M: ประกอบ stringToSign ฝั่ง server เอง แล้วเทียบลายเซ็น — ไม่ตรง = SIG_INVALID
M->>R: จดลายนิ้วมือของคำขอ TTL 600 วินาที
alt เคยเห็นลายนิ้วมือนี้แล้ว
R-->>M: SIG_REPLAY
else ยังไม่เคยเห็น
R-->>M: จดสำเร็จ
M->>R: ต่ออายุกุญแจของผู้ใช้อีก 7 วัน แบบไม่รอผล
M->>H: ปล่อยเข้า handler
end
end
end
Note over M,H: ทุกทางที่ไม่ผ่านเขียน log หนึ่งบรรทัด [ClientSignature] code oid path skew mode<br/>Mode LogOnly ปล่อยเข้า handler ต่อ · Mode Enforce ตอบ 401 ProblemDetails
1. package
<PackageReference Include="SupApp_util_lib" Version="10.30.0" />
📕 กับดัก A-1 — ข้อกำหนด TFM · restore ไม่ผ่านที่ feed · สิ่งที่ต้องทำเพิ่มถ้า service นี้เปิด A/E/F อยู่แล้วบนเวอร์ชันเก่า
2. DI — 2 บรรทัด
🔴 หาไฟล์ของตัวเองก่อน อย่าเปิด Program.cs แล้วแปะ — หลาย service ไม่ได้ wire DI/pipeline ใน Program.cs แต่มี extension ของตัวเอง (AddAppSecurity() / UseAppPipeline() ใน {Service}01.API/Extensions/) แล้ว Program.cs เรียกตัวนั้นบรรทัดเดียว · แปะผิดที่ = ลำดับ middleware เพี้ยนหรือ DI ซ้ำ
# หาว่า DI ของ security อยู่ไฟล์ไหน
grep -rn "AddAuthentication\|AddAuthorization(" src/{Service}01.API/
# หาว่า pipeline อยู่ไฟล์ไหน และ UseAuthorization() อยู่บรรทัดไหน
grep -rn "UseAuthorization()" src/{Service}01.API/
บรรทัดข้างล่างวางในไฟล์ที่ grep ชี้ ไม่ใช่ Program.cs เสมอไป
// อ่านค่า ClientSignature จาก config เข้า options ของ middleware
services.AddClientSignature(o => configuration.GetSection("ClientSignature").Bind(o));
// ตัวอ่าน public key ของผู้ใช้จาก Redis (ที่ Sentinel เขียนไว้ตอนลงทะเบียน) + ตัวกันยิงซ้ำ
services.AddClientSignatureStores(configuration);
3. Pipeline — 1 บรรทัด ต่อท้าย UseAuthorization()
วางต่อจาก UseAuthorization() ที่มีอยู่แล้วในไฟล์ของ service เอง — บล็อกข้างล่างเป็นรูปทั่วไป ไม่ใช่สิ่งที่ต้อง copy ทั้งก้อน
app.UseAuthentication();
app.UseRedisUserInfo();
app.UseAuthorization();
app.UseClientSignatureVerification(); // เพิ่ม
ถ้าจะทำ B ด้วย ให้เพิ่ม app.UsePayloadFieldDecryption(); ต่อท้ายบรรทัดนี้ทันที · D เพิ่ม app.UsePayloadWholeBodyEncryption(); ต่อท้ายอีกที (สลับกับ B ได้ ไม่มีผล) · F วาง app.UseRequestLogCapture(); ไว้ก่อนสุด และ app.UseRequestLogBodyCapture(); ไว้ท้ายสุด · E ไม่มีบรรทัด pipeline เลย มีแต่ DI
flowchart LR
R[UseRequestLogCapture] --> A[UseAuthentication]
A --> B[UseRedisUserInfo]
B --> C[UseAuthorization]
C --> D[UseClientSignatureVerification]
D --> E[UsePayloadFieldDecryption]
E --> G[UsePayloadWholeBodyEncryption]
G --> L[UseRequestLogBodyCapture]
L --> F[Handler]
style D fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332
style E fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332
style G fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332
style R fill:#e9f5ec,stroke:#74c69d,color:#1b4332
style L fill:#e9f5ec,stroke:#74c69d,color:#1b4332
UseRequestLogCapture / UseRequestLogBodyCapture เพิ่มเฉพาะเมื่อทำ F · UsePayloadFieldDecryption เฉพาะ B · UsePayloadWholeBodyEncryption เฉพาะ D
ต้องมี EnableBuffering() อยู่ก่อน middleware นี้ — ไม่มี = 401 ทุก request ที่เซ็น
🔴 ถ้า service มี EnableBuffering() ของตัวเองอยู่แล้ว ตัวนั้นชนะ UseRequestLogCapture() ของ F — EnableBuffering ของ ASP.NET Core ทำงานเฉพาะเมื่อ Request.Body.CanSeek == false ⇒ ตัวที่รันก่อนคือตัวที่กำหนด threshold จริง ตัวที่รันทีหลังกลายเป็น no-op เงียบ ๆ · วาง UseRequestLogCapture() ไว้หลัง EnableBuffering() เดิม = RequestLog:RequestBufferThresholdBytes เป็น key ตาย ตั้งเท่าไหร่ก็ไม่มีผล (ได้ threshold ตั้งต้นของ framework แทน) · อย่าถอด EnableBuffering() เดิมออก เพื่อ “ให้ F ตั้งค่าได้” — F เรียกให้เฉพาะตอน RequestLog:Enabled=true ปิด flag เมื่อไหร่ A พังทันที
📕 กับดัก A-3 — วิธี grep หา
EnableBufferingและเติมเอง · เกณฑ์ตำแหน่งที่แท้จริง · ทำไมสลับกับ B ไม่ได้ · กับดัก F — ลำดับของEnableBufferingสองตัว
4. appsettings
ใส่ใน base appsettings.json (ไฟล์นี้ไม่ถูก IaC overlay ทับ)
"ClientSignature": {
// สวิตช์หลักของ middleware ตรวจลายเซ็น
// false = ไม่ตรวจอะไรเลย request ผ่านตามปกติ (ค่าตั้งต้น)
// true = เริ่มตรวจ · จะทำอะไรตอนตรวจไม่ผ่าน ขึ้นกับ Mode ข้างล่าง
"Enabled": false,
// ทำอะไรเมื่อลายเซ็นผิดหรือไม่มี — มีผลเมื่อ Enabled = true เท่านั้น
// "LogOnly" = ตรวจแล้วเขียน log ไว้ แต่ยังปล่อยผ่าน
// "Enforce" = ตอบ 401 จริง
"Mode": "LogOnly",
// ตรวจเส้นไหน — ตัวนี้สำคัญเท่า Enabled
// "OptIn" = ตรวจเฉพาะ action ที่แปะ [RequireClientSignature] (ค่าตั้งต้น)
// "RequireAll" = ตรวจทุก action ยกเว้นที่แปะ [SkipClientSignature]
"Coverage": "OptIn",
// ใส่รายละเอียดว่าทำไมลายเซ็นไม่ผ่านลงใน response — เปิดเฉพาะตอน debug บนเครื่องตัวเอง
"DebugResponse": false,
// prefix ของ key ใน Redis ที่ Sentinel ใช้ตอนเก็บกุญแจของผู้ใช้
// ต้องตรงกับที่ Sentinel เขียน ไม่งั้นหากุญแจไม่เจอ = 401
// ไม่ตั้ง key นี้เลย = lib fallback ไปใช้ ServiceIdentity:ServiceName (คนละที่กับที่ Sentinel เขียน)
"KeyRegistryPrefix": "SuperAppPlatform"
}
ไม่ตั้ง Coverage = OptIn ⇒ เปิด Enabled=true แล้วยังไม่มีเส้นไหนถูกตรวจจนกว่าจะแปะ [RequireClientSignature]
🔴 KeyRegistryPrefix ต้องตรงกับที่ Sentinel เขียน — ค่าที่ใช้อยู่คือ SuperAppPlatform (ตรงกันทั้ง SentinelGateway01.API/appsettings.json และ user-service) · ไม่ตั้ง key นี้ = lib fallback ไปใช้ ServiceIdentity:ServiceName ซึ่งคนละที่กับที่ Sentinel เขียน ⇒ หากุญแจไม่เจอ = 401 ทุก request ที่เซ็น
📕 กับดัก A-4 — ต้อง restart ถึงจะมีผล ·
KeyRegistryPrefixผิด = 401 ทั้งหมด · กติกา parity กับ IaC
5. เลือกเส้น — [RequireClientSignature] ก่อน แล้วค่อยไปถึง RequireAll
ขั้นนี้ทำคนละแบบตาม Coverage ที่ตั้งไว้ อย่าทำงานของอีกโหมด
Coverage = OptIn (ค่าตั้งต้น · โหมดที่ใช้อยู่ตอนนี้)
ไม่ต้องไล่ inventory ทั้ง repo — เส้นที่ไม่แปะอะไรเลยไม่ถูกแตะ ⇒ งานคือ เลือกเส้นที่จะทดสอบ แล้วแปะ [RequireClientSignature] เฉพาะเส้นนั้น
[HttpGet("notice")]
[RequireClientSignature] // ตรวจลายเซ็นเฉพาะเส้นนี้
public async Task<IActionResult> GetNotice([FromQuery] string id, CancellationToken ct) => ...
เกณฑ์เลือกเส้นแรก: read-only · ไม่ผูกกับผู้ใช้คนใดคนหนึ่ง · ไม่เขียนอะไรลงระบบ ⇒ ทดสอบพังก็ไม่เสียข้อมูล · อย่าเริ่มที่ write path
เปิด Enabled=true + Mode=LogOnly แล้วยิงเส้นนั้นด้วย request ที่เซ็นจริง → อ่าน log → Enforce เฉพาะเมื่อผ่านแล้ว · เส้นอื่นทั้ง service ยังเหมือนเดิมทุกอย่างตลอดกระบวนการนี้
Coverage = RequireAll (ปลายทาง — ทำเมื่อจะบังคับทั้ง service)
โหมดนี้ตรวจ ทุก action อัตโนมัติ ⇒ ต้องไล่ติด [SkipClientSignature] ให้ครบทุกเส้นที่ตรวจไม่ได้ ก่อนสลับ · ทุก action ตกอยู่ใน 4 แบบนี้แบบใดแบบหนึ่ง
| แบบ | attribute ที่ต้องมี | ใช้กับ |
|---|---|---|
| 1. ไม่ต้อง login | [AllowAnonymous] + [SkipClientSignature] | ข้อมูลทั่วไปที่เว็บดึงก่อน login · job/batch ข้างในมาเรียก · LIFF |
| 2. login แต่ไม่ตรวจลายเซ็น | [Authorize] + [SkipClientSignature] | hub เรียกต่อหลังบ้าน (เช่น orchestrator ยิงต่อไป service อื่นโดยส่ง JWT ของผู้ใช้ไปด้วย) — หลังบ้านตรวจแค่ authorization ก็พอ |
| 3. login และตรวจลายเซ็น | [Authorize] เฉย ๆ | client ยิงตรงเข้า service นี้ — ค่าตั้งต้นของโหมดนี้ ไม่ต้องใส่อะไรเพิ่ม |
| 4. ไม่ login แต่ตรวจลายเซ็น | ❌ ยังไม่รองรับ | third party ข้างนอกมาเรียกเรา |
// แบบ 1
[HttpGet("active-ids")]
[AllowAnonymous]
[SkipClientSignature] // s2s: job ภายในเรียก ไม่มี user JWT ติดมาด้วย
public async Task<IActionResult> GetActiveIds(CancellationToken ct) => ...
// แบบ 2
[HttpPost("dispatch")]
[Authorize]
[SkipClientSignature] // orchestrator ส่ง JWT ของผู้ใช้ต่อมา แต่เซ็นแทนผู้ใช้ไม่ได้
public async Task<IActionResult> Dispatch(DispatchRequest req, CancellationToken ct) => ...
endpoint ที่ไม่มีทั้ง [Authorize] และ [AllowAnonymous] ตกเป็นแบบ 3 โดยอัตโนมัติ — นี่คือกองที่ทำให้พังจริงตอนสลับ RequireAll + Enforce ไล่ให้ครบทุกตัว · ตารางนี้ทั้งตารางไม่ใช้กับ OptIn — ใน OptIn เส้นแบบ 3 ไม่ถูกตรวจจนกว่าจะแปะ [RequireClientSignature]
route เดียวที่รับทั้ง client และ s2s แยกไม่ได้ด้วย attribute — ต้อง เพิ่ม route ใหม่ให้ s2s ชี้เข้า handler เดิม ติด [SkipClientSignature] เฉพาะเส้นใหม่ แล้วปิดไม่ให้เรียกจากภายนอก ห้ามติดทับเส้นเดิม
📕 กับดัก A-5 — attribute ไม่สืบทอด · กองที่ 3 คือตัวที่ทำให้พังจริง · ขั้นตอนเมื่อตัดสินเองไม่ได้ · แยก route ให้ s2s ต้องทำคู่กับอะไร · เส้นก่อน login ไม่ใช่ s2s
เช็คว่า A ติดตั้งสำเร็จ
| เช็ค | ผลที่ถูก |
|---|---|
| boot | ไม่มี DI error · /health = 200 |
| ปิด flag ทั้งหมดแล้วรัน test เดิม | ผ่านเท่าเดิมทุกตัว — ถ้าตกแปลว่า wire ผิด ไม่ใช่เรื่อง flag |
เปิด LogOnly แล้วยิงของเดิม | ทุกอย่างยังผ่าน แต่มี log บอกว่าเส้นไหนลายเซ็นไม่ผ่าน |
Coverage=OptIn · ยิงเส้นที่ไม่ได้แปะ [RequireClientSignature] โดยไม่เซ็น ตอน Enforce | 200 ตามปกติ — ถ้าได้ 401 แปลว่าตั้ง Coverage ผิดหรือแปะ attribute ผิดเส้น |
ยิงเส้นที่แปะ [RequireClientSignature] โดยไม่เซ็น ตอน Mode=Enforce | 401 SIG_MISSING_HEADER |
| ยิงซ้ำด้วยลายเซ็นเดิม | 401 (replay ถูกกัน) |
| กุญแจของผู้ใช้ใน Redis | มี key SuperAppPlatform:cache:client-key:<oid> หลังเบราว์เซอร์ลงทะเบียนแล้ว |
📕 กับดักตอนเช็ค — service ที่ config ว่างต้องต่อ KV ก่อน · เอา request ที่เซ็นจริงมาจากไหน
ลำดับที่แนะนำสำหรับ A
- bump package · build ให้ผ่าน ยังไม่เปิด flag อะไรเลย
- wire DI + pipeline ในไฟล์ของ service เอง · boot · รัน test เดิมต้องผ่านเท่าเดิม
- ใส่ config ครบทุก key รวม
"Coverage": "OptIn"โดยEnabledยังเป็นfalse - แปะ
[RequireClientSignature]เส้นเดียว ที่ read-only แล้วเปิดEnabled=true+Mode=LogOnlyบน dev → ยิงเส้นนั้นด้วย request ที่เซ็นจริง → อ่าน log - ผ่านแล้วค่อย
Mode=Enforceบนเส้นนั้น → ทยอยแปะเส้นถัดไป - ปลายทาง
Coverage=RequireAll— ทำเมื่อไล่[SkipClientSignature]ครบทั้ง repo แล้วเท่านั้น (ดูขั้น 5 หัวข้อRequireAll) เป็นงานแยกรอบ ไม่ใช่ขั้นตอนต่อเนื่องจากข้อ 5 - B / C / D / E / F ทำแยกทีหลัง — คนละสวิตช์ คนละความเสี่ยง ไม่มีตัวไหนบังคับให้ทำพร้อมกัน (ยกเว้น D ที่ต้องมี B ก่อน)