Private Docs

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 ประโยค:

  1. ตอน login ระบบสร้างของ 4 กอง แต่ browser ได้ไปแค่ บัตรผ่านใบเดียว — กุญแจจริงอยู่ฝั่ง server ตลอด
  2. ทุกครั้งที่ใช้งาน มี จุดตรวจนโยบายอายุ อยู่แล้ว แต่วันนี้ถูกตั้งเป็น “ผ่านตลอด”
  3. วันนี้ 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 อย่าง:

  1. browser ไม่เคยถือกุญแจจริงเลย — ถือแค่บัตรผ่าน กุญแจอยู่ในตู้ฝั่ง server ตลอด
  2. มีจุดตรวจนโยบายอายุอยู่แล้ว แค่ตอนนี้มันถูกตั้งเป็น “ไม่ต้องตรวจอะไร”

คำถามที่ 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

#ที่อยู่สถานะหลังแก้
1ProfileConfig.SessionTimeoutHours (config)เหลือตัวนี้ตัวเดียว ตั้งเป็น 48 ชม.
2AppConstants.DefaultSessionTimeoutHours (เลขตายตัวในโค้ด)🗑️ ลบทิ้ง
3ProfileConfig.SessionTimeoutHoursShort (ค่าของ RememberMe=false)🗑️ ลบทิ้ง — ยุบเหลือค่าเดียว
4RedisTicketStore fallback✅ ชี้มาที่ตัวที่ 1 อยู่แล้ว หายไปเอง
5claim 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 ล้วน)

ตัวตัดสินความเป็นตายของ sessionIdleTimeoutMinutes: 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)