diff --git a/api/auth/sendLoginOTP.cfm b/api/auth/sendLoginOTP.cfm index ae2595a..bcb3fc3 100644 --- a/api/auth/sendLoginOTP.cfm +++ b/api/auth/sendLoginOTP.cfm @@ -6,8 +6,8 @@ /* Send OTP Code for Portal Login - POST: { "Email": "user@example.com" } - Returns: { OK: true } always (don't reveal if email exists) + 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) { @@ -26,26 +26,67 @@ function readJsonBody() { return {}; } -data = readJsonBody(); -email = trim(data.Email ?: ""); +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; +} -if (!len(email)) { - apiAbort({ "OK": false, "ERROR": "missing_email", "MESSAGE": "Email is required" }); +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 - qUser = queryTimed(" - SELECT ID, FirstName - FROM Users - WHERE EmailAddress = :email - AND IsActive = 1 - LIMIT 1 - ", { - email: { value: email, cfsqltype: "cf_sql_varchar" } - }, { datasource: "payfrit" }); + // 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) { @@ -89,29 +130,42 @@ try { code: { value: code, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" }); - // Send email (skip on dev if magic OTP is enabled) + // Send OTP via email or SMS (skip on dev if magic OTP is enabled) if (!isDev) { - try { - mailService = new mail(); - mailService.setTo(email); - mailService.setFrom("admin@payfrit.com"); - mailService.setSubject("Your Payfrit login code"); - mailService.setType("html"); + 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(to=qUser.ContactNumber, message=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# + 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.

-

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#"); + mailService.send(body=emailBody); + } catch (any mailErr) { + writeLog(file="otp_errors", text="Email send failed for user #userId#: #mailErr.message#"); + } } } diff --git a/api/auth/verifyEmailOTP.cfm b/api/auth/verifyEmailOTP.cfm index f7ac373..18f7aab 100644 --- a/api/auth/verifyEmailOTP.cfm +++ b/api/auth/verifyEmailOTP.cfm @@ -5,8 +5,10 @@ /* - Verify Email OTP Code for Portal Login + Verify OTP Code for Portal Login (supports email or phone) POST: { "Email": "user@example.com", "Code": "123456" } + or { "Phone": "3105551234", "Code": "123456" } + or { "Identifier": "email or phone", "Code": "123456" } Returns: { OK: true, UserID, FirstName, Token } or { OK: false, ERROR: "invalid_code" } */ @@ -26,25 +28,61 @@ function readJsonBody() { return {}; } +function normalizePhone(phone) { + var digits = reReplace(phone, "[^0-9]", "", "all"); + if (len(digits) == 11 && left(digits, 1) == "1") { + digits = right(digits, 10); + } + return digits; +} + +function isPhoneNumber(input) { + var digits = reReplace(input, "[^0-9]", "", "all"); + return len(digits) >= 10 && len(digits) <= 11; +} + data = readJsonBody(); -email = trim(data.Email ?: ""); +identifier = trim(data.Identifier ?: data.Email ?: data.Phone ?: ""); code = trim(data.Code ?: ""); -if (!len(email) || !len(code)) { - apiAbort({ "OK": false, "ERROR": "missing_fields", "MESSAGE": "Email and code are required" }); +if (!len(identifier) || !len(code)) { + apiAbort({ "OK": false, "ERROR": "missing_fields", "MESSAGE": "Email/phone and code are required" }); +} + +// Determine if this is email or phone +isPhone = isPhoneNumber(identifier); +email = ""; +phone = ""; + +if (isPhone) { + phone = normalizePhone(identifier); +} else { + email = identifier; } try { - // Look up user by email - qUser = queryTimed(" - SELECT ID, FirstName - FROM Users - WHERE EmailAddress = :email - AND IsActive = 1 - LIMIT 1 - ", { - email: { value: email, cfsqltype: "cf_sql_varchar" } - }, { datasource: "payfrit" }); + // Look up user by email or phone + if (len(email)) { + qUser = queryTimed(" + SELECT ID, FirstName + 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 + FROM Users + WHERE ContactNumber = :phone + AND IsActive = 1 + LIMIT 1 + ", { + phone: { value: phone, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + } if (qUser.recordCount == 0) { apiAbort({ "OK": false, "ERROR": "invalid_code", "MESSAGE": "Invalid or expired code" });