- replace bearer auth with session+CSRF flow and add admin user directory - update frontend moderation flow, force password change gate, and new CLI - refresh changelog/docs/feature plan + ensure swagger dev experience
165 lines
5.0 KiB
JavaScript
165 lines
5.0 KiB
JavaScript
const bcrypt = require('bcryptjs');
|
|
const crypto = require('crypto');
|
|
const AdminUserRepository = require('../repositories/AdminUserRepository');
|
|
|
|
const DEFAULT_SALT_ROUNDS = parseInt(process.env.ADMIN_PASSWORD_SALT_ROUNDS || '12', 10);
|
|
|
|
class AdminAuthService {
|
|
async needsInitialSetup() {
|
|
const count = await AdminUserRepository.countActiveAdmins();
|
|
return count === 0;
|
|
}
|
|
|
|
async createInitialAdmin({ username, password }) {
|
|
const trimmedUsername = (username || '').trim().toLowerCase();
|
|
if (!trimmedUsername) {
|
|
throw new Error('USERNAME_REQUIRED');
|
|
}
|
|
if (!password || password.length < 10) {
|
|
throw new Error('PASSWORD_TOO_WEAK');
|
|
}
|
|
|
|
const needsSetup = await this.needsInitialSetup();
|
|
if (!needsSetup) {
|
|
throw new Error('SETUP_ALREADY_COMPLETED');
|
|
}
|
|
|
|
const passwordHash = await this.hashPassword(password);
|
|
const id = await AdminUserRepository.createAdminUser({
|
|
username: trimmedUsername,
|
|
passwordHash,
|
|
role: 'admin',
|
|
requiresPasswordChange: false
|
|
});
|
|
|
|
return {
|
|
id,
|
|
username: trimmedUsername,
|
|
role: 'admin'
|
|
};
|
|
}
|
|
|
|
async createAdminUser({ username, password, role = 'admin', requiresPasswordChange = false }) {
|
|
const trimmedUsername = (username || '').trim().toLowerCase();
|
|
if (!trimmedUsername) {
|
|
throw new Error('USERNAME_REQUIRED');
|
|
}
|
|
if (!password || password.length < 10) {
|
|
throw new Error('PASSWORD_TOO_WEAK');
|
|
}
|
|
|
|
const normalizedRole = (role || 'admin').trim().toLowerCase();
|
|
const targetRole = normalizedRole || 'admin';
|
|
|
|
const existing = await AdminUserRepository.getByUsername(trimmedUsername);
|
|
if (existing) {
|
|
throw new Error('USERNAME_IN_USE');
|
|
}
|
|
|
|
const passwordHash = await this.hashPassword(password);
|
|
const id = await AdminUserRepository.createAdminUser({
|
|
username: trimmedUsername,
|
|
passwordHash,
|
|
role: targetRole,
|
|
requiresPasswordChange
|
|
});
|
|
|
|
return {
|
|
id,
|
|
username: trimmedUsername,
|
|
role: targetRole,
|
|
requiresPasswordChange: Boolean(requiresPasswordChange)
|
|
};
|
|
}
|
|
|
|
async changePassword({ userId, currentPassword, newPassword }) {
|
|
if (!userId) {
|
|
throw new Error('USER_NOT_FOUND');
|
|
}
|
|
if (!currentPassword) {
|
|
throw new Error('CURRENT_PASSWORD_REQUIRED');
|
|
}
|
|
if (!newPassword || newPassword.length < 10) {
|
|
throw new Error('PASSWORD_TOO_WEAK');
|
|
}
|
|
|
|
const userRecord = await AdminUserRepository.getById(userId);
|
|
if (!userRecord || !userRecord.is_active) {
|
|
throw new Error('USER_NOT_FOUND');
|
|
}
|
|
|
|
const matches = await bcrypt.compare(currentPassword || '', userRecord.password_hash);
|
|
if (!matches) {
|
|
throw new Error('INVALID_CURRENT_PASSWORD');
|
|
}
|
|
|
|
const passwordHash = await this.hashPassword(newPassword);
|
|
await AdminUserRepository.updatePassword(userRecord.id, passwordHash, false);
|
|
|
|
return {
|
|
id: userRecord.id,
|
|
username: userRecord.username,
|
|
role: userRecord.role,
|
|
requiresPasswordChange: false
|
|
};
|
|
}
|
|
|
|
async hashPassword(password) {
|
|
return bcrypt.hash(password, DEFAULT_SALT_ROUNDS);
|
|
}
|
|
|
|
async verifyCredentials(username, password) {
|
|
const normalizedUsername = (username || '').trim().toLowerCase();
|
|
const user = await AdminUserRepository.getByUsername(normalizedUsername);
|
|
if (!user || !user.is_active) {
|
|
return null;
|
|
}
|
|
|
|
const matches = await bcrypt.compare(password || '', user.password_hash);
|
|
if (!matches) {
|
|
return null;
|
|
}
|
|
|
|
await AdminUserRepository.recordSuccessfulLogin(user.id);
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
requiresPasswordChange: Boolean(user.requires_password_change)
|
|
};
|
|
}
|
|
|
|
generateCsrfToken() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
startSession(req, user) {
|
|
const csrfToken = this.generateCsrfToken();
|
|
req.session.user = {
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
requiresPasswordChange: user.requiresPasswordChange || false
|
|
};
|
|
req.session.csrfToken = csrfToken;
|
|
return csrfToken;
|
|
}
|
|
|
|
async destroySession(req) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!req.session) {
|
|
return resolve();
|
|
}
|
|
req.session.destroy((err) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = new AdminAuthService();
|