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 หน้าเว็บควรแสดงตามที่ได้รับ ยกเว้นรหัสที่ต้องแปลเป็นข้อความที่แอดมินตั้งค่าไว้เอง