Multiple people: an admin adds users and chooses which tabs each may use
build-container / build (push) Successful in 2m20s
build-container / build (push) Successful in 2m20s
- Accounts: the setup code (or an existing single-user install, migrated automatically) creates the admin; the admin adds people in Settings -> People and gives each a one-time invite code to enrol their own passkey - Each person's tasks, NE Hub events and subscribe link, settings, calendar links, iCloud connection and Plex sign-in are private, stored in their own folder (/data/users/<id>/); Plex sign-in moved from browser cookies into that folder (encrypted), so it also follows them across devices - Tab access is enforced by the server (403) and reflected in the app (nav, Home, Settings, search, /api/config hides addresses of tabs a person cannot use); browser storage is namespaced per person - Admin tools: change tabs, invite a device, sign out everywhere (session epoch), turn off, make/remove admin, delete a person and their data; the last admin cannot be removed - Sessions carry the person (uid.epoch.expiry.signature); old single-user cookies still work as the admin - tools/users-test.js (83 checks: isolation, access limits, invites, sign-out/disable/delete, Plex and iCloud per person, migration) now runs in the Gitea workflow before the image is built Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
489ea0aaf0
commit
f18d9dd7a9
16 files changed
+703
-165
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// Passkey (WebAuthn) sign-in that gates the whole app.
|
||||
// First passkey: enrolled with a setup code (HUB_SETUP_CODE, or a random one printed to the server log).
|
||||
// After that, further passkeys can only be added from an already signed-in session.
|
||||
// Passkey (WebAuthn) sign-in that gates the whole app, for one admin and the people they add.
|
||||
// The first passkey (the admin) is enrolled with a setup code (HUB_SETUP_CODE, or a random one printed to the server log).
|
||||
// Everyone else enrols with a one-time invite code the admin creates; a signed-in person can also invite one of their own devices.
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
generateRegistrationOptions, verifyRegistrationResponse,
|
||||
generateAuthenticationOptions, verifyAuthenticationResponse,
|
||||
} = require('@simplewebauthn/server');
|
||||
const users = require('./users');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -23,11 +24,14 @@ if (!secret) {
|
||||
if (!secret) { secret = crypto.randomBytes(32).toString('hex'); writeJson('secret.json', { secret }); }
|
||||
}
|
||||
|
||||
const store = readJson('passkeys.json', { userId: crypto.randomBytes(32).toString('base64url'), creds: [] });
|
||||
const store = readJson('passkeys.json', { creds: [] });
|
||||
if (!Array.isArray(store.creds)) store.creds = [];
|
||||
const save = () => writeJson('passkeys.json', store);
|
||||
// Passkeys made before there were accounts belong to the admin.
|
||||
{ const admin = users.firstAdmin(); let changed = false; for (const c of store.creds) if (!c.uid && admin) { c.uid = admin.id; changed = true; } if (changed) save(); }
|
||||
|
||||
const SETUP_CODE = process.env.HUB_SETUP_CODE || crypto.randomBytes(4).toString('hex');
|
||||
if (!store.creds.length) console.log(`[auth] No passkeys yet. Setup code: ${SETUP_CODE}`);
|
||||
if (!users.all().length) console.log(`[auth] No accounts yet. Setup code: ${SETUP_CODE}`);
|
||||
|
||||
// Sessions last SESSION_DAYS and slide forward: using the app renews them, so you only sign in again after that long idle.
|
||||
const SESSION_DAYS = Math.max(1, parseInt(process.env.SESSION_DAYS, 10) || 180);
|
||||
@@ -35,58 +39,67 @@ const LIFETIME = SESSION_DAYS * 864e5;
|
||||
const mac = v => crypto.createHmac('sha256', secret).update(v).digest('base64url');
|
||||
const cookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; }));
|
||||
const isHttps = req => (req.headers['x-forwarded-proto'] || '') === 'https' || (process.env.PUBLIC_URL || '').startsWith('https://');
|
||||
const safeEq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
function isAuthed(req) {
|
||||
const [exp, sig] = String(cookies(req).hub_session || '').split('.');
|
||||
if (!exp || !sig) return false;
|
||||
const want = mac('s' + exp);
|
||||
return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > Date.now();
|
||||
/** Session cookie: uid.epoch.expiry.signature. (Older single-user cookies, expiry.signature, count as the admin.) */
|
||||
function currentUser(req) {
|
||||
const parts = String(cookies(req).hub_session || '').split('.');
|
||||
let user = null, exp;
|
||||
if (parts.length === 4) {
|
||||
const [uid, epoch, e, sig] = parts; exp = Number(e);
|
||||
const u = users.get(uid);
|
||||
if (u && !u.disabled && String(u.epoch) === epoch && safeEq(sig, mac(`s${uid}.${epoch}.${e}`))) user = u;
|
||||
} else if (parts.length === 2) {
|
||||
const [e, sig] = parts; exp = Number(e);
|
||||
if (safeEq(sig, mac('s' + e))) user = users.firstAdmin();
|
||||
}
|
||||
return user && exp > Date.now() ? user : null;
|
||||
}
|
||||
const sessionCookie = req => { const exp = Date.now() + LIFETIME;
|
||||
return `hub_session=${exp}.${mac('s' + exp)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`; };
|
||||
const isAuthed = req => !!currentUser(req);
|
||||
|
||||
const sessionCookie = (req, user) => { const exp = Date.now() + LIFETIME;
|
||||
return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`; };
|
||||
// Add a Set-Cookie without clobbering ones other code already set on this response.
|
||||
const addCookie = (res, c) => { const cur = res.getHeader('Set-Cookie'); res.setHeader('Set-Cookie', cur ? [].concat(cur, c) : c); };
|
||||
function startSession(req, res) { addCookie(res, sessionCookie(req)); }
|
||||
/** Renew the session cookie when it is more than a day old, so an active session never runs out. */
|
||||
function refresh(req, res) {
|
||||
const [exp] = String(cookies(req).hub_session || '').split('.');
|
||||
if (Number(exp) - Date.now() < LIFETIME - 864e5) addCookie(res, sessionCookie(req));
|
||||
function startSession(req, res, user) { addCookie(res, sessionCookie(req, user)); }
|
||||
/** Renew the cookie when it is more than a day old (and upgrade old-style cookies), so an active session never runs out. */
|
||||
function refresh(req, res, user) {
|
||||
const parts = String(cookies(req).hub_session || '').split('.');
|
||||
const exp = Number(parts.length === 4 ? parts[2] : parts[0]);
|
||||
if (parts.length !== 4 || exp - Date.now() < LIFETIME - 864e5) addCookie(res, sessionCookie(req, user));
|
||||
}
|
||||
|
||||
const challenges = new Map(); // cid -> { challenge, kind, exp }
|
||||
const challenges = new Map(); // cid -> { challenge, kind, extra, exp }
|
||||
const newChallenge = (challenge, kind, extra) => {
|
||||
for (const [k, v] of challenges) if (v.exp < Date.now()) challenges.delete(k);
|
||||
const cid = crypto.randomBytes(16).toString('base64url');
|
||||
challenges.set(cid, { challenge, kind, extra, exp: Date.now() + 5 * 60e3 });
|
||||
return cid;
|
||||
};
|
||||
const takeChallenge = (cid, kind) => {
|
||||
const c = challenges.get(cid); challenges.delete(cid);
|
||||
return c && c.kind === kind && c.exp > Date.now() ? c.challenge : null;
|
||||
};
|
||||
const takeRecord = cid => { const c = challenges.get(cid); challenges.delete(cid); return c && c.exp > Date.now() ? c : null; };
|
||||
|
||||
// One-time invite codes: a signed-in device creates one, a new device uses it to enrol its own passkey.
|
||||
const invites = new Map(); // code -> expiry
|
||||
// One-time invite codes: they let a device enrol its own passkey for a particular person.
|
||||
const invites = new Map(); // code -> { uid, exp }
|
||||
const INVITE_MINUTES = 10;
|
||||
const newInvite = () => {
|
||||
for (const [k, v] of invites) if (v < Date.now()) invites.delete(k);
|
||||
function newInvite(uid) {
|
||||
for (const [k, v] of invites) if (v.exp < Date.now()) invites.delete(k);
|
||||
const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no look-alike characters
|
||||
let code = ''; for (const b of crypto.randomBytes(8)) code += a[b % a.length];
|
||||
code = code.slice(0, 4) + '-' + code.slice(4);
|
||||
invites.set(code, Date.now() + INVITE_MINUTES * 60e3);
|
||||
invites.set(code, { uid, exp: Date.now() + INVITE_MINUTES * 60e3 });
|
||||
return code;
|
||||
};
|
||||
}
|
||||
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 inviteValid = c => { const k = normInvite(c); return !!k && (invites.get(k) || 0) > Date.now() ? k : 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; };
|
||||
|
||||
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 => {
|
||||
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
||||
const h = hits.get(ip) || { n: 0, t: Date.now() };
|
||||
if (Date.now() - h.t > 60e3) { h.n = 0; h.t = Date.now(); }
|
||||
h.n++; hits.set(ip, h);
|
||||
return h.n > 10;
|
||||
return h.n > RATE_LIMIT;
|
||||
};
|
||||
|
||||
// When PUBLIC_URL is set it is the source of truth for the passkey origin, so a reverse proxy that
|
||||
@@ -102,11 +115,13 @@ const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(S
|
||||
|
||||
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 send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); };
|
||||
const credsOf = uid => store.creds.filter(c => c.uid === uid);
|
||||
|
||||
async function handle(req, res) {
|
||||
const url = new URL(req.url, 'http://x'), p = url.pathname;
|
||||
try {
|
||||
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: isAuthed(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/logout' && req.method === 'POST') {
|
||||
addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||
return send(res, 200, { ok: true });
|
||||
@@ -115,39 +130,42 @@ async function handle(req, res) {
|
||||
const { rpID, origin } = rp(req);
|
||||
|
||||
if (p === '/api/auth/register/options' && req.method === 'POST') {
|
||||
const authed = isAuthed(req);
|
||||
let kind = 'reg-authed', extra;
|
||||
if (!authed) {
|
||||
let kind, uid, name, handle;
|
||||
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.' });
|
||||
if (!store.creds.length && equalStr(body.setupCode || '', SETUP_CODE)) kind = 'reg-setup';
|
||||
else if (body.inviteCode && inviteValid(body.inviteCode)) { kind = 'reg-invite'; extra = inviteValid(body.inviteCode); }
|
||||
const inv = body.inviteCode && inviteFor(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' });
|
||||
}
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: 'NE Hub', rpID, userName: 'NE Hub owner', userID: Buffer.from(store.userId, 'base64url'),
|
||||
rpName: 'NE Hub', rpID, userName: name, userID: Buffer.from(handle, 'base64url'),
|
||||
attestationType: 'none',
|
||||
excludeCredentials: store.creds.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' },
|
||||
});
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, kind, extra), options });
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && inviteFor(body.inviteCode) ? inviteFor(body.inviteCode).code : undefined }), options });
|
||||
}
|
||||
if (p === '/api/auth/register/verify' && req.method === 'POST') {
|
||||
const authed = isAuthed(req);
|
||||
if (!authed && 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.' });
|
||||
const rec = takeRecord(body.cid);
|
||||
if (!rec) return send(res, 400, { error: 'Request expired, try again' });
|
||||
const okKind = rec.kind === 'reg-authed' ? authed
|
||||
: rec.kind === 'reg-setup' ? !store.creds.length
|
||||
: rec.kind === 'reg-invite' ? !!inviteValid(rec.extra) : false;
|
||||
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;
|
||||
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' });
|
||||
let user = ex.uid ? users.get(ex.uid) : null;
|
||||
if (rec.kind === 'reg-setup') user = users.create({ name: 'Admin', role: 'admin', handle: ex.handle });
|
||||
const c = v.registrationInfo.credential;
|
||||
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() });
|
||||
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id });
|
||||
save();
|
||||
if (rec.kind === 'reg-invite') invites.delete(rec.extra); // single use
|
||||
if (!authed) startSession(req, res);
|
||||
if (rec.kind === 'reg-invite') invites.delete(ex.code); // single use
|
||||
if (!me) startSession(req, res, user);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/auth/login/options' && req.method === 'POST') {
|
||||
@@ -157,32 +175,38 @@ async function handle(req, res) {
|
||||
}
|
||||
if (p === '/api/auth/login/verify' && req.method === 'POST') {
|
||||
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
||||
const challenge = takeChallenge(body.cid, 'login');
|
||||
const rec = takeRecord(body.cid);
|
||||
const cred = store.creds.find(c => c.id === body.response?.id);
|
||||
if (!challenge || !cred) return send(res, 400, { error: 'Passkey not recognised' });
|
||||
const user = cred && users.get(cred.uid);
|
||||
if (!rec || rec.kind !== 'login' || !cred || !user) 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: 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 },
|
||||
});
|
||||
if (!v.verified) return send(res, 401, { error: 'Sign-in failed' });
|
||||
cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save();
|
||||
startSession(req, res);
|
||||
startSession(req, res, user);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
// Everything below needs a signed-in session.
|
||||
if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in' });
|
||||
if (p === '/api/auth/invite' && req.method === 'POST') {
|
||||
return send(res, 200, { code: newInvite(), minutes: INVITE_MINUTES });
|
||||
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
|
||||
return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES });
|
||||
}
|
||||
if (p === '/api/auth/passkeys' && req.method === 'GET') {
|
||||
return send(res, 200, store.creds.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') {
|
||||
const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' });
|
||||
store.creds = store.creds.filter(c => c.id !== body.id); save();
|
||||
return send(res, 200, { ok: true, remaining: store.creds.length });
|
||||
return send(res, 200, { ok: true, remaining: credsOf(me.id).length });
|
||||
}
|
||||
send(res, 404, { error: 'Not found' });
|
||||
} catch (e) { send(res, 400, { error: e.message }); }
|
||||
}
|
||||
|
||||
module.exports = { handle, isAuthed, refresh, addCookie, secret, SESSION_DAYS };
|
||||
const passkeyCount = uid => credsOf(uid).length;
|
||||
function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); }
|
||||
|
||||
module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
|
||||
Reference in new issue
Block a user