3.1.3 · Authentication & session internals
บทนี้เจาะลึกว่า JWT สร้างอย่างไร session เก็บอะไร refresh token ทำงานอย่างไร และ RBAC logic มี bug อะไรซ่อนอยู่ ทุก citation re-verify ตรงกับโค้ดปัจจุบัน (2026-07-13) — ไฟล์ lib/repositories/auth/auth.js และ lib/repositories/session/session.js ไม่มี drift เลยจาก v1
ภาพรวม — 3 แกนของ auth
| แกน | ค่า | ที่มา |
|---|---|---|
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.1.2) — บทนี้เจาะเฉพาะส่วนที่เกิดใน gate #4 และ #5
JWT structure + signing
vtrc-api/api/src/lib/repositories/auth/auth.js:14-23
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, ไม่มีตรง → CANNOT_ACCESS) → lookup existing Session ด้วย userId + role + client → update หรือ create session → sign JWT ด้วย jwt.sign({ id: sessionId, uid: userId }, JWT_SECRET, { expiresIn: '15m', issuer: 'redcross.or.th:vtrc', jwtid }) → return { accessToken, refreshToken }
สิ่งที่อยู่ใน JWT payload
vtrc-api/api/src/lib/repositories/auth/auth.js:118-120
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
vtrc-api/api/src/lib/repositories/auth/auth.js:96-102
const accessToken = jwt.sign(jwtSignObject, JWT_SECRET,
{
expiresIn: JWT_EXP,
issuer: JWT_ISSUER,
jwtid: jwtid
}
);| Claim | ค่า | ที่มา |
|---|---|---|
exp | now + 15m | JWT_EXP env |
iss | redcross.or.th:vtrc | JWT_ISSUER env |
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 V3-S2 ใน 3.1.10)
Algorithm — ไม่ได้ระบุ
vtrc-api/api/src/lib/repositories/auth/auth.js:259-285
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 class ของ jsonwebtoken 8.x ที่ยังไม่แก้ ดู Volume 12 Security
Refresh token — material ที่ไม่ใช่ secret โดยแท้
vtrc-api/api/src/lib/repositories/auth/auth.js:292-294
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 เก็บ plaintext ใน Session.refreshToken ทำให้ DB read = full session takeover
Refresh flow
vtrc-api/api/src/lib/repositories/auth/auth.js:240-248
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 อีกครั้ง — jti rotate ทุกครั้งที่ refresh ทำให้ token เก่าใช้ไม่ได้ทันทีหลัง refresh ฝั่ง frontend ใช้ side-channel apolloFetch (ไม่ใช่ Apollo client หลัก) เรียก refreshToken mutation เพื่อไม่ให้ errorLink วนลูป (ดู Volume 4 vtrc-web)
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 |
TTL rules
| สถานการณ์ | TTL |
|---|---|
| LoginSession (pre-auth) | 15 min |
| CaptchaSession | 5 min |
| Authenticated Session | 1 day |
| Notification token | 31 day |
| JWT access token | 15 min |
| Signed PDF URL | 1 hour (3600s) |
getCurrentRoleUser — หัวใจของ RBAC
vtrc-api/api/src/lib/repositories/session/session.js:126-153
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
Bug #1 — signature mismatch
caller ส่ง 3 arguments (directives/auth.js:43: checkSession(verify.data.userId, accessRole, payload.jti, context.device)) และ session.js:22 เรียก getCurrentRoleUser(userId, accessRole, device) แต่ signature จริงรับแค่ 2 arguments (session.js:126) — device ถูก dropped โดย JavaScript default → device-binding ของ role ไม่ถูก enforce ที่นี่ (ต้องพึ่ง Session.client = device check แทน)
Bug #2 — Role lookup ใช้ roleName แต่ User.role เก็บ accessRole
Role.roleName IN accessRole → ได้ roleId ที่ตรงกับ role name ที่ directive ขอ ส่วน userRole.role.accessRole อ่านจาก Users.role (JSON) — เปรียบเทียบเป็น roleId (UUID) กับ roleId (UUID) ที่ตรงกันได้ถ้าสอง source sync กัน ความเปราะบางคือมี 2 source of truth (Role table + Users.role JSON) ที่ไม่มี FK constraint บังคับ — ถ้า admin เปลี่ยน Role.roleName โดยตรงใน DB แล้ว Users.role.accessRole ที่อ้าง UUID เดิมจะยัง match อยู่ ทำให้ role rename ไม่มีผลแท้จริง
Bug #3 — log รั่วข้อมูล
vtrc-api/api/src/lib/repositories/session/session.js:147-147
log.info(log.logTextFormat('INFO', 'Method:getCurrentRoleUser', 'INFO', "userRole", userRole))log ทุก call ของ getCurrentRoleUser แสดง userRole เต็ม — production log เต็มไปด้วย role blob ของ user ทุกคนที่ใช้งาน
checkMenuAuth — ABAC overlay
vtrc-api/api/src/lib/repositories/session/session.js:50-70
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 ของ user ทั้งหมด) → lookup RoleAccess ทั้งหมดของ roleIds → filter nameEN ตรงกับ accessMenu
Bug #1 — ใช้ role ผิดตัว (cross-role escalation)
checkMenuAuth อ่าน userResult[0].role.accessRole (ทุก roleId ที่ user มี) แต่ @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 เป็นช่องโหว่เงียบที่ยังไม่มี evidence ถูก exploit
Bug #2 — fail-open ใน catch
catch block เรียก responError('DATA_NOT_FOUND', ...) ซึ่ง throw ทำให้โดยแท้จริงแล้วเป็น fail-closed (ผ่านไม่ได้) แต่ responError อาจไม่ throw ในบาง code path (ดู 3.1.9) ทำให้ behavior ไม่แน่นอน
Bug #3 — role.accessRole อาจเป็น undefined
ถ้า Users.role JSON ไม่มี field accessRole → userResult[0].role.accessRole เป็น undefined → query RoleAccess ด้วย roleId: undefined อาจ match ทุก row หรือ fail ขึ้นกับ Sequelize config
getCurrentProfileSession — cross-user session leak
vtrc-api/api/src/lib/repositories/session/session.js:214-228
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 ที่ไม่ login จะไม่เรียก field ที่ใช้ function นี้ได้เพราะติด gate #4 ก่อน — leak เกิดเฉพาะกรณี session หมดอายุระหว่างทาง หรือมี 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')
}| Caller | Input | Output |
|---|---|---|
auth.js (generateRefreshToken) | 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 (forge ได้ถ้ารู้ algorithm + เดา UUID) และ PDF URL hash ไม่มี server secret จริง (DATA_ENCRYPT_KEY แค่ append เข้า input ไม่ใช่ HMAC key)
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.1.8)
เคล็ดลับตอนทำงานจริง
ถ้า debug "TOKEN_INVALID" แต่ JWT ดูถูกต้อง
เช็กตามลำดับ — (1) JWT_SECRET ใน env ตรงกับที่ sign ไหม (2) Session.userSession.jwtId ตรงกับ payload.jti ไหม (refresh แล้ว client ใช้ token เก่า) (3) Session.client ตรงกับ device ไหม (4) Session.expireDate > now ไหม
อย่าวางใจ Op.like บน JSON
substring collision ทำให้ match ผิดตัว ควร parse JSON แล้ว check field โดยตรง
role JSON คือ source of truth โดยแท้ ไม่ใช่ Role table
Users.role เก็บ { accessRole: [roleId, ...] } — ถ้าจะ audit RBAC ต้อง scan Users row ทั้งตาราง ไม่ใช่ join กับ Role table
ResposeStatus typo — ห้ามแก้
// typeDefs/authentication.js
type ResposeStatus {
status: Boolean
}เป็น public GraphQL type ที่กระทบทุก field ที่ return type นี้ (จำนวนมาก)