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 = () => {
You must verify before using AptivaAI.
+ + {/* EMAIL CARD */} +