feat: add contact form with SMS OTP verification

This commit is contained in:
badblocks 2025-07-17 19:34:29 -07:00
parent 91b162fb44
commit 3874443c34
No known key found for this signature in database
14 changed files with 729 additions and 54 deletions

View file

@ -0,0 +1,36 @@
/**
* Normalizes and validates a phone number string based on specific rules.
*
* The normalization process is as follows:
* 1. All non-digit characters are stripped from the input string.
* 2. If the resulting number has a leading '1', it is removed.
*
* After normalization, the function validates that the resulting number
* is exactly 10 digits long.
*
* @param {string} rawPhoneNumber The raw phone number string provided by the user.
* @returns {string} The normalized, 10-digit phone number.
* @throws {Error} Throws an error if the phone number is invalid after normalization,
* allowing API endpoints to catch it and return a proper HTTP status.
*/
export function normalizeAndValidatePhoneNumber(rawPhoneNumber) {
if (!rawPhoneNumber || typeof rawPhoneNumber !== "string") {
throw new Error("Phone number is required.");
}
// 1. Strip all non-digit characters.
const digitsOnly = rawPhoneNumber.replace(/\D/g, "");
// 2. If the number starts with a '1', remove it.
let numberToValidate = digitsOnly;
if (numberToValidate.startsWith("1")) {
numberToValidate = numberToValidate.substring(1);
}
// 3. Check if the resulting number is exactly 10 digits long.
if (numberToValidate.length !== 10) {
throw new Error("Please provide a valid 10-digit phone number.");
}
return numberToValidate;
}

View file

@ -0,0 +1,33 @@
// A shared, in-memory store for tracking submission timestamps.
const submissionTimestamps = new Map();
// The rate-limiting period (1 week in milliseconds).
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Checks if a given phone number is currently rate-limited.
* A phone number is considered rate-limited if a successful submission
* was recorded within the last week.
*
* @param {string} phoneNumber The phone number to check.
* @returns {boolean} True if the number is rate-limited, false otherwise.
*/
export function isRateLimited(phoneNumber) {
const lastSubmissionTime = submissionTimestamps.get(phoneNumber);
if (!lastSubmissionTime) {
return false; // Not in the map, so not rate-limited.
}
// Check if the time elapsed since the last submission is less than one week.
return Date.now() - lastSubmissionTime < ONE_WEEK_IN_MS;
}
/**
* Records a successful submission for a given phone number by setting
* the current timestamp. This will start the 1-week rate-limiting period.
*
* @param {string} phoneNumber The phone number to record the submission for.
*/
export function recordSubmission(phoneNumber) {
submissionTimestamps.set(phoneNumber, Date.now());
}

60
server/utils/totp.js Normal file
View file

@ -0,0 +1,60 @@
import { authenticator } from "otplib";
import { createHash } from "crypto";
// These settings must be consistent between generation and verification.
authenticator.options = {
step: 60, // OTP is valid for 1 minute per window
window: [5, 1], // Allow tokens from 5 previous and 1 future time-steps.
digits: 6,
};
/**
* Derives a stable, stateless secret for a user from their phone number
* and a global salt.
* @param {string} phoneNumber The user's phone number.
* @param {string} salt The global super secret salt.
* @returns {string} A hex-encoded secret string.
*/
function getUserSecret(phoneNumber, salt) {
if (!phoneNumber || !salt) {
throw new Error(
"Phone number and salt are required to generate a user secret.",
);
}
return createHash("sha256")
.update(phoneNumber + salt)
.digest("hex");
}
/**
* Generates a Time-based One-Time Password (TOTP) for a given phone number.
* @param {string} phoneNumber The user's phone number.
* @param {string} salt The global super secret salt.
* @returns {string} The generated 6-digit OTP.
*/
export function generateTOTP(phoneNumber, salt) {
const userSecret = getUserSecret(phoneNumber, salt);
return authenticator.generate(userSecret);
}
/**
* Verifies a TOTP token submitted by a user.
* @param {string} phoneNumber The user's phone number.
* @param {string} salt The global super secret salt.
* @param {string} token The 6-digit OTP token submitted by the user.
* @returns {boolean} True if the token is valid, false otherwise.
*/
export function verifyTOTP(phoneNumber, salt, token) {
const userSecret = getUserSecret(phoneNumber, salt);
// The `verify` method checks the token against the current and adjacent
// time-steps, as configured in the options.
return authenticator.verify({ token, secret: userSecret });
}
/**
* Get the current TOTP step in seconds.
* @returns {number} The current TOTP step in seconds.
*/
export function getTOTPstep() {
return authenticator.options.step;
}