305 lines
17 KiB
JavaScript
305 lines
17 KiB
JavaScript
// Admin account route registration and profile helpers.
|
|
|
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
|
const { renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
|
|
|
module.exports = function registerAccountRoutes(app, deps) {
|
|
const pool = deps.pool;
|
|
const common = deps.common;
|
|
const pages = deps.pages;
|
|
const formatDashboardDate = deps.formatDashboardDate;
|
|
const getAuditUserId = deps.getAuditUserId;
|
|
const verifyPassword = deps.verifyPassword;
|
|
const hashPassword = deps.hashPassword;
|
|
const validatePasswordStrength = deps.validatePasswordStrength;
|
|
const createUserSession = deps.createUserSession;
|
|
const setSessionCookie = deps.setSessionCookie;
|
|
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
|
|
const parseCookies = deps.parseCookies;
|
|
const hashSessionToken = deps.hashSessionToken;
|
|
const sessionCookieName = deps.sessionCookieName;
|
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
|
const sendAccountEmail = deps.sendAccountEmail;
|
|
const createOneTimeToken = deps.createOneTimeToken;
|
|
|
|
async function getPasswordRequirements() {
|
|
const settings = await fetchAppSettings(pool);
|
|
return buildPasswordRequirements(settings);
|
|
}
|
|
|
|
function buildPasswordRequirements(settings) {
|
|
return {
|
|
minimumLength: settings['security.password_min_length'],
|
|
minimumCategories: settings['security.password_min_categories'],
|
|
requireLowercase: settings['security.password_require_lowercase'],
|
|
requireUppercase: settings['security.password_require_uppercase'],
|
|
requireNumber: settings['security.password_require_number'],
|
|
requireSymbol: settings['security.password_require_symbol']
|
|
};
|
|
}
|
|
|
|
function getSessionMetadata(req) {
|
|
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
return {
|
|
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(),
|
|
userAgent: req && req.headers && req.headers['user-agent']
|
|
};
|
|
}
|
|
|
|
function getRequestOrigin(req) {
|
|
const forwardedProto = String(req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
|
|
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
|
|
return forwardedHost ? forwardedProto + '://' + forwardedHost : '';
|
|
}
|
|
|
|
app.post('/account/email', async function (req, res, next) {
|
|
try {
|
|
const email = String(req.body.email || '').trim().toLowerCase();
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
|
|
const settings = await fetchAppSettings(pool);
|
|
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.');
|
|
const token = createOneTimeToken();
|
|
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]);
|
|
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
|
|
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
|
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email });
|
|
res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/account', function (req, res) {
|
|
fetchAppSettings(pool).then(function (settings) {
|
|
const passwordRequirements = buildPasswordRequirements(settings);
|
|
if (!settings['security.allow_user_session_revocation']) {
|
|
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements));
|
|
}
|
|
const cookies = parseCookies(req.headers.cookie || '');
|
|
const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : '';
|
|
return pool.query(
|
|
'SELECT id, session_hash, ip_address, user_agent, created_at, last_used_at, expires_at FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at DESC',
|
|
[req.currentUser.id]
|
|
).then(function (result) {
|
|
const rows = result[0] || [];
|
|
const sessions = rows.map(function (session) {
|
|
return {
|
|
id: Number(session.id),
|
|
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
|
|
ipAddress: String(session.ip_address || 'Unknown'),
|
|
userAgent: String(session.user_agent || 'Unknown browser'),
|
|
createdAtLabel: formatDashboardDate(session.created_at),
|
|
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
|
|
expiresAtLabel: formatDashboardDate(session.expires_at)
|
|
};
|
|
});
|
|
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements));
|
|
});
|
|
}).catch(function (error) {
|
|
res.status(500).send(error.message || 'Unable to load account settings.');
|
|
});
|
|
});
|
|
|
|
app.post('/account/profile', async function (req, res, next) {
|
|
try {
|
|
const name = String(req.body.name || '').trim();
|
|
const username = common.validateMaxLength(req.body.username || '', 64, 'Username');
|
|
const email = String(req.body.email || '').trim().toLowerCase();
|
|
const currentPassword = String(req.body.current_password || '');
|
|
if (!name) return res.status(400).send('Name is required.');
|
|
if (!username) return res.status(400).send('Username is required.');
|
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
|
|
if (!currentPassword) return res.status(400).send('Current password is required.');
|
|
|
|
const [rows] = await pool.query('SELECT id, username, name, email, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
|
const user = rows[0] || null;
|
|
if (!user) return res.status(404).send('User not found.');
|
|
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
|
|
if (username !== user.username && await common.fetchDuplicateName(pool, 'a_users', username, user.id, 'username')) return res.status(400).send('That username already exists.');
|
|
if (name !== user.name && await common.fetchDuplicateName(pool, 'a_users', name, user.id)) return res.status(400).send('That name already exists.');
|
|
|
|
const confirmedEmail = String(user.email || '').trim().toLowerCase();
|
|
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
|
|
const emailChanged = email !== confirmedEmail && email !== pendingEmail;
|
|
const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail);
|
|
const settings = emailChanged ? await fetchAppSettings(pool) : null;
|
|
if (emailChanged && email && (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function')) return res.status(400).send('Email delivery is not configured.');
|
|
|
|
const actorId = getAuditUserId(req);
|
|
await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]);
|
|
if (emailChanged || pendingEmailCleared) {
|
|
if (!email) {
|
|
await pool.query('UPDATE a_users SET email = NULL, email_verified_at = NULL, pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
|
} else if (pendingEmailCleared) {
|
|
await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
|
} else {
|
|
const token = createOneTimeToken();
|
|
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]);
|
|
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
|
|
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
|
|
}
|
|
}
|
|
if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } });
|
|
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. Check your new email address for a verification link.' : 'Account details updated.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/account/sessions/:id/revoke', async function (req, res, next) {
|
|
try {
|
|
const sessionId = Number(req.params.id);
|
|
if (!Number.isInteger(sessionId) || sessionId <= 0) {
|
|
return res.status(400).send('Invalid session.');
|
|
}
|
|
const settings = await fetchAppSettings(pool);
|
|
if (!settings['security.allow_user_session_revocation']) {
|
|
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
|
}
|
|
const cookies = parseCookies(req.headers.cookie || '');
|
|
const token = cookies[sessionCookieName];
|
|
if (!token) {
|
|
return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.'));
|
|
}
|
|
const [result] = await pool.query(
|
|
'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?',
|
|
[sessionId, req.currentUser.id, hashSessionToken(token)]
|
|
);
|
|
if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') {
|
|
await recordRequestAuditEvent(pool, req, {
|
|
category: 'sessions',
|
|
eventType: 'session.revoked',
|
|
actorUserId: req.currentUser.id,
|
|
targetType: 'user',
|
|
targetId: req.currentUser.id,
|
|
targetLabel: req.currentUser.username,
|
|
details: { scope: 'single' }
|
|
});
|
|
}
|
|
res.redirect('/account?message=' + encodeURIComponent(result && result.affectedRows ? 'Session signed out.' : 'The current session cannot be signed out.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/account/sessions/revoke', async function (req, res, next) {
|
|
try {
|
|
const settings = await fetchAppSettings(pool);
|
|
if (!settings['security.allow_user_session_revocation']) {
|
|
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
|
}
|
|
const cookies = parseCookies(req.headers.cookie || '');
|
|
const token = cookies[sessionCookieName];
|
|
if (token) {
|
|
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, hashSessionToken(token)]);
|
|
}
|
|
if (typeof recordRequestAuditEvent === 'function') {
|
|
await recordRequestAuditEvent(pool, req, {
|
|
category: 'sessions',
|
|
eventType: 'session.revoked',
|
|
actorUserId: req.currentUser.id,
|
|
targetType: 'user',
|
|
targetId: req.currentUser.id,
|
|
targetLabel: req.currentUser.username,
|
|
details: { scope: 'other_sessions' }
|
|
});
|
|
}
|
|
res.redirect('/account?message=' + encodeURIComponent('Other active sessions were signed out.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/account/name', async function (req, res, next) {
|
|
try {
|
|
const name = String(req.body.name || '').trim();
|
|
if (!name) {
|
|
return res.status(400).send('Name is required.');
|
|
}
|
|
|
|
if (await common.fetchDuplicateName(pool, 'a_users', name, req.currentUser.id)) {
|
|
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
|
|
}
|
|
|
|
const actorId = getAuditUserId(req);
|
|
const [result] = await pool.query('UPDATE a_users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
|
|
if (!result.affectedRows) {
|
|
return res.status(404).send('User not found.');
|
|
}
|
|
|
|
res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/account/username', async function (req, res, next) {
|
|
try {
|
|
const username = common.validateMaxLength(req.body.username || '', 255, 'Username');
|
|
const currentPassword = String(req.body.current_password || '');
|
|
if (!username) return res.status(400).send('Username is required.');
|
|
if (!currentPassword) return res.status(400).send('Current password is required.');
|
|
const [rows] = await pool.query('SELECT id, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
|
const user = rows[0] || null;
|
|
if (!user) return res.status(404).send('User not found.');
|
|
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
|
|
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, user.id]);
|
|
if (existingRows.length) return res.status(400).send('That username already exists.');
|
|
await pool.query('UPDATE a_users SET username = ?, modified_by = ? WHERE id = ?', [username, getAuditUserId(req), user.id]);
|
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.username_updated', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: username, details: { previousUsername: user.username, username: username } });
|
|
res.redirect('/account?message=' + encodeURIComponent('Username updated.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/account/password', async function (req, res, next) {
|
|
try {
|
|
const currentPassword = String(req.body.current_password || '');
|
|
const newPassword = String(req.body.new_password || '');
|
|
const confirmPassword = String(req.body.confirm_password || '');
|
|
|
|
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
|
const user = rows[0] || null;
|
|
if (!user) {
|
|
return res.status(404).send('User not found.');
|
|
}
|
|
if (!verifyPassword(currentPassword, user)) {
|
|
return res.status(400).send('Current password is incorrect.');
|
|
}
|
|
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
|
|
if (passwordStrengthMessage) {
|
|
return res.status(400).send(passwordStrengthMessage);
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
return res.status(400).send('New passwords do not match.');
|
|
}
|
|
|
|
const passwordRecord = hashPassword(newPassword);
|
|
await pool.query(
|
|
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?',
|
|
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
|
|
);
|
|
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
|
|
if (typeof recordRequestAuditEvent === 'function') {
|
|
await recordRequestAuditEvent(pool, req, {
|
|
category: 'security',
|
|
eventType: 'password.changed',
|
|
actorUserId: user.id,
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
targetLabel: user.username,
|
|
details: { source: 'account' }
|
|
});
|
|
}
|
|
|
|
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
|
|
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req));
|
|
setSessionCookie(res, token, sessionMaxAgeMs);
|
|
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
};
|