Skip to main content

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

ภาพรวม

ขั้นตอนยืนยันรหัส 6 หลักทาง SMS หรืออีเมล ก่อนที่ฟอร์มจะยอมรับคำตอบ ใช้กับฟอร์มที่เปิด profile mapping ไว้

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

Session ถูกเก็บใน Redis อายุ 3 นาที รหัสไม่เคยถูกเก็บเป็น plaintext และเบอร์โทร/อีเมลฉบับเต็มไม่เคยถูกเก็บลง session (เก็บเฉพาะค่าที่ mask แล้ว)

Business Flow

ขอรหัส OTP — POST /api/form-builder/:hash/otp/request

Endpoint เดียวกันนี้ทำหน้าที่ "ขอรหัสใหม่" ด้วย เมื่อส่งค่า ref มาพร้อมกัน

  1. โหลดฟอร์ม ตรวจสอบ x-liff-token และเงื่อนไข requireLineLogin
  2. อ่านค่า profile_mapping.otp — หากปิดอยู่หรือไม่มี fields จะตอบ 400 พร้อมข้อความ OTP is not enabled for this form
  3. จับคู่สมาชิกใหม่ฝั่ง server ทุกครั้ง จาก answers ใน body โดยไม่เชื่อข้อมูลจาก client — หากจับคู่ไม่ได้จะตอบ PROFILE_NOT_FOUND และ ไม่ส่ง OTP ออกไปเลย
  4. เลือกช่องทางส่ง ตามลำดับ: ค่า body.channel → ช่องทางของ session เดิม (กรณีขอรหัสใหม่) → หากมี field เพียงช่องทางเดียวก็ใช้ช่องทางนั้น มิฉะนั้นตอบ 400 Please choose a verification channel. ช่องทางที่ไม่ได้ตั้งค่าไว้ในฟอร์มจะถูกปฏิเสธด้วย 400 และการขอรหัสใหม่ด้วย ref ที่ผูกกับฟอร์มอื่นจะได้ OTP_EXPIRED
  5. หาปลายทางจากคอลัมน์ที่กำหนดใน customer_database_row.data ของแถวที่จับคู่ได้ — หากว่างจะตอบ 400 รหัส OTP_NO_CONTACT
  6. โหลด otp_config ของ OA แล้ว ถอดรหัส thaibulksms_secret_enc ด้วย AES-256-GCM (key คือ SHA-256(APP_ENCRYPT_SECRET) รูปแบบ base64(nonce + ciphertext) ตรงกับที่ cms-api เขียนไว้)
  7. ตรวจว่าช่องทางถูกตั้งค่าครบถ้วน — SMS ต้องเปิดใช้งานและมี key/secret ส่วน Email ต้องเปิดใช้งานและมี subject/body โดย body ต้องมี placeholder {otp} หากไม่ครบจะตอบ 400 รหัส OTP_NOT_CONFIGURED
  8. ด่านกันขอรหัสใหม่ถี่เกินไป — ห่างจากครั้งก่อนน้อยกว่า 60 วินาที ตอบ 429 รหัส OTP_RESEND_COOLDOWN และเมื่อส่งครบ 3 ครั้งแล้วตอบ 429 รหัส OTP_RESEND_LIMIT
  9. ส่งรหัสตามช่องทางที่เลือก
    • SMS — แปลงเบอร์เป็นรูปแบบ MSISDN แล้วให้ ThaiBulkSMS เป็นผู้สร้างรหัส ระบบเก็บเพียง tbsToken ไว้ยืนยันภายหลัง
    • Email — สร้างรหัส 6 หลักเอง render template โดยแทนที่ {otp} และ {ref} ส่งผ่าน SMTP ของ OA แล้วเก็บ bcrypt hash ของรหัสลง session
  10. เขียน session ลง Redis ที่คีย์ otp:{ref} โดย ref เป็นค่าสุ่มขนาด 128 บิตในรูป hex อายุ 3 นาที การขอรหัสใหม่จะ รีเซ็ต TTL เพราะเป็นรหัสชุดใหม่
  11. ตอบกลับ {ref, channel, destinationMasked, expiresIn} ด้วยสถานะ 201

ยืนยันรหัส — POST /api/form-builder/:hash/otp/verify

  1. โหลดฟอร์ม ตรวจสอบ token และ requireLineLogin — หาก ref หรือ pin ว่างจะตอบ 400
  2. โหลด session — ไม่พบหรือหมดอายุ ตอบรหัส OTP_EXPIRED หากยืนยันไปแล้วจะถือว่าผ่าน
  3. หากพยายามครบ 5 ครั้งแล้ว จะลบ session และตอบรหัส OTP_MAX_ATTEMPTS
  4. ตรวจรหัส — SMS เรียก ThaiBulkSMS verify ด้วย tbsToken ส่วน Email เทียบด้วย bcrypt โดย ความล้มเหลวของ transport ไม่นับเป็นการกรอกผิด
  5. เมื่อถูกต้อง ตั้ง verified = true แล้วอัปเดต session โดยไม่รีเซ็ต TTL เพื่อกันการต่ออายุหน้าต่างยืนยันด้วยการเดารหัส
  6. เมื่อผิด เพิ่มจำนวนครั้งที่พยายาม หากครบ 5 ครั้งจะลบ session และตอบ OTP_MAX_ATTEMPTS มิฉะนั้นตอบ 400 รหัส OTP_INVALID_PIN พร้อมข้อความแจ้งจำนวนครั้งที่เหลือ
  7. session ที่ formId ไม่ตรงกับฟอร์มปัจจุบันจะถูกปฏิเสธด้วย OTP_EXPIRED เป็นการป้องกันซ้อนอีกชั้น
  8. สำเร็จตอบ {verified: true} ด้วยสถานะ 200

ด่านตอน Submit (enforceOTP)

ฟอร์มที่เปิด OTP ต้องผ่านเงื่อนไขทั้งหมดต่อไปนี้ มิฉะนั้นจะตอบ 400 รหัส OTP_REQUIRED

  • มี otpRef มาใน body
  • session ยังคงอยู่ และมี verified = true
  • session.formId ตรงกับ form.id
  • session.matchedRowId เท่ากับแถวที่จับคู่ใหม่ในขั้นตอน submit

ไฟล์และฟังก์ชันหลัก

RouteHandler
POST /api/form-builder/:hash/otp/requestinternal/formsubmission/otp.go(*Handler).OTPRequest(*Service).OTPRequest
POST /api/form-builder/:hash/otp/verify(*Handler).OTPVerify(*Service).OTPVerify
  • internal/formsubmission/otp.goenforceOTP, decodeOTPMapping, resolveOTPField, mapOTPError, transformAnswers, handleNotFoundErr
  • internal/otp/session.goSession, SessionStore (Create/Get/Update/Delete), GenerateRef พร้อมค่าคงที่ SessionTTL=3m, MaxAttempts=5, MaxSends=3, ResendCooldown=60s และ prefix otp:
  • internal/otp/service.goNewService, LoadOAConfig, ResolveContact, Send, dispatch, Verify, checkPin, assertChannelConfigured, decryptSecret, renderEmail
  • internal/otp/mask.goMaskPhone, MaskEmail, NormalizeMSISDN
  • internal/otp/code/code.goGenerate6, Hash, Equal
  • internal/otp/thaibulksms/thaibulksms.goRequest, Verify, BaseURL
  • internal/otp/email/email.goSend, 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
  • ConfigAPP_ENCRYPT_SECRET ต้องตรงกับค่าที่ cms-api ใช้เข้ารหัส secret รวมถึง OTP_THAIBULKSMS_URL และชุดตัวแปร SMTP (OTP_SMTP_*)
  • ThaiBulkSMS — ผู้ให้บริการส่ง SMS และเป็นผู้สร้าง/ยืนยันรหัสในช่องทาง SMS
  • ฟีเจอร์ที่เกี่ยวข้อง — ต่อจาก profile mapping ของฟอร์ม และเป็นด่านที่ 7 ของขั้นตอน form submission
  • client-web — ตรงกับฟีเจอร์ form-otp-verification