4.2.4 · Page deep dive — Login, PathLogin (SSO), ManageUser
บทนี้เจาะลึก 3 หน้าสำคัญของ vtrc-rc-backoffice — Login, PathLogin (SSO bypass route), ManageUser
1. Login — src/pages/Login/index.js
หน้า login ของ backoffice — จุดเริ่มต้น auth flow
โครงสร้าง folder
src/pages/Login/
├── index.js — main component (380+ บรรทัด)
├── components/
│ ├── LoginForm.js — form กรอก username/password/organization
│ ├── Captcha.js — captcha (แสดงหลัง password ผิด 3 ครั้ง)
│ └── ErrorModal.js — modal สำหรับ INTERNAL_SERVER_ERROR
└── styles.cssUser flow
ผู้ใช้เปิด /login
↓
[1] Login constructor
└── state = { showCaptcha: false, loadingFullPage: false, showModalErrors: false }
↓
[2] ผู้ใช้กรอก empCode + password + organization
↓
[3] คลิก "Sign In" → onSubmit (Login/index.js:48)
↓
[4] เรียก Mutations.loginBackoffice
├── variables: { empCode, password, organization, apiKey, appVersion: "v1.0" }
└── backend ส่ง { accessToken, refreshToken, profileData, role, menu }
↓
[5] setToken(data.loginBackoffice) — localStorage + sessionStorage
↓
[6] ถ้า errors && errors[0].extensions.code === 'INTERNAL_SERVER_ERROR'
└── showModalErrors = true (modal error ไม่ใช่ alert)
↓
[7] สำเร็จ → this.props.history.push('/')
↓
[8] App/index.js redirect ไป SiderLayout (เพราะ isLoggedIn() ตอนนี้ true)Evidence — vtrc-rc-backoffice/src/pages/Login/index.js:48-66
onSubmit = async ({ password, remember, username }) => {
try {
const { client } = this.props
const API_DEVICE_KEY = apiKeyNews()
const { data, errors } = await client.mutate({
mutation: Mutations.loginBackoffice,
variables: {
empCode: username,
password: password,
appVersion: "v1.0",
apiKey: API_DEVICE_KEY,
organization: getOrganization(),
}
})Quirks — สำคัญมาก
NO RSA encryption — V1 (docs-v1-archive) ระบุผิดว่ามี RSA encryption ฝั่ง client จริง ๆ ไม่มี JSEncrypt หรือ node-forge library ใด ๆ ใน
vtrc-rc-backoffice(ยืนยันด้วยrg "JSEncrypt|node-forge|jsencrypt|RSA|publicKey" src/= 0 match ที่เกี่ยวกับ crypto) — password ส่ง plaintext ผ่าน HTTPS ตรง ๆ ที่Login/index.js:55-66Captcha หลัง fail 3 ครั้ง —
state.showCaptchatoggle ตามจำนวนครั้งที่ password ผิด แต่ logic นับอยู่ที่ฝั่ง backend (ไม่ใช่ client) ทำให้ client bypass ไม่ได้appVersion: "v1.0"hardcoded — ส่งทุกครั้ง ไม่ dynamic ตามpackage.jsonversion — ทำให้ backend ไม่รู้ว่า client ใช้เวอร์ชั่นไหนgetOrganization()อ่านจาก sessionStorage — ถ้า sessionStorage ว่าง จะคืนค่า default ที่ไม่ใช่ค่าจริง — ทำให้ login fail ด้วยUSER_OR_ORG_WRONGerrorconsole.log("errors", errors)ที่Login/index.js:67— แต่App/index.js:17ปิด console.log ทั้งหมด ทำให้ log ไม่แสดงไม่มี "Forgot password" ที่หน้า login — forgot password อยู่ในหน้าอื่น (เรียก admin) — ทำให้ UX ไม่สะดวก
2. PathLogin — src/pages/PathLogin/index.js (SSO bypass)
route /pathLogin/:token/:refreshToken — รับ token ทั้งคู่ผ่าน URL path สำหรับ SSO จากระบบอื่น (เช่น 2way-meeting-backoffice, pms-api, sso-api)
User flow
ระบบ A ต้องการส่ง user มา backoffice โดยไม่ต้อง login ใหม่
↓
[1] ระบบ A เรียก backend เพื่อ mint token คู่ใหม่ (หรือ reuse ของเดิม)
↓
[2] ระบบ A redirect browser ไปที่:
https://vtrcbackoffice.redcross.or.th/pathLogin/{token}/{refreshToken}
↓
[3] PathLogin mount — อ่าน useParams() เอา token + refreshToken
↓
[4] setToken({ accessToken: token, refreshToken }) — เก็บลง localStorage
↓
[5] this.props.history.push('/')
↓
[6] App/index.js redirect ไป SiderLayout (เพราะ isLoggedIn() ตอนนี้ true)Evidence — vtrc-rc-backoffice/src/containers/App/index.js:24
<Route path="/pathLogin/:token/:refreshToken" exact component={PathLogin}/>Security analysis — Severity 🔴
| Risk | Severity | เหตุผล |
|---|---|---|
| Browser history leak | 🔴 | token ทั้งคู่ปรากฏใน window.history และ back/forward dropdown |
| Server access log leak | 🔴 | nginx log /pathLogin/{token}/... เป็น text ทุก request |
| Referer header leak | 🔴 | ถ้ามี link ออกจากหน้านี้ (ระหว่างที่ URL ยังอยู่) — token ส่งไป domain อื่น |
| Shoulder surfing | 🟡 | URL ปรากฏบน address bar ชัดเจน — คนข้าง ๆ เห็นได้ |
| Bookmark share | 🟡 | ถ้า user bookmark URL นี้แล้ว share ให้คนอื่น |
Remediation
- เปลี่ยน SSO ให้ใช้ POST body แทน URL — ระบบ A ส่ง form ที่ auto-submit ไป
/sso-receiverด้วย POST - ใช้ one-time token — mint token ที่ใช้ได้ครั้งเดียว (TTL 30 วินาที) แล้วแลกเป็น long-lived token ฝั่ง server
- ถ้าต้องใช้ URL — เปลี่ยนเป็น fragment (
#token=...) ที่ browser ไม่ส่งใน request และไม่ log
3. ManageUser — src/pages/ManageUser/index.js
หน้า admin สำหรับจัดการ user + role + menu permission
โครงสร้าง folder
src/pages/ManageUser/
├── index.js — list view (table ของ user)
├── form.js — form สร้าง/แก้ไข user
├── menu/
│ ├── index.js — menu permission editor
│ └── MenuTree.js — tree component สำหรับเลือก menu
└── components/
├── RoleSelect.js — dropdown เลือก role
└── UserStatusBadge.js — badge active/inactiveUser flow
admin เปิด /manageuser
↓
[1] ManageUser componentDidMount
└── fetchUsers (Queries.FETCH_USERS)
↓
[2] render table — คอลัมน์: empCode, name, email, role, status, action
↓
[3] admin คลิก "Add" → /manageuser/form
└── กรอก empCode + role + organization → CREATE_USER mutation
↓
[4] admin คลิก "Edit" → /manageuser/form/:id
└── แก้ไข role + status → UPDATE_USER mutation
↓
[5] admin คลิก "Permission" → /manageuser/menu/:id
└── tree ของ menu ทั้งหมด → check/uncheck → UPDATE_USER_MENU mutationQuirks
role + menu แยก mutation — UPDATE_USER ไม่ได้รวม menu permission ต้องเรียก UPDATE_USER_MENU แยก ทำให้ transaction ไม่ atomic (user อาจมี role ใหม่แต่ menu เก่า)
FETCH_USERSไม่ paginate ที่ client — ดึง user ทั้งหมดในครั้งเดียว ทำให้ response ใหญ่ถ้ามี user > 1000menu tree static —
MenuTree.jsมี hardcoded menu structure ตามroutes.jsถ้าเพิ่ม route ใหม่ต้องอัปเดตMenuTree.jsด้วยไม่มี audit log —
UPDATE_USER_ROLEไม่ได้ log ว่า admin คนไหนแก้ role ของ user คนไหนเมื่อไหร่soft delete ผ่าน
status: INACTIVE— ไม่มี hard delete ทำให้ user inactive ยังคงอยู่ใน table
Pattern ที่ใช้ทั่วทุกหน้า
1. Class component (module เก่า) + Function component (module ใหม่)
Module เก่า (Login, ManageUser, LeaveStudies, Hospital):
class XxxPage extends Component {
componentDidMount() { /* fetch */ }
handleSubmit = () => { /* mutate */ }
render() { /* ... */ }
}
export default withApollo(Form.create()(XxxPage))Module ใหม่ (WorkForce, PMS, Recruitment):
const XxxPage = () => {
const [form] = Form.useForm()
const { data, loading } = useQuery(Queries.FETCH_XXX)
// ...
}
export default XxxPage2. componentDidMount สำหรับ initial fetch (class)
async componentDidMount() {
const { data } = await this.props.client.query({
query: Queries.FETCH_USERS,
fetchPolicy: 'no-cache' // ใช้ no-cache เพราะ defaultOptions ตั้งไว้
})
this.setState({ users: data.fetchUsers })
}3. State shape มาตรฐาน
state = {
loading: false,
data: [],
showModalErrors: false, // เฉพาะ backoffice มี
showCaptcha: false // เฉพาะ Login
}4. showModalErrors pattern — สำหรับ INTERNAL_SERVER_ERROR
แทนที่จะ alert — backoffice ใช้ modal แสดง error ที่สวยงามกว่า แต่ทำให้ catch error ยากขึ้นเพราะต้อง handle state
เปรียบเทียบกับ vtrc-web
| ด้าน | vtrc-web Login | vtrc-rc-backoffice Login |
|---|---|---|
| Mutation | Mutations.login | Mutations.loginBackoffice |
| Token storage | localStorage + sessionStorage | localStorage + sessionStorage |
| First login OTP | ใช่ | ไม่ใช่ |
| Captcha | ไม่มี | ใช่ (หลัง fail 3 ครั้ง) |
| Modal for errors | ไม่ใช่ (alert ตรง ๆ) | ใช่ (showModalErrors) |
| Redirect after login | /profile | / (SiderLayout) |
| SSO bypass route | ไม่มี | /pathLogin/:token/:refreshToken |
| RSA encryption | ไม่มี | ไม่มี (V1 ผิด) |