Added email/phone verification
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-09-12 09:36:00 +00:00
parent a2a2d9b558
commit 666427a7c9
7 changed files with 388 additions and 70 deletions

View File

@ -1 +1 @@
90af14136b0b935418ae62167703d1dcbcb7b3ce-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b b5aad6117f63426726be6ae9a07e5aaa938f14ff-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -16,6 +16,7 @@ import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js'; import { requireAuth } from './shared/requireAuth.js';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { sendSMS } from './utils/smsService.js';
const CANARY_SQL = ` const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary ( CREATE TABLE IF NOT EXISTS encryption_canary (
@ -507,6 +508,28 @@ const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
const PASSWORD_HELP = const PASSWORD_HELP =
'Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character (!@#$%^&*).'; '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) // Change password (must be logged in)
app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => { app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => {
try { 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: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
});
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) 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', 'career_priorities','interest_inventory_answers','riasec_scores','career_list',
'email', 'email',
'phone_e164', 'phone_e164',
'sms_opt_in' 'sms_opt_in',
'email_verified_at',
'phone_verified_at'
]); ]);
const requested = raw.split(',').map(s => s.trim()).filter(Boolean); const requested = raw.split(',').map(s => s.trim()).filter(Boolean);

View File

@ -50,6 +50,9 @@ services:
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY} SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET} 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 SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes: volumes:

View File

@ -248,3 +248,7 @@ mysqldump \
user_profile_db > full_schema.sql 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;

View File

@ -45,6 +45,8 @@ import ResetPassword from './components/ResetPassword.js';
import { clearToken } from './auth/authMemory.js'; import { clearToken } from './auth/authMemory.js';
import api from './auth/apiClient.js'; import api from './auth/apiClient.js';
import * as safeLocal from './utils/safeLocal.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 = () => {
<Route path="/paywall" element={<Paywall />} /> <Route path="/paywall" element={<Paywall />} />
<Route path="/verify" element={<Verify />} />
{/* Authenticated routes */} {/* Authenticated routes */}
{isAuthenticated && ( {isAuthenticated && (
<> <>
<Route path="/signin-landing" element={<SignInLanding user={user} />} /> <Route path="/signin-landing" element={<VerificationGate><SignInLanding user={user} /></VerificationGate>} />
<Route path="/interest-inventory" element={<InterestInventory />} /> <Route path="/interest-inventory" element={<VerificationGate><InterestInventory /></VerificationGate>} />
<Route path="/profile" element={<UserProfile />} /> <Route path="/profile" element={<VerificationGate><UserProfile /></VerificationGate>} />
<Route path="/planning" element={<PlanningLanding />} /> <Route path="/planning" element={<VerificationGate><PlanningLanding /></VerificationGate>} />
<Route path="/career-explorer" element={<CareerExplorer />} /> <Route path="/career-explorer" element={<VerificationGate><CareerExplorer /></VerificationGate>} />
<Route path="/loan-repayment" element={<LoanRepaymentPage />} /> <Route path="/loan-repayment" element={<VerificationGate><LoanRepaymentPage /></VerificationGate>} />
<Route path="/educational-programs" element={<EducationalProgramsPage />} /> <Route path="/educational-programs" element={<VerificationGate><EducationalProgramsPage /></VerificationGate>} />
<Route path="/preparing" element={<PreparingLanding />} /> <Route path="/preparing" element={<VerificationGate><PreparingLanding /></VerificationGate>} />
<Route path="/billing" element={<BillingResult />} /> <Route path="/billing" element={<BillingResult />} />
{/* Premium-wrapped */} {/* Premium-wrapped */}
<Route <Route path="/enhancing" element={<VerificationGate><PremiumRoute user={user}><EnhancingLanding /></PremiumRoute></VerificationGate>} />
path="/enhancing" <Route path="/retirement" element={<VerificationGate><PremiumRoute user={user}><RetirementLanding /></PremiumRoute></VerificationGate>} />
element={ <Route path="/career-roadmap/:careerId?" element={<VerificationGate><PremiumRoute user={user}><CareerRoadmap /></PremiumRoute></VerificationGate>} />
<PremiumRoute user={user}> <Route path="/profile/careers" element={<VerificationGate><CareerProfileList /></VerificationGate>} />
<EnhancingLanding /> <Route path="/profile/careers/:id/edit" element={<VerificationGate><CareerProfileForm /></VerificationGate>} />
</PremiumRoute> <Route path="/profile/college" element={<VerificationGate><CollegeProfileList /></VerificationGate>} />
} <Route path="/profile/college/:careerId/:id?" element={<VerificationGate><CollegeProfileForm /></VerificationGate>} />
/> <Route path="/financial-profile" element={<VerificationGate><PremiumRoute user={user}><FinancialProfileForm /></PremiumRoute></VerificationGate>} />
<Route <Route path="/retirement-planner" element={<VerificationGate><PremiumRoute user={user}><RetirementPlanner /></PremiumRoute></VerificationGate>} />
path="/retirement" <Route path="/premium-onboarding" element={<VerificationGate><PremiumRoute user={user}><OnboardingContainer /></PremiumRoute></VerificationGate>} />
element={ <Route path="/resume-optimizer" element={<VerificationGate><PremiumRoute user={user}><ResumeRewrite /></PremiumRoute></VerificationGate>} />
<PremiumRoute user={user}>
<RetirementLanding />
</PremiumRoute>
}
/>
<Route
path="/career-roadmap/:careerId?"
element={
<PremiumRoute user={user}>
<CareerRoadmap />
</PremiumRoute>
}
/>
<Route path="/profile/careers" element={<CareerProfileList />} />
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
<Route path="/profile/college" element={<CollegeProfileList />} />
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
<Route
path="/financial-profile"
element={
<PremiumRoute user={user}>
<FinancialProfileForm />
</PremiumRoute>
}
/>
<Route
path="/retirement-planner"
element={
<PremiumRoute user={user}>
<RetirementPlanner />
</PremiumRoute>
}
/>
<Route
path="/premium-onboarding"
element={
<PremiumRoute user={user}>
<OnboardingContainer />
</PremiumRoute>
}
/>
<Route
path="/resume-optimizer"
element={
<PremiumRoute user={user}>
<ResumeRewrite />
</PremiumRoute>
}
/>
</> </>
)} )}

View File

@ -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 <Navigate to="/verify" replace />;
}
return children;
}

171
src/components/Verify.js Normal file
View File

@ -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 (
<div className="max-w-lg mx-auto p-6 space-y-6">
<h1 className="text-xl font-semibold">Verify your account</h1>
<p className="text-sm text-gray-600">You must verify before using AptivaAI.</p>
{/* EMAIL CARD */}
<div className="rounded-2xl border bg-white p-4 shadow-sm space-y-3">
<h2 className="text-sm font-medium text-gray-800">Email verification</h2>
<div className="grid grid-cols-[8rem,1fr,6.5rem] items-stretch gap-2">
<Button
className="w-full text-sm"
onClick={sendEmail}
disabled={sendingEmail}
>
{sendingEmail ? 'Sending…' : 'Send email'}
</Button>
<input
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Paste token"
value={token}
onChange={e=>setToken(e.target.value)}
/>
<Button
className="w-full text-sm"
variant="secondary"
onClick={confirmEmail}
>
Confirm
</Button>
</div>
</div>
{/* PHONE CARD */}
<div className="rounded-2xl border bg-white p-4 shadow-sm space-y-3">
<h2 className="text-sm font-medium text-gray-800">Phone verification (optional)</h2>
{/* Explicit SMS consent (required for A2P accuracy) */}
<div className="flex items-start gap-2">
<input
id="sms-consent"
type="checkbox"
className="mt-1 h-4 w-4 rounded border"
checked={smsConsent}
onChange={e => setSmsConsent(e.target.checked)}
/>
<label htmlFor="sms-consent" className="text-xs text-gray-700 leading-5">
By requesting a code, you agree to receive one-time security texts from AptivaAI to verify your account.
Reply STOP to opt out. Msg & data rates may apply.
</label>
</div>
<div className="grid grid-cols-[1fr,7.5rem] items-stretch gap-2">
<input
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
value={phone}
placeholder="+1XXXXXXXXXX"
onChange={e => {
let d = e.target.value.replace(/\D/g,'');
if (!d.startsWith('1')) d = '1' + d;
const v = '+' + d.slice(0,11);
setPhone(v);
}}
/>
<Button
className="px-3 whitespace-nowrap w-full"
onClick={sendSms}
disabled={sendingSms || !smsConsent}
>
{sendingSms ? 'Sending…' : 'Send code'}
</Button>
</div>
<div className="grid grid-cols-[1fr,6.5rem] items-stretch gap-2">
<input
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
placeholder="6-digit code"
value={code}
onChange={e=>setCode(e.target.value)}
/>
<Button
className="px-3 whitespace-nowrap w-full"
variant="secondary"
onClick={confirmSms}
disabled={confirmingSms || !code}
>
{confirmingSms ? 'Checking…' : 'Confirm'}
</Button>
</div>
</div>
{!!msg && <div className="text-sm text-gray-800">{msg}</div>}
</div>
);
}