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.validateOTP → AuthDataService.generateToken |
| Back-office / ESS | token จาก VTRC (iss) หรือ client อื่น | login ที่ legacy VTRC แล้วเรียก recruitment-api ด้วย Bearer เดียวกัน |
| Service-to-self refresh | header x-refresh-token | POST /auth/refresh_token ข้าม Passport validate |
Layer 1 — Global JWT guard (Passport)
Boot: recruitment-api/src/main.ts (branch: prod):39
app.useGlobalGuards(new JwtAuthGuard(reflector));recruitment-api/libs/jwt/jwt-auth.guard.ts (branch: prod):22-38 — canActivate:
- ถ้า handler/class มี
@Public()→return true(ไม่ตรวจ JWT) - ถ้ามี header
x-refresh-token→return true(เปิดทาง refresh) - อ่าน
authorization+apikeyแล้วเรียกsuper.canActivate(Passportjwtstrategy)
handleRequest (:41-67):
- ถ้าไม่มี
user→UnauthorizedException401 - ถ้ามี ทั้ง
AuthorizationและapiKey→ เรียกcheckRole()ไป workforce-apiGET ${WORKFORCE_SERVER}/workforce-api/api/restful/checkRoleRc?menu={ControllerName}- ถ้า
status === false→ForbiddenException403 ข้อความไทย
- ถ้ามีแค่ Bearer ไม่มี apiKey → คืน
userโดยไม่เช็ค role
// 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):
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')← envSECRET_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
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_KEY | access JWT (Passport + AuthDataService) |
REFRESH_TOKEN_SECRET_KEY | refresh 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-12 — POST /auth/refresh_token body { refreshToken } → validate → generate คู่ใหม่ → updateToken
Shared secret กับ VTRC (canonical fact)
AuthDataService ตรวจ issuer หลายจุด:
| Method | บรรทัดโดยประมาณ | พฤติกรรม |
|---|---|---|
validateEmailParamEqualTokenEmail | auth.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)
GET /jobList/sent_otp/:email(@Public) →OtpService→ ลบ/สร้างauthDataเก็บ OTPGET /jobList/validate_otp(@Public) →validateOTP→generateToken({ email, client: 'rc-web' })→updateToken- Request ถัดไปใช้ Bearer —
JwtStrategyเช็คว่า email ยังมีในauthData
AuthController มีแค่ refresh — ไม่มี login password endpoint ใน recruitment-api
@Public() catalog (ยืนยันจาก grep)
Active @Public() ใน src/ (branch: prod):
| Module | File:line | Method / path | เหตุผลที่สมเหตุสมผล |
|---|---|---|---|
jobList | jobList.controller.ts:16 | GET get_by_org_unit_id | ดูประกาศสาธารณะ |
jobList | :22 | GET get_job_detail_by_id/:id | รายละเอียดประกาศ |
jobList | :28 | GET get_default_data | default/ddl หน้า list |
jobList | :34 | GET sent_otp/:email | ขอ OTP ก่อนมี JWT |
jobList | :40 | GET validate_otp | แลก OTP เป็น JWT |
jobList | :46 | PUT logout | ออกจากระบบ portal (public แต่ใช้ token ใน header ตอน service) |
jobList | :52 | GET export_other_doc | ดาวน์โหลดแบบฟอร์ม |
checkApiOn | checkApiOn.controller.ts:8 | GET check_api_on | health |
share | share.controller.ts:10 | POST Share/share_link/ | HTML สำหรับแชร์โซเชียล |
uploadFiles | upload.controller.ts:13 | GET upload/:id | อ่าน metadata ไฟล์ตาม id |
jobRequest | jobRequest.controller.ts:93 | PUT update_visit | นับ visit ประกาศ |
blacklist | blacklist.controller.ts:20 | POST import_blacklist | Import Excel โดยไม่ต้อง JWT |
Commented-out @Public() (ปัจจุบัน ไม่ public):
| File | Path ที่เคยคิดจะเปิด |
|---|---|
upload.controller.ts:19,34,40 | POST /upload, update/delete (comment) |
contract.controller.ts:51,57 | export PDF/Excel |
announcement.controller.ts:85,91,97 | export PDF / preview email |
jobList.controller.ts:58 | AI 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-104 — apiDeviceKey.prod / .uat มีรายการ:
| appType | appName (ตัวอย่าง) |
|---|---|
WEB_ADMIN | VTRC-BACKOFFICE / UAT |
RECRUITMENT-WEB | VTRC-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:9 ← process.env.WORKFORCE_SERVER
Endpoints ที่พบในโค้ด (ไม่ครบทุกบรรทัด แต่ครบชื่อ path):
| HTTP | Path | เรียกจาก (ตัวอย่าง) |
|---|---|---|
| PUT | /updateManpowerNoJobRequestRc | jobRequest.service.ts:979, contract.service.ts:825 |
| GET | /fetchListOrgUnitDataRc | jobRequest.service.ts:266, announcement.service.ts:226 |
| GET | /fetchPosByOrgUnitRc | jobRequest.repositories.ts:522 |
| GET | /fetchInfoDropdownDataRc | jobRequest.repositories.ts:539 |
| GET | /fetchManpowerNoByPositionRc | jobRequest.repositories.ts:596 |
| GET | /fetchEmployeeWfRC | jobRequest.repositories.ts:328 |
| GET | /fetchOrgUnitByIdJobListRc | jobList.repositories.ts:401 |
| GET | /checkEmpRedCrossRc | candidate.service.ts:432, appProfile.service.ts:1070 |
| GET | /fetchEmployeeCandidateRC | contract.service.ts:891, candidate.service.ts:241 |
| GET | /fetchMappingTableRc | contract.service.ts:1311,1808 |
| GET | /fetchContractInfoDropdownDataRC | contract.repositories.ts:94 |
| GET | /fetchListCapabilityLevelRc | candidate.repositories.ts:1504 |
| GET | /fetchAppProfileDropdownDataRc | appProfile.repositories.ts:118 |
| GET | /checkRoleRc | jwt-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:7 — vtrcServer: process.env.VTRC_SERVER
จุดเรียกที่ยืนยันได้:
recruitment-api/src/modules/appProfile/appProfile.repositories.ts (branch: prod):422-431
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_SERVERcommonServer: 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
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.ts | export / 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 | .env | shared กับระบบอื่นได้ |
SMTP_PASS | .env | |
RECAPTCHA_SECRET_KEY | .env | |
RC_WEB_API_KEY | .env | ใช้เรียก workforce |
| Device keys (WEB_ADMIN / RECRUITMENT-WEB) | config/configuration.ts:75-104 | hardcode ใน source |
| GCP SA JSON | vtrc-gcp-sa-key.json | deploy debt — SEC scorecard |
อย่าคัดลอกค่า secret ลงเอกสาร — อ้างชื่อตัวแปรและ path พอ
CORS / validation / Swagger (auth-adjacent)
จาก main.ts (branch: prod):40-48:
ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })— ตัด field นอก DTOenableCors({ 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 →