3.2 · GraphQL request pipeline
บทนี้เล่าว่า request หนึ่งตัวเดินทางจาก POST /api/graphql จนถึง resolver และกลับ — ผ่านกี่ gate แต่ละ gate ทำอะไร และจุดไหนที่มักเป็นปัญหา
ถ้า debug "ทำไม request ถูก reject ด้วย TOKEN_INVALID" หรือ "ทำไม cache ไม่ทำงาน" — เริ่มที่บทนี้
ภาพรวม 5 gates
ทุก GraphQL request ผ่าน gate เรียงตามลำดับนี้ ถ้า gate ไหน fail → reject ทันที ไม่เข้า gate ถัดไป
Browser POST /api/graphql
│ Headers: apiKey: <uuid>
│ Authorization: Bearer <jwt>
▼
┌─────────────────────────────────────────────────────────────┐
│ Gate #1 · apiKey │
│ index.js:33-38 context() │
│ ตรวจ apiKey ต้องตรง API_DEVICE_KEY[API_ACCESS] │
│ ถ้าไม่ตรง → responError('ACCESS_KEY_INVALID') │
└─────────────────────────────────────────────────────────────┘
│ resolve APP_TYPE จาก mapKey[0].APP_TYPE
▼
┌─────────────────────────────────────────────────────────────┐
│ Gate #2 · JWT decode (lightweight) │
│ index.js:41-44 getPayLoad(token) │
│ auth/auth.js:23-41 jwt.decode (ไม่ใช่ verify) │
│ ดึง uid + ใส่ใน context │
│ ถ้า token == null → loggedIn: false (ไม่ reject) │
└─────────────────────────────────────────────────────────────┘
│ context = { token, userId, loggedIn, device, appId, headers }
▼
┌─────────────────────────────────────────────────────────────┐
│ Apollo dispatch → resolver │
│ ถ้า field ติด @auth → gate #3-5 │
│ ถ้าไม่ติด → วิ่งเข้า resolver ทันที │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Gate #3 · JWT verify │
│ directives/auth.js:23-31 │
│ repositories/auth/auth.js:250-258 verifyToken │
│ jwt.verify + expiresIn + issuer + jwtId │
│ TokenExpiredError → 'TOKEN_EXPIRED' │
│ อื่นๆ → 'TOKEN_INVALID' │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Gate #4 · Session + role check │
│ directives/auth.js:43 │
│ repositories/session/session.js:20-48 checkSession │
│ ตรวจ role ต้องอยู่ใน accessRole[] │
│ ตรวจ Session.userSession ต้องมี jwtId ตรง │
│ ตรวจ client = device │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Gate #5 · Menu access (ABAC) │
│ directives/auth.js:44 │
│ repositories/session/session.js:50-70 checkMenuAuth │
│ ถ้า accessMenu ไม่กำหนด → ข้าม │
│ ถ้ากำหนด → user ต้องมี role ที่มี RoleAccess.nameEN ตรง │
└─────────────────────────────────────────────────────────────┘
│
▼
resolver (resolvers/*.js) → controller → repository → Models / centralizeGate #1 — apiKey (gateway class check)
ก่อนที่จะถอด JWT หรือ run resolver — ต้องผ่าน apiKey check ก่อน
const apiKey = req.headers.apikey
const mapKey = await allowedKeys.filter(value => value.key === apiKey)
if (mapKey.length === 0) {
responError('ACCESS_KEY_INVALID')
}allowedKeysคือ array จากAPI_DEVICE_KEY[API_ACCESS](ดู บท 3.1) — มี 7 ตัวใน prod, 10 ตัวใน uat- การ lookup ใช้
Array.filterทำให้เป็น O(N) ต่อ request — ที่ N=7 ไม่ใช่ปัญหา แต่เป็น code smell - ถ้าไม่ตรง →
responError('ACCESS_KEY_INVALID')ซึ่ง throw error ออกไป (ดูในlib/responErrors.js)
สิ่งที่ gate นี้ resolve
ถ้าผ่าน → context จะมี device (APP_TYPE) และ appId ให้ resolver ใช้ต่อ
return {
token,
userId: user.uid,
loggedIn,
device: mapKey[0].APP_TYPE,
appId: mapKey[0].id,
headers: req.headers
}device จะถูกใช้ใน gate #4 เพื่อ enforce ว่า Session.client ต้องตรงกับ device class ที่ login มา — เช่น token ที่ login ผ่าน WEB ใช้กับ WEB_ADMIN request ไม่ได้
Gate #2 — JWT decode (lightweight)
ใน context() จะ decode JWT เพื่อเอา uid ไว้ใน context — แต่ใช้ jwt.decode ไม่ใช่ jwt.verify ตรงนี้
export const getPayLoad = async token => {
try {
// Verify JWT Token
const payload = await decode(token, JWT_SECRET)
var setDataPayload
if (payload == null) {
setDataPayload = {
loggedIn: false, payload: { uid: null }
}
} else {
setDataPayload = {
loggedIn: true, payload
}
}
return setDataPayload
} catch (err) {
return { loggedIn: false, payload: { uid: null } }
}
};ทำไม decode แทน verify ที่ context
เหตุผลคือ resolver บางตัวเป็น public (login, generateCaptcha, refreshToken) — gate #3-5 ที่ทำ verify จริงจะรันเฉพาะ field ที่ติด @auth directive
การ decode ที่ context แค่ใส่ loggedIn flag ให้ resolver รู้ว่า user login อยู่หรือเปล่า — verify จริงเลื่อนไปที่ directive
ผลข้างเคียงที่ต้องรู้
- ถ้า JWT หมดอายุ → decode สำเร็จ แต่ resolver ที่ติด
@authจะ fail ที่ gate #3 - ถ้า JWT sign ผิด → decode คืน
null→loggedIn: false→ resolver ที่ติด@authจะ fail ที่TOKEN_INVALID - context ไม่เคย reject request ที่ตรงนี้ — ทุก public field จะผ่านไปได้แม้จะไม่มี token เลย
Apollo dispatch + @auth directive
หลังจาก context สร้างเสร็จ Apollo จะ dispatch request ไปยัง resolver ที่ field นั้น
ถ้า field ติด @auth directive — directive จะ wrap resolver function ด้วย gate #3-5 ก่อน
ตัวอย่าง field ที่ติด directive (typeDefs/authentication.js:57)
) : String! @auth(accessRole: ["USER"])directive รับ 2 argument
accessRole: [String]— role ที่ต้องการ (USER,SUPER_ADMIN,ADMIN,STAFFหรือผสม)accessMenu: [String]— menu key ถ้าต้องการเช็คเพิ่ม (optional)
directive เองอยู่ที่ directives/auth.js ยาว 51 บรรทัด เป็นหัวใจของระบบ RBAC
Gate #3 — JWT verify
const payload = await decode(context.token, JWT_SECRET)
// Verify token
const verify = verifyToken(context.token,
{
expiresIn: JWT_EXP,
issuer: JWT_ISSUER,
jwtId: payload.jti
})ในที่นี้ใช้ decode อีกครั้งเพื่อดึง jti (JWT ID) ออกมาก่อน แล้วค่อย verify โดยใช้ jti เป็น options
verifyToken แท้จริงแล้วอยู่ที่ repositories/auth/auth.js:250-258
export const verifyToken = (token, option = {}) => {
try {
const decodedVerify = decodeToken(token, option)
return { data: decodedVerify, error: null }
} catch (e) {
return { data: {}, error: e }
}
}ถ้า verify fail — directive จะสลับ error type ตาม flavor ของ error
const error = verify.error
if (error) {
if (error.name === 'TokenExpiredError') {
return responError('TOKEN_EXPIRED')
} else {
return responError('TOKEN_INVALID')
}
}frontend (ดู vtrc-web/src/config/apollo.js:74) intercept TOKEN_EXPIRED แล้วเรียก refreshToken mutation ผ่าน side-channel apolloFetch ไม่ใช่ผ่าน Apollo client หลัก — เพราะ errorLink ทำงานอยู่ใน client จะวนลูป
ความผิดพลาดที่ต้องรู้
decodeToken ไม่ได้ระบุ algorithm ให้ jwt.verify — jsonwebtoken จะใช้ algorithm ที่อยู่ใน JWT header ทำให้เปิดช่อง "alg: none" attack ถ้า attacker forge JWT ที่ระบุ alg: none ใน header ระบบจะ verify ด้วย algorithm ที่ไม่มีการตรวจ signature
จะอธิบายเต็มใน Volume 10 Security
Gate #4 — Session + role check
หลัง verify token ผ่าน directive จะเรียก checkSession ที่ repositories/session/session.js:20
await checkSession(verify.data.userId, accessRole, payload.jti, context.device)export const checkSession = async (userId, accessRole, jwtid, device) => {
//Check access role
const currentUserRole = await getCurrentRoleUser(userId, accessRole, device)
if (currentUserRole.length <= 0) {
return responError('CANNOT_ACCESS')
}
//Check Session
var sessionResult = await Models.Session.findAll({
where: {
userId: userId,
userSession: {
[Op.like]: `%${jwtid}%`,
},
client: device
}
})3 สิ่งที่เช็คในนี้
- Role membership —
getCurrentRoleUserดูว่า user มี role ที่ตรงกับaccessRole[]ไหม - Session exists —
Session.userId = userIdและuserSession(JSON column) ต้องมีjwtIdที่ตรงกับ JWT - Device matches —
Session.clientต้องตรงกับdevice(APP_TYPE ที่ resolve จาก apiKey)
ถ้าทั้งสามผ่าน → session ถือว่า valid
Bug ที่ต้องระวังใน checkSession
Op.likeบน JSON column (:31) — session lookup ใช้ LIKE%${jwtid}%ถ้าjwtidของ session หนึ่งเป็น substring ของ session อื่นจะ match ผิดตัว เป็น substring collision ที่หายากแต่เป็นไปได้getCurrentRoleUsersignature mismatch — caller ส่งdeviceเป็น arg ที่ 3 แต่ function signature รับแค่(userId, accessRole)→deviceถูก dropped โดย default (จะกล่าวถึงใน บท 3.3)- transaction arg หาย —
Models.Session.update(args, { where }, { transaction: t })ผ่าน{ transaction }เป็น arg ที่ 3 ที่ Sequelize ไม่รับ → ไม่ใช่ transaction จริง
Gate #5 — Menu access (ABAC)
ถ้า @auth(accessMenu: [...]) กำหนดไว้ → directive จะเรียก checkMenuAuth
await checkMenuAuth(verify.data.userId, accessMenu)export const checkMenuAuth = async (userId, accessMenu) => {
try {
if (!accessMenu) { return }
let userResult = await Models.User.findAll({ where: { userId: userId } })
const accessRole = await RoleAccess.findByConditions({ where: { roleId: userResult[0].role.accessRole } })
const accessableMenu = accessRole.filter(role => accessMenu.includes(role.nameEN))algorithm
- lookup
Userเพื่อดึงrole(JSON column) - lookup
RoleAccessทั้งหมดของroleIdที่อยู่ในuser.role.accessRole - filter
RoleAccess.nameENที่อยู่ในaccessMenu[]
ถ้า user มี role อย่างน้อย 1 role ที่มี menu ที่กำหนด → ผ่าน
Bug ที่สำคัญ
audit doc (Phase 4) ระบุว่า logic นี้มี dual-source-of-truth bug — getCurrentRoleUser ใช้ Role.roleName แต่ checkMenuAuth ใช้ user.role.accessRole (ซึ่งเป็น JSON ใน User ไม่ใช่ Role) รายละเอียดและ exploit scenario อยู่ใน บท 3.3 และ Volume 10 Security
หลัง gate #5 — เข้า resolver
เมื่อผ่าน 5 gate หมดแล้ว resolver function ที่ wrap ไว้จะถูกเรียก
const data = await resolve.apply(this, args);
return data;resolve คือ resolver function จริงที่อยู่ใน resolvers/*.js — ส่วนใหญ่เป็น 1-line ที่ delegate ไป controller
login: async (root, { lsid, password, clientInfo, apiKey, organization }, context, info) => {
return auth.login(lsid, password, clientInfo, apiKey, organization)
},จาก controller → repository → Models / centralize — ดู บท 3.6 สำหรับรายละเอียด controller layer
Error format — formatError
ก่อน response ออกไป Apollo จะผ่าน formatError callback (index.js:61-64)
formatError: (err) => {
console.log("err ->", err);
return IS_PRODUCTION ? graphqlFormatError(err) : err
},- ใน dev (
!IS_PRODUCTION) → return error เต็มๆ รวม stack trace - ใน prod → ผ่าน
graphqlFormatErrorในlib/responErrors.jsเพื่อ map code → Thai message + ซ่อน stack
lib/responErrors.js (14 KB) เป็น lookup table ของ error code ~80 ตัว → ข้อความไทย — ดู บท 3.9 สำหรับรายละเอียด
Playground + introspection — เปิดทั้ง prod
playground: true,
introspection: true,อันนี้เป็น security concern จริง — schema ทั้งหมด (typeDefs 43 ไฟล์, ทุก mutation/query/type/input) เปิดเผยต่อ public ใครก็เปิด https://vtrcapi.redcross.or.th/api/graphql ในเบราว์เซอร์แล้วเห็น playground พร้อม schema explorer
เป็น severity S-? ใน บท 3.10 — ปกติแนะนำปิดทั้งสองใน prod แต่ VTRC เปิดทั้งคู่ (อาจจะตั้งใจเพื่อให้ partner debug ได้ แต่ควรตั้ง whitelist IP แทน)
GraphQL upload + body limit
ก่อน Apollo middleware จะมี graphqlUploadExpress สำหรับ multipart upload
app.use(servicePath, graphqlUploadExpress({ maxFileSize: 20000000, maxFiles: 100 }));maxFileSize: 20 MBต่อไฟล์maxFiles: 100ต่อ request (เหมาะกับ Excel bulk upload 100 files)
และมี body limit ที่ Express
server.applyMiddleware({ app, path: servicePath, bodyParserConfig: { limit: '50mb' }, corss })
app.disable('x-powered-by')
app.use(cors())
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));50 MB ค่อนข้างสูง — ถ้า attack ยิง GraphQL mutation ที่มี body 50 MB ทุก request ก็กิน bandwidth ได้
CORS
const corss = {
origin: ['https://vtrc.redcross.or.th',
'https://vtrcbackoffice.redcross.or.th',
'https://uat-vtrc.redcross.or.th',
'https://uat-vtrcbackoffice.redcross.or.th',
'http://localhost:3000'],
// origin: "*",
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'authorization', 'apiKey'],
}- allowlist 5 origins (4 prod/uat + localhost)
- แต่ต่อมามี
app.use(cors())อีกที (:91) ที่เปิด default = allow ทุก origin — แทนที่ allowlist ด้านบน - ผลคือ CORS เปิดหมดจริง เพราะบรรทัด
:91ทับ config ที่ส่งไปกับapplyMiddleware
เป็น bug เงียบ — config ที่เห็นใน corss object ไม่ได้ถูกใช้จริง
เคล็ดลับตอนทำงานจริง
ตอน debug "request ถูก reject" — ดู code ก่อน
responError(code) ส่ง error code เดียวออกไป — ดู code แล้วนึกย้อนกลับได้ว่าติด gate ไหน
| Code | Gate ที่ติด |
|---|---|
ACCESS_KEY_INVALID | Gate #1 |
TOKEN_EXPIRED | Gate #3 |
TOKEN_INVALID | Gate #2, #3 หรือ Gate #4 (session/menu — session.js เรียก responError('TOKEN_INVALID')) |
CANNOT_ACCESS | Gate #4 (session.js เมื่อ session/role ตรวจไม่ผ่าน) |
| ข้อความไทย "คุณไม่สิทธิดำเนินการ" | Gate #5 (menu ไม่ตรง — มักมากับ TOKEN_INVALID) |
console.log("err ->", err) จะอยู่ใน log ทุกครั้ง
ทั้งใน context error handler (index.js:55) และ formatError (index.js:62) — production log เต็มไปด้วย error ซึ่งอาจจะดี (debug ง่าย) หรือไม่ดี (sensitive data leak)
อย่าลืมว่า @auth directive รันทุก field
ถ้า schema มี nested type ที่แต่ละ field ติด @auth — resolver แต่ละ field จะผ่าน gate #3-5 ใหม่ ทำให้เกิด N+1 query ที่ database (session lookup + role lookup × N)
ถ้าจะเพิ่ม public field ใหม่ — อย่าลืมว่าไม่ติด @auth
ในไฟล์ typeDefs ใหม่ ถ้าไม่ใส่ @auth(...) directive → field นั้นเป็น public ทันที ไม่ต้อง login ก็เรียกได้ — เคยมี bug ที่ลืมใส่แล้ว expose ข้อมูล PII