Audit log (admin Activity list) and passkey recovery codes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:53:41 -04:00
1 parent c8399ccaa7
commit 561b6181a0
8 files changed
+101 -14

No files matched your search

+5
View File
@@ -2,6 +2,7 @@
// sign them out everywhere, turn them off, or delete them and their data. Admin only (enforced by the caller). // sign them out everywhere, turn them off, or delete them and their data. Admin only (enforced by the caller).
const users = require('./users'); const users = require('./users');
const auth = require('./auth'); const auth = require('./auth');
const audit = require('./audit');
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
@@ -15,18 +16,22 @@ async function handle(req, res, me) {
if (!id && req.method === 'POST') { if (!id && req.method === 'POST') {
const b = JSON.parse(await readBody(req) || '{}'); const b = JSON.parse(await readBody(req) || '{}');
const u = users.create({ name: b.name, role: 'user', tabs: b.tabs }); const u = users.create({ name: b.name, role: 'user', tabs: b.tabs });
audit.log('person added', { who: me.name, detail: u.name }, req);
return json(res, 200, { user: row(u), code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); return json(res, 200, { user: row(u), code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES });
} }
const u = users.get(id); if (!u) return json(res, 404, { error: 'Person not found' }); const u = users.get(id); if (!u) return json(res, 404, { error: 'Person not found' });
if (action === 'invite' && req.method === 'POST') audit.log('invite created', { who: me.name, detail: u.name }, req);
if (action === 'invite' && req.method === 'POST') return json(res, 200, { code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); // for a new device or to recover access if (action === 'invite' && req.method === 'POST') return json(res, 200, { code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); // for a new device or to recover access
if (!action && req.method === 'PUT') { if (!action && req.method === 'PUT') {
const b = JSON.parse(await readBody(req) || '{}'); const b = JSON.parse(await readBody(req) || '{}');
if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' }); if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' });
audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req);
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere }); users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere });
return json(res, 200, { user: row(users.get(id)) }); return json(res, 200, { user: row(users.get(id)) });
} }
if (!action && req.method === 'DELETE') { if (!action && req.method === 'DELETE') {
if (u.id === me.id) return json(res, 400, { error: 'You cannot delete your own account' }); if (u.id === me.id) return json(res, 400, { error: 'You cannot delete your own account' });
audit.log('person removed', { who: me.name, detail: u.name }, req);
users.remove(id); auth.removeCredsOf(id); users.remove(id); auth.removeCredsOf(id);
return json(res, 200, { ok: true }); return json(res, 200, { ok: true });
} }
+26
View File
@@ -0,0 +1,26 @@
// Audit log: who signed in, who was added or changed, what was downloaded. One JSON line per event in /data/audit.log
// (rotated at about 1 MB, keeping one older file). Contains no passwords, codes or message text.
const fs = require('fs');
const path = require('path');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'audit.log'), MAX = 1024 * 1024;
const ipOf = req => String((req && (req.headers['x-forwarded-for'] || (req.socket && req.socket.remoteAddress))) || '').split(',')[0].trim();
/** log('sign-in', { who: 'Nate', ip, detail: 'iPhone' }) */
function log(event, o = {}, req) {
try {
const line = JSON.stringify({ t: Date.now(), event, who: o.who || '', detail: o.detail || '', ip: o.ip || ipOf(req) }) + '\n';
try { if (fs.statSync(FILE).size > MAX) fs.renameSync(FILE, FILE + '.1'); } catch {}
fs.appendFileSync(FILE, line, { mode: 0o600 });
} catch (e) { console.error('[audit] could not write:', e.message); }
}
/** Newest first. */
function recent(n = 200) {
const rows = [];
for (const f of [FILE, FILE + '.1']) {
let text = ''; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
for (const l of text.split('\n').reverse()) { if (!l) continue; try { rows.push(JSON.parse(l)); } catch {} if (rows.length >= n) return rows; }
}
return rows;
}
module.exports = { log, recent };
+32 -7
View File
@@ -10,6 +10,7 @@ const {
generateAuthenticationOptions, verifyAuthenticationResponse, generateAuthenticationOptions, verifyAuthenticationResponse,
} = require('@simplewebauthn/server'); } = require('@simplewebauthn/server');
const users = require('./users'); const users = require('./users');
const audit = require('./audit');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -106,6 +107,22 @@ function newInvite(uid) {
const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; }; const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; };
const inviteFor = c => { const k = normInvite(c), v = k && invites.get(k); return v && v.exp > Date.now() && users.get(v.uid) && !users.get(v.uid).disabled ? { code: k, uid: v.uid } : null; }; const inviteFor = c => { const k = normInvite(c), v = k && invites.get(k); return v && v.exp > Date.now() && users.get(v.uid) && !users.get(v.uid).disabled ? { code: k, uid: v.uid } : null; };
// Recovery codes: 8 one-time codes per person, made in Settings. Each can enrol a new passkey (like an invite) if every device is lost.
// Only keyed hashes are stored, so the file does not reveal them.
const recovery = readJson('recovery.json', {}); // uid -> [hash]
const saveRecovery = () => writeJson('recovery.json', recovery);
const recHash = code => mac('r' + normInvite(code));
function newRecoveryCodes(uid) {
const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789', codes = [];
for (let i = 0; i < 8; i++) { let c = ''; for (const b of crypto.randomBytes(8)) c += a[b % a.length]; codes.push(c.slice(0, 4) + '-' + c.slice(4)); }
recovery[uid] = codes.map(recHash); saveRecovery();
return codes;
}
const recoveryFor = c => { const k = normInvite(c); if (!k) return null; const h = recHash(k); const uid = Object.keys(recovery).find(u => recovery[u].includes(h)); const u = uid && users.get(uid); return u && !u.disabled ? { code: k, uid, recovery: true } : null; };
const useRecovery = c => { const h = recHash(c), uid = Object.keys(recovery).find(u => recovery[u].includes(h)); if (uid) { recovery[uid] = recovery[uid].filter(x => x !== h); saveRecovery(); } };
/** An invite or a recovery code, whichever this is. */
const claimFor = c => inviteFor(c) || recoveryFor(c);
const RATE_LIMIT = Math.max(1, parseInt(process.env.AUTH_RATE_LIMIT, 10) || 10); // attempts per minute per address; raised only by the tests const RATE_LIMIT = Math.max(1, parseInt(process.env.AUTH_RATE_LIMIT, 10) || 10); // attempts per minute per address; raised only by the tests
const hits = new Map(); // crude per-IP throttle on anything that checks a secret const hits = new Map(); // crude per-IP throttle on anything that checks a secret
const throttled = req => { const throttled = req => {
@@ -137,6 +154,7 @@ async function handle(req, res) {
const me = currentUser(req); const me = currentUser(req);
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: !!me }); if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: !!me });
if (p === '/api/auth/logout' && req.method === 'POST') { if (p === '/api/auth/logout' && req.method === 'POST') {
if (me) audit.log('sign-out', { who: me.name }, req);
addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
return send(res, 200, { ok: true }); return send(res, 200, { ok: true });
} }
@@ -148,7 +166,7 @@ async function handle(req, res) {
if (me) { kind = 'reg-authed'; uid = me.id; name = me.name; handle = me.handle; } if (me) { kind = 'reg-authed'; uid = me.id; name = me.name; handle = me.handle; }
else { else {
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
const inv = body.inviteCode && inviteFor(body.inviteCode); const inv = body.inviteCode && claimFor(body.inviteCode);
if (!users.all().length && equalStr(body.setupCode || '', SETUP_CODE)) { kind = 'reg-setup'; name = 'Admin'; handle = crypto.randomBytes(32).toString('base64url'); } if (!users.all().length && equalStr(body.setupCode || '', SETUP_CODE)) { kind = 'reg-setup'; name = 'Admin'; handle = crypto.randomBytes(32).toString('base64url'); }
else if (inv) { kind = 'reg-invite'; uid = inv.uid; const u = users.get(uid); name = u.name; handle = u.handle; } else if (inv) { kind = 'reg-invite'; uid = inv.uid; const u = users.get(uid); name = u.name; handle = u.handle; }
else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' }); else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' });
@@ -159,7 +177,7 @@ async function handle(req, res) {
excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })), excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
}); });
return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && inviteFor(body.inviteCode) ? inviteFor(body.inviteCode).code : undefined }), options }); return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && claimFor(body.inviteCode) ? claimFor(body.inviteCode).code : undefined }), options });
} }
if (p === '/api/auth/register/verify' && req.method === 'POST') { if (p === '/api/auth/register/verify' && req.method === 'POST') {
if (!me && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); if (!me && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
@@ -168,7 +186,7 @@ async function handle(req, res) {
const ex = rec.extra || {}; const ex = rec.extra || {};
const okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid const okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid
: rec.kind === 'reg-setup' ? !users.all().length : rec.kind === 'reg-setup' ? !users.all().length
: rec.kind === 'reg-invite' ? !!inviteFor(ex.code) && !!users.get(ex.uid) : false; : rec.kind === 'reg-invite' ? !!claimFor(ex.code) && !!users.get(ex.uid) : false;
if (!okKind) return send(res, 403, { error: 'Not allowed' }); if (!okKind) return send(res, 403, { error: 'Not allowed' });
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID }); const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID });
if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' }); if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' });
@@ -178,7 +196,9 @@ async function handle(req, res) {
store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [], store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [],
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id }); name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id });
save(); save();
if (rec.kind === 'reg-invite') invites.delete(ex.code); // single use if (rec.kind === 'reg-invite') { const wasRecovery = !inviteFor(ex.code); invites.delete(ex.code); useRecovery(ex.code); audit.log(wasRecovery ? 'recovery code used' : 'passkey added with invite', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req); } // single use
else if (rec.kind === 'reg-setup') audit.log('first admin created', { who: user.name }, req);
else audit.log('passkey added', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req);
if (!me) startSession(req, res, user); if (!me) startSession(req, res, user);
return send(res, 200, { ok: true }); return send(res, 200, { ok: true });
} }
@@ -192,27 +212,32 @@ async function handle(req, res) {
const rec = takeRecord(body.cid); const rec = takeRecord(body.cid);
const cred = store.creds.find(c => c.id === body.response?.id); const cred = store.creds.find(c => c.id === body.response?.id);
const user = cred && users.get(cred.uid); const user = cred && users.get(cred.uid);
if (!rec || rec.kind !== 'login' || !cred || !user) return send(res, 400, { error: 'Passkey not recognised' }); if (!rec || rec.kind !== 'login' || !cred || !user) { audit.log('sign-in failed', { detail: 'passkey not recognised' }, req); return send(res, 400, { error: 'Passkey not recognised' }); }
if (user.disabled) return send(res, 403, { error: 'This account has been turned off' }); if (user.disabled) return send(res, 403, { error: 'This account has been turned off' });
const v = await verifyAuthenticationResponse({ const v = await verifyAuthenticationResponse({
response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID, response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID,
credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports }, credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports },
}); });
if (!v.verified) return send(res, 401, { error: 'Sign-in failed' }); if (!v.verified) { audit.log('sign-in failed', { who: user.name, detail: 'verification failed' }, req); return send(res, 401, { error: 'Sign-in failed' }); }
cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save(); cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save();
startSession(req, res, user); startSession(req, res, user);
audit.log('sign-in', { who: user.name, detail: cred.name }, req);
return send(res, 200, { ok: true }); return send(res, 200, { ok: true });
} }
// Everything below needs a signed-in session. // Everything below needs a signed-in session.
if (!me) return send(res, 401, { error: 'Not signed in' }); if (!me) return send(res, 401, { error: 'Not signed in' });
if (p === '/api/auth/invite' && req.method === 'POST') { // an invite for one of my own devices if (p === '/api/auth/invite' && req.method === 'POST') { // an invite for one of my own devices
audit.log('invite created for a device', { who: me.name }, req);
return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES }); return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES });
} }
if (p === '/api/auth/recovery' && req.method === 'GET') return send(res, 200, { remaining: (recovery[me.id] || []).length });
if (p === '/api/auth/recovery' && req.method === 'POST') { audit.log('recovery codes created', { who: me.name }, req); return send(res, 200, { codes: newRecoveryCodes(me.id) }); }
if (p === '/api/auth/passkeys' && req.method === 'GET') { if (p === '/api/auth/passkeys' && req.method === 'GET') {
return send(res, 200, credsOf(me.id).map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null }))); return send(res, 200, credsOf(me.id).map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
} }
if (p === '/api/auth/passkeys' && req.method === 'DELETE') { if (p === '/api/auth/passkeys' && req.method === 'DELETE') {
const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' }); const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' });
audit.log('passkey removed', { who: me.name, detail: (mine.find(c => c.id === body.id) || {}).name }, req);
store.creds = store.creds.filter(c => c.id !== body.id); save(); store.creds = store.creds.filter(c => c.id !== body.id); save();
return send(res, 200, { ok: true, remaining: credsOf(me.id).length }); return send(res, 200, { ok: true, remaining: credsOf(me.id).length });
} }
@@ -221,6 +246,6 @@ async function handle(req, res) {
} }
const passkeyCount = uid => credsOf(uid).length; const passkeyCount = uid => credsOf(uid).length;
function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); } function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); delete recovery[uid]; saveRecovery(); }
module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf }; module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
+3 -2
View File
@@ -2,8 +2,9 @@
// Treat the file like a password: it contains the session secret and encrypted sign-in tokens. // Treat the file like a password: it contains the session secret and encrypted sign-in tokens.
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const path = require('path'); const path = require('path');
const audit = require('./audit');
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')); const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
function handle(req, res) { function handle(req, res, who) {
if (req.method !== 'GET') { res.writeHead(405); return res.end(); } if (req.method !== 'GET') { res.writeHead(405); return res.end(); }
const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz'; const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] }); const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
@@ -12,6 +13,6 @@ function handle(req, res) {
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' }); res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
tar.stdout.pipe(res); tar.stdout.pipe(res);
res.on('close', () => tar.kill()); res.on('close', () => tar.kill());
console.log('[backup] downloaded'); audit.log('backup downloaded', { who: who || '' }, req);
} }
module.exports = { handle }; module.exports = { handle };
+16
View File
@@ -1659,6 +1659,8 @@ function viewSettings() {
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''} <div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div> <h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div> <div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
<div id="sec-conn"><h2>Connections</h2><div class="list"> <div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div> <div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div> <div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
@@ -1740,6 +1742,20 @@ function viewSettings() {
$('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => { $('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => {
try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); } try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); }
}); });
jfetch('/api/auth/recovery').then(r => { $('#rcn').textContent = r.remaining ? r.remaining + ' recovery code' + (r.remaining === 1 ? '' : 's') + ' left' : 'No recovery codes yet'; }).catch(() => { $('#rcn').textContent = 'Recovery codes'; });
$('#rcgen').onclick = () => confirmSheet('Make new recovery codes?', 'Any codes you made before stop working.', 'Make codes', async () => {
try {
const { codes } = await jfetch('/api/auth/recovery', { method: 'POST', body: '{}' });
const el = openSheet('Your recovery codes', 'Each works once. They are shown only now: save them before closing.', `<div class="list" style="background:var(--panel2);border:0">${codes.map(c => `<div class="row"><span class="grow"><b style="font-family:ui-monospace,Menlo,monospace;letter-spacing:.08em">${esc(c)}</b></span></div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="rcc" style="flex:1;justify-content:center">Copy</button><button class="btn" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = () => { closeSheet(); viewSettings(); };
el.querySelector('#rcc').onclick = async () => { try { await navigator.clipboard.writeText(codes.join('\n')); toast('Copied'); } catch { toast('Could not copy; write them down'); } };
} catch (e) { toast(e.message); }
});
if ($('#audit')) jfetch('/api/audit').then(({ events }) => {
const el = $('#audit'); if (!el) return;
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
$('#invpk').onclick = async () => { $('#invpk').onclick = async () => {
try { try {
const { code, minutes } = await authPost('/api/auth/invite'); const { code, minutes } = await authPost('/api/auth/invite');
+3 -3
View File
@@ -14,11 +14,11 @@ async function init() {
$('#sub').textContent = 'Sign in with your passkey'; $('#sub').textContent = 'Sign in with your passkey';
$('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>'; $('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>';
$('#go').onclick = signIn; $('#go').onclick = signIn;
$('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device? Use an invite code</a></p>'); $('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device or lost your devices? Use an invite or recovery code</a></p>');
$('#inv').onclick = e => { $('#inv').onclick = e => {
e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device'; e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device';
$('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>.</p> $('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>. Lost every device? Use one of the <b>recovery codes</b> you made in Settings.</p>
<label for="ic">Invite code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required> <label for="ic">Invite or recovery code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required>
<label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off"> <label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off">
<div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`; <div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`;
$('#f').onsubmit = async ev => { $('#f').onsubmit = async ev => {
+3 -2
View File
@@ -502,7 +502,7 @@ async function handleRequest(req, res) {
return users.runAs(user, async () => { return users.runAs(user, async () => {
const rule = ACCESS.find(([re]) => re.test(pathname)); const rule = ACCESS.find(([re]) => re.test(pathname));
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res); if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
if ((pathname === '/api/system' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); if ((pathname === '/api/system' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); } if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user); if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
if (req.url === '/api/config') { // only reveal the addresses of things this person may use if (req.url === '/api/config') { // only reveal the addresses of things this person may use
@@ -512,8 +512,9 @@ async function handleRequest(req, res) {
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
} }
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res) : forbidden(res, 'Only an admin can do this'); if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
if (pathname === '/api/ddg') return ddgAnswer(req, res); if (pathname === '/api/ddg') return ddgAnswer(req, res);
if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); }
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
if (pathname === '/api/settings') return settings.handle(req, res); if (pathname === '/api/settings') return settings.handle(req, res);
if (pathname === '/api/search') return search.handle(req, res); if (pathname === '/api/search') return search.handle(req, res);
+13
View File
@@ -80,6 +80,19 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
r = await admin.call('/api/users', { name: ' ', tabs: [] }); ok('a name is required', r.status === 400, r); r = await admin.call('/api/users', { name: ' ', tabs: [] }); ok('a name is required', r.status === 400, r);
r = await register(sam, dS, { inviteCode: samCode }, 'Sam phone', base); ok('Sam enrols with the code and is signed in as Sam', r.status === 200 && sam.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, sam.cookie.slice(0, 24)]); r = await register(sam, dS, { inviteCode: samCode }, 'Sam phone', base); ok('Sam enrols with the code and is signed in as Sam', r.status === 200 && sam.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, sam.cookie.slice(0, 24)]);
r = await register(client(base), device(), { inviteCode: samCode }, 'again', base); ok('the code works only once', r.status === 403, r); r = await register(client(base), device(), { inviteCode: samCode }, 'again', base); ok('the code works only once', r.status === 403, r);
// ---- recovery codes and the audit log
r = await sam.call('/api/auth/recovery'); ok('a person starts with no recovery codes', r.status === 200 && r.json.remaining === 0, r.json);
r = await sam.call('/api/auth/recovery', {}); const recCodes = r.json.codes; ok('making recovery codes gives 8 one-time codes', r.status === 200 && recCodes.length === 8 && recCodes.every(c => /^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(c)), r.json);
ok('and they are stored only as hashes', !fs.readFileSync(path.join(dir, 'recovery.json'), 'utf8').includes(recCodes[0]), 'plain code found on disk');
const lost = client(base);
r = await register(lost, device(), { inviteCode: recCodes[0] }, 'Sam new phone', base); ok('a recovery code enrols a new passkey and signs Sam in', r.status === 200 && lost.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, lost.cookie.slice(0, 24)]);
r = await register(client(base), device(), { inviteCode: recCodes[0] }, 'again', base); ok('the same recovery code cannot be used twice', r.status === 403, r.status);
{ const pk = (await lost.call('/api/auth/passkeys')).json.find(p => p.name === 'Sam new phone'); await lost.call('/api/auth/passkeys', { id: pk.id }, 'DELETE'); } // tidy up so later checks see Sam's original passkeys
r = await sam.call('/api/auth/recovery'); ok('seven codes remain', r.json.remaining === 7, r.json);
r = await sam.call('/api/audit'); ok('an ordinary person cannot read the audit log', r.status === 403, r.status);
r = await admin.call('/api/audit'); const evs = (r.json.events || []).map(e => e.event);
ok('the admin sees the audit log: invite use, recovery codes and recovery use are recorded', ['recovery codes created', 'recovery code used', 'passkey added with invite'].every(e => evs.includes(e)), evs);
ok('and it never contains a code', !JSON.stringify(r.json).includes(recCodes[0]) && !JSON.stringify(r.json).includes(samCode), 'code leaked');
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json); r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json);
// ---- private data // ---- private data