ยืนยันตัวตนด้วย OTP ในฟอร์ม
ภาพรวม
ฟอร์มบางชุดจำเป็นต้องยืนยันว่าผู้กรอกเป็นเจ้าของหมายเลขโทรศัพท์หรืออีเมลที่ระบุจริง ตัวอย่างที่พบบ่อยคือฟอร์มที่ผูกกับฐานข้อมูลสมาชิก เมื่อแอดมินเปิดการตั้งค่า OTP ภายใต้ profile mapping จากฝั่ง CMS ฟอร์มฝั่งลูกค้าจะแทรกขั้นตอน "ยืนยันรหัส 6 หลัก" คั่นระหว่างการกดส่งกับการส่งคำตอบจริง
การป้องกันที่แท้จริงอยู่ฝั่งเซิร์ฟเวอร์ กล่าวคือเซิร์ฟเวอร์จะยอมรับ submission เฉพาะกรณีที่มีรหัสอ้างอิง OTP ที่ยืนยันสำเร็จแล้วแนบมาด้วยเท่านั้น
Business Flow
- ผู้ใช้กรอกฟอร์มครบถ้วนแล้วกดส่ง
- container ตรวจสอบว่าฟอร์มนี้ต้องยืนยัน OTP หรือไม่ หากต้องยืนยันและยังไม่มีรหัสอ้างอิง ระบบจะ แช่คำตอบไว้ในหน่วยความจำ แล้วสลับหน้าจอไปเป็นหน้ายืนยัน OTP การแช่คำตอบไว้มีเหตุผลสำคัญคือ ทำให้การจับคู่สมาชิกตอนขอ OTP และตอนส่งคำตอบจริงใช้ค่าชุดเดียวกันอย่างแม่นยำ
- หน้ายืนยัน OTP ตรวจสอบช่องทางที่แอดมินเปิดใช้ หากมีมากกว่าหนึ่งช่องทาง ระบบจะให้ผู้ใช้เลือกระหว่าง SMS และอีเมลก่อน
- เมื่อกดขอรหัส ระบบยิง
POST /form-builder/:hash/otp/requestพร้อมส่งรหัสฟอร์ม คำตอบที่แช่ไว้ และช่องทางที่เลือก แล้วได้รับรหัสอ้างอิง ช่องทางที่ใช้จริง ปลายทางแบบปิดบัง และระยะเวลาหมดอายุกลับมา - หน้าจอแสดงข้อความที่แอดมินตั้งค่าไว้ โดยแทนที่ตัวแปร
`{destination}`ด้วยปลายทางที่ถูกปิดบังบางส่วน เช่น08xxxxx051 - ผู้ใช้กรอกรหัส 6 หลัก แล้วระบบยิง
POST /form-builder/:hash/otp/verifyพร้อมรหัสอ้างอิงและรหัสที่กรอก - เมื่อยืนยันสำเร็จ container จะเก็บรหัสอ้างอิงไว้แล้วส่งคำตอบที่แช่ไว้ทันที โดยแนบรหัสอ้างอิงเข้าไปใน FormData ด้วย
- ปุ่มขอรหัสใหม่มีช่วงเวลารอคอย 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 ที่ใช้
| Method | Path | ใช้ทำอะไร |
|---|---|---|
| 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 เดียวกันนี้ใช้ทั้งการขอครั้งแรกและการขอรหัสใหม่ (แยกด้วยการส่งรหัสอ้างอิงมาด้วยหรือไม่) ลำดับการตรวจ:
- โหลดฟอร์ม → verify
x-liff-token→ เช็คการบังคับล็อกอิน - ฟอร์มที่ไม่ได้เปิด OTP → 400
OTP is not enabled for this form - จับคู่สมาชิกใหม่ฝั่ง server ทุกครั้งจากคำตอบใน body — ไม่เชื่อผลจาก client เลย ถ้าไม่ match จะได้
PROFILE_NOT_FOUNDและ ไม่มี OTP ถูกส่งออกไปแม้แต่ครั้งเดียว (นี่คือเหตุผลที่หน้าเว็บต้องแช่คำตอบไว้และส่งชุดเดียวกันทั้งตอนขอ OTP และตอน submit — ถ้าคำตอบเปลี่ยน การ match จะเปลี่ยนตาม) - เลือกช่องทางตามลำดับ: ค่าที่ส่งมาใน body → ช่องทางของ session เดิม (กรณีขอใหม่) → ถ้าฟอร์มเปิดไว้ช่องทางเดียวก็ใช้ตัวนั้น → ถ้ายังตัดสินไม่ได้ → 400
Please choose a verification channel.; ช่องทางที่ไม่ได้เปิดในฟอร์มก็ 400 เช่นกัน - อ่านปลายทางจากคอลัมน์ของแถวที่ match — ถ้าคอลัมน์นั้นว่าง → 400
{code: "OTP_NO_CONTACT"}(สมาชิกมีจริงแต่ไม่มีช่องทางติดต่อ ต้องให้แอดมินไปเติมข้อมูล) - โหลดการตั้งค่า OTP ของ OA แล้วตรวจว่าช่องทางถูกตั้งค่าครบ: SMS ต้องเปิดใช้และมี key/secret ครบ; อีเมลต้องเปิดใช้และมีหัวเรื่องกับเนื้อหา และเนื้อหาต้องมีตัวแปร
{otp}อยู่ด้วย — ไม่ครบจะได้ 400{code: "OTP_NOT_CONFIGURED"}ซึ่งเป็นปัญหาการตั้งค่า ไม่ใช่ความผิดผู้ใช้ - ตัวกันการขอซ้ำ (มีผลกับการขอรหัสใหม่เท่านั้น ไม่ใช่ครั้งแรก): ขอห่างจากครั้งก่อนไม่ถึง 60 วินาที → 429
{code: "OTP_RESEND_COOLDOWN"}; ขอครบ 3 ครั้งแล้ว → 429{code: "OTP_RESEND_LIMIT"}— สังเกตว่าเป็น 429 ไม่ใช่ 400 ต่างจาก error อื่นในกลุ่มนี้ - ส่งรหัสตามช่องทาง แล้วเขียน session ลง Redis พร้อมรหัสอ้างอิงที่สุ่มขึ้นใหม่
- คืนรหัสอ้างอิง ช่องทางที่ใช้จริง ปลายทางแบบปิดบัง และเวลาหมดอายุ ด้วย 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"} เหมือนกันหมด:
- มีรหัสอ้างอิงแนบมาใน body
- session นั้นยังมีอยู่ (ไม่หมดอายุ)
- session ยืนยันแล้วจริง
- session ผูกกับฟอร์มนี้
- แถวที่ 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 หน้าเว็บควรแสดงตามที่ได้รับ ยกเว้นรหัสที่ต้องแปลเป็นข้อความที่แอดมินตั้งค่าไว้เอง