v_1.5
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user