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>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-24 09:21:54 -04:00
1 parent a6e8243705
commit 841e754e16
16 files changed
+727 -50

No files matched your search

+158 -3
View File
@@ -6,12 +6,13 @@ import {
verifyTotpCode,
generateRecoveryCodes,
} from '../auth.js'
import { parseCookies } from '../helpers.js'
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.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'
@@ -19,12 +20,15 @@ import {
normalizeStudyUsername,
isValidStudyUsername,
hashStudyPassword,
hashPasswordResetToken,
hashEmailVerifyToken,
findStudyUserByUsername,
getStudyAvatarUrl,
createStudySession,
setStudySessionCookie,
clearStudySessionCookie,
requireStudyAuth,
invalidateAllStudySessions,
createStudyTotpPendingToken,
consumeStudyTotpPendingToken,
generateEmailOtp,
@@ -32,7 +36,15 @@ import {
verifyEmailOtp,
getStudyUserFromRequest,
} from '../study-helpers.js'
import { sendEmailOtp, sendStudyWelcomeEmail, syncContactToResend } from '../email.js'
import {
sendEmailOtp,
sendStudyWelcomeEmail,
sendStudyVerifyEmail,
sendStudyPasswordResetEmail,
sendStudyPasswordChangedEmail,
syncContactToResend,
getCanonicalBaseUrl,
} from '../email.js'
const studyAuthRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
@@ -54,6 +66,7 @@ export function register(app) {
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,
@@ -87,6 +100,7 @@ export function register(app) {
}
const now = new Date().toISOString()
const rawVerifyToken = randomUUID()
const user = {
id: randomUUID(),
username,
@@ -95,6 +109,11 @@ export function register(app) {
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,
@@ -123,6 +142,8 @@ export function register(app) {
}
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)
@@ -356,11 +377,145 @@ export function register(app) {
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 ?? {}