From 3874443c34d8f3c6747f426029ec32261278f5c9 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:34:29 -0700 Subject: [PATCH] feat: add contact form with SMS OTP verification --- .env.example | 5 + app/components/ContactForm.vue | 262 ++++++++++++++++++++++++++++++++ app/pages/index.vue | 58 +------ bun.lock | 15 ++ nuxt.config.ts | 12 +- package.json | 3 +- server/api/send-message.post.js | 97 ++++++++++++ server/api/send-otp.post.js | 56 +++++++ server/api/verify-otp.post.js | 59 +++++++ server/lib/http-client.js | 45 ++++++ server/lib/sms-gateway.js | 42 +++++ server/utils/phone-validator.js | 36 +++++ server/utils/rate-limiter.js | 33 ++++ server/utils/totp.js | 60 ++++++++ 14 files changed, 729 insertions(+), 54 deletions(-) create mode 100644 .env.example create mode 100644 app/components/ContactForm.vue create mode 100644 server/api/send-message.post.js create mode 100644 server/api/send-otp.post.js create mode 100644 server/api/verify-otp.post.js create mode 100644 server/lib/http-client.js create mode 100644 server/lib/sms-gateway.js create mode 100644 server/utils/phone-validator.js create mode 100644 server/utils/rate-limiter.js create mode 100644 server/utils/totp.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fb346a0 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +NUXT_ANDROID_SMS_GATEWAY_LOGIN="" +NUXT_ANDROID_SMS_GATEWAY_PASSWORD="" +NUXT_ANDROID_SMS_GATEWAY_URL="" # including http(s):// +NUXT_MY_PHONE_NUMBER="" +NUXT_SUPER_SECRET_SALT="" #openssl rand -base64 60 diff --git a/app/components/ContactForm.vue b/app/components/ContactForm.vue new file mode 100644 index 0000000..8c344eb --- /dev/null +++ b/app/components/ContactForm.vue @@ -0,0 +1,262 @@ + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index f3cee6a..dd10256 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -121,52 +121,7 @@

Get In Touch

-
-
-
- -
-
-
-
- - -
-
-
-
- - -
-
-
-
- -
-
- -
-
-
+
@@ -181,13 +136,12 @@ onMounted(() => { const typedOptions = { strings: [ "an aspiring Software Engineer.", - "a Web Developer.", - "a Backend Magician.", + "a self-taught Web Developer with experience.", + "a Full-Stack Magician.", "a UI/UX Problem Solver.", - "an Accessibility Advocate.", + "a Web Accessibility Advocate.", "an Open Source Contributor.", - "a Collaborative Teammate.", - "a Creative Coder.", + "a Collaborative Team Player.", ], typeSpeed: 60, backSpeed: 40, @@ -208,7 +162,7 @@ onMounted(() => { $sr.reveal("#projects .card", { interval: 100 }); - $sr.reveal("#contact form", { delay: 300 }); + $sr.reveal("#contact .max-w-xl", { delay: 300 }); }); diff --git a/bun.lock b/bun.lock index 34796d8..e777794 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "eslint": "^9.0.0", "husky": "^9.1.7", "nuxt": "^4.0.0", + "otplib": "^12.0.1", "postcss": "^8.5.6", "scrollreveal": "^4.0.9", "typed.js": "^2.1.0", @@ -322,6 +323,16 @@ "@nuxtjs/mdc": ["@nuxtjs/mdc@0.17.0", "", { "dependencies": { "@nuxt/kit": "^3.16.2", "@shikijs/langs": "^3.3.0", "@shikijs/themes": "^3.3.0", "@shikijs/transformers": "^3.3.0", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@vue/compiler-core": "^3.5.13", "consola": "^3.4.2", "debug": "4.4.0", "defu": "^6.1.4", "destr": "^2.0.5", "detab": "^3.0.2", "github-slugger": "^2.0.0", "hast-util-format": "^1.1.0", "hast-util-to-mdast": "^10.1.2", "hast-util-to-string": "^3.0.1", "mdast-util-to-hast": "^13.2.0", "micromark-util-sanitize-uri": "^2.0.1", "parse5": "^7.3.0", "pathe": "^2.0.3", "property-information": "^7.0.0", "rehype-external-links": "^3.0.0", "rehype-minify-whitespace": "^6.0.2", "rehype-raw": "^7.0.0", "rehype-remark": "^10.0.1", "rehype-slug": "^6.0.0", "rehype-sort-attribute-values": "^5.0.1", "rehype-sort-attributes": "^5.0.1", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", "remark-mdc": "v3.6.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "scule": "^1.3.0", "shiki": "^3.3.0", "ufo": "^1.6.1", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-visit": "^5.0.0", "unwasm": "^0.3.9", "vfile": "^6.0.3" } }, "sha512-5HFJ2Xatl4oSfEZuYRJhzYhVHNvb31xc9Tu/qfXpRIWeQsQphqjaV3wWB5VStZYEHpTw1i6Hzyz/ojQZVl4qPg=="], + "@otplib/core": ["@otplib/core@12.0.1", "", {}, "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="], + + "@otplib/plugin-crypto": ["@otplib/plugin-crypto@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1" } }, "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g=="], + + "@otplib/plugin-thirty-two": ["@otplib/plugin-thirty-two@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "thirty-two": "^1.0.2" } }, "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA=="], + + "@otplib/preset-default": ["@otplib/preset-default@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/plugin-crypto": "^12.0.1", "@otplib/plugin-thirty-two": "^12.0.1" } }, "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ=="], + + "@otplib/preset-v11": ["@otplib/preset-v11@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/plugin-crypto": "^12.0.1", "@otplib/plugin-thirty-two": "^12.0.1" } }, "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg=="], + "@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.77.2", "", { "os": "android", "cpu": "arm64" }, "sha512-mYTzTLmuIz8bg7DyXu7IL3cOy28jfuiTRff4pZcQCRhWgU4LxAq2WmLw69/XvFE1zTJGVRc0nbOoxVw2o4FHIw=="], "@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.77.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aPj/O0U66GY5Wcyd8Vn6ypkfWPEX7LZRyfUOpDNruGujM5N/i2Q9jJZfIzApMIBZXpNtUpD+RKpOaI5xJPiVdw=="], @@ -1740,6 +1751,8 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "otplib": ["otplib@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/preset-default": "^12.0.1", "@otplib/preset-v11": "^12.0.1" } }, "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg=="], + "oxc-minify": ["oxc-minify@0.77.2", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.77.2", "@oxc-minify/binding-darwin-arm64": "0.77.2", "@oxc-minify/binding-darwin-x64": "0.77.2", "@oxc-minify/binding-freebsd-x64": "0.77.2", "@oxc-minify/binding-linux-arm-gnueabihf": "0.77.2", "@oxc-minify/binding-linux-arm-musleabihf": "0.77.2", "@oxc-minify/binding-linux-arm64-gnu": "0.77.2", "@oxc-minify/binding-linux-arm64-musl": "0.77.2", "@oxc-minify/binding-linux-riscv64-gnu": "0.77.2", "@oxc-minify/binding-linux-s390x-gnu": "0.77.2", "@oxc-minify/binding-linux-x64-gnu": "0.77.2", "@oxc-minify/binding-linux-x64-musl": "0.77.2", "@oxc-minify/binding-wasm32-wasi": "0.77.2", "@oxc-minify/binding-win32-arm64-msvc": "0.77.2", "@oxc-minify/binding-win32-x64-msvc": "0.77.2" } }, "sha512-+/1Gmx5K7t4beud4ZRYFo5Egkk0Op0qRAWPORWM0EnU1P343PxvJlpSt6QendAH+8XVk2urgidPKzkub53+GEg=="], "oxc-parser": ["oxc-parser@0.77.2", "", { "dependencies": { "@oxc-project/types": "^0.77.2" }, "optionalDependencies": { "@oxc-parser/binding-android-arm64": "0.77.2", "@oxc-parser/binding-darwin-arm64": "0.77.2", "@oxc-parser/binding-darwin-x64": "0.77.2", "@oxc-parser/binding-freebsd-x64": "0.77.2", "@oxc-parser/binding-linux-arm-gnueabihf": "0.77.2", "@oxc-parser/binding-linux-arm-musleabihf": "0.77.2", "@oxc-parser/binding-linux-arm64-gnu": "0.77.2", "@oxc-parser/binding-linux-arm64-musl": "0.77.2", "@oxc-parser/binding-linux-riscv64-gnu": "0.77.2", "@oxc-parser/binding-linux-s390x-gnu": "0.77.2", "@oxc-parser/binding-linux-x64-gnu": "0.77.2", "@oxc-parser/binding-linux-x64-musl": "0.77.2", "@oxc-parser/binding-wasm32-wasi": "0.77.2", "@oxc-parser/binding-win32-arm64-msvc": "0.77.2", "@oxc-parser/binding-win32-x64-msvc": "0.77.2" } }, "sha512-+FMfYsACcAcoeVJfvewk9FeSVXO2maiv78hE1zHzQPwLB7QTdsharc08HnBa0bUPl9D1dfzs20wPGmLLY2HYtg=="], @@ -2150,6 +2163,8 @@ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + "thirty-two": ["thirty-two@1.0.2", "", {}, "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], diff --git a/nuxt.config.ts b/nuxt.config.ts index d9b2c5e..594c637 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -19,4 +19,14 @@ export default defineNuxtConfig({ "@nuxt/test-utils", "@nuxt/ui", ], -}); \ No newline at end of file + runtimeConfig: { + androidSmsGatewayUrl: process.env.NUXT_ANDROID_SMS_GATEWAY_URL, + androidSmsGatewayLogin: process.env.NUXT_ANDROID_SMS_GATEWAY_LOGIN, + androidSmsGatewayPassword: process.env.NUXT_ANDROID_SMS_GATEWAY_PASSWORD, + myPhoneNumber: process.env.NUXT_MY_PHONE_NUMBER, + superSecretSalt: process.env.NUXT_SUPER_SECRET_SALT, + + // Keys within public, will be also exposed to the client-side + public: {}, + }, +}); diff --git a/package.json b/package.json index 8b875db..7bd3d96 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "nuxt build", - "dev": "nuxt dev", + "dev": "bun --env-file=.env nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare" @@ -25,6 +25,7 @@ "eslint": "^9.0.0", "husky": "^9.1.7", "nuxt": "^4.0.0", + "otplib": "^12.0.1", "postcss": "^8.5.6", "scrollreveal": "^4.0.9", "typed.js": "^2.1.0", diff --git a/server/api/send-message.post.js b/server/api/send-message.post.js new file mode 100644 index 0000000..d07c3c2 --- /dev/null +++ b/server/api/send-message.post.js @@ -0,0 +1,97 @@ +import { verifyTOTP } from "../utils/totp"; +import { createSmsGatewayClient } from "../lib/sms-gateway"; +import { isRateLimited, recordSubmission } from "../utils/rate-limiter.js"; +import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(event); + const { + name, + message: userMessage, + phoneNumber: rawPhoneNumber, + code, + } = await readBody(event); + + let phoneNumber; + try { + phoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber); + } catch (error) { + throw createError({ statusCode: 400, statusMessage: error.message }); + } + + // --- Input Validation --- + if (!name || !userMessage || !code) { + throw createError({ + statusCode: 400, + statusMessage: "All fields are required.", + }); + } + + // Prevent abuse by checking rate limit before doing anything + if (isRateLimited(phoneNumber)) { + throw createError({ + statusCode: 429, + statusMessage: + "You have already sent a message within the last week. Please try again later.", + }); + } + + if (userMessage.length > 140) { + throw createError({ + statusCode: 400, + statusMessage: "Message cannot be longer than 140 characters.", + }); + } + + const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/; + if (!printableAsciiRegex.test(userMessage)) { + throw createError({ + statusCode: 400, + statusMessage: "Message contains non-ASCII or non-printable characters.", + }); + } + + // --- Server Configuration Check --- + if (!config.myPhoneNumber || !config.superSecretSalt) { + console.error( + "Server is not fully configured. MY_PHONE_NUMBER and SUPER_SECRET_SALT are required.", + ); + throw createError({ + statusCode: 500, + statusMessage: "A server configuration error occurred.", + }); + } + + // --- Verification --- + const isVerified = verifyTOTP(phoneNumber, config.superSecretSalt, code); + if (!isVerified) { + throw createError({ + statusCode: 401, + statusMessage: + "Your verification code is invalid or has expired. Please try again.", + }); + } + + // --- Send Message --- + try { + const api = createSmsGatewayClient(config); + const finalMessage = `New message from ${name} ( ${phoneNumber} ) via your portfolio:\n\n"${userMessage}"`; + const message = { + phoneNumbers: [config.myPhoneNumber], + message: finalMessage, + }; + + const state = await api.send(message); + + // On success, record the submission time to start the rate-limiting period. + recordSubmission(phoneNumber); + + return { success: true, messageId: state.id }; + } catch (error) { + console.error("Failed to send message:", error); + throw createError({ + statusCode: 500, + statusMessage: "Failed to send message.", + }); + } +}); diff --git a/server/api/send-otp.post.js b/server/api/send-otp.post.js new file mode 100644 index 0000000..ac01706 --- /dev/null +++ b/server/api/send-otp.post.js @@ -0,0 +1,56 @@ +import { generateTOTP } from "../utils/totp"; +import { createSmsGatewayClient } from "../lib/sms-gateway"; +import { isRateLimited } from "../utils/rate-limiter.js"; +import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const { phoneNumber: rawPhoneNumber } = await readBody(event); + let normalizedPhoneNumber; + + try { + normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber); + } catch (error) { + // The validator throws an error with a user-friendly message. + throw createError({ statusCode: 400, statusMessage: error.message }); + } + + // Prevent abuse by checking rate limit before sending an SMS + if (isRateLimited(normalizedPhoneNumber)) { + throw createError({ + statusCode: 429, + statusMessage: + "You have already sent a message within the last week. Please try again later.", + }); + } + + if (!config.superSecretSalt) { + console.error("SUPER_SECRET_SALT is not configured on the server."); + throw createError({ + statusCode: 500, + statusMessage: "A server configuration error occurred.", + }); + } + + try { + const api = createSmsGatewayClient(config); + const otp = generateTOTP(normalizedPhoneNumber, config.superSecretSalt); + const step_min = Math.floor(getTOTPstep() / 60); + const step_sec = getTOTPstep() % 60; + + const message = { + phoneNumbers: [normalizedPhoneNumber], + message: `${otp} is your verification code. This code is valid for ${step_min}m${step_sec}s.`, + }; + + const state = await api.send(message); + return { success: true, messageId: state.id }; + } catch (error) { + console.error("Failed to send OTP:", error); + throw createError({ + statusCode: 500, + statusMessage: + "An error occurred while trying to send the verification code.", + }); + } +}); diff --git a/server/api/verify-otp.post.js b/server/api/verify-otp.post.js new file mode 100644 index 0000000..1546c16 --- /dev/null +++ b/server/api/verify-otp.post.js @@ -0,0 +1,59 @@ +import { verifyTOTP } from "../utils/totp"; +import { isRateLimited } from "../utils/rate-limiter.js"; +import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const { phoneNumber: rawPhoneNumber, code } = await readBody(event); + let normalizedPhoneNumber; + + try { + normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber); + } catch (error) { + // The validator throws an error with a user-friendly message. + throw createError({ statusCode: 400, statusMessage: error.message }); + } + + if (!code) { + throw createError({ + statusCode: 400, + statusMessage: "Verification code is required.", + }); + } + + // Prevent abuse by checking rate limit before doing anything + if (isRateLimited(normalizedPhoneNumber)) { + throw createError({ + statusCode: 429, + statusMessage: + "You have already sent a message within the last week. Please try again later.", + }); + } + + // Check for necessary server configuration. + if (!config.superSecretSalt) { + console.error("SUPER_SECRET_SALT is not configured on the server."); + // This is an internal server error, so we don't expose details to the client. + throw createError({ + statusCode: 500, + statusMessage: "A server configuration error occurred.", + }); + } + + const isValid = verifyTOTP( + normalizedPhoneNumber, + config.superSecretSalt, + code, + ); + + if (isValid) { + // In a stateful app, one might set a session cookie here. + return { success: true }; + } else { + // The code is incorrect or has expired. + throw createError({ + statusCode: 401, // Unauthorized + statusMessage: "Invalid or expired verification code.", + }); + } +}); diff --git a/server/lib/http-client.js b/server/lib/http-client.js new file mode 100644 index 0000000..25ad37f --- /dev/null +++ b/server/lib/http-client.js @@ -0,0 +1,45 @@ +/** + * A simple HTTP client wrapper around the global fetch function. + * This is used by the android-sms-gateway client. + */ +export const httpFetchClient = { + get: async (url, headers) => { + const response = await fetch(url, { + method: "GET", + headers, + }); + if (!response.ok) { + const errorBody = await response.text(); + console.error("Gateway GET error:", errorBody); + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }, + post: async (url, body, headers) => { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const errorBody = await response.text(); + console.error("Gateway POST error:", errorBody); + throw new Error( + `HTTP error! status: ${response.status}, body: ${errorBody}`, + ); + } + return response.json(); + }, + delete: async (url, headers) => { + const response = await fetch(url, { + method: "DELETE", + headers, + }); + if (!response.ok) { + const errorBody = await response.text(); + console.error("Gateway DELETE error:", errorBody); + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }, +}; diff --git a/server/lib/sms-gateway.js b/server/lib/sms-gateway.js new file mode 100644 index 0000000..95e87d5 --- /dev/null +++ b/server/lib/sms-gateway.js @@ -0,0 +1,42 @@ +import Client from "android-sms-gateway"; +import { httpFetchClient } from "./http-client"; + +/** + * Creates and configures an instance of the android-sms-gateway client. + * It centralizes the client instantiation and ensures that all necessary + * configuration for the gateway is present. + * + * @param {object} config The runtime configuration object from `useRuntimeConfig`. + * It should contain `androidSmsGatewayLogin`, `androidSmsGatewayPassword`, + * and `androidSmsGatewayUrl`. + * @returns {Client} A configured instance of the SMS gateway client. + * @throws {Error} If the required gateway configuration is missing, prompting + * a 500-level error in the calling API endpoint. + */ +export function createSmsGatewayClient(config) { + const { + androidSmsGatewayLogin, + androidSmsGatewayPassword, + androidSmsGatewayUrl, + } = config; + + if ( + !androidSmsGatewayLogin || + !androidSmsGatewayPassword || + !androidSmsGatewayUrl + ) { + console.error( + "SMS Gateway service is not configured. Missing required environment variables for the gateway.", + ); + // This indicates a critical server misconfiguration. The calling API endpoint + // should handle this and return a generic 500 error to the client. + throw new Error("Server is not configured for sending SMS."); + } + + return new Client( + androidSmsGatewayLogin, + androidSmsGatewayPassword, + httpFetchClient, + androidSmsGatewayUrl, + ); +} diff --git a/server/utils/phone-validator.js b/server/utils/phone-validator.js new file mode 100644 index 0000000..2d56198 --- /dev/null +++ b/server/utils/phone-validator.js @@ -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; +} diff --git a/server/utils/rate-limiter.js b/server/utils/rate-limiter.js new file mode 100644 index 0000000..d28b27e --- /dev/null +++ b/server/utils/rate-limiter.js @@ -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()); +} diff --git a/server/utils/totp.js b/server/utils/totp.js new file mode 100644 index 0000000..0485d46 --- /dev/null +++ b/server/utils/totp.js @@ -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; +}