Session Lifetime 101 — ทำไม session 30 วันถึงอันตราย และทำไมแก้เลขตรงๆ ถึงไม่ได้ผล
อธิบายแบบง่ายที่สุด: session หนึ่งอันจริงๆ มีกี่ชิ้น อายุคนละแบบยังไง ทำไมการลดเลข 720 เหลือ 24 ถึงได้ session ผีแทน logout เลขอายุที่กระจายอยู่ 5 ที่คืออะไรบ้าง และแผนแก้รากที่ยุบให้เหลือที่เดียวพร้อมเปิด idle timeout 24 ชม. — พร้อม sequence diagram ทุกขั้น โค้ดจริงทุกจุด และผลรัน test ที่พิสูจน์ว่าบั๊กมีจริง
อัปเดต: 2026-08-18
Session Lifetime 101 — ทำไม session 30 วันถึงอันตราย และทำไมแก้เลขตรงๆ ถึงไม่ได้ผล
เล่มนี้จบในตัว — อ่านจบแล้วจะรู้ว่าปัญหาคืออะไร ทำไมวิธีที่ดูง่ายที่สุดถึงใช้ไม่ได้ ต้องแก้อะไรจริงๆ และหลังแก้แล้วยังเหลือช่องอะไร
ทุก diagram อ่านจากบนลงล่าง ทุกก้อนโค้ดคือของจริงในระบบวันนี้ ไม่ใช่ตัวอย่างสมมติ
🔄 อัปเดต 18/08/2026 — แผนเปลี่ยนไปจากฉบับแรก ฉบับแรกเสนอทางที่เลี่ยงราก คือแค่เปิดสวิตช์ที่มีอยู่ (1 บรรทัดใน config ไม่แตะโค้ด) เจ้าของงานสั่งให้ แก้รากด้วย เพราะอยู่ในช่วง development ยังยอมให้ session ตายหมดได้ ⇒ ตอนนี้เป็นงาน แก้โค้ด + เปิดสวิตช์ พร้อมกัน รายละเอียดอยู่ที่ คำถามที่ 7
ภาพรวมก่อนเริ่ม — ชีวิตของ session หนึ่งอัน ตั้งแต่เกิดจนตาย
ถ้าจำอะไรไม่ได้แล้ว เริ่มที่รูปนี้รูปเดียวพอ ทั้งเอกสารคือการขยายรูปนี้
sequenceDiagram
autonumber
participant U as ผู้ใช้
participant A as APIM (ประตูหน้า)
participant SG as Sentinel
participant E as Entra ID
participant R as Redis
participant BE as Backend API
Note over U,BE: 1 · เกิด — ตอน login
U->>SG: กรอกรหัสผ่าน หรือ OTP
SG->>E: ส่งไปพิสูจน์ตัวตน
E-->>SG: ผ่าน + มอบกุญแจ (token)
SG->>R: สร้างของ 4 กอง<br/>บัตรผ่าน · ticket · ทะเบียน · ตู้กุญแจ
SG-->>U: คืนให้แค่ cookie (บัตรผ่าน)<br/>กุญแจไม่เคยออกจาก server
Note over U,BE: 2 · ใช้ชีวิต — ทำซ้ำทุก request
U->>A: เรียก API + แนบบัตรผ่าน
A->>SG: บัตรใบนี้ใช้ได้ไหม
SG->>R: อ่านทะเบียน (เปิดเมื่อไหร่ · ใช้ล่าสุดเมื่อไหร่)
SG->>SG: ตรวจนโยบายอายุ<br/>วันนี้ปิดอยู่ = ผ่านตลอด
SG->>R: หยิบกุญแจ + ประทับ ใช้ล่าสุด = ตอนนี้
SG-->>A: ใช้ได้ + กุญแจ
A->>BE: ส่งต่อ + แนบกุญแจ
BE-->>U: ผลลัพธ์
Note over U,BE: 3 · ตาย — มีได้ 3 ทาง
alt ทางที่ 1 · ไม่แตะเครื่องนานเกินกำหนด
SG->>SG: ใช้ล่าสุดเกิน 24 ชม.
SG->>R: ปิด session · เหตุผล idle_timeout
SG-->>U: 401 แล้วเด้งไปหน้า login
Note right of SG: ทางนี้ยังไม่เปิดใช้วันนี้<br/>คือสิ่งที่เอกสารนี้เสนอให้เปิด
else ทางที่ 2 · กด logout เอง
U->>SG: logout
SG->>R: ลบของทั้ง 4 กอง
else ทางที่ 3 · Entra ยกเลิกสิทธิ์
SG->>E: ขอต่ออายุกุญแจ
E-->>SG: ปฏิเสธถาวร (ลาออก / เปลี่ยนรหัส / admin สั่ง)
SG->>R: ปิด session ทันที
Note right of SG: ทางนี้ทำไว้แล้วและใช้งานอยู่จริง
end
สรุปรูปนี้เป็น 3 ประโยค:
- ตอน login ระบบสร้างของ 4 กอง แต่ browser ได้ไปแค่ บัตรผ่านใบเดียว — กุญแจจริงอยู่ฝั่ง server ตลอด
- ทุกครั้งที่ใช้งาน มี จุดตรวจนโยบายอายุ อยู่แล้ว แต่วันนี้ถูกตั้งเป็น “ผ่านตลอด”
- วันนี้ session ตายได้จริงแค่ 2 ทางจาก 3 (logout เอง กับ Entra ยกเลิกสิทธิ์) ⇒ คนที่ไม่กด logout และไม่ถูกยกเลิกสิทธิ์ จะอยู่ในระบบตลอดไป — ช่องนี้แหละที่ทางที่ 1 เข้ามาปิด
คำถามที่ 1: ตอนนี้เป็นยังไง แล้วมันแย่ตรงไหน
วันนี้ผู้ใช้ที่ login เข้า SuperApp ครั้งหนึ่ง จะอยู่ในระบบได้ 30 วัน และทุกครั้งที่กดใช้งาน นาฬิกาจะถูกตั้งใหม่เป็น 30 วันอีกรอบ
แปลว่า ถ้าเข้าใช้อย่างน้อยเดือนละครั้ง session นั้นจะไม่มีวันหมดอายุเลยตลอดชีวิต
เทียบของจริง: เหมือนบัตรผ่านเข้าตึกที่ต่ออายุตัวเองอัตโนมัติทุกครั้งที่รูด และไม่มีวันต้องแสดงบัตรประชาชนซ้ำอีก — ถ้าบัตรใบนั้นหล่นหาย คนเก็บได้ก็เดินเข้าตึกได้เรื่อยๆ
| ระบบเรา (วันนี้) | ธนาคารทั่วไป | |
|---|---|---|
| ไม่แตะเครื่องนานแค่ไหนถึงเตะออก | ไม่เตะเลย | 10–30 นาที |
| อยู่ได้สูงสุดเท่าไหร่นับจาก login | ไม่จำกัด | 8–12 ชั่วโมง |
ช่องว่างนี้ไม่ใช่เรื่องทฤษฎี — เคสที่เจอบ่อยที่สุดคือ เครื่องหาย / ลืม logout / ใช้เครื่องสาธารณะ ทั้งสามเคสนี้ session 30 วันแปลว่าคนที่ได้เครื่องไปใช้ต่อได้ทันทีโดยไม่ต้องรู้รหัสผ่าน
คำถามที่ 2: “session” หนึ่งอัน จริงๆ แล้วมีกี่ชิ้น
นี่คือหัวใจของทั้งเรื่อง — session ไม่ใช่ของชิ้นเดียว มันคือของ 4 กองที่วางอยู่คนละที่ และ แต่ละกองมีนาฬิกาของตัวเอง
flowchart LR
B["Browser<br/>ถือแค่ บัตรผ่าน (cookie)<br/>ในบัตรไม่มีข้อมูลอะไรเลย<br/>เป็นแค่หมายเลขอ้างอิง"]
subgraph R["Redis ฝั่ง server"]
T["ticket<br/>ตัวบัตรผ่านฉบับจริง"]
S["state = ทะเบียน<br/>ใครถือ · เปิดเมื่อไหร่ · ใช้ล่าสุดเมื่อไหร่"]
K["tokens = ตู้เก็บกุญแจ<br/>กุญแจจริงที่ Entra ออกให้"]
end
P["PostgreSQL<br/>สำเนาทะเบียน เผื่อ Redis ล่ม"]
B -->|ส่ง cookie มาทุก request| T
T --> S
S --> K
S -.สำเนา.-> P
แปลเป็นภาษาคน:
| ชิ้น | เปรียบเหมือน | ถ้าชิ้นนี้หมดอายุ จะเกิดอะไร |
|---|---|---|
| cookie ใน browser | บัตรผ่านในกระเป๋าเรา | หน้าเว็บเด้งไปหน้า login |
| ticket | บัตรผ่านฉบับจริงที่ยาม | เหมือนข้างบน |
| state (ทะเบียน) | สมุดทะเบียนของ รปภ. | ระบบไม่รู้จัก session นี้ แล้วเตะออก |
| tokens (ตู้กุญแจ) | ตู้เก็บกุญแจห้องต่างๆ ที่ Entra ออกให้ | ⚠️ บัตรผ่านยังใช้ได้ แต่เปิดห้องไหนไม่ได้เลย |
จำแถวสุดท้ายไว้ให้ดี — มันคือสาเหตุที่วิธีที่ดูง่ายที่สุดถึงใช้ไม่ได้ อธิบายในคำถามที่ 4
คำถามที่ 3: กดใช้งาน 1 ครั้ง เกิดอะไรขึ้นบ้าง
sequenceDiagram
participant U as ผู้ใช้ (browser)
participant A as APIM (ประตูหน้า)
participant SG as Sentinel
participant R as Redis
participant BE as Backend API
U->>A: เรียก API + แนบบัตรผ่าน (cookie)
A->>SG: บัตรใบนี้ยังใช้ได้ไหม
SG->>R: เปิดทะเบียนดู
R-->>SG: เปิดเมื่อไหร่ · ใช้ล่าสุดเมื่อไหร่
SG->>SG: ตรวจนโยบายอายุ<br/>(จุดนี้แหละที่เราจะเปิดใช้)
SG->>R: หยิบกุญแจจริงจากตู้
R-->>SG: กุญแจ (access token)
SG->>R: ประทับ ใช้ล่าสุด = ตอนนี้ ลงทะเบียน
SG-->>A: ใช้ได้ + กุญแจ
A->>BE: ส่งต่อ + แนบกุญแจ
BE-->>U: ผลลัพธ์
สังเกต 2 อย่าง:
- browser ไม่เคยถือกุญแจจริงเลย — ถือแค่บัตรผ่าน กุญแจอยู่ในตู้ฝั่ง server ตลอด
- มีจุดตรวจนโยบายอายุอยู่แล้ว แค่ตอนนี้มันถูกตั้งเป็น “ไม่ต้องตรวจอะไร”
คำถามที่ 4: ถ้าลดเลข 720 เหลือ 24 ตรงๆ จะเกิดอะไร
วิธีที่ดูตรงไปตรงมาที่สุดคือไปหาเลข 720 (ชั่วโมง = 30 วัน) แล้วเปลี่ยนเป็น 24
ผลที่ได้ไม่ใช่ “ถูก logout ตอน 24 ชั่วโมง” แต่เป็น session ผี — คือหน้าจอยังบอกว่า login อยู่ แต่กดปุ่มอะไรก็พังหมด
ทำไม — เพราะตู้กุญแจไม่เคยถูกต่ออายุ
ตอนต่ออายุกุญแจกับ Entra โค้ดจะเก็บกุญแจใหม่ลงตู้ แต่ไม่ได้ตั้งเวลาตู้ใหม่ มันอ่านเวลาที่เหลือแล้วใช้ค่านั้นต่อ
public async Task UpdateAsync(string sessionId, TokenData tokenData, CancellationToken ct = default)
{
var db = redis.GetDatabase();
// อ่าน TTL ที่เหลืออยู่ใน Redis — ไม่รีเซ็ตกลับ default เพื่อให้สอดคล้องกับ cookie ที่เหลืออยู่
var remainingTtl = await db.KeyTimeToLiveAsync(keyPrefixes.SessionTokens(sessionId));
var ttlToUse = remainingTtl ?? TimeSpan.FromHours(profileOptions.Value.SessionTimeoutHours);
await StoreAsync(sessionId, tokenData, ttlToUse, ct);
}
สองบรรทัดกลางคือตัวปัญหา: remainingTtl = เวลาที่เหลือ ไม่ใช่เวลาเต็ม ⇒ นาฬิกาของตู้กุญแจ เดินถอยหลังทางเดียวตั้งแต่วินาที login ไม่มีอะไรหยุดมันได้
ทุกวันนี้เราไม่เห็นปัญหาเพราะตู้ถูกตั้งไว้ 30 วัน — ไม่มีใครใช้ session เดียวนานขนาดนั้น พอลดเหลือ 24 ชั่วโมง มันจะโผล่ทันที
sequenceDiagram
participant U as ผู้ใช้
participant SG as Sentinel
participant R as Redis
Note over U,R: ชั่วโมงที่ 0 — login
SG->>R: สร้างบัตรผ่าน + ทะเบียน + ตู้กุญแจ
Note right of R: ตู้กุญแจตั้งเวลาไว้ 24 ชม.
Note over U,R: ชั่วโมงที่ 1 ถึง 24 — ใช้งานปกติทุกวัน
U->>SG: เรียก API
SG->>R: ต่ออายุบัตรผ่าน + ทะเบียน เป็น 720 ชม.
Note right of R: แต่ ตู้กุญแจ ไม่ถูกต่ออายุ<br/>นาฬิกายังเดินถอยหลังต่อไป
Note over U,R: ชั่วโมงที่ 25
U->>SG: เรียก API (บัตรผ่านยังใช้ได้อยู่)
SG->>R: ขอเปิดตู้กุญแจ
R-->>SG: ว่างเปล่า — หมดอายุไปแล้ว
SG-->>U: isValid = false (ไม่มีกุญแจให้)
Note over U: หน้าจอยังขึ้นว่า login อยู่<br/>แต่ทุกปุ่มที่กดจะพัง
ซ้ำร้าย — เลข 720 อยู่ 2 ที่ที่ไม่รู้จักกัน
ที่หนึ่งอยู่ใน config (แก้ได้) อีกที่เป็นเลขตายตัวในโค้ด:
/// <summary>ระยะเวลา session เริ่มต้น (ชั่วโมง)</summary>
public const int DefaultSessionTimeoutHours = 720;
และตัวที่ต่ออายุทะเบียนทุกครั้งที่ใช้งาน อ่านจาก เลขตายตัวตัวนี้ ไม่ใช่จาก config:
private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(AppConstants.DefaultSessionTimeoutHours);
public async Task UpdateActivityAsync(Guid sessionId, CancellationToken ct = default)
{
var session = await GetByIdAsync(sessionId, ct);
if (session is not null)
{
session.LastActivityAt = DateTime.UtcNow;
session.ExpiresAt = DateTime.UtcNow.Add(DefaultTtl); // 720 เสมอ ไม่สนใจ config
...
}
}
⇒ แก้ config เป็น 24 แล้วทะเบียนยังถูกดันกลับไป 720 เหมือนเดิมภายใน 5 นาทีแรกที่ใช้งาน และ ทดสอบสั้นๆ จะดูเหมือนทุกอย่างถูกต้อง เพราะกว่าจะเห็นอาการต้องรอข้ามวัน
สรุปคำถามที่ 4
| ชิ้น | ถ้าตั้ง config = 24 ชม. จะเป็นยังไง |
|---|---|
| บัตรผ่าน (cookie) | ต่ออายุเรื่อยๆ — ยังใช้ได้ |
| ทะเบียน (state) | ถูกดันกลับเป็น 720 ชม. — ยังใช้ได้ |
| ตู้กุญแจ (tokens) | ตายที่ 24 ชม. เป๊ะ ไม่ว่าจะขยันใช้แค่ไหน |
= session ผี ไม่ใช่ logout
คำถามที่ 5: แล้วต้องทำยังไง — คำตอบคือของที่ต้องการมีอยู่แล้ว
ในโค้ดมีสวิตช์ 2 ตัวที่ทำสิ่งที่เราต้องการเป๊ะๆ อยู่แล้ว เขียนเสร็จ ต่อสายเสร็จ มี test แล้ว แต่ถูกตั้งค่าเป็น 0 = ปิด
/// <summary>
/// Server-side idle timeout (นาที) — ถ้า user ไม่ active เกินค่านี้ → ปิด session บังคับ login ใหม่
/// 0 = ปิด (คงพฤติกรรม sliding เดิม) — ตั้งค่าตามเกณฑ์ compliance (BOT) เช่น 15–30 นาทีสำหรับงาน sensitive
/// </summary>
public int IdleTimeoutMinutes { get; init; }
/// <summary>
/// Absolute session lifetime cap (ชั่วโมง) — อายุสูงสุดนับจาก CreatedAt ไม่ว่าจะ active แค่ไหน → บังคับ re-auth
/// 0 = ปิด (ไม่มีเพดาน — พฤติกรรมเดิม) — ตั้งค่าตามเกณฑ์ compliance เช่น 8–12 ชั่วโมง
/// </summary>
public int AbsoluteSessionLifetimeHours { get; init; }
อ่าน comment ให้ดี — คนเขียนโค้ดนี้เผื่อไว้สำหรับ เกณฑ์ compliance ของ ธปท. (BOT) และเขียนตัวเลขแนะนำไว้เลยว่า idle 15–30 นาที, เพดาน 8–12 ชั่วโมง ซึ่งตรงกับที่ยกตัวอย่าง KBank มา ⇒ เรื่องนี้ไม่ใช่ของใหม่ที่ต้องสร้าง เป็นของที่วางไว้แล้วแต่ยังไม่เคยเปิด
สวิตช์นี้ทำงานยังไง
public static string? Evaluate(
DateTime createdAtUtc,
DateTime lastActivityAtUtc,
int idleTimeoutMinutes,
int absoluteLifetimeHours,
DateTime nowUtc)
{
if (absoluteLifetimeHours > 0 &&
nowUtc - createdAtUtc > TimeSpan.FromHours(absoluteLifetimeHours))
{
return AppConstants.DeactivationAbsoluteTimeout; // "absolute_timeout"
}
if (idleTimeoutMinutes > 0 &&
nowUtc - lastActivityAtUtc > TimeSpan.FromMinutes(idleTimeoutMinutes))
{
return AppConstants.DeactivationIdleTimeout; // "idle_timeout"
}
return null; // ยังใช้งานได้
}
อ่านเป็นภาษาคน 3 บรรทัด:
- ถ้า เปิดสวิตช์เพดาน ไว้ และ เปิด session มานานเกินเพดาน → หมดอายุ
- ถ้า เปิดสวิตช์ idle ไว้ และ ไม่แตะมานานเกินกำหนด → หมดอายุ
- ไม่งั้นก็ผ่าน
> 0 คือสวิตช์ — ค่า 0 แปลว่า “ข้ามการตรวจข้อนี้ไป” ซึ่งเป็นสถานะวันนี้ทั้งสองตัว
คำถามที่ 6: ทำไมวิธีนี้ถึงไม่เจอปัญหา session ผี
เพราะมัน ไม่ได้ไปยุ่งกับนาฬิกาของของทั้ง 4 กองเลย — มันอ่าน “ทะเบียน” แล้วตัดสินเอง
| แก้เลข 720 เป็น 24 | เปิดสวิตช์ (วิธีที่เสนอ) | |
|---|---|---|
| ไปยุ่งกับอะไร | นาฬิกาของทุกชิ้น ซึ่งไม่ sync กัน | ไม่ยุ่งกับนาฬิกาเลย |
| ตัดสินจากอะไร | ของชิ้นไหนหมดอายุก่อนก็ชนะ | วันที่ในทะเบียน — เปิดเมื่อไหร่ / ใช้ล่าสุดเมื่อไหร่ |
| ผลกับ session ที่ใช้อยู่ตอนนี้ | ต้องรอ / ต้องล้าง Redis | ตัดสินด้วยกฎใหม่ทันทีที่มี request ถัดไป |
| ต้องแก้โค้ดไหม | ใช่ หลายไฟล์ | ไม่ต้อง |
จุดที่สำคัญที่สุด: การตรวจนโยบายเกิดขึ้น ก่อน การต่ออายุ ดูลำดับจริงในโค้ด สองบล็อกนี้ติดกัน ไม่มีอะไรคั่น
// SECURITY (SG-05): บังคับ idle timeout + absolute lifetime cap (ถ้าตั้งค่าไว้ใน config; 0 = ปิด)
var expireReason = SessionLifetimePolicy.Evaluate(
session.CreatedAt,
session.LastActivityAt,
_profile.IdleTimeoutMinutes,
_profile.AbsoluteSessionLifetimeHours,
DateTime.UtcNow);
if (expireReason is not null)
{
await sessionRepo.DeactivateWithReasonAsync(sessionId, expireReason);
await context.SignOutAsync();
await WriteSessionError(context, "session_expired", expireReason,
"Session หมดอายุ กรุณา login ใหม่");
return; // จบตรงนี้ ไม่ไปต่อ
}
// ── บล็อกถัดไปทันที ──
// อัปเดต LastActivityAt (throttle 5 นาที) + Sliding Cookie
var timeSinceLastActivity = DateTime.UtcNow - session.LastActivityAt;
if (timeSinceLastActivity > ActivityUpdateInterval)
{
await sessionRepo.UpdateActivityAsync(sessionId);
...
}
⇒ session เก่าที่ค้างอยู่ใน Redis ไม่มีทางรอด เพราะยามตรวจทะเบียนก่อนจะประทับ “ใช้ล่าสุด” เสมอ
sequenceDiagram
participant U as ผู้ใช้ที่ login ค้างไว้ 3 วันแล้ว
participant SG as Sentinel
participant R as Redis
Note over SG: เพิ่งเปิดสวิตช์ idle = 24 ชม.
U->>SG: เรียก API (บัตรผ่านยังไม่หมดอายุ)
SG->>R: เปิดทะเบียน
R-->>SG: ใช้ล่าสุด = 3 วันที่แล้ว
SG->>SG: 3 วัน มากกว่า 24 ชม. จึงหมดอายุ
SG->>R: ปิด session ทิ้ง พร้อมเหตุผล idle_timeout
SG-->>U: 401 · reason = idle_timeout
Note over U: เด้งไปหน้า login ตามปกติ
คำถามที่ 7: แล้วต้องแก้อะไรจริงๆ
🔄 ส่วนนี้เปลี่ยนไปจากฉบับแรก เดิมเสนอว่า “แค่เปิดสวิตช์ ไม่แตะโค้ด” ซึ่งได้ผลลัพธ์ที่ต้องการจริง แต่ทิ้งรากไว้ไม่แก้ เจ้าของงานเลือกแก้รากด้วย ตอนนี้จึงมี 2 ส่วน
ส่วนที่ 1 — แก้ราก: ทำให้เลขมีที่อยู่เดียวจริงๆ
ตอนไล่โค้ดจริงพบว่า เลขอายุ session ไม่ได้อยู่ 2 ที่ แต่อยู่ 5 ที่ และฉบับแรกกำจัดได้แค่ 3
| # | ที่อยู่ | สถานะหลังแก้ |
|---|---|---|
| 1 | ProfileConfig.SessionTimeoutHours (config) | ✅ เหลือตัวนี้ตัวเดียว ตั้งเป็น 48 ชม. |
| 2 | AppConstants.DefaultSessionTimeoutHours (เลขตายตัวในโค้ด) | 🗑️ ลบทิ้ง |
| 3 | ProfileConfig.SessionTimeoutHoursShort (ค่าของ RememberMe=false) | 🗑️ ลบทิ้ง — ยุบเหลือค่าเดียว |
| 4 | RedisTicketStore fallback | ✅ ชี้มาที่ตัวที่ 1 อยู่แล้ว หายไปเอง |
| 5 | claim session_ttl_hours ที่ถูกฝังลง cookie ตอน login | 🗑️ เลิกใช้ |
ตัวที่ 5 คือตัวที่แอบร้าย — มันคือสำเนาของ config ที่ถูกถ่ายไว้ ณ วินาทีที่ user login แล้วติดอยู่กับ cookie ใบนั้นตลอดไป ⇒ แก้ config วันนี้ ก็ยังไม่ขยับอายุ cookie ของคนที่ login ไปแล้ว เป็นบั๊กพันธุ์เดียวกับที่เรากำลังฆ่า แค่ย้ายที่ไปซ่อน ถ้าไม่จัดการ คำว่า “รวมไว้ที่เดียว” ก็ไม่จริง
ทำไมถึงยุบ RememberMe ได้
เดิม RememberMe=true ให้ 30 วัน false ให้ 8 ชม. แต่พอไล่โค้ดจริงพบว่าความต่างนี้แทบเป็นเรื่องแต่งอยู่แล้ว — ตัวที่ต่ออายุทะเบียนทุกครั้งที่ใช้งานดันทุกอย่างกลับไป 720 ชม. เหมือนกันหมด ไม่สนว่าติ๊ก RememberMe หรือไม่ และจุดตรวจนโยบายก็ไม่เคยอ่านค่านี้เลย
⇒ ยุบเหลือค่าเดียว แล้วให้ idle timeout เป็นตัวตัดสินความเป็นความตายแทน · ตัว flag ยังอยู่ เพราะมันคือทางเดียวที่บังคับให้กรอกรหัสใหม่ (prompt=login)
สิ่งที่แลกไป — ต้องรู้: เครื่องที่ใช้ร่วมกัน (ติ๊ก “ไม่จำฉัน”) เดิมตายใน 8 ชม. ตอนนี้จะตายด้วยกติกา idle 24 ชม.แทน
ส่วนที่ 2 — เปิดสวิตช์ (เหมือนเดิม)
เพิ่มใน IaC ทั้ง 3 env:
ของเดิม (ตัดมาเฉพาะช่วงท้ายให้เห็นบริบท):
"SentinelProfile": {
"CookieDomain": ".exim.go.th",
"CookieEnvSuffix": "dev",
"CookieSameSiteNone": true,
"OverrideAccessTokenExpiry": true,
"AccessTokenLifetimeMinutes": 1
}
ของใหม่:
"SentinelProfile": {
"CookieDomain": ".exim.go.th",
"CookieEnvSuffix": "dev",
"CookieSameSiteNone": true,
"OverrideAccessTokenExpiry": true,
"AccessTokenLifetimeMinutes": 1,
"IdleTimeoutMinutes": 1440
}
1440 นาที = 24 ชั่วโมง — ไม่แตะเครื่องเกิน 24 ชม. เมื่อไหร่ ตัดทันที และตราบใดที่ยังใช้งานอยู่ก็ต่ออายุไปเรื่อยๆ ตามที่ต้องการ
ทำไมไม่ต้องแตะไฟล์ appsettings ในโปรเจกต์
ตอน CI build image ค่าจาก IaC จะถูกเอามาทับ appsettings ของ service เสมอ ⇒ แก้ในโปรเจกต์อย่างเดียวไม่มีผลบน cluster ค่าจริงต้องมาจาก IaC
และเนื่องจากฝั่ง service ไม่มี key นี้อยู่แล้ว ตัวตรวจ config-parity จึงผ่านฉลุย (มันจะ fail เฉพาะกรณี IaC ขาด key ที่ service มี ซึ่งเป็นคนละทิศกับที่เรากำลังทำ)
ส่วนที่ 3 — กันไม่ให้ปิดเงียบ
IdleTimeoutMinutes มีค่าเริ่มต้นเป็น 0 = ปิด และไม่มีอะไรบอกเลยว่ามันปิดอยู่ ประกอบกับไฟล์ IaC ทั้ง 3 env ของ service นี้ drift จากกันแล้วจริง (dev มี OverrideAccessTokenExpiry แต่ sit ไม่มี · CookieEnvSuffix ของ sit ว่างแต่ของ dev เป็น dev)
⇒ ถ้าวันหนึ่งมีคนลืมใส่ key นี้ใน env ใด env นั้นจะกลับไปเป็นแบบเดิมเงียบๆ และไม่มีใครรู้จนกว่าจะผ่านไป 30 วัน
จึงเพิ่ม: ตอน service boot ถ้าไม่ได้อยู่เครื่อง local แล้ว IdleTimeoutMinutes เป็น 0 → log Warning บอกตรงๆ ว่านโยบาย idle ปิดอยู่
บั๊กนี้พิสูจน์แล้วว่ามีจริง ไม่ใช่แค่อ่านโค้ดแล้วเดา
ก่อนแก้อะไร เราเขียน test ที่จับบั๊กตัวนี้โดยเฉพาะแล้วรันกับโค้ดปัจจุบัน — ต้องแดง ถ้ามันเขียว แปลว่าเราเข้าใจบั๊กผิดตั้งแต่ต้นและต้องกลับไปคิดใหม่
วิธีตั้งกับดัก: ตั้ง config เป็น 37 ชั่วโมง (เลขที่ไม่ชนกับค่าไหนในระบบเลย — ไม่ใช่ 720 ไม่ใช่ 48 ไม่ใช่ 8) แล้วหลอกว่าตู้กุญแจเหลือเวลาอีก 30 นาที จากนั้นสั่งต่ออายุ
ถ้าโค้ดถูก มันต้องเขียน 37 ชั่วโมง ถ้าโค้ดพัง มันจะเขียน 30 นาที
ผลจริงที่ได้:
Failed: 1, Passed: 14, Total: 15
Expected: "EX 133200" ← 37 ชั่วโมง (ที่ควรจะเป็น)
Actual: "EX 1800" ← 30 นาที (ที่เป็นจริง)
บั๊กมีจริง และ test เดิมอีก 14 ตัวไม่กระทบเลย — การใช้เลข 37 ทำให้มั่นใจได้ว่าถ้าวันหนึ่งมันเขียว มันเขียวเพราะค่ามาจาก config จริง ไม่ใช่เพราะบังเอิญไปตรงกับค่า default ตัวใดตัวหนึ่ง
คำถามที่ 8: ผู้ใช้จะเจออะไร
เมื่อ session หมดอายุ ระบบตอบกลับเป็น 401 พร้อม บอกเหตุผลมาด้วย:
{
"error": "session_expired",
"reason": "idle_timeout",
"message": "Session หมดอายุ กรุณา login ใหม่"
}
reason มีได้ 2 ค่า ตามที่นิยามไว้ในโค้ด:
public const string DeactivationIdleTimeout = "idle_timeout"; // ไม่แตะเครื่องนานเกินไป
public const string DeactivationAbsoluteTimeout = "absolute_timeout"; // ชนเพดานอายุสูงสุด
หน้าเว็บเห็น 401 ก็เด้งไปหน้า login ตามปกติอยู่แล้ว — รอบแรกนี้ฝั่ง frontend ไม่ต้องแก้อะไรเลย
คำถามที่ 9: ทำแล้ว ยังไม่ปลอดภัยตรงไหน
พูดตอนนี้ดีกว่าให้ไปเจอเอง
ประโยคที่ควรใช้เวลาเล่าให้คนอื่นฟัง — สิ่งที่ควบคุมนี้ทำได้จริงคือ “ไม่อนุญาตให้เข้าถึง backend ใหม่ ถ้าไม่ได้ใช้งานเกิน 24 ชม.” ไม่ใช่ “ตัดทุกอย่างทันทีที่ครบ 24 ชม.”
ต่างกันตรงที่มันฆ่า การออกกุญแจใบใหม่ ไม่ได้ฆ่า กุญแจที่ออกไปแล้ว — กุญแจใบสุดท้ายที่แจกไปยังใช้ได้จนกว่าจะหมดอายุของมันเอง
1. เราเลือกยังไม่เปิด “เพดานอายุสูงสุด” ในรอบนี้
IdleTimeoutMinutes ตอบโจทย์ “ไม่ใช้แล้วต้องตาย” แต่ไม่ตอบ “ใช้อยู่ตลอดต้องตายไหม”
sequenceDiagram
participant H as คนที่ได้ cookie ไป
participant SG as Sentinel
loop ทุก 23 ชั่วโมง
H->>SG: ยิง request เบาๆ 1 ครั้ง
SG->>SG: ยังไม่ถึง 24 ชม. จึงผ่าน<br/>ประทับ ใช้ล่าสุด = ตอนนี้
end
Note over H,SG: ทำแบบนี้ไปได้ไม่มีวันจบ<br/>มีแต่ เพดานอายุสูงสุด เท่านั้นที่หยุดได้
เปิดเมื่อไหร่ก็ได้ทีหลัง — เติมอีก 1 บรรทัด ("AbsoluteSessionLifetimeHours": 168 = 7 วัน) ไม่ต้อง deploy code
ถ้าจะเปิดเพดานทีหลัง ต้องรู้อะไรเพิ่ม
ตอนเพดานหมดอายุ ถ้า Entra ยังจำ user คนนั้นได้ การ “login ใหม่” จะกลายเป็นการเด้งไป-กลับเงียบๆ โดยไม่ต้องกรอกอะไรเลย เพราะโค้ดบังคับให้กรอกใหม่เฉพาะตอน rememberMe=false ซึ่งไม่ใช่ค่า default:
public IActionResult Login(
[FromQuery] string tenant,
[FromQuery] string? returnUrl = null,
[FromQuery] bool rememberMe = true) // default = true
{
// rememberMe = false → force re-login ทุกครั้ง (prompt=login) + TTL สั้น
// rememberMe = true → ใช้ SSO session ที่มีอยู่ได้ + TTL ยาว
if (!rememberMe)
properties.Items["prompt"] = "login";
}
⇒ ถ้าเปิดเพดานเมื่อไหร่ ต้องเพิ่มงานฝั่ง frontend ให้อ่าน reason = absolute_timeout แล้วเรียก login แบบบังคับกรอกใหม่ ไม่งั้นเพดานจะกันได้แค่กรณี cookie ถูกขโมย ไม่ได้บังคับ re-login จริงสำหรับคนทั่วไป
2. session ที่หมดอายุแล้ว ยังสั่งงาน WorkflowService ได้
เมื่อ Sentinel ตอบว่า “ไม่ผ่าน” ประตูหน้า (APIM) ยังส่ง request ต่อไปหลังบ้านอยู่ดี แค่ไม่แนบกุญแจไปให้ — จะรับหรือไม่รับเป็นเรื่องของแต่ละ service ตัดสินเอง และ WorkflowService วันนี้ยังเปิดรับแบบไม่ตรวจ
⇒ เรื่องนี้ไม่เกี่ยวกับอายุ session และงานรอบนี้ไม่ได้แก้มัน ต้องเปิดเป็นงานแยก
3. ถ้าจะไปให้ถึงระดับ KBank (ต่ำกว่า 10 นาที) มีเพดานทางเทคนิคอยู่
“ใช้ล่าสุดเมื่อไหร่” ถูกอัปเดตแบบหน่วงทุก 5 นาที (เพื่อไม่ให้เขียน Redis ถี่เกินไป) ซ้อนทับด้วย cache ที่ประตูหน้าอีก 2 นาที ⇒ ค่า idle ที่ต่ำกว่าราว 10–15 นาที จะไม่แม่นยำ ต่อให้ตั้งค่าลงไปก็ตาม ถ้าอยากได้จริงต้องแก้กลไกการอัปเดตก่อน
4. WebSocket ที่เปิดค้างไว้ ไม่ถูกตรวจซ้ำเลย
หน้าจอที่รับ notification แบบ real-time จะเปิด connection ค้างไว้ยาวๆ ระบบตรวจ session ครั้งเดียวตอนเปิด connection แล้วไม่ตรวจอีกเลยจนกว่าจะหลุด
sequenceDiagram
participant U as หน้าจอผู้ใช้
participant A as APIM
participant SG as Sentinel
U->>A: เปิด WebSocket
A->>SG: session ใช้ได้ไหม
SG-->>A: ใช้ได้
Note over A,U: connection เปิดค้าง — ไม่ตรวจซ้ำอีกเลย
loop หลายวันต่อมา
A-->>U: ยัง push ข้อมูลให้อยู่
end
Note over U,SG: แม้ session จะถูกตัดด้วย idle ไปแล้วก็ตาม
และมีอีกด้านที่กลับกัน: traffic ของ WebSocket ไม่นับเป็น activity ⇒ คนที่นั่งดูหน้าจอ real-time อย่างเดียวโดยไม่กดอะไรเลย จะถูกนับว่า “ไม่ใช้งาน” และโดนตัดทั้งที่กำลังใช้อยู่
ทั้งสองด้านนี้ปิดได้เมื่อ connection หลุดแล้วต่อใหม่ (จะโดนตรวจรอบใหม่) แต่ไม่ได้ปิดสนิท — เป็นงานแยก ไม่อยู่ในรอบนี้
5. ของแถมที่เจอระหว่างทาง — limit จำนวน session ต่อ user ไม่เคยทำงาน
ในโค้ดมีค่า MaxSessionsPerUser ตั้งไว้ 10 พร้อม comment ว่า “เมื่อถึง limit จะปฏิเสธ login ใหม่” — แต่ไม่มีโค้ดไหนอ่านค่านี้เลยแม้แต่บรรทัดเดียว
⇒ ใครที่เข้าใจว่าระบบจำกัดจำนวน session ต่อคนอยู่ กำลังเข้าใจผิด · ไม่เกี่ยวกับงานรอบนี้ แต่บันทึกไว้เพราะเป็นความเข้าใจผิดที่อันตราย
Rollout และการถอยกลับ
| ทำอะไร | |
|---|---|
| ลำดับ | dev → sit → uat (prod ยังไม่อยู่ในรอบนี้) |
| แต่ละ env | เพิ่ม "IdleTimeoutMinutes": 1440 ใน IaC แล้วให้ CI deploy |
| วิธีตรวจ | หา session ที่ไม่ถูกแตะเกิน 24 ชม. แล้วเรียก API — ต้องได้ 401 พร้อม reason: "idle_timeout" |
| สิ่งที่จะเกิด | คนที่ค้าง session เก่ากว่า 24 ชม. จะถูกเด้ง login ใหม่รอบเดียว |
| ถอยกลับ | เปลี่ยนค่าเป็น 0 = ปิด — แก้ IaC บรรทัดเดียว ไม่ต้อง revert code |
บันทึกการตัดสินใจ (ADR-005)
สถานะ: อนุมัติแล้ว กำลัง implement (อัปเดต 18/08/2026 — ฉบับแรกเป็น Proposed แบบ config ล้วน)
| ตัวตัดสินความเป็นตายของ session | IdleTimeoutMinutes: 1440 (24 ชม.) เปิดผ่าน IaC ทั้ง 3 env |
| เพดานอายุสูงสุด | ตั้ง 0 = ปิด ในรอบนี้ — ค่าที่เคยเสนอคือ 168 ชม. (7 วัน) เก็บไว้รอบหน้า |
| อายุของข้อมูลใน Redis | ยุบเหลือค่าเดียว 48 ชม. (2 เท่าของ idle เผื่อช่อง) — เลิกแยกตาม RememberMe |
| งาน code | มี — ยุบแหล่งเก็บเลขจาก 5 เหลือ 1, ลบ AppConstants.DefaultSessionTimeoutHours และ SessionTimeoutHoursShort, เลิกใช้ claim session_ttl_hours, แก้ตู้กุญแจให้ reset เต็ม, log Warning เมื่อ idle ปิดอยู่ |
| ทางเลือกที่ไม่เลือก | ลด SessionTimeoutHours เหลือ 24 ตรงๆ → ได้ session ผี (คำถามที่ 4) · เขียนกลไกเพดานใหม่ → ซ้ำกับของที่มีอยู่ · ให้ตอนต่อกุญแจสำเร็จไปต่ออายุทะเบียนด้วย → ตีตกเพราะ dev/uat ตั้งอายุ token ไว้ 1 นาที ทำให้ต่อกุญแจแทบทุก request การไปเขียน DB ตรงนั้นจะกลายเป็นเขียนทุก request โดยแทบไม่ได้อะไรเพิ่ม เพราะจุดตรวจต่ออายุให้อยู่ก่อนแล้ว |
ความสัมพันธ์กับ ADR-004 — อ่านก่อนถ้าจะทำงานต่อจากเอกสารเก่า
ADR-004 (พ.ค. 2026) เสนอทิศทาง ตรงข้าม คือขยาย session เป็น 90 วันเพื่อให้ตรงกับอายุของ Entra ADR-005 นี้กลับทิศนั้น
แต่ ห้ามทิ้ง ADR-004 เพราะมันถูกทำไปแล้วบางส่วน:
- ข้อ 7 ของ ADR-004 ทำเสร็จและใช้งานอยู่จริง — เมื่อ Entra ปฏิเสธการต่อกุญแจแบบถาวร ระบบจะปิด session ให้ทันที ห้ามไปสร้างซ้ำ
- ข้อ 1 ถึง 6 (การขยายเป็น 90 วัน, กลไกเพดานแบบ clamp, การแก้บั๊กต่ออายุเกิน) ไม่เคยถูกทำเลย
- บั๊กที่ ADR-004 ข้อ 6 ระบุไว้ (ต่ออายุทะเบียนไป 720 ชม. เสมอ ไม่สนใจ config) ยังอยู่จนถึงวันนี้ — เอกสารนี้ไม่ได้แก้มัน แต่ไม่กระทบ เพราะการตรวจนโยบายชนะการตัดสินใจอยู่แล้ว
บทเรียน: ในระบบนี้ ADR สถานะ Accepted ไม่ได้แปลว่าโค้ดทำตามแล้ว ต้องไล่เช็คทีละข้อก่อนวางแผนต่อยอด
งานที่ยังค้าง
| งาน | ทำไมต้องทำ |
|---|---|
| รอผล implement + test ทั้ง suite | กำลังทำอยู่ตอนที่เขียนเอกสารนี้ — ยังไม่มีผล build/test ของรอบแก้จริง เอกสารนี้จะอัปเดตอีกครั้งเมื่อได้ผล |
| ตรวจ log ของการต่อกุญแจบน dev 1 ครั้ง | ยืนยันว่า session แบบ OTP (ที่ต่อกุญแจไม่ได้อยู่แล้ว) ไม่ถูกกลไกข้อ 7 ของ ADR-004 ปิดผิดจังหวะ — งาน 1 บรรทัด แต่ควรทำก่อน deploy |
| ไล่ดู repo ฝั่ง frontend | ยังไม่ได้ยืนยันว่าไม่มีที่ไหน assume ว่า session อยู่ได้ 30 วัน |
| WorkflowService รับ request ที่ไม่มีกุญแจ | งานแยก ไม่เกี่ยวกับอายุ session (คำถามที่ 9 ข้อ 2) |
| WebSocket ไม่ถูกตรวจซ้ำ + ไม่นับเป็น activity | งานแยก (คำถามที่ 9 ข้อ 4) |
MaxSessionsPerUser ไม่เคยทำงาน | งานแยก (คำถามที่ 9 ข้อ 5) — ต้องตัดสินว่าจะทำให้มันทำงาน หรือลบทิ้งเพื่อไม่ให้เข้าใจผิด |
เพดานอายุสูงสุด (AbsoluteSessionLifetimeHours) | ยังปิดอยู่ตามที่ตัดสิน — เปิดทีหลังได้ด้วยการเติม 1 บรรทัดใน IaC (คำถามที่ 9 ข้อ 1) |