/* Send OTP Code for Portal Login POST: { "Email": "user@example.com" } or { "Phone": "3105551234" } or { "Identifier": "email or phone" } Returns: { OK: true } always (don't reveal if account exists) */ function apiAbort(required struct payload) { writeOutput(serializeJSON(payload)); abort; } function readJsonBody() { var raw = getHttpRequestData().content; if (isNull(raw)) raw = ""; if (!len(trim(raw))) return {}; try { var data = deserializeJSON(raw); if (isStruct(data)) return data; } catch (any e) {} return {}; } function normalizePhone(phone) { // Strip all non-digits var digits = reReplace(phone, "[^0-9]", "", "all"); // Remove leading 1 if 11 digits (US country code) if (len(digits) == 11 && left(digits, 1) == "1") { digits = right(digits, 10); } return digits; } function isPhoneNumber(input) { // Check if input looks like a phone number (mostly digits, possibly formatted) var digits = reReplace(input, "[^0-9]", "", "all"); return len(digits) >= 10 && len(digits) <= 11; } data = readJsonBody(); // Accept Email, Phone, or generic Identifier field identifier = trim(data.Identifier ?: data.Email ?: data.Phone ?: ""); if (!len(identifier)) { apiAbort({ "OK": false, "ERROR": "missing_identifier", "MESSAGE": "Email or phone is required" }); } // Determine if this is email or phone isPhone = isPhoneNumber(identifier); email = ""; phone = ""; if (isPhone) { phone = normalizePhone(identifier); } else { email = identifier; } genericResponse = { "OK": true, "MESSAGE": "If an account exists, a code has been sent." }; try { // Look up user by email or phone if (len(email)) { qUser = queryTimed(" SELECT ID, FirstName, ContactNumber FROM Users WHERE EmailAddress = :email AND IsActive = 1 LIMIT 1 ", { email: { value: email, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" }); } else { qUser = queryTimed(" SELECT ID, FirstName, ContactNumber FROM Users WHERE ContactNumber = :phone AND IsActive = 1 LIMIT 1 ", { phone: { value: phone, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" }); } // Always return OK to not reveal if email exists if (qUser.recordCount == 0) { writeOutput(serializeJSON(genericResponse)); abort; } userId = qUser.ID; // Rate limit: max 3 codes per user in last 10 minutes qRateCheck = queryTimed(" SELECT COUNT(*) AS cnt FROM OTPCodes WHERE UserID = :userId AND CreatedAt > DATE_SUB(NOW(), INTERVAL 10 MINUTE) ", { userId: { value: userId, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (qRateCheck.cnt >= 3) { writeOutput(serializeJSON(genericResponse)); abort; } // Generate 6-digit code (or use magic code on dev) code = randRange(100000, 999999); isDev = findNoCase("dev.payfrit.com", cgi.SERVER_NAME) > 0 || findNoCase("localhost", cgi.SERVER_NAME) > 0; if (isDev && structKeyExists(application, "MAGIC_OTP_ENABLED") && application.MAGIC_OTP_ENABLED && structKeyExists(application, "MAGIC_OTP_CODE") && len(application.MAGIC_OTP_CODE)) { code = application.MAGIC_OTP_CODE; } // Store in DB with 10-minute expiry queryTimed(" INSERT INTO OTPCodes (UserID, Code, ExpiresAt) VALUES (:userId, :code, DATE_ADD(NOW(), INTERVAL 10 MINUTE)) ", { userId: { value: userId, cfsqltype: "cf_sql_integer" }, code: { value: code, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" }); // Send OTP via email or SMS (skip on dev if magic OTP is enabled) if (!isDev) { if (len(phone) && len(qUser.ContactNumber)) { // Send via SMS try { if (structKeyExists(application, "twilioObj")) { smsMessage = "Your Payfrit login code is: " & code & ". It expires in 10 minutes."; application.twilioObj.sendSMS(recipientNumber="+1" & qUser.ContactNumber, messageBody=smsMessage); } } catch (any smsErr) { writeLog(file="otp_errors", text="SMS send failed for user #userId#: #smsErr.message#"); } } else { // Send via email try { mailService = new mail(); mailService.setTo(email); mailService.setFrom("admin@payfrit.com"); mailService.setSubject("Your Payfrit login code"); mailService.setType("html"); emailBody = "

Your login code

Hi #encodeForHTML(qUser.FirstName)#, use this code to sign in to Payfrit:

#code#

This code expires in 10 minutes. If you didn't request this, you can safely ignore it.

"; mailService.send(body=emailBody); } catch (any mailErr) { writeLog(file="otp_errors", text="Email send failed for user #userId#: #mailErr.message#"); } } } // On dev, include the code in response for easy testing resp = duplicate(genericResponse); if (isDev) { resp["DEV_OTP"] = code; } writeOutput(serializeJSON(resp)); } catch (any e) { writeLog(file="otp_errors", text="sendLoginOTP error for #email#: #e.message#"); writeOutput(serializeJSON(genericResponse)); }