This commit is contained in:
jpmvaz
2026-09-13 20:17:19 +01:00
commit cfd83aedc4
11 changed files with 1348 additions and 0 deletions
+392
View File
@@ -0,0 +1,392 @@
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;