/* 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" } */ 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) { 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(); identifier = trim(data.Identifier ?: data.Email ?: data.Phone ?: ""); code = trim(data.Code ?: ""); 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 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" }); } userId = qUser.ID; // Check for valid OTP in OTPCodes table qOTP = queryTimed(" SELECT ID FROM OTPCodes WHERE UserID = :userId AND Code = :code AND ExpiresAt > NOW() AND UsedAt IS NULL ORDER BY CreatedAt DESC LIMIT 1 ", { userId: { value: userId, cfsqltype: "cf_sql_integer" }, code: { value: code, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" }); if (qOTP.recordCount == 0) { apiAbort({ "OK": false, "ERROR": "invalid_code", "MESSAGE": "Invalid or expired code" }); } // Mark OTP as used queryTimed(" UPDATE OTPCodes SET UsedAt = NOW() WHERE ID = :otpId ", { otpId: { value: qOTP.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); // Create auth token (same as login.cfm) token = replace(createUUID(), "-", "", "all"); queryTimed( "INSERT INTO UserTokens (UserID, Token) VALUES (?, ?)", [ { value: userId, cfsqltype: "cf_sql_integer" }, { value: token, cfsqltype: "cf_sql_varchar" } ], { datasource: "payfrit" } ); // Set session lock timeout="15" throwontimeout="yes" type="exclusive" scope="session" { session.UserID = userId; } request.UserID = userId; writeOutput(serializeJSON({ "OK": true, "ERROR": "", "UserID": userId, "FirstName": qUser.FirstName, "Token": token })); } catch (any e) { apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message }); }