Files
Siteforge/server/routes/study-auth.js
T
nmemmertandClaude Sonnet 5 841e754e16 Add student account self-service security features and discoverability fixes; v2.1.0
Adds forgot/reset password, signup email verification, sign-out-of-all-devices,
note-usage and certificate display in account settings, and password-changed
security emails. Fixes study sessions being lost on server restart by
persisting them to disk, and fixes sanitizeStudyUsers silently dropping the
new pending-token fields on load. Also surfaces the lesson discussion board
in the main site nav, trims the sign-in page for returning users, and renames
colossiansStudy.tsx/colossiansStudyData.ts to studyPages.tsx/legacyColossiansData.ts
now that the study system covers more than Colossians.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:21:54 -04:00

547 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
hashPasswordResetToken,
hashEmailVerifyToken,
findStudyUserByUsername,
getStudyAvatarUrl,
createStudySession,
setStudySessionCookie,
clearStudySessionCookie,
requireStudyAuth,
invalidateAllStudySessions,
createStudyTotpPendingToken,
consumeStudyTotpPendingToken,
generateEmailOtp,
storeEmailOtp,
verifyEmailOtp,
getStudyUserFromRequest,
} from '../study-helpers.js'
import {
sendEmailOtp,
sendStudyWelcomeEmail,
sendStudyVerifyEmail,
sendStudyPasswordResetEmail,
sendStudyPasswordChangedEmail,
syncContactToResend,
getCanonicalBaseUrl,
} from '../email.js'
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) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
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) {
res.status(401).json({ message: 'Invalid email or password.' })
return
}
const submittedHash = hashStudyPassword(password)
const expectedHash = user.passwordHash
const a = Buffer.from(submittedHash, 'utf8')
const b = Buffer.from(expectedHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Invalid email or password.' })
return
}
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: user.enrolledStudySlugs ?? [],
})
})
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 ?? {}
const submittedHash = hashStudyPassword(typeof password === 'string' ? password : '')
const a = Buffer.from(submittedHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
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('<!doctype html><html><head><meta charset="utf-8"><title>Unsubscribed</title><style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:24px;text-align:center;color:#333}h1{font-size:1.4rem;margin-bottom:12px}p{color:#666;line-height:1.6}</style></head><body><h1>You\'ve been unsubscribed</h1><p>You will no longer receive re-engagement reminder emails. You can re-enable them anytime from your account settings.</p></body></html>')
})
}