/* Send OTP Code for Portal Login POST: { "Email": "user@example.com" } Returns: { OK: true } always (don't reveal if email 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 {}; } data = readJsonBody(); email = trim(data.Email ?: ""); if (!len(email)) { apiAbort({ "OK": false, "ERROR": "missing_email", "MESSAGE": "Email is required" }); } 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" }); // 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 email (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"); 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)); }