เข้าสู่ระบบและการจัดการ Token
ภาพรวม
CMS ใช้การเข้าสู่ระบบแบบ 2 ขั้น เนื่องจาก token ของระบบไม่ได้ผูกกับผู้ใช้เพียงอย่างเดียว แต่ผูกกับ LINE OA (channel) ที่ผู้ใช้เลือกทำงานด้วยในขณะนั้น
- ขั้นที่ 1 — ยืนยันตัวตนด้วย email/password ได้ token ชุดแรกที่ยังไม่ระบุ OA
- ขั้นที่ 2 — เลือก LINE OA ที่ต้องการทำงานด้วย เพื่อแลก token ชุดใหม่ที่มี
lineOaIdฝังอยู่
ผลลัพธ์คือทุกคำขอหลังจากนั้นจะถูกจำกัดขอบเขตอยู่ที่ OA ที่เลือกไว้โดยอัตโนมัติ ผู้ใช้ที่ดูแลหลาย OA จึงสลับบริบทการทำงานได้โดยกลับไปที่หน้าเลือก OA ใหม่
เอกสารนี้เหมาะกับผู้ดูแลระบบและทีมพัฒนาที่ต้องเข้าใจว่าเหตุใดผู้ใช้จึงถูกบังคับให้เลือก OA ก่อนใช้งานเมนูอื่น และ token ถูกต่ออายุหรือเพิกถอนอย่างไร
Business Flow
ขั้นที่ 1 — เข้าสู่ระบบด้วยอีเมลและรหัสผ่าน
- ผู้ใช้เปิดหน้า
/loginกรอกอีเมลและรหัสผ่าน หน้าจอมีลิงก์ไปยัง "ลืมรหัสผ่าน" และ "สมัครใช้งาน" - ระบบส่งข้อมูลไปยัง
POST /auth/loginและได้accessTokenกับrefreshTokenกลับมา - Token ถูกเก็บลง
localStorageและระบบถอดข้อมูลผู้ใช้จาก JWT (ชื่อ-นามสกุล, องค์กร, บทบาท, จำนวน channel สูงสุด) เก็บไว้ใน state ส่วนกลาง - ระบบพาไปยังหน้าเลือก LINE OA ด้วยการโหลดหน้าใหม่ทั้งหน้า เพื่อให้ระบบสิทธิ์ถูกคำนวณใหม่จาก token ล่าสุด
- หากเข้าสู่ระบบไม่สำเร็จจะแสดง modal แจ้งข้อผิดพลาด กรณีที่ระบบตอบว่าองค์กรยังไม่ถูกเปิดใช้งาน (
Organization is inactive) จะแสดงข้อความเฉพาะว่า "รออนุมัติ"
ขั้นที่ 2 — เลือก LINE OA เพื่อแลก Token
- หน้า
/line-oa-managementแสดงรายการ channel ทั้งหมดขององค์กรในรูปแบบการ์ด - ผู้ใช้คลิกการ์ด OA ที่มีสถานะ active ระบบเรียก
POST /auth/login-with-line-oaเพื่อแลก token ชุดใหม่ - Token ชุดใหม่ที่ได้จะมี
lineOaIdและข้อมูล OA อยู่ภายใน ระบบเขียนทับ token เดิม - ระบบโหลดสิทธิ์การใช้งานใหม่ทันทีก่อนจะพาไปหน้าถัดไป เพื่อไม่ให้ผู้ใช้ติดสิทธิ์ชุดเดิมที่ยังจำกัดอยู่
- ล้าง cache ของตัวกรองทุกหน้าที่ค้างอยู่ แล้วเข้าสู่หน้าแดชบอร์ด
การป้องกันเส้นทาง (Route Guard)
ทุกครั้งที่แอปพลิเคชันถูกโหลด ระบบจะตรวจสอบสถานะการเข้าสู่ระบบตามลำดับ
- ไม่มี token — ถ้าเส้นทางปัจจุบันไม่ใช่หน้าสาธารณะ (login, register, forgot/reset password, verify email หรือหน้า public ใต้
/p/) ระบบจะพากลับไปหน้า/login - มี token แต่ยังไม่ได้เลือก OA — ระบบให้สิทธิ์เข้าหน้าเลือก OA ไว้เสมอโดยไม่ขึ้นกับผลจาก API สิทธิ์ และหากผู้ใช้พยายามเข้าหน้าที่ไม่ใช่หน้าระดับบัญชี จะถูกพากลับไปเลือก OA ก่อน
- มี token ครบแล้ว — ระบบโหลดสิทธิ์จาก
GET /user/{id}/permissionแล้วสร้างชุดกฎการเข้าถึงให้ทั้งระบบ หากเรียกไม่สำเร็จ ผู้ใช้จะยังเข้าได้เฉพาะหน้าเลือก OA
การต่ออายุ Token
ระบบใช้กลไก idle-check แทนการตั้งเวลาแบบตายตัว โดยตรวจสอบทุกครั้งที่ผู้ใช้มีการกระทำบนหน้าจอ (throttle 1 วินาที)
- Access token หมดอายุแต่ refresh token ยังใช้ได้ — เรียก
POST /auth/refresh-access-tokenเพื่อขอ token ชุดใหม่ - หมดอายุทั้งคู่ — ออกจากระบบทันที
- นอกจากนี้ตัวดักจับ response กลางจะจับ HTTP 401 จากทุกคำขอ แล้วบังคับออกจากระบบพร้อมเพิกถอน token
ออกจากระบบ
- ผู้ใช้เลือก "Logout" จากเมนูโปรไฟล์บนแถบด้านบน
- ระบบล้าง cache ตัวกรองใน sessionStorage แล้วเรียก
POST /auth/revoke-tokenเพื่อเพิกถอน token ฝั่งเซิร์ฟเวอร์ - ลบ
accessToken,refreshTokenและ state ที่ persist ไว้ออกจาก localStorage แล้วพากลับไปหน้า/login
หน้าจอและองค์ประกอบหลัก
หน้าเข้าสู่ระบบ
หน้า /login ประกอบด้วยฟอร์มอีเมล/รหัสผ่านที่วางอยู่บน layout ร่วมของกลุ่มหน้า authentication (AuthLayout) ตัว container จะรอให้คอมโพเนนต์ mount เสร็จก่อนจึงแสดงฟอร์ม เพื่อป้องกันปัญหา hydration mismatch จากการ build แบบ static export ระหว่างนั้นจะแสดงข้อความกำลังโหลด
ตัวเลือก "จดจำฉัน" ถูกเตรียมโครงไว้ในโครงสร้างข้อมูลของฟอร์มแล้ว แต่ยังไม่เปิดใช้งานในเวอร์ชันปัจจุบัน
ชั้นบริการด้านการยืนยันตัวตน
บริการกลางด้าน authentication (src/services/auth.service.ts) รับผิดชอบการอ่าน เขียน และลบ token ใน localStorage การถอดรหัส JWT การต่ออายุ token และการเพิกถอน token โดยแยกเป็นฟังก์ชันย่อยที่ถูกเรียกใช้จากทั้ง UI และตัวดักจับ HTTP
การเข้าสู่ระบบทั้งสองขั้นเรียกผ่าน POST /auth/login และ POST /auth/login-with-line-oa โดยขั้นแรกยิงตรงด้วย axios ที่ยังไม่แนบ token ส่วนขั้นที่สองยิงผ่าน instance กลางที่แนบ token ขั้นแรกไปด้วย
ตัวจัดการสถานะแอปพลิเคชัน
AppProvider เป็นศูนย์กลางของทั้งสามเรื่อง คือ route guard, การโหลดสิทธิ์เข้าระบบสิทธิ์กลาง และการตรวจอายุ token แบบ idle เมื่อคอมโพเนนต์นี้ทำงาน ระบบสิทธิ์ทั้งเว็บจึงถูกกำหนดใหม่ทุกครั้งที่โหลดหน้า
แถบด้านบนและเมนูโปรไฟล์
แถบด้านบนของแอปมีเมนูโปรไฟล์ที่รวมทางเข้าไปยัง "จัดการ LINE OA" (สลับ OA), "เปลี่ยนรหัสผ่าน" และ "ออกจากระบบ"
ข้อมูลใน JWT
ข้อมูลที่ฝั่งหน้าเว็บใช้จาก JWT payload ได้แก่ รหัสผู้ใช้ (sub), organizationId, lineOaId และชื่อ OA (มีเฉพาะหลังขั้นที่ 2), ชื่อ-นามสกุล, roleId, roleName และ maxChannel
จุดเชื่อมต่อกับฟีเจอร์อื่น
- จัดการ LINE OA — หน้าเลือก OA คือขั้นที่ 2 ของการเข้าสู่ระบบโดยตรง และเป็นทางเดียวที่จะได้ token ที่ระบุ OA
- ระบบสิทธิ์ (CASL) — สิทธิ์ทั้งเว็บถูกกำหนดที่จุดเดียวคือ
AppProviderก่อนเลือก OA ระบบจะเปิดสิทธิ์ดูหน้าเลือก OA ไว้เสมอ - จัดการรหัสผ่าน — เส้นทางลืมรหัสผ่าน ตั้งรหัสผ่านใหม่ และเปลี่ยนรหัสผ่าน ทั้งหมดจบลงด้วยการกลับมาที่หน้า
/login - สมัครใช้งาน — ผู้ใช้ใหม่ต้องสมัครและยืนยันอีเมลก่อนจึงจะเข้าสู่ระบบได้ และองค์กรต้องถูกอนุมัติแล้ว
- สถานะส่วนกลาง — ข้อมูลโปรไฟล์ สิทธิ์ และธีมถูก persist ไว้ ส่วนชุดกฎการเข้าถึงไม่ถูก persist จึงต้องคำนวณใหม่ทุกครั้งที่โหลดหน้า
- ข้อจำกัดจาก build — เว็บถูก build แบบ static export และเติม slash ปิดท้าย URL ทำให้รายการเส้นทางสาธารณะต้องระบุทั้งแบบมีและไม่มี slash
รายละเอียดฝั่ง Backend (CMS API)
หัวข้อนี้อธิบายว่าฝั่ง cms-api ทำอะไรจริง ๆ เมื่อได้รับคำขอจากหน้าจอนี้ เพื่อให้เห็นภาพว่าทำไมหน้าเว็บจึงต้องทำ 2 ขั้น และทำไม token ที่ดูเหมือนยังไม่หมดอายุจึงถูกปฏิเสธได้
Endpoint ที่หน้านี้เรียกใช้
| ขั้นตอน | Endpoint | Guard ฝั่ง backend |
|---|---|---|
| ขั้นที่ 1 เข้าสู่ระบบ | POST /api/auth/login | ไม่มี (public) |
| ขั้นที่ 2 เลือก LINE OA | POST /api/auth/login-with-line-oa | JwtLoginAuth (ยอมรับ token ที่ยังไม่มี OA) |
| ต่ออายุ token | POST /api/auth/refresh-access-token | RefreshTokenAuth |
| เพิกถอน token | POST /api/auth/revoke-token | ไม่มี |
โมดูล auth ฝั่ง backend ถูก register แยกจากโมดูลฟีเจอร์อื่น เพราะนอกจากจะให้ endpoint ข้างต้นแล้ว ยังทำหน้าที่เป็นผู้ให้ global JWT guard ที่ route group ของทุกโมดูลนำไปครอบด้วย
สิ่งที่ backend ตรวจและทำในแต่ละขั้น
ขั้นที่ 1 — login
- เทียบรหัสผ่านที่ส่งมากับค่า hash ในตาราง
userด้วย bcrypt - ออก access token + refresh token ที่ยังไม่มี claim
lineOaIdโดยเจตนา — token ชุดนี้จึงผ่านได้เฉพาะ route ที่ใช้JwtLoginAuthเท่านั้น ยังเรียกเมนูงานจริงไม่ได้
ขั้นที่ 2 — login-with-line-oa
- ตรวจสิทธิ์เข้าถึง OA รายบุคคลจากตาราง
user_line_oaไม่ใช่แค่ว่า OA อยู่ในองค์กรเดียวกัน - ออก token ชุดใหม่ที่ฝัง
lineOaId,lineOaHash,organizationIdและroleId— นี่คือ token ที่ทุกเมนูงานต้องการ - Backfill อัตโนมัติ: ถ้า OA นั้นยังไม่มีค่า
lineOaHashระบบจะสร้างให้แล้วเขียนกลับลงตารางline_oaทันที ดังนั้น OA ที่ถูกสร้างไว้ก่อนหน้าจะได้ hash ตอนมีคนเลือกใช้ครั้งแรก
กลไก session ใน Redis (จุดสำคัญที่สุดของหน้านี้)
ทุกครั้งที่ออก token ระบบจะบันทึกลง Redis hash h_session:<userId> โดยเก็บเพียง 15 ตัวอักษรแรกของ SHA256 ของ token ไว้ในฟิลด์ accessToken / refreshToken ไม่ได้เก็บตัว token จริง
ผลที่ตามมาซึ่งควรรู้:
- ทุกคำขอที่ผ่าน
JwtAuthจะไม่ได้ verify แค่ลายเซ็น JWT แต่จะเทียบ hash กับค่าใน Redis ด้วย ถ้าไม่ตรงจะได้ 401 พร้อม error codeAPP_001ทันที แม้ token ยังไม่หมดอายุตามเวลา - เพราะฟิลด์ใน hash มีชุดเดียวต่อผู้ใช้ การเข้าสู่ระบบใหม่จึง เขียนทับ session เดิม ทำให้เครื่องที่ล็อกอินค้างไว้ก่อนหน้าหลุดออกเอง — อาการนี้ผู้ใช้จะเห็นเป็นการถูกเด้งออกจากระบบโดยไม่ได้กด logout
POST /api/auth/revoke-tokenลบ keyh_session:<userId>ทั้งก้อน ทำให้ token ทุกใบของผู้ใช้นั้นใช้งานไม่ได้ทันที- ต่ออายุ token สำเร็จต้องมีทั้ง refresh token ที่ยังอยู่ใน Redis และ claim
nbfในตัว token ถ้าไม่ผ่านจะได้ 400 พร้อมAPP_002หรือAPP_003ซึ่งเป็นเหตุให้หน้าเว็บบังคับออกจากระบบ
ลำดับการตรวจของ JwtAuth
Guard กลางของทุกโมดูลจะทำตามลำดับ: verify ลายเซ็น → บังคับว่าต้องมี claim lineOaId → เติมค่า tenant (user, OA, องค์กร, role) ลง context ที่ระบบสิทธิ์ทั้งหมดอ่านต่อ → เทียบ hash กับ Redis
ข้อนี้อธิบายพฤติกรรมที่หน้าเว็บบังคับให้เลือก OA ก่อน: token ขั้นที่ 1 จะถูก backend ปฏิเสธที่ขั้น "ต้องมี lineOaId" ไม่ใช่แค่ถูกกันด้วย route guard ฝั่งหน้าเว็บ
ข้อสังเกตด้านความปลอดภัยที่ควรทราบ
POST /api/auth/revoke-tokenไม่มี guard และในโค้ดมีบันทึกไว้ว่ายังไม่ได้ตรวจ admin role ตามที่ควรเป็นJWT_SECRETเป็น env ที่ขาดไม่ได้ — ถ้าไม่ตั้งค่า บริการจะไม่ boot ขึ้นเลย- นอกจาก guard ของผู้ใช้ปกติ ยังมี
SuperAdmin()ที่บังคับroleId == 1และInternalApiKey()ที่ตรวจ headerX-Internal-Keyเทียบกับ envINTERNAL_API_KEYสำหรับการเรียกภายในระบบ - key ใน Redis ถูกคั่นด้วย namespace ตาม env
REDIS_NAMESPACEดังนั้นสภาพแวดล้อมที่แชร์ Redis ก้อนเดียวกันจะไม่ปนกัน