Skip to content

4 · Auth model + external integrations

บทนี้อธิบายโมเดลยืนยันตัวตนของ recruitment-api (branch: prod), แคตตาล็อก @Public(), การแชร์ JWT secret กับ VTRC, และ integrations ภายนอก (workforce-api, vtrc-api HTTP, Google reCAPTCHA, SMTP, GCP deploy) พร้อมรายการ secret ที่อยู่ใน source control

Auth architecture — สองชั้นซ้อนกัน

ระบบต้องรองรับอย่างน้อย 3 กลุ่มผู้ใช้ในโค้ดเดียวกัน:

กลุ่มสัญญาณในโค้ดวิธีได้ token
ผู้สมัคร portal (rc-web)payload.client === 'rc-web'OTP → jobList.validateOTPAuthDataService.generateToken
Back-office / ESStoken จาก VTRC (iss) หรือ client อื่นlogin ที่ legacy VTRC แล้วเรียก recruitment-api ด้วย Bearer เดียวกัน
Service-to-self refreshheader x-refresh-tokenPOST /auth/refresh_token ข้าม Passport validate

Layer 1 — Global JWT guard (Passport)

Boot: recruitment-api/src/main.ts (branch: prod):39

typescript
app.useGlobalGuards(new JwtAuthGuard(reflector));

recruitment-api/libs/jwt/jwt-auth.guard.ts (branch: prod):22-38canActivate:

  1. ถ้า handler/class มี @Public()return true (ไม่ตรวจ JWT)
  2. ถ้ามี header x-refresh-tokenreturn true (เปิดทาง refresh)
  3. อ่าน authorization + apikey แล้วเรียก super.canActivate (Passport jwt strategy)

handleRequest (:41-67):

  • ถ้าไม่มี userUnauthorizedException 401
  • ถ้ามี ทั้ง Authorization และ apiKey → เรียก checkRole() ไป workforce-api
    • GET ${WORKFORCE_SERVER}/workforce-api/api/restful/checkRoleRc?menu={ControllerName}
    • ถ้า status === falseForbiddenException 403 ข้อความไทย
  • ถ้ามีแค่ Bearer ไม่มี apiKey → คืน user โดยไม่เช็ค role
typescript
// jwt-auth.guard.ts — checkRole
const { data } = await this.axios.get(`${this.workforceEndPoint}/checkRoleRc`, {
  headers: { Authorization: this.token, apiKey: this.apiKey },
  params: { menu: this.controllerName },
});

controllerName = context.getClass().name (เช่น CandidateController) — workforce ต้องรู้จักชื่อ class เหล่านี้

Decorator export จากไฟล์เดียวกัน (:88-90):

typescript
export const isPublicKey = 'isPublic';
export const Public = () => SetMetadata(isPublicKey, true);

Layer 1b — JwtStrategy (Passport-JWT)

recruitment-api/libs/jwt/jwt.strategy.ts (branch: prod):10-32

  • Extract Bearer token
  • secretOrKey: configService.get('jwtSecretKey') ← env SECRET_KEY (configuration.ts:5)
  • validate(payload):
    • ถ้ามี payload.email และ payload.client === 'rc-web' → ต้องมีแถวใน authData (getByEmailCheckExists) ไม่งั้น 403
    • token จาก VTRC (ไม่มี client: rc-web หรือมี iss) ผ่านได้โดยไม่ต้องมีแถว authData

นี่คือจุดบังคับ server-side session สำหรับ portal: logout / ลบ authData ทำให้ JWT เดิมใช้ต่อไม่ได้แม้ยังไม่หมดอายุตาม exp

Layer 2 — Self-managed JWT ใน AuthDataService

recruitment-api/src/modules/auth/auth.service.ts (branch: prod):17-25

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

generateToken(payload: any) {
  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 };
}

Env ที่เกี่ยวข้อง (ชื่อเท่านั้น — อย่า commit ค่าจริงซ้ำ):

Envใช้ทำ
SECRET_KEYaccess JWT (Passport + AuthDataService)
REFRESH_TOKEN_SECRET_KEYrefresh JWT
JWT_EXPIRE_INเช่น 15m
REFRESH_EXPIRE_INเช่น 1d
OTP_EXPIRE_INนาที (default 5 ถ้าไม่ตั้ง — auth.service.ts:19)

Controller refresh: recruitment-api/src/modules/auth/auth.controller.ts (branch: prod):7-12POST /auth/refresh_token body { refreshToken } → validate → generate คู่ใหม่ → updateToken

Shared secret กับ VTRC (canonical fact)

AuthDataService ตรวจ issuer หลายจุด:

Methodบรรทัดโดยประมาณพฤติกรรม
validateEmailParamEqualTokenEmailauth.service.ts:152อนุญาตถ้า email ตรง หรือ iss === 'redcross.or.th:vtrc'
validateToken:170ถ้ามี email และ iss ไม่ใช่ VTRC → ต้องมีแถว authData
validateTokenOnlyVTRC:194บังคับ iss เป็น VTRC
isVTRC:263คืน boolean จาก iss
validateTokenOnlyRCWeb:249บังคับ client === 'rc-web'

เพราะ verify ด้วย SECRET_KEY เดียวกัน token ที่ vtrc-api ออกด้วย iss นี้จึง verify ผ่านที่ recruitment-api ได้โดยไม่เรียก JWKS / central auth — รูปแบบ shared-secret SSO ระหว่าง microservice

ความเสี่ยง: ถ้า SECRET_KEY รั่ว (รวมกรณี .env ใน git) จะกระทบทุก service ที่แชร์ค่าเดียวกันพร้อมกัน

OTP issue path (เชื่อมบท 2)

  1. GET /jobList/sent_otp/:email (@Public) → OtpService → ลบ/สร้าง authData เก็บ OTP
  2. GET /jobList/validate_otp (@Public) → validateOTPgenerateToken({ email, client: 'rc-web' })updateToken
  3. Request ถัดไปใช้ Bearer — JwtStrategy เช็คว่า email ยังมีใน authData

AuthController มีแค่ refresh — ไม่มี login password endpoint ใน recruitment-api

@Public() catalog (ยืนยันจาก grep)

Active @Public() ใน src/ (branch: prod):

ModuleFile:lineMethod / pathเหตุผลที่สมเหตุสมผล
jobListjobList.controller.ts:16GET get_by_org_unit_idดูประกาศสาธารณะ
jobList:22GET get_job_detail_by_id/:idรายละเอียดประกาศ
jobList:28GET get_default_datadefault/ddl หน้า list
jobList:34GET sent_otp/:emailขอ OTP ก่อนมี JWT
jobList:40GET validate_otpแลก OTP เป็น JWT
jobList:46PUT logoutออกจากระบบ portal (public แต่ใช้ token ใน header ตอน service)
jobList:52GET export_other_docดาวน์โหลดแบบฟอร์ม
checkApiOncheckApiOn.controller.ts:8GET check_api_onhealth
shareshare.controller.ts:10POST Share/share_link/HTML สำหรับแชร์โซเชียล
uploadFilesupload.controller.ts:13GET upload/:idอ่าน metadata ไฟล์ตาม id
jobRequestjobRequest.controller.ts:93PUT update_visitนับ visit ประกาศ
blacklistblacklist.controller.ts:20POST import_blacklistImport Excel โดยไม่ต้อง JWT

Commented-out @Public() (ปัจจุบัน ไม่ public):

FilePath ที่เคยคิดจะเปิด
upload.controller.ts:19,34,40POST /upload, update/delete (comment)
contract.controller.ts:51,57export PDF/Excel
announcement.controller.ts:85,91,97export PDF / preview email
jobList.controller.ts:58AI recommend (ตอนนี้ต้อง auth)

จุดที่ต้องระวังเป็นพิเศษ

POST /blacklist/import_blacklist เป็น @Public() (blacklist.controller.ts:20-27) — รับ multipart Excel แล้ว import บัญชีดำโดยไม่ผ่าน JWT ตาม guard ปกติ ถือเป็น surface ที่ควรถามทีมว่าตั้งใจหรือ leftover

GET /upload/:id เป็น @Public() — อ่านรายการไฟล์ด้วย id; การ upload จริง (POST /upload) ต้อง auth

Candidate / Announcement / Contract create ไม่มี @Public() — ต้องมี Bearer เสมอ

Device / API keys ใน configuration

recruitment-api/config/configuration.ts (branch: prod):75-104apiDeviceKey.prod / .uat มีรายการ:

appTypeappName (ตัวอย่าง)
WEB_ADMINVTRC-BACKOFFICE / UAT
RECRUITMENT-WEBVTRC-RECRUITMENT / UAT

ค่า key / id hardcode ในไฟล์ config (sensitive pattern เดียวกับ legacy) — ใช้คู่กับ header apiKey เมื่อเรียกข้ามระบบและเมื่อ guard เรียก checkRoleRc

rcWebAPIKey จาก env RC_WEB_API_KEY (configuration.ts:10) ถูกใช้เป็น apiKey ตอนเรียก workforce จากบาง service path (เช่น contract.service.ts:1805, candidate.service.ts checkEmpRedCross headers)

External integrations

1) workforce-api (หนักที่สุด — ยืนยัน 100%)

Base: `${WORKFORCE_SERVER}/workforce-api/api/restful`

ตั้งค่าผ่าน configuration.ts:9process.env.WORKFORCE_SERVER

Endpoints ที่พบในโค้ด (ไม่ครบทุกบรรทัด แต่ครบชื่อ path):

HTTPPathเรียกจาก (ตัวอย่าง)
PUT/updateManpowerNoJobRequestRcjobRequest.service.ts:979, contract.service.ts:825
GET/fetchListOrgUnitDataRcjobRequest.service.ts:266, announcement.service.ts:226
GET/fetchPosByOrgUnitRcjobRequest.repositories.ts:522
GET/fetchInfoDropdownDataRcjobRequest.repositories.ts:539
GET/fetchManpowerNoByPositionRcjobRequest.repositories.ts:596
GET/fetchEmployeeWfRCjobRequest.repositories.ts:328
GET/fetchOrgUnitByIdJobListRcjobList.repositories.ts:401
GET/checkEmpRedCrossRccandidate.service.ts:432, appProfile.service.ts:1070
GET/fetchEmployeeCandidateRCcontract.service.ts:891, candidate.service.ts:241
GET/fetchMappingTableRccontract.service.ts:1311,1808
GET/fetchContractInfoDropdownDataRCcontract.repositories.ts:94
GET/fetchListCapabilityLevelRccandidate.repositories.ts:1504
GET/fetchAppProfileDropdownDataRcappProfile.repositories.ts:118
GET/checkRoleRcjwt-auth.guard.ts:80

หมายเหตุสถาปัตยกรรม: ใน .env production ค่า WORKFORCE_SERVER มักชี้โดเมนเดียวกับ vtrcapi.redcross.or.th — path /workforce-api/ อาจเป็น reverse-proxy ไป service อื่น ไม่ยืนยัน ว่าโค้ดอยู่ภายใน repo vtrc-api หรือแยก deploy (UNVERIFIED repo identity)

TypeORM connection ชื่อ workforce ไม่ได้ใช้ สำหรับ queries เหล่านี้

2) vtrc-api HTTP (ยืนยันบางส่วน)

configuration.ts:7vtrcServer: process.env.VTRC_SERVER

จุดเรียกที่ยืนยันได้:

recruitment-api/src/modules/appProfile/appProfile.repositories.ts (branch: prod):422-431

typescript
const { data } = await this.axios.post(
  `${this.vtrcEndPoint}employeeDetailForRecruit`,
  { profileKey },
  { headers: { Authorization, apiKey } },
);

ใช้ใน flow verifyUserAndDifferentData / sync โปรไฟล์พนักงานภายในตอนสมัครหรือแก้ profile (ดูบท 2)

ไม่พบการเรียก GraphQL /api/graphql จาก recruitment-api ในรอบนี้ — เป็น REST path ต่อท้าย VTRC_SERVER โดยตรง

3) centralize-api / vtrc-common (ประกาศ env อย่างเดียว)

configuration.ts:6,8:

  • centralizeServer: process.env.CENTRALIZE_SERVER
  • commonServer: process.env.VTRC_COMMON_SERVER

ไม่พบ การอ่าน config.get('centralizeServer') หรือ commonServer ใน src/ / libs/ ระหว่างการสำรวจรอบนี้ — ถือว่าเป็น env ที่เตรียมไว้แต่ยังไม่ถูก wiring ใน runtime path ที่ตรวจได้ (UNVERIFIED usage)

4) Google reCAPTCHA (ยืนยันใช้จริง)

recruitment-api/src/modules/appProfile/appProfile.service.ts (branch: prod):1130-1141

typescript
async verifyCaptcha(token: string): Promise<boolean> {
  if (!token) return true; // ไม่มี token = ผ่าน
  const secretKey = process.env.RECAPTCHA_SECRET_KEY;
  const response = await this.axios.post(
    'https://www.google.com/recaptcha/api/siteverify',
    null,
    { params: { secret: secretKey, response: token } },
  );
  return response.data.success === true;
}

ใช้คู่กับ flow ตรวจพนักงานสภากาชาด (getCountEmpRedCross / check redcross) — ถ้าไม่ส่ง captcha token จะ return true (bypass)

5) SMTP / email (ยืนยันใช้จริง)

Env: SMTP_HOST, SMTP_USER, SMTP_PASS (+ อาจมี PRIVATE_KEY ใน .env สำหรับ use อื่น)

บริการอีเมล 4 ไฟล์ใต้ libs/utils/:

Fileใช้ใน flow
announcementEmailService.tsประกาศผลสอบ/สัมภาษณ์/คัดเลือก
contractEmailService.tsexport / cancel / decline ไปทีมฐาน (HRMI notification)
jobRequestEmailService.tsแจ้ง job request (เช่น restructure)
deleteHistoryEmailService.tsแจ้งคำขอลบบัญชี PDPA

ผู้รับหลายประเภทอ่านจากตาราง emailConfig (EmailConfigEntity) ตาม emailType — ไม่ hardcode ทุกกรณีใน service

6) GCP — deploy only (ไม่ใช่ HRMI runtime)

  • ไฟล์ recruitment-api/vtrc-gcp-sa-key.json (branch: prod) — service account key ใน repo (sensitive — ห้าม log / ห้ามวางในแชท)
  • bitbucket-pipelines.yml ใช้ Bitbucket variable $GCP_KEY_FILE (base64) สำหรับ gcloud + push image ไป Container Registry — แยกจากไฟล์ที่ commit
  • Runtime export HRMI = email ไม่ใช่ GCP API (ดูบท 2)

Secrets ที่อยู่ใน source control / config files

ประเภทที่อยู่หมายเหตุ
DB passwords (DB_*, DB_AI_*, VTRC_DB_*, WORKFORCE_DB_*).env (branch: prod)ห้าม commit ใหม่; rotate ถ้าเคยหลุด
SECRET_KEY / REFRESH_TOKEN_SECRET_KEY.envshared กับระบบอื่นได้
SMTP_PASS.env
RECAPTCHA_SECRET_KEY.env
RC_WEB_API_KEY.envใช้เรียก workforce
Device keys (WEB_ADMIN / RECRUITMENT-WEB)config/configuration.ts:75-104hardcode ใน source
GCP SA JSONvtrc-gcp-sa-key.jsondeploy debt — SEC scorecard

อย่าคัดลอกค่า secret ลงเอกสาร — อ้างชื่อตัวแปรและ path พอ

CORS / validation / Swagger (auth-adjacent)

จาก main.ts (branch: prod):40-48:

  • ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) — ตัด field นอก DTO
  • enableCors({ origin: '*', allowedHeaders: '*', methods: 'GET,PUT,POST,DELETE,PATCH,OPTIONS' }) — เปิดกว้าง
  • Swagger ที่ ${BASE_PATH}docs — ไม่มี flag ปิดในโค้ดที่อ่าน

Body limit 50mb (main.ts:35-36) เพื่อรองรับ base64 ไฟล์ — เพิ่ม attack surface DoS ถ้าระบบไม่มี rate limit ด้านหน้า

สรุป trust model

[rc-web] OTP → authData row → JWT(client=rc-web, email) → JwtStrategy checks authData exists
[bo/ess]  VTRC JWT(iss=redcross.or.th:vtrc) → same SECRET_KEY verify → skip authData row
[+apiKey] JwtAuthGuard.checkRole → workforce checkRoleRc(menu=ControllerClassName)
[refresh] x-refresh-token header bypasses Passport; body refresh uses REFRESH_TOKEN_SECRET_KEY

เคล็ดลับตอนทำงานจริง

  • เพิ่ม endpoint สาธารณะต้องใส่ @Public() จาก libs/jwt/jwt-auth.guard — import ผิดไฟล์จะไม่ทำงาน
  • อย่าสมมติว่ามี apiKey แล้วปลอดภัยเสมอ — ถ้าไม่ส่ง apiKey guard จะข้าม checkRole
  • ตอน debug 403 ของ portal ให้เช็คแถว authData ก่อน (logout / OTP ใหม่ลบ session)
  • ก่อนพึ่ง CENTRALIZE_SERVER / VTRC_COMMON_SERVER ให้ grep อีกรอบ — ตอนนี้ยังไม่เห็น runtime call
  • import_blacklist ที่ public ควรอยู่ในรายการ review ด้านความปลอดภัยทันที

ไป บท 5 Cron jobs