cool stuff
This commit is contained in:
@@ -656,6 +656,50 @@ let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
||||
let lastCachePurgeStatus = { ok: true, at: null, error: null }
|
||||
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
||||
const studySessions = new Map()
|
||||
// Short-lived tokens for 2FA second step: token → { userId, expiresAt }
|
||||
const studyTotpPendingTokens = new Map()
|
||||
const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
// In-memory email OTP store: userId → { codeHash, expiresAt, attempts }
|
||||
const emailOtpStore = new Map()
|
||||
const EMAIL_OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
const EMAIL_OTP_MAX_ATTEMPTS = 5
|
||||
|
||||
function generateEmailOtp() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000))
|
||||
}
|
||||
|
||||
function hashEmailOtp(code) {
|
||||
return createHash('sha256').update(String(code).trim()).digest('hex')
|
||||
}
|
||||
|
||||
function storeEmailOtp(userId, code) {
|
||||
emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 })
|
||||
}
|
||||
|
||||
function verifyEmailOtp(userId, code) {
|
||||
const entry = emailOtpStore.get(userId)
|
||||
if (!entry) return 'no-code'
|
||||
if (Date.now() > entry.expiresAt) { emailOtpStore.delete(userId); return 'expired' }
|
||||
entry.attempts += 1
|
||||
if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { emailOtpStore.delete(userId); return 'too-many' }
|
||||
if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong'
|
||||
emailOtpStore.delete(userId)
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function createStudyTotpPendingToken(userId) {
|
||||
const token = randomUUID()
|
||||
studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
function consumeStudyTotpPendingToken(token) {
|
||||
const entry = studyTotpPendingTokens.get(token)
|
||||
if (!entry) return null
|
||||
studyTotpPendingTokens.delete(token)
|
||||
if (Date.now() > entry.expiresAt) return null
|
||||
return entry.userId
|
||||
}
|
||||
|
||||
function sanitizeReplyTemplates(value) {
|
||||
if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
|
||||
@@ -2470,6 +2514,34 @@ async function sendStudyWelcomeEmail(email, displayName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEmailOtp(email, code) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = cachedSiteContent ?? {}
|
||||
const subject = cfg.twoFaOtpEmailSubject?.trim() || 'Your sign-in code — Verse by Verse with Nate'
|
||||
const bodyText = cfg.twoFaOtpEmailBody?.trim() || 'Your two-factor sign-in code is below. Enter it to complete sign-in.'
|
||||
const expiryText = cfg.twoFaOtpEmailExpiry?.trim() || 'This code expires in 10 minutes. If you did not request this, you can ignore this message.'
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
||||
to: [email],
|
||||
subject,
|
||||
text: `${bodyText}\n\n${code}\n\n${expiryText}\n\nVerse by Verse with Nate`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Your Sign-In Code',
|
||||
eyebrow: 'Account Security',
|
||||
bodyHtml:
|
||||
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
|
||||
`<p style="margin:0 0 16px;font-size:2.5rem;font-weight:700;letter-spacing:0.35em;color:#c9a84c;font-family:monospace;">${code}</p>` +
|
||||
`<p style="margin:0 0 16px;font-size:0.9rem;color:#7a7060;">${escapeHtml(expiryText)}</p>`,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[email-otp] send error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendStudyAccountDeletedEmail(email, displayName) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
@@ -3004,6 +3076,9 @@ app.get('/api/study-auth/status', (req, res) => {
|
||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||||
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3088,6 +3163,22 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
// If 2FA is enabled, pause here and return a pending token
|
||||
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
|
||||
queueStudyUsersWrite()
|
||||
@@ -3105,6 +3196,182 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── Study 2FA (TOTP) ─────────────────────────────────────────────────────────
|
||||
|
||||
// Step 2 of login: verify TOTP code after password accepted
|
||||
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 = studyUsers.find(u => u.id === userId)
|
||||
if (!user || !user.totpSecret || !user.totpVerified) {
|
||||
res.status(400).json({ message: '2FA is not configured for this account.' })
|
||||
return
|
||||
}
|
||||
|
||||
const codeStr = typeof code === 'string' ? code.replace(/\s/g, '') : ''
|
||||
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
|
||||
|
||||
function completeLogin(extra = {}) {
|
||||
user.lastLoginAt = new Date().toISOString()
|
||||
user.updatedAt = user.lastLoginAt
|
||||
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 ?? [] })
|
||||
}
|
||||
|
||||
// Email OTP method
|
||||
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
|
||||
}
|
||||
|
||||
// App (TOTP) method
|
||||
if (verifyTotpCode(user.totpSecret, codeStr)) {
|
||||
completeLogin()
|
||||
return
|
||||
}
|
||||
|
||||
// Try recovery code
|
||||
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.' })
|
||||
})
|
||||
|
||||
// Begin 2FA setup: generate secret + QR 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)
|
||||
// Store unverified secret temporarily on the user record
|
||||
user.totpSecretPending = secret
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ qrDataUrl, secret })
|
||||
})
|
||||
|
||||
// Confirm 2FA setup: verify first code then activate
|
||||
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 })
|
||||
})
|
||||
|
||||
// Email 2FA setup — step 1: send verification code
|
||||
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 })
|
||||
})
|
||||
|
||||
// Email 2FA setup — step 2: confirm code and activate
|
||||
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 }
|
||||
// Clear any app 2FA and switch to email
|
||||
user.twoFaMethod = 'email'
|
||||
user.totpSecret = null
|
||||
user.totpVerified = false
|
||||
user.totpRecoveryCodes = []
|
||||
delete user.totpSecretPending
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Resend email OTP during login (uses pending token to identify user)
|
||||
app.post('/api/study-auth/email-otp-resend', studyAuthRateLimiter, async (req, res) => {
|
||||
const { pendingToken } = req.body ?? {}
|
||||
// Peek at the pending token without consuming it
|
||||
const entry = studyTotpPendingTokens.get(pendingToken)
|
||||
if (!entry || Date.now() > entry.expiresAt) { res.status(401).json({ message: 'Session expired. Please sign in again.' }); return }
|
||||
const user = 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 })
|
||||
})
|
||||
|
||||
// Disable 2FA (requires current password confirmation)
|
||||
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 })
|
||||
})
|
||||
|
||||
// Regenerate recovery codes (requires active session)
|
||||
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.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
res.json({
|
||||
|
||||
Reference in New Issue
Block a user