false, 'ERROR' => 'missing_identifier', 'MESSAGE' => 'Email or phone is required']); } $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 { if (!empty($email)) { $user = queryOne( "SELECT ID, FirstName, ContactNumber FROM Users WHERE EmailAddress = ? AND IsActive = 1 LIMIT 1", [$email] ); } else { $user = queryOne( "SELECT ID, FirstName, ContactNumber FROM Users WHERE ContactNumber = ? AND IsActive = 1 LIMIT 1", [$phone] ); } // Always return OK to not reveal if account exists if (!$user) { jsonResponse($genericResponse); } $uid = (int) $user['ID']; // Rate limit: max 3 codes per user in last 10 minutes $rateCheck = queryOne( "SELECT COUNT(*) AS cnt FROM OTPCodes WHERE UserID = ? AND CreatedAt > DATE_SUB(NOW(), INTERVAL 10 MINUTE)", [$uid] ); if (((int) ($rateCheck['cnt'] ?? 0)) >= 3) { jsonResponse($genericResponse); } $code = random_int(100000, 999999); $dev = isDev(); // Store with 10-minute expiry queryTimed( "INSERT INTO OTPCodes (UserID, Code, ExpiresAt) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE))", [$uid, $code] ); // Send OTP via SMS or email (skip on dev) if (!$dev) { if (!empty($phone) && !empty($user['ContactNumber'])) { sendSMS("+1" . $user['ContactNumber'], "Your Payfrit login code is: {$code}. It expires in 10 minutes."); } else { // TODO: Email sending } } $resp = $genericResponse; if ($dev) { $resp['DEV_OTP'] = $code; } jsonResponse($resp); } catch (\Exception $e) { // Swallow errors — always return generic OK error_log("sendLoginOTP error for {$email}: " . $e->getMessage()); jsonResponse($genericResponse); }