Project-Image-Uploader/backend/src/services/AdminAuthService.js
matthias.lotz 6332b82c6a Feature Request: admin session security
- 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
2025-11-23 21:18:42 +01:00

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();