3.3 · Authentication & session internals
บทนี้เจาะลึกว่า JWT สร้างอย่างไร session เก็บอะไร refresh token ทำงานอย่างไร และ RBAC logic มี bug อะไรซ่อนอยู่
บทนี้ยาวเพราะมีรายละเอียดมาก — แต่ถ้า debug เรื่อง session หรือทำ modernize auth ต้องอ่านจบ
ภาพรวม — 3 แกนของ auth
VTRC ใช้ auth แบบ 3 แกนผสมกัน
| แกน | ค่า | ที่มา |
|---|---|---|
Device class (APP_TYPE) | WEB, MOBILE, WEB_ADMIN, INTEGRATE_APP, TWOWAY, MEETING | apiKey header → API_DEVICE_KEY lookup |
| Role (RBAC) | USER, STAFF, ADMIN, SUPER_ADMIN | JWT + Users.role JSON |
| Menu (ABAC overlay) | ~32 menus (news, hospitalHr, Leave, ...) | RoleAccess table + accessMenu directive arg |
3 แกนนี้ evaluate ใน 5 gate (ดู บท 3.2) — บทนี้เจาะเฉพาะส่วนที่เกิดใน gate #4 และ #5
JWT structure + signing
export const generateToken = async (
userId,
accessRole,
client,
oldSessionId = null,
profileKey,
accessTokenCD = null,
refreshTokenCD = null,
empCode = null
) => {generateToken ทำ 6 สิ่งตามลำดับ
- lookup
User— ถ้าไม่พบ →USER_NOT_FOUND - resolve current role (
getCurrentRoleUser) — ถ้าไม่มี role ตรง →CANNOT_ACCESS - lookup existing
Sessionด้วยuserId + role + client - ถ้ามี session เดิม → update, ถ้าไม่มี → create
- sign JWT ด้วย
jwt.sign({ id: sessionId, uid: userId }, JWT_SECRET, { expiresIn: '15m', issuer: 'redcross.or.th:vtrc', jwtid }) - return
{ accessToken, refreshToken }
สิ่งที่อยู่ใน JWT payload
const generateJwtSignData = (sessionId, userId) => {
return { id: sessionId, uid: userId }
}JWT มีแค่ 2 field — id (sessionId) กับ uid (userId) ไม่ได้ฝัง role หรือ device ใน token ทำให้ token เล็ก แต่ทุก request ต้อง lookup Session ใหม่เพื่อเอา role/client มาใช้
Header / signing
const accessToken = jwt.sign(jwtSignObject, JWT_SECRET,
{
expiresIn: JWT_EXP,
issuer: JWT_ISSUER,
jwtid: jwtid
}
);| Claim | ค่า | ที่มา |
|---|---|---|
exp | now + 15m | JWT_EXP env (config.js:38) |
iss | redcross.or.th:vtrc | JWT_ISSUER (config.js:39) |
jti | UUID v4 | Session.userSession.jwtId (rotate ทุกครั้งที่ refresh) |
id | sessionId | UUID |
uid | userId | UUID |
secret ใช้ JWT_SECRET ที่ default เป็น 'secret' (config.js:12) — ถ้า env ไม่ override → ใครก็ forge ได้ (severity S5 ใน บท 3.10)
Algorithm — ไม่ได้ระบุ
const decodeToken = (token, verifyOption = {}) => {
let tokenData = {
token,
userId: null,
tokenVerify: false
}
if (token) {
// 1. Verify token and decode
let verifyDecoded
if (Object.keys(verifyOption).length > 0) {
verifyDecoded = jwt.verify(token, JWT_SECRET, verifyOption) // verify with option and decode
} else {
verifyDecoded = jwt.verify(token, JWT_SECRET) // verify and decode
}jwt.verify(token, JWT_SECRET, options) ไม่ได้กำหนด algorithms: ['HS256'] → jsonwebtoken ใช้ algorithm จาก JWT header ทำให้เปิดช่อง "alg: none" attack
แม้ว่าจะเป็น known vulnerability ของ jsonwebtoken 8.x (CVE-2015-9235) — code นี้ยังไม่ได้แก้ ดู Volume 10 Security สำหรับ exploit
Refresh token — material ที่ไม่ใช่ secret โดยแท้
export const generateRefreshToken = () => {
return EncryptAll(uuidv4(), "sha384")
}EncryptAll อยู่ที่ lib/encrypt/index.js เป็นแค่
crypto.createHash(type).update(input).digest('hex')คือ plain SHA-384 hash ของ UUID v4 — ไม่มี salt ไม่มี HMAC key ไม่มี server-side secret
ผลคือ refresh token ไม่ใช่ "secret" ในความหมาย cryptographically — ถ้า attacker รู้ algorithm (SHA-384) + เดา UUID ที่ใช้ generate ก็ forge ได้ (UUID v4 มี 122-bit entropy โดยแท้ แต่ถ้า PRNG อ่อนก็เสี่ยง)
ยิ่งกว่านั้น refresh token ถูกเก็บ plaintext ใน Session.refreshToken ทำให้ DB read = full session takeover
Refresh flow
export const refreshToken = async (userId, accessRole, client, oldSession, profileKey, empCode) => {
try {
const newToken = await generateToken(userId, accessRole, client, oldSession, profileKey, null, null, empCode)
return { token: newToken.accessToken, error: null }
} catch (e) {
return { token: null, error: e }
}
}refreshToken แค่เรียก generateToken อีกครั้ง — ใน generateToken จะเลือก path ว่า update session เดิม หรือ destroy + create ใหม่ ขึ้นกับว่า oldSessionId ส่งมาไหม
ที่สำคัญ — jti rotate ทุกครั้งที่ refresh (เพราะ updateSession สร้าง jwtId: uuidv4() ใหม่) ทำให้ token เก่าใช้ไม่ได้ทันทีหลัง refresh
ฝั่ง frontend — side-channel refresh
frontend ใช้ apolloFetch (ไม่ใช่ Apollo client หลัก) เรียก refreshToken mutation เพราะ errorLink ที่ intercept อยู่ใน Apollo client หลัก ถ้าใช้ client หลักเรียก refresh จะวนลูป
ดู vtrc-web/src/config/apollo.js:74-80 สำหรับ implementation
จุดนี้เปราะบาง — ถ้า refresh fail user ถูก log out อย่างเงียบ ไม่มี retry ไม่มี notification
Session row — เก็บอะไรบ้าง
Session model (models/core/session/session.js) เก็บ state หนึ่งแถวต่อ "login session หนึ่งตัว"
| Field | Type | หมายเหตุ |
|---|---|---|
sessionId | UUID PK | |
userId | UUID FK | |
role | UUID | roleId ปัจจุบัน (single role แม้ user จะมีหลาย role) |
currentProfile | String | profileKey ที่ user เลือก (multi-role users) |
client | ENUM | WEB / MOBILE / WEB_ADMIN |
refreshToken | TEXT | plaintext SHA-384 hash |
tokenCD | TEXT | Chula SSO bearer token (passthrough ไป cu-central-api) |
refreshTokenCD | TEXT | refresh token ของ cu-central-api |
userSession | JSON | { jwtId, empCode } — jwtId rotate ทุก refresh |
expireDate | DATE | now + 1 day (dateDayAfter(1)) |
notificationExpireDate | DATE | now + 31 day — push notification ยังใช้ได้หลัง session หมด |
TTL rules
| สถานการณ์ | TTL | ที่มา |
|---|---|---|
| LoginSession (pre-auth) | 15 min | loginSession.js:43 |
| CaptchaSession | 5 min | user.js:219, 247 |
| Authenticated Session | 1 day | auth.js:133 |
| Notification token | 31 day | auth.js:134 |
| JWT access token | 15 min | config.js:38 |
| Signed PDF URL | 1 hour (3600s) | routes.js:55,104,208,258,310 |
getCurrentRoleUser — หัวใจของ RBAC
export const getCurrentRoleUser = async (userId, accessRole) => {
const role = await Models.Role.findAll({
attributes: ['roleId'],
where: {
roleName: {
[Op.or]: accessRole
},
// Only active role that can get role
isActive: 1
}
})
const arrayRole = await role.map(value => { return value.roleId })
const userRole = await Models.User.findOne({
attributes: ['role'],
where: {
userId: userId
}
})
log.info(log.logTextFormat('INFO', 'Method:getCurrentRoleUser', 'INFO', "userRole", userRole))
const arrayUserRole = userRole ? userRole.role.accessRole : []
log.info(log.logTextFormat('INFO', 'Method:getCurrentRoleUser', 'INFO', "arrayUserRole", arrayUserRole))
const currentUserRole = await arrayRole.filter(f => arrayUserRole.includes(f))
return currentUserRole
}algorithm คือ — เอา roleId ที่ user claim มา (จาก Users.role.accessRole ที่เป็น JSON array) intersect กับ roleId ที่ตรงกับ accessRole[] (จาก directive arg) และ isActive: 1
ถ้าผล intersection ไม่ว่าง → user มี role ที่ตรงกับที่ field ต้องการ
Bug #1 — signature mismatch
caller ส่ง 3 arguments
await checkSession(verify.data.userId, accessRole, payload.jti, context.device) const currentUserRole = await getCurrentRoleUser(userId, accessRole, device)function signature รับแค่ 2 arguments
export const getCurrentRoleUser = async (userId, accessRole) => {device ถูก dropped โดย JavaScript default → device-binding ของ role ไม่ได้ถูก enforce ที่นี่ (ต้องพึ่ง Session.client = device check แทน)
Bug #2 — Role lookup ใช้ roleName แต่ User.role เก็บ accessRole
จากโค้ดด้านบน
- บรรทัด 130-132:
Role.roleName IN accessRole→ ได้ roleId ที่ตรงกับ role name ที่ directive ขอ - บรรทัด 148:
userRole.role.accessRole→ อ่านจากUsers.role(JSON) ที่เก็บไว้ในรูปแบบ{ accessRole: [roleId, roleId, ...] }
ปัญหาคือ — accessRole field ของ Users.role JSON ไม่ใช่ชื่อเดียวกันกับ Role.roleName ทำให้ดูเหมือนจะเปรียบเทียบ apple กับ orange
แต่แท้จริงแล้วเปรียบเทียบ roleId (UUID) ที่ได้จาก lookup กับ roleId (UUID) ที่อยู่ใน JSON ที่ user → ตรงกันได้ถ้าสอง source มีการ sync กัน
ความเปราะบางคือ — มี 2 source of truth (Role table + Users.role JSON) ที่ไม่มี FK constraint บังคับ ถ้า admin เปลี่ยน Role.roleName ใน DB ตาราง Role โดยตรง → Users.role.accessRole ที่อ้างถึง UUID เดิมจะยัง match อยู่ ทำให้ role rename ไม่มีผลจริง
Bug #3 — log รั่วข้อมูล
log.info(log.logTextFormat('INFO', 'Method:getCurrentRoleUser', 'INFO', "userRole", userRole))log ทุก call ของ getCurrentRoleUser ที่แสดง userRole ทั้งหมด — production log จะเต็มไปด้วย role blob ของ user ทุกคนที่กำลังใช้งาน
checkMenuAuth — ABAC overlay
export const checkMenuAuth = async (userId, accessMenu) => {
try {
if (!accessMenu) { return }
// const accessableMenu = MENUS_BACK_OFFICE.filter(menu => accessMenu.includes(menu))
let userResult = await Models.User.findAll({ where: { userId: userId } })
const accessRole = await RoleAccess.findByConditions({ where: { roleId: userResult[0].role.accessRole } })
// ใช้ nameEn เนื่องจาก menukey เป็น lowercase
const accessableMenu = accessRole.filter(role => accessMenu.includes(role.nameEN))
log.info(log.logTextFormat('INFO', 'Method:checkMenuAuth', 'QUERY', "Debugging", { userResult, accessRole, accessableMenu }))
// Check access Role
if (accessableMenu.length <= 0) {
// return accessableMenu
responError('TOKEN_INVALID', {}, null, "คุณไม่สิทธิดำเนินการ")
}
return accessableMenu
} catch (err) {
log.error(log.logTextFormat('TOKEN_INVALID', 'Method:checkMenuAuth', 'QUERY', err.stack, { userId, accessMenu }))
// responError(err, {}, "", "Something went wrong")
responError('DATA_NOT_FOUND', {}, null, "คุณไม่สิทธิดำเนินการ")
}
}algorithm — lookup User.role.accessRole (roleId list) → lookup RoleAccess ทั้งหมดของ roleIds → filter ที่ nameEN ตรงกับ accessMenu
Bug #1 — ใช้ role ผิดตัว
checkMenuAuth อ่าน userResult[0].role.accessRole (ทุก roleId ที่ user มี) แล้ว lookup RoleAccess ทั้งหมด
แต่ @auth(accessRole: [...], accessMenu: [...]) ตั้งใจให้ — "user ต้องมี role X และ role X ต้องมี menu Y"
code ปัจจุบันทำ "user ต้องมี role X (จาก checkSession) และ role ใดๆ ของ user ต้องมี menu Y (จาก checkMenuAuth)" — ไม่ใช่ role เดียวกัน
ผลคือ — ถ้า user มี 2 role คือ STAFF (มี menu A) และ USER (ไม่มี menu A) → ใช้ field @auth(accessRole: ["USER"], accessMenu: ["A"]) ได้ เพราะ checkSession ผ่านด้วย USER แล้ว checkMenuAuth ผ่านด้วย STAFF
นี่คือ cross-role escalation bug — ยังไม่มี evidence ว่าถูก exploit แต่เป็นช่องโหว่เงียบ
Bug #2 — fail-open ใน catch
} catch (err) {
log.error(log.logTextFormat('TOKEN_INVALID', 'Method:checkMenuAuth', 'QUERY', err.stack, { userId, accessMenu }))
// responError(err, {}, "", "Something went wrong")
responError('DATA_NOT_FOUND', {}, null, "คุณไม่สิทธิดำเนินการ")
}ใน catch แท้จริงแล้วน่าจะ return ว่า "ไม่ผ่าน" แต่โค้ดเรียก responError('DATA_NOT_FOUND', ...) ซึ่ง throw → กลายเป็น "ผ่านไม่ได้"
อย่างไรก็ตาม responError อาจจะไม่ throw ในบาง code path (ดู บท 3.9) ทำให้ behavior ไม่แน่นอน
Bug #3 — role.accessRole อาจเป็น undefined
ถ้า Users.role JSON ไม่มี field accessRole → userResult[0].role.accessRole เป็น undefined → RoleAccess.findByConditions({ where: { roleId: undefined }}) อาจจะ match ทุก row หรือ fail ก็ได้ขึ้นกับ Sequelize config
getCurrentProfileSession — cross-user session leak
export const getCurrentProfileSession = async (userId, client, isSystem = false) => {
const session = await Models.Session.findAll({ where: { userId: userId, client: client } })
if (session.length <= 0 && !isSystem) {
log.error(log.logTextFormat('DATA_NOT_FOUND', 'Method:getCurrentProfileSession', 'ERROR', "Client not match in this session", { userId: userId, client: client }))
responError('DATA_NOT_FOUND')
}
const session2 = await Models.Session.findOne({
where: {
tokenCD: {
[Op.not]: null
}
}
})
return session.length ? session[0] : session2
}ถ้า user ไม่มี session → fallback ไป session2 ที่ lookup Session ทั้งตารางโดยมีแค่เงื่อนไข tokenCD IS NOT NULL
ผล — ถ้าเรียก getCurrentProfileSession(userA, 'WEB') โดย userA ไม่มี session จะได้ session ของ user อื่นที่มี tokenCD ไม่ null (อาจจะเป็น user ล่าสุดที่ login)
function นี้ถูกเรียกจาก slip/slip.js, profile/profile.js, timeAttendance/timeAttendance.js, fund/fund.js และอื่นๆ — ทุกที่ที่ต้องใช้ tokenCD เพื่อยิง cu-central-api
เป็น leak ที่ร้าย เพราะ user A อาจจะเห็นข้อมูล HR ของ user B ผ่าน token ของ user B
Mitigation ชั่วคราว
ในกรณีปกติ user A ที่ไม่ login จะไม่สามารถเรียก field ที่ใช้ getCurrentProfileSession ได้ เพราะติด gate #4 (checkSession fail) — ดังนั้น leak จะเกิดเฉพาะกรณีที่ session ของ user หมดอายุระหว่างทาง หรือมี code path อื่นที่เรียกโดยไม่ผ่าน gate
Encryption helper — EncryptAll
// lib/encrypt/index.js
import crypto from 'crypto'
export const EncryptAll = (input, type) => {
return crypto.createHash(type).update(input).digest('hex')
}function นี้ถูกใช้ในหลายที่ที่สำคัญ
| Caller | Input | Output |
|---|---|---|
auth.js:293 | uuidv4() | refresh token (SHA-384) |
slip.js | ${year}:${month}:${pid}:${empCode}:${device}:${Date.now()}:${DATA_ENCRYPT_KEY} | signed PDF URL hash (SHA-256) |
loginSession.js | csId (client-side encrypted) | hashSum check |
ปัญหา — unsalted, no HMAC key ทำให้
- refresh token เป็นแค่ hash ของ UUID → ถ้ารู้ algorithm + เดา UUID ก็ forge ได้
- PDF URL hash ไม่มี server secret (DATA_ENCRYPT_KEY แค่ append เข้าไปใน input ไม่ใช่ HMAC key) → rainbow table ได้ถ้ารู้ format
ทั้งสองเป็น vulnerability ที่ยังไม่ถูกแก้ — ดู Volume 10 Security
ลำดับความพร้อมของ session lifecycle
┌──────────────────────────────────┐
│ generateCaptcha (TTL 5 min) │
│ captchaSession.create │
└──────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ verifyEmpCodeLogin (TTL 15 min, attempt ≤ 3) │
│ loginSession.create │
│ ถ้า captchaText ผิด → reject │
│ ถ้า usingCount > 3 → AUTH_LIMIT │
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ login / loginBackoffice / loginBypass │
│ 1. checkLoginSession(lsid) │
│ 2. signInCD → centralize/auth/signin (REST) │
│ 3. user.create (upsert) │
│ 4. generateToken → Session.create + jwt.sign │
│ 5. setUserDivision │
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ Authenticated Session (TTL 1 day, JWT 15 min) │
│ - role + client + tokenCD │
│ - jwtId ใน userSession JSON │
└────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ TOKEN_EXPIRED│ │ logout │ │ หมดอายุ │
│ → refresh │ │ → destroySession│ → orphan │
│ (rotate │ │ → signOutCD │ │ row ค้าง │
│ jwtId) │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘หมายเหตุ — ไม่มี background job ที่ cleanup Session ที่หมดอายุ ทำให้ตารางโตเรื่อยๆ (ดู บท 3.8)
เคล็ดลับตอนทำงานจริง
ถ้า debug "TOKEN_INVALID" แต่ JWT ดูถูกต้อง
เช็กตามลำดับ
- JWT_SECRET ใน env ตรงกับที่ sign ไหม (deploy ผิด env)
Session.userSession.jwtIdตรงกับpayload.jtiไหม (refresh แล้ว client ใช้ token เก่า)Session.clientตรงกับdeviceที่ resolve จาก apiKey ไหมSession.expireDate > nowไหม
อย่าวางใจ Op.like บน JSON
ถ้าเขียน query where: { userSession: { [Op.like]: '%${jwtid}%' } } — substring collision ทำให้ match ผิดตัว ควร parse JSON แล้ว check field โดยตรง
role JSON เป็น source of truth โดยแท้ ไม่ใช่ Role table
Users.role เก็บ { accessRole: [roleId, ...] } ในรูปแบบ JSON — ถ้าจะ audit RBAC ต้อง scan Users row ทั้งตาราง ไม่ใช่ join กับ Role table
ถ้าจะเพิ่ม role ใหม่
- insert
Rolerow (isActive: 1) - update
Users.roleJSON ของ user ที่ต้องการ โดย append roleId ใหม่เข้าไปในaccessRolearray - อย่าลืมว่า
Role.roleNameต้องตรงกับaccessRolearg ใน typeDefs — ไม่งั้น gate #4 reject
MENUS_BACK_OFFICE ใน config.js ไม่ใช่ enforcement
มันเป็นแค่ catalog — checkMenuAuth ใช้ RoleAccess.nameEN จาก DB ไม่ใช่จาก config ดังนั้นถ้าจะเพิ่ม menu ใหม่ต้อง insert RoleAccess row + MENUS_BACK_OFFICE ใน config (เผื่อ frontoffice ใช้แสดงผล)
ResposeStatus typo
type ResposeStatus {
status: Boolean
}ResposeStatus (ผิดจาก Response) เป็น public GraphQL type — ห้าม rename เพราะกระทบทุก field ที่ return type นี้ (จำนวนมาก)