Skip to content

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, 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.2) — บทนี้เจาะเฉพาะส่วนที่เกิดใน gate #4 และ #5


JWT structure + signing

14:22:vtrc-api/api/src/lib/repositories/auth/auth.js
export const generateToken = async (
    userId,
    accessRole,
    client,
    oldSessionId = null,
    profileKey,
    accessTokenCD = null,
    refreshTokenCD = null,
    empCode = null
) => {

generateToken ทำ 6 สิ่งตามลำดับ

  1. lookup User — ถ้าไม่พบ → USER_NOT_FOUND
  2. resolve current role (getCurrentRoleUser) — ถ้าไม่มี role ตรง → CANNOT_ACCESS
  3. lookup existing Session ด้วย userId + role + client
  4. ถ้ามี session เดิม → update, ถ้าไม่มี → create
  5. sign JWT ด้วย jwt.sign({ id: sessionId, uid: userId }, JWT_SECRET, { expiresIn: '15m', issuer: 'redcross.or.th:vtrc', jwtid })
  6. return { accessToken, refreshToken }

สิ่งที่อยู่ใน JWT payload

118:120:vtrc-api/api/src/lib/repositories/auth/auth.js
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

96:102:vtrc-api/api/src/lib/repositories/auth/auth.js
        const accessToken = jwt.sign(jwtSignObject, JWT_SECRET,
            {
                expiresIn: JWT_EXP,
                issuer: JWT_ISSUER,
                jwtid: jwtid
            }
        );
Claimค่าที่มา
expnow + 15mJWT_EXP env (config.js:38)
issredcross.or.th:vtrcJWT_ISSUER (config.js:39)
jtiUUID v4Session.userSession.jwtId (rotate ทุกครั้งที่ refresh)
idsessionIdUUID
uiduserIdUUID

secret ใช้ JWT_SECRET ที่ default เป็น 'secret' (config.js:12) — ถ้า env ไม่ override → ใครก็ forge ได้ (severity S5 ใน บท 3.10)

Algorithm — ไม่ได้ระบุ

259:285:vtrc-api/api/src/lib/repositories/auth/auth.js
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 โดยแท้

292:294:vtrc-api/api/src/lib/repositories/auth/auth.js
export const generateRefreshToken = () => {
    return EncryptAll(uuidv4(), "sha384")
}

EncryptAll อยู่ที่ lib/encrypt/index.js เป็นแค่

javascript
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

240:248:vtrc-api/api/src/lib/repositories/auth/auth.js
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 หนึ่งตัว"

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 — push notification ยังใช้ได้หลัง session หมด

TTL rules

สถานการณ์TTLที่มา
LoginSession (pre-auth)15 minloginSession.js:43
CaptchaSession5 minuser.js:219, 247
Authenticated Session1 dayauth.js:133
Notification token31 dayauth.js:134
JWT access token15 minconfig.js:38
Signed PDF URL1 hour (3600s)routes.js:55,104,208,258,310

getCurrentRoleUser — หัวใจของ RBAC

126:153:vtrc-api/api/src/lib/repositories/session/session.js
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

43:43:vtrc-api/api/src/directives/auth.js
      await checkSession(verify.data.userId, accessRole, payload.jti, context.device)
22:22:vtrc-api/api/src/lib/repositories/session/session.js
  const currentUserRole = await getCurrentRoleUser(userId, accessRole, device)

function signature รับแค่ 2 arguments

126:126:vtrc-api/api/src/lib/repositories/session/session.js
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 รั่วข้อมูล

147:147:vtrc-api/api/src/lib/repositories/session/session.js
  log.info(log.logTextFormat('INFO', 'Method:getCurrentRoleUser', 'INFO', "userRole", userRole))

log ทุก call ของ getCurrentRoleUser ที่แสดง userRole ทั้งหมด — production log จะเต็มไปด้วย role blob ของ user ทุกคนที่กำลังใช้งาน


checkMenuAuth — ABAC overlay

50:70:vtrc-api/api/src/lib/repositories/session/session.js
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

65:69:vtrc-api/api/src/lib/repositories/session/session.js
  } 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 accessRoleuserResult[0].role.accessRole เป็น undefinedRoleAccess.findByConditions({ where: { roleId: undefined }}) อาจจะ match ทุก row หรือ fail ก็ได้ขึ้นกับ Sequelize config


getCurrentProfileSession — cross-user session leak

214:228:vtrc-api/api/src/lib/repositories/session/session.js
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

javascript
// lib/encrypt/index.js
import crypto from 'crypto'
export const EncryptAll = (input, type) => {
  return crypto.createHash(type).update(input).digest('hex')
}

function นี้ถูกใช้ในหลายที่ที่สำคัญ

CallerInputOutput
auth.js:293uuidv4()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 → ถ้ารู้ 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 ดูถูกต้อง

เช็กตามลำดับ

  1. JWT_SECRET ใน env ตรงกับที่ sign ไหม (deploy ผิด env)
  2. Session.userSession.jwtId ตรงกับ payload.jti ไหม (refresh แล้ว client ใช้ token เก่า)
  3. Session.client ตรงกับ device ที่ resolve จาก apiKey ไหม
  4. 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 ใหม่

  1. insert Role row (isActive: 1)
  2. update Users.role JSON ของ user ที่ต้องการ โดย append roleId ใหม่เข้าไปใน accessRole array
  3. อย่าลืมว่า Role.roleName ต้องตรงกับ accessRole arg ใน typeDefs — ไม่งั้น gate #4 reject

มันเป็นแค่ catalog — checkMenuAuth ใช้ RoleAccess.nameEN จาก DB ไม่ใช่จาก config ดังนั้นถ้าจะเพิ่ม menu ใหม่ต้อง insert RoleAccess row + MENUS_BACK_OFFICE ใน config (เผื่อ frontoffice ใช้แสดงผล)

ResposeStatus typo

101:103:vtrc-api/api/src/typeDefs/authentication.js
  type ResposeStatus {
    status: Boolean
  }

ResposeStatus (ผิดจาก Response) เป็น public GraphQL type — ห้าม rename เพราะกระทบทุก field ที่ return type นี้ (จำนวนมาก)


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

ไป บท 3.4 The centralize bridge