const crypto = require('crypto'); const db = require('./db/database'); // Sessions live for 7 days, after which the user must log in again. const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // ── Password hashing (scrypt, no external deps) ─────────────────────────────── // Stored format: scrypt$$ function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(password, salt, 64); return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; } function verifyPassword(password, stored) { try { const [scheme, saltHex, hashHex] = String(stored).split('$'); if (scheme !== 'scrypt') return false; const salt = Buffer.from(saltHex, 'hex'); const expected = Buffer.from(hashHex, 'hex'); const actual = crypto.scryptSync(password, salt, expected.length); return crypto.timingSafeEqual(expected, actual); } catch { return false; } } // ── Bootstrap admin from environment ────────────────────────────────────────── // Creates the admin account if missing, or re-syncs its password every startup // so rotating ADMIN_PASSWORD in the container rotates the live credential. // Because of this, the bootstrap admin's password cannot be changed from the UI // — it is owned by the Docker environment. Additional users created in the UI // are unaffected. function bootstrapAdmin() { const username = (process.env.ADMIN_USERNAME || 'admin').trim(); const password = process.env.ADMIN_PASSWORD; if (!password) { console.warn('[AUTH] ADMIN_PASSWORD is not set — admin account was NOT bootstrapped. Set it in the container environment.'); return; } const existing = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!existing) { db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') .run(username, hashPassword(password), 'admin'); console.log(`[AUTH] Bootstrapped admin user '${username}'.`); } else { db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE id = ?') .run(hashPassword(password), 'admin', existing.id); console.log(`[AUTH] Synced admin user '${username}' password from environment.`); } } // ── User management ─────────────────────────────────────────────────────────── function findUserByUsername(username) { return db.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim()); } function listUsers() { return db.prepare('SELECT id, username, role, created_at FROM users ORDER BY username ASC').all(); } function createUser({ username, password, role }) { const uname = String(username || '').trim(); if (!uname) throw Object.assign(new Error('username is required'), { status: 400 }); if (!password) throw Object.assign(new Error('password is required'), { status: 400 }); if (password.length < 6) throw Object.assign(new Error('password must be at least 6 characters'), { status: 400 }); const safeRole = role === 'admin' ? 'admin' : 'user'; if (findUserByUsername(uname)) { throw Object.assign(new Error('A user with that username already exists'), { status: 409 }); } const result = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') .run(uname, hashPassword(password), safeRole); return { id: result.lastInsertRowid, username: uname, role: safeRole }; } function deleteUser(id) { return db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0; } function setPassword(id, password) { if (!password || password.length < 6) { throw Object.assign(new Error('password must be at least 6 characters'), { status: 400 }); } return db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id).changes > 0; } // ── Sessions ────────────────────────────────────────────────────────────────── function createSession(user) { const token = crypto.randomBytes(32).toString('hex'); const expires = new Date(Date.now() + SESSION_TTL_MS).toISOString(); db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)') .run(token, user.id, expires); return token; } function getSessionUser(token) { if (!token) return null; const row = db.prepare(` SELECT s.expires_at, u.id, u.username, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? `).get(token); if (!row) return null; if (new Date(row.expires_at).getTime() < Date.now()) { db.prepare('DELETE FROM sessions WHERE token = ?').run(token); return null; } return { id: row.id, username: row.username, role: row.role }; } function destroySession(token) { if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token); } function login(username, password) { const user = findUserByUsername(username); if (!user || !verifyPassword(password, user.password_hash)) return null; const token = createSession(user); return { token, user: { id: user.id, username: user.username, role: user.role } }; } // ── Express middleware ──────────────────────────────────────────────────────── function tokenFromReq(req) { const h = req.headers.authorization || ''; return h.startsWith('Bearer ') ? h.slice(7) : null; } function requireAuth(req, res, next) { const user = getSessionUser(tokenFromReq(req)); if (!user) return res.status(401).json({ error: 'Authentication required' }); req.user = user; req.authToken = tokenFromReq(req); next(); } function requireAdmin(req, res, next) { if (!req.user || req.user.role !== 'admin') { return res.status(403).json({ error: 'Admin access required' }); } next(); } module.exports = { bootstrapAdmin, login, destroySession, listUsers, createUser, deleteUser, setPassword, requireAuth, requireAdmin, };