Skip to main content

เข้าสู่ระบบและการจัดการ 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 — เข้าสู่ระบบด้วยอีเมลและรหัสผ่าน

  1. ผู้ใช้เปิดหน้า /login กรอกอีเมลและรหัสผ่าน หน้าจอมีลิงก์ไปยัง "ลืมรหัสผ่าน" และ "สมัครใช้งาน"
  2. ระบบส่งข้อมูลไปยัง POST /auth/login และได้ accessToken กับ refreshToken กลับมา
  3. Token ถูกเก็บลง localStorage และระบบถอดข้อมูลผู้ใช้จาก JWT (ชื่อ-นามสกุล, องค์กร, บทบาท, จำนวน channel สูงสุด) เก็บไว้ใน state ส่วนกลาง
  4. ระบบพาไปยังหน้าเลือก LINE OA ด้วยการโหลดหน้าใหม่ทั้งหน้า เพื่อให้ระบบสิทธิ์ถูกคำนวณใหม่จาก token ล่าสุด
  5. หากเข้าสู่ระบบไม่สำเร็จจะแสดง modal แจ้งข้อผิดพลาด กรณีที่ระบบตอบว่าองค์กรยังไม่ถูกเปิดใช้งาน (Organization is inactive) จะแสดงข้อความเฉพาะว่า "รออนุมัติ"

ขั้นที่ 2 — เลือก LINE OA เพื่อแลก Token

  1. หน้า /line-oa-management แสดงรายการ channel ทั้งหมดขององค์กรในรูปแบบการ์ด
  2. ผู้ใช้คลิกการ์ด OA ที่มีสถานะ active ระบบเรียก POST /auth/login-with-line-oa เพื่อแลก token ชุดใหม่
  3. Token ชุดใหม่ที่ได้จะมี lineOaId และข้อมูล OA อยู่ภายใน ระบบเขียนทับ token เดิม
  4. ระบบโหลดสิทธิ์การใช้งานใหม่ทันทีก่อนจะพาไปหน้าถัดไป เพื่อไม่ให้ผู้ใช้ติดสิทธิ์ชุดเดิมที่ยังจำกัดอยู่
  5. ล้าง 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

ออกจากระบบ

  1. ผู้ใช้เลือก "Logout" จากเมนูโปรไฟล์บนแถบด้านบน
  2. ระบบล้าง cache ตัวกรองใน sessionStorage แล้วเรียก POST /auth/revoke-token เพื่อเพิกถอน token ฝั่งเซิร์ฟเวอร์
  3. ลบ 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 ที่หน้านี้เรียกใช้

ขั้นตอนEndpointGuard ฝั่ง backend
ขั้นที่ 1 เข้าสู่ระบบPOST /api/auth/loginไม่มี (public)
ขั้นที่ 2 เลือก LINE OAPOST /api/auth/login-with-line-oaJwtLoginAuth (ยอมรับ token ที่ยังไม่มี OA)
ต่ออายุ tokenPOST /api/auth/refresh-access-tokenRefreshTokenAuth
เพิกถอน tokenPOST /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 code APP_001 ทันที แม้ token ยังไม่หมดอายุตามเวลา
  • เพราะฟิลด์ใน hash มีชุดเดียวต่อผู้ใช้ การเข้าสู่ระบบใหม่จึง เขียนทับ session เดิม ทำให้เครื่องที่ล็อกอินค้างไว้ก่อนหน้าหลุดออกเอง — อาการนี้ผู้ใช้จะเห็นเป็นการถูกเด้งออกจากระบบโดยไม่ได้กด logout
  • POST /api/auth/revoke-token ลบ key h_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() ที่ตรวจ header X-Internal-Key เทียบกับ env INTERNAL_API_KEY สำหรับการเรียกภายในระบบ
  • key ใน Redis ถูกคั่นด้วย namespace ตาม env REDIS_NAMESPACE ดังนั้นสภาพแวดล้อมที่แชร์ Redis ก้อนเดียวกันจะไม่ปนกัน