Audit log (admin Activity list) and passkey recovery codes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c8399ccaa7
commit
561b6181a0
8 files changed
+101
-14
No files matched your search
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user