Files
study-bible-project/server/devices.js
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

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

186 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Passkeys (WebAuthn sign-in with Face ID, a fingerprint or a security key) and the list of devices signed in.
// A passkey is a second factor in itself (the device checks it is really you), so signing in with one skips the
// authenticator-code step. Passwords and 2FA keep working as before.
import { createHash } from 'crypto';
import {
generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {
addPasskey, deletePasskey, destroyAllSessionsForUser, destroySession, getPasskey, getUserById, listPasskeys,
listUserSessions, renamePasskey, setLoginAlerts, touchPasskey,
} from './db.js';
import { logEvent } from './health.js';
import { sendNtfy } from './reminders.js';
const MONTH = 30 * 24 * 60 * 60 * 1000;
const APP_NAME = 'Bible Study';
// "Chrome on macOS" from a User-Agent string; good enough to recognise your own devices.
export function describeDevice(ua = '') {
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null;
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : null;
if (browser && os) return `${browser} on ${os}`;
return browser ?? os ?? 'Unknown device';
}
export const sessionKey = (sid) => createHash('sha256').update(sid).digest('hex').slice(0, 16);
// Where the browser says it is, for checking passkeys: APP_URL when set, else the address this request came to.
export function relyingParty(req, env = process.env) {
if (env.APP_URL) {
try { const u = new URL(env.APP_URL); return { rpID: u.hostname, origin: u.origin }; } catch { /* fall through */ }
}
return { rpID: req.hostname, origin: `${req.protocol}://${req.get('host')}` };
}
/** Called right after a session gets its userId: remembers the device and (if asked) sends a sign-in alert. */
export function noteSignIn(req, user, method) {
const label = describeDevice(req.get('user-agent') ?? '');
req.session.device = { label, ip: req.ip, at: Date.now(), method };
if (user.loginAlerts && user.ntfyTopic) {
const how = { password: 'password', passkey: 'passkey', mfa: 'password and authenticator code', register: 'a new account' }[method] ?? method;
sendNtfy(user, { title: 'New sign-in', body: `${label} signed in to your Bible Study account with ${how}. If that wasn’t you, change your password and sign out other devices.`, tags: 'warning' })
.catch((err) => { console.error('Sign-in alert failed:', err.message); logEvent('notify', `Sign-in alert: ${err.message}`, { userId: user.id }); });
}
}
const publicPasskey = (p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt });
export function registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany }) {
const fail = (res, status, error) => res.status(status).json({ error });
// ---- Signing in with a passkey (no email needed: the passkey says who you are) ----
app.post('/api/auth/passkey/options', async (req, res) => {
try {
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(tooMany);
const { rpID } = relyingParty(req);
const options = await generateAuthenticationOptions({ rpID, userVerification: 'required' });
req.session.passkeyChallenge = options.challenge;
res.json(options);
} catch (err) {
console.error('POST /api/auth/passkey/options error:', err);
fail(res, 500, 'Could not start passkey sign-in.');
}
});
app.post('/api/auth/passkey/verify', async (req, res) => {
const keys = [`ip:${req.ip}`];
try {
if (isLimited(...keys)) return res.status(429).json(tooMany);
const challenge = req.session?.passkeyChallenge;
const response = req.body;
const passkey = typeof response?.id === 'string' ? getPasskey(response.id) : null;
const failed = () => { recordFailure(...keys); logEvent('signin', `Passkey sign-in failed from ${req.ip}`); return fail(res, 401, 'That passkey didn’t work. Try again, or use your password.'); };
if (!challenge || !passkey) return failed();
const { rpID, origin } = relyingParty(req);
let result;
try {
result = await verifyAuthenticationResponse({
response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports },
});
} catch { return failed(); }
if (!result.verified) return failed();
const user = getUserById(passkey.userId);
if (!user) return failed();
touchPasskey(passkey.id, result.authenticationInfo.newCounter);
clearFailures(...keys);
req.session.regenerate((err) => {
if (err) return fail(res, 500, 'Could not create session.');
req.session.userId = user.id;
noteSignIn(req, user, 'passkey');
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/passkey/verify error:', err);
fail(res, 500, 'Could not check that passkey.');
}
});
// ---- Managing passkeys (signed in) ----
app.get('/api/passkeys', requireAuth, (req, res) => res.json(listPasskeys(req.session.userId).map(publicPasskey)));
app.post('/api/passkeys/options', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
const { rpID } = relyingParty(req);
const options = await generateRegistrationOptions({
rpName: APP_NAME, rpID, userName: user.email, userID: new TextEncoder().encode(user.id), attestationType: 'none',
excludeCredentials: listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
});
req.session.passkeyChallenge = options.challenge;
res.json(options);
} catch (err) {
console.error('POST /api/passkeys/options error:', err);
fail(res, 500, 'Could not start adding a passkey.');
}
});
app.post('/api/passkeys', requireAuth, async (req, res) => {
try {
const challenge = req.session.passkeyChallenge;
if (!challenge) return fail(res, 400, 'Start again: that request expired.');
const { rpID, origin } = relyingParty(req);
let result;
try {
result = await verifyRegistrationResponse({ response: req.body?.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
} catch (err) { return fail(res, 400, `That passkey couldn’t be added (${err.message}).`); }
if (!result.verified) return fail(res, 400, 'That passkey couldn’t be added.');
delete req.session.passkeyChallenge;
const { credential } = result.registrationInfo;
if (getPasskey(credential.id)) return fail(res, 409, 'That passkey is already registered.');
const name = String(req.body?.name ?? '').trim().slice(0, 60) || describeDevice(req.get('user-agent') ?? '');
addPasskey({ id: credential.id, userId: req.session.userId, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, transports: credential.transports, name });
res.json(listPasskeys(req.session.userId).map(publicPasskey));
} catch (err) {
console.error('POST /api/passkeys error:', err);
fail(res, 500, 'Could not save that passkey.');
}
});
app.patch('/api/passkeys/:id', requireAuth, (req, res) => {
const name = String(req.body?.name ?? '').trim().slice(0, 60);
if (!name) return fail(res, 400, 'Give it a name.');
if (!renamePasskey(req.params.id, req.session.userId, name)) return fail(res, 404, 'No such passkey.');
res.json(listPasskeys(req.session.userId).map(publicPasskey));
});
app.delete('/api/passkeys/:id', requireAuth, (req, res) => {
if (!deletePasskey(req.params.id, req.session.userId)) return fail(res, 404, 'No such passkey.');
res.json(listPasskeys(req.session.userId).map(publicPasskey));
});
// ---- Where you are signed in ----
app.get('/api/sessions', requireAuth, (req, res) => {
const rows = listUserSessions(req.session.userId).map(({ sid, sess, expires }) => ({
id: sessionKey(sid), current: sid === req.sessionID,
label: sess.device?.label ?? 'Signed in before this list existed', ip: sess.device?.ip ?? null,
method: sess.device?.method ?? null, signedInAt: sess.device?.at ?? null,
lastSeen: expires - MONTH, // sessions are renewed on each request, so this is when it was last used
})).sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen);
res.json(rows);
});
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
const found = listUserSessions(req.session.userId).find((s) => sessionKey(s.sid) === req.params.id);
if (!found) return fail(res, 404, 'That device is already signed out.');
if (found.sid === req.sessionID) return fail(res, 400, 'Use Sign out for this device.');
destroySession(found.sid);
res.json({ ok: true });
});
app.post('/api/sessions/sign-out-others', requireAuth, (req, res) => {
destroyAllSessionsForUser(req.session.userId, req.sessionID);
res.json({ ok: true });
});
app.put('/api/auth/login-alerts', requireAuth, (req, res) => {
const user = getUserById(req.session.userId);
const enabled = !!req.body?.enabled;
if (enabled && !user.ntfyTopic) return fail(res, 400, 'Set up a notification topic first (under Notifications).');
setLoginAlerts(user.id, enabled);
res.json({ loginAlerts: enabled });
});
}