v_1.7
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { USERS_FILE, SEED_ADMIN_USER, SEED_ADMIN_PASSWORD, SEED_ADMIN_EMAIL } from './config.js';
|
||||
import { readJson, writeJson } from './store.js';
|
||||
import { logEvent } from './logger.js';
|
||||
|
||||
let users = []; // { id, username, email, role, salt, hash, createdAt }
|
||||
const authSessions = new Map(); // token -> { userId, expires }
|
||||
const WEEK = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
return crypto.scryptSync(String(password), salt, 64).toString('hex');
|
||||
}
|
||||
|
||||
function buildUser(username, email, password, role) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
return {
|
||||
id: nanoid(10),
|
||||
username: username.trim(),
|
||||
email: (email || '').trim(),
|
||||
role: role === 'admin' ? 'admin' : 'user',
|
||||
salt,
|
||||
hash: hashPassword(password, salt),
|
||||
createdAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
export async function initUsers() {
|
||||
const saved = await readJson(USERS_FILE, null);
|
||||
users = Array.isArray(saved) ? saved : [];
|
||||
// No auto-seed: on first run the operator creates the admin in the browser.
|
||||
// For unattended deploys, ADMIN_USERNAME + ADMIN_PASSWORD may pre-seed one.
|
||||
if (users.length === 0 && process.env.ADMIN_PASSWORD) {
|
||||
users = [buildUser(SEED_ADMIN_USER, SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, 'admin')];
|
||||
await persist();
|
||||
await logEvent({ action: 'seed', message: `Pre-seeded admin "${SEED_ADMIN_USER}" from environment` });
|
||||
}
|
||||
}
|
||||
|
||||
export function hasUsers() {
|
||||
return users.length > 0;
|
||||
}
|
||||
|
||||
// Create the very first admin account (first-run setup). Refuses once any user exists.
|
||||
export async function createFirstAdmin({ username, email, password }) {
|
||||
if (users.length > 0) throw new Error('Setup has already been completed.');
|
||||
if (!username || !password) throw new Error('Username and password are required.');
|
||||
const u = buildUser(username, email, password, 'admin');
|
||||
users.push(u);
|
||||
await persist();
|
||||
return publicUser(u);
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
await writeJson(USERS_FILE, users);
|
||||
}
|
||||
|
||||
export function publicUser(u) {
|
||||
return u ? { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt } : null;
|
||||
}
|
||||
export function listUsers() { return users.map(publicUser); }
|
||||
export function getUserById(id) { return users.find((u) => u.id === id); }
|
||||
export function getUserByName(name) {
|
||||
const n = String(name || '').toLowerCase();
|
||||
return users.find((u) => u.username.toLowerCase() === n);
|
||||
}
|
||||
export function adminCount() { return users.filter((u) => u.role === 'admin').length; }
|
||||
|
||||
export async function createUser({ username, email, password, role }) {
|
||||
if (!username || !password) throw new Error('Username and password are required.');
|
||||
if (getUserByName(username)) throw new Error('That username is already taken.');
|
||||
const u = buildUser(username, email, password, role);
|
||||
users.push(u);
|
||||
await persist();
|
||||
return publicUser(u);
|
||||
}
|
||||
|
||||
export async function deleteUser(id, actingUserId) {
|
||||
const u = getUserById(id);
|
||||
if (!u) throw new Error('User not found.');
|
||||
if (id === actingUserId) throw new Error('You cannot delete your own account.');
|
||||
if (u.role === 'admin' && adminCount() <= 1) throw new Error('Cannot delete the last remaining admin.');
|
||||
users = users.filter((x) => x.id !== id);
|
||||
await persist();
|
||||
for (const [token, s] of authSessions) if (s.userId === id) authSessions.delete(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function setPassword(id, password) {
|
||||
const u = getUserById(id);
|
||||
if (!u) throw new Error('User not found.');
|
||||
if (!password) throw new Error('Password cannot be empty.');
|
||||
u.salt = crypto.randomBytes(16).toString('hex');
|
||||
u.hash = hashPassword(password, u.salt);
|
||||
await persist();
|
||||
}
|
||||
|
||||
export async function setRole(id, role, actingUserId) {
|
||||
const u = getUserById(id);
|
||||
if (!u) throw new Error('User not found.');
|
||||
const next = role === 'admin' ? 'admin' : 'user';
|
||||
if (u.role === 'admin' && next === 'user' && adminCount() <= 1) {
|
||||
throw new Error('Cannot demote the last remaining admin.');
|
||||
}
|
||||
if (id === actingUserId && next === 'user') {
|
||||
throw new Error('You cannot remove your own admin rights.');
|
||||
}
|
||||
u.role = next;
|
||||
await persist();
|
||||
}
|
||||
|
||||
export function verifyCredentials(username, password) {
|
||||
const u = getUserByName(username);
|
||||
if (!u) return null;
|
||||
const attempt = Buffer.from(hashPassword(password, u.salt));
|
||||
const known = Buffer.from(u.hash);
|
||||
if (attempt.length !== known.length || !crypto.timingSafeEqual(attempt, known)) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
// --- auth sessions (cookie tokens, in-memory) ------------------------------
|
||||
export function createAuthSession(userId) {
|
||||
const token = nanoid(32);
|
||||
authSessions.set(token, { userId, expires: Date.now() + WEEK });
|
||||
return token;
|
||||
}
|
||||
export function getAuthUser(token) {
|
||||
const s = token && authSessions.get(token);
|
||||
if (!s) return null;
|
||||
if (s.expires < Date.now()) { authSessions.delete(token); return null; }
|
||||
return getUserById(s.userId) || null;
|
||||
}
|
||||
export function destroyAuthSession(token) {
|
||||
if (token) authSessions.delete(token);
|
||||
}
|
||||
Reference in New Issue
Block a user