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
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/signin | login หลัก — รับ password | plaintext password ผ่าน internal HTTP |
POST /auth/signinbypass | passwordless signin | 🔴 รับแค่ username + org + apiKey ไม่ตรวจ credential |
POST /auth/token | refresh token | รับ refreshToken + accessToken เก่า |
POST /auth/signout | logout | ลบ Redis session |
POST /conicle/longterm | Conicle SFTP trigger | (ไม่เกี่ยวกับ auth) |
LDAP/AD connection
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 functionldapConnection()(บรรทัด 26) — singleton ดูเหมือนไม่ถูกใช้ (caller ใช้ factory) แต่ยังเปิด connection ที่ module load
AD_IS_ENABLED gate
AD_IS_ENABLED = '0' (default)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
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
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)
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
// - บังคับว่า EmpCode หนึ่งต้อง map ไป idCard hash เดียวเท่านั้น
// - ตรวจว่า org unit ไม่อยู่ใน ORG_UNIT_NOT_ALLOWED blacklistStep 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
}| Username | Algorithm | ใช้ทำอะไร |
|---|---|---|
internalUsername | sha256(idCard) (~50 chars) | JWT uid claim |
adUsername | crc64(idCard + ':' + HASH_KEY) | AD CN (สั้นกว่า) |
HASH_KEY คือ env var (default empty string) — ทำหน้าที่เป็น HMAC key ของ AD username
Step 7 — validateSignInCredential stub
const validateSignInCredential = (...) => {
// TODO: validate organization and account is not disabled
return true
}validate ที่ TODO ไว้ตั้งแต่เขียนครั้งแรก — ไม่เคย implement
JWT signing
const jwtOption = (option = {}) => {
return {
...{
algorithm: 'HS256',
expiresIn: JWT_EXPIRE_IN,
issuer: JWT_ISSUER,
jwtid: uuidv4()
},
...option
}
}| Field | Value | Source |
|---|---|---|
| Algorithm | HS256 | hardcoded |
| expiresIn | '20m' | JWT_EXPIRE_IN (config.js:17) |
| issuer | '' (default empty) | JWT_ISSUER (config.js:15) — verified ตอน refresh |
| jwtid | UUIDv4 ใหม่ต่อ token | uuidv4() |
| Secret | JWT_SECRET | 🔴 default empty string (บท 4.8) |
clockTolerance แปลก
// inject clockTolerance เป็น payload claim
return { ...payload, clockTolerance: 5 }clockTolerance ปกติเป็น jwt.verify option — ใส่เป็น payload claim ไม่มีผล เป็น misuse ของ jsonwebtoken API
Refresh token
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
const sessionKey = (uid, sid) => {
return `${RedisController.getRedisPrefix('SESSION')}:${uid}_${sid}`
}key pattern SESSION:<prefix>:<uid>_<sid>
Signin
// generateUserSessionAndStore
// store: { sessionId, refreshToken, identityCard, userId, appName, audience }
// TTL: จาก RedisControllerSignout
const destroyUserSession = (uid, sid) => {
return redis.del(sessionKey(uid, sid))
}session lookup ใน @auth directive ผ่าน getSessionFromTokenCredential (session.js:57-65)
/auth/signinbypass — passwordless signin
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
| ขั้น | หน้าที่ | ความเสี่ยง |
|---|---|---|
| 1 | validate params | — |
| 2 | checkApiKey | apiKey commit ใน code (4.4) |
| 3 | formatOrgId | DUP* special-case hardcode UUID |
| 4 | getEmployeeByEmpCode | — |
| 5 | checkEmployeeAbleLogin | — |
| 6 | derive usernames | HASH_KEY default empty |
| 7 | LDAP bind 2-stage | 🔴 rejectUnauthorized: false |
| 8 | generateSignInToken | 🔴 JWT_SECRET default empty (4.8) |
| 9 | store Redis session | — |
cu-central-api เป็นเจ้าของ LDAP/AD โดยตรง — ต่างจาก vtrc-api ที่ forward password มา ดังนั้นจุดที่ password verify โดยแท้จะเกิดที่นี่
อ่านต่อ → 4.4 @auth directive + bypass