Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- 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>
186 lines
9.8 KiB
JavaScript
186 lines
9.8 KiB
JavaScript
// 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 });
|
||
});
|
||
}
|