Skip to content

10.2 · Authentication flow

บทนี้เจาะ login end-to-end ตั้งแต่ pre-login (verifyEmpCodeLogin) จนถึง generateToken พร้อมช่องโหว่ที่พบในแต่ละขั้นตอน บทนี้ complement Volume 3.3 — Volume 3 อธิบายว่า flow ทำงานอย่างไร บทนี้อธิบายว่าแต่ละขั้นตอนมี bug อะไร


Login flow ในภาพรวม

Live path (ยืนยันจากโค้ด): vtrc-api → cu-central-api เท่านั้น

  • GraphQL HR: END_POINT_CENTRALIZEcu-central-api /api/graphql (เช่น isAccountExists)
  • Auth REST: END_POINT_CENTRALIZE_AUTHcu-central-api /auth (/signin, /signinbypass, /signout, /token)
  • ตรวจรหัสผ่านหลัก = LDAP/AD bind ที่ cu-central-api — ไม่ใช่ NestJS centralize-api และ ไม่ใช่ การ verify password ใน MSSQL
    NestJS centralize-api (port 3000) เป็นบริการแยก — ไม่มี GraphQL /signin; caller จาก vtrc-api ยังไม่ยืนยัน (E-08)
┌─────────────┐
│   Browser   │  1. POST verifyEmpCodeLogin(empCode, captcha, org)
│  (employee) │     ────────────────────────────────────────────▶
└─────────────┘
                     ┌──────────────────────────────────┐
                     │  vtrc-api (resolvers/auth.js)    │
                     │  ├── checkAccountExit            │
                     │  │   ├── isAccountExists ─GraphQL──▶ cu-central-api
                     │  │   ├── checkRsaKey (สร้าง LoginSession row)
                     │  │   └── captcha gate (usingCount > 2)
                     │  └── return lsid
                     └──────────────────────────────────┘
       ◀──────── 2. LoginSession { lsid, status }

┌─────────────┐
│   Browser   │  3. POST login(lsid, password, apiKey, clientInfo)
│  (employee) │     ────────────────────────────────────────────▶
└─────────────┘
                     ┌──────────────────────────────────┐
                     │  vtrc-api (signInApp)            │
                     │  ├── validate apiKey (1st gate)  │
                     │  ├── checkLoginSession(lsid)     │
                     │  ├── signInCD ─HTTPS─────────────┼──▶ cu-central-api /auth/signin
                     │  │      (password plaintext!)    │      └── LDAP/AD bind
                     │  ├── create/update User row      │
                     │  ├── generateToken               │
                     │  │   ├── JWT sign (HS256)        │
                     │  │   └── store Session row       │
                     │  └── return { accessToken, refreshToken }
                     └──────────────────────────────────┘
       ◀──────── 4. { accessToken, refreshToken }

ขั้นตอนที่ 1 · verifyEmpCodeLogin (pre-login)

Pre-login เป็น query ที่ public ไม่มี @auth directive หน้าที่คือเช็คว่า empCode มีอยู่ใน HR หรือไม่ ก่อนสร้าง LoginSession row ที่จะใช้ติดตาม

13:15:vtrc-api/api/src/resolvers/authentication.js
    verifyEmpCodeLogin ( 
      empCode: String!

ใน controller checkAccountExit มี 4 ขั้นย่อย

155:193:vtrc-api/api/src/lib/repositories/user/user.js
export const checkAccountExit = async (args, device) => {
    try {

        var setData = {
            empCode: args.empCode,
            org: args.organization,
            workflow: args.workflow
        }
        log.verify(log.logTextFormat('INFO', 'Method:checkAccountExit step 1 verifyEmpCodeLogin', 'INFO', "setData", { setData }))
        const data = await isAccountExists(setData)
        console.log("isAccountExists data ->", data);
        log.verify(log.logTextFormat('INFO', 'Method:checkAccountExit step 2 verifyEmpCodeLogin', 'INFO', "get isAccountExists", { data }))
        const resultData = data.isAccountExists
        console.log("bf loginSession ->>>>");
        const loginSession = await checkRsaKey(args, device)
        console.log("af loginSession ->>>>");
        log.verify(log.logTextFormat('INFO', 'Method:checkAccountExit step 3 verifyEmpCodeLogin', 'INFO', "get loginSession", { loginSession }))
        if (loginSession.usingCount > 2) {
            if (args.captchaUid == null || args.captchaUid == undefined) {
                responError('CAPTCHA_INVALID')
            }
            await checkCaptcha(args.captchaUid, args.captchaText)
        }

        setData = {
            ...setData,
            status: resultData.ableLogin,
            workflow: resultData.workflow
        }
        log.verify(log.logTextFormat('INFO', 'Method:checkAccountExit step 4 verifyEmpCodeLogin', 'INFO', "setData 2", { setData }))
        let result = {}

        if (loginSession != null) {
            log.verify(log.logTextFormat('INFO', 'Method:checkAccountExit step 5 verifyEmpCodeLogin', 'INFO', "case update"))
            result = await updateAccountLoginSession(loginSession.loginSessionId, setData, resultData.ableLogin)
        } else {
            log.verify(log.logTextFormat('INFO', 'Method:checkAccountExit step 5 verifyEmpCodeLogin', 'INFO', "case create"))
            result = await createAccountLoginSession(setData, resultData.ableLogin)
        }

🟡 ช่องโหว่ที่ 1 — Captcha ไม่ใช่ lockout

172:177:vtrc-api/api/src/lib/repositories/user/user.js
        if (loginSession.usingCount > 2) {
            if (args.captchaUid == null || args.captchaUid == undefined) {
                responError('CAPTCHA_INVALID')
            }
            await checkCaptcha(args.captchaUid, args.captchaText)
        }

หลังจาก login ผิด 3 ครั้ง (usingCount > 2) ระบบจะบังคับให้ solve captcha — ไม่ใช่ lock account ผู้โจมตีที่ solve captcha ได้ (อาจใช้ captcha-solving service) สามารถ brute force ต่อได้ไม่จำกัด

ที่แย่กว่านั้น captcha gate นี้ apply แค่ที่ verifyEmpCodeLoginไม่ใช่ที่ login mutation เอง เมื่อได้ lsid มาแล้ว attacker สามารถยิง login(lsid, password) ด้วย password ต่าง ๆ ได้โดยไม่มี gate ใด ๆ (บทที่ 9.10 จะพูดถึงการไม่มี rate limit)

🟡 ช่องโหว่ที่ 2 — empCode enumeration

isAccountExists คืน ableLogin: true/false — response ที่แตกต่างกันทำให้ attacker รู้ว่า empCode ไหนมีอยู่จริง เป็น user enumeration vector แบบ classic แม้ไม่ critical ก็ตาม

สังเกต console.log ที่หลงเหลือ

บรรทัดที่ 165, 168 — console.log("isAccountExists data ->", data) และ console.log("bf loginSession ->>>>") เป็น debug print ที่ลืมลบ output จะปนไปกับ production log และมี response จาก cu-central-api ที่อาจมี PII

ขั้นตอนที่ 2 · login (mutation)

32:34:vtrc-api/api/src/resolvers/authentication.js
login: async (root, { lsid, password, clientInfo, apiKey, organization }, context, info) => {
    return auth.login(lsid, password, clientInfo, apiKey, organization)
},

login เป็น mutation ที่ public รับ password เป็น plaintext และ apiKey เป็น input จาก client (ไม่ใช่ header) Controller ที่ทำงานจริงคือ signInApp

55:90:vtrc-api/api/src/lib/controllers/auth/auth.js
const signInApp = async (sessionId, password, clientInfo, apiKey, accessRole, appName = null, organization) => {

  const mapKey = await allowedKeys.filter(value => value.key === apiKey)
  log.info(log.logTextFormat("INFO", 'Method:signInApp step 1 logLogin', 'mapKey', mapKey))

  if (mapKey.length == 0) {
    log.warning(log.logTextFormat('ACCESS_KEY_INVALID', 'Method:signInApp', 'WARNING', 'apikey wrong or not found.', { apiKey: apiKey }))
    responError("ACCESS_KEY_INVALID")
  }

  log.info(log.logTextFormat("INFO", 'Method:signInApp step 2 logLogin', 'appName', appName))
  if (appName !== null) {
    if (appName !== mapKey[0].APP_NAME) {
      log.warning(log.logTextFormat('ACCESS_KEY_INVALID', 'Method:signInApp', 'WARNING', 'apikey wrong or not found.', { apiKey: apiKey }))
      responError("ACCESS_KEY_INVALID")
    }
  }

  //check exp session
  const sessionLogin = await checkLoginSession(sessionId)
  log.info(log.logTextFormat("INFO", 'Method:signInApp step 3 logLogin', 'check LoginSession', sessionLogin))
  if (sessionLogin.loginSession.stat !== 1) {
    log.error(log.logTextFormat('SESSION_INVALID', 'Method:checkLoginSession', 'ERROR', 'Session not found.', { sessionLogin }))
    responError('SESSION_INVALID')
  }

  const appType = mapKey[0].APP_TYPE
  const setData = {
    ...clientInfo,
    APP_TYPE: appType
  }

  log.info(log.logTextFormat("INFO", 'Method:signInApp step 4 logLogin', 'setData', setData))
  // createSetting
  const result = await signInCD(sessionLogin, password, organization)

ขั้นตอนที่ signInApp ทำ

  1. L57-62 — validate apiKey โดย filter API_DEVICE_KEY array หากไม่พบ throw ACCESS_KEY_INVALID
  2. L66-72 — (backoffice เท่านั้น) เช็คว่า apiKey ตรงกับ APP_NAME ที่คาดไว้
  3. L76-82 — เช็คว่า LoginSession ยัง valid (stat === 1)
  4. L89 — forward password ไป cu-central-api (END_POINT_CENTRALIZE_AUTH/signin) ผ่าน signInCD

🔴 ช่องโหว่ที่ 3 — apiKey เป็น "1st factor" แต่ commit อยู่ใน repo

apiKey เป็น input parameter ของ mutation (ไม่ใช่ header-only) และค่า prod ทั้ง 7 ตัว commit อยู่ใน config.js:41-148 (รายละเอียดในบทที่ 9.5) ผู้โจมตีที่มี repo read สามารถส่ง request ที่ผ่าน step 1 ได้ทันที

เมื่อ apiKey รั่วไหล gate ที่เหลืออยู่คือ

  • cu-central-api ตรวจรหัสผ่านด้วย LDAP/AD bind (bindAdWithUserAndPassword)
  • นั่นคือสิ่งเดียวที่ขวาง attacker จากการ login ผิดกฎหมาย (ไม่ใช่ NestJS / ไม่ใช่ MSSQL password check)

🟡 ช่องโหว่ที่ 4 — allowedKeys[2] hardcoded index

43:49:vtrc-api/api/src/lib/controllers/auth/auth.js
export const login = async (sessionId, password, clientInfo, apiKey, organization) => {
  return await signInApp(sessionId, password, clientInfo, apiKey, ACCESS_USER, null, organization)
}

export const loginBackoffice = async (sessionId, password, clientInfo, apiKey, organization) => {
  return await signInApp(sessionId, password, clientInfo, apiKey, ACCESS_BACK_OFFICE, allowedKeys[2].APP_NAME, organization)
}

loginBackoffice ฮาร์ดโค้ด index [2] ของ API_DEVICE_KEY.prod — ถ้ามีคน reorder array ใน config.js backoffice login จะ break โดยไม่มี compile error หรือ test บอก เป็น brittle binding แบบ classic

🟡 ช่องโหว่ที่ 5 — Password plaintext ตลอดทาง

27:49:vtrc-api/api/src/centralize/controllers/auth.js
export const signIn = async (username, password, org) => {
    let url = `${END_POINT_CENTRALIZE_AUTH}/signin`
    const data = {
        username: username,
        password: password,
        org: org,
        apiKey: API_CENTRALIZE_KEY
    };
    const options = {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        data: qs.stringify(data),
        url,
    };
    const result = await axios(options)

vtrc-api ไม่ hash password เอง — forward plaintext ไป cu-central-api ผ่าน qs.stringify (form-encoded body) บน HTTP internal พึ่ง TLS เป็นตัวป้องกัน sniffing ถ้า internal network ถูก MITM (เช่นผ่าน rejectUnauthorized: false ใน hrmi.js:5-8) password รั่วออกไป

Primary credential check ที่ปลายทางคือ LDAP/AD bind — ไม่ใช่การเทียบ password hash ใน MSSQL และไม่ผ่าน NestJS centralize-api

setup/change/reset password ก็ passthrough เหมือนกัน

OperationFile:linePattern
verifyOTPAndSetupPasswordlib/repositories/verification/verification.js:82-142ส่ง password ไป cu-central-api
setupPasswordlib/repositories/verification/verification.jsเหมือนกัน
changePasswordUserlib/repositories/user/user.js:135-153เหมือนกัน
changePasswordByVerifyCodelib/repositories/user/user.js:458-465เหมือนกัน

ทั้งหมดเป็น passthrough — vtrc-api ส่ง plaintext ต่อไปยัง cu-central-api โดยไม่ hash เอง ความแข็งแรงของ credential จึงขึ้นกับนโยบาย AD/LDAP ที่ปลายทาง ไม่ใช่การเก็บ password ใน MSSQL ของ NestJS centralize-api


ขั้นตอนที่ 3 · generateToken (JWT minting)

หลัง cu-central-api คืนสัญญาณว่า login สำเร็จ (LDAP bind ผ่าน) vtrc-api สร้าง JWT และเก็บ Session row

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
            }
        );

config ของ signing

FieldValueSource
AlgorithmHS256 (default — ไม่ระบุ)jsonwebtoken@8.5.1
expiresIn'15m'JWT_EXP ที่ config.js:38
issuer'redcross.or.th:vtrc'JWT_ISSUER ที่ config.js:39
jwtidUUIDv4 ใหม่ต่อ sessionstored ใน JSON blob Session.userSession.jwtId
Payload{ id: sessionId, uid: userId }auth.js:118-120

JWT ไม่ฝัง role หรือ device ใน payload — ทำให้ token เล็ก แต่ทุก request ต้อง lookup Session เพื่อ resolve role (บทที่ 9.3 จะพูดถึง performance impact)

🔴 ช่องโหว่ที่ 6 — JWT_SECRET default เป็น 'secret'

12:12:vtrc-api/api/src/config.js
  JWT_SECRET = 'secret',

ถ้า env JWT_SECRET ไม่ถูก set (เช่น deploy ใหม่ ลืม env, container ใช้ default) vtrc-api จะ boot ด้วย secret 'secret' โดยไม่ fail ผู้โจมตีที่อ่าน source สามารถ forge JWT ที่ valid ได้ทันที เพราะ payload structure ({id, uid}) เป็น public

ไม่มี guard แบบ if (IS_PRODUCTION && JWT_SECRET === 'secret') throw ที่จะ hard-fail ที่ boot


ขั้นตอนที่ 4 · loginBypass (mobile bypass)

นอกจาก login ปกติยังมี loginBypass สำหรับ mobile ที่ใช้ RSA-signed token แทน password

162:164:vtrc-api/api/src/lib/controllers/auth/auth.js
// (ใน signInAppBypass)
crypto.verify(...)

flow นี้ใช้ crypto.createVerify('RSA-SHA256') เพื่อ verify signature ของ bypass token ที่ mobile ส่งมา เป็น public-key verification แบบถูกต้อง — เป็นส่วนที่ "แข็ง" ที่สุดใน authentication layer

แต่ loginBypass ก็เป็น mutation ที่ public เช่นกัน — attacker สามารถยิงเข้ามาได้ และถ้า private key ของ bypass token รั่ว (เก็บใน mobile app ที่ reversible) bypass flow ก็จะถูก abuse


ขั้นตอนที่ 5 · forceLoging (log injection)

63:68:vtrc-api/api/src/typeDefs/authentication.js
    forceLoging(
      errorMessage: String
      methode: String
      "json format ex. '{'id': 1, 'text': 'abc'}'"
      param: JSON
    ): ResposeStatus
329:332:vtrc-api/api/src/lib/controllers/auth/auth.js
export const forceLoging = (errorMessage = "", method = "", param = {}) => {
  log.forceLogs(log.logTextFormat("FORCE_LOGS", `Method:${method}`, "FORCE_LOGS", errorMessage, param))
  return { status: true }
}

🔴 ช่องโหว่ที่ 7 — forceLoging เป็น log injection vector

mutation นี้

  • เป็น public (ไม่มี @auth)
  • รับ errorMessage, methode (typo ใน schema), param: JSON อะไรก็ได้
  • เขียนตรงลง log.forceLogs โดยไม่ sanitize

ผู้โจมตีสามารถ

  • Poison audit log — เขียน fake "login success" หรือ fake "admin action" เพื่อ confuse forensic
  • Log-injection — ใส่ newline + fake log entry format เพื่อทำให้ log parser สับสน (ถ้า downstream ใช้ regex parser)
  • Storage exhaustion — ยิง JSON payload ขนาดใหญ่เพื่อเติม disk

ที่สำคัญที่สุด — typo ทั้งชื่อ mutation (forceLoging) และ parameter (methode) อยู่ใน public GraphQL schema แก้ไม่ได้โดยไม่ break client


ตารารางสรุปช่องโหว่ในบทนี้

#ปัญหาSeverityFile:lineFix
1Captcha ไม่ใช่ lockout🟡 Highuser.js:172-177Account lockout หลัง N attempts + rate limit
2empCode enumeration🟢 Mediumuser.js:164Generic error response
3apiKey commit ใน repo🔴 Criticalconfig.js:41-148Rotate + secret manager
4allowedKeys[2] hardcode🟢 Mediumauth.js:48lookup ด้วย APP_NAME field
5Password plaintext passthrough🟡 Highcentralize/controllers/auth.js:27-49hash ที่ centralize + don't log
6JWT_SECRET = 'secret' default🔴 Criticalconfig.js:12Hard-fail at boot ถ้า missing
7forceLoging log injection🔴 Criticalauth.js:329-332Remove หรือเปลี่ยนเป็น @auth(SUPER_ADMIN)

ช่องโหว่ที่เกี่ยวกับ JWT verify/decode และ session lookup จะอยู่ในบทถัดไป


อ่านต่อ → 9.3 JWT + session internals