personal-site/src/lib/Otp.ts
badbl0cks 72e57fb7ff
Break out contact form into separate component and implement OTP and rate
limiting, add iconify-icon dependency

Add src/components/ContactForm.astro implementing OTP verify/send flows and
server-side validation Add src/lib/Otp.ts (otplib-backed) with per-hour OTP and
per-week message rate limits and helper functions Expose OTP_SUPER_SECRET_SALT
in astro.config and add otplib Rename src/lib/cap.ts to src/lib/CapAdapter.ts
and update imports Replace inline contact page logic with ContactForm and adjust
SMS client Add iconify-icon dependency
2026-01-09 10:25:15 -08:00

152 lines
4.4 KiB
TypeScript

import { authenticator } from "otplib";
import { createHash } from "crypto";
const submissionTimestamps = new Map();
const otpRequestTimestamps = new Map();
const ONE_WEEK_IN_MS: number = 7 * 24 * 60 * 60 * 1000;
const ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
const MAX_OTP_REQUESTS_PER_HOUR: number = 3;
const MAX_MESSAGES_PER_WEEK: number = 3;
const OTP_STEP_IN_SEC: number = 60;
const VALID_PAST_OTP_STEPS: number = 5;
const VALID_FUTURE_OTP_STEPS: number = 1;
const OTP_NUM_DIGITS: number = 6;
authenticator.options = {
step: OTP_STEP_IN_SEC,
window: [VALID_PAST_OTP_STEPS, VALID_FUTURE_OTP_STEPS],
digits: OTP_NUM_DIGITS,
};
function getUserSecret(phoneNumber: string, salt: string): string {
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");
}
export function validatePhoneNumber(unsafePhoneNum: string) {
if (typeof unsafePhoneNum !== "string") {
return { success: false, message: "Invalid phone number." };
}
unsafePhoneNum = unsafePhoneNum.replace(/[^0-9]/g, "").trim();
const cleanedNumber = unsafePhoneNum.startsWith("1")
? unsafePhoneNum.substring(1)
: unsafePhoneNum;
const isValidFormat = /^[2-7][0-8][0-9][2-9][0-9]{6}$/.test(cleanedNumber);
const isNotAllSameDigit = !/^(.)\1{9}$/.test(cleanedNumber);
const isNot911Number = !/^[0-9]{3}911[0-9]{4}$/.test(cleanedNumber);
const isNot555Number = !/^[0-9]{3}555[0-9]{4}$/.test(cleanedNumber);
const isNotPopSongNumber = !/^[0-9]{3}8675309$/.test(cleanedNumber);
if (
isValidFormat &&
isNotAllSameDigit &&
isNot911Number &&
isNot555Number &&
isNotPopSongNumber
) {
return { success: true, validatedPhoneNumber: cleanedNumber };
}
return { success: false, validatedPhoneNumber: undefined };
}
export function generateOtp(phoneNumber: string, salt: string): string {
const userSecret = getUserSecret(phoneNumber, salt);
return authenticator.generate(userSecret);
}
export function verifyOtp(
phoneNumber: string,
salt: string,
token: string,
): boolean {
const userSecret = getUserSecret(phoneNumber, salt);
return authenticator.verify({ token, secret: userSecret });
}
export function getOtpStep(): number {
const step = authenticator.options.step;
if (typeof step !== "number") {
return 0;
}
return step;
}
export function isRateLimitedForMsgs(phoneNumber: string): boolean {
const submissionTimestampsArray = submissionTimestamps.get(phoneNumber);
if (!submissionTimestampsArray || submissionTimestampsArray.length === 0) {
return false;
}
const now = Date.now();
const recentSubmissions = submissionTimestampsArray.filter(
(timestamp: number) => now - timestamp < ONE_WEEK_IN_MS,
);
if (recentSubmissions.length !== submissionTimestampsArray.length) {
submissionTimestamps.set(phoneNumber, recentSubmissions);
}
return recentSubmissions.length >= MAX_MESSAGES_PER_WEEK;
}
export function recordMsgSubmission(phoneNumber: string) {
const now = Date.now();
const existingSubmissions = submissionTimestamps.get(phoneNumber) || [];
const recentSubmissions = existingSubmissions.filter(
(timestamp: number) => now - timestamp < ONE_WEEK_IN_MS,
);
recentSubmissions.push(now);
submissionTimestamps.set(phoneNumber, recentSubmissions);
}
export function isRateLimitedForOtp(phoneNumber: string): boolean {
const requestTimestamps = otpRequestTimestamps.get(phoneNumber);
if (!requestTimestamps || requestTimestamps.length === 0) {
return false;
}
const now = Date.now();
const recentRequests = requestTimestamps.filter(
(timestamp: number) => now - timestamp < ONE_HOUR_IN_MS,
);
if (recentRequests.length !== requestTimestamps.length) {
otpRequestTimestamps.set(phoneNumber, recentRequests);
}
return recentRequests.length >= MAX_OTP_REQUESTS_PER_HOUR;
}
export function recordOtpRequest(phoneNumber: string) {
const now = Date.now();
const existingRequests = otpRequestTimestamps.get(phoneNumber) || [];
const recentRequests = existingRequests.filter(
(timestamp: number) => now - timestamp < ONE_HOUR_IN_MS,
);
recentRequests.push(now);
otpRequestTimestamps.set(phoneNumber, recentRequests);
}
export default {
validatePhoneNumber,
generateOtp,
verifyOtp,
getOtpStep,
recordOtpRequest,
recordMsgSubmission,
isRateLimitedForOtp,
isRateLimitedForMsgs,
};