This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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$<saltHex>$<hashHex>
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user