Files
nmemmertandClaude Sonnet 5.5 9893883b19
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Split server/index.js into one file per area
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin);
index.js keeps setup only. A smoke test walks 90 API calls against the real
server and compares every status with the pre-split behaviour.

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

325 lines
15 KiB
JavaScript

// Accounts: sign up, sign in (password + two-factor), password reset, change password, two-factor setup.
import QRCode from 'qrcode';
import { randomUUID } from 'crypto';
import { adminDeleteUser, bumpPasswordResetAttempts, claimOrphanProjects, clearPasswordReset, countUsers, createUser, destroyAllSessionsForUser, disableTotp, enableTotp, findOpenInvite, getPasswordReset, getSetting, getUserByEmail, getUserById, setBackupCodeHashes, setPasswordReset, setUserPassword, useInvite } from '../db.js';
import { consumeBackupCode, generateBackupCodes, generateResetCode, generateTotpSecret, hashBackupCodes, hashPassword, isAdminEmail, isValidEmail, isValidPassword, normalizeResetCode, requireAuth, totpKeyUri, verifyPassword, verifyTotpToken } from '../auth.js';
import { sendNtfy } from '../reminders.js';
import { noteSignIn } from '../devices.js';
import { checkSignup, normalizeInviteCode } from '../signup.js';
import { logEvent } from '../health.js';
import { userPayload } from '../userPayload.js';
import { TOO_MANY, clearFailures, isLimited, recordFailure } from '../limiter.js';
export function registerAuthRoutes(app) {
// Compared against when an email isn't registered, so a login takes the same time either way.
const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK';
// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window.
const _usedTotp = new Map();
function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; }
function markTotpUsed(userId, token) {
_usedTotp.set(userId, token);
setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref();
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
app.post('/api/auth/register', async (req, res) => {
try {
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY);
recordFailure(`ip:${req.ip}`); // every registration attempt counts
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
const inviteCode = normalizeInviteCode(req.body?.invite);
const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) });
if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error });
if (!isValidEmail(email)) {
return res.status(400).json({ error: 'Enter a valid email address.' });
}
if (!isValidPassword(password)) {
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
}
if (getUserByEmail(email)) {
return res.status(409).json({ error: 'An account with that email already exists.' });
}
const passwordHash = await hashPassword(password);
const user = createUser({ id: randomUUID(), email, passwordHash });
if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) {
adminDeleteUser(user.id); // someone used the code between the check and now
return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' });
}
// The very first account inherits any projects created before multi-user support existed.
if (countUsers() === 1) {
claimOrphanProjects(user.id);
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id;
noteSignIn(req, user, 'register');
res.json({
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
});
});
} catch (err) {
console.error('POST /api/auth/register error:', err);
res.status(500).json({ error: 'Failed to register.' });
}
});
app.post('/api/auth/login', async (req, res) => {
try {
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
const keys = [`ip:${req.ip}`, `acct:${email}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
const user = getUserByEmail(email);
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
if (!valid) {
recordFailure(...keys);
logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`);
return res.status(401).json({ error: 'Incorrect email or password.' });
}
clearFailures(`acct:${email}`);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
if (user.totpEnabled) {
// Password is correct, but the session stays unauthenticated (no userId)
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
req.session.pendingUserId = user.id;
return res.json({ mfaRequired: true });
}
req.session.userId = user.id;
noteSignIn(req, user, 'password');
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/login error:', err);
res.status(500).json({ error: 'Failed to log in.' });
}
});
app.post('/api/auth/mfa/verify', async (req, res) => {
try {
const pendingUserId = req.session?.pendingUserId;
if (!pendingUserId) {
return res.status(400).json({ error: 'No sign-in in progress.' });
}
const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
const user = getUserById(pendingUserId);
if (!user || !user.totpEnabled) {
return res.status(400).json({ error: 'No sign-in in progress.' });
}
const token = req.body?.token;
const backupCode = req.body?.backupCode;
let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false;
if (ok) markTotpUsed(user.id, String(token));
if (!ok && backupCode) {
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
if (remaining) {
setBackupCodeHashes(user.id, remaining);
ok = true;
}
}
if (!ok) {
recordFailure(...keys);
logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id });
return res.status(401).json({ error: 'Invalid code.' });
}
clearFailures(`mfa:${pendingUserId}`);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id;
noteSignIn(req, user, 'mfa');
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/mfa/verify error:', err);
res.status(500).json({ error: 'Failed to verify code.' });
}
});
// ---------------------------------------------------------------------------
// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same
// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the
// same whether or not the account exists, so this can't be used to find out who has one.
// ---------------------------------------------------------------------------
const RESET_MINUTES = 15;
const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' };
app.post('/api/auth/forgot', async (req, res) => {
try {
const email = String(req.body?.email ?? '').trim().toLowerCase();
const keys = [`ip:${req.ip}`, `forgot:${email}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
recordFailure(...keys); // every request counts
const user = isValidEmail(email) ? getUserByEmail(email) : null;
if (user?.ntfyTopic) {
const code = generateResetCode();
setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000);
sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' })
.catch((err) => console.error('Password reset notification failed:', err.message));
}
res.json(RESET_REPLY);
} catch (err) {
console.error('POST /api/auth/forgot error:', err);
res.status(500).json({ error: 'Failed to start the reset.' });
}
});
app.post('/api/auth/reset', async (req, res) => {
try {
const email = String(req.body?.email ?? '').trim().toLowerCase();
const keys = [`ip:${req.ip}`, `reset:${email}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); };
const newPassword = String(req.body?.newPassword ?? '');
if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
const user = getUserByEmail(email);
const reset = user ? getPasswordReset(user.id) : null;
const code = normalizeResetCode(req.body?.code);
// Always spend one bcrypt compare, so timing doesn't show whether a code was issued
const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5;
if (reset && !codeOk) bumpPasswordResetAttempts(user.id);
if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.');
if (user.totpEnabled) {
const token = String(req.body?.token ?? '');
if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.');
markTotpUsed(user.id, token);
}
setUserPassword(user.id, await hashPassword(newPassword));
clearPasswordReset(user.id);
destroyAllSessionsForUser(user.id);
clearFailures(...keys);
res.json({ ok: true });
} catch (err) {
console.error('POST /api/auth/reset error:', err);
res.status(500).json({ error: 'Failed to reset the password.' });
}
});
// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code.
app.get('/api/auth/signup-mode', (_req, res) => {
res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') });
});
app.post('/api/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ ok: true });
});
});
app.get('/api/auth/me', (req, res) => {
const user = req.session?.userId ? getUserById(req.session.userId) : null;
if (!user) return res.status(401).json({ error: 'Not signed in.' });
res.json(userPayload(user));
});
// ---------------------------------------------------------------------------
// POST /api/auth/change-password — self-service password change (Account Settings)
// ---------------------------------------------------------------------------
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
const currentPassword = String(req.body?.currentPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
recordFailure(`pw:${user.id}`);
return res.status(401).json({ error: 'Current password is incorrect.' });
}
if (!isValidPassword(newPassword)) {
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
}
clearFailures(`pw:${user.id}`);
const passwordHash = await hashPassword(newPassword);
setUserPassword(user.id, passwordHash);
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
destroyAllSessionsForUser(user.id, req.sessionID);
res.json({ ok: true });
} catch (err) {
console.error('POST /api/auth/change-password error:', err);
res.status(500).json({ error: 'Failed to change password.' });
}
});
// ---------------------------------------------------------------------------
// Two-factor auth setup (requires an already-authenticated session)
// ---------------------------------------------------------------------------
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
try {
const user = getUserById(req.session.userId);
const secret = generateTotpSecret();
// Held only in the session until confirmed with a real code — never written
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
req.session.pendingTotpSecret = secret;
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
res.json({ secret, qrCodeDataUrl });
});
} catch (err) {
console.error('POST /api/auth/mfa/setup error:', err);
res.status(500).json({ error: 'Failed to start 2FA setup.' });
}
});
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
try {
const secret = req.session.pendingTotpSecret;
if (!secret) {
return res.status(400).json({ error: 'Start 2FA setup first.' });
}
if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY);
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
recordFailure(`mfaset:${req.session.userId}`);
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
}
const backupCodes = generateBackupCodes();
const backupCodeHashes = await hashBackupCodes(backupCodes);
enableTotp(req.session.userId, secret, backupCodeHashes);
delete req.session.pendingTotpSecret;
res.json({ backupCodes });
} catch (err) {
console.error('POST /api/auth/mfa/enable error:', err);
res.status(500).json({ error: 'Failed to enable 2FA.' });
}
});
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
if (!valid) {
recordFailure(`pw:${user.id}`);
return res.status(401).json({ error: 'Incorrect password.' });
}
disableTotp(user.id);
res.json({ ok: true });
} catch (err) {
console.error('POST /api/auth/mfa/disable error:', err);
res.status(500).json({ error: 'Failed to disable 2FA.' });
}
});
}