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_CENTRALIZE→cu-central-api/api/graphql(เช่นisAccountExists)- Auth REST:
END_POINT_CENTRALIZE_AUTH→cu-central-api/auth(/signin,/signinbypass,/signout,/token)- ตรวจรหัสผ่านหลัก = LDAP/AD bind ที่ cu-central-api — ไม่ใช่ NestJS
centralize-apiและ ไม่ใช่ การ verify password ใน MSSQL
NestJScentralize-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 ที่จะใช้ติดตาม
verifyEmpCodeLogin (
empCode: String!ใน controller checkAccountExit มี 4 ขั้นย่อย
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
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)
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
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 ทำ
- L57-62 — validate apiKey โดย filter
API_DEVICE_KEYarray หากไม่พบ throwACCESS_KEY_INVALID - L66-72 — (backoffice เท่านั้น) เช็คว่า apiKey ตรงกับ APP_NAME ที่คาดไว้
- L76-82 — เช็คว่า
LoginSessionยัง valid (stat === 1) - 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
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 ตลอดทาง
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 เหมือนกัน
| Operation | File:line | Pattern |
|---|---|---|
verifyOTPAndSetupPassword | lib/repositories/verification/verification.js:82-142 | ส่ง password ไป cu-central-api |
setupPassword | lib/repositories/verification/verification.js | เหมือนกัน |
changePasswordUser | lib/repositories/user/user.js:135-153 | เหมือนกัน |
changePasswordByVerifyCode | lib/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
const accessToken = jwt.sign(jwtSignObject, JWT_SECRET,
{
expiresIn: JWT_EXP,
issuer: JWT_ISSUER,
jwtid: jwtid
}
);config ของ signing
| Field | Value | Source |
|---|---|---|
| Algorithm | HS256 (default — ไม่ระบุ) | jsonwebtoken@8.5.1 |
| expiresIn | '15m' | JWT_EXP ที่ config.js:38 |
| issuer | 'redcross.or.th:vtrc' | JWT_ISSUER ที่ config.js:39 |
| jwtid | UUIDv4 ใหม่ต่อ session | stored ใน 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'
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
// (ใน 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)
forceLoging(
errorMessage: String
methode: String
"json format ex. '{'id': 1, 'text': 'abc'}'"
param: JSON
): ResposeStatusexport 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
ตารารางสรุปช่องโหว่ในบทนี้
| # | ปัญหา | Severity | File:line | Fix |
|---|---|---|---|---|
| 1 | Captcha ไม่ใช่ lockout | 🟡 High | user.js:172-177 | Account lockout หลัง N attempts + rate limit |
| 2 | empCode enumeration | 🟢 Medium | user.js:164 | Generic error response |
| 3 | apiKey commit ใน repo | 🔴 Critical | config.js:41-148 | Rotate + secret manager |
| 4 | allowedKeys[2] hardcode | 🟢 Medium | auth.js:48 | lookup ด้วย APP_NAME field |
| 5 | Password plaintext passthrough | 🟡 High | centralize/controllers/auth.js:27-49 | hash ที่ centralize + don't log |
| 6 | JWT_SECRET = 'secret' default | 🔴 Critical | config.js:12 | Hard-fail at boot ถ้า missing |
| 7 | forceLoging log injection | 🔴 Critical | auth.js:329-332 | Remove หรือเปลี่ยนเป็น @auth(SUPER_ADMIN) |
ช่องโหว่ที่เกี่ยวกับ JWT verify/decode และ session lookup จะอยู่ในบทถัดไป
อ่านต่อ → 9.3 JWT + session internals