Skip to content

4.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

10:63:main-api/src/routes.js
export default (app) => {
  app.post(`${API_PREFIX_PATH}/conicle/longterm`, urlencodedParser, async (req, res) => {
    // ...
  })

  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 signInResult = await Auth.signInByPass(params.username, params.org, params.apiKey)
    // ...
  })

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

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

5 endpoints ทั้งหมด application/x-www-form-urlencoded (ไม่รับ JSON)

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(ไม่เกี่ยวกับ auth)

LDAP/AD connection

lib/auth/activeDirectory.connector.js

20:48:main-api/src/lib/auth/activeDirectory.connector.js
const tlsOptions = {
  rejectUnauthorized: false,
  // cert: cer,
  // key
}

export const ldapConnection = () => {
  const ldapClient = ldap.createClient({
    url: AD_URL,
    reconnect: true,
    tlsOptions
  })
  // ...
}
const ldapClient = ldap.createClient({
  url: AD_URL,
  reconnect: true,
  tlsOptions
})
  • library: ldapjs ^2.2.0 (archived ตั้งแต่ 2023 — ดูบท 4.8)
  • 🔴 rejectUnauthorized: false — ปิด TLS cert verification บน LDAP connection
  • มี singleton ldapClient ที่ module-level (บรรทัด 37) และ factory function ldapConnection() (บรรทัด 26) — singleton ดูเหมือนไม่ถูกใช้ (caller ใช้ factory) แต่ยังเปิด connection ที่ module load

AD_IS_ENABLED gate

config.js
AD_IS_ENABLED = '0'  (default)
auth.js:45
const isAdBound = await checkSignIn(...)
// ถ้า AD_IS_ENABLED=0 → isAdBound เป็น null → login fail ทุกกรณี

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


2-stage LDAP bind

16:42:main-api/src/lib/auth/activeDirectory.model.js
const adAuthentication = async (username, password) => {
  const ldapClient = await adBindAuthorizeAccount()
  return new Promise((resolve, reject) => {
    try {
      ldapClient.bind(username, password, (err) => {
        if (err === null) {
          log.info(log.logTextFormat('AD_AUTH_SUCCESS', ...))
          ldapClient.destroy()
          resolve(true)
        } else {
          log.info(log.logTextFormat('AD_AUTH_FAILED', ...))
          ldapClient.destroy()
          resolve(false)
        }
      })
    } catch (e) { /* ... */ }
  })
}

2-stage pattern

Stage 1: adBindAuthorizeAccount()
  └── bind ด้วย service account (AD_USERNAME / AD_PASSWORD)
      └── search หา user DN ผ่าน AD_SEARCH_FILTER

Stage 2: ldapClient.bind(userDN, userPassword)
  └── bind ด้วย credential ของ user จริง
      └── resolve(true/false)

Search filter

71:73:main-api/src/lib/auth/activeDirectory.model.js
const adSearchFilterFormat = (param1, param2, param3, param4) => {
  return sprintf(AD_SEARCH_FILTER, param1, param2, param3, param4)
}

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

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


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

11:98:main-api/src/lib/auth/auth.js
const signIn = async (username, password, org, apiKey) => {
  // 1. validate params
  // 2. checkApiKey(apiKey)
  // 3. formatOrgId(org)
  // 4. getEmployeeByEmpCode(username) → MSSQL
  // 5. checkEmployeeAbleLogin(profiles) → idCard uniqueness + org blacklist
  // 6. derive usernames (ด้านล่าง)
  // 7. checkSignIn → bindAdWithUserAndPassword
  // 8. generateSignInToken (JWT)
  // 9. generateUserSessionAndStore (Redis)
  // 10. return AuthResponse
}

Step 3 — formatOrgId special-case

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

Step 5 — checkEmployeeAbleLogin

141:201:main-api/src/lib/auth/auth.controller.js
// - บังคับว่า EmpCode หนึ่งต้อง map ไป idCard hash เดียวเท่านั้น
// - ตรวจว่า org unit ไม่อยู่ใน ORG_UNIT_NOT_ALLOWED blacklist

Step 6 — derive 2 usernames

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

226:230:main-api/src/lib/auth/auth.controller.js
export const usernameShortFromIdentityCard = (identityCardNumber) => {
  const text = `${identityCardNumber}:${HASH_KEY}`
  const hash = crc.crc64(Buffer.from(text, 'utf8')).toString('hex')
  return hash
}
UsernameAlgorithmใช้ทำอะไร
internalUsernamesha256(idCard) (~50 chars)JWT uid claim
adUsernamecrc64(idCard + ':' + HASH_KEY)AD CN (สั้นกว่า)

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

Step 7 — validateSignInCredential stub

124:127:main-api/src/lib/auth/auth.js
const validateSignInCredential = (...) => {
  // TODO: validate organization and account is not disabled
  return true
}

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


JWT signing

20:30:main-api/src/lib/jwtToken.js
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 (บท 4.8)

clockTolerance แปลก

33:34:main-api/src/lib/jwtToken.js
// inject clockTolerance เป็น payload claim
return { ...payload, clockTolerance: 5 }

clockTolerance ปกติเป็น jwt.verify option — ใส่เป็น payload claim ไม่มีผล เป็น misuse ของ jsonwebtoken API

Refresh token

127:130:main-api/src/lib/jwtToken.js
const generateRefreshToken = () => {
  return sha512(uuidv4())  // unsalted SHA-512 ของ UUID
}
  • เก็บใน Redis session
  • compare ด้วย string equality ตอน refresh (jwtToken.js:101-103)
  • ถ้า refreshToken รั่ว = bearer credential ที่ใช้ได้จนกว่า session จะหมดอายุ

Redis session

18:20:main-api/src/lib/session/session.js
const sessionKey = (uid, sid) => {
  return `${RedisController.getRedisPrefix('SESSION')}:${uid}_${sid}`
}

key pattern SESSION:<prefix>:<uid>_<sid>

Signin

32:51:main-api/src/lib/session/session.js
// generateUserSessionAndStore
//   store: { sessionId, refreshToken, identityCard, userId, appName, audience }
//   TTL: จาก RedisController

Signout

53:55:main-api/src/lib/session/session.js
const destroyUserSession = (uid, sid) => {
  return redis.del(sessionKey(uid, sid))
}

session lookup ใน @auth directive ผ่าน getSessionFromTokenCredential (session.js:57-65)


/auth/signinbypass — passwordless signin

27:31:main-api/src/routes.js
  app.post(`${API_PREFIX_PATH}/auth/signinbypass`, urlencodedParser, async (req, res) => {
    const signInResult = await Auth.signInByPass(params.username, params.org, params.apiKey)
    // ...
  })

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

  • live ใน production image
  • ไม่มี authentication check ใด ๆ นอกจาก apiKey (ที่ commit อยู่ใน code — บท 4.4)
  • ใครที่ถือ apiKey สามารถ impersonate user ใดก็ได้ที่รู้ username (empCode)

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


สรุป flow

ขั้นหน้าที่ความเสี่ยง
1validate params
2checkApiKeyapiKey commit ใน code (4.4)
3formatOrgIdDUP* special-case hardcode UUID
4getEmployeeByEmpCode
5checkEmployeeAbleLogin
6derive usernamesHASH_KEY default empty
7LDAP bind 2-stage🔴 rejectUnauthorized: false
8generateSignInToken🔴 JWT_SECRET default empty (4.8)
9store Redis session

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


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