From f59b99203e4d074c24df31d40bd6003ea3d9eccb Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 14 Feb 2026 18:25:55 +0000 Subject: [PATCH] feat: add 6-digit code email verification to signup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /v1/signup/free now returns verification code (temp in response) - New POST /v1/signup/verify endpoint to verify code and get API key - Codes expire after 15 minutes, max 3 attempts - Frontend updated with 2-step signup modal (email → code → key) - Legacy token verification kept for existing links --- public/app.js | 59 +++++++++++++++++-- public/index.html | 23 ++++++-- src/routes/signup.ts | 85 ++++++++++++++++++++------- src/services/verification.ts | 111 ++++++++++++++++++++++++++--------- 4 files changed, 219 insertions(+), 59 deletions(-) diff --git a/public/app.js b/public/app.js index 6882a11..8559a73 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,9 @@ +var signupEmail = ''; + function showState(state) { - ['signupInitial', 'signupLoading', 'signupResult'].forEach(function(id) { - document.getElementById(id).classList.remove('active'); + ['signupInitial', 'signupLoading', 'signupVerify', 'signupResult'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.classList.remove('active'); }); document.getElementById(state).classList.add('active'); } @@ -9,7 +12,10 @@ function openSignup() { document.getElementById('signupModal').classList.add('active'); showState('signupInitial'); document.getElementById('signupError').style.display = 'none'; + document.getElementById('verifyError').style.display = 'none'; document.getElementById('signupEmail').value = ''; + document.getElementById('verifyCode').value = ''; + signupEmail = ''; } function closeSignup() { @@ -48,8 +54,11 @@ async function submitSignup() { return; } - // Show "check your email" message - showState('signupResult'); + signupEmail = email; + document.getElementById('verifyEmailDisplay').textContent = email; + showState('signupVerify'); + document.getElementById('verifyCode').focus(); + btn.disabled = false; } catch (err) { showState('signupInitial'); errEl.textContent = 'Network error. Please try again.'; @@ -58,11 +67,50 @@ async function submitSignup() { } } +async function submitVerify() { + var errEl = document.getElementById('verifyError'); + var btn = document.getElementById('verifyBtn'); + var codeInput = document.getElementById('verifyCode'); + var code = codeInput.value.trim(); + + if (!code || !/^\d{6}$/.test(code)) { + errEl.textContent = 'Please enter a 6-digit code.'; + errEl.style.display = 'block'; + return; + } + + errEl.style.display = 'none'; + btn.disabled = true; + + try { + var res = await fetch('/v1/signup/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: signupEmail, code: code }) + }); + var data = await res.json(); + + if (!res.ok) { + errEl.textContent = data.error || 'Verification failed.'; + errEl.style.display = 'block'; + btn.disabled = false; + return; + } + + document.getElementById('apiKeyText').textContent = data.apiKey; + showState('signupResult'); + } catch (err) { + errEl.textContent = 'Network error. Please try again.'; + errEl.style.display = 'block'; + btn.disabled = false; + } +} + function copyKey() { var key = document.getElementById('apiKeyText').textContent; var btn = document.getElementById('copyBtn'); function showCopied() { - btn.textContent = '✓ Copied!'; + btn.textContent = '\u2713 Copied!'; setTimeout(function() { btn.textContent = 'Copy'; }, 2000); } try { @@ -96,6 +144,7 @@ document.addEventListener('DOMContentLoaded', function() { document.getElementById('btn-checkout').addEventListener('click', checkout); document.getElementById('btn-close-signup').addEventListener('click', closeSignup); document.getElementById('signupBtn').addEventListener('click', submitSignup); + document.getElementById('verifyBtn').addEventListener('click', submitVerify); document.getElementById('signupModal').addEventListener('click', function(e) { if (e.target === this) closeSignup(); }); diff --git a/public/index.html b/public/index.html index 508c659..0f6afc4 100644 --- a/public/index.html +++ b/public/index.html @@ -113,10 +113,11 @@ footer .container { display: flex; align-items: center; justify-content: space-b .modal .close:hover { color: var(--fg); } /* Signup states */ -#signupInitial, #signupLoading, #signupResult { display: none; } +#signupInitial, #signupLoading, #signupVerify, #signupResult { display: none; } #signupInitial.active { display: block; } #signupLoading.active { display: flex; flex-direction: column; align-items: center; padding: 40px 0; text-align: center; } #signupResult.active { display: block; } +#signupVerify.active { display: block; } .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px; } @keyframes spin { to { transform: rotate(360deg); } } @@ -356,12 +357,24 @@ html, body {

Generating your API key…

+
+

Enter verification code

+

We sent a 6-digit code to

+ + + +

Code expires in 15 minutes

+
+
-

📧 Check your email!

-

We've sent a verification link to your email address. Click the link to get your API key.

+

🚀 Your API key is ready!

- 💡 - The link expires in 24 hours. Check your spam folder if you don't see it. + ⚠️ + Save your API key now — we can't recover it later. +
+
+ +

100 free PDFs/month • Read the docs →

diff --git a/src/routes/signup.ts b/src/routes/signup.ts index 508a321..50f9f39 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -1,26 +1,28 @@ import { Router, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import { createFreeKey } from "../services/keys.js"; -import { createVerification, verifyToken, isEmailVerified, getVerifiedApiKey } from "../services/verification.js"; +import { createVerification, createPendingVerification, verifyCode, isEmailVerified, getVerifiedApiKey } from "../services/verification.js"; import { sendVerificationEmail } from "../services/email.js"; const router = Router(); -// Rate limiting for signup - 5 signups per IP per hour const signupLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 5, - message: { - error: "Too many signup attempts. Please try again in 1 hour.", - retryAfter: "1 hour" - }, + message: { error: "Too many signup attempts. Please try again in 1 hour.", retryAfter: "1 hour" }, standardHeaders: true, legacyHeaders: false, - skipSuccessfulRequests: false, - skipFailedRequests: false, }); -// Self-service free API key signup — now requires email verification +const verifyLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + message: { error: "Too many verification attempts. Please try again later." }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Step 1: Request signup — generates 6-digit code router.post("/free", signupLimiter, async (req: Request, res: Response) => { const { email } = req.body || {}; @@ -31,26 +33,69 @@ router.post("/free", signupLimiter, async (req: Request, res: Response) => { const cleanEmail = email.trim().toLowerCase(); - // If already verified, tell them if (isEmailVerified(cleanEmail)) { - res.status(409).json({ error: "This email is already registered. Check your inbox for the original verification email, or contact support." }); + res.status(409).json({ error: "This email is already registered. Contact support if you need help." }); return; } - // Create the API key (but don't reveal it yet) - const keyInfo = createFreeKey(cleanEmail); + const pending = createPendingVerification(cleanEmail); - // Create verification record - const verification = createVerification(cleanEmail, keyInfo.key); - - // Send verification email - await sendVerificationEmail(cleanEmail, verification.token); + // TODO: Send code via email once SMTP is configured + // For now, return code in response for testing + console.log(`📧 Verification code for ${cleanEmail}: ${pending.code}`); res.json({ - message: "Check your email for a verification link to get your API key.", + status: "verification_required", + message: "Check your email for a verification code", email: cleanEmail, - verified: false, + code: pending.code, // TEMP: remove once email infra is live }); }); +// Step 2: Verify code — creates API key +router.post("/verify", verifyLimiter, (req: Request, res: Response) => { + const { email, code } = req.body || {}; + + if (!email || !code) { + res.status(400).json({ error: "Email and code are required." }); + return; + } + + const cleanEmail = email.trim().toLowerCase(); + const cleanCode = String(code).trim(); + + if (isEmailVerified(cleanEmail)) { + res.status(409).json({ error: "This email is already verified." }); + return; + } + + const result = verifyCode(cleanEmail, cleanCode); + + switch (result.status) { + case "ok": { + const keyInfo = createFreeKey(cleanEmail); + // Mark as verified via legacy system too + const verification = createVerification(cleanEmail, keyInfo.key); + verification.verifiedAt = new Date().toISOString(); + + res.json({ + status: "verified", + message: "Email verified! Here's your API key.", + apiKey: keyInfo.key, + tier: keyInfo.tier, + }); + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please sign up again." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please sign up again to get a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } +}); + export { router as signupRouter }; diff --git a/src/services/verification.ts b/src/services/verification.ts index 6f1f524..e66cc37 100644 --- a/src/services/verification.ts +++ b/src/services/verification.ts @@ -1,5 +1,5 @@ -import { randomBytes } from "crypto"; -import { existsSync, mkdirSync } from "fs"; +import { randomBytes, randomInt } from "crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -7,9 +7,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.join(__dirname, "../../data"); const DB_PATH = path.join(DATA_DIR, "verifications.json"); -// Simple JSON-based store (no SQLite dependency needed) -import { readFileSync, writeFileSync } from "fs"; - export interface Verification { email: string; token: string; @@ -18,50 +15,60 @@ export interface Verification { verifiedAt: string | null; } +export interface PendingVerification { + email: string; + code: string; + createdAt: string; + expiresAt: string; + attempts: number; +} + let verifications: Verification[] = []; +let pendingVerifications: PendingVerification[] = []; function ensureDataDir(): void { - if (!existsSync(DATA_DIR)) { - mkdirSync(DATA_DIR, { recursive: true }); - } + if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); } function load(): void { ensureDataDir(); if (existsSync(DB_PATH)) { try { - verifications = JSON.parse(readFileSync(DB_PATH, "utf-8")); + const data = JSON.parse(readFileSync(DB_PATH, "utf-8")); + // Support both old format (array) and new format (object) + if (Array.isArray(data)) { + verifications = data; + pendingVerifications = []; + } else { + verifications = data.verifications || []; + pendingVerifications = data.pendingVerifications || []; + } } catch { verifications = []; + pendingVerifications = []; } } } function save(): void { ensureDataDir(); - writeFileSync(DB_PATH, JSON.stringify(verifications, null, 2)); + writeFileSync(DB_PATH, JSON.stringify({ verifications, pendingVerifications }, null, 2)); } -// Initialize on import load(); -const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; +const CODE_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes +const MAX_ATTEMPTS = 3; +// Legacy token-based verification (keep for existing links) export function createVerification(email: string, apiKey: string): Verification { - // Check for existing pending verification for this email - const existing = verifications.find( - (v) => v.email === email && !v.verifiedAt - ); + const existing = verifications.find(v => v.email === email && !v.verifiedAt); if (existing) { - // Check if expired const age = Date.now() - new Date(existing.createdAt).getTime(); - if (age < TOKEN_EXPIRY_MS) { - return existing; // Return existing pending verification - } - // Expired — remove it - verifications = verifications.filter((v) => v !== existing); + if (age < TOKEN_EXPIRY_MS) return existing; + verifications = verifications.filter(v => v !== existing); } - const verification: Verification = { email, token: randomBytes(32).toString("hex"), @@ -75,24 +82,70 @@ export function createVerification(email: string, apiKey: string): Verification } export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } { - const v = verifications.find((v) => v.token === token); + const v = verifications.find(v => v.token === token); if (!v) return { status: "invalid" }; - if (v.verifiedAt) return { status: "already_verified", verification: v }; - const age = Date.now() - new Date(v.createdAt).getTime(); if (age > TOKEN_EXPIRY_MS) return { status: "expired" }; - v.verifiedAt = new Date().toISOString(); save(); return { status: "ok", verification: v }; } +// New 6-digit code verification +export function createPendingVerification(email: string): PendingVerification { + // Remove any existing pending for this email + pendingVerifications = pendingVerifications.filter(p => p.email !== email); + + const now = new Date(); + const pending: PendingVerification = { + email, + code: String(randomInt(100000, 999999)), + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(), + attempts: 0, + }; + pendingVerifications.push(pending); + save(); + return pending; +} + +export function verifyCode(email: string, code: string): { status: "ok" | "invalid" | "expired" | "max_attempts" } { + const cleanEmail = email.trim().toLowerCase(); + const pending = pendingVerifications.find(p => p.email === cleanEmail); + + if (!pending) return { status: "invalid" }; + + if (new Date() > new Date(pending.expiresAt)) { + pendingVerifications = pendingVerifications.filter(p => p !== pending); + save(); + return { status: "expired" }; + } + + if (pending.attempts >= MAX_ATTEMPTS) { + pendingVerifications = pendingVerifications.filter(p => p !== pending); + save(); + return { status: "max_attempts" }; + } + + pending.attempts++; + + if (pending.code !== code) { + save(); + return { status: "invalid" }; + } + + // Success - remove pending + pendingVerifications = pendingVerifications.filter(p => p !== pending); + save(); + return { status: "ok" }; +} + export function isEmailVerified(email: string): boolean { - return verifications.some((v) => v.email === email && v.verifiedAt !== null); + return verifications.some(v => v.email === email && v.verifiedAt !== null); } export function getVerifiedApiKey(email: string): string | null { - const v = verifications.find((v) => v.email === email && v.verifiedAt !== null); + const v = verifications.find(v => v.email === email && v.verifiedAt !== null); return v?.apiKey ?? null; }