From 666427a7c958d986ef4a1c8cea336101749f4fd9 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 12 Sep 2025 09:36:00 +0000 Subject: [PATCH] Added email/phone verification --- .build.hash | 2 +- backend/server1.js | 154 +++++++++++++++++++++++++- docker-compose.yml | 3 + migrate_encrypted_columns.sql | 4 + src/App.js | 90 ++++----------- src/components/VerificationGate.js | 34 ++++++ src/components/Verify.js | 171 +++++++++++++++++++++++++++++ 7 files changed, 388 insertions(+), 70 deletions(-) create mode 100644 src/components/VerificationGate.js create mode 100644 src/components/Verify.js diff --git a/.build.hash b/.build.hash index 737d2bc..deeecf0 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -90af14136b0b935418ae62167703d1dcbcb7b3ce-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b +b5aad6117f63426726be6ae9a07e5aaa938f14ff-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/backend/server1.js b/backend/server1.js index 171cdfd..9e5277f 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -16,6 +16,7 @@ import rateLimit from 'express-rate-limit'; import { readFile } from 'fs/promises'; // ← needed for /healthz import { requireAuth } from './shared/requireAuth.js'; import cookieParser from 'cookie-parser'; +import { sendSMS } from './utils/smsService.js'; const CANARY_SQL = ` CREATE TABLE IF NOT EXISTS encryption_canary ( @@ -507,6 +508,28 @@ const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/; const PASSWORD_HELP = 'Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character (!@#$%^&*).'; + /* ──────────────────────────────────────────────────────────────── + Verification helpers / rate limits + ---------------------------------------------------------------- */ +function absoluteWebBase() { + // You already require APTIVA_API_BASE above; reuse it to build magic links. + return String(RESET_CONFIG.BASE_URL || '').replace(/\/+$/, ''); +} +const verifySendLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 3, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.ip, +}); +const verifyConfirmLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 6, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.ip, +}); + // Change password (must be logged in) app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => { try { @@ -706,6 +729,133 @@ app.post('/api/auth/password-reset/confirm', pwConfirmLimiter, async (req, res) } }); +/* ------------------------------------------------------------------ + AUTH STATUS (used by client gate) + ------------------------------------------------------------------ */ +app.get('/api/auth/status', requireAuth, async (req, res) => { + try { + const uid = req.userId; + const [rows] = await (pool.raw || pool).query( + 'SELECT email_verified_at, phone_verified_at FROM user_profile WHERE id = ? LIMIT 1', + [uid] + ); + const row = rows?.[0] || {}; + return res.status(200).json({ + is_authenticated: true, + email_verified_at: row.email_verified_at || null, + phone_verified_at: row.phone_verified_at || null + }); + } catch (e) { + console.error('[auth/status]', e?.message || e); + return res.status(500).json({ error: 'Server error' }); + } +}); + +/* ------------------------------------------------------------------ + EMAIL VERIFICATION (send + confirm) + ------------------------------------------------------------------ */ +app.post('/api/auth/verify/email/send', requireAuth, verifySendLimiter, async (req, res) => { + try { + if (!SENDGRID_ENABLED) return res.status(503).json({ error: 'Email not configured' }); + const uid = req.userId; + const [[row]] = await (pool.raw || pool).query('SELECT email FROM user_profile WHERE id=? LIMIT 1', [uid]); + const enc = row?.email; + if (!enc) return res.status(400).json({ error: 'No email on file' }); + let emailPlain = ''; + try { emailPlain = decrypt(enc); } catch { emailPlain = enc; } + + const token = jwt.sign({ sub: String(uid), prp: 'verify_email' }, JWT_SECRET, { expiresIn: '30m' }); + const link = `${absoluteWebBase()}/verify?t=${encodeURIComponent(token)}`; + + const text = +`Verify your AptivaAI email by clicking the link below (expires in 30 minutes): ++${link}`; + + await sgMail.send({ + to: emailPlain, + from: RESET_CONFIG.FROM, + subject: 'Verify your email — AptivaAI', + text, + html: `
${text}
` + }); + return res.status(200).json({ ok: true }); + } catch (e) { + console.error('[verify/email/send]', e?.message || e); + return res.status(500).json({ error: 'Failed to send verification email' }); + } +}); + +app.post('/api/auth/verify/email/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => { + try { + const { token } = req.body || {}; + if (!token) return res.status(400).json({ error: 'Token required' }); + let payload; + try { payload = jwt.verify(token, JWT_SECRET); } + catch { return res.status(400).json({ error: 'Invalid or expired token' }); } + if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_email') { + return res.status(400).json({ error: 'Token/user mismatch' }); + } + const [r] = await (pool.raw || pool).query( + 'UPDATE user_profile SET email_verified_at = UTC_TIMESTAMP() WHERE id = ?', + [req.userId] + ); + return res.status(200).json({ ok: !!r?.affectedRows }); + } catch (e) { + console.error('[verify/email/confirm]', e?.message || e); + return res.status(500).json({ error: 'Failed to confirm email' }); + } +}); + +/* ------------------------------------------------------------------ + PHONE VERIFICATION (send + confirm) — optional + ------------------------------------------------------------------ */ +app.post('/api/auth/verify/phone/send', requireAuth, verifySendLimiter, async (req, res) => { + try { + const { phone_e164 } = req.body || {}; + if (!phone_e164 || !/^\+1\d{10}$/.test(phone_e164)) { + return res.status(400).json({ error: 'Phone must be +1 followed by 10 digits' }); + } + // persist/overwrite phone on file + await (pool.raw || pool).query('UPDATE user_profile SET phone_e164=? WHERE id=?', [phone_e164, req.userId]); + const code = String(Math.floor(100000 + Math.random() * 900000)); + await sendSMS({ to: phone_e164, body: `AptivaAI security code: ${code}. Expires in 10 minutes.` }); + + // store short-lived challenge in HttpOnly cookie (10 min) + const tok = jwt.sign({ sub: String(req.userId), prp: 'verify_phone', code }, JWT_SECRET, { expiresIn: '10m' }); + res.cookie('aptiva_phone_vc', tok, { ...sessionCookieOptions(), maxAge: 10 * 60 * 1000 }); + return res.status(200).json({ ok: true }); + } catch (e) { + console.error('[verify/phone/send]', e?.message || e); + return res.status(500).json({ error: 'Failed to send code' }); + } +}); + +app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => { + try { + const { code } = req.body || {}; + if (!code) return res.status(400).json({ error: 'Code required' }); + const tok = req.cookies?.aptiva_phone_vc; + if (!tok) return res.status(400).json({ error: 'No challenge issued' }); + let payload; + try { payload = jwt.verify(tok, JWT_SECRET); } + catch { return res.status(400).json({ error: 'Challenge expired' }); } + if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_phone') { + return res.status(400).json({ error: 'Challenge mismatch' }); + } + if (String(payload?.code) !== String(code)) { + return res.status(400).json({ error: 'Invalid code' }); + } + const [r] = await (pool.raw || pool).query( + 'UPDATE user_profile SET phone_verified_at = UTC_TIMESTAMP() WHERE id = ?', + [req.userId] + ); + res.clearCookie('aptiva_phone_vc', sessionCookieOptions()); + return res.status(200).json({ ok: !!r?.affectedRows }); + } catch (e) { + console.error('[verify/phone/confirm]', e?.message || e); + return res.status(500).json({ error: 'Failed to confirm phone' }); + } +}); /* ------------------------------------------------------------------ USER REGISTRATION (MySQL) @@ -1011,7 +1161,9 @@ app.get('/api/user-profile', requireAuth, async (req, res) => { 'career_priorities','interest_inventory_answers','riasec_scores','career_list', 'email', 'phone_e164', - 'sms_opt_in' + 'sms_opt_in', + 'email_verified_at', + 'phone_verified_at' ]); const requested = raw.split(',').map(s => s.trim()).filter(Boolean); diff --git a/docker-compose.yml b/docker-compose.yml index 88c6646..1df5a45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,9 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY} EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET} + TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID} + TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN} + TWILIO_MESSAGING_SERVICE_SID: ${TWILIO_MESSAGING_SERVICE_SID} SALARY_DB_PATH: /app/salary_info.db FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} volumes: diff --git a/migrate_encrypted_columns.sql b/migrate_encrypted_columns.sql index 0b9c551..c7fb41f 100644 --- a/migrate_encrypted_columns.sql +++ b/migrate_encrypted_columns.sql @@ -248,3 +248,7 @@ mysqldump \ user_profile_db > full_schema.sql +-- /home/jcoakley/sql/2025-09-11_add_verification_flags.sql +ALTER TABLE user_profile + ADD COLUMN email_verified_at DATETIME NULL AFTER email_lookup, + ADD COLUMN phone_verified_at DATETIME NULL AFTER phone_e164; diff --git a/src/App.js b/src/App.js index eb47c66..9b3c3dd 100644 --- a/src/App.js +++ b/src/App.js @@ -45,6 +45,8 @@ import ResetPassword from './components/ResetPassword.js'; import { clearToken } from './auth/authMemory.js'; import api from './auth/apiClient.js'; import * as safeLocal from './utils/safeLocal.js'; +import VerificationGate from './components/VerificationGate.js'; +import Verify from './components/Verify.js'; @@ -655,81 +657,33 @@ const cancelLogout = () => { } /> + } /> {/* Authenticated routes */} {isAuthenticated && ( <> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> {/* Premium-wrapped */} - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> )} diff --git a/src/components/VerificationGate.js b/src/components/VerificationGate.js new file mode 100644 index 0000000..77dde07 --- /dev/null +++ b/src/components/VerificationGate.js @@ -0,0 +1,34 @@ +// /home/jcoakley/aptiva-dev1-app/src/components/VerificationGate.jsx +import React, { useEffect, useState } from 'react'; +import api from '../auth/apiClient.js'; +import { useLocation, Navigate } from 'react-router-dom'; + +export default function VerificationGate({ children }) { + const [ready, setReady] = useState(false); + const [verified, setVerified] = useState(false); + const loc = useLocation(); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + // Ask user-profile for extra fields; backend safely ignores unknown "fields" + const { data } = await api.get('/api/user-profile?fields=firstname,is_premium,is_pro_premium,email_verified_at,phone_verified_at'); + if (cancelled) return; + const v = Boolean(data?.email_verified_at || data?.phone_verified_at); + setVerified(v); + } catch { + setVerified(false); + } finally { + if (!cancelled) setReady(true); + } + })(); + return () => { cancelled = true; }; + }, [loc.pathname]); + + if (!ready) return null; // keep splash minimal + if (!verified && !/^\/verify(?:$|\?)/.test(loc.pathname)) { + return ; + } + return children; +} diff --git a/src/components/Verify.js b/src/components/Verify.js new file mode 100644 index 0000000..53aca63 --- /dev/null +++ b/src/components/Verify.js @@ -0,0 +1,171 @@ +// /home/jcoakley/aptiva-dev1-app/src/components/Verify.js +import React, { useEffect, useState } from 'react'; +import { Button } from './ui/button.js'; +import api from '../auth/apiClient.js'; +import { useNavigate } from 'react-router-dom'; + +export default function Verify() { + const navigate = useNavigate(); + const [msg, setMsg] = useState(''); + const qs = new URLSearchParams(window.location.search); + const [token, setToken] = useState(() => qs.get('t') || ''); + const next = qs.get('next') || '/signin-landing'; + const [phone, setPhone] = useState('1'); + const [code, setCode] = useState(''); + const [sendingEmail, setSendingEmail] = useState(false); + const [sendingSms, setSendingSms] = useState(false); + const [confirmingSms, setConfirmingSms] = useState(false); + const [smsConsent, setSmsConsent] = useState(false); // explicit consent for SMS + + const sendEmail = async () => { + if (sendingEmail) return; + setSendingEmail(true); + try { + await api.post('/api/auth/verify/email/send', {}); + setMsg('Verification email sent.'); + } catch { + setMsg('Could not send verification email.'); + } finally { + // auto re-enable after 30s to avoid lockout + setTimeout(() => setSendingEmail(false), 30000); + } + }; + + const confirmEmail = async () => { + try { + await api.post('/api/auth/verify/email/confirm', { token }); + setMsg('Email verified. Redirecting…'); + // give backend a heartbeat to persist, then navigate + setTimeout(() => navigate(next, { replace: true }), 350); + } + catch { setMsg('Invalid or expired email token.'); } + }; + + const sendSms = async () => { + if (sendingSms) return; + setSendingSms(true); + try { + await api.post('/api/auth/verify/phone/send', { phone_e164: phone }); + setMsg('SMS code sent.'); + } catch { + setMsg('Could not send SMS code.'); + } finally { + // re-enable after 30s; avoids hammering the endpoint + setTimeout(() => setSendingSms(false), 30000); + } + }; + + const confirmSms = async () => { + if (confirmingSms) return; + setConfirmingSms(true); + try { + await api.post('/api/auth/verify/phone/confirm', { code }); + setMsg('Phone verified. Redirecting…'); + setTimeout(() => navigate(next, { replace: true }), 350); + } catch { + setMsg('Invalid or expired code.'); + } finally { + setConfirmingSms(false); + } + }; + + useEffect(() => { + if (token) { confirmEmail(); } // magic-link auto confirm → then redirect + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+

Verify your account

+

You must verify before using AptivaAI.

+ + {/* EMAIL CARD */} +
+

Email verification

+
+ + setToken(e.target.value)} + /> + +
+
+ + {/* PHONE CARD */} +
+

Phone verification (optional)

+ + + {/* Explicit SMS consent (required for A2P accuracy) */} +
+ setSmsConsent(e.target.checked)} + /> + +
+ + +
+ { + let d = e.target.value.replace(/\D/g,''); + if (!d.startsWith('1')) d = '1' + d; + const v = '+' + d.slice(0,11); + setPhone(v); + }} + /> + +
+
+ setCode(e.target.value)} + /> + +
+
+ + {!!msg &&
{msg}
} +
+ ); +}