Skip to content

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.css

User 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

javascript
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 — สำคัญมาก

  1. 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-66

  2. Captcha หลัง fail 3 ครั้งstate.showCaptcha toggle ตามจำนวนครั้งที่ password ผิด แต่ logic นับอยู่ที่ฝั่ง backend (ไม่ใช่ client) ทำให้ client bypass ไม่ได้

  3. appVersion: "v1.0" hardcoded — ส่งทุกครั้ง ไม่ dynamic ตาม package.json version — ทำให้ backend ไม่รู้ว่า client ใช้เวอร์ชั่นไหน

  4. getOrganization() อ่านจาก sessionStorage — ถ้า sessionStorage ว่าง จะคืนค่า default ที่ไม่ใช่ค่าจริง — ทำให้ login fail ด้วย USER_OR_ORG_WRONG error

  5. console.log("errors", errors) ที่ Login/index.js:67 — แต่ App/index.js:17 ปิด console.log ทั้งหมด ทำให้ log ไม่แสดง

  6. ไม่มี "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

javascript
<Route path="/pathLogin/:token/:refreshToken" exact component={PathLogin}/>

Security analysis — Severity 🔴

RiskSeverityเหตุผล
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

  1. เปลี่ยน SSO ให้ใช้ POST body แทน URL — ระบบ A ส่ง form ที่ auto-submit ไป /sso-receiver ด้วย POST
  2. ใช้ one-time token — mint token ที่ใช้ได้ครั้งเดียว (TTL 30 วินาที) แล้วแลกเป็น long-lived token ฝั่ง server
  3. ถ้าต้องใช้ 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/inactive

User 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 mutation

Quirks

  1. role + menu แยก mutation — UPDATE_USER ไม่ได้รวม menu permission ต้องเรียก UPDATE_USER_MENU แยก ทำให้ transaction ไม่ atomic (user อาจมี role ใหม่แต่ menu เก่า)

  2. FETCH_USERS ไม่ paginate ที่ client — ดึง user ทั้งหมดในครั้งเดียว ทำให้ response ใหญ่ถ้ามี user > 1000

  3. menu tree staticMenuTree.js มี hardcoded menu structure ตาม routes.js ถ้าเพิ่ม route ใหม่ต้องอัปเดต MenuTree.js ด้วย

  4. ไม่มี audit logUPDATE_USER_ROLE ไม่ได้ log ว่า admin คนไหนแก้ role ของ user คนไหนเมื่อไหร่

  5. soft delete ผ่าน status: INACTIVE — ไม่มี hard delete ทำให้ user inactive ยังคงอยู่ใน table


Pattern ที่ใช้ทั่วทุกหน้า

1. Class component (module เก่า) + Function component (module ใหม่)

Module เก่า (Login, ManageUser, LeaveStudies, Hospital):

javascript
class XxxPage extends Component {
  componentDidMount() { /* fetch */ }
  handleSubmit = () => { /* mutate */ }
  render() { /* ... */ }
}
export default withApollo(Form.create()(XxxPage))

Module ใหม่ (WorkForce, PMS, Recruitment):

javascript
const XxxPage = () => {
  const [form] = Form.useForm()
  const { data, loading } = useQuery(Queries.FETCH_XXX)
  // ...
}
export default XxxPage

2. componentDidMount สำหรับ initial fetch (class)

javascript
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 มาตรฐาน

javascript
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 Loginvtrc-rc-backoffice Login
MutationMutations.loginMutations.loginBackoffice
Token storagelocalStorage + sessionStoragelocalStorage + 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 ผิด)

ขั้นตอนถัดไป

ไป บท 4.2.5 Security analysis