ยืนยันตัวตนด้วย OTP ในฟอร์ม
ภาพรวม
ขั้นตอนยืนยันรหัส 6 หลักทาง SMS หรืออีเมล ก่อนที่ฟอร์มจะยอมรับคำตอบ ใช้กับฟอร์มที่เปิด profile mapping ไว้
หลักการสำคัญคือ เมื่อคำตอบของผู้กรอกตรงกับแถวในฐานข้อมูลลูกค้าแล้ว ระบบจะส่งรหัสไปยัง ช่องทางที่บันทึกอยู่ในแถวนั้น ไม่ใช่เบอร์โทรหรืออีเมลที่ผู้กรอกพิมพ์เข้ามา จึงเป็นการพิสูจน์ว่าผู้กรอกคือเจ้าของ record ตัวจริง
Session ถูกเก็บใน Redis อายุ 3 นาที รหัสไม่เคยถูกเก็บเป็น plaintext และเบอร์โทร/อีเมลฉบับเต็มไม่เคยถูกเก็บลง session (เก็บเฉพาะค่าที่ mask แล้ว)
Business Flow
ขอรหัส OTP — POST /api/form-builder/:hash/otp/request
Endpoint เดียวกันนี้ทำหน้าที่ "ขอรหัสใหม่" ด้วย เมื่อส่งค่า ref มาพร้อมกัน
- โหลดฟอร์ม ตรวจสอบ
x-liff-tokenและเงื่อนไขrequireLineLogin - อ่านค่า
profile_mapping.otp— หากปิดอยู่หรือไม่มีfieldsจะตอบ 400 พร้อมข้อความOTP is not enabled for this form - จับคู่สมาชิกใหม่ฝั่ง server ทุกครั้ง จาก
answersใน body โดยไม่เชื่อข้อมูลจาก client — หากจับคู่ไม่ได้จะตอบPROFILE_NOT_FOUNDและ ไม่ส่ง OTP ออกไปเลย - เลือกช่องทางส่ง ตามลำดับ: ค่า
body.channel→ ช่องทางของ session เดิม (กรณีขอรหัสใหม่) → หากมี field เพียงช่องทางเดียวก็ใช้ช่องทางนั้น มิฉะนั้นตอบ 400Please choose a verification channel.ช่องทางที่ไม่ได้ตั้งค่าไว้ในฟอร์มจะถูกปฏิเสธด้วย 400 และการขอรหัสใหม่ด้วยrefที่ผูกกับฟอร์มอื่นจะได้OTP_EXPIRED - หาปลายทางจากคอลัมน์ที่กำหนดใน
customer_database_row.dataของแถวที่จับคู่ได้ — หากว่างจะตอบ 400 รหัสOTP_NO_CONTACT - โหลด
otp_configของ OA แล้ว ถอดรหัสthaibulksms_secret_encด้วย AES-256-GCM (key คือSHA-256(APP_ENCRYPT_SECRET)รูปแบบbase64(nonce + ciphertext)ตรงกับที่ cms-api เขียนไว้) - ตรวจว่าช่องทางถูกตั้งค่าครบถ้วน — SMS ต้องเปิดใช้งานและมี key/secret ส่วน Email ต้องเปิดใช้งานและมี subject/body โดย body ต้องมี placeholder
{otp}หากไม่ครบจะตอบ 400 รหัสOTP_NOT_CONFIGURED - ด่านกันขอรหัสใหม่ถี่เกินไป — ห่างจากครั้งก่อนน้อยกว่า 60 วินาที ตอบ 429 รหัส
OTP_RESEND_COOLDOWNและเมื่อส่งครบ 3 ครั้งแล้วตอบ 429 รหัสOTP_RESEND_LIMIT - ส่งรหัสตามช่องทางที่เลือก
- SMS — แปลงเบอร์เป็นรูปแบบ MSISDN แล้วให้ ThaiBulkSMS เป็นผู้สร้างรหัส ระบบเก็บเพียง
tbsTokenไว้ยืนยันภายหลัง - Email — สร้างรหัส 6 หลักเอง render template โดยแทนที่
{otp}และ{ref}ส่งผ่าน SMTP ของ OA แล้วเก็บ bcrypt hash ของรหัสลง session
- SMS — แปลงเบอร์เป็นรูปแบบ MSISDN แล้วให้ ThaiBulkSMS เป็นผู้สร้างรหัส ระบบเก็บเพียง
- เขียน session ลง Redis ที่คีย์
otp:{ref}โดยrefเป็นค่าสุ่มขนาด 128 บิตในรูป hex อายุ 3 นาที การขอรหัสใหม่จะ รีเซ็ต TTL เพราะเป็นรหัสชุดใหม่ - ตอบกลับ
{ref, channel, destinationMasked, expiresIn}ด้วยสถานะ 201
ยืนยันรหัส — POST /api/form-builder/:hash/otp/verify
- โหลดฟอร์ม ตรวจสอบ token และ
requireLineLogin— หากrefหรือpinว่างจะตอบ 400 - โหลด session — ไม่พบหรือหมดอายุ ตอบรหัส
OTP_EXPIREDหากยืนยันไปแล้วจะถือว่าผ่าน - หากพยายามครบ 5 ครั้งแล้ว จะลบ session และตอบรหัส
OTP_MAX_ATTEMPTS - ตรวจรหัส — SMS เรียก ThaiBulkSMS verify ด้วย
tbsTokenส่วน Email เทียบด้วย bcrypt โดย ความล้มเหลวของ transport ไม่นับเป็นการกรอกผิด - เมื่อถูกต้อง ตั้ง
verified = trueแล้วอัปเดต session โดยไม่รีเซ็ต TTL เพื่อกันการต่ออายุหน้าต่างยืนยันด้วยการเดารหัส - เมื่อผิด เพิ่มจำนวนครั้งที่พยายาม หากครบ 5 ครั้งจะลบ session และตอบ
OTP_MAX_ATTEMPTSมิฉะนั้นตอบ 400 รหัสOTP_INVALID_PINพร้อมข้อความแจ้งจำนวนครั้งที่เหลือ - session ที่
formIdไม่ตรงกับฟอร์มปัจจุบันจะถูกปฏิเสธด้วยOTP_EXPIREDเป็นการป้องกันซ้อนอีกชั้น - สำเร็จตอบ
{verified: true}ด้วยสถานะ 200
ด่านตอน Submit (enforceOTP)
ฟอร์มที่เปิด OTP ต้องผ่านเงื่อนไขทั้งหมดต่อไปนี้ มิฉะนั้นจะตอบ 400 รหัส OTP_REQUIRED
- มี
otpRefมาใน body - session ยังคงอยู่ และมี
verified = true session.formIdตรงกับform.idsession.matchedRowIdเท่ากับแถวที่จับคู่ใหม่ในขั้นตอน submit
ไฟล์และฟังก์ชันหลัก
| Route | Handler |
|---|---|
POST /api/form-builder/:hash/otp/request | internal/formsubmission/otp.go → (*Handler).OTPRequest → (*Service).OTPRequest |
POST /api/form-builder/:hash/otp/verify | (*Handler).OTPVerify → (*Service).OTPVerify |
internal/formsubmission/otp.go—enforceOTP,decodeOTPMapping,resolveOTPField,mapOTPError,transformAnswers,handleNotFoundErrinternal/otp/session.go—Session,SessionStore(Create/Get/Update/Delete),GenerateRefพร้อมค่าคงที่SessionTTL=3m,MaxAttempts=5,MaxSends=3,ResendCooldown=60sและ prefixotp:internal/otp/service.go—NewService,LoadOAConfig,ResolveContact,Send,dispatch,Verify,checkPin,assertChannelConfigured,decryptSecret,renderEmailinternal/otp/mask.go—MaskPhone,MaskEmail,NormalizeMSISDNinternal/otp/code/code.go—Generate6,Hash,Equalinternal/otp/thaibulksms/thaibulksms.go—Request,Verify,BaseURLinternal/otp/email/email.go—Send,SMTP
ชุด error code ที่ระบบใช้: OTP_RESEND_COOLDOWN, OTP_RESEND_LIMIT, OTP_EXPIRED, OTP_INVALID_PIN, OTP_MAX_ATTEMPTS, OTP_NOT_CONFIGURED, OTP_NO_CONTACT
จุดเชื่อมต่อกับ Service อื่น
- Redis — client ชื่อ
redisทำหน้าที่เป็น session store หากไม่มี Redis ทุกเมธอดจะคืน error ไม่ panic - ฐานข้อมูล — ตาราง
otp_config(thaibulksms_key,thaibulksms_secret_enc,sms_enabled,email_enabled,email_subject,email_body) และcustomer_database_row - Config —
APP_ENCRYPT_SECRETต้องตรงกับค่าที่ cms-api ใช้เข้ารหัส secret รวมถึงOTP_THAIBULKSMS_URLและชุดตัวแปร SMTP (OTP_SMTP_*) - ThaiBulkSMS — ผู้ให้บริการส่ง SMS และเป็นผู้สร้าง/ยืนยันรหัสในช่องทาง SMS
- ฟีเจอร์ที่เกี่ยวข้อง — ต่อจาก profile mapping ของฟอร์ม และเป็นด่านที่ 7 ของขั้นตอน form submission
- client-web — ตรงกับฟีเจอร์
form-otp-verification