recruitment-web — Core Pipeline
เล่มนี้ (branch: prod) trace flow หลักสี่ชุด: OTP login, AES + localStorage session, บันทึกใบสมัคร, และ สมัครตำแหน่งงาน — พร้อมจุดที่ email/OTP รั่วผ่าน GET query string และไฟล์แนบเป็น base64
Flow 1 — Login ด้วยอีเมล + OTP
ผู้ใช้เปิด login modal (HeaderHome)
│ FormSignIn — กรอกอีเมล กดยืนยัน
│ `pages/login/form-signIn.js` → props.handleLogin
▼
components/header/index.js#handleLogin
│ `header/index.js:218-246`
│ validate email ฝั่ง client
│
│ ถ้า process.env.REACT_APP_NODE_ENV === 'dev':
│ ข้าม backend — ใช้ { otp: 111111, refCode: 'REF CODE TEST | OTP is 111111' }
│ `header/index.js:230-231`
│ มิฉะนั้น:
│ GET /jobList/sent_otp/{email}
│ `header/index.js:232`
│
│ เก็บ refCode → setIsShowOtp(true) → แสดง FormOtp
▼
FormOtp — กรอก OTP 6 หลัก (auto-focus ช่องถัดไป)
│ อายุ OTP แสดงว่า 5 นาที (UI) + cooldown ส่งซ้ำ 60 วินาที
│ `form-otp.js:124-137`
▼
handleConfirmOtp
│ `form-otp.js:48-62`
│ รวม otp1..otp6 เป็น string
│ GET /jobList/validate_otp/?email={email}&otp={otp}
│ ↑ email + OTP อยู่ใน query string ของ GET
│ ถ้าได้ token:
│ localStorage.setItem('token', ...)
│ localStorage.setItem('refreshToken', ...)
│ เรียก onFinish() จาก header
▼
header#onFinish
│ `header/index.js:53-172`
│ encryptData({ isOtp: true }) → เก็บใน user object เป็น checkLogin
│ GET /appProfile/get_by_email/{email}
│ ถ้าพบ profile:
│ decryptText() ~25+ field PII (title, firstName, idCard, address, phone, ...)
│ `header/index.js:104-148`
│ setUserStorage(_dataLogin) ← PII plaintext ใน localStorage
│ ถ้า delAccStatus === 'wait' → modal ยกเลิกการลบบัญชี
│ ถ้าไม่พบ:
│ setUserStorage (ยังไม่มี id) → navigate /applicantProfile
│ (ถ้ามี rc-job-req ใน storage จะส่ง state.jobReq ไปด้วย)GET-query OTP leak (SEC-RCWEB-04)
| Call | Method | Sensitive ใน URL? | Citation |
|---|---|---|---|
| ส่ง OTP | GET /jobList/sent_otp/{email} | อีเมลใน path | header/index.js:232 |
| ยืนยัน OTP | GET /jobList/validate_otp/?email=...&otp=... | อีเมล + OTP ใน query | form-otp.js:51-52 |
| Logout | PUT /jobList/logout?email=... | อีเมลใน query | header/index.js:266 |
ค่าเหล่านี้จะอยู่ใน access log ของทุก proxy/nginx ที่ request ผ่าน และอาจอยู่ใน browser history / Referer
Dev OTP bypass (SEC-RCWEB-03)
เงื่อนไข REACT_APP_NODE_ENV === 'dev' เป็น runtime check ใน bundle (header/index.js:230) — ไม่ใช่ dead-code ที่ tree-shake ออกได้ ถ้า build โดยตั้ง env เป็น dev ระบบ OTP จริงจะถูกข้ามและใช้ OTP คงที่ 111111
Flow 2 — Session gate ด้วย AES checkLogin
หลัง OTP สำเร็จ:
_dataLogin.checkLogin = encryptData({ isOtp: true })
│ ใช้ REACT_APP_SECRET_KEY
│ `header/index.js:59-69`, `cryptoJs.js:4-13`
▼
setUserStorage(_dataLogin) → localStorage['user-login']
│ โครงสร้าง: { value: { email, id, data, checkLogin, ... } }
│ ไม่มี field expiry จริง (comment ปิด)
│ `localStorage.js:1-9,35-37`ตอนเข้าแอปใหม่ (App.js mount):
user = getUserStorage()
decryptedData = decryptData(user.checkLogin)
isOtp = decryptedData.isOtp
│ บรรทัดที่เคยเช็ค expiry ถูก comment ไว้:
│ //&& new Date().getTime() <= decryptedData.expiry
│ `App.js:52-56`
▼
ถ้า !isOtp และ path เป็น myjob / applicantprofile → history('/')
`App.js:59-61`สรุป: gate นี้เป็น soft client-side check เท่านั้น — ไม่ใช่ server session validation ผู้โจมตีที่แก้ localStorage ได้สามารถปลอม checkLogin ได้ถ้ามี SECRET_KEY จาก bundle
Flow 3 — กรอกและส่งใบสมัครงาน (Application Profile)
/ApplicantProfile
│ DropdownDataProvider → GET /appProfile/get_dropdown_data
│ `dropdownDataContext.js:47`
▼
Content (content.js)
│ getUser() จาก localStorage → setFormData ถ้ามีข้อมูลเดิม
│
│ ผู้ใช้กรอก 10 step แล้วกด "บันทึก" หรือ "บันทึกและสมัครงาน"
▼
handleSave(saveDraft)
│ `content.js:905-973`
│ form.validateFields() + ตรวจเพิ่ม:
│ - idCard 13 หลัก (หลังตัด '-')
│ - phone 10 หลัก
│ - age >= 18
│ - reference ≥ 1 คน (ถ้าไม่ใช่ draft)
│ - ไม่ซ้ำ father/mother/spouse ต่อ type
│ - ใบขับขี่ตรงกับ checkbox + ไฟล์แนบ
│ getCountEmpRedCross()
│ POST /appProfile/check_emp_redcross { email, idCard }
│ ถ้า status=false และ count >= 2 → เปิด reCAPTCHA popup
│ `content.js:882-903`
▼
onFinish(values [, captchaToken])
│ `content.js:384-585`
│ setDataBeforeSave(values) → encrypt ทุก field PII + base64 ไฟล์ใหม่
│ POST appProfile/create_app_profile หรือ PUT update_app_profile
│ ตรวจ response:
│ - isEmpRedCross → ห้ามบุคลากรสมัครผ่านฟอร์มนี้
│ - isStatusWait → เลขบัตรซ้ำ
│ - มี id → สำเร็จ; ถ้ามี jobReq → applyJob()
│ ดึง get_by_email อีกรอบ → decrypt → setUserStorage ใหม่Fields ที่ encrypt ด้วย encryptText ก่อนส่ง
จาก setDataBeforeSave (content.js:705-748):
title, firstName, lastName, gender, nationality, race, religion, birthDateStr, salaryStr, idCard, maritalStatus, militaryStatus, address, province, district, subDistrict, postCode, currentAddress, currentProvince, currentDistrict, currentSubDistrict, currentPostCode, phone, facebook, addressNo, moo, roomNumber, building, floor, village, lane, road, และชุด current* ของที่อยู่ละเอียด
ไม่ encrypt: email (lowercase อย่างเดียว), flags ใบขับขี่, consent flags, nested arrays (edu/exp/capability/reference), ไฟล์ metadata
Base64 attachments
ไฟล์ใหม่ (ยังไม่มี id) ถูกแปลงด้วย getPreviewUrl → FileReader.readAsDataURL (utils/file.js:1-19) แล้วฝังใน:
item.attachFiles = [{
fileType,
fileBase64: await getPreviewUrl(item.attachFile) // เฉพาะเมื่อ !id
}]ที่ content.js:654-668 (professional license) และ content.js:677-696 (เอกสารแนบทั่วไป)
ไฟล์ที่มี id แล้วส่งแค่ metadata ไม่ส่ง base64 ซ้ำ
ไม่ใช่ multipart upload — ทั้งฟอร์ม + ไฟล์ไปใน JSON body เดียวของ create/update profile
Client-only size/type check: uploadForm.js:22-30 — ≤ 2MB และ MIME ใน LIST_ACCEPT / LIST_ACCEPTNOTPDF — UNVERIFIED ว่า recruitment-api ตรวจซ้ำฝั่ง server
Flow 4 — สมัครตำแหน่งงาน (apply)
เกิดได้จากสองทาง:
- จาก ApplicantProfile หลังบันทึกสำเร็จพร้อม
location.state.jobReq→applyJob()(content.js:987-1041) - จากหน้า JobDetail โดยตรง →
candidate/createในjobDetail/content/index.js:330
validateApplyJob (เฉพาะจาก content.js)
│ `content.js:1233-1292` (โดยประมาณ — เรียกจาก onFinish)
│ - checkStartDateApply() — วันเปิดรับ
│ - GET candidate/check_duplicate_jobRequestNo?email=&jobReqDocumentNo=
│ - checkInvalidData / checkAttachFile / checkCarLicense
▼
POST candidate/create
body รวม:
status: 'wait'
documentNo: `RCC-${Date.now()}`
jobRequestNo, position*, bu*, orgUnit*, email, applyDate, ...
│ `content.js:992-1022`
▼
status 201 → ReactGA.event("สมัครงาน" / "สมัครงานสำเร็จ")
│ `content.js:1023-1031`
▼
Modal.success → navigate /myJobบน JobDetail ยังมี PUT /jobRequest/update_visit นับ visit ทุกครั้งที่เปิดหน้า (jobDetail/index.js:27-30)
Flow 5 — ลบบัญชี (delete account)
จาก form-signOut.js เมื่อ login แล้ว:
GET dropdown/get_dropdown_by_type?dropdownType=delAccReason(form-signOut.js:53-55)- ผู้ใช้กรอกเหตุผล →
POST deleteHistory/create_delete_history(form-signOut.js:175) - ถ้า login มาแล้วเจอ
delAccStatus === 'wait'→PUT deleteHistory/check_update_del_accเพื่อยกเลิก (header/index.js:174-182)
จุดที่ต้องแก้พร้อมกันเมื่อเพิ่ม field PII
| จุด | ทำอะไร | Citation |
|---|---|---|
setDataBeforeSave | encrypt ก่อนส่ง | content.js:705-748 |
onFinish หลัง save | decrypt หลัง get_by_email | content.js:492-537 |
header#onFinish | decrypt ตอน login | header/index.js:104-148 |
ถ้าเพิ่ม field ที่จุดเดียว ข้อมูลจะไม่ sync ระหว่าง login กับหลังบันทึก (QUAL-RCWEB-04)
reCAPTCHA
- Site key อ่านจาก
process.env.REACT_APP_RECAPTCHA_SITE_KET(typoKET) ที่content.js:1355 - ไม่บังคับทุกครั้ง — เปิดเฉพาะเมื่อ
check_emp_redcrossคืนcount >= 2 - Token ส่งเป็น
captchaTokenใน payload profile (content.js:392-394)
สรุปความเสี่ยงของ pipeline นี้
- OTP และอีเมลใน GET URL → access log leak
- AES key ใน client bundle + git → การ encrypt ไม่มีความลับจริง
- PII plaintext ใน
localStorageไม่มี expiry - Payload base64 ใหญ่ — timeout / memory ฝั่ง proxy เป็นความเสี่ยงเชิงปฏิบัติ
- Auth gate อยู่แค่ client (
App.js) — server ต้อง enforce JWT เองที่recruitment-api(นอก scope เล่มนี้)