Files
LuHost/utils/auth.js
Nathan Schneider 3aed09b60f Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 17:32:37 -06:00

288 lines
7.4 KiB
JavaScript

const bcrypt = require('bcrypt');
const fs = require('fs').promises;
const path = require('path');
const sqlite3 = require('sqlite3');
const { promisify } = require('util');
class AuthManager {
constructor() {
this.dbPath = path.join(process.cwd(), 'users.db');
this.db = null;
this.saltRounds = 12;
}
async initialize() {
return new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
reject(err);
return;
}
// Create users table if it doesn't exist
this.db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (created_by) REFERENCES users (id)
)
`, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
});
}
async createUser(username, password, createdByUserId = null) {
if (!username || !password) {
throw new Error('Username and password are required');
}
// Check if this is not the first user and no creator is specified
const isFirstUser = await this.isFirstUser();
if (!isFirstUser && !createdByUserId) {
throw new Error('Only existing administrators can create new accounts');
}
// Validate username format
if (!/^[a-zA-Z0-9_-]{3,20}$/.test(username)) {
throw new Error('Username must be 3-20 characters, letters, numbers, underscore, or hyphen only');
}
// Validate password strength
if (password.length < 8) {
throw new Error('Password must be at least 8 characters long');
}
try {
const passwordHash = await bcrypt.hash(password, this.saltRounds);
return new Promise((resolve, reject) => {
const stmt = this.db.prepare(`
INSERT INTO users (username, password_hash, created_by)
VALUES (?, ?, ?)
`);
stmt.run([username, passwordHash, createdByUserId], function(err) {
if (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
reject(new Error('Username already exists'));
} else {
reject(err);
}
return;
}
resolve({
id: this.lastID,
username: username,
created_at: new Date().toISOString()
});
});
stmt.finalize();
});
} catch (error) {
throw new Error('Failed to create user: ' + error.message);
}
}
async authenticateUser(username, password) {
if (!username || !password) {
throw new Error('Username and password are required');
}
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM users WHERE username = ? AND is_active = 1',
[username],
async (err, user) => {
if (err) {
reject(err);
return;
}
if (!user) {
reject(new Error('Invalid username or password'));
return;
}
try {
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
reject(new Error('Invalid username or password'));
return;
}
// Update last login
this.db.run(
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
[user.id]
);
// Return user info (without password hash)
resolve({
id: user.id,
username: user.username,
created_at: user.created_at,
last_login: user.last_login
});
} catch (bcryptError) {
reject(bcryptError);
}
}
);
});
}
async getUserById(id) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1',
[id],
(err, user) => {
if (err) {
reject(err);
return;
}
resolve(user || null);
}
);
});
}
async getUserByUsername(username) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT id, username, created_at, last_login FROM users WHERE username = ? AND is_active = 1',
[username],
(err, user) => {
if (err) {
reject(err);
return;
}
resolve(user || null);
}
);
});
}
async getAllUsers() {
return new Promise((resolve, reject) => {
this.db.all(
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 ORDER BY created_at DESC',
[],
(err, users) => {
if (err) {
reject(err);
return;
}
resolve(users || []);
}
);
});
}
async deleteUser(id) {
return new Promise((resolve, reject) => {
this.db.run(
'UPDATE users SET is_active = 0 WHERE id = ?',
[id],
function(err) {
if (err) {
reject(err);
return;
}
resolve(this.changes > 0);
}
);
});
}
async changePassword(id, currentPassword, newPassword) {
if (!currentPassword || !newPassword) {
throw new Error('Current password and new password are required');
}
if (newPassword.length < 8) {
throw new Error('New password must be at least 8 characters long');
}
return new Promise((resolve, reject) => {
this.db.get(
'SELECT password_hash FROM users WHERE id = ? AND is_active = 1',
[id],
async (err, user) => {
if (err) {
reject(err);
return;
}
if (!user) {
reject(new Error('User not found'));
return;
}
try {
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash);
if (!passwordMatch) {
reject(new Error('Current password is incorrect'));
return;
}
const newPasswordHash = await bcrypt.hash(newPassword, this.saltRounds);
this.db.run(
'UPDATE users SET password_hash = ? WHERE id = ?',
[newPasswordHash, id],
function(err) {
if (err) {
reject(err);
return;
}
resolve(this.changes > 0);
}
);
} catch (bcryptError) {
reject(bcryptError);
}
}
);
});
}
async isFirstUser() {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT COUNT(*) as count FROM users WHERE is_active = 1',
[],
(err, result) => {
if (err) {
reject(err);
return;
}
resolve(result.count === 0);
}
);
});
}
close() {
if (this.db) {
this.db.close();
}
}
}
module.exports = AuthManager;