Skip to content

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, MEETINGapiKey header → API_DEVICE_KEY lookup
Role (RBAC)USER, STAFF, ADMIN, SUPER_ADMINJWT + 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

javascript
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

javascript
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

javascript
        const accessToken = jwt.sign(jwtSignObject, JWT_SECRET,
            {
                expiresIn: JWT_EXP,
                issuer: JWT_ISSUER,
                jwtid: jwtid
            }
        );
Claimค่าที่มา
expnow + 15mJWT_EXP env
issredcross.or.th:vtrcJWT_ISSUER env
jtiUUID v4Session.userSession.jwtId (rotate ทุกครั้งที่ refresh)
idsessionIdUUID
uiduserIdUUID

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

javascript
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

javascript
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

javascript
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 หนึ่งตัว

FieldTypeหมายเหตุ
sessionIdUUID PK
userIdUUID FK
roleUUIDroleId ปัจจุบัน (single role แม้ user จะมีหลาย role)
currentProfileStringprofileKey ที่ user เลือก (multi-role users)
clientENUMWEB / MOBILE / WEB_ADMIN
refreshTokenTEXTplaintext SHA-384 hash
tokenCDTEXTChula SSO bearer token (passthrough ไป cu-central-api)
refreshTokenCDTEXTrefresh token ของ cu-central-api
userSessionJSON{ jwtId, empCode } — jwtId rotate ทุก refresh
expireDateDATEnow + 1 day (dateDayAfter(1))
notificationExpireDateDATEnow + 31 day

TTL rules

สถานการณ์TTL
LoginSession (pre-auth)15 min
CaptchaSession5 min
Authenticated Session1 day
Notification token31 day
JWT access token15 min
Signed PDF URL1 hour (3600s)

getCurrentRoleUser — หัวใจของ RBAC

vtrc-api/api/src/lib/repositories/session/session.js:126-153

javascript
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

javascript
  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

javascript
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 accessRoleuserResult[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

javascript
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

javascript
// lib/encrypt/index.js
import crypto from 'crypto'
export const EncryptAll = (input, type) => {
  return crypto.createHash(type).update(input).digest('hex')
}
CallerInputOutput
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.jscsId (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 — ห้ามแก้

javascript
// typeDefs/authentication.js
type ResposeStatus {
  status: Boolean
}

เป็น public GraphQL type ที่กระทบทุก field ที่ return type นี้ (จำนวนมาก)


ขั้นตอนถัดไป

ไป 3.1.4 The centralize bridge