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/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 (ต้องส่ง 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 functionldapConnection()(บรรทัด 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 เดียวเท่านั้น (
idTempkeys ≤ 1) - ตรวจว่า org unit ไม่อยู่ใน
ORG_UNIT_NOT_ALLOWEDblacklist (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
}| Username | Algorithm | ใช้ทำอะไร |
|---|---|---|
internalUsername | sha256(idCard) (~50 chars) | JWT uid claim |
adUsername | crc64(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
}
}| 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 (ดู 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
| ขั้น | หน้าที่ | ความเสี่ยง |
|---|---|---|
| 1 | validate params | — |
| 2 | checkApiKey | apiKey commit ใน code (3.2.4) |
| 3 | formatOrgId | DUP* special-case hardcode UUID |
| 4 | getEmployeeByEmpCode | — |
| 5 | checkEmployeeAbleLogin | org blacklist + idCard uniqueness |
| 6 | derive usernames | HASH_KEY default empty |
| 7 | LDAP bind 2-stage | 🔴 rejectUnauthorized: false |
| 8 | generateSignInToken | 🔴 JWT_SECRET default empty (3.2.8) |
| 9 | store Redis session | — |
| 10 | return tokens + profileData | plaintext password ส่งต่อไป SSO |
cu-central-api เป็นเจ้าของ LDAP/AD โดยตรง — ต่างจาก vtrc-api ที่ forward password มา ดังนั้นจุดที่ password verify โดยแท้จะเกิดที่นี่
อ่านต่อ → 3.2.4 @auth directive + bypass