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
This commit is contained in:
parent
d5a7887ad2
commit
72e57fb7ff
10 changed files with 516 additions and 129 deletions
152
src/lib/Otp.ts
Normal file
152
src/lib/Otp.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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,
|
||||
};
|
||||
|
|
@ -2,7 +2,6 @@ import Client from "android-sms-gateway";
|
|||
import {
|
||||
ANDROID_SMS_GATEWAY_LOGIN,
|
||||
ANDROID_SMS_GATEWAY_PASSWORD,
|
||||
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
|
||||
ANDROID_SMS_GATEWAY_URL,
|
||||
} from "astro:env/server";
|
||||
import httpFetchClient from "@lib/HttpFetchClient";
|
||||
|
|
@ -19,9 +18,9 @@ class SmsClient {
|
|||
);
|
||||
}
|
||||
|
||||
async sendSMS(message: string) {
|
||||
async sendSMS(phoneNumber: string, message: string) {
|
||||
const bundle = {
|
||||
phoneNumbers: [ANDROID_SMS_GATEWAY_RECIPIENT_PHONE], // hard-coded on purpose ;)
|
||||
phoneNumbers: [phoneNumber],
|
||||
message: message,
|
||||
};
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue