191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
import { authenticator } from "otplib";
|
|
import { createHmac } from "crypto";
|
|
import base32 from "thirty-two";
|
|
|
|
const submissionTimestamps = new Map<string, number[]>();
|
|
const otpRequestTimestamps = new Map<string, number[]>();
|
|
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 = 300;
|
|
const VALID_PAST_OTP_STEPS: number = 1;
|
|
const VALID_FUTURE_OTP_STEPS: number = 0;
|
|
const OTP_NUM_DIGITS: number = 6;
|
|
const MAX_GLOBAL_OTP_SENDS_PER_HOUR: number = 10;
|
|
let globalOtpSendTimestamps: number[] = [];
|
|
|
|
export function isRateLimitedGlobally(): boolean {
|
|
const now = Date.now();
|
|
globalOtpSendTimestamps = globalOtpSendTimestamps.filter(
|
|
(t) => now - t < ONE_HOUR_IN_MS,
|
|
);
|
|
return globalOtpSendTimestamps.length >= MAX_GLOBAL_OTP_SENDS_PER_HOUR;
|
|
}
|
|
|
|
export function recordGlobalOtpSend() {
|
|
globalOtpSendTimestamps.push(Date.now());
|
|
}
|
|
|
|
function cleanupStaleEntries(map: Map<string, number[]>, windowMs: number) {
|
|
const now = Date.now();
|
|
for (const [key, timestamps] of map) {
|
|
const recent = timestamps.filter((t) => now - t < windowMs);
|
|
if (recent.length === 0) map.delete(key);
|
|
else if (recent.length !== timestamps.length) map.set(key, recent);
|
|
}
|
|
}
|
|
|
|
setInterval(() => {
|
|
cleanupStaleEntries(submissionTimestamps, ONE_WEEK_IN_MS);
|
|
cleanupStaleEntries(otpRequestTimestamps, ONE_HOUR_IN_MS);
|
|
}, ONE_HOUR_IN_MS).unref?.();
|
|
|
|
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.",
|
|
);
|
|
}
|
|
|
|
const digest = createHmac("sha256", salt).update(phoneNumber).digest("hex");
|
|
return base32.encode(digest).toString().replace(/=/g, "");
|
|
}
|
|
|
|
export function normalizePhone(phone: string) {
|
|
let digits = phone.replace(/\D/g, "");
|
|
if (digits.length === 11 && digits.startsWith("1")) {
|
|
digits = digits.slice(1);
|
|
}
|
|
if (digits.length !== 10) {
|
|
throw new Error("Invalid phone number.");
|
|
}
|
|
return digits;
|
|
}
|
|
|
|
export function isValidPhone(phone: string): boolean {
|
|
phone = normalizePhone(phone);
|
|
const match = phone.match(/(\d{3})(\d{3})(\d{4})/);
|
|
const [, prefix, exchange, station] = match ?? [];
|
|
const isValidNANPFormat =
|
|
/^[2-9][0-9]{2}$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange);
|
|
const isNotAllSameDigit = !/^(.)\1{6}$/.test(exchange + station);
|
|
const isNot911Number = prefix !== "911" && exchange !== "911";
|
|
const isNotTollFreeNumber = !(
|
|
/^[8-9][0-9]{2}$/.test(prefix) &&
|
|
/^(99|88|77|66|55|44|33|22|11|00)$/.test(prefix.slice(1, 3))
|
|
);
|
|
const isNot555Number = prefix !== "555" && exchange !== "555";
|
|
const isNotPopSongNumber = exchange !== "867" && station !== "5309";
|
|
|
|
return (
|
|
isValidNANPFormat &&
|
|
isNotAllSameDigit &&
|
|
isNot911Number &&
|
|
isNot555Number &&
|
|
isNotTollFreeNumber &&
|
|
isNotPopSongNumber
|
|
);
|
|
}
|
|
|
|
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 {
|
|
normalizePhone,
|
|
isValidPhone,
|
|
generateOtp,
|
|
verifyOtp,
|
|
getOtpStep,
|
|
recordOtpRequest,
|
|
recordMsgSubmission,
|
|
isRateLimitedForOtp,
|
|
isRateLimitedForMsgs,
|
|
isRateLimitedGlobally,
|
|
recordGlobalOtpSend,
|
|
};
|