เข้ารหัสรายฟิลด์ — โค้ดจะหน้าตาแบบไหน (เทียบ 2 ทาง)
ตัวอย่างโค้ดจริงของ 2 ทางเลือกที่ต้องตัดสิน: ทำเป็น middleware ให้ถอดอัตโนมัติก่อนเข้า API หรือ คงตัวตรวจลายเซ็นเป็น filter แล้วให้ developer เรียก method ถอดเอง — พร้อมข้อดีข้อเสียที่เห็นจากโค้ดจริง
อัปเดต: 2026-08-27
เข้ารหัสรายฟิลด์ — โค้ดจะหน้าตาแบบไหน
✅ ตัดสินแล้ว 27/08: เลือก ทาง ก — middleware · ขอบเขต UserService เท่านั้น (Owner: “งานยาวไม่เป็นไร เพราะทำแค่ user service”) ตรวจโครงจริงหลังเลือกแล้วพบว่า ถูกกว่าที่ประเมินไว้:
EcdsaClientSignatureVerifierและCanonicalRequestเป็น public อยู่แล้ว ⇒ ไม่ต้องแตะไลบรารีกลางเลย และ ไม่ต้องแก้ DTO (ดูหัวข้อถัดไป)หน้านี้เขียนขึ้นเพื่อตอบคำถามเดียว: “ขอเห็นตัวอย่างก่อนถึงจะตัดสินใจได้” ทั้ง 2 ทางให้ผลลัพธ์ด้านความปลอดภัยเหมือนกันเป๊ะ — ต่างกันที่ ใครเป็นคนสั่งถอด และ ถ้า developer ลืมจะเกิดอะไรขึ้น
สิ่งที่เหมือนกันทั้ง 2 ทาง
หน้าตาคำขอ (เข้ารหัสเฉพาะ field)
POST /api/user-service/v1/users
Content-Type: application/json
{
"email": "somchai@example.com", // ไม่เข้ารหัส — ใช้ค้นหา
"firstNameTH": "สมชาย", // ไม่เข้ารหัส
"phone": "0812345678", // ไม่เข้ารหัส
"birthDate": "eyJhbGciOiJSU0Et…AbC.DeF.GhI.JkL", // ← JWE
"nationality": "eyJhbGciOiJSU0Et…MnO.PqR.StU.VwX", // ← JWE
"mailingAddress": "eyJhbGciOiJSU0Et…YzA.BcD.EfG.HiJ" // ← JWE (ทั้ง object เป็นข้อความเดียว)
}
ยังเป็น JSON ปกติทุกประการ — gateway ยัง route ได้ · log ยังอ่านได้ว่าใครเรียกอะไร · Swagger ยังแสดงหน้าตา request · เห็นแค่ 3 ค่าที่เป็นข้อความยาว ๆ อ่านไม่ออก
ฝั่งหน้าบ้าน — เหมือนกันทั้ง 2 ทาง
@exim/auth-sdk มี method ให้ developer หยิบไปใช้:
import { encryptFields } from '@exim/auth-sdk/payload-encryption';
const body = await encryptFields(form, ['birthDate', 'nationality', 'mailingAddress']);
await this.http.post('/api/user-service/v1/users', body);
// interceptor ลายเซ็นทำงานต่อเอง เซ็นทับ body ที่เข้ารหัสแล้ว — developer ไม่ต้องรู้
ต้องแก้ DTO ไหม — ขึ้นกับทางที่เลือก
✅ แก้ 27/08 หลังตรวจโครงจริง — ข้อความเดิมที่ว่า “ต้องแก้ DTO เหมือนกันทั้ง 2 ทาง” ไม่ถูกต้อง
ทาง ก (ที่เลือกแล้ว) ไม่ต้องแก้ DTO เลยแม้แต่ตัวเดียว — เพราะ middleware ถอดเสร็จก่อน MVC จะแปลง JSON เป็น object · หน้าบ้านเข้ารหัส
JSON.stringify(ค่า)· middleware ถอดแล้วเอา JSON กลับเข้าไปแทนที่ ⇒ MVC เห็นเป็นค่าปกติทุกประการ ⇒BirthDateยังเป็นชนิดวันที่เหมือนเดิม ·MailingAddressยังเป็น object เหมือนเดิม · validator ไม่ต้องแตะที่ต้องเปลี่ยนชนิดเป็นข้อความคือ ทาง ข เท่านั้น (เพราะ MVC แปลง JSON ก่อน แล้วค่อยถอดทีหลัง)
ทาง ข — ต้องเปลี่ยนชนิดก่อน แล้วแปลงกลับหลังถอด
public sealed record CreateUserRequest(
string Email,
string FirstNameTH,
[EncryptedField] string? BirthDate, // เดิม: DateOnly?
[EncryptedField] string? Nationality,
[EncryptedField] string? MailingAddress); // เดิม: CreateUserAddressRequest?
[EncryptedField] เป็นตัวบอกว่า field ไหนต้องถอด — ประกาศที่เดียว ใช้ได้ทั้ง 2 ทาง
ทาง ก — Middleware ถอดให้อัตโนมัติก่อนเข้า API
สิ่งที่ต้องทำครั้งเดียว (ทีมกลาง)
ย้าย ตัวตรวจลายเซ็น จาก action filter มาเป็น middleware ก่อน แล้ววางตัวถอดต่อท้าย
// Program.cs — ลำดับนี้คือหัวใจ ห้ามสลับ
app.UseRouting(); // ต้องมาก่อน เพื่อให้ middleware อ่าน attribute ของ endpoint ได้
app.UseClientSignatureVerification(); // ① ตรวจลายเซ็นจาก "ไบต์ที่วิ่งมาบนสาย"
app.UsePayloadFieldDecryption(); // ② ผ่านแล้วค่อยถอด field ที่ทำเครื่องหมายไว้
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
public sealed class PayloadFieldDecryptionMiddleware(RequestDelegate next, IPayloadCipher cipher)
{
public async Task InvokeAsync(HttpContext ctx)
{
// รู้ว่า endpoint นี้มี field ไหนต้องถอด จาก attribute บน DTO ของ action นั้น
var fields = ctx.GetEndpoint()?.Metadata.GetMetadata<EncryptedFieldMap>();
if (fields is null) { await next(ctx); return; }
ctx.Request.EnableBuffering();
var node = await JsonNode.ParseAsync(ctx.Request.Body);
foreach (var name in fields.Names)
{
if (node?[name] is not JsonValue v) continue;
try
{
node[name] = cipher.Decrypt(v.GetValue<string>()); // ค่าที่ถอดแล้วทับกลับลงไป
}
catch (PayloadCipherException)
{
await ctx.WriteProblem(400, "PayloadDecryptionError"); // fail-closed
return;
}
}
// เขียน body ใหม่ให้ MVC อ่านต่อ — ตั้งแต่จุดนี้ระบบเห็นเป็น JSON ธรรมดา
ctx.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(node!.ToJsonString()));
ctx.Request.Body.Position = 0;
await next(ctx);
}
}
สิ่งที่ developer ต้องเขียนต่อ endpoint
[HttpPost]
[Authorize]
public async Task<IActionResult> CreateUserAsync(
[FromBody] CreateUserRequest request, // ← กลับมาใช้ [FromBody] ได้ตามปกติ
CancellationToken ct)
{
var command = request.ToCommand();
return ApiMatch(await _mediator.Send(command, ct));
}
เท่านี้จริง ๆ — controller ไม่รู้เลยว่ามีการเข้ารหัส เพราะค่าถูกถอดไปแล้วตั้งแต่ก่อนถึง MVC
| ข้อดี | ข้อเสีย |
|---|---|
| developer ลืมไม่ได้ — ไม่มีอะไรให้ลืม | ต้อง รื้อชั้นลายเซ็นที่ใช้งานจริงอยู่แล้วทุก endpoint มาเป็น middleware |
controller สะอาด กลับไปใช้ [FromBody] | middleware อยู่นอก MVC ⇒ ต้องอ่าน attribute ผ่าน endpoint metadata (เขียนยากกว่า filter นิดหน่อย) |
เพิ่ม endpoint ใหม่ = แปะ [EncryptedField] บน DTO จบ | ถ้าลำดับใน Program.cs ถูกสลับ = พังเงียบ (ต้องมี test กันไว้) |
| test ได้ที่ระดับ HTTP ครบวงจร | งานครั้งแรกใหญ่กว่า — กระทบทุก service ที่ใช้ชั้นลายเซ็นอยู่แล้ว |
ทาง ข — คงลายเซ็นเป็น filter แล้วให้ developer เรียก method ถอด
สิ่งที่ต้องทำครั้งเดียว (ทีมกลาง)
ไม่แตะชั้นลายเซ็นเลย เพิ่มแค่ method ใน lib กลาง
public interface IPayloadFieldDecryptor
{
/// <summary>ถอดทุก property ที่ติด [EncryptedField] แล้วคืน object เดิมที่ค่าเป็น plaintext</summary>
T Decrypt<T>(T request);
}
สิ่งที่ developer ต้องเขียนต่อ endpoint
[HttpPost]
[Authorize]
public async Task<IActionResult> CreateUserAsync(
[FromBody] CreateUserRequest request,
[FromServices] IPayloadFieldDecryptor decryptor,
CancellationToken ct)
{
request = decryptor.Decrypt(request); // ← บรรทัดเดียวที่ต้องจำ
var command = request.ToCommand();
return ApiMatch(await _mediator.Send(command, ct));
}
| ข้อดี | ข้อเสีย |
|---|---|
| ไม่ต้องแตะของที่ใช้งานอยู่เลย — ชั้นลายเซ็นคงเดิมทั้งหมด | 🔴 developer ลืมได้ — ลืมบรรทัดเดียว = ciphertext ถูกบันทึกลง DB เงียบ ๆ ไม่มี error |
| งานครั้งแรกเล็กมาก ทำทันก่อน demo สบาย | ต้องเขียนซ้ำทุก endpoint ที่มี field เข้ารหัส |
| ลำดับ “ตรวจก่อนถอด” ยังถูกต้องโดยโครงสร้าง (เนื้อ method รันหลัง filter เสมอ) | reviewer ต้องคอยตรวจว่าใครลืมไหม |
controller กลับไปใช้ [FromBody] ได้เหมือนกัน — Swagger ไม่หาย |
🔴 ความเสี่ยงข้อเดียวที่ต้องรับให้ได้ถ้าเลือกทางนี้: ลืมเรียก Decrypt แล้วระบบ ไม่ฟ้องอะไรเลย — ข้อมูลที่บันทึกจะเป็นข้อความเข้ารหัส กว่าจะรู้ตัวคือตอนเปิดดูข้อมูลจริง
กันได้บางส่วน ด้วยการให้ validator ปฏิเสธค่าที่หน้าตาเป็น JWE:
RuleFor(x => x.BirthDate)
.Must(v => !LooksLikeJwe(v))
.WithMessage("ค่านี้ยังไม่ได้ถอดรหัส — ลืมเรียก IPayloadFieldDecryptor.Decrypt หรือเปล่า");
ทางที่ 3 (ถ้าอยากได้ข้อดีของทั้งคู่)
เรียก Decrypt อัตโนมัติ ด้วย pipeline behavior ของ MediatR ที่มีใช้อยู่แล้วในระบบ — ไม่ต้องรื้อชั้นลายเซ็น และ developer ก็ลืมไม่ได้
public sealed class PayloadDecryptionBehavior<TReq, TRes>(IPayloadFieldDecryptor decryptor)
: IPipelineBehavior<TReq, TRes> where TReq : notnull
{
public Task<TRes> Handle(TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct)
=> next(decryptor.Decrypt(request)); // ทุก command ผ่านตรงนี้เองอยู่แล้ว
}
// สิ่งที่ developer เขียน = เท่ากับทาง ก (ไม่มีอะไรให้ลืม)
public async Task<IActionResult> CreateUserAsync([FromBody] CreateUserRequest request, CancellationToken ct)
=> ApiMatch(await _mediator.Send(request.ToCommand(), ct));
| ทาง ก (middleware) | ทาง ข (method) | ทาง 3 (behavior) | |
|---|---|---|---|
| developer ลืมได้ไหม | ❌ ลืมไม่ได้ | 🔴 ลืมได้ | ❌ ลืมไม่ได้ |
| ต้องรื้อชั้นลายเซ็น | 🔴 ต้องรื้อ | ✅ ไม่ต้อง | ✅ ไม่ต้อง |
| ลำดับ ตรวจ → ถอด | ✅ โดยลำดับใน Program.cs | ✅ โดยโครงสร้าง pipeline | ✅ โดยโครงสร้าง pipeline |
| Swagger | ✅ | ✅ | ✅ |
| งานครั้งแรก | ใหญ่ (ทุก service) | เล็กที่สุด | เล็ก (lib กลาง + register 1 บรรทัด) |
| ทันก่อน demo 1-2/09 | ⚠️ เสี่ยง | ✅ | ✅ |
สิ่งที่ต้องการจากคุณ
เลือก 1 ใน 3 — เลือกแล้วเริ่มลงมือได้ทันที เพราะส่วนที่เหลือ (กุญแจใน Key Vault, JWKS, ตัวถอดในไลบรารีกลาง, [EncryptedField], การแก้ชนิดของ BirthDate) เหมือนกันหมดทุกทาง