Add user management with admin and viewer roles

- data/users.json store; existing single account migrates to first admin
- Roles: admin (settings, import, users) and viewer (read-only, own password)
- Live role/disabled lookup; per-user session epoch so password changes,
  resets and disabling sign that user out everywhere
- Login brute-force lockout (5 fails -> 5 min), constant-time unknown-user check
- /api/users CRUD with last-admin and self-lockout safeguards
- Account page (own password + admin user management); role-aware header
- Min password length 8; recovery token resets the primary admin and clears lockouts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 12:34:13 -04:00
1 parent b3f1061f5c
commit 1566ac2897
7 files changed
+442 -93

No files matched your search

+212 -56
View File
@@ -35,13 +35,14 @@ function parseCookies(req) {
}).filter(([k]) => k));
}
function signSession(username) {
function signSession(username, epoch = 0) {
const expiry = Date.now() + 30 * 24 * 60 * 60 * 1000;
const payload = Buffer.from(`${username}|${expiry}`).toString('base64url');
const payload = Buffer.from(`${username}|${expiry}|${epoch}`).toString('base64url');
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
// Returns { username, epoch } for a valid, unexpired token (epoch 0 for pre-users cookies)
function verifySession(token) {
if (!token || typeof token !== 'string') return null;
const dotIdx = token.lastIndexOf('.');
@@ -53,9 +54,14 @@ function verifySession(token) {
if (!crypto.timingSafeEqual(Buffer.from(sig + '='.repeat((4 - sig.length % 4) % 4), 'base64'), Buffer.from(expected + '='.repeat((4 - expected.length % 4) % 4), 'base64'))) return null;
} catch { return null; }
const decoded = Buffer.from(payload, 'base64url').toString();
const [username, expiry] = decoded.split('|');
const [username, expiry, epoch] = decoded.split('|');
if (!username || !expiry || Date.now() > parseInt(expiry)) return null;
return username;
return { username, epoch: parseInt(epoch) || 0 };
}
function sessionCookie(req, token) {
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '';
return `dsession=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 3600}; SameSite=Lax${secure}`;
}
function hashPassword(password, salt) {
@@ -67,14 +73,91 @@ function getDailyRecoveryToken() {
return crypto.createHmac('sha256', SESSION_SECRET).update('recover:' + day).digest('hex').slice(0, 16);
}
// ── Users & roles ─────────────────────────────────────────────────────────────
// data/users.json: { users: [{ username, salt, hash, role: 'admin'|'viewer', disabled, epoch, createdAt, lastLogin }] }
// `epoch` is bumped on password change/reset/disable so that user's existing sessions stop working.
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const PASSWORD_MIN = 8;
const USERNAME_RE = /^[A-Za-z0-9._-]{3,32}$/;
const ROLES = ['admin', 'viewer'];
let usersStore = { users: [] };
try { usersStore = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch {}
if (!usersStore || !Array.isArray(usersStore.users)) usersStore = { users: [] };
function saveUsers() {
try {
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify(usersStore, null, 2), { mode: 0o600 });
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
} catch (e) { console.error('[auth] could not save users:', e.message); }
}
// First run after upgrade: the existing single account becomes the first admin.
if (!usersStore.users.length && dashAuth?.username && dashAuth.hash && dashAuth.salt) {
usersStore.users.push({ username: dashAuth.username, salt: dashAuth.salt, hash: dashAuth.hash, role: 'admin', epoch: 0, createdAt: Date.now() });
saveUsers();
console.log(`[auth] migrated existing account "${dashAuth.username}" to admin`);
}
const hasUsers = () => usersStore.users.length > 0;
const findUser = (name) => usersStore.users.find(u => u.username.toLowerCase() === String(name || '').toLowerCase());
const activeAdmins = () => usersStore.users.filter(u => u.role === 'admin' && !u.disabled);
const publicUser = (u) => ({ username: u.username, role: u.role, disabled: !!u.disabled, createdAt: u.createdAt || null, lastLogin: u.lastLogin || null });
const DUMMY_SALT = crypto.randomBytes(16).toString('hex');
function checkPassword(user, password) {
const hash = hashPassword(String(password || ''), user ? user.salt : DUMMY_SALT); // always hash: no timing hint about unknown users
if (!user) return false;
try { return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(user.hash, 'hex')); } catch { return false; }
}
function setPassword(user, password) {
user.salt = crypto.randomBytes(16).toString('hex');
user.hash = hashPassword(password, user.salt);
user.epoch = (user.epoch || 0) + 1; // signs out every existing session for this user
}
// Simple brute-force guard: 5 failures within 15 min locks that username+IP for 5 min.
const loginAttempts = new Map();
const attemptKey = (req, username) => `${req.ip}|${String(username || '').toLowerCase()}`;
function isLockedOut(key) {
const a = loginAttempts.get(key);
return !!(a && a.lockedUntil && a.lockedUntil > Date.now());
}
function noteLoginFailure(key) {
const now = Date.now();
const a = loginAttempts.get(key);
const cur = a && now - a.first < 15 * 60 * 1000 ? a : { fails: 0, first: now };
cur.fails++;
if (cur.fails >= 5) cur.lockedUntil = now + 5 * 60 * 1000;
loginAttempts.set(key, cur);
}
setInterval(() => { const now = Date.now(); for (const [k, a] of loginAttempts) if (now - a.first > 15 * 60 * 1000 && !(a.lockedUntil > now)) loginAttempts.delete(k); }, 10 * 60 * 1000).unref();
function clearAttemptsFor(username) {
const suffix = '|' + String(username).toLowerCase();
for (const k of loginAttempts.keys()) if (k.endsWith(suffix)) loginAttempts.delete(k);
}
function currentUser(req) {
const s = verifySession(parseCookies(req).dsession);
if (!s) return null;
const u = findUser(s.username);
if (!u || u.disabled || (u.epoch || 0) !== s.epoch) return null;
return u;
}
function requireDashboardAuth(req, res, next) {
const cookies = parseCookies(req);
const user = verifySession(cookies.dsession);
if (user) return next();
const user = currentUser(req);
if (user) { req.dashUser = user; return next(); }
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorized' });
res.redirect('/dashboard/login');
}
function requireAdmin(req, res, next) {
if (req.dashUser?.role === 'admin') return next();
if (req.path.startsWith('/api/')) return res.status(403).json({ error: 'Admin access required' });
res.redirect('/dashboard');
}
// ── Station data recording ────────────────────────────────────────────────────
const STATION_DIR = path.join(DATA_DIR, 'station');
if (!fs.existsSync(STATION_DIR)) fs.mkdirSync(STATION_DIR, { recursive: true });
@@ -930,7 +1013,7 @@ function saveAmbientConfig() {
const ambientCaches = new Map();
// Save keys server-side so all devices share them
app.post('/api/ambient-config', requireDashboardAuth, (req, res) => {
app.post('/api/ambient-config', requireDashboardAuth, requireAdmin, (req, res) => {
const { apiKey, appKey } = req.body || {};
if (!apiKey || !appKey) return res.status(400).json({ error: 'apiKey and appKey required' });
ambientServerConfig = { apiKey, appKey };
@@ -1250,44 +1333,46 @@ app.use('/dashboard', express.urlencoded({ extended: false }));
// Setup (first-time account creation)
app.get('/dashboard/setup', (req, res) => {
if (dashAuth) return res.redirect('/dashboard');
if (hasUsers()) return res.redirect('/dashboard');
res.sendFile(path.join(__dirname, 'public', 'dashboard-setup.html'));
});
// First-run setup creates the initial admin account
app.post('/dashboard/setup', (req, res) => {
if (dashAuth) return res.redirect('/dashboard');
if (hasUsers()) return res.redirect('/dashboard');
const { username, password } = req.body || {};
if (!username || !password || password.length < 6) {
if (!USERNAME_RE.test(String(username || '')) || !password || password.length < PASSWORD_MIN) {
return res.redirect('/dashboard/setup?error=1');
}
const salt = crypto.randomBytes(16).toString('hex');
const hash = hashPassword(password, salt);
dashAuth = { username, salt, hash };
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
const token = signSession(username);
res.setHeader('Set-Cookie', `dsession=${token}; HttpOnly; Path=/; Max-Age=${30*24*3600}; SameSite=Lax`);
const user = { username, role: 'admin', epoch: 0, createdAt: Date.now(), lastLogin: Date.now() };
setPassword(user, password); user.epoch = 0;
usersStore.users.push(user);
saveUsers();
dashAuth = dashAuth || {};
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
res.redirect('/dashboard');
});
// Login
app.get('/dashboard/login', (req, res) => {
if (!dashAuth) return res.redirect('/dashboard/setup');
const cookies = parseCookies(req);
if (verifySession(cookies.dsession)) return res.redirect('/dashboard');
if (!hasUsers()) return res.redirect('/dashboard/setup');
if (currentUser(req)) return res.redirect('/dashboard');
res.sendFile(path.join(__dirname, 'public', 'dashboard-login.html'));
});
app.post('/dashboard/login', (req, res) => {
if (!dashAuth) return res.redirect('/dashboard/setup');
if (!hasUsers()) return res.redirect('/dashboard/setup');
const { username, password } = req.body || {};
if (!username || !password) return res.redirect('/dashboard/login?error=1');
if (username !== dashAuth.username) return res.redirect('/dashboard/login?error=1');
const hash = hashPassword(password, dashAuth.salt);
let match = false;
try { match = crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(dashAuth.hash, 'hex')); } catch {}
if (!match) return res.redirect('/dashboard/login?error=1');
const token = signSession(username);
res.setHeader('Set-Cookie', `dsession=${token}; HttpOnly; Path=/; Max-Age=${30*24*3600}; SameSite=Lax`);
const key = attemptKey(req, username);
if (isLockedOut(key)) return res.redirect('/dashboard/login?error=2');
const user = findUser(username);
const ok = checkPassword(user, password) && !user.disabled;
if (!ok) { noteLoginFailure(key); return res.redirect('/dashboard/login?error=1'); }
loginAttempts.delete(key);
user.lastLogin = Date.now();
saveUsers();
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch || 0)));
res.redirect('/dashboard');
});
@@ -1297,35 +1382,41 @@ app.get('/dashboard/logout', (_req, res) => {
res.redirect('/dashboard/login');
});
// Password recovery
// Password recovery: the daily token (printed in the server log) resets the primary admin account
app.get('/dashboard/recover', (_req, res) => {
if (!dashAuth) return res.redirect('/dashboard/setup');
if (!hasUsers()) return res.redirect('/dashboard/setup');
res.sendFile(path.join(__dirname, 'public', 'dashboard-recover.html'));
});
app.post('/dashboard/recover', (req, res) => {
if (!dashAuth) return res.redirect('/dashboard/setup');
if (!hasUsers()) return res.redirect('/dashboard/setup');
const { token, password } = req.body || {};
if (!token || !password || password.length < 6) return res.redirect('/dashboard/recover?error=1');
if (!token || !password || password.length < PASSWORD_MIN) return res.redirect('/dashboard/recover?error=1');
if (isLockedOut('recover|' + req.ip)) return res.redirect('/dashboard/recover?error=1');
const expected = getDailyRecoveryToken();
let match = false;
try { match = crypto.timingSafeEqual(Buffer.from(token.trim()), Buffer.from(expected)); } catch {}
if (!match) return res.redirect('/dashboard/recover?error=1');
const salt = crypto.randomBytes(16).toString('hex');
const hash = hashPassword(password, salt);
dashAuth = { ...dashAuth, salt, hash };
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
try { match = crypto.timingSafeEqual(Buffer.from(String(token).trim()), Buffer.from(expected)); } catch {}
if (!match) { noteLoginFailure('recover|' + req.ip); return res.redirect('/dashboard/recover?error=1'); }
const admin = activeAdmins()[0] || usersStore.users.find(u => u.role === 'admin') || usersStore.users[0];
admin.role = 'admin'; admin.disabled = false;
setPassword(admin, password);
clearAttemptsFor(admin.username);
saveUsers();
console.log(`[auth] recovery reset password for admin "${admin.username}"`);
res.redirect('/dashboard/login?recovered=1');
});
// Main dashboard
app.get('/dashboard', requireDashboardAuth, (_req, res) => {
if (!dashAuth) return res.redirect('/dashboard/setup');
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
// Settings page
app.get('/dashboard/settings', requireDashboardAuth, (_req, res) => {
// Account page: everyone can change their own password; admins also manage users here
app.get('/dashboard/account', requireDashboardAuth, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard-account.html'));
});
app.get('/dashboard/settings', requireDashboardAuth, requireAdmin, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
});
@@ -1448,7 +1539,7 @@ async function runBackfill(daysBack) {
console.log(`[backfill] complete — inserted ${inserted} records`);
}
app.post('/api/station/backfill', requireDashboardAuth, (req, res) => {
app.post('/api/station/backfill', requireDashboardAuth, requireAdmin, (req, res) => {
if (backfillState.running) return res.json({ ok: false, error: 'Backfill already running' });
const days = Math.min(Math.max(parseInt(req.body?.days || '30'), 1), 365);
res.json({ ok: true, days });
@@ -1488,7 +1579,7 @@ app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
});
// Re-scan raw readings into the records store (merge only; never lowers a record)
app.post('/api/station/records/rebuild', requireDashboardAuth, (_req, res) => {
app.post('/api/station/records/rebuild', requireDashboardAuth, requireAdmin, (_req, res) => {
const scanned = rebuildRecordsFromFiles();
res.json({ ok: true, scanned });
});
@@ -1623,18 +1714,83 @@ app.get('/api/conditions/public', (_req, res) => {
// ── Dashboard management APIs ─────────────────────────────────────────────────
// Change password
// Who am I
app.get('/api/dashboard/me', requireDashboardAuth, (req, res) => {
res.json({ username: req.dashUser.username, role: req.dashUser.role });
});
// Change own password (signs out other sessions, keeps this one)
app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword || newPassword.length < 6)
return res.status(400).json({ error: 'Invalid input' });
const hash = hashPassword(currentPassword, dashAuth.salt);
let match = false;
try { match = crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(dashAuth.hash, 'hex')); } catch {}
if (!match) return res.status(403).json({ error: 'Current password incorrect' });
const salt = crypto.randomBytes(16).toString('hex');
dashAuth = { ...dashAuth, salt, hash: hashPassword(newPassword, salt) };
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
if (!currentPassword || !newPassword || newPassword.length < PASSWORD_MIN)
return res.status(400).json({ error: `New password must be at least ${PASSWORD_MIN} characters` });
const user = req.dashUser;
const key = 'pw|' + user.username.toLowerCase();
if (isLockedOut(key)) return res.status(429).json({ error: 'Too many attempts — try again in a few minutes' });
if (!checkPassword(user, currentPassword)) { noteLoginFailure(key); return res.status(403).json({ error: 'Current password incorrect' }); }
loginAttempts.delete(key);
setPassword(user, newPassword);
saveUsers();
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
res.json({ ok: true });
});
// ── User management (admin only) ──────────────────────────────────────────────
app.get('/api/users', requireDashboardAuth, requireAdmin, (_req, res) => {
res.json(usersStore.users.map(publicUser));
});
app.post('/api/users', requireDashboardAuth, requireAdmin, (req, res) => {
const { username, password, role } = req.body || {};
if (!USERNAME_RE.test(String(username || ''))) return res.status(400).json({ error: 'Username must be 3–32 characters: letters, numbers, . _ -' });
if (!password || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
if (findUser(username)) return res.status(409).json({ error: 'That username is already taken' });
const user = { username, role, epoch: 0, createdAt: Date.now() };
setPassword(user, password); user.epoch = 0;
usersStore.users.push(user);
saveUsers();
console.log(`[auth] ${req.dashUser.username} created ${role} "${username}"`);
res.status(201).json(publicUser(user));
});
app.patch('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
const user = findUser(req.params.username);
if (!user) return res.status(404).json({ error: 'User not found' });
const { role, password, disabled } = req.body || {};
const isSelf = user.username === req.dashUser.username;
const losesAdmin = (role && role !== 'admin') || disabled === true;
if (user.role === 'admin' && !user.disabled && losesAdmin) {
if (isSelf) return res.status(400).json({ error: "You can't demote or disable your own account" });
if (activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
}
if (role !== undefined) {
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
user.role = role;
}
if (disabled !== undefined) {
user.disabled = !!disabled;
if (user.disabled) user.epoch = (user.epoch || 0) + 1;
}
if (password !== undefined) {
if (typeof password !== 'string' || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
setPassword(user, password);
clearAttemptsFor(user.username);
if (isSelf) res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
}
saveUsers();
console.log(`[auth] ${req.dashUser.username} updated "${user.username}"`);
res.json(publicUser(user));
});
app.delete('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
const user = findUser(req.params.username);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.username === req.dashUser.username) return res.status(400).json({ error: "You can't delete your own account" });
if (user.role === 'admin' && !user.disabled && activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
usersStore.users = usersStore.users.filter(u => u !== user);
saveUsers();
console.log(`[auth] ${req.dashUser.username} deleted "${user.username}"`);
res.json({ ok: true });
});
@@ -1642,7 +1798,7 @@ app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => {
app.get('/api/dashboard/station-name', requireDashboardAuth, (_req, res) => {
res.json({ name: dashAuth?.stationName || 'Genesis 8:22' });
});
app.post('/api/dashboard/station-name', requireDashboardAuth, (req, res) => {
app.post('/api/dashboard/station-name', requireDashboardAuth, requireAdmin, (req, res) => {
const { name } = req.body || {};
if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Invalid name' });
dashAuth = { ...dashAuth, stationName: name.trim().slice(0, 60) };
@@ -1695,7 +1851,7 @@ app.get('/api/subscriptions', requireDashboardAuth, (_req, res) => {
});
// Update thresholds on all subscriptions at once
app.post('/api/subscriptions/thresholds', requireDashboardAuth, (req, res) => {
app.post('/api/subscriptions/thresholds', requireDashboardAuth, requireAdmin, (req, res) => {
const { thresholds } = req.body || {};
if (!thresholds || typeof thresholds !== 'object') return res.status(400).json({ error: 'Invalid' });
for (const sub of pushSubscriptions) sub.thresholds = { ...(sub.thresholds || {}), ...thresholds };
@@ -1711,7 +1867,7 @@ app.get('/api/dashboard/location', requireDashboardAuth, (_req, res) => {
return res.json({ lat: pushSubscriptions[0].lat, lon: pushSubscriptions[0].lon, fromSubscription: true });
res.json(null);
});
app.post('/api/dashboard/location', requireDashboardAuth, (req, res) => {
app.post('/api/dashboard/location', requireDashboardAuth, requireAdmin, (req, res) => {
const { lat, lon } = req.body || {};
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180))
return res.status(400).json({ error: 'Invalid coordinates' });
@@ -1805,7 +1961,7 @@ app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
app.listen(PORT, () => {
console.log(`Weather app running on port ${PORT}`);
if (dashAuth) {
if (hasUsers()) {
console.log(`Dashboard recovery token (changes daily): ${getDailyRecoveryToken()}`);
console.log(` Use at http://localhost:${PORT}/dashboard/recover`);
}