Files
cpas/auth.js
T
jason 97be2d2908
Build and Push Docker Image / build (push) Successful in 18s
auth modal
2026-05-27 09:07:23 -05:00

158 lines
6.2 KiB
JavaScript

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,
};