tinfo-web-server / controllers /authController.js
Faridaqurr
initial backend
e8c33fa
Raw
History Blame Contribute Delete
13.5 kB
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const mongoose = require('mongoose');
const { asyncController } = require('../utils/asyncController');
const { recordAuditTrail } = require('../services/auditTrailService');
const AdminUser = require('../models/AdminUser');
const { ensureAdminInitialized } = require('../services/adminAuthService');
const { isValidPersonName } = require('../validation/commonValidation');
const { env } = require('../configs/env');
const JWT_SECRET = env.jwtSecret;
const BCRYPT_SALT_ROUNDS = 12;
const USERNAME_PATTERN = /^[a-z0-9._-]{3,30}$/;
const buildAuthUser = (admin) => ({
id: String(admin._id),
username: admin.username,
name: admin.name,
role: 'admin',
isActive: admin.isActive !== false,
});
const generateToken = (user, sessionToken) => {
if (!JWT_SECRET) {
throw new Error('JWT_SECRET is not configured');
}
const payload = { id: user.id };
if (sessionToken) payload.st = sessionToken;
return jwt.sign(payload, JWT_SECRET, {
expiresIn: env.jwtExpire,
});
};
const normalizeUsername = (value) => String(value || '').trim().toLowerCase();
const buildManagedUser = (admin) => ({
id: String(admin._id),
username: admin.username,
name: admin.name,
role: admin.role || 'admin',
isActive: admin.isActive !== false,
createdAt: admin.createdAt,
updatedAt: admin.updatedAt,
});
const login = asyncController(async (req, res) => {
await ensureAdminInitialized();
const identifier = String(req.body?.email || req.body?.username || '').trim();
const password = String(req.body?.password || '');
if (!identifier || !password) {
return res.status(400).json({ message: 'Username and password are required' });
}
const admin = await AdminUser.findOne({
username: identifier.toLowerCase(),
}).select('+passwordHash');
if (!admin) {
return res.status(401).json({ message: 'Incorrect username or password.' });
}
if (admin.isActive === false) {
return res.status(403).json({ message: 'This account is inactive' });
}
const isValidPass = await bcrypt.compare(password, admin.passwordHash || '');
if (!isValidPass) {
return res.status(401).json({ message: 'Incorrect username or password.' });
}
const sessionToken = crypto.randomBytes(32).toString('hex');
await AdminUser.findByIdAndUpdate(admin._id, { sessionToken });
const user = buildAuthUser(admin);
const token = generateToken(user, sessionToken);
void recordAuditTrail({
req,
actor: { id: user.id, name: user.name },
action: 'login',
moduleName: 'auth',
summary: 'Admin logged in',
endpoint: 'POST /api/auth/login',
method: 'POST',
statusCode: 200,
});
res.cookie('token', token, {
httpOnly: true,
secure: env.isProduction,
sameSite: 'strict',
});
return res.json({ token, user });
});
const register = async (req, res) => {
return res.status(501).json({ message: 'Registration is disabled for this project' });
};
const getMe = (req, res) => {
return res.json(req.user || null);
};
const updateProfile = asyncController(async (req, res) => {
await ensureAdminInitialized();
const { currentPassword, newName, newUsername, newPassword } = req.body || {};
if (!currentPassword) {
return res.status(400).json({ message: 'Current password is required' });
}
const adminId = req.user?.id || req.user?._id;
if (!adminId) {
return res.status(401).json({ message: 'Not authorized, invalid token' });
}
const admin = await AdminUser.findById(adminId).select('+passwordHash');
if (!admin) {
return res.status(404).json({ message: 'Account not found' });
}
const isCurrentPasswordValid = await bcrypt.compare(currentPassword, admin.passwordHash || '');
if (!isCurrentPasswordValid) {
return res.status(401).json({ message: 'Current password is incorrect' });
}
if (newUsername !== undefined) {
const normalizedUsername = normalizeUsername(newUsername);
if (!USERNAME_PATTERN.test(normalizedUsername)) {
return res.status(400).json({
message: 'Username must be 3-30 characters using letters, numbers, dots, dashes, or underscores',
});
}
if (normalizedUsername !== admin.username) {
const usernameInUse = await AdminUser.findOne({
username: normalizedUsername,
_id: { $ne: admin._id },
})
.select('_id')
.lean();
if (usernameInUse) {
return res.status(409).json({ message: 'Username is already taken' });
}
admin.username = normalizedUsername;
}
}
if (newName && newName.trim()) {
if (!isValidPersonName(newName)) {
return res.status(400).json({ message: 'Display name must contain letters only (no numbers)' });
}
admin.name = newName.trim();
}
if (newPassword) {
if (newPassword.length < env.adminPasswordMinLength) {
return res.status(400).json({ message: `New password must be at least ${env.adminPasswordMinLength} characters` });
}
admin.passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
}
await admin.save();
const updatedAdmin = await AdminUser.findById(admin._id).select('+sessionToken').lean();
const user = buildAuthUser(admin);
const token = generateToken(user, updatedAdmin?.sessionToken || undefined);
res.cookie('token', token, {
httpOnly: true,
secure: env.isProduction,
sameSite: 'strict',
});
return res.json({ user, message: 'Profile updated successfully' });
});
const listUsers = asyncController(async (_req, res) => {
await ensureAdminInitialized();
const users = await AdminUser.find({})
.sort({ createdAt: 1 })
.select('_id username name role isActive createdAt updatedAt')
.lean();
return res.json((users || []).map(buildManagedUser));
});
const createUser = asyncController(async (req, res) => {
await ensureAdminInitialized();
const username = normalizeUsername(req.body?.username);
const name = String(req.body?.name || '').trim();
const password = String(req.body?.password || '');
const isActive = req.body?.isActive !== false;
if (!USERNAME_PATTERN.test(username)) {
return res.status(400).json({
message: 'Username must be 3-30 characters using letters, numbers, dots, dashes, or underscores',
});
}
if (!name) {
return res.status(400).json({ message: 'Display name is required' });
}
if (!isValidPersonName(name)) {
return res.status(400).json({ message: 'Display name must contain letters only (no numbers)' });
}
if (password.length < env.adminPasswordMinLength) {
return res.status(400).json({ message: `Password must be at least ${env.adminPasswordMinLength} characters` });
}
const usernameInUse = await AdminUser.findOne({ username }).select('_id').lean();
if (usernameInUse) {
return res.status(409).json({ message: 'Username is already taken' });
}
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const admin = await AdminUser.create({
username,
name,
passwordHash,
role: 'admin',
isActive,
});
return res.status(201).json(buildManagedUser(admin));
});
const updateUser = asyncController(async (req, res) => {
await ensureAdminInitialized();
const targetId = String(req.params?.id || '').trim();
if (!targetId) {
return res.status(400).json({ message: 'User id is required' });
}
const admin = await AdminUser.findById(targetId).select('+passwordHash');
if (!admin) {
return res.status(404).json({ message: 'User not found' });
}
const requesterId = String(req.user?.id || req.user?._id || '');
const hasUsername = Object.prototype.hasOwnProperty.call(req.body || {}, 'username');
const hasName = Object.prototype.hasOwnProperty.call(req.body || {}, 'name');
const hasPassword = Object.prototype.hasOwnProperty.call(req.body || {}, 'password');
const hasIsActive = Object.prototype.hasOwnProperty.call(req.body || {}, 'isActive');
if (hasUsername) {
const username = normalizeUsername(req.body?.username);
if (!USERNAME_PATTERN.test(username)) {
return res.status(400).json({
message: 'Username must be 3-30 characters using letters, numbers, dots, dashes, or underscores',
});
}
if (username !== admin.username) {
const usernameInUse = await AdminUser.findOne({ username, _id: { $ne: admin._id } }).select('_id').lean();
if (usernameInUse) {
return res.status(409).json({ message: 'Username is already taken' });
}
admin.username = username;
}
}
if (hasName) {
const name = String(req.body?.name || '').trim();
if (!name) {
return res.status(400).json({ message: 'Display name is required' });
}
if (!isValidPersonName(name)) {
return res.status(400).json({ message: 'Display name must contain letters only (no numbers)' });
}
admin.name = name;
}
if (hasPassword) {
const password = String(req.body?.password || '');
if (password && password.length < env.adminPasswordMinLength) {
return res.status(400).json({ message: `Password must be at least ${env.adminPasswordMinLength} characters` });
}
if (password) {
admin.passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
}
}
if (hasIsActive) {
const nextIsActive = req.body?.isActive !== false;
if (requesterId && requesterId === String(admin._id) && !nextIsActive) {
return res.status(400).json({ message: 'You cannot deactivate your own account' });
}
if (!nextIsActive && admin.isActive !== false) {
const session = await mongoose.startSession();
let saved;
try {
await session.withTransaction(async () => {
const activeCount = await AdminUser.countDocuments(
{ isActive: { $ne: false } },
{ session }
);
if (activeCount <= 1) {
throw Object.assign(new Error('At least one active admin account is required'), { statusCode: 400 });
}
admin.isActive = false;
saved = await admin.save({ session });
});
} catch (txErr) {
if (txErr.statusCode === 400) {
return res.status(400).json({ message: txErr.message });
}
throw txErr;
} finally {
await session.endSession();
}
return res.json(buildManagedUser(saved || admin));
}
admin.isActive = nextIsActive;
}
await admin.save();
return res.json(buildManagedUser(admin));
});
const deleteUser = asyncController(async (req, res) => {
await ensureAdminInitialized();
const targetId = String(req.params?.id || '').trim();
if (!targetId) {
return res.status(400).json({ message: 'User id is required' });
}
const requesterId = String(req.user?.id || req.user?._id || '');
if (requesterId && requesterId === targetId) {
return res.status(400).json({ message: 'You cannot delete your own account' });
}
const targetUser = await AdminUser.findById(targetId).select('_id isActive').lean();
if (!targetUser) {
return res.status(404).json({ message: 'User not found' });
}
if (targetUser.isActive !== false) {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
const activeCount = await AdminUser.countDocuments(
{ isActive: { $ne: false } },
{ session }
);
if (activeCount <= 1) {
throw Object.assign(new Error('At least one active admin account is required'), { statusCode: 400 });
}
const deleted = await AdminUser.findByIdAndDelete(targetId, { session }).select('_id').lean();
if (!deleted) {
throw Object.assign(new Error('User not found'), { statusCode: 404 });
}
});
} catch (txErr) {
if (txErr.statusCode === 400) return res.status(400).json({ message: txErr.message });
if (txErr.statusCode === 404) return res.status(404).json({ message: txErr.message });
throw txErr;
} finally {
await session.endSession();
}
return res.status(204).send();
}
const deleted = await AdminUser.findByIdAndDelete(targetId).select('_id').lean();
if (!deleted) {
return res.status(404).json({ message: 'User not found' });
}
return res.status(204).send();
});
const logout = asyncController(async (req, res) => {
const token = req.cookies?.token;
let actorId = 'unknown';
let actorName = 'Administrator';
if (token) {
try {
const decoded = jwt.decode(token);
if (decoded?.id) {
actorId = String(decoded.id);
const admin = await AdminUser.findById(actorId).select('name').lean();
if (admin?.name) actorName = admin.name;
}
} catch (_) {}
}
if (actorId !== 'unknown') {
try {
await AdminUser.findByIdAndUpdate(actorId, { sessionToken: null });
} catch (_) {}
}
void recordAuditTrail({
req,
actor: { id: actorId, name: actorName },
action: 'logout',
moduleName: 'auth',
summary: 'Admin logged out',
endpoint: 'POST /api/auth/logout',
method: 'POST',
statusCode: 200,
});
res.clearCookie('token', { httpOnly: true, secure: env.isProduction, sameSite: 'strict' });
return res.json({ message: 'Logged out' });
});
module.exports = {
login,
logout,
register,
getMe,
updateProfile,
listUsers,
createUser,
updateUser,
deleteUser,
};