ข้ามไปยังเนื้อหา

Session Store

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ # cli/... คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ สคริปต์ใช้ idle timeout 3 วินาทีเพื่อให้เห็นการหมดอายุ ใน production ใช้หลักนาที

application server ควร stateless ดังนั้น session ต้องอยู่ที่ที่ทุกเซิร์ฟเวอร์เข้าถึงได้ Redis เหมาะ: อ่านเขียนเร็ว key หมดอายุได้ในตัว และ hash แมปกับ object ของ session ที่มีชื่อ field ได้ตรงๆ เพราะ store ใช้ร่วมกัน การ logout บนเซิร์ฟเวอร์เดียวจึง logout ทุกเครื่อง

session แต่ละอันอยู่ที่ key session:<id> field อย่าง userId, email และ role คือ hash field EXPIRE บน key ตั้ง idle timeout การรีเฟรชมันทุกคำขอที่ผ่านการยืนยันตัวตนให้ sliding TTL

cli/patterns-sessions-1.redis
127.0.0.1:6379> HSET session:abc123 userId 42 email "[email protected]" role "admin"
(integer) 3
127.0.0.1:6379> EXPIRE session:abc123 1800
(integer) 1
127.0.0.1:6379> HGETALL session:abc123
1) "userId"
2) "42"
3) "email"
5) "role"
6) "admin"
127.0.0.1:6379> HGET session:abc123 role
"admin"
127.0.0.1:6379> EXPIRE session:abc123 1800
(integer) 1
127.0.0.1:6379> TTL session:abc123
(integer) 1800
127.0.0.1:6379> HSET session:abc123 role "superadmin"
(integer) 0
127.0.0.1:6379> HGET session:abc123 role
"superadmin"
127.0.0.1:6379> HSET session:abc123 lastSeen 1700000000
(integer) 1
127.0.0.1:6379> HSET session:abc123 sudo 1
(integer) 1
127.0.0.1:6379> HEXPIRE session:abc123 60 FIELDS 1 sudo
1) (integer) 1
127.0.0.1:6379> HGETEX session:abc123 EX 300 FIELDS 1 role
1) "superadmin"
127.0.0.1:6379> HTTL session:abc123 FIELDS 3 sudo role userId
1) (integer) 60
2) (integer) 300
3) (integer) -1
127.0.0.1:6379> RENAME session:abc123 session:new456
OK
127.0.0.1:6379> TTL session:new456
(integer) 1800
127.0.0.1:6379> EXISTS session:abc123
(integer) 0
127.0.0.1:6379> UNLINK session:new456
(integer) 1
127.0.0.1:6379> EXPIRE session:gone 1800
(integer) 0

วิธีอ่าน:

  • HSET กับคู่ field-value หลายคู่สร้าง session ในคำสั่งเดียว (HSET แบบ variadic มีมาตั้งแต่ Redis 4.0) มันคืนจำนวน field ใหม่; การอัปเดต role คืน 0
  • EXPIRE ครั้งที่สองคือ sliding timeout: ทุกครั้งที่เรียกจะเริ่มนับถอยหลังใหม่ ได้ 1800 เพราะเพิ่งตั้ง (TTL ปัดเป็นวินาทีที่ใกล้ที่สุด)
  • HSET ไม่แตะ TTL ของ key การเขียน lastSeen ไม่ ยืดอายุ session มีแต่ EXPIRE ที่ยืด สคริปต์ด้านล่างพิสูจน์ด้วยนาฬิกา
  • expiry รายฟิลด์ (HEXPIRE, Redis 7.4) ให้ field แต่ละตัวมีตัวจับเวลาของตัวเอง: flag sudo อยู่ 60 วินาที, role ได้ 300 วินาทีจาก HGETEX ... EX (8.0 อ่านพร้อมตั้ง TTL ในคำสั่งเดียว) และ userId ไม่มี (-1) ใช้กับ flag อายุสั้นใน session ไม่ใช่กับ idle timeout: EXPIRE ระดับ key ยังเป็นตัวที่ลบทั้ง session
  • RENAME ย้ายข้อมูลไปชื่อ key ใหม่และ คง TTL (1800) ซึ่งเป็นสิ่งที่การหมุน session id ต้องการ
  • EXPIRE บน key ที่ไม่มีคืน 0 และไม่สร้างอะไร ซึ่งสคริปต์พึ่งพาอยู่
HSET session:abc123 userId 42 email "[email protected]" role "admin"
EXPIRE session:abc123 1800
HGETALL session:abc123
HGET session:abc123 role
EXPIRE session:abc123 1800
TTL session:abc123
HSET session:abc123 role "superadmin"
HGET session:abc123 role

สตริง JSON ก้อนเดียวบังคับให้อ่านและเขียนทั้งก้อนเพื่อเปลี่ยน field เดียว และคำขอสองตัวที่อัปเดตคนละ field อาจเขียนทับกัน (ตัวหลังชนะทั้งสตริง) กับ hash HGET ดึง field เดียว HSET เขียนหนึ่ง field และการอัปเดตคนละ field พร้อมกันไม่ชนกัน ราคาที่จ่ายคือ field เป็นสตริงแบบแบน โครงสร้างซ้อนต้อง serialize ลงใน field

src/patterns-sessions.ts
// @run
import { randomBytes } from 'node:crypto';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const IDLE = 3; // seconds; production: 1800 (30 min)
const key = (sid: string) => `b3:session:${sid}`;
// 128-bit random id from the OS CSPRNG (OWASP asks for >= 64 bits of entropy)
const newId = () => randomBytes(16).toString('base64url');
// HSET + EXPIRE inside MULTI: both or neither reach the server, never a session without a TTL
async function create(data: Record<string, string>) {
const sid = newId();
await redis.multi().hset(key(sid), data).expire(key(sid), IDLE).exec();
return sid;
}
// Read and slide the idle timeout in one round trip. EXPIRE on a missing key does nothing.
async function touch(sid: string) {
const res = await redis.multi().hgetall(key(sid)).expire(key(sid), IDLE).exec();
const [, data] = res![0]! as [Error | null, Record<string, string>];
return Object.keys(data).length ? data : null;
}
// Login must issue a NEW id (session fixation): RENAME keeps the data and the TTL
async function rotate(sid: string) {
const next = newId();
await redis.rename(key(sid), key(next));
return next;
}
const logout = (sid: string) => redis.unlink(key(sid));
let sid = await create({ userId: '42', role: 'admin' });
console.log('id length', sid.length, 'chars, ttl', await redis.ttl(key(sid)));
await sleep(1500);
console.log('after 1.5 s idle, ttl', await redis.ttl(key(sid)), '-> touch:', await touch(sid));
console.log('touch slid the timeout back to', await redis.ttl(key(sid)));
// HSET does NOT slide the TTL; only EXPIRE does
await sleep(1500);
await redis.hset(key(sid), 'lastSeen', String(Date.now()));
console.log('after HSET lastSeen, ttl is still counting down:', await redis.ttl(key(sid)));
const before = await redis.ttl(key(sid));
sid = await rotate(sid);
console.log('rotate: ttl before', before, 'after', await redis.ttl(key(sid)), '| data kept:', (await redis.hgetall(key(sid))).role);
await sleep(3200);
console.log('3.2 s of no requests -> session gone:', (await touch(sid)) === null);
// logout: delete now, do not wait for the TTL
sid = await create({ userId: '7' });
await logout(sid);
console.log('after logout:', (await touch(sid)) === null);
// Redis 8 HGETEX: expire individual FIELDS (here a short-lived "sudo" flag), not the whole key
sid = await create({ userId: '42', sudo: '1' });
await redis.call('HEXPIRE', key(sid), 1, 'FIELDS', 1, 'sudo');
await sleep(1200);
console.log('sudo field after 1.2 s:', await redis.hget(key(sid), 'sudo'), '| userId still there:', await redis.hget(key(sid), 'userId'));
await redis.unlink(key(sid));
await redis.quit();

ผลลัพธ์จริง (Mac เครื่องนี้):

id length 22 chars, ttl 3
after 1.5 s idle, ttl 1 -> touch: { userId: '42', role: 'admin' }
touch slid the timeout back to 3
after HSET lastSeen, ttl is still counting down: 1
rotate: ttl before 1 after 1 | data kept: admin
3.2 s of no requests -> session gone: true
after logout: true
sudo field after 1.2 s: null | userId still there: 42

สิ่งที่พิสูจน์ได้:

  • สร้างแบบ atomic multi().hset().expire().exec() ส่งสองคำสั่งเป็นหนึ่ง transaction ดังนั้น crash ระหว่างสองคำสั่งไม่ทิ้ง session ที่ไม่มีวันหมดอายุ (ต่างจาก await แยกสองตัวที่ทำได้) id คือ 16 ไบต์จาก CSPRNG ของ OS = 128 บิต, 22 ตัวอักษร base64url OWASP กำหนดอย่างน้อย 64 บิตของ entropy จากตัวสร้างที่ปลอดภัยเชิงเข้ารหัส (Session Management Cheat Sheet) อย่าสร้าง id จาก user id, timestamp หรือ Math.random()
  • อ่านและเลื่อนเวลาในรอบเดียว touch รัน HGETALL กับ EXPIRE ด้วยกัน: TTL กลับจาก 1 เป็น 3 หลังอ่าน session ที่ไม่มีคืน hash ว่าง (สคริปต์แมปเป็น null) และ EXPIRE บน key ที่ไม่มีไม่สร้างอะไร
  • HSET ไม่เลื่อนเวลา หลังผ่านไปอีก 1.5 วินาที การเขียน lastSeen ไม่แตะการนับถอยหลัง (ยังเป็น 1) การเขียน “อัปเดตกิจกรรม” ทุกครั้งต้องจับคู่กับ EXPIRE
  • หมุน id เมื่อสิทธิ์เปลี่ยน ออก id ใหม่ตอน login (ไม่เช่นนั้นผู้โจมตีที่ฝัง id ไว้ก่อน login ยังเข้าถึงได้: session fixation) RENAME คงทั้งข้อมูลและ TTL (ก่อน 1 หลัง 1) และ id เก่าหายไป
  • idle timeout ทำงาน 3.2 วินาทีที่ไม่มีคำขอ session ก็หายไป ไม่ต้องมี job ทำความสะอาด
  • Logout ลบทันที UNLINK (หรือ DEL) ลบ session ทันที การพึ่ง TTL อย่างเดียวทำให้ cookie ที่ถูกขโมยใช้ได้จนกว่าจะหมดเวลา
  • expiry ระดับ field field sudo หายไปหลังตัวจับเวลา 1 วินาที ขณะที่ userId ยังอยู่

AI เขียน middleware “อัปเดตกิจกรรมล่าสุด” และบอกว่ามัน “ทำให้ session ยังมีชีวิต”:

🐛 หา bug ในโค้ดนี้

app.use(async (req, res, next) => {
const key = `session:${req.cookies.sid}`;
const session = await redis.hgetall(key);
if (!session.userId) return res.status(401).end();
await redis.hset(key, 'lastSeen', String(Date.now())); // "keeps the session alive"
req.session = session;
next();
});
เฉลย

HSET ไม่เปลี่ยน TTL ของ key ในการรันด้านบน การเขียน lastSeen ปล่อยให้ ttl นับถอยหลังต่อ ผู้ใช้ที่ใช้งานอยู่จึงถูก logout เมื่อ TTL ตอน login เดิมหมด ไม่ว่าจะขยันแค่ไหน เลื่อนเวลาด้วย EXPIRE และทำการอ่าน การเขียน และการรีเฟรชใน MULTI เดียว เพื่อไม่ให้แยกจากกันได้:

const [[, session]] = (await redis
.multi()
.hgetall(key)
.hset(key, 'lastSeen', String(Date.now()))
.expire(key, 1800)
.exec())!;

session คือ ตั๋วฝากเสื้อที่มีตราวันหมดอายุ hash คือเสื้อ id ใน cookie คือตั๋ว และ EXPIRE คือตรา การจับเสื้อ (เปลี่ยน field) ไม่ต่ออายุตรา มีแต่การประทับตราใหม่ การเปลี่ยนเลขตั๋วเมื่อตัวตนของผู้เยี่ยมชมเปลี่ยน (RENAME) ทำให้ตั๋วเก่าที่ถูกคัดลอกใช้ไม่ได้ และการคืนเสื้อ (UNLINK) จบมันทันที

ตัวเลือกข้อดีข้อเสีย
hash หนึ่งอันต่อ sessionอ่าน/อัปเดตทีละ field โดยไม่ต้อง deserialize ทั้งหมด; อัปเดตคนละ field พร้อมกันไม่ชนกันfield แบนเท่านั้น; คุณเลือกชื่อ field เอง Redis ไม่บังคับ schema
สตริง JSON เดียวเรียบง่าย เก็บโครงสร้างซ้อนได้read-modify-write ทั้งก้อน; อัปเดตพร้อมกันเขียนทับกัน
Sliding TTL (EXPIRE ต่อคำขอ)session ที่ใช้งานอยู่ไม่หมดอายุกลางคันเพิ่มหนึ่งคำสั่งต่อคำขอ (ฟรีถ้าอยู่ใน MULTI เดียวกับการอ่าน)
มีอายุสูงสุดแบบ absolute ด้วยจำกัด session ที่ถูกขโมยต้องเก็บ createdAt และตรวจ; ผู้ใช้ถูก logout แม้กำลังใช้งาน
HEXPIRE/HGETEX รายฟิลด์flag อายุสั้นใน session ที่อายุยาวใช้ได้เฉพาะ Redis 7.4/8.0 (ไม่มีบนเซิร์ฟเวอร์เก่าหรือ fork ส่วนใหญ่) และไม่แทน TTL ของ key
session อยู่ใน Redis อย่างเดียวเร็ว หมดอายุเองข้อมูลหายถ้า Redis เสียมัน; ผู้ใช้ต้อง login ใหม่ (ดู persistence)
  • “เขียนลง session แล้วมันรีเฟรชเอง” มีแต่ EXPIRE (หรือ SET เต็มที่พก EX ใหม่) ที่เปลี่ยน TTL; HSET ไม่เปลี่ยน
  • “HSET แล้ว EXPIRE เป็น await สองตัวก็ไม่เป็นไร” crash ระหว่างสองตัวทิ้ง session ที่อยู่ตลอดกาล ใส่ทั้งคู่ใน MULTI
  • “หมดอายุก็พอสำหรับ logout” TTL เป็นทางสำรอง logout ที่ชัดเจนต้องลบ key
  • “TTL รายฟิลด์แทน TTL ของ session” มันแค่ทำให้บาง field หายเร็วขึ้น key ยังต้องมี EXPIRE ของตัวเอง
  • ไม่รีเฟรชอะไรเมื่อมีกิจกรรม — ผู้ใช้ที่ใช้งานอยู่ถูก logout ตอน TTL เดิมหมด
  • รีเฟรชด้วย HSET แทน EXPIRE — พิสูจน์ข้างบนแล้วว่าไม่เลื่อนเวลา
  • ไม่ DEL/UNLINK ตอน logout — cookie ที่ถูกขโมยยังใช้ได้จน TTL หมด
  • ใช้ id เดิมหลัง login — ออก id ใหม่ (session fixation)
  • id ที่เดาได้ — ใช้บิตสุ่มอย่างน้อย 128 บิตจาก CSPRNG
  • เก็บรหัสผ่านหรือ token ใน hash — session เก็บเท่าที่การให้สิทธิ์ต้องใช้ ไม่เกินนั้น

Real-world example

connect-redis ซึ่งเป็น Redis store ของ express-session ใน Express ไม่ได้ใช้ hash: source ของมัน serialize แต่ละ session ด้วย JSON.stringify แล้วเขียนด้วย SET key value EX ttl และ touch() เลื่อน idle timeout ด้วยการเรียก EXPIRE นั่นคือดีไซน์แบบสตริง JSON ในตารางด้านบน กับกฎ “EXPIRE เลื่อนเวลา” เดียวกับที่บทนี้พิสูจน์กับ hash (บทเวอร์ชันก่อนอ้างว่ามันใช้ hash แต่ source บอกว่าไม่ใช่)

คำสั่งใดอ่านทุก field และค่าของ session hash?
จะทำ sliding idle timeout อย่างไร?
หลัง HSET session:x lastSeen 1 บน key ที่เหลือ 100 วินาที TTL เป็นเท่าไร?
ทำไมต้องรัน HSET + EXPIRE ใน MULTI ตอนสร้าง session?
RENAME ทำอะไรกับ TTL ของ key และทำไมสำคัญกับการหมุน session?
คำสั่งใดจบ session ตอน logout?