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:
1 parent
a6e8243705
commit
841e754e16
16 files changed
+727
-50
No files matched your search
@@ -73,6 +73,7 @@ export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
||||
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
||||
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
// Seafile integration for Nuggets episode uploads
|
||||
@@ -124,6 +125,8 @@ export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
||||
export const MAX_STUDY_NOTES_PER_USER = 500
|
||||
export const MAX_STUDY_NOTE_LENGTH = 12000
|
||||
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
||||
export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000
|
||||
export const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
||||
export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
CALENDAR_EVENTS_FILE,
|
||||
AUDIT_LOG_FILE,
|
||||
WEBHOOKS_FILE,
|
||||
STUDY_SESSIONS_FILE,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
DEFAULT_REPLY_TEMPLATES,
|
||||
@@ -496,6 +497,33 @@ export function loadWebhooksFromDisk() {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// ── Study sessions ──────────────────────────────────────────────────────
|
||||
|
||||
export function queueStudySessionsWrite() {
|
||||
state.studySessionsWritePromise = state.studySessionsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
const sessions = Array.from(state.studySessions.entries()).map(([token, session]) => ({ token, ...session }))
|
||||
await writeFile(STUDY_SESSIONS_FILE, JSON.stringify({ sessions }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => { console.error('[study-sessions] write failed:', err) })
|
||||
}
|
||||
|
||||
export function loadStudySessionsFromDisk() {
|
||||
return readFile(STUDY_SESSIONS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed?.sessions)) return
|
||||
const now = Date.now()
|
||||
for (const entry of parsed.sessions) {
|
||||
if (!entry?.token || !entry?.userId || typeof entry.expiresAt !== 'number') continue
|
||||
if (entry.expiresAt <= now) continue
|
||||
state.studySessions.set(entry.token, { userId: entry.userId, expiresAt: entry.expiresAt })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export async function fireWebhooks(eventType, payload) {
|
||||
const targets = state.webhooks.filter(w =>
|
||||
Array.isArray(w.events) && (w.events.includes(eventType) || w.events.includes('*'))
|
||||
@@ -1435,6 +1463,18 @@ export function sanitizeStudyUsers(value) {
|
||||
requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null,
|
||||
}
|
||||
: null
|
||||
const pendingEmailVerification = item?.pendingEmailVerification && typeof item.pendingEmailVerification === 'object' && !Array.isArray(item.pendingEmailVerification)
|
||||
? {
|
||||
tokenHash: typeof item.pendingEmailVerification.tokenHash === 'string' ? item.pendingEmailVerification.tokenHash.trim() : '',
|
||||
expiresAt: typeof item.pendingEmailVerification.expiresAt === 'number' ? item.pendingEmailVerification.expiresAt : 0,
|
||||
}
|
||||
: null
|
||||
const pendingPasswordReset = item?.pendingPasswordReset && typeof item.pendingPasswordReset === 'object' && !Array.isArray(item.pendingPasswordReset)
|
||||
? {
|
||||
tokenHash: typeof item.pendingPasswordReset.tokenHash === 'string' ? item.pendingPasswordReset.tokenHash.trim() : '',
|
||||
expiresAt: typeof item.pendingPasswordReset.expiresAt === 'number' ? item.pendingPasswordReset.expiresAt : 0,
|
||||
}
|
||||
: null
|
||||
seen.add(username)
|
||||
out.push({
|
||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
@@ -1450,6 +1490,9 @@ export function sanitizeStudyUsers(value) {
|
||||
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||||
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
|
||||
pendingEmailChange,
|
||||
emailVerified: item?.emailVerified === true,
|
||||
pendingEmailVerification,
|
||||
pendingPasswordReset,
|
||||
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
|
||||
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
|
||||
totpVerified: item?.totpVerified === true,
|
||||
|
||||
+100
@@ -485,6 +485,106 @@ export async function sendStudyWelcomeEmail(email, displayName) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendStudyVerifyEmail(email, displayName, verifyUrl) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const subject = cfg.emailVerifySubject?.trim() || 'Verify your email address'
|
||||
const bodyText = cfg.emailVerifyBody?.trim() || 'Please confirm this is your email address so we can keep your study account secure and deliver reminders you opt into.'
|
||||
const ctaLabel = cfg.emailVerifyCtaLabel?.trim() || 'Verify Email Address'
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||||
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
|
||||
)
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
||||
to: [email],
|
||||
subject,
|
||||
text: `Hi ${namePart},\n\n${bodyText}\n\n${verifyUrl}\n\nVerse by Verse with Nate`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Verify Your Email',
|
||||
eyebrow: 'Study Account',
|
||||
bodyHtml,
|
||||
ctaLabel,
|
||||
ctaUrl: verifyUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't create this account, you can ignore this message.</p>`,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-verify-email] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-verify-email] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendStudyPasswordResetEmail(email, displayName, resetUrl) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const subject = cfg.passwordResetSubject?.trim() || 'Reset your password'
|
||||
const bodyText = cfg.passwordResetBody?.trim() || 'We received a request to reset the password on your study account. Click below to choose a new password. This link expires in 1 hour.'
|
||||
const ctaLabel = cfg.passwordResetCtaLabel?.trim() || 'Reset Password'
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||||
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
|
||||
)
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
||||
to: [email],
|
||||
subject,
|
||||
text: `Hi ${namePart},\n\n${bodyText}\n\n${resetUrl}\n\nIf you didn't request this, you can safely ignore this email — your password won't change.\n\nVerse by Verse with Nate`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Reset Your Password',
|
||||
eyebrow: 'Account Security',
|
||||
bodyHtml,
|
||||
ctaLabel,
|
||||
ctaUrl: resetUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't request this, you can safely ignore this email — your password won't change.</p>`,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-password-reset] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-password-reset] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendStudyPasswordChangedEmail(email, displayName) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
|
||||
const subject = cfg.passwordChangedSubject?.trim() || 'Your password was changed'
|
||||
const bodyText = cfg.passwordChangedBody?.trim() || 'This confirms the password on your study account was just changed. All other active sessions have been signed out. If this wasn’t you, please contact us right away.'
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||||
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
|
||||
)
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
||||
to: [email],
|
||||
subject,
|
||||
text: `Hi ${namePart},\n\n${bodyText}\n\nManage your account: ${accountUrl}\n\nVerse by Verse with Nate`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Password Changed',
|
||||
eyebrow: 'Account Security',
|
||||
bodyHtml,
|
||||
ctaLabel: 'Manage Account',
|
||||
ctaUrl: accountUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-password-changed] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-password-changed] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailOtp(email, code) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
normalizeStudySlug,
|
||||
isEnrollableStudySlug,
|
||||
makeUnsubUrl,
|
||||
invalidateAllStudySessions,
|
||||
} from '../study-helpers.js'
|
||||
import { sendStudyReengagementEmail } from '../email.js'
|
||||
import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js'
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
|
||||
@@ -237,9 +238,9 @@ export function register(app) {
|
||||
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
|
||||
}
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
invalidateAllStudySessions(user.id)
|
||||
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
|
||||
.catch(err => console.error('[admin] password-changed email error:', err))
|
||||
}
|
||||
|
||||
if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
|
||||
@@ -265,9 +266,7 @@ export function register(app) {
|
||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
invalidateAllStudySessions(user.id)
|
||||
|
||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||
queueStudyUsersWrite()
|
||||
|
||||
@@ -3,8 +3,8 @@ import rateLimit from 'express-rate-limit'
|
||||
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js'
|
||||
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js'
|
||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl, parseCookies } from '../helpers.js'
|
||||
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTE_LENGTH, STUDY_SESSION_COOKIE } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueStudyUsersWrite,
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
normalizeStudySlug,
|
||||
getStudyCatalog,
|
||||
clearStudySessionCookie,
|
||||
invalidateAllStudySessions,
|
||||
} from '../study-helpers.js'
|
||||
import {
|
||||
sendStudyAccountDeletedEmail,
|
||||
sendStudyPasswordChangedEmail,
|
||||
syncContactToResend,
|
||||
buildBrandedEmailHtml,
|
||||
getCanonicalBaseUrl,
|
||||
@@ -187,6 +189,11 @@ export function register(app) {
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
invalidateAllStudySessions(user.id, parseCookies(req.headers.cookie)[STUDY_SESSION_COOKIE])
|
||||
|
||||
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
|
||||
.catch(err => console.error('[study-account] password-changed email error:', err))
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -213,6 +220,16 @@ export function register(app) {
|
||||
}
|
||||
})
|
||||
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const certificates = state.studyCertificates
|
||||
.filter(c => c.userId === user.id)
|
||||
.map(c => ({
|
||||
studySlug: c.studySlug,
|
||||
studyTitle: c.studyTitle,
|
||||
issuedAt: c.issuedAt,
|
||||
url: buildAbsoluteUrl(baseUrl, `/study/certificate/${c.token}`),
|
||||
}))
|
||||
|
||||
res.json({
|
||||
profile: {
|
||||
username: user.username,
|
||||
@@ -222,9 +239,12 @@ export function register(app) {
|
||||
reminderFrequency: user.reminderFrequency ?? 'immediate',
|
||||
remindersPausedUntil: user.remindersPausedUntil ?? null,
|
||||
avatarUrl: getStudyAvatarUrl(user),
|
||||
emailVerified: user.emailVerified === true,
|
||||
},
|
||||
stats: {
|
||||
noteCount: Object.keys(notes).length,
|
||||
noteLimit: MAX_STUDY_NOTES_PER_USER,
|
||||
noteCharLimit: MAX_STUDY_NOTE_LENGTH,
|
||||
memberSince: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
currentStreak: user.currentStreak ?? 0,
|
||||
@@ -232,6 +252,7 @@ export function register(app) {
|
||||
lastStudiedDate: user.lastStudiedDate ?? null,
|
||||
},
|
||||
studies,
|
||||
certificates,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -478,9 +499,7 @@ export function register(app) {
|
||||
const deletedEmail = user.username
|
||||
const deletedDisplayName = user.displayName || user.username
|
||||
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
invalidateAllStudySessions(user.id)
|
||||
|
||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||
queueStudyUsersWrite()
|
||||
|
||||
+158
-3
@@ -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 ?? {}
|
||||
|
||||
@@ -116,6 +116,7 @@ export const state = {
|
||||
studyProgressWriteQueues: new Map(), // userId -> Promise
|
||||
|
||||
studySessions: new Map(),
|
||||
studySessionsWritePromise: Promise.resolve(),
|
||||
studyTotpPendingTokens: new Map(),
|
||||
emailOtpStore: new Map(),
|
||||
|
||||
|
||||
+22
-1
@@ -22,7 +22,7 @@ import {
|
||||
DOWNLOAD_TOKEN_TTL_MS,
|
||||
} from './config.js'
|
||||
import { state } from './state.js'
|
||||
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js'
|
||||
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js'
|
||||
|
||||
// ── Username / slug normalizers ────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +64,14 @@ export function hashEmailChangeToken(token) {
|
||||
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
export function hashPasswordResetToken(token) {
|
||||
return createHash('sha256').update(`study-password-reset:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
export function hashEmailVerifyToken(token) {
|
||||
return createHash('sha256').update(`study-email-verify:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
||||
|
||||
export function getStudyCatalog() {
|
||||
@@ -144,9 +152,22 @@ export function getStudyAvatarUrl(subject) {
|
||||
export function createStudySession(userId) {
|
||||
const token = randomUUID()
|
||||
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
||||
queueStudySessionsWrite()
|
||||
return token
|
||||
}
|
||||
|
||||
// Revokes every active session for a user (password change/reset, account deletion, "sign out everywhere").
|
||||
export function invalidateAllStudySessions(userId, exceptToken = null) {
|
||||
let changed = false
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === userId && token !== exceptToken) {
|
||||
state.studySessions.delete(token)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) queueStudySessionsWrite()
|
||||
}
|
||||
|
||||
export function setStudySessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
|
||||
Reference in new issue
Block a user