mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-02-23 23:47:43 +01:00
Enable CSRF for public endpoints. Split users module. Add rate limiter.
This commit is contained in:
parent
497f38111f
commit
411a8ef8a7
22
package-lock.json
generated
22
package-lock.json
generated
@ -39,6 +39,7 @@
|
|||||||
"png-chunk-text": "^1.0.0",
|
"png-chunk-text": "^1.0.0",
|
||||||
"png-chunks-encode": "^1.0.0",
|
"png-chunks-encode": "^1.0.0",
|
||||||
"png-chunks-extract": "^1.0.0",
|
"png-chunks-extract": "^1.0.0",
|
||||||
|
"rate-limiter-flexible": "^5.0.0",
|
||||||
"response-time": "^2.3.2",
|
"response-time": "^2.3.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"sillytavern-transformers": "^2.14.6",
|
"sillytavern-transformers": "^2.14.6",
|
||||||
@ -57,6 +58,7 @@
|
|||||||
"sillytavern": "server.js"
|
"sillytavern": "server.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jquery": "^3.5.29",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"jquery": "^3.6.4"
|
"jquery": "^3.6.4"
|
||||||
}
|
}
|
||||||
@ -731,6 +733,15 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jquery": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-oXQQC9X9MOPRrMhPHHOsXqeQDnWeCDT3PelUIg/Oy8FAbzSZtFHRjc7IpbfFVmpLtJ+UOoywpRsuO5Jxjybyeg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/sizzle": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/keyv": {
|
"node_modules/@types/keyv": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -761,6 +772,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/sizzle": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -3288,6 +3305,11 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rate-limiter-flexible": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ivCyLBwPtR5IRrz+aZnztVwX16ZK3iAjdlW21I/vjHq56at5Zb8eIefDzODg8R7hwPOHpBtb6Pj9Zdmn0nRb8g=="
|
||||||
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "2.5.2",
|
"version": "2.5.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
@ -29,6 +29,7 @@
|
|||||||
"png-chunk-text": "^1.0.0",
|
"png-chunk-text": "^1.0.0",
|
||||||
"png-chunks-encode": "^1.0.0",
|
"png-chunks-encode": "^1.0.0",
|
||||||
"png-chunks-extract": "^1.0.0",
|
"png-chunks-extract": "^1.0.0",
|
||||||
|
"rate-limiter-flexible": "^5.0.0",
|
||||||
"response-time": "^2.3.2",
|
"response-time": "^2.3.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"sillytavern-transformers": "^2.14.6",
|
"sillytavern-transformers": "^2.14.6",
|
||||||
@ -81,6 +82,7 @@
|
|||||||
},
|
},
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jquery": "^3.5.29",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"jquery": "^3.6.4"
|
"jquery": "^3.6.4"
|
||||||
}
|
}
|
||||||
|
@ -26,8 +26,8 @@
|
|||||||
<title>SillyTavern</title>
|
<title>SillyTavern</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="login" style="display: none;">
|
<body class="login">
|
||||||
<div id="shadow_popup">
|
<div id="shadow_popup" style="opacity: 0;">
|
||||||
<div id="dialogue_popup">
|
<div id="dialogue_popup">
|
||||||
<div id="dialogue_popup_holder">
|
<div id="dialogue_popup_holder">
|
||||||
<div id="dialogue_popup_text">
|
<div id="dialogue_popup_text">
|
||||||
|
@ -1,15 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* CRSF token for requests.
|
||||||
|
*/
|
||||||
|
let csrfToken = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a CSRF token from the server.
|
||||||
|
* @returns {Promise<string>} CSRF token
|
||||||
|
*/
|
||||||
|
async function getCsrfToken() {
|
||||||
|
const response = await fetch('/csrf-token');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a list of users from the server.
|
||||||
|
* @returns {Promise<object>} List of users
|
||||||
|
*/
|
||||||
async function getUserList() {
|
async function getUserList() {
|
||||||
const response = await fetch('/api/users/list');
|
const response = await fetch('/api/users/list', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
const userListObj = await response.json();
|
const userListObj = await response.json();
|
||||||
console.log(userListObj);
|
console.log(userListObj);
|
||||||
return userListObj;
|
return userListObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a recovery code for the user.
|
||||||
|
* @param {string} handle User handle
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async function sendRecoveryPart1(handle) {
|
async function sendRecoveryPart1(handle) {
|
||||||
const response = await fetch('/api/users/recover-step1', {
|
const response = await fetch('/api/users/recover-step1', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ handle }),
|
body: JSON.stringify({ handle }),
|
||||||
});
|
});
|
||||||
@ -22,6 +53,13 @@ async function sendRecoveryPart1(handle) {
|
|||||||
showRecoveryBlock();
|
showRecoveryBlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a new password for the user using the recovery code.
|
||||||
|
* @param {string} handle User handle
|
||||||
|
* @param {string} code Recovery code
|
||||||
|
* @param {string} newPassword New password
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async function sendRecoveryPart2(handle, code, newPassword) {
|
async function sendRecoveryPart2(handle, code, newPassword) {
|
||||||
const recoveryData = {
|
const recoveryData = {
|
||||||
handle,
|
handle,
|
||||||
@ -33,6 +71,7 @@ async function sendRecoveryPart2(handle, code, newPassword) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(recoveryData),
|
body: JSON.stringify(recoveryData),
|
||||||
});
|
});
|
||||||
@ -46,6 +85,50 @@ async function sendRecoveryPart2(handle, code, newPassword) {
|
|||||||
await performLogin(handle, newPassword);
|
await performLogin(handle, newPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to log in the user.
|
||||||
|
* @param {string} handle User's handle
|
||||||
|
* @param {string} password User's password
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function performLogin(handle, password) {
|
||||||
|
const userInfo = {
|
||||||
|
handle: handle,
|
||||||
|
password: password,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(userInfo),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
return displayError(errorData.error || 'An error occurred');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.handle) {
|
||||||
|
console.log(`Successfully logged in as ${handle}!`);
|
||||||
|
redirectToHome();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging in:', error);
|
||||||
|
displayError(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the user selection event.
|
||||||
|
* @param {object} user User object
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async function onUserSelected(user) {
|
async function onUserSelected(user) {
|
||||||
// No password, just log in
|
// No password, just log in
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
@ -72,52 +155,33 @@ async function onUserSelected(user) {
|
|||||||
displayError('');
|
displayError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an error message to the user.
|
||||||
|
* @param {string} message Error message
|
||||||
|
*/
|
||||||
function displayError(message) {
|
function displayError(message) {
|
||||||
$('#errorMessage').text(message);
|
$('#errorMessage').text(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performLogin(handle, password) {
|
/**
|
||||||
const userInfo = {
|
* Redirects the user to the home page.
|
||||||
handle: handle,
|
*/
|
||||||
password: password,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/users/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(userInfo),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
return displayError(errorData.error || 'An error occurred');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.handle) {
|
|
||||||
console.log(`Successfully logged in as ${handle}!`);
|
|
||||||
redirectToHome();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error logging in:', error);
|
|
||||||
displayError(String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function redirectToHome() {
|
function redirectToHome() {
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the password entry block and shows the password recovery block.
|
||||||
|
*/
|
||||||
function showRecoveryBlock() {
|
function showRecoveryBlock() {
|
||||||
$('#passwordEntryBlock').hide();
|
$('#passwordEntryBlock').hide();
|
||||||
$('#passwordRecoveryBlock').show();
|
$('#passwordRecoveryBlock').show();
|
||||||
displayError('');
|
displayError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the password recovery block and shows the password entry block.
|
||||||
|
*/
|
||||||
function onCancelRecoveryClick() {
|
function onCancelRecoveryClick() {
|
||||||
$('#passwordRecoveryBlock').hide();
|
$('#passwordRecoveryBlock').hide();
|
||||||
$('#passwordEntryBlock').show();
|
$('#passwordEntryBlock').show();
|
||||||
@ -125,6 +189,7 @@ function onCancelRecoveryClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
|
csrfToken = await getCsrfToken();
|
||||||
const userList = await getUserList();
|
const userList = await getUserList();
|
||||||
console.log(userList);
|
console.log(userList);
|
||||||
for (const user of userList) {
|
for (const user of userList) {
|
||||||
@ -137,6 +202,6 @@ function onCancelRecoveryClick() {
|
|||||||
userBlock.on('click', () => onUserSelected(user));
|
userBlock.on('click', () => onUserSelected(user));
|
||||||
$('#userList').append(userBlock);
|
$('#userList').append(userBlock);
|
||||||
}
|
}
|
||||||
document.body.style.display = '';
|
document.getElementById('shadow_popup').style.opacity = '';
|
||||||
$('#cancelRecovery').on('click', onCancelRecoveryClick);
|
$('#cancelRecovery').on('click', onCancelRecoveryClick);
|
||||||
})();
|
})();
|
||||||
|
77
server.js
77
server.js
@ -192,36 +192,6 @@ app.use(cookieSession({
|
|||||||
|
|
||||||
app.use(userModule.setUserDataMiddleware);
|
app.use(userModule.setUserDataMiddleware);
|
||||||
|
|
||||||
// Static files
|
|
||||||
// Host index page
|
|
||||||
app.get('/', (request, response) => {
|
|
||||||
if (userModule.shouldRedirectToLogin(request)) {
|
|
||||||
return response.redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') });
|
|
||||||
});
|
|
||||||
// Host login page
|
|
||||||
app.get('/login', async (request, response) => {
|
|
||||||
if (!enableAccounts) {
|
|
||||||
console.log('User accounts are disabled. Redirecting to index page.');
|
|
||||||
return response.redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoLogin = await userModule.tryAutoLogin(request);
|
|
||||||
|
|
||||||
if (autoLogin) {
|
|
||||||
return response.redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
|
|
||||||
});
|
|
||||||
app.use(express.static(process.cwd() + '/public', {}));
|
|
||||||
|
|
||||||
app.use('/api/users', userModule.publicEndpoints);
|
|
||||||
|
|
||||||
app.use(userModule.requireLoginMiddleware);
|
|
||||||
|
|
||||||
// CSRF Protection //
|
// CSRF Protection //
|
||||||
if (!cliArguments.disableCsrf) {
|
if (!cliArguments.disableCsrf) {
|
||||||
const COOKIES_SECRET = userModule.getCookieSecret();
|
const COOKIES_SECRET = userModule.getCookieSecret();
|
||||||
@ -255,12 +225,51 @@ if (!cliArguments.disableCsrf) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// User management
|
// Static files
|
||||||
app.use('/', userModule.router);
|
// Host index page
|
||||||
app.use('/api/users', userModule.authenticatedEndpoints);
|
app.get('/', (request, response) => {
|
||||||
app.use('/api/users', userModule.adminEndpoints);
|
if (userModule.shouldRedirectToLogin(request)) {
|
||||||
|
return response.redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host login page
|
||||||
|
app.get('/login', async (request, response) => {
|
||||||
|
if (!enableAccounts) {
|
||||||
|
console.log('User accounts are disabled. Redirecting to index page.');
|
||||||
|
return response.redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoLogin = await userModule.tryAutoLogin(request);
|
||||||
|
|
||||||
|
if (autoLogin) {
|
||||||
|
return response.redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host frontend assets
|
||||||
|
app.use(express.static(process.cwd() + '/public', {}));
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
app.use('/api/users', require('./src/endpoints/users-public').router);
|
||||||
|
|
||||||
|
// Everything below this line requires authentication
|
||||||
|
app.use(userModule.requireLoginMiddleware);
|
||||||
|
|
||||||
|
// File uploads
|
||||||
app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
|
app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
|
||||||
|
|
||||||
|
// User data mount
|
||||||
|
app.use('/', userModule.router);
|
||||||
|
// Private endpoints
|
||||||
|
app.use('/api/users', require('./src/endpoints/users-private').router);
|
||||||
|
// Admin endpoints
|
||||||
|
app.use('/api/users', require('./src/endpoints/users-admin').router);
|
||||||
|
|
||||||
app.get('/version', async function (_, response) {
|
app.get('/version', async function (_, response) {
|
||||||
const data = await getVersion();
|
const data = await getVersion();
|
||||||
response.send(data);
|
response.send(data);
|
||||||
|
@ -462,6 +462,7 @@ router.post('/update', jsonParser, function (request, response) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
router,
|
router,
|
||||||
|
recreateStats,
|
||||||
init,
|
init,
|
||||||
onExit,
|
onExit,
|
||||||
};
|
};
|
||||||
|
123
src/endpoints/users-admin.js
Normal file
123
src/endpoints/users-admin.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
const storage = require('node-persist');
|
||||||
|
const express = require('express');
|
||||||
|
const slugify = require('slugify').default;
|
||||||
|
const uuid = require('uuid');
|
||||||
|
const { jsonParser } = require('../express-common');
|
||||||
|
const { checkForNewContent } = require('./content-manager');
|
||||||
|
const {
|
||||||
|
KEY_PREFIX,
|
||||||
|
toKey,
|
||||||
|
requireAdminMiddleware,
|
||||||
|
getUserAvatar,
|
||||||
|
getAllUserHandles,
|
||||||
|
getPasswordSalt,
|
||||||
|
getPasswordHash,
|
||||||
|
getUserDirectories,
|
||||||
|
ensurePublicDirectoriesExist,
|
||||||
|
} = require('../users');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||||
|
/** @type {import('../users').User[]} */
|
||||||
|
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
||||||
|
|
||||||
|
const viewModels = users
|
||||||
|
.sort((x, y) => x.created - y.created)
|
||||||
|
.map(user => ({
|
||||||
|
handle: user.handle,
|
||||||
|
name: user.name,
|
||||||
|
avatar: getUserAvatar(user.handle),
|
||||||
|
admin: user.admin,
|
||||||
|
enabled: user.enabled,
|
||||||
|
created: user.created,
|
||||||
|
password: !!user.password,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return response.json(viewModels);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||||
|
if (!request.body.handle) {
|
||||||
|
console.log('Disable user failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.body.handle === request.user.profile.handle) {
|
||||||
|
console.log('Disable user failed: Cannot disable yourself');
|
||||||
|
return response.status(400).json({ error: 'Cannot disable yourself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('../users').User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Disable user failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.enabled = false;
|
||||||
|
await storage.setItem(toKey(request.body.handle), user);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||||
|
if (!request.body.handle) {
|
||||||
|
console.log('Enable user failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('../users').User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Enable user failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.enabled = true;
|
||||||
|
await storage.setItem(toKey(request.body.handle), user);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||||
|
if (!request.body.handle || !request.body.name) {
|
||||||
|
console.log('Create user failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = await getAllUserHandles();
|
||||||
|
const handle = slugify(request.body.handle, { lower: true, trim: true });
|
||||||
|
|
||||||
|
if (handles.some(x => x === handle)) {
|
||||||
|
console.log('Create user failed: User with that handle already exists');
|
||||||
|
return response.status(409).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = getPasswordSalt();
|
||||||
|
const password = request.body.password ? getPasswordHash(request.body.password, salt) : '';
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
uuid: uuid.v4(),
|
||||||
|
handle: handle,
|
||||||
|
name: request.body.name || 'Anonymous',
|
||||||
|
created: Date.now(),
|
||||||
|
password: password,
|
||||||
|
salt: salt,
|
||||||
|
admin: !!request.body.admin,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.setItem(toKey(handle), newUser);
|
||||||
|
|
||||||
|
// Create user directories
|
||||||
|
console.log('Creating data directories for', newUser.handle);
|
||||||
|
await ensurePublicDirectoriesExist();
|
||||||
|
const directories = getUserDirectories(newUser.handle);
|
||||||
|
await checkForNewContent([directories]);
|
||||||
|
return response.json({ handle: newUser.handle });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
router,
|
||||||
|
};
|
83
src/endpoints/users-private.js
Normal file
83
src/endpoints/users-private.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
const storage = require('node-persist');
|
||||||
|
const express = require('express');
|
||||||
|
const { jsonParser } = require('../express-common');
|
||||||
|
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/logout', async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (!request.session) {
|
||||||
|
console.error('Session not available');
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.session.handle = null;
|
||||||
|
return response.sendStatus(204);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (!request.user) {
|
||||||
|
return response.sendStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = request.user.profile;
|
||||||
|
const viewModel = {
|
||||||
|
handle: user.handle,
|
||||||
|
name: user.name,
|
||||||
|
avatar: getUserAvatar(user.handle),
|
||||||
|
admin: user.admin,
|
||||||
|
password: !!user.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response.json(viewModel);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/change-password', jsonParser, async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (!request.body.handle || !request.body.oldPassword || !request.body.newPassword) {
|
||||||
|
console.log('Change password failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('../users').User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Change password failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.enabled) {
|
||||||
|
console.log('Change password failed: User is disabled');
|
||||||
|
return response.status(403).json({ error: 'User is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
|
||||||
|
console.log('Change password failed: Incorrect password');
|
||||||
|
return response.status(401).json({ error: 'Incorrect password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = getPasswordSalt();
|
||||||
|
user.password = getPasswordHash(request.body.newPassword, salt);
|
||||||
|
user.salt = salt;
|
||||||
|
await storage.setItem(toKey(request.body.handle), user);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
router,
|
||||||
|
};
|
187
src/endpoints/users-public.js
Normal file
187
src/endpoints/users-public.js
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const storage = require('node-persist');
|
||||||
|
const express = require('express');
|
||||||
|
const { RateLimiterMemory, RateLimiterRes } = require('rate-limiter-flexible');
|
||||||
|
const { jsonParser, getIpFromRequest } = require('../express-common');
|
||||||
|
const { color, Cache } = require('../util');
|
||||||
|
const { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users');
|
||||||
|
|
||||||
|
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const loginLimiter = new RateLimiterMemory({
|
||||||
|
points: 5,
|
||||||
|
duration: 60,
|
||||||
|
});
|
||||||
|
const recoverLimiter = new RateLimiterMemory({
|
||||||
|
points: 5,
|
||||||
|
duration: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/list', async (_request, response) => {
|
||||||
|
try {
|
||||||
|
/** @type {import('../users').User[]} */
|
||||||
|
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
||||||
|
const viewModels = users
|
||||||
|
.filter(x => x.enabled)
|
||||||
|
.sort((x, y) => x.created - y.created)
|
||||||
|
.map(user => ({
|
||||||
|
handle: user.handle,
|
||||||
|
name: user.name,
|
||||||
|
avatar: getUserAvatar(user.handle),
|
||||||
|
password: !!user.password,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return response.json(viewModels);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User list failed:', error);
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', jsonParser, async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (!request.body.handle) {
|
||||||
|
console.log('Login failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getIpFromRequest(request);
|
||||||
|
await loginLimiter.consume(ip);
|
||||||
|
|
||||||
|
/** @type {import('../users').User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Login failed: User not found');
|
||||||
|
return response.status(401).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.enabled) {
|
||||||
|
console.log('Login failed: User is disabled');
|
||||||
|
return response.status(403).json({ error: 'User is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) {
|
||||||
|
console.log('Login failed: Incorrect password');
|
||||||
|
return response.status(401).json({ error: 'Incorrect password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.session) {
|
||||||
|
console.error('Session not available');
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginLimiter.delete(ip);
|
||||||
|
request.session.handle = user.handle;
|
||||||
|
console.log('Login successful:', user.handle, request.session);
|
||||||
|
return response.json({ handle: user.handle });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RateLimiterRes) {
|
||||||
|
console.log('Login failed: Rate limited from', getIpFromRequest(request));
|
||||||
|
return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/recover-step1', jsonParser, async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (!request.body.handle) {
|
||||||
|
console.log('Recover step 1 failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getIpFromRequest(request);
|
||||||
|
await recoverLimiter.consume(ip);
|
||||||
|
|
||||||
|
/** @type {import('../users').User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Recover step 1 failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.enabled) {
|
||||||
|
console.log('Recover step 1 failed: User is disabled');
|
||||||
|
return response.status(403).json({ error: 'User is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mfaCode = String(crypto.randomInt(1000, 9999));
|
||||||
|
console.log();
|
||||||
|
console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
|
||||||
|
console.log();
|
||||||
|
MFA_CACHE.set(user.handle, mfaCode);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RateLimiterRes) {
|
||||||
|
console.log('Recover step 1 failed: Rate limited from', getIpFromRequest(request));
|
||||||
|
return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Recover step 1 failed:', error);
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/recover-step2', jsonParser, async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (!request.body.handle || !request.body.code) {
|
||||||
|
console.log('Recover step 2 failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('../users').User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
const ip = getIpFromRequest(request);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Recover step 2 failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.enabled) {
|
||||||
|
console.log('Recover step 2 failed: User is disabled');
|
||||||
|
return response.status(403).json({ error: 'User is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mfaCode = MFA_CACHE.get(user.handle);
|
||||||
|
|
||||||
|
if (request.body.code !== mfaCode) {
|
||||||
|
await recoverLimiter.consume(ip);
|
||||||
|
console.log('Recover step 2 failed: Incorrect code');
|
||||||
|
return response.status(401).json({ error: 'Incorrect code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.body.newPassword) {
|
||||||
|
const salt = getPasswordSalt();
|
||||||
|
user.password = getPasswordHash(request.body.newPassword, salt);
|
||||||
|
user.salt = salt;
|
||||||
|
await storage.setItem(toKey(user.handle), user);
|
||||||
|
} else {
|
||||||
|
user.password = '';
|
||||||
|
user.salt = '';
|
||||||
|
await storage.setItem(toKey(user.handle), user);
|
||||||
|
}
|
||||||
|
|
||||||
|
await recoverLimiter.delete(ip);
|
||||||
|
MFA_CACHE.remove(user.handle);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RateLimiterRes) {
|
||||||
|
console.log('Recover step 2 failed: Rate limited from', getIpFromRequest(request));
|
||||||
|
return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Recover step 2 failed:', error);
|
||||||
|
return response.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
router,
|
||||||
|
};
|
@ -1,7 +1,28 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const ipaddr = require('ipaddr.js');
|
||||||
|
|
||||||
// Instantiate parser middleware here with application-level size limits
|
// Instantiate parser middleware here with application-level size limits
|
||||||
const jsonParser = express.json({ limit: '200mb' });
|
const jsonParser = express.json({ limit: '200mb' });
|
||||||
const urlencodedParser = express.urlencoded({ extended: true, limit: '200mb' });
|
const urlencodedParser = express.urlencoded({ extended: true, limit: '200mb' });
|
||||||
|
|
||||||
module.exports = { jsonParser, urlencodedParser };
|
/**
|
||||||
|
* Gets the IP address of the client from the request object.
|
||||||
|
* @param {import('express'.Request)} req Request object
|
||||||
|
* @returns {string} IP address of the client
|
||||||
|
*/
|
||||||
|
function getIpFromRequest(req) {
|
||||||
|
let clientIp = req.connection.remoteAddress;
|
||||||
|
let ip = ipaddr.parse(clientIp);
|
||||||
|
// Check if the IP address is IPv4-mapped IPv6 address
|
||||||
|
if (ip.kind() === 'ipv6' && ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) {
|
||||||
|
const ipv4 = ip.toIPv4Address().toString();
|
||||||
|
clientIp = ipv4;
|
||||||
|
} else {
|
||||||
|
clientIp = ip;
|
||||||
|
clientIp = clientIp.toString();
|
||||||
|
}
|
||||||
|
return clientIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { jsonParser, urlencodedParser, getIpFromRequest };
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const ipaddr = require('ipaddr.js');
|
|
||||||
const ipMatching = require('ip-matching');
|
const ipMatching = require('ip-matching');
|
||||||
|
|
||||||
|
const { getIpFromRequest } = require('../express-common');
|
||||||
const { color, getConfigValue } = require('../util');
|
const { color, getConfigValue } = require('../util');
|
||||||
|
|
||||||
const whitelistPath = path.join(process.cwd(), './whitelist.txt');
|
const whitelistPath = path.join(process.cwd(), './whitelist.txt');
|
||||||
@ -19,20 +19,6 @@ if (fs.existsSync(whitelistPath)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIpFromRequest(req) {
|
|
||||||
let clientIp = req.connection.remoteAddress;
|
|
||||||
let ip = ipaddr.parse(clientIp);
|
|
||||||
// Check if the IP address is IPv4-mapped IPv6 address
|
|
||||||
if (ip.kind() === 'ipv6' && ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) {
|
|
||||||
const ipv4 = ip.toIPv4Address().toString();
|
|
||||||
clientIp = ipv4;
|
|
||||||
} else {
|
|
||||||
clientIp = ip;
|
|
||||||
clientIp = clientIp.toString();
|
|
||||||
}
|
|
||||||
return clientIp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a middleware function that checks if the client IP is in the whitelist.
|
* Returns a middleware function that checks if the client IP is in the whitelist.
|
||||||
* @param {boolean} listen If listen mode is enabled via config or command line
|
* @param {boolean} listen If listen mode is enabled via config or command line
|
||||||
|
303
src/users.js
303
src/users.js
@ -7,21 +7,17 @@ const os = require('os');
|
|||||||
// Express and other dependencies
|
// Express and other dependencies
|
||||||
const storage = require('node-persist');
|
const storage = require('node-persist');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const uuid = require('uuid');
|
|
||||||
const mime = require('mime-types');
|
const mime = require('mime-types');
|
||||||
const slugify = require('slugify').default;
|
|
||||||
|
|
||||||
// Local imports
|
|
||||||
const { jsonParser } = require('./express-common');
|
|
||||||
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants');
|
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants');
|
||||||
const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util');
|
const { getConfigValue, color, delay, setConfigValue } = require('./util');
|
||||||
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
||||||
const { checkForNewContent } = require('./endpoints/content-manager');
|
|
||||||
|
|
||||||
const KEY_PREFIX = 'user:';
|
const KEY_PREFIX = 'user:';
|
||||||
const DATA_ROOT = getConfigValue('dataRoot', './data');
|
const DATA_ROOT = getConfigValue('dataRoot', './data');
|
||||||
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
|
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
|
||||||
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache for user directories.
|
* Cache for user directories.
|
||||||
* @type {Map<string, UserDirectoryList>}
|
* @type {Map<string, UserDirectoryList>}
|
||||||
@ -381,7 +377,7 @@ function getPasswordHash(password, salt) {
|
|||||||
*/
|
*/
|
||||||
function getCsrfSecret(request) {
|
function getCsrfSecret(request) {
|
||||||
if (!request || !request.user) {
|
if (!request || !request.user) {
|
||||||
throw new Error('Request object is required to get the CSRF secret.');
|
return ANON_CSRF_SECRET;
|
||||||
}
|
}
|
||||||
|
|
||||||
let csrfSecret = readSecret(request.user.directories, STORAGE_KEYS.csrfSecret);
|
let csrfSecret = readSecret(request.user.directories, STORAGE_KEYS.csrfSecret);
|
||||||
@ -600,301 +596,24 @@ router.use('/user/images/*', createRouteHandler(req => req.user.directories.user
|
|||||||
router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
|
router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
|
||||||
router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions));
|
router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions));
|
||||||
|
|
||||||
const publicEndpoints = express.Router();
|
|
||||||
|
|
||||||
publicEndpoints.get('/list', async (_request, response) => {
|
|
||||||
/** @type {User[]} */
|
|
||||||
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
|
||||||
const viewModels = users
|
|
||||||
.filter(x => x.enabled)
|
|
||||||
.sort((x, y) => x.created - y.created)
|
|
||||||
.map(user => ({
|
|
||||||
handle: user.handle,
|
|
||||||
name: user.name,
|
|
||||||
avatar: getUserAvatar(user.handle),
|
|
||||||
password: !!user.password,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return response.json(viewModels);
|
|
||||||
});
|
|
||||||
|
|
||||||
publicEndpoints.post('/login', jsonParser, async (request, response) => {
|
|
||||||
if (!request.body.handle) {
|
|
||||||
console.log('Login failed: Missing required fields');
|
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {User} */
|
|
||||||
const user = await storage.getItem(toKey(request.body.handle));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('Login failed: User not found');
|
|
||||||
return response.status(401).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.enabled) {
|
|
||||||
console.log('Login failed: User is disabled');
|
|
||||||
return response.status(403).json({ error: 'User is disabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) {
|
|
||||||
console.log('Login failed: Incorrect password');
|
|
||||||
return response.status(401).json({ error: 'Incorrect password' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!request.session) {
|
|
||||||
console.error('Session not available');
|
|
||||||
return response.sendStatus(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
request.session.handle = user.handle;
|
|
||||||
console.log('Login successful:', user.handle, request.session);
|
|
||||||
return response.json({ handle: user.handle });
|
|
||||||
});
|
|
||||||
|
|
||||||
publicEndpoints.post('/recover-step1', jsonParser, async (request, response) => {
|
|
||||||
if (!request.body.handle) {
|
|
||||||
console.log('Recover step 1 failed: Missing required fields');
|
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {User} */
|
|
||||||
const user = await storage.getItem(toKey(request.body.handle));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('Recover step 1 failed: User not found');
|
|
||||||
return response.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.enabled) {
|
|
||||||
console.log('Recover step 1 failed: User is disabled');
|
|
||||||
return response.status(403).json({ error: 'User is disabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mfaCode = String(crypto.randomInt(1000, 9999));
|
|
||||||
console.log();
|
|
||||||
console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
|
|
||||||
console.log();
|
|
||||||
MFA_CACHE.set(user.handle, mfaCode);
|
|
||||||
return response.sendStatus(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
publicEndpoints.post('/recover-step2', jsonParser, async (request, response) => {
|
|
||||||
if (!request.body.handle || !request.body.code) {
|
|
||||||
console.log('Recover step 2 failed: Missing required fields');
|
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {User} */
|
|
||||||
const user = await storage.getItem(toKey(request.body.handle));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('Recover step 2 failed: User not found');
|
|
||||||
return response.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.enabled) {
|
|
||||||
console.log('Recover step 2 failed: User is disabled');
|
|
||||||
return response.status(403).json({ error: 'User is disabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mfaCode = MFA_CACHE.get(user.handle);
|
|
||||||
|
|
||||||
if (request.body.code !== mfaCode) {
|
|
||||||
console.log('Recover step 2 failed: Incorrect code');
|
|
||||||
return response.status(401).json({ error: 'Incorrect code' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.body.newPassword) {
|
|
||||||
const salt = getPasswordSalt();
|
|
||||||
user.password = getPasswordHash(request.body.newPassword, salt);
|
|
||||||
user.salt = salt;
|
|
||||||
await storage.setItem(toKey(user.handle), user);
|
|
||||||
} else {
|
|
||||||
user.password = '';
|
|
||||||
user.salt = '';
|
|
||||||
await storage.setItem(toKey(user.handle), user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.sendStatus(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
const authenticatedEndpoints = express.Router();
|
|
||||||
|
|
||||||
authenticatedEndpoints.post('/logout', async (request, response) => {
|
|
||||||
if (!request.session) {
|
|
||||||
console.error('Session not available');
|
|
||||||
return response.sendStatus(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
request.session.handle = null;
|
|
||||||
return response.sendStatus(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
authenticatedEndpoints.get('/me', async (request, response) => {
|
|
||||||
if (!request.user) {
|
|
||||||
return response.sendStatus(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = request.user.profile;
|
|
||||||
const viewModel = {
|
|
||||||
handle: user.handle,
|
|
||||||
name: user.name,
|
|
||||||
avatar: getUserAvatar(user.handle),
|
|
||||||
admin: user.admin,
|
|
||||||
password: !!user.password,
|
|
||||||
};
|
|
||||||
|
|
||||||
return response.json(viewModel);
|
|
||||||
});
|
|
||||||
|
|
||||||
authenticatedEndpoints.post('/change-password', jsonParser, async (request, response) => {
|
|
||||||
if (!request.body.handle || !request.body.oldPassword || !request.body.newPassword) {
|
|
||||||
console.log('Change password failed: Missing required fields');
|
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {User} */
|
|
||||||
const user = await storage.getItem(toKey(request.body.handle));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('Change password failed: User not found');
|
|
||||||
return response.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.enabled) {
|
|
||||||
console.log('Change password failed: User is disabled');
|
|
||||||
return response.status(403).json({ error: 'User is disabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
|
|
||||||
console.log('Change password failed: Incorrect password');
|
|
||||||
return response.status(401).json({ error: 'Incorrect password' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const salt = getPasswordSalt();
|
|
||||||
user.password = getPasswordHash(request.body.newPassword, salt);
|
|
||||||
user.salt = salt;
|
|
||||||
await storage.setItem(toKey(request.body.handle), user);
|
|
||||||
return response.sendStatus(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminEndpoints = express.Router();
|
|
||||||
|
|
||||||
adminEndpoints.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => {
|
|
||||||
/** @type {User[]} */
|
|
||||||
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
|
||||||
|
|
||||||
const viewModels = users
|
|
||||||
.sort((x, y) => x.created - y.created)
|
|
||||||
.map(user => ({
|
|
||||||
handle: user.handle,
|
|
||||||
name: user.name,
|
|
||||||
avatar: getUserAvatar(user.handle),
|
|
||||||
admin: user.admin,
|
|
||||||
enabled: user.enabled,
|
|
||||||
created: user.created,
|
|
||||||
password: !!user.password,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return response.json(viewModels);
|
|
||||||
});
|
|
||||||
|
|
||||||
adminEndpoints.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
|
||||||
if (!request.body.handle) {
|
|
||||||
console.log('Disable user failed: Missing required fields');
|
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.body.handle === request.user.profile.handle) {
|
|
||||||
console.log('Disable user failed: Cannot disable yourself');
|
|
||||||
return response.status(400).json({ error: 'Cannot disable yourself' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {User} */
|
|
||||||
const user = await storage.getItem(toKey(request.body.handle));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('Disable user failed: User not found');
|
|
||||||
return response.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
user.enabled = false;
|
|
||||||
await storage.setItem(toKey(request.body.handle), user);
|
|
||||||
return response.sendStatus(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
adminEndpoints.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
|
||||||
if (!request.body.handle) {
|
|
||||||
console.log('Enable user failed: Missing required fields');
|
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {User} */
|
|
||||||
const user = await storage.getItem(toKey(request.body.handle));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('Enable user failed: User not found');
|
|
||||||
return response.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
user.enabled = true;
|
|
||||||
await storage.setItem(toKey(request.body.handle), user);
|
|
||||||
return response.sendStatus(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
adminEndpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
|
|
||||||
if (!request.body.handle || !request.body.name) {
|
|
||||||
console.log('Create user failed: Missing required fields');
|
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const handles = await getAllUserHandles();
|
|
||||||
const handle = slugify(request.body.handle, { lower: true, trim: true });
|
|
||||||
|
|
||||||
if (handles.some(x => x === handle)) {
|
|
||||||
console.log('Create user failed: User with that handle already exists');
|
|
||||||
return response.status(409).json({ error: 'User already exists' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const salt = getPasswordSalt();
|
|
||||||
const password = request.body.password ? getPasswordHash(request.body.password, salt) : '';
|
|
||||||
|
|
||||||
const newUser = {
|
|
||||||
uuid: uuid.v4(),
|
|
||||||
handle: handle,
|
|
||||||
name: request.body.name || 'Anonymous',
|
|
||||||
created: Date.now(),
|
|
||||||
password: password,
|
|
||||||
salt: salt,
|
|
||||||
admin: !!request.body.admin,
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
await storage.setItem(toKey(handle), newUser);
|
|
||||||
|
|
||||||
// Create user directories
|
|
||||||
console.log('Creating data directories for', newUser.handle);
|
|
||||||
const directories = await ensurePublicDirectoriesExist();
|
|
||||||
await checkForNewContent(directories);
|
|
||||||
return response.json({ handle: newUser.handle });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
KEY_PREFIX,
|
||||||
|
toKey,
|
||||||
initUserStorage,
|
initUserStorage,
|
||||||
ensurePublicDirectoriesExist,
|
ensurePublicDirectoriesExist,
|
||||||
getAllUserHandles,
|
getAllUserHandles,
|
||||||
getUserDirectories,
|
getUserDirectories,
|
||||||
setUserDataMiddleware,
|
setUserDataMiddleware,
|
||||||
requireLoginMiddleware,
|
requireLoginMiddleware,
|
||||||
|
requireAdminMiddleware,
|
||||||
migrateUserData,
|
migrateUserData,
|
||||||
|
getPasswordSalt,
|
||||||
|
getPasswordHash,
|
||||||
getCsrfSecret,
|
getCsrfSecret,
|
||||||
getCookieSecret,
|
getCookieSecret,
|
||||||
getCookieSessionName,
|
getCookieSessionName,
|
||||||
router,
|
getUserAvatar,
|
||||||
publicEndpoints,
|
|
||||||
authenticatedEndpoints,
|
|
||||||
adminEndpoints,
|
|
||||||
shouldRedirectToLogin,
|
shouldRedirectToLogin,
|
||||||
tryAutoLogin,
|
tryAutoLogin,
|
||||||
|
router,
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user