const express = require('express'); const fs = require('fs'); const path = require('path'); const cors = require('cors'); const crypto = require('crypto'); const QRCode = require('qrcode'); const app = express(); const PORT = 8000; // Middleware app.use(cors()); app.use(express.json({ limit: '50mb' })); // Increased limit for base64 images/avatars // SECURITY: never let the static handler expose the auth store (contains // password hashes and MFA secrets). Must come BEFORE express.static. app.use((req, res, next) => { if (/users_data\.json/i.test(req.path)) return res.status(404).end(); next(); }); app.use(express.static(__dirname)); // Serve static files from current directory const DATA_FILE = path.join(__dirname, 'contacts_data.json'); const USERS_FILE = path.join(__dirname, 'users_data.json'); // Initialize contacts data file if it doesn't exist if (!fs.existsSync(DATA_FILE)) { const initialData = { contacts: [ { id: 1, name: 'John Smith', unit: 'Marketing', phone: '+351 123 456 789', mobile: '+351 987 654 321', email: 'john.smith@company.com', department: 'IT', locations: ['Martinhal Oriente'], photo: 'https://i.pravatar.cc/150?img=12' }, { id: 2, name: 'Maria Santos', unit: 'Finance', phone: '+351 123 456 790', mobile: '+351 987 654 322', email: 'maria.santos@company.com', department: 'Accounting', locations: ['Martinhal Lisbon', 'Martinhal Quinta'], photo: 'https://i.pravatar.cc/150?img=5' }, { id: 3, name: 'Pedro Costa', unit: 'Operations', phone: '+351 123 456 791', mobile: '+351 987 654 323', email: 'pedro.costa@company.com', department: 'Maintenance', locations: ['Martinhal Sagres'], photo: 'https://i.pravatar.cc/150?img=33' } ], departments: ['IT', 'Accounting', 'Board', 'Housekeeping', 'Maintenance'], locations: ['Martinhal Oriente', 'Martinhal Lisbon', 'Martinhal Quinta', 'Martinhal Sagres'] }; fs.writeFileSync(DATA_FILE, JSON.stringify(initialData, null, 2)); console.log('Created initial data file:', DATA_FILE); } // Initialize (empty) users store if it doesn't exist -> triggers first-run setup if (!fs.existsSync(USERS_FILE)) { fs.writeFileSync(USERS_FILE, JSON.stringify({ users: [] }, null, 2)); console.log('Created empty users store:', USERS_FILE); } // --------------------------------------------------------------------------- // Auth helpers // --------------------------------------------------------------------------- function loadUsers() { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch (e) { return { users: [] }; } } function saveUsers(d) { fs.writeFileSync(USERS_FILE, JSON.stringify(d, null, 2)); } // Password hashing (scrypt + random salt, constant-time compare) function hashPassword(pw) { const salt = crypto.randomBytes(16).toString('hex'); const hash = crypto.scryptSync(String(pw), salt, 64).toString('hex'); return { salt, hash }; } function verifyPassword(pw, salt, hash) { try { const h = crypto.scryptSync(String(pw), salt, 64).toString('hex'); const a = Buffer.from(h, 'hex'); const b = Buffer.from(hash, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } catch (e) { return false; } } // --- TOTP (RFC 6238) implemented with built-in crypto, no extra deps --- const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; function base32Encode(buf) { let bits = 0, value = 0, out = ''; for (let i = 0; i < buf.length; i++) { value = (value << 8) | buf[i]; bits += 8; while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += B32[(value << (5 - bits)) & 31]; return out; } function base32Decode(str) { str = String(str).replace(/=+$/, '').toUpperCase().replace(/\s/g, ''); let bits = 0, value = 0; const out = []; for (const ch of str) { const idx = B32.indexOf(ch); if (idx < 0) continue; value = (value << 5) | idx; bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return Buffer.from(out); } function genTotpSecret() { return base32Encode(crypto.randomBytes(20)); } function hotp(secretB32, counter) { const key = base32Decode(secretB32); const buf = Buffer.alloc(8); for (let i = 7; i >= 0; i--) { buf[i] = counter & 0xff; counter = Math.floor(counter / 256); } const hmac = crypto.createHmac('sha1', key).update(buf).digest(); const offset = hmac[hmac.length - 1] & 0xf; const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff); return (code % 1000000).toString().padStart(6, '0'); } function verifyTotp(secretB32, token, window = 1) { if (!token) return false; token = String(token).replace(/\s/g, ''); const counter = Math.floor(Date.now() / 1000 / 30); for (let e = -window; e <= window; e++) { if (hotp(secretB32, counter + e) === token) return true; } return false; } function otpauthUrl(username, secret) { const issuer = 'Martinhal Contacts'; return 'otpauth://totp/' + encodeURIComponent(issuer) + ':' + encodeURIComponent(username) + '?secret=' + secret + '&issuer=' + encodeURIComponent(issuer) + '&algorithm=SHA1&digits=6&period=30'; } // Sessions (in-memory; users re-login after a server restart) const sessions = new Map(); // token -> username function issueToken(username) { const t = crypto.randomBytes(24).toString('hex'); sessions.set(t, username); return t; } function currentUser(req) { const h = req.headers.authorization || ''; const t = h.startsWith('Bearer ') ? h.slice(7) : null; if (!t || !sessions.has(t)) return null; const uname = sessions.get(t); const d = loadUsers(); return d.users.find(u => u.username === uname) || null; } function requireAuth(req, res, next) { const u = currentUser(req); if (!u) return res.status(401).json({ error: 'Not authenticated' }); req.user = u; next(); } function requireAdmin(req, res, next) { const u = currentUser(req); if (!u) return res.status(401).json({ error: 'Not authenticated' }); if (!u.isAdmin) return res.status(403).json({ error: 'Administrator privileges required' }); req.user = u; next(); } function safeProfile(u) { return { username: u.username, displayName: u.displayName || u.username, avatar: u.avatar || '', mfaEnabled: !!u.mfaEnabled, isAdmin: !!u.isAdmin }; } // --------------------------------------------------------------------------- // Auth routes // --------------------------------------------------------------------------- // Is initial setup done? (any user exists) app.get('/api/auth/status', (req, res) => { const d = loadUsers(); res.json({ setupComplete: d.users.length > 0 }); }); // First-run setup: create the very first admin (only when no users exist). // MFA is optional, so we don't force enrollment — we just sign them in. app.post('/api/auth/setup', (req, res) => { const d = loadUsers(); if (d.users.length > 0) return res.status(403).json({ error: 'Setup already completed' }); const { username, password, displayName } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); const { salt, hash } = hashPassword(password); const user = { username: String(username).trim(), salt, hash, mfaSecret: '', // generated only if/when the user enables MFA mfaEnabled: false, displayName: (displayName || '').trim() || String(username).trim(), avatar: '', isAdmin: true, createdAt: new Date().toISOString() }; d.users.push(user); saveUsers(d); res.json({ status: 'ok', token: issueToken(user.username), profile: safeProfile(user) }); }); // Login. MFA is OPTIONAL: a code is only required for users who have // chosen to enable it. Everyone else logs in with username + password. app.post('/api/auth/login', (req, res) => { const { username, password, code } = req.body || {}; const d = loadUsers(); const u = d.users.find(x => x.username === String(username || '').trim()); if (!u || !verifyPassword(password, u.salt, u.hash)) { return res.status(401).json({ error: 'Invalid username or password' }); } // Only enforce a second factor if this account has MFA switched on. if (u.mfaEnabled) { if (!code) return res.json({ status: 'mfa' }); if (!verifyTotp(u.mfaSecret, code)) return res.status(401).json({ status: 'mfa', error: 'Invalid code' }); } return res.json({ status: 'ok', token: issueToken(u.username), profile: safeProfile(u) }); }); app.post('/api/auth/logout', (req, res) => { const h = req.headers.authorization || ''; const t = h.startsWith('Bearer ') ? h.slice(7) : null; if (t) sessions.delete(t); res.json({ success: true }); }); // Current profile (session restore) app.get('/api/auth/me', requireAuth, (req, res) => { res.json({ profile: safeProfile(req.user) }); }); // Update own profile (avatar / display name) app.post('/api/auth/profile', requireAuth, (req, res) => { const d = loadUsers(); const u = d.users.find(x => x.username === req.user.username); if (!u) return res.status(404).json({ error: 'User not found' }); if (typeof req.body.displayName === 'string') u.displayName = req.body.displayName.trim() || u.username; if (typeof req.body.avatar === 'string') u.avatar = req.body.avatar; // '' clears it saveUsers(d); res.json({ profile: safeProfile(u) }); }); // --- Optional MFA management (per-user, opt-in) --- // Begin enabling MFA: generate a fresh secret + QR. Does NOT enable it yet. app.post('/api/auth/mfa/setup', requireAuth, (req, res) => { const d = loadUsers(); const u = d.users.find(x => x.username === req.user.username); if (!u) return res.status(404).json({ error: 'User not found' }); u.mfaSecret = genTotpSecret(); u.mfaEnabled = false; saveUsers(d); const url = otpauthUrl(u.username, u.mfaSecret); QRCode.toDataURL(url, (err, qr) => { res.json({ otpauthUrl: url, qrDataUrl: err ? '' : qr, secret: u.mfaSecret }); }); }); // Confirm a code and switch MFA on for this account. app.post('/api/auth/mfa/enable', requireAuth, (req, res) => { const { code } = req.body || {}; const d = loadUsers(); const u = d.users.find(x => x.username === req.user.username); if (!u) return res.status(404).json({ error: 'User not found' }); if (!u.mfaSecret) return res.status(400).json({ error: 'Start MFA setup first' }); if (!verifyTotp(u.mfaSecret, code)) return res.status(400).json({ error: 'Invalid code, try again' }); u.mfaEnabled = true; saveUsers(d); res.json({ profile: safeProfile(u) }); }); // Turn MFA back off (user is already authenticated). app.post('/api/auth/mfa/disable', requireAuth, (req, res) => { const d = loadUsers(); const u = d.users.find(x => x.username === req.user.username); if (!u) return res.status(404).json({ error: 'User not found' }); u.mfaEnabled = false; u.mfaSecret = ''; saveUsers(d); res.json({ profile: safeProfile(u) }); }); // Change your own password (any authenticated user, admins included). app.post('/api/auth/password', requireAuth, (req, res) => { const { currentPassword, newPassword } = req.body || {}; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' }); if (String(newPassword).length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters' }); const d = loadUsers(); const u = d.users.find(x => x.username === req.user.username); if (!u) return res.status(404).json({ error: 'User not found' }); if (!verifyPassword(currentPassword, u.salt, u.hash)) { return res.status(401).json({ error: 'Current password is incorrect' }); } const { salt, hash } = hashPassword(newPassword); u.salt = salt; u.hash = hash; saveUsers(d); // Keep the current session but sign this account out everywhere else. const h = req.headers.authorization || ''; const cur = h.startsWith('Bearer ') ? h.slice(7) : null; for (const [tok, uname] of sessions) if (uname === u.username && tok !== cur) sessions.delete(tok); res.json({ success: true }); }); app.get('/api/auth/users', requireAdmin, (req, res) => { const d = loadUsers(); res.json({ users: d.users.map(safeProfile) }); }); // Add a new user (they enroll their own MFA on first login). Only admins may // add users, and they choose whether the new account is an admin or view-only. app.post('/api/auth/users', requireAdmin, (req, res) => { const { username, password, displayName, isAdmin } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); const d = loadUsers(); if (d.users.some(u => u.username === String(username).trim())) { return res.status(409).json({ error: 'Username already exists' }); } const { salt, hash } = hashPassword(password); d.users.push({ username: String(username).trim(), salt, hash, mfaSecret: '', mfaEnabled: false, displayName: (displayName || '').trim() || String(username).trim(), avatar: '', isAdmin: !!isAdmin, createdAt: new Date().toISOString() }); saveUsers(d); res.json({ success: true }); }); // Delete a user (cannot delete yourself or the last remaining user) app.delete('/api/auth/users/:username', requireAdmin, (req, res) => { const target = req.params.username; if (target === req.user.username) return res.status(400).json({ error: "You can't delete your own account" }); const d = loadUsers(); if (d.users.length <= 1) return res.status(400).json({ error: 'Cannot delete the last user' }); const before = d.users.length; d.users = d.users.filter(u => u.username !== target); if (d.users.length === before) return res.status(404).json({ error: 'User not found' }); saveUsers(d); // Invalidate any active sessions for that user for (const [tok, uname] of sessions) if (uname === target) sessions.delete(tok); res.json({ success: true }); }); // --------------------------------------------------------------------------- // Contacts data routes (unchanged behaviour) // --------------------------------------------------------------------------- app.get('/api/data', requireAuth, (req, res) => { try { const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); res.json(data); } catch (error) { console.error('Error reading data:', error); res.status(500).json({ error: 'Failed to read data' }); } }); app.post('/api/data', requireAdmin, (req, res) => { try { fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2)); res.json({ success: true }); } catch (error) { console.error('Error saving data:', error); res.status(500).json({ error: 'Failed to save data' }); } }); app.post('/api/backup', requireAdmin, (req, res) => { try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupFile = path.join(__dirname, `backup_${timestamp}.json`); const data = fs.readFileSync(DATA_FILE, 'utf8'); fs.writeFileSync(backupFile, data); res.json({ success: true, file: backupFile }); } catch (error) { console.error('Error creating backup:', error); res.status(500).json({ error: 'Failed to create backup' }); } }); if (require.main === module) { app.listen(PORT, () => { const d = loadUsers(); console.log('================================='); console.log(`Server running at http://localhost:${PORT}`); console.log(`Data file: ${DATA_FILE}`); console.log(`Users file: ${USERS_FILE}`); console.log(`Setup complete: ${d.users.length > 0} (${d.users.length} user(s))`); console.log('================================='); }); } module.exports = app;