Split server/index.js into one file per area
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
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>
This commit is contained in:
1 parent
31ac6b9179
commit
9893883b19
12 files changed
+1337
-1040
No files matched your search
@@ -0,0 +1,324 @@
|
||||
// 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.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user