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>
This commit is contained in:
288
utils/auth.js
Normal file
288
utils/auth.js
Normal file
@@ -0,0 +1,288 @@
|
||||
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;
|
Reference in New Issue
Block a user