import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto' import rateLimit from 'express-rate-limit' import qrcode from 'qrcode' import { generateTotpSecret, verifyTotpCode, generateRecoveryCodes, } from '../auth.js' import { parseCookies, buildAbsoluteUrl } from '../helpers.js' import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS, PASSWORD_RESET_TOKEN_TTL_MS, EMAIL_VERIFY_TOKEN_TTL_MS } from '../config.js' import { state } from '../state.js' import { queueStudyUsersWrite, queueContactSubmissionsWrite, queueStudySessionsWrite, normalizeContactEmailStatus, normalizeMessageType, } from '../data.js' import { normalizeStudyUsername, isValidStudyUsername, hashStudyPassword, verifyStudyPassword, studyPasswordNeedsRehash, hashPasswordResetToken, hashEmailVerifyToken, findStudyUserByUsername, getStudyAvatarUrl, createStudySession, setStudySessionCookie, clearStudySessionCookie, requireStudyAuth, invalidateAllStudySessions, createStudyTotpPendingToken, consumeStudyTotpPendingToken, generateEmailOtp, storeEmailOtp, verifyEmailOtp, getStudyUserFromRequest, getAccessibleStudySlugs, } from '../study-helpers.js' import { sendEmailOtp, sendStudyWelcomeEmail, sendStudyVerifyEmail, sendStudyPasswordResetEmail, sendStudyPasswordChangedEmail, syncContactToResend, getCanonicalBaseUrl, } from '../email.js' const DUMMY_PASSWORD_HASH = hashStudyPassword('timing-equalizer') const studyAuthRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, message: { message: 'Too many attempts. Please wait 15 minutes and try again.' }, skipSuccessfulRequests: true, }) export function register(app) { app.get('/api/study-auth/status', (req, res) => { const user = getStudyUserFromRequest(req) res.json({ authenticated: Boolean(user), username: user?.username ?? '', displayName: user?.displayName ?? '', subscribeNewsletter: user?.subscribeNewsletter !== false, studyRemindersEnabled: user?.studyRemindersEnabled === true, avatarUrl: user ? getStudyAvatarUrl(user) : '', // Studies the student can open: enrolled ones plus any they have finished enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [], completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [], emailVerified: user?.emailVerified === true, totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))), twoFaMethod: user?.twoFaMethod ?? null, totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0, }) }) app.post('/api/study-auth/signup', studyAuthRateLimiter, async (req, res) => { const username = normalizeStudyUsername(req.body?.username) const password = typeof req.body?.password === 'string' ? req.body.password : '' const subscribe = req.body?.subscribe === true const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : '' if (!isValidStudyUsername(username)) { res.status(400).json({ message: 'Please enter a valid email address.' }) return } if (typeof password !== 'string' || password.length < 8 || password.length > 200) { res.status(400).json({ message: 'Password must be 8-200 characters.' }) return } if (findStudyUserByUsername(username)) { res.status(409).json({ message: 'An account with that email already exists.' }) return } if (state.studyUsers.length >= MAX_STUDY_USERS) { res.status(503).json({ message: 'Account registration is temporarily unavailable. Please try again later.' }) return } const now = new Date().toISOString() const rawVerifyToken = randomUUID() const user = { id: randomUUID(), username, passwordHash: hashStudyPassword(password), displayName, subscribeNewsletter: subscribe, studyRemindersEnabled: false, pendingEmailChange: null, emailVerified: false, pendingEmailVerification: { tokenHash: hashEmailVerifyToken(rawVerifyToken), expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS, }, enrolledStudySlugs: [], createdAt: now, updatedAt: now, lastLoginAt: now, } state.studyUsers.push(user) queueStudyUsersWrite() if (subscribe) { const submission = { id: randomUUID(), submittedAt: now, name: displayName || username, email: username, message: '', messageType: 'general', subscribe: true, archived: false, emailStatus: normalizeContactEmailStatus(null, true), } state.contactSubmissions.unshift(submission) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) queueContactSubmissionsWrite() syncContactToResend(displayName || username, username).catch(err => console.error('[study-signup] resend sync error:', err)) } sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err)) const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawVerifyToken)}`) sendStudyVerifyEmail(username, displayName || username, verifyUrl).catch(err => console.error('[study-signup] verify email error:', err)) const sessionToken = createStudySession(user.id) setStudySessionCookie(res, sessionToken) res.json({ ok: true, username: user.username, displayName: user.displayName, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs, }) }) app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { const username = normalizeStudyUsername(req.body?.username) const password = typeof req.body?.password === 'string' ? req.body.password : '' const user = findStudyUserByUsername(username) if (!user) { verifyStudyPassword(password, DUMMY_PASSWORD_HASH) // equalize timing with the real-user path res.status(401).json({ message: 'Invalid email or password.' }) return } if (!verifyStudyPassword(password, user.passwordHash)) { res.status(401).json({ message: 'Invalid email or password.' }) return } if (studyPasswordNeedsRehash(user.passwordHash)) { user.passwordHash = hashStudyPassword(password) queueStudyUsersWrite() } const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null) if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) { const pendingToken = createStudyTotpPendingToken(user.id) res.json({ totpRequired: true, pendingToken, method: 'app' }) return } if (twoFaMethod === 'email') { const code = generateEmailOtp() storeEmailOtp(user.id, code) const pendingToken = createStudyTotpPendingToken(user.id) sendEmailOtp(user.username, code).catch(err => console.error('[email-otp] login send error:', err)) res.json({ totpRequired: true, pendingToken, method: 'email' }) return } user.lastLoginAt = new Date().toISOString() user.updatedAt = user.lastLoginAt user.reengagementSentAt = {} queueStudyUsersWrite() const sessionToken = createStudySession(user.id) setStudySessionCookie(res, sessionToken) res.json({ ok: true, username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: getAccessibleStudySlugs(user), completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [], }) }) app.post('/api/study-auth/totp-verify', studyAuthRateLimiter, (req, res) => { const { pendingToken, code } = req.body ?? {} const userId = consumeStudyTotpPendingToken(pendingToken) if (!userId) { res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' }) return } const user = state.studyUsers.find(u => u.id === userId) const twoFaMethodCheck = user?.twoFaMethod ?? (user?.totpSecret && user?.totpVerified ? 'app' : null) if (!user || !twoFaMethodCheck) { res.status(400).json({ message: '2FA is not configured for this account.' }) return } const codeStr = typeof code === 'string' ? code.replace(/\s/g, '') : '' const twoFaMethod = twoFaMethodCheck function completeLogin(extra = {}) { user.lastLoginAt = new Date().toISOString() user.updatedAt = user.lastLoginAt user.reengagementSentAt = {} queueStudyUsersWrite() const sessionToken = createStudySession(user.id) setStudySessionCookie(res, sessionToken) res.json({ ok: true, ...extra, username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs ?? [] }) } if (twoFaMethod === 'email') { const result = verifyEmailOtp(user.id, codeStr) if (result === 'ok') { completeLogin(); return } if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please sign in again to receive a new code.' }); return } if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please sign in again.' }); return } res.status(401).json({ message: 'Invalid code. Check your email and try again.' }) return } if (verifyTotpCode(user.totpSecret, codeStr)) { completeLogin() return } if (Array.isArray(user.totpRecoveryCodes) && user.totpRecoveryCodes.length > 0) { const normalised = codeStr.replace(/-/g, '').toUpperCase() const matchIdx = user.totpRecoveryCodes.findIndex(h => { try { return createHash('sha256').update(normalised).digest('hex') === h } catch { return false } }) if (matchIdx !== -1) { user.totpRecoveryCodes.splice(matchIdx, 1) completeLogin({ usedRecoveryCode: true, remainingRecoveryCodes: user.totpRecoveryCodes.length }) return } } res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' }) }) app.post('/api/study-auth/totp-setup-init', requireStudyAuth, async (req, res) => { const user = req.studyUser const secret = generateTotpSecret() const label = user.username const issuer = 'Verse by Verse with Nate' const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` const qrDataUrl = await qrcode.toDataURL(uri) user.totpSecretPending = secret user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ qrDataUrl, secret }) }) app.post('/api/study-auth/totp-setup-confirm', requireStudyAuth, (req, res) => { const user = req.studyUser const { code } = req.body ?? {} if (!user.totpSecretPending) { res.status(400).json({ message: 'No 2FA setup in progress. Start setup first.' }) return } if (!verifyTotpCode(user.totpSecretPending, typeof code === 'string' ? code.replace(/\s/g, '') : '')) { res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' }) return } const recoveryCodes = generateRecoveryCodes() user.totpSecret = user.totpSecretPending user.totpVerified = true user.twoFaMethod = 'app' user.totpEnabledAt = new Date().toISOString() user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex')) delete user.totpSecretPending user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true, recoveryCodes }) }) app.post('/api/study-auth/2fa-setup-email', studyAuthRateLimiter, requireStudyAuth, async (req, res) => { const user = req.studyUser const code = generateEmailOtp() storeEmailOtp(user.id, code) await sendEmailOtp(user.username, code) res.json({ ok: true }) }) app.post('/api/study-auth/2fa-setup-email-confirm', studyAuthRateLimiter, requireStudyAuth, (req, res) => { const user = req.studyUser const { code } = req.body ?? {} const result = verifyEmailOtp(user.id, typeof code === 'string' ? code.trim() : '') if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please request a new one.' }); return } if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please request a new code.' }); return } if (result !== 'ok') { res.status(401).json({ message: 'Invalid code. Check your email and try again.' }); return } user.twoFaMethod = 'email' user.totpSecret = null user.totpVerified = false user.totpRecoveryCodes = [] delete user.totpSecretPending user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true }) }) app.post('/api/study-auth/email-otp-resend', studyAuthRateLimiter, async (req, res) => { const { pendingToken } = req.body ?? {} const entry = state.studyTotpPendingTokens.get(pendingToken) if (!entry || Date.now() > entry.expiresAt) { res.status(401).json({ message: 'Session expired. Please sign in again.' }); return } const user = state.studyUsers.find(u => u.id === entry.userId) if (!user) { res.status(404).json({ message: 'User not found.' }); return } const code = generateEmailOtp() storeEmailOtp(user.id, code) await sendEmailOtp(user.username, code) res.json({ ok: true }) }) app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => { const user = req.studyUser const { password } = req.body ?? {} if (!verifyStudyPassword(typeof password === 'string' ? password : '', user.passwordHash)) { res.status(401).json({ message: 'Incorrect password.' }) return } user.twoFaMethod = null user.totpSecret = null user.totpVerified = false user.totpRecoveryCodes = [] delete user.totpSecretPending user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true }) }) app.post('/api/study-auth/totp-regen-recovery', requireStudyAuth, (req, res) => { const user = req.studyUser if (!user.totpSecret || !user.totpVerified) { res.status(400).json({ message: '2FA is not enabled.' }) return } const recoveryCodes = generateRecoveryCodes() user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex')) user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true, recoveryCodes }) }) app.post('/api/study-auth/logout', (req, res) => { const cookies = parseCookies(req.headers.cookie) const token = cookies[STUDY_SESSION_COOKIE] if (token) { state.studySessions.delete(token) queueStudySessionsWrite() } clearStudySessionCookie(res) res.json({ ok: true }) }) // Signs out every other active session for the current user (this device stays signed in). app.post('/api/study-auth/logout-all', requireStudyAuth, (req, res) => { const cookies = parseCookies(req.headers.cookie) const currentToken = cookies[STUDY_SESSION_COOKIE] invalidateAllStudySessions(req.studyUser.id, currentToken) res.json({ ok: true }) }) // Request a password-reset email. Always responds ok to avoid leaking which emails have accounts. app.post('/api/study-auth/forgot-password', studyAuthRateLimiter, async (req, res) => { const username = normalizeStudyUsername(req.body?.username) if (!isValidStudyUsername(username)) { res.status(400).json({ message: 'Please enter a valid email address.' }) return } const user = findStudyUserByUsername(username) if (user) { const rawToken = randomUUID() user.pendingPasswordReset = { tokenHash: hashPasswordResetToken(rawToken), expiresAt: Date.now() + PASSWORD_RESET_TOKEN_TTL_MS, } user.updatedAt = new Date().toISOString() queueStudyUsersWrite() const resetUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/reset-password?token=${encodeURIComponent(rawToken)}`) sendStudyPasswordResetEmail(user.username, user.displayName || user.username, resetUrl) .catch(err => console.error('[study-forgot-password] send error:', err)) } res.json({ ok: true, message: 'If that email has an account, a reset link is on its way.' }) }) app.post('/api/study-auth/reset-password', studyAuthRateLimiter, (req, res) => { const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '' const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : '' if (!token) { res.status(400).json({ message: 'Invalid or missing reset token.' }) return } if (newPassword.length < 8 || newPassword.length > 200) { res.status(400).json({ message: 'New password must be 8–200 characters.' }) return } const tokenHash = hashPasswordResetToken(token) const user = state.studyUsers.find(u => { const pending = u.pendingPasswordReset if (!pending?.tokenHash) return false const a = Buffer.from(tokenHash, 'utf8') const b = Buffer.from(pending.tokenHash, 'utf8') return a.length === b.length && timingSafeEqual(a, b) }) if (!user) { res.status(400).json({ message: 'This reset link is invalid. Please request a new one.' }) return } if (user.pendingPasswordReset.expiresAt <= Date.now()) { user.pendingPasswordReset = null queueStudyUsersWrite() res.status(400).json({ message: 'This reset link has expired. Please request a new one.' }) return } user.passwordHash = hashStudyPassword(newPassword) user.pendingPasswordReset = null user.updatedAt = new Date().toISOString() queueStudyUsersWrite() invalidateAllStudySessions(user.id) sendStudyPasswordChangedEmail(user.username, user.displayName || user.username) .catch(err => console.error('[study-reset-password] confirmation email error:', err)) res.json({ ok: true }) }) // Verify the email address used at signup. Not auth-gated so it works from any device/browser. app.post('/api/study-auth/verify-email', studyAuthRateLimiter, (req, res) => { const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '' if (!token) { res.status(400).json({ message: 'Invalid or missing verification token.' }) return } const tokenHash = hashEmailVerifyToken(token) const user = state.studyUsers.find(u => { const pending = u.pendingEmailVerification if (!pending?.tokenHash) return false const a = Buffer.from(tokenHash, 'utf8') const b = Buffer.from(pending.tokenHash, 'utf8') return a.length === b.length && timingSafeEqual(a, b) }) if (!user) { res.status(400).json({ message: 'This verification link is invalid or has already been used.' }) return } if (user.pendingEmailVerification.expiresAt <= Date.now()) { res.status(400).json({ message: 'This verification link has expired. Request a new one from My Account.' }) return } user.emailVerified = true user.pendingEmailVerification = null user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true }) }) app.post('/api/study-auth/resend-verification', studyAuthRateLimiter, requireStudyAuth, (req, res) => { const user = req.studyUser if (user.emailVerified) { res.json({ ok: true, alreadyVerified: true }) return } const rawToken = randomUUID() user.pendingEmailVerification = { tokenHash: hashEmailVerifyToken(rawToken), expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS, } user.updatedAt = new Date().toISOString() queueStudyUsersWrite() const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawToken)}`) sendStudyVerifyEmail(user.username, user.displayName || user.username, verifyUrl) .catch(err => console.error('[study-resend-verification] send error:', err)) res.json({ ok: true }) }) // One-click unsubscribe from re-engagement emails (no login required) app.get('/api/study-auth/unsubscribe-reminders', (req, res) => { const { uid, token } = req.query ?? {} if (typeof uid !== 'string' || typeof token !== 'string') { res.status(400).send('Invalid unsubscribe link.') return } const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge' const expected = createHmac('sha256', secret).update(uid).digest('hex') const expectedBuf = Buffer.from(expected, 'hex') const actualBuf = Buffer.from(token.length === expected.length ? token : '', 'hex') let valid = false try { valid = timingSafeEqual(expectedBuf, actualBuf) } catch { valid = false } if (!valid) { res.status(400).send('Invalid or expired unsubscribe link.') return } const user = state.studyUsers.find(u => u.id === uid) if (!user) { res.status(404).send('Account not found.') return } user.studyRemindersEnabled = false user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.send('
You will no longer receive re-engagement reminder emails. You can re-enable them anytime from your account settings.
') }) }