Files
study-bible-project/server/auth.js
nmemmertandClaude Sonnet 5.5 f274bc9c8a
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Harden sync and security; add IQ Bible, templates, password reset, backups
Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:13:36 -04:00

102 lines
3.6 KiB
JavaScript

import bcrypt from 'bcryptjs';
import { authenticator } from 'otplib';
import { randomBytes } from 'crypto';
import { getUserById } from './db.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// The single admin account for this deployment. Override via env var if you
// redeploy this app for someone else — don't hardcode your own email into a
// fork without changing this.
const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || 'nmemmert@duck.com').toLowerCase();
export function isValidEmail(email) {
return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email);
}
export function isValidPassword(password) {
return typeof password === 'string' && password.length >= 8 && password.length <= 200;
}
export function hashPassword(password) {
return bcrypt.hash(password, 10);
}
export function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
/** Blocks the request unless a logged-in session is present. */
export function requireAuth(req, res, next) {
if (!req.session?.userId) {
return res.status(401).json({ error: 'Not signed in.' });
}
next();
}
export function isAdminEmail(email) {
return typeof email === 'string' && email.toLowerCase() === ADMIN_EMAIL;
}
/** Blocks the request unless the signed-in account is the designated admin. */
export function requireAdmin(req, res, next) {
const user = req.session?.userId ? getUserById(req.session.userId) : null;
if (!user || !isAdminEmail(user.email)) {
return res.status(403).json({ error: 'Admin access only.' });
}
next();
}
// ---------------------------------------------------------------------------
// Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app)
// ---------------------------------------------------------------------------
export function generateTotpSecret() {
return authenticator.generateSecret();
}
export function totpKeyUri(email, secret) {
return authenticator.keyuri(email, 'Bible Study Project', secret);
}
export function verifyTotpToken(token, secret) {
if (typeof token !== 'string' || !/^\d{6}$/.test(token)) return false;
try {
return authenticator.verify({ token, secret });
} catch {
return false;
}
}
/** Returns { codes: string[] } plaintext codes to show the user once, for hashBackupCodes(). */
export function generateBackupCodes(count = 8) {
return Array.from({ length: count }, () => randomBytes(5).toString('hex'));
}
export async function hashBackupCodes(codes) {
return Promise.all(codes.map((code) => bcrypt.hash(code, 10)));
}
/** Checks a submitted backup code against stored hashes; returns the remaining hashes if it matched, else null. */
export async function consumeBackupCode(submitted, hashes) {
if (typeof submitted !== 'string' || !Array.isArray(hashes)) return null;
const normalized = submitted.trim().toLowerCase();
for (let i = 0; i < hashes.length; i++) {
if (await bcrypt.compare(normalized, hashes[i])) {
return [...hashes.slice(0, i), ...hashes.slice(i + 1)];
}
}
return null;
}
/** A random temporary password for admin-assisted resets — shown once, relayed to the user out-of-band. */
export function generateTemporaryPassword() {
return randomBytes(6).toString('hex');
}
// A one-time password-reset code: 8 characters from an alphabet without look-alikes (no 0/O, 1/I/L).
const RESET_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
export function generateResetCode() {
return Array.from(randomBytes(8), (b) => RESET_ALPHABET[b % RESET_ALPHABET.length]).join('');
}
export const normalizeResetCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');