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
@@ -10,6 +10,7 @@ const {
|
||||
generateAuthenticationOptions, verifyAuthenticationResponse,
|
||||
} = require('@simplewebauthn/server');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
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 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 hits = new Map(); // crude per-IP throttle on anything that checks a secret
|
||||
const throttled = req => {
|
||||
@@ -137,6 +154,7 @@ async function handle(req, res) {
|
||||
const me = currentUser(req);
|
||||
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 (me) audit.log('sign-out', { who: me.name }, req);
|
||||
addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||
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; }
|
||||
else {
|
||||
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'); }
|
||||
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' });
|
||||
@@ -159,7 +177,7 @@ async function handle(req, res) {
|
||||
excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })),
|
||||
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 (!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 okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid
|
||||
: 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' });
|
||||
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' });
|
||||
@@ -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 || [],
|
||||
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id });
|
||||
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);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
@@ -192,27 +212,32 @@ async function handle(req, res) {
|
||||
const rec = takeRecord(body.cid);
|
||||
const cred = store.creds.find(c => c.id === body.response?.id);
|
||||
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' });
|
||||
const v = await verifyAuthenticationResponse({
|
||||
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 },
|
||||
});
|
||||
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();
|
||||
startSession(req, res, user);
|
||||
audit.log('sign-in', { who: user.name, detail: cred.name }, req);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
// Everything below needs a signed-in session.
|
||||
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
|
||||
audit.log('invite created for a device', { who: me.name }, req);
|
||||
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') {
|
||||
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') {
|
||||
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();
|
||||
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;
|
||||
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 };
|
||||
Reference in new issue
Block a user