Skip to content

3.2.3 · Authentication + LDAP/AD flow

บทนี้เจาะ flow การ login ของ cu-central-api ซึ่งต่างจาก vtrc-api อย่างสำคัญ — cu-central-api เป็นเจ้าของ LDAP/AD bind โดยตรง ไม่ได้ delegate ให้ใคร และ login endpoint เป็น REST (ไม่ใช่ GraphQL mutation)


Login flow ในภาพรวม

┌─────────────┐
│  vtrc-api   │  POST /auth/signin (form-encoded)
│  (caller)   │  username + password + org + apiKey
└──────┬──────┘


┌─────────────────────────────────────────────────────────────────┐
│  cu-central-api / lib/auth/auth.js  signIn()                    │
│                                                                  │
│  1. validate params                                             │
│  2. checkApiKey(apiKey)        ← hard-coded 2 keys              │
│  3. formatOrgId(org)           ← special-case DUP* org codes    │
│  4. getEmployeeByEmpCode(username)  → MSSQL lookup              │
│  5. checkEmployeeAbleLogin()   ← org blacklist + idCard unique  │
│  6. derive usernames:                                           │
│     ├── internalUsername = sha256(idCard)         → JWT uid     │
│     └── adUsername = crc64(idCard:HASH_KEY)       → AD CN       │
│  7. checkSignIn() → bindAdWithUserAndPassword()                │
│     └── LDAP bind 2-stage                                       │
│         ├── stage 1: bind with service account                  │
│         └── stage 2: bind with user DN + password               │
│  8. generateSignInToken()  → JWT sign (HS256, 20m)              │
│  9. generateUserSessionAndStore()  → Redis session              │
│  10. return { accessToken, refreshToken, profileData }          │
└─────────────────────────────────────────────────────────────────┘


┌─────────────┐
│  vtrc-api   │  forward token ไปยัง client
└─────────────┘

REST auth routes (routes.js)

cu-central-api เปิด auth endpoint เป็น REST (ไม่ใช่ GraphQL) — แตกต่างจาก vtrc-api ที่ใช้ GraphQL mutation

export default (app) => {
  app.post(`${API_PREFIX_PATH}/conicle/longterm`, urlencodedParser, async (req, res) => {
    const params = req.body
    if (typeof params.runLong !== 'undefined' && params.runLong === '20k') {
      console.log('Run Long term and send to Conicle.')
      await transferFileToConicle('long')
      console.log('Run finished.')
    }
    res.send('OK').end()
  })

  app.post(`${API_PREFIX_PATH}/auth/signin`, urlencodedParser, async (req, res) => {
    const params = req.body
    const signInResult = await Auth.signIn(params.username, params.password, params.org, params.apiKey)
    const ssoLoginResponse = await ssoLogin(params.username, params.password)
    res.send(JSON.stringify({ ...signInResult, ssoLoginResponse })).end()
  })

  app.post(`${API_PREFIX_PATH}/auth/signinbypass`, urlencodedParser, async (req, res) => {
    const params = req.body
    const signInResult = await Auth.signInByPass(params.username, params.org, params.apiKey)
    res.send(JSON.stringify(signInResult)).end()
  })

  app.post(`${API_PREFIX_PATH}/auth/token`, urlencodedParser, async (req, res) => {
    const params = req.body
    const newToken = await Auth.refreshToken(params.refreshToken, params.accessToken, params.apiKey, params.clientId)
    res.send(JSON.stringify(newToken)).end()
  })

  app.post(`${API_PREFIX_PATH}/auth/signout`, urlencodedParser, async (req, res) => {
    const params = req.body
    const signOutResult = await Auth.signOut(params.accessToken, params.apiKey)
    res.send(JSON.stringify(signOutResult)).end()
  })
}

5 endpoints ทั้งหมด application/x-www-form-urlencoded (ไม่รับ JSON) เพราะใช้ bodyParser.urlencoded({ extended: false }) (routes.js:8)

Endpointหน้าที่ความเสี่ยง
POST /auth/signinlogin หลัก — รับ passwordplaintext password ผ่าน internal HTTP
POST /auth/signinbypasspasswordless signin🔴 รับแค่ username + org + apiKey ไม่ตรวจ credential
POST /auth/tokenrefresh tokenรับ refreshToken + accessToken เก่า
POST /auth/signoutlogoutลบ Redis session
POST /conicle/longtermConicle SFTP trigger (ต้องส่ง runLong=20k)ไม่เกี่ยวกับ auth

LDAP/AD connection

lib/auth/activeDirectory.connector.js

import ldap from 'ldapjs'
import { AD_BASE_DN, AD_PASSWORD, AD_URL, AD_USERNAME } from '../../config'
import fs from 'fs'
const tlsOptions = {
  rejectUnauthorized: false,
  // cert: cer,
  // key
}

export const ldapConnection = () => {
  const ldapClient = ldap.createClient({
    url: AD_URL,
    reconnect: true,
    tlsOptions
  })
  ldapClient.on('error', (err) => {
    console.warn(`${new Date().toLocaleString()} | ⚠️ LDAP connection failed, and reconnect OK.`, err)
  })
  return ldapClient
}
const ldapClient = ldap.createClient({
  url: AD_URL,
  reconnect: true,
  tlsOptions
})
ldapClient.on('error', (err) => {
  console.warn(`${new Date().toLocaleString()} |⚠️ LDAP connection failed, and reconnect OK.`, err)
})

export { ldapClient }
  • library: ldapjs ^2.2.0 (cu-central-api/main-api/package.json:30 — archived ตั้งแต่ 2023 ดู 3.2.8)
  • 🔴 rejectUnauthorized: false (activeDirectory.connector.js:21) — ปิด TLS cert verification บน LDAP connection
  • มี singleton ldapClient ที่ module-level (บรรทัด 37) และ factory function ldapConnection() (บรรทัด 26) — singleton ดูเหมือนไม่ถูกใช้ (caller ใช้ factory) แต่ยังเปิด connection ที่ module load

AD_IS_ENABLED gate

AD_IS_ENABLED = 0  (default)
const signedInInfo = await checkSignIn((+AD_IS_ENABLED ? adUsername : username), password)
// ถ้า AD_IS_ENABLED=0 → ใช้ raw username (empCode) ในการ bind → ล้มเหลวเสมอ

โดย default AD_IS_ENABLED=0 — service boot ได้แต่ login ไม่ได้จนกว่าจะ set AD_IS_ENABLED=1 ใน env


2-stage LDAP bind

const adAuthentication = async (username, password) => {
  const ldapClient = await adBindAuthorizeAccount()
  return new Promise((resolve, reject) => {
    // With ldapjs
    try {
      ldapClient.bind(username, password, (err) => {
        // "err IS NULL" = not error / success
        if (err === null) {
          log.info(log.logTextFormat('AD_AUTH_SUCCESS', 'Method:adAuthentication', 'INFO', 'Authen with AD succeeded.', { username }))
          ldapClient.destroy()
          resolve(true)
        } else {
          log.info(log.logTextFormat('AD_AUTH_FAILED', 'Method:adAuthentication', 'ERROR', 'Authen with AD failed.', { username, err }))
          ldapClient.destroy()
          resolve(false)
        }
      })
    } catch (e) {
      log.error(log.logTextFormat('AD_BIND_ERROR', 'Method:adAuthentication', 'ERROR', '', { error: e }))
      if (ldapClient) {
        ldapClient.destroy()
      }
      resolve(false)
    }
  })
}

2-stage pattern

Stage 1: adBindAuthorizeAccount()
  └── bind ด้วย service account (AD_USERNAME / AD_PASSWORD)
      └── return ldapClient ที่ authenticated

Stage 2: ldapClient.bind(userDN, userPassword)
  └── bind ด้วย credential ของ user จริง
      └── resolve(true/false)
const adBindAuthorizeAccount = async () => {
  const ldapClient = ldapConnection()
  return new Promise((resolve, reject) => {
    // With ldapjs
    try {
      ldapClient.bind(AD_USERNAME, AD_PASSWORD, (err) => {
        // "err IS NULL" = not error or success
        if (err === null) {
          resolve(ldapClient)
        } else {
          log.info(log.logTextFormat('AD_AUTH_FAILED', 'Method:adBindAuthorizeAccount', 'ERROR', 'Authen with authorize account from AD failed.', { AD_USERNAME, err }))
          resolve(false)
        }
      })
    } catch (e) {
      console.log('AD_BIND_ERROR', e)
      log.error(log.logTextFormat('AD_BIND_ERROR', 'Method:adBindAuthorizeAccount', 'ERROR', '', { error: e }))
      resolve(false)
    }
  })
}

Search filter

const adSearchFilterFormat = (param1, param2, param3, param4) => {
  return sprintf(AD_SEARCH_FILTER, param1, param2, param3, param4)
}

default AD_SEARCH_FILTER = '(&(cn=%1$s)(objectClass=person))' (config.js:55) — sprintf รับ 4 args แต่ใช้แค่ตัวแรก (CN)

size limit hardcoded ที่ 50 (activeDirectory.model.js:85)

const adSearch = async (cn, dn = '', baseDn = AD_BASE_DN) => {
  const optss = {
    filter: adSearchFilterFormat(cn[0], cn[1]),
    scope: 'sub',
    sizeLimit: 50
    // attributes: ['dn', 'sn', 'cn']
  }

ขั้นตอน login (controller layer)

const signIn = async (username, password, org, apiKey) => {
  const validated = validateParams({ username, password, org, apiKey }, ['username', 'password', 'org', 'apiKey'])
  // ...
  if (validated.result) {
    const appInfo = checkApiKey(apiKey)
    if (typeof appInfo !== 'undefined') {
      const appId = appInfo.appId
      const appName = appInfo.appName

      const orgFormat = formatOrgId(org)
      org = orgFormat.org
      const orgUnit = orgFormat.orgUnit

      const profiles = await getEmployeeByEmpCode(username)
      const isProfileAllowed = await checkEmployeeAbleLogin(profiles)
      if (isProfileAllowed) {
        const identityCard = getIdCardFromEmpProfileObj(profiles[0])
        if (identityCard !== null) {
          const internalUsername = usernameFromIdentityCard(identityCard)
          const adUsername = usernameShortFromIdentityCard(identityCard)

          const signedInInfo = await checkSignIn((+AD_IS_ENABLED ? adUsername : username), password)
          if (signedInInfo !== null) {
            if (validateSignInCredential(signedInInfo)) {
              const sessionId = generateSessionId()
              const audience = `${appId}:${createHashSha(uuidv4())}`
              const tokens = generateSignInToken(sessionId, internalUsername, appId, audience)
              const mainProfile = await getMainProfile(identityCard, username)
              const empProfile = formatWorkprofileFromEmployee(mainProfile[0])
              const profileData = { uid: internalUsername, profileKey: empProfile.profileKey, sourceDb: empProfile.sourceDb }
              const sessionData = generateUserSessionAndStore(sessionId, internalUsername, tokens.refreshToken, identityCard, appName, audience)
              if (sessionData) {
                return AuthResponse.signIn(tokens.token, tokens.refreshToken, tokens.tokenType, profileData)
              }
            }
          }
        }
      }
    }
  }
}

Step 3 — formatOrgId special-case

export const formatOrgId = (org) => {
  const response = {
    org: null,
    orgUnit: null
  }
  org = org.toString()
  if (org.startsWith('DUP')) {
    response.org = '3F3BF3AD-B4C9-4D44-A56F-AB55C4E4FB01-00'
  } else {
    const splitOrg = org.split('_')
    if (splitOrg.length === 2) {
      // Have an orgUnit and org.
      response.org = splitOrg[0]
      // ...

org code ที่ขึ้นต้นด้วย DUP จะถูก remap ไป UUID 3F3BF3AD-B4C9-4D44-A56F-AB55C4E4FB01-00 ที่ hardcoded — เป็นทางลัดสำหรับ "หน่วยงาน Red Cross อื่น ๆ ที่ไม่ใช่ Chula"

Step 5 — checkEmployeeAbleLogin

export const checkEmployeeAbleLogin = async (empProfiles) => {
  let isAllowed = false
  // Profile from EmpCode should be at least one
  if (empProfiles.length >= 1) {
    const idTemp = {}
    for (const emp of empProfiles) {
      if (typeof emp.personInfo !== 'undefined') {
        if (typeof emp.personInfo.identityCard !== 'undefined') {
          const idCard = cleansingIdentityCardNumber(emp.personInfo.identityCard)
          if (typeof idTemp[idCard] === 'undefined') {
            idTemp[idCard] = 0
          }
          idTemp[idCard]++
        }
      }
    }
    // Check only one ID card number from EmpCode
    isAllowed = Object.keys(idTemp).length <= 1
    if (!isAllowed) {
      // Not allowed because single Employee Code have many difference ID Card
      log.warning(log.logTextFormat('DATA_WARNING', 'Method:checkEmployeeAbleLogin', 'WARNING', 'Single Employee Code have many difference identity card.', { empCode: empProfiles.map(emp => emp.empCode) }))
    } else {
      // Allowed then check from filter from main profile
      const mainProfile = empProfiles[0]
      isAllowed = await filterEmployeeByOrgUnit(mainProfile.workingProfile.orgUnitId, mainProfile.sourceDb)
    }
  }
  return isAllowed
}
  • บังคับว่า EmpCode หนึ่งต้อง map ไป idCard hash เดียวเท่านั้น (idTemp keys ≤ 1)
  • ตรวจว่า org unit ไม่อยู่ใน ORG_UNIT_NOT_ALLOWED blacklist (config.js:60) ผ่าน filterEmployeeByOrgUnit

Step 6 — derive 2 usernames

cu-central-api ใช้ idCard เป็นตัวเชื่อมระหว่าง MSSQL employee กับ AD account — แต่ AD CN จำกัดความยาว จึงต้อง derive 2 ชื่อ

export const usernameShortFromIdentityCard = (identityCardNumber) => {
  const text = `${identityCardNumber}:${HASH_KEY}`
  const hash = crc.crc64(Buffer.from(text, 'utf8')).toString('hex')
  return hash
}
export const usernameFromIdentityCard = (identityCardNumber) => {
  let user = null
  if (typeof identityCardNumber === 'string') {
    const text = cleansingIdentityCardNumber(identityCardNumber)
    if (text !== '') {
      user = createHashSha(text, 'sha256', true)
    }
  }
  return user
}
UsernameAlgorithmใช้ทำอะไร
internalUsernamesha256(idCard) (~50 chars)JWT uid claim
adUsernamecrc64(idCard + ':' + HASH_KEY)AD CN (สั้นกว่า)

HASH_KEY คือ env var (config.js:10 default empty string) — ทำหน้าที่เป็น HMAC key ของ AD username

Step 7 — validateSignInCredential stub

const validateSignInCredential = (profile) => {
  // TODO: Create script for validate organization and account is not disabled.
  return true
}

validate ที่ TODO ไว้ตั้งแต่เขียนครั้งแรก — ไม่เคย implement


JWT signing

const getPrivayeKey = () => {
  return JWT_SECRET
}

typo getPrivayeKey (ควรเป็น getPrivateKey) — ไม่มี impact ต่อ behavior แต่บ่งบอบว่าไม่มี code review ที่จริงจังในจุดนี้

const jwtOption = (option = {}) => {
  return {
    ...{
      algorithm: 'HS256',
      expiresIn: JWT_EXPIRE_IN,
      issuer: JWT_ISSUER,
      jwtid: uuidv4()
    },
    ...option
  }
}
FieldValueSource
AlgorithmHS256hardcoded
expiresIn'20m'JWT_EXPIRE_IN (config.js:17)
issuer'' (default empty)JWT_ISSUER (config.js:15) — verified ตอน refresh
jwtidUUIDv4 ใหม่ต่อ tokenuuidv4()
SecretJWT_SECRET🔴 default empty string (ดู 3.2.8)

clockTolerance แปลก

export const generateToken = (data, option = {}) => {
  let dataOption = {
    clockTolerance: 5
  }
  if (Object.keys(option).length > 0) {
    dataOption = { ...dataOption, ...option }
  }
  data = { ...data, ...dataOption }

clockTolerance ปกติเป็น jwt.verify option — ใส่เป็น payload claim (ฝังใน JWT เอง) ไม่มีผล ต่อการ verify จริง เป็น misuse ของ jsonwebtoken API

Refresh token

const generateRefreshToken = () => {
  const hash = uuidv4()
  return createHashSha(hash, 'sha512')
}
  • uuidv4() สุ่ม แล้ว hash ด้วย SHA-512 ไม่มี salt — เก็บใน Redis session
  • compare ด้วย string equality ตอน refresh (jwtToken.js:101-103)
  • ถ้า refreshToken รั่ว = bearer credential ที่ใช้ได้จนกว่า session จะหมดอายุ

Redis session

export const buildSessionPattern = (sessionId, userUid) => {
  return `${sessionPrefix.name}:${userUid}_${sessionId}`
}

key pattern SESSION:{redisPrefix}:{userUid}_{sessionId} (ต่างจาก vtrc-api ที่ใช้ UUID เดี่ยว)

Signin

export const generateUserSessionAndStore = async (sessionId, userId, refreshToken, identityCard, appName, audience) => {
  const sessionData = {
    sessionId,
    refreshToken,
    identityCard,
    userId,
    appName,
    audience
  }
  // Store at redis
  const saved = await storeSession(sessionId, userId, sessionData)
  if (saved) {
    return {
      sid: sessionId,
      uid: userId
    }
  } else {
    return null
  }
}

Signout

export const destroyUserSession = async (sessionId, userId) => {
  return deleteSession(sessionId, userId)
}

session lookup ใน @auth directive ผ่าน getSessionFromTokenCredential

export const getSessionFromTokenCredential = async (userCredential) => {
  let sessionData = null
  if (typeof userCredential !== 'undefined') {
    if (typeof userCredential.sid !== 'undefined' && typeof userCredential.uid !== 'undefined') {
      sessionData = await fetchSession(userCredential.sid, userCredential.uid)
    }
  }
  return sessionData
}

จุดที่ดีกว่า vtrc-api — session lookup ใช้ Redis key pattern โดยตรง ไม่ใช่ LIKE '%jwtid%' JSON scan (ที่ vtrc-api ใช้) ทำให้เร็วกว่าและไม่มี substring collision risk


/auth/signinbypass — passwordless signin

  app.post(`${API_PREFIX_PATH}/auth/signinbypass`, urlencodedParser, async (req, res) => {
    const params = req.body
    const signInResult = await Auth.signInByPass(params.username, params.org, params.apiKey)
    // Response SignedIn data
    res.send(JSON.stringify(signInResult)).end()
  })
const signInByPass = async (username, org, apiKey) => {
  const validated = validateParams({ username, org, apiKey }, ['username', 'org', 'apiKey'])
  if (validated.result) {
    const appInfo = checkApiKey(apiKey)
    if (typeof appInfo !== 'undefined') {
      const appId = appInfo.appId
      const appName = appInfo.appName

      const orgFormat = formatOrgId(org)
      org = orgFormat.org
      const orgUnit = orgFormat.orgUnit

      const profiles = await getEmployeeByEmpCode(username)
      const isProfileAllowed = await checkEmployeeAbleLogin(profiles)
      if (isProfileAllowed) {
        const identityCard = getIdCardFromEmpProfileObj(profiles[0])
        if (identityCard !== null) {
          const internalUsername = usernameFromIdentityCard(identityCard)

          const sessionId = generateSessionId()
          const audience = `${appId}:${createHashSha(uuidv4())}`
          const tokens = generateSignInToken(sessionId, internalUsername, appId, audience)
          const mainProfile = await getMainProfile(identityCard, username)
          const empProfile = formatWorkprofileFromEmployee(mainProfile[0])
          const profileData = { uid: internalUsername, profileKey: empProfile.profileKey, sourceDb: empProfile.sourceDb }
          const sessionData = generateUserSessionAndStore(sessionId, internalUsername, tokens.refreshToken, identityCard, appName, audience)
          if (sessionData) {
            return AuthResponse.signIn(tokens.token, tokens.refreshToken, tokens.tokenType, profileData)
          }
        }
      }
    }
  }
}

🔴 Critical (SEC-CU-04) — endpoint ที่รับแค่ username + org + apiKey แล้ว mint token เต็มโดยไม่ตรวจ credential

  • live ใน production image
  • ไม่มี authentication check ใด ๆ นอกจาก apiKey (ที่ commit อยู่ใน code — ดู 3.2.4)
  • ใครที่ถือ apiKey สามารถ impersonate user ใดก็ได้ที่รู้ username (empCode)
  • ข้าม LDAP/AD bind ทั้ง 2 stage — ไม่ต้องผ่าน checkSignIn

endpoint นี้น่าจะมีไว้สำหรับ integration testing หรือ SSO fallback — แต่ไม่ควรอยู่ใน prod image

Fix: gate ด้วย IS_PRODUCTION หรือลบออกจาก routes ถ้าไม่จำเป็น


SSO side-call ใน signin

จุดที่น่าสนใจ — routes.js:25 หลังจาก Auth.signIn สำเร็จ ยังเรียก ssoLogin(params.username, params.password) อีกครั้ง

  app.post(`${API_PREFIX_PATH}/auth/signin`, urlencodedParser, async (req, res) => {
    const params = req.body
    const signInResult = await Auth.signIn(params.username, params.password, params.org, params.apiKey)
    log.info(log.logTextFormat('INFO', 'Method:Post | Route:/auth/signin step1 logLogin', 'INFO', 'signInResult', signInResult))
    const ssoLoginResponse = await ssoLogin(params.username, params.password)
    // Response SignedIn data
    // res.send(JSON.stringify(signInResult)).end()
    res.send(JSON.stringify({ ...signInResult, ssoLoginResponse })).end()
  })

password ถูกส่งซ้ำไปยัง SSO service แม้ว่า signIn จะ verify ผ่าน AD แล้ว — เป็นการรวม credential จาก 2 source (AD + SSO) ใน response เดียว


สรุป flow

ขั้นหน้าที่ความเสี่ยง
1validate params
2checkApiKeyapiKey commit ใน code (3.2.4)
3formatOrgIdDUP* special-case hardcode UUID
4getEmployeeByEmpCode
5checkEmployeeAbleLoginorg blacklist + idCard uniqueness
6derive usernamesHASH_KEY default empty
7LDAP bind 2-stage🔴 rejectUnauthorized: false
8generateSignInToken🔴 JWT_SECRET default empty (3.2.8)
9store Redis session
10return tokens + profileDataplaintext password ส่งต่อไป SSO

cu-central-api เป็นเจ้าของ LDAP/AD โดยตรง — ต่างจาก vtrc-api ที่ forward password มา ดังนั้นจุดที่ password verify โดยแท้จะเกิดที่นี่


อ่านต่อ → 3.2.4 @auth directive + bypass