Skip to content

4 · Auth model + external integrations

pms-api ใช้โมเดล auth แบบ dual-header เหมือน legacy frontends ของแพลตฟอร์ม — Authorization: Bearer <jwt> + apiKey: <device-key> — ผ่าน global JwtAuthGuard พร้อม @Public() override และมี ThrottlerGuard เป็น global APP_GUARD เพิ่มจาก repo อื่นในกลุ่ม

ทุก claim อ้างจาก canonical branch uat

Auth stack overview

ส่วนหลักฐานบทบาท
Global JWT guardpms-api/src/main.ts (branch: uat):42app.useGlobalGuards(new JwtAuthGuard(...))ทุก route ต้องผ่าน auth ยกเว้น @Public()
Passport JWT strategypms-api/libs/jwt/jwt.strategy.ts (branch: uat)ตรวจลายเซ็น JWT ด้วย SECRET_KEY
Guard logic + apiKeypms-api/libs/jwt/jwt-auth.guard.ts (branch: uat)ตรวจ apiKey จาก API_DEVICE_KEY, เรียก workforce checkRoleRc
Token issue/storepms-api/src/modules/auth/auth.service.ts (branch: uat)jsonwebtoken sign + เก็บลง authData
Rate limitpms-api/src/app.module.ts (branch: uat):51-58,116100 req / 60s ต่อ IP
Device keyspms-api/config/configuration.ts (branch: uat):69-211map keyAPP_TYPE สำหรับ prod และ uat

JwtAuthGuard — flow ทีละขั้น

pms-api/libs/jwt/jwt-auth.guard.ts (branch: uat):33-83

typescript
canActivate(context: ExecutionContext) {
  const isPublic = this.reflector.getAllAndOverride<boolean>(
    isPublicKey,
    [context.getHandler(), context.getClass()],
  );
  if (isPublic) {
    return true;
  }
  this.apiKey = request.headers?.apikey;
  const apiDeviceKey = this.configService.get('API_DEVICE_KEY') || [];
  const mapKey = apiDeviceKey[process.env.NODE_ENV]?.find(
    (a) => a.key === this.apiKey,
  );
  // TRIGGER bypass ...
  if (!mapKey && process.env.NODE_ENV !== 'development') {
    throw new UnauthorizedException({ statusCode: 401, error: 'API KEY Invalid' });
  }
  return super.canActivate(context);
}

ลำดับการตัดสินใจ:

  1. @Public() → ผ่านทันที ไม่ตรวจ JWT ไม่ตรวจ apiKey
  2. อ่าน apikey header (ตัวพิมพ์เล็ก — Nest normalize headers เป็น lowercase)
  3. หา device key จาก API_DEVICE_KEY[NODE_ENV] — ใน UAT ใช้ชุด uat: ใน configuration.ts:132-211
  4. TRIGGER bypass — ถ้า mapKey.APP_TYPE === 'TRIGGER' และ JWT payload มี appType === 'trigger' → ผ่านโดยไม่เรียก Passport (jwt-auth.guard.ts:64-68) ใช้สำหรับ usage-permission trigger จากระบบภายนอก
  5. apiKey บังคับนอก development — ถ้าไม่มี mapKey และ NODE_ENV !== 'development'401 API KEY Invalid
  6. Passport JWTsuper.canActivate() ตรวจ Bearer token
  7. handleRequest + checkRole — ถ้ามีทั้ง token และ apiKey จะเรียก workforce HTTP เพื่อเช็ค role ตามชื่อ controller

checkRole → workforce-api

pms-api/libs/jwt/jwt-auth.guard.ts (branch: uat):126-144

typescript
async checkRole() {
  const params = { menu: this.controllerName };
  const headers = {
    Authorization: this.token,
    apiKey: this.apiKey,
  };
  const { data } = await this.axios.get(
    `${this.workforceEndPoint}/checkRoleRc`,
    { headers, params },
  );
  return data;
}

endpoint ปลายทางสร้างจาก WORKFORCE_SERVER + /workforce-api/api/restful/checkRoleRc (บรรทัด 31 ของ guard)

ถ้า checkRoleResult.status === false403 Forbidden (handleRequest บรรทัด 110-112)

ความเสี่ยง — ถ้า workforce-api ล่ม ทุก authenticated request ที่ส่งทั้ง token+apiKey จะได้ 403 (ไม่ใช่ 503) เพราะ catch block แปลง error เป็น Forbidden (jwt-auth.guard.ts:115-121)

JwtStrategy validate

pms-api/libs/jwt/jwt.strategy.ts (branch: uat):24-37

typescript
async validate(payload: any) {
  if (payload.email && payload?.client === 'rc-web') {
    const result = await this.authDateService.getByEmailCheckExists(
      { email: payload.email },
    );
    if (!result) {
      throw new ForbiddenException({ statusCode: 403 });
    }
  }
  return payload;
}

เงื่อนไขพิเศษเฉพาะ client rc-web — ต้องมีแถวใน authData ตาม email มิฉะนั้น 403 client อื่นผ่านด้วย payload อย่างเดียวหลังลายเซ็นถูกต้อง

AuthDataService — ออกและเก็บ token

pms-api/src/modules/auth/auth.service.ts (branch: uat):21-32

typescript
private readonly privateKey = process.env.SECRET_KEY;
private readonly refreshKey = process.env.REFRESH_TOKEN_SECRET_KEY;

generateToken(payload: any): { token: string; refreshToken: string } {
  const token = jwt.sign(payload, this.privateKey, {
    expiresIn: process.env.JWT_EXPIRE_IN,
  });
  const refreshToken = jwt.sign(payload, this.refreshKey, {
    expiresIn: process.env.REFRESH_EXPIRE_IN,
  });
  return { token, refreshToken };
}
หัวข้อสถานะบน branch uat
SECRET_KEYมีใน .env:41
REFRESH_TOKEN_SECRET_KEYไม่พบใน .env ที่ commitrefreshKey จะเป็น undefined ถ้าไม่มี env runtime อื่น
JWT_EXPIRE_IN / REFRESH_EXPIRE_INไม่พบใน .env ที่ commit — expire ขึ้นกับค่าที่ inject ตอน deploy

token ถูกอัปเดตลงตาราง authData ผ่าน updateToken() (auth.service.ts:34-54) — ทำให้ revoke ได้ด้วยการลบแถว (logout / delete)

AuthController ที่ auth.controller.ts ว่างเปล่า (ไม่มี route) — การ login จริงน่าจะเกิดที่ service อื่นหรือถูกเรียกจากภายใน UNVERIFIED ว่า frontend เรียก auth ของ pms-api โดยตรงหรือได้ JWT จาก vtrc-api/workforce

Token validation helpers

Methodบรรทัดทำอะไร
validateEmailParamEqualTokenEmailauth.service.ts:179-203verify JWT แล้วเทียบ email กับ param (ยกเว้น iss === redcross.or.th:vtrc)
validateToken:205-238ตรวจ existence ใน authData + email match
validateTokenOnlyVTRC:240-256บังคับ iss === redcross.or.th:vtrc
validateOTP:275-308OTP TTL 5 นาที (เทียบจาก createdAt) — ต่างจาก cu-central ที่ TTL 3 นาที

ThrottlerGuard (จุดเด่นของ pms-api)

pms-api/src/app.module.ts (branch: uat):51-58,116

typescript
ThrottlerModule.forRoot({
  throttlers: [{ ttl: 60000, limit: 100 }],
}),
// ...
providers: [..., { provide: APP_GUARD, useClass: ThrottlerGuard }],
  • 100 requests ต่อ 60 วินาทีต่อ IP (default ของ @nestjs/throttler)
  • เป็น repo เดียวในกลุ่ม Domain Microservices ที่ตั้ง global throttle
  • @Public() endpoints ก็ถูก throttle ด้วย (ยกเว้นถ้ามี @SkipThrottle() — ไม่พบในโค้ดปัจจุบัน)

Device keys (API_DEVICE_KEY)

อยู่ใน configuration.ts ไม่ใช่ .env — มีชุด prod และ uat

ตัวอย่าง APP_TYPE ที่พบในชุด uat:

APP_TYPEAPP_NAME (ตัวอย่าง)
WEBVTRC-WEB-UAT
MOBILEVTRC-MOBILE-UAT
WEB_ADMINVTRC-BACKOFFICE-UAT
INTEGRATE_APPINFOMA-UAT, CONICLE-UAT
TWOWAYVTRC-TWOWAY-UAT
MEETINGVTRC-MEETING-UAT
RECRUITMENT / RECRUITMENT-WEBVTRC-RECRUITMENT-UAT
TRIGGERUSAGE-PERMISSION-TRIGGER

ค่า key เป็น UUID ที่ hardcode ใน source — ใครอ่าน repo ได้ก็ปลอม apiKey ได้ (ต้องคู่กับ JWT ที่ถูกต้อง ยกเว้น @Public())

มี entry LOAD_TEST_* ในชุด uat (configuration.ts:158-174) — ใช้สำหรับ load test

@Public() endpoints (9 จุด active)

grep @Public() ทั่ว pms-api/src (branch: uat) — นับเฉพาะที่ไม่ได้ comment

#ControllerMethod / Pathบรรทัดชั้นป้องกันที่ 2ระดับความเสี่ยง
1BudgetControllerGET /budget/listbudget.controller.ts:17-20ไม่มีกลาง — รายการ budget
2BudgetControllerGET /budget/getOrgUnit:38-41ไม่มีต่ำ-กลาง — org unit dropdown
3BudgetControllerGET /budget/getEmployeeSalary:49-53ไม่มีสูงมาก — คืนเงินเดือนตาม org unit
4ConfigNotificationControllerPOST /configNotification/check-notificationsconfigNotification.controller.ts:44-55NODE_ENV === 'development'ต่ำ (นอก dev คืน { message: 'Nope !!!' })
5MigrateDataControllerPOST /migrateData/migrate-datamigrateData.controller.ts:32-67NODE_ENV === 'development'ต่ำ (นอก development ปฏิเสธ)
6UsagePermissionControllerPOST /usagePermission/send-email-promote-newusagePermission.controller.ts:89-118NODE_ENV === 'development'ต่ำ
7UploadControllerGET /upload/:idupload.controller.ts:13-16ไม่มีกลาง — รายการไฟล์ตาม id
8AppControllerGET /healthapp.controller.ts:8-11ไม่มีต่ำ — health check สมเหตุสมผล

commented @Public() ใน upload controller (บรรทัด 19, 34, 40) ไม่นับ

รายละเอียด getEmployeeSalary (จุดวิกฤต)

pms-api/src/modules/budget/budget.controller.ts (branch: uat):49-53

typescript
@Public()
@Get('/getEmployeeSalary')
getEmployeeSalary(@Query() query: GetSalaryByOrgUnitDto) {
  return this.service.getEmployeeSalary(query);
}

service เรียก stored procedure sp_get_employeeSalary_orgUnit (budget.repositories.ts:256-273) หรือ query builder ทางเลือก — คืน budgetPerDepartment, salaryStaff, salaryEmployee, budgetStaff, budgetEmployee

ไม่ต้องมี JWT ไม่ต้องมี apiKey — ถูก throttle แค่ 100 req/นาทีเท่านั้น ดู exploit ใน 06 Scorecard SEC-PMS-01

Pattern NODE_ENV double-guard

สาม endpoint (notification / migrate / promote email) ใช้ pattern เดียวกัน:

typescript
if (process.env.NODE_ENV === 'development') {
  // ทำงานจริง
}
return { message: '...' }; // ปฏิเสธ

ดีกว่า benefit-api ที่ batch endpoints เป็น @Public() โดยไม่มีชั้นสอง — แต่บน UAT (NODE_ENV=uat) ชั้นนี้จะปฏิเสธอยู่แล้ว จึงไม่เปิดช่องบน UAT/prod สำหรับสามอันนี้

ข้อยกเว้น — budget @Public() ทั้งสามไม่มีชั้นสองใด ๆ

External integrations

ปลายทางenv varค่า UAT (จาก .env)เรียกจากใช้ทำอะไร
workforce DB (ไม่ใช่ HTTP)WORKFORCE_DB_*host 10.188.128.58, db dbWorkforce10 module (ดู 03 Persistence)อ่านองค์กร/พนักงาน/trigger queue
workforce-api HTTPWORKFORCE_SERVERhttps://uat-vtrcapi.redcross.or.thjwt-auth.guard.ts:31,136-141GET .../checkRoleRc
pms-ai-apiPMS_AI_API_URLhttps://pms-ai-api.vtrcsupport.comcreateForm.service.ts:408, createForm.repositories.ts:1133vector embedding สร้าง/ดึง master course
VTRC ESS / Backoffice (URL ใน email เท่านั้น)VTRC_ESS_URL, VTRC_BACKOFFICE_URLhttps://uat-vtrc.redcross.or.th, https://uat-vtrcbackoffice.redcross.or.thconfigNotificationSendEmail.service.ts:172ลิงก์ในอีเมลแจ้งเตือน — ไม่ใช่ API call
SMTP OutlookSMTP_HOST/USER/PASSsmtp-mail.outlook.comlibs/services/emailService.tsส่งอีเมล (retry 3 ครั้ง)
vtrc-api (env ประกาศ)VTRC_SERVERhttps://uat-vtrcapi.redcross.or.th/configuration.ts:6UNVERIFIED — ไม่พบจุดเรียกใช้ชัดเจนในรอบนี้
vtrc DB (TypeORM)VTRC_DB_*database vtrcconnection ประกาศใน AppModuleUNVERIFIED — ไม่พบ inject

pms-ai-api (โดเมนภายนอก)

pms-api/src/modules/createForm/createForm.service.ts (branch: uat):400-417

typescript
async setCourseLearning(evaluateFormLearning, req) {
  const dataForCreate = evaluateFormLearning
    ?.filter((i) => !i.courseId && i.courseName)
    ?.map((i) => ({ courseName: i.courseName, createdBy: req?.user?.profile?.empCode })) || [];
  if (dataForCreate?.length > 0) {
    const { data } = await this.axios.post(
      `${this.pmsApiURL}/vector-embeddings/createMasterCourse`,
      dataForCreate,
    );
    // map courseId กลับเข้า DTO
  }
}

และ createForm.repositories.ts:1133GET .../vector-embeddings/getMasterCourse

โดเมน vtrcsupport.com ไม่ใช่ redcross.or.th — ต่างจาก integration อื่นในกลุ่ม ส่วนใหญ่ที่อยู่ใต้โดเมนสภากาชาด ข้อมูลที่ส่งออกคือชื่อคอร์ส + empCode ของผู้สร้าง — ควรยืนยัน data processing agreement (ดู QUAL-PMS-04 ใน scorecard)

พบเอกสารอ้างอิงใน workflow-api/docs/pms-ai-api-spec.mdUNVERIFIED ความสัมพันธ์ runtime ระหว่าง workflow-api กับ service นี้

SMTP

libs/services/emailService.ts — nodemailer port 587, retry สูงสุด 3 ครั้ง, render EJS จาก libs/pdfTemplate/email-template/

Secrets ที่อยู่ใน source control

ประเภทไฟล์บรรทัด / ขอบเขตหมายเหตุ
MSSQL password ×4 ชุด (main / vtrc / workforce / workforceTemp)pms-api/.env (branch: uat)12, 19, 26, 33ค่า UAT (NODE_ENV=uat) ไม่ใช่ prod
JWT SECRET_KEY.env:41ค่าเดียวกับ benefit/recruitment/ai-recruitmentรั่วแล้วกระทบ forge JWT ข้าม service
SMTP password.env:45noreply-vtrc@redcross.or.thส่งอีเมลในนามองค์กรได้ถ้าถูกขโมย
GCP SA keypms-api/vtrc-gcp-sa-key.json (branch: uat)ทั้งไฟล์ห้าม log / exfiltrate
Device API keysconfig/configuration.ts:69-211hardcode ใน sourceปลอม apiKey ได้ถ้ามี JWT

ห้าม paste ค่า secret จริงลงเอกสารหรือ chat — อ้างเฉพาะ path:line

แม้ .env เป็น UAT แต่ SECRET_KEY แชร์กับ prod ของอย่างน้อย 3 service อื่นในแพลตฟอร์ม — severity ระดับแพลตฟอร์ม ไม่ใช่แค่ UAT (ดู SEC-PMS-02)

CORS และ ValidationPipe (เกี่ยวข้องกับ auth surface)

pms-api/src/main.ts (branch: uat):43-66

  • ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }) — reject field นอก DTO (เข้มกว่า repo อื่น)
  • CORS: นอก prod อนุญาตทุก origin; ใน prod block origin ที่มีคำว่า localhost แต่ยัง allowedHeaders: '*' และอนุญาต origin อื่นเกือบทั้งหมด

ผลต่อ @Public() — จาก browser ใดก็เรียก getEmployeeSalary ได้ถ้า network ถึง UAT/prod (ขึ้นกับ network ACL)

สรุปความแตกต่างจาก repo เพื่อนบ้าน

หัวข้อpms-apibenefit-api / recruitment-api
Global throttleมี (100/60s)ไม่มี
@Public() + NODE_ENV double-guardมีบน migrate/noti/emailมักไม่มี
Public salary endpointมี — ความเสี่ยงสูงสุดของ repo
Shared SECRET_KEYยืนยันครั้งที่ 4 ในกลุ่มเหมือนกัน
Role check ภายนอกเรียก workforce checkRoleRcแตกต่างตาม repo

ไป บท 5 Cron jobs