Skip to main content

ยืนยันตัวตนด้วย OTP ในฟอร์ม

ภาพรวม​

ฟอร์มบางชุดจำเป็นต้องยืนยันว่าผู้กรอกเป็นเจ้าของหมายเลขโทรศัพท์หรืออีเมลที่ระบุจริง ตัวอย่างที่พบบ่อยคือฟอร์มที่ผูกกับฐานข้อมูลสมาชิก เมื่อแอดมินเปิดการตั้งค่า OTP ภายใต้ profile mapping จากฝั่ง CMS ฟอร์มฝั่งลูกค้าจะแทรกขั้นตอน "ยืนยันรหัส 6 หลัก" คั่นระหว่างการกดส่งกับการส่งคำตอบจริง

การป้องกันที่แท้จริงอยู่ฝั่งเซิร์ฟเวอร์ กล่าวคือเซิร์ฟเวอร์จะยอมรับ submission เฉพาะกรณีที่มีรหัสอ้างอิง OTP ที่ยืนยันสำเร็จแล้วแนบมาด้วยเท่านั้น

Business Flow​

  1. ผู้ใช้กรอกฟอร์มครบถ้วนแล้วกดส่ง
  2. container ตรวจสอบว่าฟอร์มนี้ต้องยืนยัน OTP หรือไม่ หากต้องยืนยันและยังไม่มีรหัสอ้างอิง ระบบจะ แช่คำตอบไว้ในหน่วยความจำ แล้วสลับหน้าจอไปเป็นหน้ายืนยัน OTP การแช่คำตอบไว้มีเหตุผลสำคัญคือ ทำให้การจับคู่สมาชิกตอนขอ OTP และตอนส่งคำตอบจริงใช้ค่าชุดเดียวกันอย่างแม่นยำ
  3. หน้ายืนยัน OTP ตรวจสอบช่องทางที่แอดมินเปิดใช้ หากมีมากกว่าหนึ่งช่องทาง ระบบจะให้ผู้ใช้เลือกระหว่าง SMS และอีเมลก่อน
  4. เมื่อกดขอรหัส ระบบยิง POST /form-builder/:hash/otp/request พร้อมส่งรหัสฟอร์ม คำตอบที่แช่ไว้ และช่องทางที่เลือก แล้วได้รับรหัสอ้างอิง ช่องทางที่ใช้จริง ปลายทางแบบปิดบัง และระยะเวลาหมดอายุกลับมา
  5. หน้าจอแสดงข้อความที่แอดมินตั้งค่าไว้ โดยแทนที่ตัวแปร `{destination}` ด้วยปลายทางที่ถูกปิดบังบางส่วน เช่น 08xxxxx051
  6. ผู้ใช้กรอกรหัส 6 หลัก แล้วระบบยิง POST /form-builder/:hash/otp/verify พร้อมรหัสอ้างอิงและรหัสที่กรอก
  7. เมื่อยืนยันสำเร็จ container จะเก็บรหัสอ้างอิงไว้แล้วส่งคำตอบที่แช่ไว้ทันที โดยแนบรหัสอ้างอิงเข้าไปใน FormData ด้วย
  8. ปุ่มขอรหัสใหม่มีช่วงเวลารอคอย 60 วินาที พร้อมนับถอยหลังทีละวินาทีให้ผู้ใช้เห็น

การจัดการข้อผิดพลาด​

กรณีพฤติกรรม
กรอกรหัสผิดเกินจำนวนครั้งที่กำหนด หรือรหัสหมดอายุบังคับให้ผู้ใช้ขอรหัสใหม่ก่อนจึงจะกรอกได้อีกครั้ง
ไม่พบโปรไฟล์สมาชิกที่ตรงกับข้อมูลที่กรอกเด้งกลับไปหน้าฟอร์ม พร้อมแสดงข้อความบนฟอร์ม เนื่องจากเป็นปัญหาของข้อมูลที่กรอก ไม่ใช่ปัญหาการยืนยันตัวตน

หน้าจอและองค์ประกอบหลัก​

หน้ายืนยัน OTP

  • หน้าจอหลัก (src/components/form-builder/otp/otp-page.tsx) บรรจุ state machine ทั้งหมดไว้ในที่เดียว ครอบคลุมการขอรหัส การยืนยัน การนับถอยหลังของปุ่มขอรหัสใหม่ และตัวเลือกช่องทาง ค่าคงที่สำคัญคือความยาวรหัส 6 หลัก และช่วงรอคอยขอรหัสใหม่ 60 วินาที

โมดูลสนับสนุน (อยู่ภายใต้ src/components/form-builder/otp/)

  • โมดูลตัวช่วย (otp-util.ts) รับผิดชอบการหาช่องทางที่เปิดใช้งาน การตรวจว่าฟอร์มต้องยืนยัน OTP หรือไม่ และการแปลงชื่อช่องทางเป็นข้อความที่ผู้ใช้อ่านได้
  • โมดูลข้อความ (otp-copy.ts) เก็บข้อความเริ่มต้นและฟังก์ชันแทนที่ตัวแปรในข้อความ ข้อความเริ่มต้นชุดนี้ ต้องตรงกับฝั่ง CMS ทุกตัวอักษร เพื่อให้สิ่งที่แอดมินเห็นตอนตั้งค่าตรงกับสิ่งที่ผู้ใช้เห็นจริง
  • โมดูลแปลข้อผิดพลาด (otp-error.ts) อ่านโครงสร้าง error ที่ไม่ได้อยู่ในรูป envelope มาตรฐานของระบบ และรองรับกรณีที่รหัสข้อผิดพลาดซ้อนอยู่ภายใน message

Service และชนิดข้อมูล

  • OTP service (src/service/otp.service.ts) ให้ฟังก์ชันขอรหัสและยืนยันรหัส
  • ชนิดข้อมูลที่เกี่ยวข้องกับการตั้งค่า OTP ช่องทาง และข้อความ อยู่ใน src/service/types/form-builder.types.ts

Endpoint ที่ใช้

MethodPathใช้ทำอะไร
POST/form-builder/:hash/otp/requestขอรหัส OTP
POST/form-builder/:hash/otp/verifyยืนยันรหัส OTP

ส่วนนี้มี unit test ครอบคลุมทั้งหน้าจอ โมดูลตัวช่วย และโมดูลข้อความ อยู่ใน src/components/form-builder/otp/__tests__/

จุดเชื่อมต่อกับฟีเจอร์อื่น​

  • ทั้งสอง endpoint แนบ header x-liff-token เช่นเดียวกับ endpoint ส่งฟอร์ม ผ่านตัวช่วยชุดเดียวกัน (ดู การล็อกอิน LINE ผ่าน LIFF)
  • ใช้ CSS variable ธีมชุดเดียวกับฟอร์ม เพื่อให้หน้าตาต่อเนื่องกันไม่รู้สึกว่าออกจากฟอร์ม
  • ผูกกับ กรอกฟอร์ม แบบแยกจากกันไม่ได้ เนื่องจากเป็นขั้นตอนหนึ่งภายใน container เดียวกัน
  • ข้อความข้อผิดพลาดระดับระบบมาจากเซิร์ฟเวอร์ทั้งหมด ไม่มีการแปลหรือดัดแปลงฝั่ง client

รายละเอียดฝั่ง Backend (Client API)​

แนวคิดหลัก: OTP ส่งไปที่ช่องทาง "ในฐานข้อมูล" ไม่ใช่ที่ผู้ใช้พิมพ์​

นี่คือจุดที่สำคัญที่สุดของฟีเจอร์นี้และมักถูกเข้าใจผิด — เมื่อผู้กรอกตอบตรงกับแถวในฐานข้อมูลลูกค้าแล้ว ระบบจะส่งรหัสไปที่เบอร์หรืออีเมลที่บันทึกอยู่ในแถวนั้น ไม่ใช่ที่ผู้กรอกพิมพ์เข้ามา เพราะเจตนาคือพิสูจน์ว่าคนที่กรอกเป็นเจ้าของ record จริง ไม่ใช่แค่รู้ข้อมูลของ record

ผลที่ตามมาสำหรับหน้าเว็บ: ปลายทางที่แสดงให้ผู้ใช้เห็น (แบบปิดบัง) มาจาก backend เท่านั้น หน้าเว็บไม่สามารถคำนวณเองได้ และผู้ใช้อาจเห็นเบอร์ที่ตัวเองไม่ได้กรอกเข้ามา

ตอนขอรหัส (POST /api/form-builder/:hash/otp/request)​

endpoint เดียวกันนี้ใช้ทั้งการขอครั้งแรกและการขอรหัสใหม่ (แยกด้วยการส่งรหัสอ้างอิงมาด้วยหรือไม่) ลำดับการตรวจ:

  1. โหลดฟอร์ม → verify x-liff-token → เช็คการบังคับล็อกอิน
  2. ฟอร์มที่ไม่ได้เปิด OTP → 400 OTP is not enabled for this form
  3. จับคู่สมาชิกใหม่ฝั่ง server ทุกครั้งจากคำตอบใน body — ไม่เชื่อผลจาก client เลย ถ้าไม่ match จะได้ PROFILE_NOT_FOUND และ ไม่มี OTP ถูกส่งออกไปแม้แต่ครั้งเดียว (นี่คือเหตุผลที่หน้าเว็บต้องแช่คำตอบไว้และส่งชุดเดียวกันทั้งตอนขอ OTP และตอน submit — ถ้าคำตอบเปลี่ยน การ match จะเปลี่ยนตาม)
  4. เลือกช่องทางตามลำดับ: ค่าที่ส่งมาใน body → ช่องทางของ session เดิม (กรณีขอใหม่) → ถ้าฟอร์มเปิดไว้ช่องทางเดียวก็ใช้ตัวนั้น → ถ้ายังตัดสินไม่ได้ → 400 Please choose a verification channel. ; ช่องทางที่ไม่ได้เปิดในฟอร์มก็ 400 เช่นกัน
  5. อ่านปลายทางจากคอลัมน์ของแถวที่ match — ถ้าคอลัมน์นั้นว่าง → 400 {code: "OTP_NO_CONTACT"} (สมาชิกมีจริงแต่ไม่มีช่องทางติดต่อ ต้องให้แอดมินไปเติมข้อมูล)
  6. โหลดการตั้งค่า OTP ของ OA แล้วตรวจว่าช่องทางถูกตั้งค่าครบ: SMS ต้องเปิดใช้และมี key/secret ครบ; อีเมลต้องเปิดใช้และมีหัวเรื่องกับเนื้อหา และเนื้อหาต้องมีตัวแปร {otp} อยู่ด้วย — ไม่ครบจะได้ 400 {code: "OTP_NOT_CONFIGURED"} ซึ่งเป็นปัญหาการตั้งค่า ไม่ใช่ความผิดผู้ใช้
  7. ตัวกันการขอซ้ำ (มีผลกับการขอรหัสใหม่เท่านั้น ไม่ใช่ครั้งแรก): ขอห่างจากครั้งก่อนไม่ถึง 60 วินาที → 429 {code: "OTP_RESEND_COOLDOWN"} ; ขอครบ 3 ครั้งแล้ว → 429 {code: "OTP_RESEND_LIMIT"} — สังเกตว่าเป็น 429 ไม่ใช่ 400 ต่างจาก error อื่นในกลุ่มนี้
  8. ส่งรหัสตามช่องทาง แล้วเขียน session ลง Redis พร้อมรหัสอ้างอิงที่สุ่มขึ้นใหม่
  9. คืนรหัสอ้างอิง ช่องทางที่ใช้จริง ปลายทางแบบปิดบัง และเวลาหมดอายุ ด้วย status 201

ตอนยืนยันรหัส (POST /api/form-builder/:hash/otp/verify)​

  • session ที่ไม่มีหรือหมดอายุแล้ว → {code: "OTP_EXPIRED"}
  • session ที่ยืนยันสำเร็จไปแล้ว → ผ่านทันทีโดยไม่ตรวจรหัสซ้ำ (ยิงซ้ำจึงไม่ทำให้เสียจำนวนครั้ง)
  • กรอกผิดครบ 5 ครั้ง → ลบ session ทิ้ง แล้วคืน {code: "OTP_MAX_ATTEMPTS"} — ต้องขอรหัสใหม่ทั้งรอบ
  • กรอกผิดแต่ยังไม่ครบ → 400 {code: "OTP_INVALID_PIN"} พร้อมข้อความที่ บอกจำนวนครั้งที่เหลือ มาให้ตรงๆ หน้าเว็บจึงแสดงตัวเลขนี้ได้โดยไม่ต้องนับเอง
  • ความล้มเหลวของ transport (ThaiBulkSMS ล่ม, network error) ไม่ถูกนับเป็นการกรอกผิด ผู้ใช้จึงไม่เสียจำนวนครั้งเพราะปัญหาของระบบ
  • ยืนยันสำเร็จจะตั้งธง verified ไว้แต่ ไม่ต่ออายุ TTL โดยเจตนา — เพื่อไม่ให้ใครยืดหน้าต่างการยืนยันออกไปเรื่อยๆ (ต่างจากการขอรหัสใหม่ที่ reset TTL เพราะมีรหัสใหม่จริง)
  • session ที่ผูกกับฟอร์มอื่นจะถูกปฏิเสธด้วย OTP_EXPIRED เป็นการป้องกันซ้อนชั้น ไม่ให้เอา OTP ของฟอร์มหนึ่งไปใช้กับอีกฟอร์ม

ด่านตอน submit — ทำไมยืนยัน OTP แล้วยังอาจได้ OTP_REQUIRED​

ตอนส่งฟอร์มจริง backend ตรวจ ครบทั้ง 5 ข้อ ไม่ผ่านข้อใดข้อหนึ่งก็ได้ 400 {code: "OTP_REQUIRED"} เหมือนกันหมด:

  1. มีรหัสอ้างอิงแนบมาใน body
  2. session นั้นยังมีอยู่ (ไม่หมดอายุ)
  3. session ยืนยันแล้วจริง
  4. session ผูกกับฟอร์มนี้
  5. แถวที่ session ผูกไว้ ต้องเท่ากับแถวที่ match ใหม่ตอน submit

ข้อที่ 5 เป็นสาเหตุที่พบได้จริง: ถ้าผู้ใช้ย้อนกลับไปแก้คำตอบที่ใช้จับคู่สมาชิกหลังยืนยัน OTP แล้ว การ match จะเปลี่ยนแถวและ OTP ที่ยืนยันไว้จะใช้ไม่ได้ทันที — เป็นการป้องกันการยืนยันด้วยข้อมูลชุดหนึ่งแล้วส่งข้อมูลอีกชุด นี่คือเหตุผลที่หน้าเว็บต้องแช่คำตอบไว้แล้วส่งชุดเดิม ไม่ใช่อ่านค่าจากฟอร์มใหม่

ข้อสังเกตด้าน security ที่น่าสนใจ​

  • รหัส OTP ไม่เคยถูกเก็บเป็น plaintext — ช่องทางอีเมลเก็บเป็น bcrypt hash ส่วนช่องทาง SMS ให้ผู้ให้บริการเป็นผู้สร้างและยืนยันรหัส ระบบเก็บเพียง token สำหรับยืนยันภายหลัง
  • เบอร์และอีเมลเต็มไม่เคยถูกเก็บลง session เลย เก็บเฉพาะค่าที่ปิดบังแล้ว — ถ้า Redis รั่ว ก็ไม่ได้ข้อมูลติดต่อของสมาชิกออกไป
  • secret ของผู้ให้บริการ SMS ถูกเก็บในฐานข้อมูลแบบเข้ารหัส (AES-256-GCM) และถอดรหัสด้วยกุญแจที่มาจาก config เท่านั้น — กุญแจนี้ต้องตรงกับที่ฝั่ง CMS ใช้เข้ารหัสไว้ ถ้าตั้งไม่ตรงกันจะถอดรหัสไม่ได้และส่ง SMS ไม่ออก
  • การ throttle ทำที่ระดับ session ไม่ใช่ระดับ IP — จำนวนการขอรหัสใหม่ 3 ครั้งและ cooldown 60 วินาทีผูกกับรหัสอ้างอิง ดังนั้นการเริ่มรอบใหม่ (ขอรหัสครั้งแรกอีกครั้ง) จะได้โควตาใหม่ ยังมี rate limit ระดับแอปตาม IP คุมอยู่อีกชั้น

Edge case ที่ควรรู้​

  • session มีอายุเพียง 3 นาที — สั้นกว่าที่ผู้ใช้หลายคนคาด ถ้าผู้ใช้สลับไปเปิดแอปอื่นเพื่อดูรหัสแล้วกลับมาช้า จะเจอ OTP_EXPIRED ได้จริง
  • ถ้าไม่มี Redis ฟีเจอร์ OTP ใช้งานไม่ได้เลย ทุกเมธอดจะคืน error (ไม่ crash) เพราะ session store อยู่บน Redis ทั้งหมด
  • error ของ OTP ทั้งหมดมาเป็น object ดิบที่มี key code ไม่ใช่ envelope มาตรฐาน — ตรงกับที่โมดูลแปลข้อผิดพลาดฝั่งเว็บออกแบบไว้ และเป็นเหตุผลที่ต้องรองรับกรณีที่รหัสซ้อนอยู่ใน message
  • ข้อความ error ทั้งหมดมาจาก backend หน้าเว็บควรแสดงตามที่ได้รับ ยกเว้นรหัสที่ต้องแปลเป็นข้อความที่แอดมินตั้งค่าไว้เอง